diff --git a/.env.example b/.env.example index cb6be9f..cf04f32 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,7 @@ CAMPUX_TOKEN="campux" CAMPUX_REDIS_ADDR="localhost:6379" CAMPUX_REDIS_PASSWORD="" CAMPUX_REDIS_PUBLISH_POST_STREAM="campux_publish_post" -CAMPUX_HELP_MESSAGE="填写未匹配指令时的帮助信息" \ No newline at end of file +CAMPUX_HELP_MESSAGE="填写未匹配指令时的帮助信息" + +CAMPUX_QQ_BOT_UIN=12345678 +CAMPUX_QQ_ADMIN_UIN=12345678 \ No newline at end of file diff --git a/.gitignore b/.gitignore index d044411..85a2754 100644 --- a/.gitignore +++ b/.gitignore @@ -158,4 +158,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -venv \ No newline at end of file +venv +cache.json \ No newline at end of file diff --git a/campux/__init__.py b/campux/__init__.py index 42e1298..e69de29 100644 --- a/campux/__init__.py +++ b/campux/__init__.py @@ -1,2 +0,0 @@ -from .imbot import nbmod -from . import api \ No newline at end of file diff --git a/campux/common/cache.py b/campux/common/cache.py new file mode 100644 index 0000000..5bebaf9 --- /dev/null +++ b/campux/common/cache.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import os +import sys +import json + + +class CacheManager: + + data: dict + + file: str + + def __init__(self, file: str="cache.json"): + self.data = {} + self.file = file + + if not os.path.exists(file): + with open(file, "w", encoding="utf-8") as f: + json.dump({}, f) + + def load(self): + with open(self.file, "r", encoding="utf-8") as f: + self.data = json.load(f) + + def save(self): + with open(self.file, "w", encoding="utf-8") as f: + json.dump(self.data, f) diff --git a/campux/core/app.py b/campux/core/app.py index 5aec6aa..fd0195d 100644 --- a/campux/core/app.py +++ b/campux/core/app.py @@ -1,16 +1,29 @@ from __future__ import annotations +import asyncio +import threading + +import nonebot, nonebot.config +from nonebot.adapters.onebot.v11 import Adapter as OnebotAdapter # 避免重复命名 + from ..api import api from ..mq import redis from ..social import mgr as social_mgr from ..imbot import mgr as imbot_mgr +from ..common import cache as cache_mgr class Application: + + cache: cache_mgr.CacheManager @property def cpx_api(self) -> api.CampuxAPI: return api.campux_api + + @property + def config(self) -> nonebot.config.Config: + return nonebot.get_driver().config mq: redis.RedisStreamMQ @@ -18,10 +31,34 @@ def cpx_api(self) -> api.CampuxAPI: imbot: imbot_mgr.IMBotManager + async def run(self): + + def nonebot_thread(): + nonebot.run() + + threading.Thread(target=nonebot_thread).start() + + # while True: + # await asyncio.sleep(5) + +async def create_app() -> Application: + + # 注册适配器 + driver = nonebot.get_driver() + driver.register_adapter(OnebotAdapter) + + # 在这里加载插件 + nonebot.load_plugin("campux.imbot.nbmod") # 本地插件 + + # 缓存管理器 + cache = cache_mgr.CacheManager() + cache.load() -def create_app() -> Application: ap = Application() + ap.cache = cache + ap.mq = redis.RedisStreamMQ(ap) ap.social = social_mgr.SocialPlatformManager(ap) + await ap.social.initialize() ap.imbot = imbot_mgr.IMBotManager(ap) - return ap \ No newline at end of file + return ap diff --git a/campux/imbot/mgr.py b/campux/imbot/mgr.py index 683e98d..030b6ac 100644 --- a/campux/imbot/mgr.py +++ b/campux/imbot/mgr.py @@ -1,5 +1,7 @@ from __future__ import annotations +import nonebot + from ..core import app @@ -9,3 +11,15 @@ class IMBotManager: def __init__(self, ap: app.Application): self.ap = ap + + async def send_private_message( + self, + user_id: int, + message + ): + bot = nonebot.get_bot() + + await bot.send_private_msg( + user_id=user_id, + message=message + ) diff --git a/campux/imbot/nbmod.py b/campux/imbot/nbmod.py index 1aec7b9..3fd3546 100644 --- a/campux/imbot/nbmod.py +++ b/campux/imbot/nbmod.py @@ -7,11 +7,16 @@ from nonebot.adapters import Event from ..api import api +from ..core import app +ap: app.Application = None + sign_up = on_command("注册账号", rule=to_me(), priority=10, block=True) reset_password = on_command("重置密码", rule=to_me(), priority=10, block=True) +relogin_qzone = on_command("更新cookies", rule=to_me(), priority=10, block=True) + any_message = on_regex(r".*", rule=to_me(), priority=100, block=True) @sign_up.handle() @@ -37,6 +42,22 @@ async def reset_password_func(event: Event): traceback.print_exc() await reset_password.finish(str(e)) +@relogin_qzone.handle() +async def relogin_qzone_func(event: Event): + user_id = int(event.get_user_id()) + + if user_id != ap.config.campux_qq_admin_uin: + await relogin_qzone.finish("无权限") + return + + try: + await ap.social.platform_api.relogin() + except Exception as e: + if isinstance(e, nonebot.exception.FinishedException): + return + traceback.print_exc() + await relogin_qzone.finish(str(e)) + @any_message.handle() async def any_message_func(event: Event): await any_message.finish(nonebot.get_driver().config.campux_help_message) diff --git a/campux/social/mgr.py b/campux/social/mgr.py index b24c71a..3062d65 100644 --- a/campux/social/mgr.py +++ b/campux/social/mgr.py @@ -1,14 +1,44 @@ from __future__ import annotations +import asyncio + +import nonebot + from ..core import app +from .qzone import api as qzone_api class SocialPlatformManager: ap: app.Application + platform_api: qzone_api.QzoneAPI + + current_invalid_cookies: dict = None + def __init__(self, ap: app.Application): self.ap = ap + self.platform_api = qzone_api.QzoneAPI(ap) + + async def initialize(self): + async def schedule_loop(): + await asyncio.sleep(15) + while True: + asyncio.create_task(self.schedule_task()) + await asyncio.sleep(30) + + asyncio.create_task(schedule_loop()) + + async def schedule_task(self): + # 检查cookies是否失效 + if not await self.platform_api.token_valid() and self.platform_api.cookies != self.current_invalid_cookies: + + await self.ap.imbot.send_private_message( + self.ap.config.campux_qq_admin_uin, + "QQ空间cookies已失效,请发送 #更新cookies 命令进行重新登录。" + ) + + self.current_invalid_cookies = self.platform_api.cookies async def publish_post(self, post_id: int): pass diff --git a/campux/social/qzone/__init__.py b/campux/social/qzone/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/campux/social/qzone/api.py b/campux/social/qzone/api.py new file mode 100644 index 0000000..fad5b77 --- /dev/null +++ b/campux/social/qzone/api.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import asyncio +import json +import traceback + +from nonebot import logger +import nonebot.adapters.onebot.v11.message as message +import requests + +from ...core import app +from . import login + + +def generate_gtk(skey) -> str: + """生成gtk""" + hash_val = 5381 + for i in range(len(skey)): + hash_val += (hash_val << 5) + ord(skey[i]) + return str(hash_val & 2147483647) + +GET_VISITOR_AMOUNT_URL="https://h5.qzone.qq.com/proxy/domain/g.qzone.qq.com/cgi-bin/friendshow/cgi_get_visitor_more?uin={}&mask=7&g_tk={}&page=1&fupdate=1&clear=1" + +class QzoneAPI: + + ap: app.Application + + cookies: dict + + gtk2: str + + uin: int + + def __init__(self, ap: app.Application, cookies_dict: dict={}): + self.ap = ap + self.cookies = cookies_dict + self.gtk2 = '' + self.uin = 0 + + if 'qzone_cookies' in self.ap.cache.data and not cookies_dict and self.ap.cache.data['qzone_cookies']: + self.cookies = self.ap.cache.data['qzone_cookies'] + + if 'p_skey' in self.cookies: + self.gtk2 = generate_gtk(self.cookies['p_skey']) + + if 'uin' in self.cookies: + self.uin = int(self.cookies['uin'][1:]) + + async def token_valid(self) -> bool: + try: + today, total = await self.get_visitor_amount() + logger.info("检查cookies有效性结果:{}, {}".format(today, total)) + return True + except Exception as e: + traceback.print_exc() + return False + + async def relogin(self): + loginmgr = login.QzoneLogin() + + async def qrcode_callback(content: bytes): + asyncio.create_task(self.ap.imbot.send_private_message( + self.ap.config.campux_qq_admin_uin, + message=[ + message.MessageSegment.text("请使用QQ扫描以下二维码以登录QQ空间:"), + message.MessageSegment.image(content) + ] + )) + + self.cookies = await loginmgr.login_via_qrcode(qrcode_callback) + + if 'p_skey' in self.cookies: + self.gtk2 = generate_gtk(self.cookies['p_skey']) + + if 'uin' in self.cookies: + self.uin = int(self.cookies['uin'][1:]) + + asyncio.create_task(self.ap.imbot.send_private_message( + self.ap.config.campux_qq_admin_uin, + "登录流程完成。" + )) + + self.ap.cache.data['qzone_cookies'] = self.cookies + self.ap.cache.save() + + async def get_visitor_amount(self) -> tuple[int, int]: + """获取空间访客信息 + + Returns: + tuple[int, int]: 今日访客数, 总访客数 + """ + res = requests.get( + url=GET_VISITOR_AMOUNT_URL.format(self.uin, self.gtk2), + cookies=self.cookies, + timeout=10 + ) + json_text = res.text.replace("_Callback(", '')[:-3] + + try: + json_obj = json.loads(json_text) + visit_count = json_obj['data'] + return visit_count['todaycount'], visit_count['totalcount'] + except Exception as e: + raise e diff --git a/campux/social/qzone/login.py b/campux/social/qzone/login.py new file mode 100644 index 0000000..06a8251 --- /dev/null +++ b/campux/social/qzone/login.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import typing +import asyncio +import re + +import requests + +qrcode_url = "https://ssl.ptlogin2.qq.com/ptqrshow?appid=549000912&e=2&l=M&s=3&d=72&v=4&t=0.31232733520361844&daid=5&pt_3rd_aid=0" + +login_check_url = "https://xui.ptlogin2.qq.com/ssl/ptqrlogin?u1=https://qzs.qq.com/qzone/v5/loginsucc.html?para=izone&ptqrtoken={}&ptredirect=0&h=1&t=1&g=1&from_ui=1&ptlang=2052&action=0-0-1656992258324&js_ver=22070111&js_type=1&login_sig=&pt_uistyle=40&aid=549000912&daid=5&has_onekey=1&&o1vId=1e61428d61cb5015701ad73d5fb59f73" + +check_sig_url = "https://ptlogin2.qzone.qq.com/check_sig?pttype=1&uin={}&service=ptqrlogin&nodirect=1&ptsigx={}&s_url=https://qzs.qq.com/qzone/v5/loginsucc.html?para=izone&f_url=&ptlang=2052&ptredirect=100&aid=549000912&daid=5&j_later=0&low_login_hour=0®master=0&pt_login_type=3&pt_aid=0&pt_aaid=16&pt_light=0&pt_3rd_aid=0" + + +class QzoneLogin: + + def __init__(self): + pass + + def getptqrtoken(self, qrsig): + e = 0 + for i in range(1, len(qrsig) + 1): + e += (e << 5) + ord(qrsig[i - 1]) + return str(2147483647 & e) + + async def check_cookies(self, cookies: dict) -> bool: + return False + + async def login_via_qrcode( + self, + qrcode_callback: typing.Callable[[bytes], typing.Awaitable[None]], + max_timeout_times: int = 3, + ) -> dict: + for i in range(max_timeout_times): + # 图片URL + req = requests.get(qrcode_url) + + qrsig = '' + + set_cookie = req.headers['Set-Cookie'] + set_cookies_set = req.headers['Set-Cookie'].split(";") + for set_cookies in set_cookies_set: + if set_cookies.startswith("qrsig"): + qrsig = set_cookies.split("=")[1] + break + if qrsig == '': + raise Exception("qrsig is empty") + + # 获取ptqrtoken + ptqrtoken = self.getptqrtoken(qrsig) + + await qrcode_callback(req.content) + + # 检查是否登录成功 + while True: + await asyncio.sleep(2) + req = requests.get(login_check_url.format(ptqrtoken), cookies={"qrsig": qrsig}) + if req.text.find("二维码已失效") != -1: + break + if req.text.find("登录成功") != -1: + # 检出检查登录的响应头 + response_header_dict = req.headers + + # 检出url + url = eval(req.text.replace("ptuiCB", ""))[2] + + # 获取ptsigx + m = re.findall(r"ptsigx=[A-z \d]*&", url) + + ptsigx = m[0].replace("ptsigx=", "").replace("&", "") + + # 获取uin + m = re.findall(r"uin=[\d]*&", url) + uin = m[0].replace("uin=", "").replace("&", "") + + # 获取skey和p_skey + res = requests.get(check_sig_url.format(uin, ptsigx), cookies={"qrsig": qrsig}, + headers={'Cookie': response_header_dict['Set-Cookie']}) + + final_cookie = res.headers['Set-Cookie'] + + final_cookie_dict = {} + for set_cookie in final_cookie.split(";, "): + for cookie in set_cookie.split(";"): + spt = cookie.split("=") + if len(spt) == 2 and final_cookie_dict.get(spt[0]) is None: + final_cookie_dict[spt[0]] = spt[1] + + return final_cookie_dict + raise Exception("{}次尝试失败".format(max_timeout_times)) + + +if __name__ == '__main__': + login = QzoneLogin() + + async def qrcode_callback(qrcode: bytes): + with open("qrcode.png", "wb") as f: + f.write(qrcode) + + print(asyncio.run(login.login_via_qrcode(qrcode_callback))) \ No newline at end of file diff --git a/main.py b/main.py index 45d2b5f..b080d1f 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,23 @@ -import nonebot -from nonebot.adapters.onebot.v11 import Adapter as OnebotAdapter # 避免重复命名 +import asyncio +import nonebot # 初始化 NoneBot nonebot.init() -# 注册适配器 -driver = nonebot.get_driver() -driver.register_adapter(OnebotAdapter) +from campux.core import app + + +async def main(): + ap = await app.create_app() + + from campux.imbot import nbmod + nbmod.ap = ap + + await ap.run() -# 在这里加载插件 -nonebot.load_plugin("campux.imbot.nbmod") # 本地插件 if __name__ == "__main__": - nonebot.run() + + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) + loop.run_forever() diff --git a/requirements.txt b/requirements.txt index 4596d4c..e30aba6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ nonebot-adapter-console nonebot-adapter-onebot aiohttp -redis \ No newline at end of file +redis +requests