diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 086f5e073f5..8a275a3b75a 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -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) diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index 4b3402387a4..b906d4883ec 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -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"] +