Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/langbot/pkg/platform/sources/aiocqhttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,13 +375,104 @@ async def shutdown_trigger_placeholder():
self.bot = aiocqhttp.CQHttp()

async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
# Check if message contains a Forward component
forward_msg = message.get_first(platform_message.Forward)
if forward_msg and target_type == 'group':
# Send as merged forward message via OneBot API
await self._send_forward_message(int(target_id), forward_msg)
return
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forward messages are only sent as proper merged forward cards when target_type is 'group' (line 380). If a Forward message is sent to a person (private chat), the code falls through to line 385 where it uses the standard yiri2target converter, which flattens Forward messages into plain text by concatenating nested message chains (see lines 60-62 in yiri2target). This behavioral difference between group and person messages might be intentional (due to API limitations), but it could be unexpected for users. Consider documenting this limitation or adding a warning log when a Forward message is sent to a person.

Suggested change
return
return
elif forward_msg and target_type == 'person':
# Forward messages in private chats are not sent as merged forward cards.
# They will be flattened into plain text by the standard yiri2target converter.
# This warning documents the limitation to avoid surprising users.
if hasattr(self, "logger") and self.logger is not None:
self.logger.warning(
"Forward messages sent to private chats are delivered as plain text; "
"merged forward cards are only supported in group messages."
)

Copilot uses AI. Check for mistakes.

aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0]

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

async def _send_forward_message(self, group_id: int, forward: platform_message.Forward):
"""Send a merged forward message to a group using NapCat extended API."""
messages = []

for node in forward.node_list:
# Build content for each node
content = []
if node.message_chain:
for component in node.message_chain:
if isinstance(component, platform_message.Plain):
if component.text:
content.append({'type': 'text', 'data': {'text': component.text}})
elif isinstance(component, platform_message.Image):
Comment on lines +406 to +409
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type checking pattern is inconsistent with the existing yiri2target method. In yiri2target (lines 29-71), Plain and Image components use 'type(component) is platform_message.Plain' and 'type(component) is platform_message.Image' for exact type matching. However, in _send_forward_message, lines 401 and 404 use 'isinstance(component, platform_message.Plain)' and 'isinstance(component, platform_message.Image)'. For consistency with the existing codebase pattern, these should use 'type(component) is' instead of 'isinstance'.

Suggested change
if isinstance(component, platform_message.Plain):
if component.text:
content.append({'type': 'text', 'data': {'text': component.text}})
elif isinstance(component, platform_message.Image):
if type(component) is platform_message.Plain:
if component.text:
content.append({'type': 'text', 'data': {'text': component.text}})
elif type(component) is platform_message.Image:

Copilot uses AI. Check for mistakes.
img_data = {}
if component.base64:
b64 = component.base64
if b64.startswith('data:'):
b64 = b64.split(',', 1)[-1] if ',' in b64 else b64
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The base64 handling at lines 408-409 has a logical issue. If a base64 string starts with 'data:' but doesn't contain a comma, line 409 evaluates to 'b64' (unchanged) due to the ternary operator. However, this means the string will still have the 'data:' prefix, which is then re-added at line 410 with 'base64://', resulting in 'base64://data:image/...'. The correct approach is to either strip 'data:' unconditionally after checking it exists, or handle the case where comma is missing differently. Consider: if b64.startswith('data:'): b64 = b64.split(',', 1)[1] if ',' in b64 else b64[5:] (to strip 'data:' prefix).

Suggested change
b64 = b64.split(',', 1)[-1] if ',' in b64 else b64
b64 = b64.split(',', 1)[1] if ',' in b64 else b64[5:]

Copilot uses AI. Check for mistakes.
img_data['file'] = f'base64://{b64}'
elif component.url:
img_data['file'] = component.url
elif component.path:
img_data['file'] = str(component.path)

if img_data:
content.append({'type': 'image', 'data': img_data})

Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _send_forward_message method only handles Plain and Image components in forward messages. However, the existing yiri2target converter in this file (lines 28-75) supports many more component types including At, AtAll, Voice, File, Face, and nested Forward messages. This creates an inconsistency where forward messages with these component types will be silently ignored rather than properly converted. Consider either supporting all component types that yiri2target supports, or at least handling unsupported types explicitly (e.g., converting them to text as done in yiri2target line 74).

Suggested change
else:
# Fallback: convert other component types to text, similar to yiri2target
text = getattr(component, 'text', None)
if not text:
text = str(component)
if text:
content.append({'type': 'text', 'data': {'text': text}})

Copilot uses AI. Check for mistakes.
if not content:
continue

# Build node data - use user_id and nickname format for NapCat
user_id = str(node.sender_id) if node.sender_id else str(self.bot_account_id or '10000')
node_data = {
'type': 'node',
'data': {
'user_id': user_id,
'nickname': node.sender_name or '未知',
'content': content,
},
}

messages.append(node_data)

if not messages:
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a forward message node has no valid content (empty content list), the node is skipped with 'continue' at line 420. If all nodes are skipped this way, the method returns early at line 436 without sending anything or notifying the caller. This could lead to silent failures where the user expects a forward message to be sent but nothing happens. Consider logging a warning when all nodes are empty, or throwing an exception to make the failure explicit.

Suggested change
if not messages:
if not messages:
# No valid nodes produced any content; nothing will be sent.
# Log this condition to avoid silent failures for callers.
await self.logger.info(
f'Forward message to group {group_id} not sent: all nodes had empty or invalid content.'
)

Copilot uses AI. Check for mistakes.
return

# Build the full message payload for NapCat's send_forward_msg API
# This matches the format used by GiveMeSetuPlugin
bot_id = str(self.bot_account_id) if self.bot_account_id else '10000'
payload = {
'group_id': str(group_id),
'user_id': bot_id, # Required by NapCat for display
'messages': messages,
}

# Add display settings if available
if forward.display:
if forward.display.title:
payload['news'] = [{'text': forward.display.title}]
if forward.display.brief:
payload['prompt'] = forward.display.brief
if forward.display.summary:
payload['summary'] = forward.display.summary
if forward.display.source:
payload['source'] = forward.display.source

try:
# Use send_forward_msg (NapCat extended API) instead of send_group_forward_msg
await self.logger.info(
f'Sending forward message to group {group_id} with {len(messages)} nodes, payload keys: {list(payload.keys())}'
)
result = await self.bot.call_action('send_forward_msg', **payload)
await self.logger.info(f'Forward message sent to group {group_id}, result: {result}')
except Exception as e:
await self.logger.error(f'Failed to send forward message to group {group_id}: {e}')
# Fallback: try standard OneBot API with integer group_id
try:
await self.logger.info('Trying fallback API send_group_forward_msg')
Comment on lines +472 to +474
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the primary send_forward_msg API fails, the fallback at line 470 calls send_group_forward_msg with only group_id and messages parameters. However, the primary API call at line 463 uses additional parameters from the payload dict including user_id, and potentially news, prompt, summary, and source fields. The fallback doesn't include these display settings, which means if the primary API fails, the forward message will be sent without the custom display settings (title, brief, summary, source) that the user may have configured in forward.display. Consider including these parameters in the fallback call, or documenting that display settings are not supported in fallback mode.

Suggested change
# Fallback: try standard OneBot API with integer group_id
try:
await self.logger.info('Trying fallback API send_group_forward_msg')
# Fallback: try standard OneBot API with integer group_id.
# Note: OneBot's send_group_forward_msg does not support the extended display
# fields (news/prompt/summary/source) or the NapCat-specific user_id behavior,
# so only group_id and messages are sent here and any forward.display settings
# will not affect the fallback message rendering.
try:
await self.logger.info('Trying fallback API send_group_forward_msg without extended display settings')

Copilot uses AI. Check for mistakes.
await self.bot.call_action('send_group_forward_msg', group_id=group_id, messages=messages)
await self.logger.info(f'Forward message sent via fallback API to group {group_id}')
except Exception as e2:
await self.logger.error(f'Fallback also failed: {e2}')
raise

async def reply_message(
self,
message_source: platform_events.MessageEvent,
Expand Down
75 changes: 73 additions & 2 deletions src/langbot/pkg/plugin/handler.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import typing
from typing import Any
from typing import Any, List, Dict
import base64
import traceback

Expand All @@ -26,6 +26,76 @@
from ..utils import constants


def _deserialize_message_chain(data: List[Dict[str, Any]]) -> platform_message.MessageChain:
"""Deserialize message chain with proper handling of Forward messages.

The default MessageChain.model_validate doesn't properly deserialize nested
MessageChain in ForwardMessageNode, causing data loss. This function handles
that case explicitly.
"""
components = []
component_types = platform_message.MessageChain._get_component_types()

for item in data:
if not isinstance(item, dict) or 'type' not in item:
components.append(platform_message.Unknown(text=f'Invalid component: {item}'))
continue

comp_type = item['type']
if comp_type not in component_types:
components.append(platform_message.Unknown(text=f'Unknown type: {comp_type}'))
continue
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an item in the data list is not a dict or doesn't have a 'type' field (line 40-42), or when the type is unknown (line 45-47), the function creates an Unknown component with the issue description. However, this could lead to confusion if the original issue was a transient serialization problem, as the Unknown component will be permanently stored in the MessageChain. Consider logging these occurrences at a warning level to aid debugging, especially since this function is meant to fix a deserialization bug and should be robust against various input formats.

Copilot uses AI. Check for mistakes.

comp_class = component_types[comp_type]

# Special handling for Forward messages
if comp_type == 'Forward':
node_list = []
for node_data in item.get('node_list', []):
# Recursively deserialize message_chain in each node
mc_data = node_data.get('message_chain', [])
if isinstance(mc_data, list):
mc = _deserialize_message_chain(mc_data)
elif isinstance(mc_data, platform_message.MessageChain):
mc = mc_data
else:
mc = platform_message.MessageChain([])

node = platform_message.ForwardMessageNode(
sender_id=node_data.get('sender_id', ''),
sender_name=node_data.get('sender_name', ''),
message_chain=mc,
message_id=node_data.get('message_id', 0),
)
node_list.append(node)

display_data = item.get('display', {})
display = platform_message.ForwardMessageDiaplay(
title=display_data.get('title', 'Chat history'),
brief=display_data.get('brief', '[Chat history]'),
source=display_data.get('source', 'Chat history'),
preview=display_data.get('preview', []),
summary=display_data.get('summary', 'View forwarded messages'),
)

forward = platform_message.Forward(
display=display,
node_list=node_list,
)
components.append(forward)
else:
# For other component types, use default validation
# but handle Quote's nested MessageChain
if comp_type == 'Quote' and 'origin' in item:
origin_data = item['origin']
if isinstance(origin_data, list):
item['origin'] = _deserialize_message_chain(origin_data)

components.append(comp_class.model_validate(item))

return platform_message.MessageChain(root=components)


class RuntimeConnectionHandler(handler.Handler):
"""Runtime connection handler"""

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

message_chain_obj = platform_message.MessageChain.model_validate(message_chain)
# Use custom deserializer that properly handles Forward messages
message_chain_obj = _deserialize_message_chain(message_chain)

bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
if bot is None:
Expand Down
Loading