Skip to content

feat(platform): add Forward message support for aiocqhttp adapter#2003

Merged
RockChinQ merged 5 commits intolangbot-app:masterfrom
ydzat:feat/forward-message-support
Feb 25, 2026
Merged

feat(platform): add Forward message support for aiocqhttp adapter#2003
RockChinQ merged 5 commits intolangbot-app:masterfrom
ydzat:feat/forward-message-support

Conversation

@ydzat
Copy link
Contributor

@ydzat ydzat commented Feb 25, 2026

Overview

This PR adds native support for sending QQ merged forward cards (合并转发) through the aiocqhttp adapter, and fixes a bug in MessageChain deserialization that caused data loss for Forward messages.

Changes

1. aiocqhttp.py - Forward message sending

  • Added _send_forward_message() method to handle Forward message components
  • Uses NapCat's send_forward_msg API with fallback to standard send_group_forward_msg
  • Supports display settings (title, brief, summary, source)

2. handler.py - MessageChain deserialization fix

  • Added _deserialize_message_chain() function for proper Forward message handling
  • Fixed bug where nested ForwardMessageNode.message_chain was not properly deserialized
  • The default MessageChain.model_validate() lost data in nested MessageChain fields

Screenshots

Before: Forward messages were flattened into plain text concatenation (type='Plain'type='Plain'...)

After: Forward messages are sent as proper QQ merged forward cards

Checklist

For PR author

  • Have you read the contribution guide?
  • Have you communicated with the project maintainer?
  • I have tested the changes and ensured they work as expected.

For project maintainer

  • Have you linked the related issues?
  • Have you written the configuration items? Have you written the migration? Has it taken effect? (N/A - no config changes)
  • Have you added the dependencies to pyproject.toml and core/bootutils/deps.py? (N/A - no new deps)
  • Have you written the documentation?

- 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.
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. eh: Feature enhance: 新功能添加 / add new features IM: aiocqhttp OneBot 协议适配器相关 / OneBot adapter related labels Feb 25, 2026
- Remove f-string prefix from log message without placeholders
- Apply ruff format to aiocqhttp.py and handler.py
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds native support for sending QQ merged forward cards through the aiocqhttp adapter and fixes a MessageChain deserialization bug affecting Forward messages.

Changes:

  • Added _send_forward_message() method in aiocqhttp adapter to send Forward messages as proper QQ merged forward cards using NapCat's API with fallback support
  • Fixed MessageChain deserialization in handler.py by introducing _deserialize_message_chain() function that properly handles nested MessageChain objects in Forward and Quote components

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.

File Description
src/langbot/pkg/plugin/handler.py Adds custom deserialization function to properly handle nested MessageChain in Forward and Quote components, replacing default model_validate that caused data loss
src/langbot/pkg/platform/sources/aiocqhttp.py Adds _send_forward_message method to send Forward messages as QQ merged forward cards with display settings, supporting Plain and Image components

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +401 to +404
if isinstance(component, platform_message.Plain):
if component.text:
content.append({'type': 'text', 'data': {'text': component.text}})
elif isinstance(component, platform_message.Image):
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.
Comment on lines 40 to 47
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.
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.
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.
Comment on lines +467 to +469
# Fallback: try standard OneBot API with integer group_id
try:
await self.logger.info('Trying fallback API send_group_forward_msg')
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.

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.

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.
@RockChinQ
Copy link
Member

Hi @ydzat 👋

I reviewed this PR and noticed that the _deserialize_message_chain function in handler.py duplicates (and replaces) the existing MessageChain.model_validate logic, which has a large blast radius — all message deserialization goes through the new code path, not just Forward messages.

Root cause: The SDK's MessageChain.model_validate doesn't handle ForwardMessageNode.message_chain because its annotation is Optional[MessageChain] (not MessageChain), so the recursive field check doesn't match.

Fix: I've submitted a fix to the SDK directly: langbot-app/langbot-plugin-sdk#38

Once that SDK PR is merged, the custom _deserialize_message_chain function in handler.py can be removed entirely — just use the standard MessageChain.model_validate() and Forward will deserialize correctly.

Suggested changes for this PR:

  1. Remove the _deserialize_message_chain function from handler.py
  2. Revert the import change (List, Dict not needed)
  3. Keep the send_message / _send_forward_message changes in aiocqhttp.py as-is (those are good 👍)

Other minor issues in aiocqhttp.py to consider:

  • await self.logger.info(...) — is the logger async? If not, drop the await
  • group_id is passed as str in the payload but standard OneBot expects int
  • Forward + target_type == 'person' silently falls through to normal conversion (should at least log a warning)

Happy to help if you want to coordinate on timing with the SDK fix!

…lization

- Remove _deserialize_message_chain from handler.py; use standard
  MessageChain.model_validate() (Forward handling fixed in SDK via
  langbot-app/langbot-plugin-sdk#38)
- Fix group_id type: use int instead of str for OneBot compatibility
- Add warning log when Forward message is used with non-group target
@dosubot dosubot bot added size:M This PR changes 30-99 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Feb 25, 2026
@RockChinQ
Copy link
Member

Update: ignore my earlier note about await self.logger.info() — LangBot's logger methods are indeed async def, so await is correct. 👍

@RockChinQ RockChinQ merged commit 298437f into langbot-app:master Feb 25, 2026
5 checks passed
@codecov
Copy link

codecov bot commented Feb 25, 2026

Codecov Report

❌ Patch coverage is 0% with 59 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/langbot/pkg/platform/sources/aiocqhttp.py 0.00% 59 Missing ⚠️

📢 Thoughts on this report? Let us know!

RockChinQ added a commit that referenced this pull request Feb 25, 2026
* fix a bag updata

* Update page.tsx

* Update page.tsx

* Append text area to body for selection

* Update page.tsx

* Update mcp.py

* fix(web): Handle null/undefined starCount and installCount (#1970)

* Initial plan

* fix(web): Handle null/undefined values for starCount and installCount

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* fix(web): Hide star count badge when API fails instead of showing '0'

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add files via upload

* Update README.md

* Update README_EN.md

* Update README_TW.md

* Add Satori to communication tools list

* Add Satori to supported platforms list

* Add Satori to the supported LLMs list

* Add Satori to the supported platforms list

* Add Satori to supported platforms list

* Add Satori to the supported platforms list

* Add files via upload

* Update README_TW.md

* Add files via upload

* Add files via upload

* chore(deps): bump pillow from 12.1.0 to 12.1.1 (#1977)

Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.1.0 to 12.1.1.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](python-pillow/Pillow@12.1.0...12.1.1)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump cryptography from 46.0.4 to 46.0.5 (#1978)

Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.4 to 46.0.5.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](pyca/cryptography@46.0.4...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump axios from 1.13.4 to 1.13.5 in /web (#1979)

Bumps [axios](https://github.com/axios/axios) from 1.13.4 to 1.13.5.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](axios/axios@v1.13.4...v1.13.5)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.13.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revise bug report instructions for clarity

Updated bug report template to request export files for external platforms.

* Update bug-report_en.yml

* Replace English README with Chinese version and update language links across all README files

* Update README files across multiple languages to reflect new platform capabilities and improve clarity. Enhanced descriptions for AI bot development and deployment, and added links for further documentation.

* Update src/langbot/pkg/platform/sources/satori.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/langbot/pkg/platform/sources/satori.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add files via upload

* Delete README_EN.md

* Update README.md

* Add Satori support to README_TW.md

* Update README_VI.md

* Add Satori support to the README_KO.md

* Update README_RU.md

* Update fmt.Println message from 'Hello' to 'Goodbye'

* Update print statement from 'Hello' to 'Goodbye'

* ruff

* Add files via upload

* Change type from int to integer in satori.yaml

* fix: correct license declaration in OpenAPI spec from AGPL-3.0 to Apache-2.0 (#1988)

* Initial plan

* fix: update license from AGPL-3.0 to Apache-2.0 in service-api-openapi.json

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Update satori.py

* Update satori.py

* Update satori.py

* feat: Implement extension and bot limitations across services and UI (#1991)

- Added checks for maximum allowed extensions, bots, and pipelines in the backend services (PluginsRouterGroup, BotService, MCPService, PipelineService).
- Updated system configuration to include limitation settings for max_bots, max_pipelines, and max_extensions.
- Enhanced frontend components to handle limitations, providing user feedback when limits are reached.
- Added internationalization support for limitation messages in English, Japanese, Simplified Chinese, and Traditional Chinese.

* feat: Add unsaved changes tracking to PipelineFormComponent

* chore: Update logo in README files to new resource location

* chore: Standardize section headers in multiple language README files

* chore: Bump version to 4.8.4 and update langbot-plugin dependency to 0.2.6

* feat: add plugin recommendation lists to market page (#2001)

* fix: Add the file upload function and optimize the media message proc… (#2002)

* fix: Add the file upload function and optimize the media message processing

* fix: Optimize the message processing logic, improve the concatenation of text elements and the sending of media messages

* fix: Simplify the file request construction and message processing logic to enhance code readability

* fix(web): emit initial form values on mount to prevent saving empty config (#2004)

DynamicFormComponent uses form.watch(callback) to notify parent of form
values, but react-hook-form's watch callback only fires on subsequent
changes, not on mount. This causes PluginForm's currentFormValues to
remain as {} if the user saves without modifying any field, overwriting
the existing plugin config with an empty object in the database.

* feat(platform): add Forward message support for aiocqhttp adapter (#2003)

* 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.

* style: fix ruff lint and format issues

- Remove f-string prefix from log message without placeholders
- Apply ruff format to aiocqhttp.py and handler.py

* refactor: remove custom deserializer, rely on SDK for Forward deserialization

- Remove _deserialize_message_chain from handler.py; use standard
  MessageChain.model_validate() (Forward handling fixed in SDK via
  langbot-app/langbot-plugin-sdk#38)
- Fix group_id type: use int instead of str for OneBot compatibility
- Add warning log when Forward message is used with non-group target

* chore: bump langbot-plugin to 0.2.7 (Forward deserialization fix)

---------

Co-authored-by: RockChinQ <rockchinq@gmail.com>

* feat: message aggregator (#1985)

* feat: aggregator

* fix: resolve deadlock, mutation, and safety issues in message aggregator

- Fix deadlock: don't await cancelled timer tasks inside the lock;
  _flush_buffer acquires the same lock, causing a deadlock cycle
- Fix message_event mutation: keep original message_event unmodified
  to preserve message_id/metadata for reply/quote; only pass merged
  message_chain separately
- Fix Plain positional arg: Plain('\n') → Plain(text='\n')
- Fix float() ValueError: wrap delay cast in try/except
- Add MAX_BUFFER_MESSAGES (10) cap to prevent unbounded buffer growth
- Default enabled to false to avoid surprising latency on upgrade
- Fix flush_all: cancel all timers under one lock acquisition, then
  flush outside the lock to avoid deadlock

---------

Co-authored-by: RockChinQ <rockchinq@gmail.com>

* feat: add session message monitoring tab to bot detail dialog

Add a new "Sessions" tab in the bot detail dialog that displays
sent & received messages grouped by sessions. Users can select
any session to view its messages in a chat-bubble style layout.

Backend changes:
- Add sessionId filter to monitoring messages endpoint
- Add role column to MonitoringMessage (user/assistant)
- Record bot responses in monitoring via record_query_response()
- Add DB migration (dbm019) for the new role column

Frontend changes:
- New BotSessionMonitor component with session list + message viewer
- Add Sessions sidebar tab to BotDetailDialog
- Add getBotSessions/getSessionMessages API methods to BackendClient
- Add i18n translations (en-US, zh-Hans, zh-Hant, ja-JP)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

* refactor: remove outdated version comment from PipelineManager class

* fix: bump required_database_version to 19 to trigger monitoring_messages.role migration

* fix: prevent session message auto-scroll from pushing dialog content out of view

Replace scrollIntoView (which scrolls all ancestor containers) with
direct scrollTop manipulation on the ScrollArea viewport. This keeps
the scroll contained within the messages panel only.

* ui: redesign BotSessionMonitor with polished chat UI

- Wider session list (w-72) with avatar circles and cleaner layout
- Richer chat header with avatar, platform info, and active indicator
- User messages now use blue-500 (solid) instead of blue-100 for
  clear visual distinction
- Metadata (time, runner) shown on hover below bubbles, not inside
- Proper empty state illustrations for both panels
- Better spacing, rounded corners, and shadow treatment
- Consistent dark mode styling

* fix: infinite re-render loop in DynamicFormComponent

The useEffect depended on onSubmit which was a new closure every
parent render. Calling onSubmit inside the effect triggered parent
state update → re-render → new onSubmit ref → effect re-runs → loop.

Fix: use useRef to hold a stable reference to onSubmit, removing it
from the useEffect dependency array.

Also add DialogDescription to BotDetailDialog to suppress Radix
aria-describedby warning.

* fix: remove .html suffix from docs.langbot.app links (Mintlify migration)

* style: fix prettier and ruff formatting

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Typer_Body <mcjiekejiemi@163.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Typer_Body <marcelacelani74@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Guanchao Wang <wangcham233@gmail.com>
Co-authored-by: fdc310 <82008029+fdc310@users.noreply.github.com>
Co-authored-by: Dongze Yang <50231148+ydzat@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

eh: Feature enhance: 新功能添加 / add new features IM: aiocqhttp OneBot 协议适配器相关 / OneBot adapter related size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants