Skip to content

Commit 8252a09

Browse files
committed
feat(platform): add Forward message support for aiocqhttp adapter
- Add _send_forward_message method to send merged forward cards via OneBot API - Support NapCat's send_forward_msg API with fallback to send_group_forward_msg - Fix MessageChain deserialization for Forward messages in handler.py - Properly deserialize nested ForwardMessageNode.message_chain to preserve data This enables plugins to send QQ merged forward cards through the standard LangBot send_message API using the Forward message component.
1 parent 42caae1 commit 8252a09

File tree

2 files changed

+172
-2
lines changed

2 files changed

+172
-2
lines changed

src/langbot/pkg/platform/sources/aiocqhttp.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,13 +375,112 @@ async def shutdown_trigger_placeholder():
375375
self.bot = aiocqhttp.CQHttp()
376376

377377
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
378+
# Check if message contains a Forward component
379+
forward_msg = message.get_first(platform_message.Forward)
380+
if forward_msg and target_type == 'group':
381+
# Send as merged forward message via OneBot API
382+
await self._send_forward_message(int(target_id), forward_msg)
383+
return
384+
378385
aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0]
379386

380387
if target_type == 'group':
381388
await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg)
382389
elif target_type == 'person':
383390
await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg)
384391

392+
async def _send_forward_message(self, group_id: int, forward: platform_message.Forward):
393+
"""Send a merged forward message to a group using NapCat extended API."""
394+
messages = []
395+
396+
for node in forward.node_list:
397+
# Build content for each node
398+
content = []
399+
if node.message_chain:
400+
for component in node.message_chain:
401+
if isinstance(component, platform_message.Plain):
402+
if component.text:
403+
content.append({
404+
"type": "text",
405+
"data": {"text": component.text}
406+
})
407+
elif isinstance(component, platform_message.Image):
408+
img_data = {}
409+
if component.base64:
410+
b64 = component.base64
411+
if b64.startswith('data:'):
412+
b64 = b64.split(',', 1)[-1] if ',' in b64 else b64
413+
img_data["file"] = f"base64://{b64}"
414+
elif component.url:
415+
img_data["file"] = component.url
416+
elif component.path:
417+
img_data["file"] = str(component.path)
418+
419+
if img_data:
420+
content.append({
421+
"type": "image",
422+
"data": img_data
423+
})
424+
425+
if not content:
426+
continue
427+
428+
# Build node data - use user_id and nickname format for NapCat
429+
user_id = str(node.sender_id) if node.sender_id else str(self.bot_account_id or "10000")
430+
node_data = {
431+
"type": "node",
432+
"data": {
433+
"user_id": user_id,
434+
"nickname": node.sender_name or "未知",
435+
"content": content,
436+
}
437+
}
438+
439+
messages.append(node_data)
440+
441+
if not messages:
442+
return
443+
444+
# Build the full message payload for NapCat's send_forward_msg API
445+
# This matches the format used by GiveMeSetuPlugin
446+
bot_id = str(self.bot_account_id) if self.bot_account_id else "10000"
447+
payload = {
448+
"group_id": str(group_id),
449+
"user_id": bot_id, # Required by NapCat for display
450+
"messages": messages,
451+
}
452+
453+
# Add display settings if available
454+
if forward.display:
455+
if forward.display.title:
456+
payload["news"] = [{"text": forward.display.title}]
457+
if forward.display.brief:
458+
payload["prompt"] = forward.display.brief
459+
if forward.display.summary:
460+
payload["summary"] = forward.display.summary
461+
if forward.display.source:
462+
payload["source"] = forward.display.source
463+
464+
try:
465+
# Use send_forward_msg (NapCat extended API) instead of send_group_forward_msg
466+
await self.logger.info(f"Sending forward message to group {group_id} with {len(messages)} nodes, payload keys: {list(payload.keys())}")
467+
result = await self.bot.call_action("send_forward_msg", **payload)
468+
await self.logger.info(f"Forward message sent to group {group_id}, result: {result}")
469+
except Exception as e:
470+
await self.logger.error(f"Failed to send forward message to group {group_id}: {e}")
471+
# Fallback: try standard OneBot API with integer group_id
472+
try:
473+
await self.logger.info(f"Trying fallback API send_group_forward_msg")
474+
await self.bot.call_action(
475+
"send_group_forward_msg",
476+
group_id=group_id,
477+
messages=messages
478+
)
479+
await self.logger.info(f"Forward message sent via fallback API to group {group_id}")
480+
except Exception as e2:
481+
await self.logger.error(f"Fallback also failed: {e2}")
482+
raise
483+
385484
async def reply_message(
386485
self,
387486
message_source: platform_events.MessageEvent,

src/langbot/pkg/plugin/handler.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import typing
4-
from typing import Any
4+
from typing import Any, List, Dict
55
import base64
66
import traceback
77

@@ -26,6 +26,76 @@
2626
from ..utils import constants
2727

2828

29+
def _deserialize_message_chain(data: List[Dict[str, Any]]) -> platform_message.MessageChain:
30+
"""Deserialize message chain with proper handling of Forward messages.
31+
32+
The default MessageChain.model_validate doesn't properly deserialize nested
33+
MessageChain in ForwardMessageNode, causing data loss. This function handles
34+
that case explicitly.
35+
"""
36+
components = []
37+
component_types = platform_message.MessageChain._get_component_types()
38+
39+
for item in data:
40+
if not isinstance(item, dict) or 'type' not in item:
41+
components.append(platform_message.Unknown(text=f"Invalid component: {item}"))
42+
continue
43+
44+
comp_type = item['type']
45+
if comp_type not in component_types:
46+
components.append(platform_message.Unknown(text=f"Unknown type: {comp_type}"))
47+
continue
48+
49+
comp_class = component_types[comp_type]
50+
51+
# Special handling for Forward messages
52+
if comp_type == 'Forward':
53+
node_list = []
54+
for node_data in item.get('node_list', []):
55+
# Recursively deserialize message_chain in each node
56+
mc_data = node_data.get('message_chain', [])
57+
if isinstance(mc_data, list):
58+
mc = _deserialize_message_chain(mc_data)
59+
elif isinstance(mc_data, platform_message.MessageChain):
60+
mc = mc_data
61+
else:
62+
mc = platform_message.MessageChain([])
63+
64+
node = platform_message.ForwardMessageNode(
65+
sender_id=node_data.get('sender_id', ''),
66+
sender_name=node_data.get('sender_name', ''),
67+
message_chain=mc,
68+
message_id=node_data.get('message_id', 0),
69+
)
70+
node_list.append(node)
71+
72+
display_data = item.get('display', {})
73+
display = platform_message.ForwardMessageDiaplay(
74+
title=display_data.get('title', 'Chat history'),
75+
brief=display_data.get('brief', '[Chat history]'),
76+
source=display_data.get('source', 'Chat history'),
77+
preview=display_data.get('preview', []),
78+
summary=display_data.get('summary', 'View forwarded messages'),
79+
)
80+
81+
forward = platform_message.Forward(
82+
display=display,
83+
node_list=node_list,
84+
)
85+
components.append(forward)
86+
else:
87+
# For other component types, use default validation
88+
# but handle Quote's nested MessageChain
89+
if comp_type == 'Quote' and 'origin' in item:
90+
origin_data = item['origin']
91+
if isinstance(origin_data, list):
92+
item['origin'] = _deserialize_message_chain(origin_data)
93+
94+
components.append(comp_class.model_validate(item))
95+
96+
return platform_message.MessageChain(root=components)
97+
98+
2999
class RuntimeConnectionHandler(handler.Handler):
30100
"""Runtime connection handler"""
31101

@@ -279,7 +349,8 @@ async def send_message(data: dict[str, Any]) -> handler.ActionResponse:
279349
target_id = data['target_id']
280350
message_chain = data['message_chain']
281351

282-
message_chain_obj = platform_message.MessageChain.model_validate(message_chain)
352+
# Use custom deserializer that properly handles Forward messages
353+
message_chain_obj = _deserialize_message_chain(message_chain)
283354

284355
bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
285356
if bot is None:

0 commit comments

Comments
 (0)