diff --git a/plugins/login/LICENSE b/plugins/login/LICENSE new file mode 100644 index 0000000..5704cd7 --- /dev/null +++ b/plugins/login/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 xfgryujk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/login/app.py b/plugins/login/app.py new file mode 100644 index 0000000..5e7fc2f --- /dev/null +++ b/plugins/login/app.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +import asyncio +import concurrent.futures +import http.cookies +import logging +import threading +import time +from typing import * + +import aiohttp +import webview + +import blcsdk +import config +import cookies_mgr +import listener + +logger = logging.getLogger('login.' + __name__) + +_app: Optional['App'] = None + + +def init(): + global _app + _app = App() + _app.init() + + +def get_app(): + return _app + + +class App: + def __init__(self): + self._is_standalone_mode = False + + self._logic_worker = LogicWorker() + + self._browser_window = webview.create_window( + title='登录B站账号', + width=1100, + height=700, + hidden=True, + ) + self._browser_window.events.closing += self._on_browser_closing + self._browser_window.events.loaded += self._on_browser_loaded + + def init(self): + self._logic_worker.init() + + @staticmethod + def run(): + logger.info('Running event loop') + webview.start(user_agent=config.USER_AGENT) + logger.info('Start to shut down') + + def start_shut_down(self): + self._browser_window.events.loaded -= self._on_browser_loaded + self._browser_window.events.closing -= self._on_browser_closing + self._browser_window.destroy() + + def shut_down(self): + self._logic_worker.start_shut_down() + self._logic_worker.join(10) + + @property + def is_standalone_mode(self): + """不作为blivechat插件运行,而是单独运行""" + return self._is_standalone_mode + + def set_standalone_mode(self): + self._is_standalone_mode = True + + def open_admin_ui(self): + self._browser_window.clear_cookies() + # 如果不换个URL,不会发loaded事件,会一直等待... + url = f'https://passport.bilibili.com/login?t={time.time()}' + self._browser_window.load_url(url) + self._browser_window.show() + + def _on_browser_closing(self): + if self.is_standalone_mode: + return True + + # 插件模式时随主程序一起结束,先不关闭窗口 + self._browser_window.hide() + self._browser_window.load_html('') + return False + + def _on_browser_loaded(self): + simple_cookies: List[http.cookies.SimpleCookie] = self._browser_window.get_cookies() + async def try_save(): + cookie_jar = aiohttp.CookieJar() + for simple_cookie in simple_cookies: + cookie_jar.update_cookies(simple_cookie) + + if not await cookies_mgr.validate_and_save_cookies(cookie_jar): + return + + if self.is_standalone_mode: + self.start_shut_down() + else: + self._browser_window.hide() + self._browser_window.load_html('') + + self._logic_worker.run_coro(try_save()) + + +class LogicWorker: + def __init__(self): + self._worker_thread = threading.Thread( + target=asyncio.run, args=(self._worker_thread_func(),), daemon=True + ) + self._thread_init_future = concurrent.futures.Future() + + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._shut_down_event: Optional[asyncio.Event] = None + + def init(self): + self._worker_thread.start() + self._thread_init_future.result(10) + + def start_shut_down(self): + if self._shut_down_event is not None: + self._loop.call_soon_threadsafe(self._shut_down_event.set) + + def join(self, timeout=None): + self._worker_thread.join(timeout) + return not self._worker_thread.is_alive() + + async def _worker_thread_func(self): + self._loop = asyncio.get_running_loop() + self._shut_down_event = asyncio.Event() + try: + try: + await self._init_in_worker_thread() + self._thread_init_future.set_result(None) + except BaseException as e: + self._thread_init_future.set_exception(e) + return + + await self._run() + finally: + await self._shut_down() + + @staticmethod + async def _init_in_worker_thread(): + try: + await blcsdk.init() + except blcsdk.InitError as e: + logger.info('SDK initializing failed: %s', e) + logger.info('Switch to standalone mode') + get_app().set_standalone_mode() + else: + await listener.init() + + cookies_mgr.init() + + async def _run(self): + logger.info('Running logic thread event loop') + await self._shut_down_event.wait() + logger.info('Logic thread start to shut down') + + @staticmethod + async def _shut_down(): + await cookies_mgr.shut_down() + + if not get_app().is_standalone_mode: + listener.shut_down() + await blcsdk.shut_down() + + # def call_soon(self, func, *args): + # self._loop.call_soon_threadsafe(func, *args) + + def run_coro(self, coro: Coroutine): + self._loop.call_soon_threadsafe(self._loop.create_task, coro) diff --git a/plugins/login/config.py b/plugins/login/config.py new file mode 100644 index 0000000..5509c17 --- /dev/null +++ b/plugins/login/config.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +import os + +BASE_PATH = os.path.dirname(os.path.realpath(__file__)) +LOG_PATH = os.path.join(BASE_PATH, 'log') + +USER_AGENT = ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36' +) diff --git a/plugins/login/cookies_mgr.py b/plugins/login/cookies_mgr.py new file mode 100644 index 0000000..77d8f3f --- /dev/null +++ b/plugins/login/cookies_mgr.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +import asyncio +import dataclasses +import logging +import os +import pickle +import sys +from typing import * + +import aiohttp +import yarl + +import app +import config + +logger = logging.getLogger('login.' + __name__) + +_BILIBILI_DOMAIN = 'bilibili.com' +_USER_INFO_URL = 'https://api.bilibili.com/x/web-interface/nav' + +if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + # 被PyInstaller打包了,用当前目录计算 + _BLC_COOKIE_JAR_PATH = os.path.realpath(os.path.join('..', '..', 'cookie_jar.pickle')) +else: + _BLC_COOKIE_JAR_PATH = os.path.realpath(os.path.join(config.BASE_PATH, '..', '..', 'data', 'cookie_jar.pickle')) + +_logic_loop: Optional[asyncio.AbstractEventLoop] = None +_http_session: Optional[aiohttp.ClientSession] = None + +# 用来防重 +_validating_sessdata_set: Set[str] = set() + + +def init(): + global _logic_loop, _http_session + _logic_loop = asyncio.get_running_loop() + _http_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=10), + ) + + _logic_loop.create_task(_validate_on_startup()) + + +async def shut_down(): + if _http_session is not None: + await _http_session.close() + + +async def _validate_on_startup(): + """如果未登录则打开登录窗口""" + # 等主线程初始化 + sleep_task = asyncio.create_task(asyncio.sleep(2)) + + app_ = app.get_app() + if app_.is_standalone_mode: + await sleep_task + app_.open_admin_ui() + return + + cookie_jar = _load_blc_cookies() + if cookie_jar is not None: + user_info = await _get_user_info_from_cookies(cookie_jar) + if user_info is not None: + logger.info('Found logged in user: uid=%d, name=%s', user_info.uid, user_info.name) + return + + logger.info('No user info. Please log in') + await sleep_task + app_.open_admin_ui() + + +def _load_blc_cookies(): + try: + cookie_jar = aiohttp.CookieJar() + cookie_jar.load(_BLC_COOKIE_JAR_PATH) + return cookie_jar + except (OSError, pickle.PickleError): + return None + + +def _save_blc_cookies(cookie_jar: aiohttp.CookieJar): + logger.info('Saving cookies') + + tmp_path = _BLC_COOKIE_JAR_PATH + '.tmp' + cookie_jar.save(tmp_path) + os.replace(tmp_path, _BLC_COOKIE_JAR_PATH) + + logger.info('Cookies saved. Please restart blivechat to take effect') + + +async def validate_and_save_cookies(cookie_jar: aiohttp.CookieJar): + sessdata = _get_sessdata(cookie_jar) + if sessdata is None or sessdata in _validating_sessdata_set: + return False + _validating_sessdata_set.add(sessdata) + + try: + user_info = await _get_user_info_from_cookies(cookie_jar) + if user_info is None: + return False + logger.info('Got user info: uid=%d, name=%s', user_info.uid, user_info.name) + + _save_blc_cookies(cookie_jar) + return True + except: + _validating_sessdata_set.discard(sessdata) + raise + + +def _get_sessdata(cookie_jar: aiohttp.CookieJar): + cookies = cookie_jar.filter_cookies(yarl.URL(_USER_INFO_URL)) + sessdata_cookie = cookies.get('SESSDATA', None) + if sessdata_cookie is None or sessdata_cookie.value == '': + return None + return sessdata_cookie.value + + +@dataclasses.dataclass +class BilibiliUserInfo: + uid: int + name: str + + +async def _get_user_info_from_cookies(cookie_jar: aiohttp.CookieJar): + if _get_sessdata(cookie_jar) is None: + logger.info('Failed to get user info: no SESSDATA') + return None + + try: + async with _http_session.get( + _USER_INFO_URL, + cookies=cookie_jar.filter_cookies(yarl.URL(_USER_INFO_URL)), + headers={'User-Agent': config.USER_AGENT}, + ) as res: + res.raise_for_status() + + data = await res.json() + if data['code'] != 0: + if data['code'] == -101: + logger.info('Failed to get user info: not logged in') + else: + logger.warning('Failed to get user info: code=%d, message=%s', data['code'], data['message']) + return None + + data = data['data'] + if not data['isLogin']: + logger.info('Failed to get user info: not logged in') + return None + + return BilibiliUserInfo( + uid=data['mid'], + name=data['uname'], + ) + except aiohttp.ClientError as e: + logger.warning('Failed to get user info: %s', e) + return None diff --git a/plugins/login/listener.py b/plugins/login/listener.py new file mode 100644 index 0000000..f75eeeb --- /dev/null +++ b/plugins/login/listener.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +import logging +from typing import * + +import app +import blcsdk +import blcsdk.models as sdk_models + +logger = logging.getLogger('login.' + __name__) + +_msg_handler: Optional['MsgHandler'] = None + + +async def init(): + global _msg_handler + _msg_handler = MsgHandler() + blcsdk.set_msg_handler(_msg_handler) + + +def shut_down(): + blcsdk.set_msg_handler(None) + + +class MsgHandler(blcsdk.BaseHandler): + def on_client_stopped(self, client: blcsdk.BlcPluginClient, exception: Optional[Exception]): + logger.info('blivechat disconnected') + app.get_app().start_shut_down() + + def _on_open_plugin_admin_ui( + self, client: blcsdk.BlcPluginClient, message: sdk_models.OpenPluginAdminUiMsg, extra: sdk_models.ExtraData + ): + app.get_app().open_admin_ui() diff --git a/plugins/login/log/.gitkeep b/plugins/login/log/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/login/login.spec b/plugins/login/login.spec new file mode 100644 index 0000000..0550738 --- /dev/null +++ b/plugins/login/login.spec @@ -0,0 +1,89 @@ +# -*- mode: python ; coding: utf-8 -*- +import typing +import subprocess +import sys + +import webview.__pyinstaller + +if typing.TYPE_CHECKING: + import os + + from PyInstaller.building.api import COLLECT, EXE, PYZ + from PyInstaller.building.build_main import Analysis + + SPECPATH = '' + DISTPATH = '' + + +# exe文件名、打包目录名 +NAME = 'login' +# 模块搜索路径 +PYTHONPATH = [ + os.path.join(SPECPATH, '..', '..'), # 为了找到blcsdk +] +# 数据 +DATAS = [ + ('plugin.json', '.'), + ('LICENSE', '.'), + ('log/.gitkeep', 'log'), +] + +block_cipher = None + + +a = Analysis( + ['main.py'], + pathex=PYTHONPATH, + binaries=[], + datas=DATAS, + hiddenimports=[], + hookspath=[ + os.path.dirname(webview.__pyinstaller.__file__), # pyinstaller-hooks-contrib的版本太老了,少打包了js文件... + ], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ( + a.pure, + a.zipped_data, + cipher=block_cipher, +) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name=NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name=NAME, +) + +# 打包 +print('Start to package') +subprocess.run([sys.executable, '-m', 'zipfile', '-c', NAME + '.zip', NAME], cwd=DISTPATH) diff --git a/plugins/login/main.py b/plugins/login/main.py new file mode 100644 index 0000000..3fcf845 --- /dev/null +++ b/plugins/login/main.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import logging.handlers +import os +import signal +import sys + +import app +import config + +logger = logging.getLogger('login') + + +def main(): + try: + init() + run() + finally: + shut_down() + return 0 + + +def init(): + init_signal_handlers() + + init_logging() + + app.init() + + +def init_signal_handlers(): + for signum in (signal.SIGINT, signal.SIGTERM): + signal.signal(signum, start_shut_down) + + +def start_shut_down(*_args): + app_ = app.get_app() + if app_ is not None: + app_.start_shut_down() + + +def init_logging(): + filename = os.path.join(config.LOG_PATH, 'login.log') + stream_handler = logging.StreamHandler() + file_handler = logging.handlers.TimedRotatingFileHandler( + filename, encoding='utf-8', when='midnight', backupCount=7, delay=True + ) + logging.basicConfig( + format='{asctime} {levelname} [{name}]: {message}', + style='{', + level=logging.INFO, + # level=logging.DEBUG, + handlers=[stream_handler, file_handler], + ) + + +def run(): + app.get_app().run() + + +def shut_down(): + app_ = app.get_app() + if app_ is not None: + app_.shut_down() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/plugins/login/plugin.json b/plugins/login/plugin.json new file mode 100644 index 0000000..c9536ae --- /dev/null +++ b/plugins/login/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "登录B站账号", + "version": "1.0.0", + "author": "xfgryujk", + "description": "启动后会弹出登录账号的窗口,登录后重启blivechat即是登录状态。登录后即使通过房间ID连接,也可以获取昵称。主要用于连不上公共服务器时的应急措施,平时还是尽量通过身份码连接。需要 1. blivechat版本1.10以上;2. 开启“通过服务器转发消息”选项。**建议用小号登录,因为如果连接太多房间,有被B站封号的风险。**cookie完全存储在本地,不会上传到任何服务器。发布地址:https://github.com/xfgryujk/blivechat/discussions/276", + "run": "login.exe", + "enabled": true +} diff --git a/plugins/login/requirements.txt b/plugins/login/requirements.txt new file mode 100644 index 0000000..51ef4c0 --- /dev/null +++ b/plugins/login/requirements.txt @@ -0,0 +1,3 @@ +aiohttp==3.9.5 +pywebview==6.1 +yarl==1.9.11