fix(qqbot): add INTERACTION intent and expose video/file cached paths

1. Add INTERACTION intent bit (1<<26) to _send_identify, fixing approval
   button clicks not being received (INTERACTION_CREATE events were never
   dispatched by the gateway)
2. Include local cached path in video/file attachment descriptions so the
   LLM can reference files for re-sending to users
3. Add unit tests (TestIdentifyIntents, TestProcessAttachmentsPathExposure)
This commit is contained in:
walli 2026-05-18 17:25:47 +08:00 committed by Teknium
parent 66d81f9e14
commit bbd77d165c
2 changed files with 212 additions and 5 deletions

View file

@ -705,9 +705,8 @@ class QQAdapter(BasePlatformAdapter):
"token": f"QQBot {token}",
"intents": (1 << 25)
| (1 << 30)
| (
1 << 12
), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE
| (1 << 12)
| (1 << 26), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE + INTERACTION
"shard": [0, 1],
"properties": {
"$os": "macOS",
@ -1620,11 +1619,15 @@ class QQAdapter(BasePlatformAdapter):
except Exception as exc:
logger.debug("[%s] Failed to cache image: %s", self._log_tag, exc)
else:
# Other attachments (video, file, etc.): record as text.
# Other attachments (video, file, etc.): download and record with path.
try:
cached_path = await self._download_and_cache(url, ct)
if cached_path:
other_attachments.append(f"[Attachment: {filename or ct}]")
name = filename or ct
if ct.startswith("video/"):
other_attachments.append(f"[video: {name} ({cached_path})]")
else:
other_attachments.append(f"[file: {name} ({cached_path})]")
except Exception as exc:
logger.debug("[%s] Failed to cache attachment: %s", self._log_tag, exc)

View file

@ -1810,3 +1810,207 @@ class TestSendUpdatePrompt:
adapter.send_with_keyboard = fake_swk # type: ignore[assignment]
await adapter.send_update_prompt(chat_id="u", prompt="ok?")
# ---------------------------------------------------------------------------
# _send_identify includes INTERACTION intent
# ---------------------------------------------------------------------------
class TestIdentifyIntents:
"""Verify the WebSocket identify payload includes the INTERACTION intent bit."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
@pytest.mark.asyncio
async def test_intents_include_interaction_bit(self):
adapter = self._make_adapter()
# Mock token retrieval and WebSocket
adapter._access_token = "fake_token"
adapter._token_expires_at = 9999999999.0
sent_payloads = []
class FakeWS:
closed = False
async def send_json(self, payload):
sent_payloads.append(payload)
adapter._ws = FakeWS()
await adapter._send_identify()
assert len(sent_payloads) == 1
intents = sent_payloads[0]["d"]["intents"]
# Verify all expected intent bits are present
assert intents & (1 << 25), "GROUP_MESSAGES (1<<25) missing"
assert intents & (1 << 30), "GUILD_AT_MESSAGE (1<<30) missing"
assert intents & (1 << 12), "DIRECT_MESSAGES (1<<12) missing"
assert intents & (1 << 26), "INTERACTION (1<<26) missing"
# ---------------------------------------------------------------------------
# _process_attachments: video/file path exposure
# ---------------------------------------------------------------------------
class TestProcessAttachmentsPathExposure:
"""Verify that video and file attachments include the cached local path."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
@pytest.mark.asyncio
async def test_video_attachment_includes_path(self):
adapter = self._make_adapter()
# Mock _download_and_cache to return a known path
async def fake_download(url, ct):
return "/tmp/cache/video_abc123.mp4"
adapter._download_and_cache = fake_download # type: ignore[assignment]
attachments = [
{
"content_type": "video/mp4",
"url": "https://multimedia.nt.qq.com.cn/download/video123",
"filename": "my_video.mp4",
}
]
result = await adapter._process_attachments(attachments)
assert result["image_urls"] == []
assert result["voice_transcripts"] == []
info = result["attachment_info"]
assert "[video:" in info
assert "my_video.mp4" in info
assert "/tmp/cache/video_abc123.mp4" in info
@pytest.mark.asyncio
async def test_file_attachment_includes_path(self):
adapter = self._make_adapter()
async def fake_download(url, ct):
return "/tmp/cache/doc_abc123_report.pdf"
adapter._download_and_cache = fake_download # type: ignore[assignment]
attachments = [
{
"content_type": "application/pdf",
"url": "https://multimedia.nt.qq.com.cn/download/file456",
"filename": "report.pdf",
}
]
result = await adapter._process_attachments(attachments)
info = result["attachment_info"]
assert "[file:" in info
assert "report.pdf" in info
assert "/tmp/cache/doc_abc123_report.pdf" in info
@pytest.mark.asyncio
async def test_video_without_filename_falls_back_to_content_type(self):
adapter = self._make_adapter()
async def fake_download(url, ct):
return "/tmp/cache/video_xyz.mp4"
adapter._download_and_cache = fake_download # type: ignore[assignment]
attachments = [
{
"content_type": "video/mp4",
"url": "https://cdn.qq.com/vid",
"filename": "",
}
]
result = await adapter._process_attachments(attachments)
info = result["attachment_info"]
assert "[video: video/mp4" in info
assert "/tmp/cache/video_xyz.mp4" in info
@pytest.mark.asyncio
async def test_download_failure_produces_no_attachment_info(self):
adapter = self._make_adapter()
async def fake_download(url, ct):
return None
adapter._download_and_cache = fake_download # type: ignore[assignment]
attachments = [
{
"content_type": "video/mp4",
"url": "https://cdn.qq.com/vid",
"filename": "vid.mp4",
}
]
result = await adapter._process_attachments(attachments)
assert result["attachment_info"] == ""
@pytest.mark.asyncio
async def test_quoted_video_includes_path_in_quote_block(self):
"""Quoted video attachments should surface the cached path in the quote block."""
adapter = self._make_adapter()
async def fake_process(atts):
# Simulate the fixed _process_attachments for a video attachment.
return {
"image_urls": [],
"image_media_types": [],
"voice_transcripts": [],
"attachment_info": "[video: clip.mp4 (/tmp/cache/clip.mp4)]",
}
adapter._process_attachments = fake_process # type: ignore[assignment]
d = {
"message_type": 103,
"msg_elements": [{
"content": "看看这个视频",
"attachments": [
{"content_type": "video/mp4",
"url": "https://qq-cdn/clip.mp4",
"filename": "clip.mp4"}
],
}],
}
out = await adapter._process_quoted_context(d)
assert "[Quoted message]:" in out["quote_block"]
assert "/tmp/cache/clip.mp4" in out["quote_block"]
@pytest.mark.asyncio
async def test_quoted_file_includes_path_in_quote_block(self):
"""Quoted file attachments should surface the cached path in the quote block."""
adapter = self._make_adapter()
async def fake_process(atts):
return {
"image_urls": [],
"image_media_types": [],
"voice_transcripts": [],
"attachment_info": "[file: report.pdf (/tmp/cache/report.pdf)]",
}
adapter._process_attachments = fake_process # type: ignore[assignment]
d = {
"message_type": 103,
"msg_elements": [{
"content": "",
"attachments": [
{"content_type": "application/pdf",
"url": "https://qq-cdn/report.pdf",
"filename": "report.pdf"}
],
}],
}
out = await adapter._process_quoted_context(d)
assert "[Quoted message]:" in out["quote_block"]
assert "/tmp/cache/report.pdf" in out["quote_block"]