From 71ef1e2d3096b14180ccda7ed64f62fdb2d1c44f Mon Sep 17 00:00:00 2001 From: Shu-Ying <1754798088@qq.com> Date: Fri, 23 Oct 2026 00:41:51 +0800 Subject: [PATCH] =?UTF-8?q?fixed=20#=20=E7=BB=9F=E4=B8=80=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=E8=8E=B7=E5=8F=96=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- integrations/astrbot/README.md | 5 + integrations/astrbot/avatars.py | 94 +++++++++++++++++ integrations/astrbot/main.py | 10 +- integrations/astrbot/requirements.txt | 1 + scripts/build_astrbot.py | 11 +- scripts/verify_astrbot.py | 6 +- tests/test_astrbot_avatars.py | 144 ++++++++++++++++++++++++++ tests/test_astrbot_package.py | 31 +++++- 8 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 integrations/astrbot/avatars.py create mode 100644 tests/test_astrbot_avatars.py diff --git a/integrations/astrbot/README.md b/integrations/astrbot/README.md index b6e9ae2..b3abc44 100644 --- a/integrations/astrbot/README.md +++ b/integrations/astrbot/README.md @@ -33,6 +33,11 @@ Windows双击主仓库根目录的`build_astrbot.cmd`,成功后控制台打印 真寻账号。缺少稳定消息ID时拒绝写操作。字体不可用时尽可能退回文字。 本地模拟和ZIP校验不等于真实平台发图成功,需要部署者实际验证。 +QQ/OneBot(aiocqhttp)图片查询会读取发送者的腾讯头像,用于我的农场、 +农场详述和地块编号。头像请求最多等待2秒、限制2MiB,成功缓存5分钟, +失败缓存30秒,最多缓存16项;失败时仍使用默认头像。不记录头像URL或 +QQ号。其他平台暂用默认头像,不将其用户ID当作QQ号查询。 + 官方接口参考: [插件入口](https://docs.astrbot.app/en/dev/star/guides/simple.html)、 [插件配置](https://docs.astrbot.app/en/dev/star/guides/plugin-config.html)。 diff --git a/integrations/astrbot/avatars.py b/integrations/astrbot/avatars.py new file mode 100644 index 0000000..b3a5b8c --- /dev/null +++ b/integrations/astrbot/avatars.py @@ -0,0 +1,94 @@ +"""QQ/OneBot头像读取;固定来源、有界缓存和可关闭的异步请求。""" + +import asyncio +from collections import OrderedDict +import logging +import re +from time import monotonic + +import aiohttp + + +logger = logging.getLogger(__name__) +MAX_BYTES = 2 * 1024 * 1024 +MAX_ENTRIES = 16 +FETCH_TIMEOUT = 2 + + +async def download_avatar(user_id): + """只访问固定腾讯HTTPS端点,不跟随重定向或读取用户提供的URL。""" + if not re.fullmatch(r'[0-9]{1,20}', user_id): + return None + url = f'https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=100' + timeout = aiohttp.ClientTimeout(total=FETCH_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, allow_redirects=False) as response: + response.raise_for_status() + if response.status != 200: + return None + if (response.content_length is not None + and response.content_length > MAX_BYTES): + return None + content = bytearray() + async for chunk in response.content.iter_chunked(65536): + if len(content) + len(chunk) > MAX_BYTES: + return None + content.extend(chunk) + return bytes(content) or None + + +class AvatarCache: + """单事件循环使用;缓存最多16项,同一QQ的并发请求共享下载。""" + + def __init__(self): + self._cache = OrderedDict() + self._pending = {} + self._closed = False + + async def get(self, platform, user_id): + user_id = str(user_id) + if (self._closed or platform != 'aiocqhttp' + or not re.fullmatch(r'[0-9]{1,20}', user_id)): + return None + cached = self._cache.get(user_id) + if cached and cached[0] > monotonic(): + self._cache.move_to_end(user_id) + return cached[1] + task = self._pending.get(user_id) + if task is None: + if len(self._pending) >= MAX_ENTRIES: + return None + task = asyncio.create_task(self._fetch(user_id)) + self._pending[user_id] = task + return await asyncio.shield(task) + + async def _fetch(self, user_id): + try: + content = await asyncio.wait_for( + download_avatar(user_id), timeout=FETCH_TIMEOUT, + ) + if (not isinstance(content, bytes) or not content + or len(content) > MAX_BYTES): + content = None + except Exception: + # 不记录QQ号、完整URL和异常中的潜在身份信息。 + logger.warning('farm astrbot avatar unavailable; using default') + content = None + finally: + self._pending.pop(user_id, None) + self._cache[user_id] = ( + monotonic() + (300 if content else 30), content, + ) + self._cache.move_to_end(user_id) + while len(self._cache) > MAX_ENTRIES: + self._cache.popitem(last=False) + return content + + async def close(self): + self._closed = True + tasks = list(self._pending.values()) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._cache.clear() diff --git a/integrations/astrbot/main.py b/integrations/astrbot/main.py index 5f28c44..57047b0 100644 --- a/integrations/astrbot/main.py +++ b/integrations/astrbot/main.py @@ -10,6 +10,7 @@ from astrbot.api.event import AstrMessageEvent, filter from astrbot.api.message_components import At, Image from astrbot.api.star import Context, Star, StarTools +from .avatars import AvatarCache from .runtime import COMMANDS, Runtime @@ -22,6 +23,7 @@ class FarmPlugin(Star): self.config = config self._lifecycle_lock = asyncio.Lock() self._closed = False + self._avatars = AvatarCache() configured = config.get('data_dir', '') data_dir = (Path(configured).expanduser() if configured else StarTools.get_data_dir('astrbot_plugin_farm')) @@ -66,12 +68,17 @@ class FarmPlugin(Star): yield event.plain_result('无法取得稳定的平台或用户标识,未执行。') return bot_id = json.dumps([str(platform_id), str(event.get_self_id())]) + avatar = None + if action in ('farm', 'details', 'plot_ids') and self.runtime.rendering: + avatar = await self._avatars.get( + event.get_platform_name(), event.get_sender_id(), + ) response = await self.runtime.execute( action, arguments, bot_id=bot_id, platform=event.get_platform_name(), user_id=event.get_sender_id(), message_id=getattr(event.message_obj, 'message_id', None), display_name=event.get_sender_name() or '农场玩家', - target_user_id=target, + target_user_id=target, avatar=avatar, ) if isinstance(response, bytes): yield event.chain_result([Image.fromBytes(response)]) @@ -83,5 +90,6 @@ class FarmPlugin(Star): if self._closed: return self._closed = True + await self._avatars.close() await self.runtime.close() logger.info('farm astrbot plugin terminated') diff --git a/integrations/astrbot/requirements.txt b/integrations/astrbot/requirements.txt index 1263d11..0bc85bb 100644 --- a/integrations/astrbot/requirements.txt +++ b/integrations/astrbot/requirements.txt @@ -1 +1,2 @@ Pillow>=10.4 +aiohttp>=3.9,<4 diff --git a/scripts/build_astrbot.py b/scripts/build_astrbot.py index 9339669..3d904ea 100644 --- a/scripts/build_astrbot.py +++ b/scripts/build_astrbot.py @@ -27,11 +27,16 @@ def build(root, output): '_version.py', 'version.txt', 'LICENSE', )} source = root / 'integrations/astrbot' - for name in ('main.py', '_conf_schema.json', 'requirements.txt'): + for name in ('main.py', 'avatars.py', '_conf_schema.json', + 'requirements.txt'): files[name] = (source / name).read_bytes() files['README.md'] = (source / 'README.md').read_bytes() - files['runtime.py'] = (root / 'integrations/zhenxun/runtime.py').read_bytes() - files['verify_package.py'] = (root / 'scripts/verify_astrbot.py').read_bytes() + files['runtime.py'] = ( + root / 'integrations/zhenxun/runtime.py' + ).read_bytes() + files['verify_package.py'] = ( + root / 'scripts/verify_astrbot.py' + ).read_bytes() # JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。 files['metadata.yaml'] = (json.dumps({ 'name': ROOT_NAME, 'display_name': '农场', diff --git a/scripts/verify_astrbot.py b/scripts/verify_astrbot.py index ece5623..f09ad1c 100644 --- a/scripts/verify_astrbot.py +++ b/scripts/verify_astrbot.py @@ -29,13 +29,15 @@ def verify_files(files): or manifest.get('plugin') != ROOT_NAME): raise ValueError('Invalid package identity') expected = manifest.get('files') - if not isinstance(expected, dict) or set(files) != set(expected) | {MANIFEST}: + if (not isinstance(expected, dict) + or set(files) != set(expected) | {MANIFEST}): raise ValueError('Package files differ from manifest') for name, digest in expected.items(): safe_path(name) if hashlib.sha256(files[name]).hexdigest() != digest: raise ValueError('Package checksum mismatch') - required = {'main.py', 'metadata.yaml', '_conf_schema.json', 'runtime.py', + required = {'main.py', 'avatars.py', 'metadata.yaml', '_conf_schema.json', + 'runtime.py', 'requirements.txt', 'LICENSE', 'version.txt', '_version.py', '_vendor/farm_core/__init__.py', '_vendor/farm_render/data/assets.zip'} diff --git a/tests/test_astrbot_avatars.py b/tests/test_astrbot_avatars.py new file mode 100644 index 0000000..8559611 --- /dev/null +++ b/tests/test_astrbot_avatars.py @@ -0,0 +1,144 @@ +import asyncio +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import AsyncMock, patch + + +ROOT = Path(__file__).resolve().parents[1] + + +class AvatarTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + spec = importlib.util.spec_from_file_location( + 'farm_astrbot_avatars', + ROOT / 'integrations/astrbot/avatars.py', + ) + self.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.module) + self.cache = self.module.AvatarCache() + + async def asyncTearDown(self): + await self.cache.close() + + async def test_avatar_success_cache_and_concurrent_requests(self): + async def download(user_id): + await asyncio.sleep(0) + return b'avatar' + + with patch.object(self.module, 'download_avatar', + side_effect=download) as fetch: + results = await asyncio.gather(*( + self.cache.get('aiocqhttp', '123456') for _ in range(8) + )) + self.assertEqual(results, [b'avatar'] * 8) + self.assertEqual( + await self.cache.get('aiocqhttp', '123456'), b'avatar', + ) + fetch.assert_awaited_once_with('123456') + self.assertEqual( + await self.cache.get('aiocqhttp', '654321'), b'avatar', + ) + self.assertEqual(fetch.await_count, 2) + + async def test_failure_negative_cache_and_expiry(self): + with patch.object(self.module, 'download_avatar', + new=AsyncMock(side_effect=OSError('offline'))) as f: + with patch.object(self.module, 'monotonic', return_value=0): + self.assertIsNone(await self.cache.get('aiocqhttp', '12345')) + with patch.object(self.module, 'monotonic', return_value=20): + self.assertIsNone(await self.cache.get('aiocqhttp', '12345')) + self.assertEqual(f.await_count, 1) + with patch.object(self.module, 'monotonic', return_value=31): + self.assertIsNone(await self.cache.get('aiocqhttp', '12345')) + self.assertEqual(f.await_count, 2) + + async def test_unknown_platform_invalid_id_and_closed_cache(self): + with patch.object(self.module, 'download_avatar') as fetch: + for platform, user in ( + ('telegram', '12345'), ('qq_official', '12345'), + ('aiocqhttp', '../secret'), ('aiocqhttp', '12345'), + ('aiocqhttp', ''), ('aiocqhttp', '1' * 21), + ): + self.assertIsNone(await self.cache.get(platform, user)) + await self.cache.close() + self.assertIsNone(await self.cache.get('aiocqhttp', '12345')) + fetch.assert_not_called() + + async def test_timeout_size_and_cache_bound(self): + async def slow(user_id): + await asyncio.sleep(10) + + with patch.object(self.module, 'FETCH_TIMEOUT', 0.01): + with patch.object(self.module, 'download_avatar', side_effect=slow): + self.assertIsNone(await self.cache.get('aiocqhttp', '12345')) + with patch.object(self.module, 'download_avatar', new=AsyncMock( + return_value=b'x' * (self.module.MAX_BYTES + 1))): + self.assertIsNone(await self.cache.get('aiocqhttp', '54321')) + with patch.object(self.module, 'download_avatar', + new=AsyncMock(return_value=b'avatar')): + for index in range(30): + await self.cache.get('aiocqhttp', str(100000 + index)) + self.assertLessEqual(len(self.cache._cache), 16) + + async def test_close_and_independent_caller_cancellation(self): + started, finish = asyncio.Event(), asyncio.Event() + + async def download(user_id): + started.set() + await finish.wait() + return b'avatar' + + with patch.object(self.module, 'download_avatar', side_effect=download): + first = asyncio.create_task(self.cache.get('aiocqhttp', '12345')) + await started.wait() + second = asyncio.create_task(self.cache.get('aiocqhttp', '12345')) + await asyncio.sleep(0) + first.cancel() + with self.assertRaises(asyncio.CancelledError): + await first + finish.set() + self.assertEqual(await second, b'avatar') + started.clear() + finish.clear() + third = asyncio.create_task(self.cache.get('aiocqhttp', '54321')) + await started.wait() + await self.cache.close() + with self.assertRaises(asyncio.CancelledError): + await third + self.assertFalse(self.cache._pending) + self.assertFalse(self.cache._cache) + + async def test_downloader_stream_limit_and_no_redirect(self): + class Body: + async def iter_chunked(self, size): + yield b'a' * (self_limit + 1) + + self_limit = self.module.MAX_BYTES + + class Response: + status = 200 + content_length = None + content = Body() + + def raise_for_status(self): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + class Session(Response): + def get(self, url, **kwargs): + self_url.append((url, kwargs)) + return Response() + + self_url = [] + with patch.object(self.module.aiohttp, 'ClientSession', + return_value=Session()): + self.assertIsNone(await self.module.download_avatar('12345')) + self.assertEqual(self_url[0][0], + 'https://q1.qlogo.cn/g?b=qq&nk=12345&s=100') + self.assertFalse(self_url[0][1]['allow_redirects']) diff --git a/tests/test_astrbot_package.py b/tests/test_astrbot_package.py index 8a48b3e..06b2696 100644 --- a/tests/test_astrbot_package.py +++ b/tests/test_astrbot_package.py @@ -7,7 +7,7 @@ import sys import tempfile from types import ModuleType, SimpleNamespace import unittest -from unittest.mock import patch +from unittest.mock import AsyncMock, patch ROOT = Path(__file__).resolve().parents[1] @@ -115,7 +115,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase): return 'aiocqhttp' def get_sender_id(self): - return 'player' + return '123456' def get_sender_name(self): return '离线测试' @@ -139,14 +139,37 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase): again = await call('开通农场') self.assertIsInstance(again, str) self.assertIn('开通农场', await call('我的种子', 'bot-b')) - result = await call('我的农场 高清') from io import BytesIO from PIL import Image + avatar_buffer = BytesIO() + Image.new('RGB', (64, 64), '#ee2222').save( + avatar_buffer, format='PNG', + ) + avatar_bytes = avatar_buffer.getvalue() + with patch.object(plugin._avatars, 'get', new=AsyncMock( + return_value=avatar_bytes)) as avatar_get: + with patch.object(plugin.runtime, 'execute', + wraps=plugin.runtime.execute) as execute: + result = await call('我的农场 高清') + self.assertEqual(execute.call_args.kwargs['avatar'], + avatar_bytes) + await call('农场详述') + await call('地块编号') + await call('我的种子 文字') + self.assertIsNone(execute.call_args.kwargs['avatar']) + self.assertEqual(avatar_get.await_count, 3) + avatar_get.assert_awaited_with('aiocqhttp', '123456') with Image.open(BytesIO(result[0])) as image: self.assertEqual(image.width, 1536) + red, green, blue = image.getpixel((70, 70))[:3] + self.assertGreater(red, 180) + self.assertLess(green, 90) + self.assertLess(blue, 90) self.assertIn('仅我的农场', await call('我的种子 高清')) self.assertIn('暂无种子', await call('我的种子 文字')) - self.assertTrue((self.root / 'plugin-data/farm.sqlite3').is_file()) + self.assertTrue( + (self.root / 'plugin-data/farm.sqlite3').is_file(), + ) await plugin.terminate() self.assertFalse(plugin.runtime.started) finally: