From d1c79a35e80cec65b26663adb824d75698eb6f9e Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Thu, 10 Sep 2026 15:06:57 +0800 Subject: [PATCH] fix(4.11): align omni adapters and EventProcessor marketplace support --- .../adapters/acceptance-report.md | 24 +- docs/event-based-agents/adapters/aiocqhttp.md | 2 +- docs/event-based-agents/adapters/discord.md | 2 +- docs/event-based-agents/adapters/kook.md | 2 +- docs/event-based-agents/adapters/lark.md | 2 +- .../adapters/officialaccount.md | 4 +- .../event-based-agents/adapters/qqofficial.md | 4 +- docs/event-based-agents/adapters/slack.md | 4 +- docs/event-based-agents/adapters/telegram.md | 2 +- docs/event-based-agents/adapters/wecom.md | 8 +- docs/event-based-agents/adapters/wecombot.md | 8 +- docs/event-based-agents/adapters/wecomcs.md | 4 +- docs/review/omni-mainline-sync-2026-09-10.md | 63 +++ skills/skills/langbot-mcp-ops/SKILL.md | 2 +- src/langbot/libs/dingtalk_api/api.py | 5 +- .../libs/wecom_customer_service_api/api.py | 26 -- src/langbot/pkg/api/http/service/agent.py | 2 +- src/langbot/pkg/api/http/service/bot.py | 13 +- src/langbot/pkg/api/mcp/server.py | 2 +- src/langbot/pkg/platform/adapter_names.py | 25 ++ .../platform/adapters/aiocqhttp/adapter.py | 8 +- .../adapters/aiocqhttp/event_converter.py | 42 +- .../platform/adapters/aiocqhttp/manifest.yaml | 8 +- .../adapters/aiocqhttp/message_converter.py | 32 +- .../pkg/platform/adapters/dingtalk/adapter.py | 18 +- .../platform/adapters/dingtalk/interaction.py | 2 +- .../platform/adapters/dingtalk/manifest.yaml | 44 +- .../pkg/platform/adapters/dingtalk/types.py | 2 +- .../pkg/platform/adapters/discord/adapter.py | 101 +++++ .../pkg/platform/adapters/discord/api_impl.py | 6 +- .../adapters/discord/event_converter.py | 24 +- .../platform/adapters/discord/interaction.py | 2 +- .../platform/adapters/discord/manifest.yaml | 6 +- .../adapters/discord/message_converter.py | 27 +- .../platform/adapters/discord/platform_api.py | 6 +- .../pkg/platform/adapters/discord/voice.py | 2 +- .../pkg/platform/adapters/kook/adapter.py | 30 +- .../platform/adapters/kook/event_converter.py | 4 +- .../pkg/platform/adapters/kook/manifest.yaml | 6 +- .../pkg/platform/adapters/lark/adapter.py | 139 +++++- .../pkg/platform/adapters/lark/api_impl.py | 2 +- .../pkg/platform/adapters/lark/interaction.py | 2 +- .../pkg/platform/adapters/lark/manifest.yaml | 73 +++- .../adapters/lark/message_converter.py | 101 +---- .../pkg/platform/adapters/lark/types.py | 2 +- .../adapters/officialaccount/adapter.py | 13 +- .../adapters/officialaccount/manifest.yaml | 21 +- .../adapters/officialaccount/types.py | 2 +- .../platform/adapters/qqofficial/adapter.py | 88 +++- .../adapters/qqofficial/interaction.py | 2 +- .../adapters/qqofficial/manifest.yaml | 47 ++- .../adapters/qqofficial/message_converter.py | 7 +- .../pkg/platform/adapters/qqofficial/types.py | 3 +- .../pkg/platform/adapters/slack/adapter.py | 16 +- .../pkg/platform/adapters/slack/manifest.yaml | 8 +- .../adapters/slack/message_converter.py | 10 +- .../pkg/platform/adapters/slack/types.py | 2 +- .../pkg/platform/adapters/telegram/adapter.py | 107 ++++- .../adapters/telegram/event_converter.py | 28 +- .../platform/adapters/telegram/interaction.py | 2 +- .../platform/adapters/telegram/manifest.yaml | 6 +- .../adapters/telegram/message_converter.py | 146 +------ .../pkg/platform/adapters/wecom/adapter.py | 11 +- .../pkg/platform/adapters/wecom/manifest.yaml | 20 +- .../adapters/wecom/message_converter.py | 2 +- .../pkg/platform/adapters/wecom/types.py | 4 +- .../pkg/platform/adapters/wecombot/adapter.py | 41 +- .../platform/adapters/wecombot/interaction.py | 2 +- .../platform/adapters/wecombot/manifest.yaml | 34 +- .../adapters/wecombot/message_converter.py | 53 ++- .../pkg/platform/adapters/wecombot/types.py | 2 +- .../pkg/platform/adapters/wecomcs/adapter.py | 16 +- .../platform/adapters/wecomcs/manifest.yaml | 21 +- .../pkg/platform/adapters/wecomcs/types.py | 4 +- src/langbot/pkg/platform/botmgr.py | 31 +- .../pkg/platform/sources/aiocqhttp.yaml | 6 +- .../pkg/platform/sources/dingtalk.yaml | 6 +- src/langbot/pkg/platform/sources/discord.py | 2 +- src/langbot/pkg/platform/sources/discord.yaml | 6 +- .../pkg/platform/sources/http_bot.yaml | 6 +- src/langbot/pkg/platform/sources/kook.yaml | 6 +- src/langbot/pkg/platform/sources/lark.py | 36 +- src/langbot/pkg/platform/sources/lark.yaml | 6 +- src/langbot/pkg/platform/sources/line.yaml | 6 +- src/langbot/pkg/platform/sources/matrix.py | 4 +- .../pkg/platform/sources/mattermost.py | 375 +++++++++++++++++ .../pkg/platform/sources/mattermost.svg | 1 + .../pkg/platform/sources/mattermost.yaml | 75 ++++ .../pkg/platform/sources/officialaccount.yaml | 6 +- .../pkg/platform/sources/openclaw_weixin.yaml | 6 +- .../pkg/platform/sources/qqofficial.py | 2 +- .../pkg/platform/sources/qqofficial.yaml | 6 +- src/langbot/pkg/platform/sources/satori.yaml | 6 +- src/langbot/pkg/platform/sources/slack.yaml | 6 +- .../pkg/platform/sources/telegram.yaml | 6 +- .../pkg/platform/sources/wechatpad.yaml | 6 +- src/langbot/pkg/platform/sources/wecom.py | 6 +- src/langbot/pkg/platform/sources/wecom.yaml | 6 +- src/langbot/pkg/platform/sources/wecombot.py | 2 +- .../pkg/platform/sources/wecombot.yaml | 6 +- src/langbot/pkg/platform/sources/wecomcs.yaml | 6 +- tests/e2e/live_discord_eba_probe.py | 6 +- tests/e2e/live_wecom_eba_probe.py | 11 +- tests/e2e/live_wecombot_eba_probe.py | 26 +- tests/e2e/live_wecomcs_eba_probe.py | 9 +- .../api/http/service/test_bot_service.py | 3 +- .../api/service/test_bot_service.py | 5 +- .../platform/test_aiocqhttp_eba_adapter.py | 5 +- .../platform/test_botmgr_tenancy.py | 12 +- .../unit_tests/platform/test_dingtalk_api.py | 43 +- .../platform/test_dingtalk_eba_adapter.py | 4 +- .../platform/test_discord_eba_adapter.py | 2 +- .../platform/test_kook_eba_adapter.py | 2 +- .../unit_tests/platform/test_lark_adapter.py | 123 +++++- .../platform/test_lark_eba_adapter.py | 27 +- .../platform/test_mattermost_adapter.py | 171 ++++++++ .../test_officialaccount_eba_adapter.py | 2 +- .../platform/test_omni_adapter_names.py | 70 +++ .../platform/test_omni_mainline_parity.py | 398 ++++++++++++++++++ .../platform/test_qqofficial_eba_adapter.py | 6 +- .../platform/test_slack_eba_adapter.py | 10 +- .../platform/test_wecom_eba_adapter.py | 4 +- .../platform/test_wecom_send_message.py | 59 +++ .../platform/test_wecombot_eba_adapter.py | 19 +- .../platform/test_wecomcs_eba_adapter.py | 2 +- .../home/bots/components/bot-form/BotForm.tsx | 2 +- .../plugin-installed/PluginComponentList.tsx | 2 + .../plugin-market/PluginComponentIcons.ts | 2 + .../plugin-market/PluginMarketComponent.tsx | 6 + web/src/i18n/locales/en-US.ts | 4 +- web/src/i18n/locales/es-ES.ts | 4 +- web/src/i18n/locales/ja-JP.ts | 4 +- web/src/i18n/locales/ru-RU.ts | 4 +- web/src/i18n/locales/th-TH.ts | 4 +- web/src/i18n/locales/vi-VN.ts | 4 +- web/src/i18n/locales/zh-Hans.ts | 4 +- web/src/i18n/locales/zh-Hant.ts | 4 +- 137 files changed, 2653 insertions(+), 657 deletions(-) create mode 100644 docs/review/omni-mainline-sync-2026-09-10.md create mode 100644 src/langbot/pkg/platform/adapter_names.py create mode 100644 src/langbot/pkg/platform/sources/mattermost.py create mode 100644 src/langbot/pkg/platform/sources/mattermost.svg create mode 100644 src/langbot/pkg/platform/sources/mattermost.yaml create mode 100644 tests/unit_tests/platform/test_mattermost_adapter.py create mode 100644 tests/unit_tests/platform/test_omni_adapter_names.py create mode 100644 tests/unit_tests/platform/test_omni_mainline_parity.py create mode 100644 tests/unit_tests/platform/test_wecom_send_message.py diff --git a/docs/event-based-agents/adapters/acceptance-report.md b/docs/event-based-agents/adapters/acceptance-report.md index eaa7ffe25..ac86b62b7 100644 --- a/docs/event-based-agents/adapters/acceptance-report.md +++ b/docs/event-based-agents/adapters/acceptance-report.md @@ -4,17 +4,17 @@ Date: May 10, 2026 Scope: -- `telegram-eba` -- `discord-eba` -- `aiocqhttp-eba` -- `dingtalk-eba` -- `lark-eba` -- `wecom-eba` -- `wecombot-eba` -- `wecomcs-eba` -- `officialaccount-eba` -- `qqofficial-eba` -- `slack-eba` +- `telegram-omni` +- `discord-omni` +- `aiocqhttp-omni` +- `dingtalk-omni` +- `lark-omni` +- `wecom-omni` +- `wecombot-omni` +- `wecomcs-omni` +- `officialaccount-omni` +- `qqofficial-omni` +- `slack-omni` This report follows `acceptance-checklist.md`. Evidence levels are intentionally strict: @@ -72,7 +72,7 @@ All four adapters deliver common SDK entities to plugins before LangBot core/plu | Requirement | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu | |-------------|----------|---------|-----------|----------|---------------| | `bot_uuid` filled | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e | live plugin-e2e pending | -| `adapter_name` filled | `telegram` | `discord` | `aiocqhttp` | `dingtalk` | `lark-eba` in current unit/code; older live text evidence recorded `lark` before the naming fix | +| `adapter_name` filled | `telegram` | `discord` | `aiocqhttp` | `dingtalk` | `lark-omni` in current unit/code; older live text evidence recorded `lark` before the naming fix | | common `MessageChain` delivered | `Plain`, group `At + Plain`, private `Image`, private `File` | `Source + Plain` | UI `Source + Plain`; protocol `Source + Plain + At + Face + Image + Voice + File + Quote + Plain` | `Source + Plain`, private `Source + Image`, private `Source + File` | live private `Source + Plain`; unit `Source + Plain + At/Image/File`; latest live image/file blocked | | common user/group entities | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e private user; group not completed | live private user; unit private/group | | raw native object isolation | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | raw data stays in `source_platform_object` | diff --git a/docs/event-based-agents/adapters/aiocqhttp.md b/docs/event-based-agents/adapters/aiocqhttp.md index 270ceb2ab..aa2edf8ca 100644 --- a/docs/event-based-agents/adapters/aiocqhttp.md +++ b/docs/event-based-agents/adapters/aiocqhttp.md @@ -16,7 +16,7 @@ src/langbot/pkg/platform/adapters/aiocqhttp/ └── onebot.svg ``` -The EBA adapter is registered as `aiocqhttp-eba`. The legacy adapter remains at `src/langbot/pkg/platform/sources/aiocqhttp.py`. +The EBA adapter is registered as `aiocqhttp-omni`. The legacy adapter remains at `src/langbot/pkg/platform/sources/aiocqhttp.py`. ## Configuration diff --git a/docs/event-based-agents/adapters/discord.md b/docs/event-based-agents/adapters/discord.md index a2ea4143c..95e1fb91e 100644 --- a/docs/event-based-agents/adapters/discord.md +++ b/docs/event-based-agents/adapters/discord.md @@ -23,7 +23,7 @@ src/langbot/pkg/platform/adapters/discord/ └── voice.py ``` -The adapter is registered as `discord-eba`. +The adapter is registered as `discord-omni`. ## Configuration diff --git a/docs/event-based-agents/adapters/kook.md b/docs/event-based-agents/adapters/kook.md index 25a5e4aef..14e3198a3 100644 --- a/docs/event-based-agents/adapters/kook.md +++ b/docs/event-based-agents/adapters/kook.md @@ -15,7 +15,7 @@ src/langbot/pkg/platform/adapters/kook/ └── types.py ``` -The adapter is registered as `kook-eba`. +The adapter is registered as `kook-omni`. ## Configuration diff --git a/docs/event-based-agents/adapters/lark.md b/docs/event-based-agents/adapters/lark.md index c162284bb..06af00794 100644 --- a/docs/event-based-agents/adapters/lark.md +++ b/docs/event-based-agents/adapters/lark.md @@ -15,7 +15,7 @@ The Lark/Feishu adapter now has an Event-Based Agents adapter package with: - `api_impl.py` for common EBA API implementations. - `platform_api.py` for Feishu-specific `call_platform_api` actions. -The legacy `lark` adapter remains available while the EBA adapter is registered separately as `lark-eba`. +The legacy `lark` adapter remains available while the EBA adapter is registered separately as `lark-omni`. ## Configuration diff --git a/docs/event-based-agents/adapters/officialaccount.md b/docs/event-based-agents/adapters/officialaccount.md index 92f744f12..d5f24d582 100644 --- a/docs/event-based-agents/adapters/officialaccount.md +++ b/docs/event-based-agents/adapters/officialaccount.md @@ -2,7 +2,7 @@ Adapter directory: `src/langbot/pkg/platform/adapters/officialaccount/` -Manifest name: `officialaccount-eba` +Manifest name: `officialaccount-omni` Status: partial migration. Unit/API-shape coverage is present, and private text `plugin-e2e-ui` plus safe API evidence has been verified against the `dev.rockchin.top` Official Account fixture. Proactive outbound `send_message` remains not supported by this adapter because WeChat Official Account replies must be tied to inbound webhook windows. @@ -70,7 +70,7 @@ Verified UI message: `EBA officialaccount single probe 2026-05-28 16:53` Observed event/API evidence: -- `MessageReceived`: `bot_uuid=d7c46880-a9f8-431a-9172-5d3e0d663dbc`, `adapter_name=officialaccount-eba`, `chat_type=private`, `chat_id=ovH9L7OW6hNpWZWvp_NMmypVh26w`, `message_chain=[Source, Plain]`. +- `MessageReceived`: `bot_uuid=d7c46880-a9f8-431a-9172-5d3e0d663dbc`, `adapter_name=officialaccount-omni`, `chat_type=private`, `chat_id=ovH9L7OW6hNpWZWvp_NMmypVh26w`, `message_chain=[Source, Plain]`. - Common safe APIs through probe platform sweep: `get_message`, `get_user_info`, `get_friend_list`. - Platform APIs through `call_platform_api`: `get_mode`, `get_cached_response_status`. - `send_message` and outbound component sweep returned explicit `NotSupportedError: send_message:official_account_requires_inbound_webhook_reply`, as expected for this adapter. diff --git a/docs/event-based-agents/adapters/qqofficial.md b/docs/event-based-agents/adapters/qqofficial.md index 66b1c0059..bf1e657a5 100644 --- a/docs/event-based-agents/adapters/qqofficial.md +++ b/docs/event-based-agents/adapters/qqofficial.md @@ -2,7 +2,7 @@ Adapter directory: `src/langbot/pkg/platform/adapters/qqofficial/` -Manifest name: `qqofficial-eba` +Manifest name: `qqofficial-omni` Status: partial migration. The EBA adapter structure, manifest, converters, cache-backed safe APIs, platform API map, unit tests, and direct live probe scaffold are in place. A real QQ Official WebSocket bot on `dev.rockchin.top` received an inbound user message and drove LangBot into the normal pipeline path; the response path was blocked by the test environment model service returning `model_not_found` for `deepseek-v3`. @@ -77,7 +77,7 @@ Status: partial migration. The EBA adapter structure, manifest, converters, cach Test date: 2026-06-02 -Endpoint/simulator: `dev.rockchin.top` with a real QQ Official WebSocket bot (`qqofficial-eba`, bot UUID `80a5560b-52b1-40e7-b7d6-4a2341eb4780`) and LangBot running from `/home/wgc/LangBotxg/LangBotEbaTest`. +Endpoint/simulator: `dev.rockchin.top` with a real QQ Official WebSocket bot (`qqofficial-omni`, bot UUID `80a5560b-52b1-40e7-b7d6-4a2341eb4780`) and LangBot running from `/home/wgc/LangBotxg/LangBotEbaTest`. Observed evidence: diff --git a/docs/event-based-agents/adapters/slack.md b/docs/event-based-agents/adapters/slack.md index 514d2dfa7..1a860a26f 100644 --- a/docs/event-based-agents/adapters/slack.md +++ b/docs/event-based-agents/adapters/slack.md @@ -9,7 +9,7 @@ Slack is migrated into `src/langbot/pkg/platform/adapters/slack/` with the stand - `message_converter.py` maps common `MessageChain` components to Slack text fallback and maps inbound Slack text/image payloads back to EBA components. - `api_impl.py` provides cache-backed common read APIs. - `platform_api.py` declares safe Slack-specific API actions. -- `manifest.yaml` declares `slack-eba`. +- `manifest.yaml` declares `slack-omni`. The legacy `src/langbot/pkg/platform/sources/slack.py` adapter is kept unchanged. @@ -71,7 +71,7 @@ Evidence file: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/slack_eba_plugin_pr Observed: -- Real Slack private text produced `MessageReceived` with `adapter_name=slack-eba`, `Source + Plain`, private chat type, and filled `bot_uuid`. +- Real Slack private text produced `MessageReceived` with `adapter_name=slack-omni`, `Source + Plain`, private chat type, and filled `bot_uuid`. - Safe common APIs passed: `get_message`, `get_user_info`, `get_friend_list`. - Outbound component fallback sweep passed through `send_message`: plain/at/face, image, quote, file, and forward. - Declared Slack platform APIs passed: `get_mode`, `auth_test`. diff --git a/docs/event-based-agents/adapters/telegram.md b/docs/event-based-agents/adapters/telegram.md index 336508425..c8cdc2999 100644 --- a/docs/event-based-agents/adapters/telegram.md +++ b/docs/event-based-agents/adapters/telegram.md @@ -15,7 +15,7 @@ src/langbot/pkg/platform/adapters/telegram/ └── types.py ``` -The adapter is registered as `telegram-eba`. +The adapter is registered as `telegram-omni`. ## Configuration diff --git a/docs/event-based-agents/adapters/wecom.md b/docs/event-based-agents/adapters/wecom.md index c217cbd31..fe7730c45 100644 --- a/docs/event-based-agents/adapters/wecom.md +++ b/docs/event-based-agents/adapters/wecom.md @@ -15,9 +15,9 @@ src/langbot/pkg/platform/adapters/wecom/ └── types.py ``` -The adapter is registered as `wecom-eba`. +The adapter is registered as `wecom-omni`. -This record covers the regular WeCom application-message adapter. WeCom AI Bot (`wecombot-eba`) uses a different protocol flow and is documented separately in `wecombot.md`. WeCom Customer Service (`wecomcs`) remains a separate follow-up migration. +This record covers the regular WeCom application-message adapter. WeCom AI Bot (`wecombot-omni`) uses a different protocol flow and is documented separately in `wecombot.md`. WeCom Customer Service (`wecomcs`) remains a separate follow-up migration. ## Configuration @@ -102,13 +102,13 @@ uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.c Evidence: - JSONL: `data/temp/wecom_eba_plugin_probe.jsonl` -- Bot: `wecom-eba` +- Bot: `wecom-omni` - Client: real WeCom desktop client - Environment: `dev.rockchin.top` test server Observed and verified: -- A real private WeCom user message reached the plugin as `MessageReceived` with `adapter_name=wecom-eba`, common sender/chat fields, and `Source + Plain`. +- A real private WeCom user message reached the plugin as `MessageReceived` with `adapter_name=wecom-omni`, common sender/chat fields, and `Source + Plain`. - SDK API calls succeeded through the standalone runtime, including `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin/workspace storage, and manifest/list APIs. - Safe adapter API checks succeeded through the plugin path for cached message/user data and declared safe platform API actions. diff --git a/docs/event-based-agents/adapters/wecombot.md b/docs/event-based-agents/adapters/wecombot.md index 5eee651ff..23751aec7 100644 --- a/docs/event-based-agents/adapters/wecombot.md +++ b/docs/event-based-agents/adapters/wecombot.md @@ -15,9 +15,9 @@ src/langbot/pkg/platform/adapters/wecombot/ └── types.py ``` -The adapter is registered as `wecombot-eba`. +The adapter is registered as `wecombot-omni`. -This is separate from regular WeCom internal applications (`wecom-eba`). WeComBot supports WebSocket long connection mode, which does not require a webhook URL. Webhook mode remains available when `enable-webhook=true`. +This is separate from regular WeCom internal applications (`wecom-omni`). WeComBot supports WebSocket long connection mode, which does not require a webhook URL. Webhook mode remains available when `enable-webhook=true`. ## Configuration @@ -118,13 +118,13 @@ Evidence: - JSONL: `data/temp/wecombot_eba_plugin_probe.jsonl` - Bot UUID: `9f5d4125-7b6d-4c98-8ca2-111111111111` -- Adapter: `wecombot-eba` +- Adapter: `wecombot-omni` - Client: real WeCom desktop client, private `LangBot` BOT chat - Mode: WebSocket long connection (`enable-webhook=false`) Observed and verified: -- A real user-side message reached the plugin as `MessageReceived` with `adapter_name=wecombot-eba`, common sender/chat fields, and `Source + Plain`. +- A real user-side message reached the plugin as `MessageReceived` with `adapter_name=wecombot-omni`, common sender/chat fields, and `Source + Plain`. - SDK API calls succeeded through the standalone runtime: `get_langbot_version`, `get_bots`, `get_bot_info`, `send_message`, plugin/workspace storage, manifest/list APIs, and safe cached common platform APIs. - Outbound component sweep was visible in the WeCom client and returned `errcode=0`: plain/mention/face fallback, base64 image marker, quote fallback, file marker, and flattened forward fallback. - Declared WeComBot platform APIs succeeded through `plugin.call_platform_api`: `is_websocket_mode`, `get_stream_session_status`, and `send_markdown`. diff --git a/docs/event-based-agents/adapters/wecomcs.md b/docs/event-based-agents/adapters/wecomcs.md index b5549396f..04a42dc53 100644 --- a/docs/event-based-agents/adapters/wecomcs.md +++ b/docs/event-based-agents/adapters/wecomcs.md @@ -15,7 +15,7 @@ src/langbot/pkg/platform/adapters/wecomcs/ └── types.py ``` -The adapter is registered as `wecomcs-eba`. It is separate from regular WeCom application messages (`wecom-eba`) and WeCom AI Bot (`wecombot-eba`). +The adapter is registered as `wecomcs-omni`. It is separate from regular WeCom application messages (`wecom-omni`) and WeCom AI Bot (`wecombot-omni`). ## Configuration @@ -120,7 +120,7 @@ Evidence: - Server JSONL: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl` - Trigger text: `EBA wecomcs dedupe probe 2026-05-27` - `bot_uuid`: `cc810d2c-91f3-4f92-8f27-e1bf9f7b6cb4` -- `adapter_name`: `wecomcs-eba` +- `adapter_name`: `wecomcs-omni` - Observed common event: `MessageReceived`, `event.type=message.received` - Observed message chain: `Source + Plain` - Observed chat: `chat_type=private`, `chat_id=external_userid|open_kfid` diff --git a/docs/review/omni-mainline-sync-2026-09-10.md b/docs/review/omni-mainline-sync-2026-09-10.md new file mode 100644 index 000000000..add380cc8 --- /dev/null +++ b/docs/review/omni-mainline-sync-2026-09-10.md @@ -0,0 +1,63 @@ +# Legacy and Omni adapter mainline audit + +Audit date: 2026-09-10. + +- Destination: `dev/4.11.x`, starting at `5ba25c39af85844283f7049beddc54ea71afea76`. +- Upstream: refreshed `origin/master`, `ce6b647fe7e620031c3dd9d16a8e8c08ba13b4b1`. +- Scope: adapter changes since the first Omni fork in March 2026, including shared platform clients, configuration manifests, and shutdown/resource handling. +- Existing local changes, including the Omni identifier rename, are preserved. + +The two implementations do not inherit behavior automatically. A commit in `sources/` does not update `adapters/`. The audit compares the current legacy source with master, then checks its actual equivalent in the Omni converter, transport, client, configuration, and lifecycle code. Native interaction handling continues to use `interaction.request` and typed callbacks; old Dify runner-private state is not copied into the new event architecture. + +## Platforms with an Omni adapter + +| Platform | Relevant mainline work | Legacy result | Omni result | +| --- | --- | --- | --- | +| OneBot / aiocqhttp | JSON cards (`c75890874`, `0963fd544`), base64 (`ccc51522c`), group metadata (`cc7a13158`), listener lifecycle (`2c3e52c16`), bounded lookups (`e1ac5e0fc`), original image URLs (`463b12092`) | Already present | Added JSON-card parsing and normalization for image, voice, and file payloads. Reused the cached, timeout-bounded group/member lookup per adapter instance. Preserved native handler registration, typed events, and original image URLs. | +| Telegram | Streaming (`0755beebc`), bounded media/state (`e1ac5e0fc`), token-bearing image URLs (`9df021eb8`) | Already present | Shares the legacy message converter; ported persistent-message streaming, throttling/size fallback, bounded state, and shutdown. Token-bearing download URLs remain absent from the public Image component. | +| Discord | Streaming (`0755beebc`), media/resource limits (`e1ac5e0fc`), original image URLs (`463b12092`) | Already present | Added snapshot-based send/edit streaming with bounded state. Enforced the mainline media limit on Omni image, voice, and file loading. Incoming public attachment URLs are preserved. | +| Feishu / Lark | Files (`e06fac2bb`), proactive sends (`3680a8024`), service domain (`c7cb42bd7`), tables (`1d15798e5`), nonblocking connection discovery (`48952206d`), resource bounds (`e1ac5e0fc`), final duplicate text (`5ca30133a`), feedback association (`f8010a20e`) | Applied final-text fix; retained the newer SDK cache-task cleanup | Added identical domestic/international/custom domain fields and applied the selected domain to both HTTP and WebSocket clients. Reused nonblocking connection discovery and bounded upload helpers; bounded incoming downloads and callback tasks. Added table cards and feedback-to-monitoring association. The final card contains one text element and exits streaming mode; it does not use the legacy duplicate-placeholder layout. | +| DingTalk | Voice recognition (`de4d14fee`), files (`e06fac2bb`), interactive cards (`0755beebc`), task/card bounds (`e1ac5e0fc`), automatic layout (`8cf001550`) | Applied layout fix in the shared client | Shared client fix reaches both `create_and_card` and native interaction cards. Retained typed file/voice conversion and native callbacks, bounded normal stream cards, cleared callback/card state on shutdown, and exposed the template download. | +| QQ Official | Optional token (`cb45807b1`), Markdown (`c87548c0b`), complete stream snapshots (`79634772d`), resource bounds (`e1ac5e0fc`), original image URLs (`463b12092`) | Applied optional-token fix | Removed the token requirement in constructor and manifest. Added Markdown configuration and sending. Stream and non-stream fallback replace full snapshots instead of appending deltas. Restored original image URLs, bounded state, and closed the owned client on shutdown. | +| WeCom application | Media ID key (`d942bfe19`), original image URLs (`463b12092`), client lifetime (`e1ac5e0fc`) | Applied the `media_id` send fix | Media dispatch already used `media_id`; restored original image URLs and client shutdown. | +| WeCom AI Bot | WS/files/feedback (`d9378c3a8`, `14b1e0d33`, `c7efa4dd7`, `83ccb33fd`), sandbox media (`e934f08ad`), WS loading (`e69a80f5e`) | Already present | Outbound conversion now retains media items. Replies and final stream chunks use the shared upload/reply implementation instead of `[Image]` / `[File]` placeholders. Existing native inbound file/voice/quote handling, feedback, and shared WS fixes remain active. | +| WeCom customer service | Proactive text (`13dba887d`), `open_kfid` and unique outbound `msgid` (`cabde423a`), client limits (`e1ac5e0fc`) | Already present | Existing target parsing and generated outbound message IDs already match. Added owned-client shutdown and cache clearing; removed a shadowed obsolete image-send definition, retaining the bounded active implementation. | +| Slack | Original image URLs (`463b12092`), shared HTTP session (`e1ac5e0fc`) | Already present | Restored original image URLs alongside downloaded data. Existing shared-client behavior remains active; bounded retained typed-event caches. | +| KOOK | Bounded gateway decompression, HTTP responses, and shared sessions (`e1ac5e0fc`) | Already present | Reuses the bounded gateway decoder off the event loop and limited JSON response reader. Existing URL-based image conversion does not eagerly download media. | +| WeChat Official Account | Resource/state cleanup (`e1ac5e0fc`) and outbound IP display (`bca710dbd`) | Already present | Calls the shared client's cleanup on shutdown, bounds typed-event caches, and exposes the outbound IP configuration field. | + +All 12 Omni manifests include the corresponding legacy configuration field names and current deployment help links. The Feishu/Lark `domain` and `custom_domain` fields are structurally identical, including labels, descriptions, options, defaults, and conditional display. Display names are unchanged. + +## Platforms with no separate Omni implementation + +| Platform | Result | +| --- | --- | +| Matrix | Applied `fc1c99843` to fix the unbound logout command during re-login. Existing connection/media/resource fixes already match master. | +| Mattermost | Added the complete mainline adapter, manifest, icon, and regression tests from `d6443b10b`. | +| LINE | Stable source-based sessions (`777fe1f20`), mention conversion (`855ae2bdb`), and resource limits already match master. | +| OpenClaw Weixin | Mainline adapter, outbound IP display, and resource/lifecycle changes already present. | +| WeChatPad | Mainline media/download and resource/lifecycle changes already present. | +| Satori | Mainline lifecycle/resource changes already present. | +| HTTP Bot | Mainline standalone HTTP integration and workspace/resource changes already present. | +| WebPage Bot | Delegated stream helpers and embedded-session behavior already match master. | +| WebSocket / browser debug | Mainline session isolation, scoped history, resource bounds, and routing work already present. Retained 4.11's image-reference retention and pipeline metadata additions. | +| Archived Gewechat, Nakuru, QQBotPy sources | Mainline source changes already present; no separate Omni counterpart exists. | + +Documentation-link updates `1cfe87186` and `ec63978ec` were also applied to legacy manifests and matching Omni manifests. + +## Intentional architectural differences + +- New typed events and platform APIs remain in the Omni modules. Legacy Pipeline message/context conversion stays in the established compatibility path. +- Native interaction delivery uses the generic interaction request/callback protocol. The current 4.11 pipeline/provider code no longer emits `_form_data` or `_resume_from_form`; copying old Dify callback internals would reconnect the wrong execution model. +- This audit establishes parity for the mainline changes in scope, not a claim that every pre-fork legacy-only extension has a typed Omni API. For example, Discord voice-channel management still belongs to the legacy source; sending voice attachments and the post-fork streaming/resource fixes are covered here. +- Shared platform clients retain 4.11-specific return values and additional APIs instead of being overwritten with entire master files. + +## Verification + +- Full backend unit suite: **3751 passed, 1 skipped**. This includes platform transport tests, Pipeline/event routing, service validation, plugin actions, workspace isolation, and resource handling. +- Dedicated mainline-parity regression suite: **62 passed**. Coverage for configuration coverage, OneBot JSON/base64/metadata, QQ Markdown/snapshots, WeCom media replies, bounded media/gateway reads, lifecycle cleanup, streaming, and Feishu/Lark region selection. +- Ruff formatting and checks pass on the affected Python files. +- Restarted the local backend on port 5399; `/healthz` returns success and reports the plugin runtime connected. All 12 Omni icon endpoints and the new Mattermost icon return HTTP 200. +- Vendor requests in automated tests are mocked. Real delivery to every external platform has not been tested. The instance contains pre-existing incomplete Feishu and WeCom AI Bot configurations; the same missing-credential errors were present before this audit. + +Tests: `tests/unit_tests/platform/test_omni_mainline_parity.py`, the platform-specific test files, and the imported Mattermost/WeCom/Lark/DingTalk regressions. diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 314e8c9f2..c40b02b56 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -121,7 +121,7 @@ a component it supports no events. Discover installed components with `get_processor_metadata`, then use `update_processor` with `component_ref` and optional `parameters`. API callers may also supply these when creating an instance. Bind bot events to this instance with `target_type: "event_processor"` and `target_id` equal to its UUID. Installation alone never activates a handler. -`debug_agent` accepts the complete typed EBA event in `payload.data` for this kind. +`debug_agent` accepts the complete typed event in `payload.data` for this kind. Legacy EventListener plugins remain in the Pipeline lifecycle. `list_processor_runs` includes `created_at_ms`, `started_at_ms`, and diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index a896c9970..d6a4914ce 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -707,9 +707,10 @@ class DingTalkClient: if not await self.check_access_token(): await self.get_access_token() - cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)} + template_params = dict(card_param_map or {}) if card_data_config is not None: - cardData['config'] = json.dumps(card_data_config) + template_params['config'] = card_data_config + cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)} body: dict = { 'cardTemplateId': card_template_id, diff --git a/src/langbot/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py index be8a754dc..da3caa9fd 100644 --- a/src/langbot/libs/wecom_customer_service_api/api.py +++ b/src/langbot/libs/wecom_customer_service_api/api.py @@ -295,32 +295,6 @@ class WecomCSClient: raise Exception(f'Failed to send message: {data}') return data - async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str): - if not await self.check_access_token(): - self.access_token = await self.get_access_token(self.secret) - - url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}' - payload = { - 'touser': external_userid, - 'open_kfid': open_kfid, - 'msgid': msgid, - 'msgtype': 'image', - 'image': { - 'media_id': media_id, - }, - } - - async with httpx.AsyncClient() as client: - response = await client.post(url, json=payload) - data = response.json() - if data['errcode'] == 40014 or data['errcode'] == 42001: - self.access_token = await self.get_access_token(self.secret) - return await self.send_image_msg(open_kfid, external_userid, msgid, media_id) - if data['errcode'] != 0: - await self.logger.error(f'发送图片消息失败:{data}') - raise Exception('Failed to send image message') - return data - @_bounded_token_retry async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str): if not await self.check_access_token(): diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index 0591fea9c..d423faf19 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -502,7 +502,7 @@ class AgentService: raise ValueError('EventProcessor component is unavailable') from exc raise if descriptor.component_kind != 'EventProcessor' or not descriptor.supported_event_patterns: - raise ValueError('The component does not declare supported EBA events') + raise ValueError('The component does not declare supported events') config['runner'] = {'id': component_ref} parameters = data.get('parameters') if parameters is None: diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 82199904d..05f4a35ca 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -14,6 +14,7 @@ from ....workspace.errors import WorkspaceNotFoundError from .tenant import TenantContext, require_workspace_uuid, scope_statement from ....utils import httpclient from ....platform.sources import http_bot_signing +from ....platform.adapter_names import canonical_adapter_name class BotService: @@ -41,6 +42,7 @@ class BotService: def _get_adapter_component(self, adapter_name: str) -> engine.Component | None: """Return the discovered platform adapter component for an adapter name.""" + adapter_name = canonical_adapter_name(adapter_name) for component in self.ap.discover.get_components_by_kind('MessagePlatformAdapter'): if component.metadata.name == adapter_name: return component @@ -555,6 +557,8 @@ class BotService: update_data.pop('uuid', None) update_data = {key: value for key, value in update_data.items() if key in self.BOT_FIELDS} + if 'adapter' in update_data: + update_data['adapter'] = canonical_adapter_name(update_data['adapter']) if 'event_bindings' in update_data: update_data['event_bindings'] = await self._normalize_event_bindings( context, update_data.get('event_bindings') @@ -573,7 +577,10 @@ class BotService: if not include_secret: masked_columns = ['adapter_config'] - return [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) for bot in bots] + serialized = [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) for bot in bots] + for bot in serialized: + bot['adapter'] = canonical_adapter_name(bot['adapter']) + return serialized async def get_bot(self, context: TenantContext, bot_uuid: str, include_secret: bool = False) -> dict | None: """获取机器人""" @@ -594,7 +601,9 @@ class BotService: if not include_secret: masked_columns = ['adapter_config'] - return self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) + serialized = self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) + serialized['adapter'] = canonical_adapter_name(serialized['adapter']) + return serialized async def get_runtime_bot_info( self, diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 06765db6e..3bbcf4b12 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -244,7 +244,7 @@ class LangBotMCPServer: 'Run a synthetic event against an Agent or Event processor without platform delivery. ' 'Returns final text and execution_events containing reported messages/thinking and tool calls. ' 'Platform tools use mock adapters; other tools execute normally. ' - 'For Event processors, data contains the complete typed EBA event fields. ' + 'For Event processors, data contains the complete typed event fields. ' 'Requires runtime.operate; payload accepts event_type, text, data, conversation_id, actor, subject and ' 'mock (errors/results keyed by platform tool name; unsupported_apis lists unavailable platform APIs).' ) diff --git a/src/langbot/pkg/platform/adapter_names.py b/src/langbot/pkg/platform/adapter_names.py new file mode 100644 index 000000000..448182af6 --- /dev/null +++ b/src/langbot/pkg/platform/adapter_names.py @@ -0,0 +1,25 @@ +"""Canonical names for built-in Omni adapters and saved pre-release configs.""" + +OMNI_ADAPTER_NAMES = frozenset( + { + 'aiocqhttp', + 'telegram', + 'discord', + 'qqofficial', + 'lark', + 'dingtalk', + 'slack', + 'kook', + 'wecom', + 'wecombot', + 'wecomcs', + 'officialaccount', + } +) + + +def canonical_adapter_name(name: str) -> str: + """Accept previous built-in IDs without renaming legacy or custom adapters.""" + if name.endswith('-eba') and name[:-4] in OMNI_ADAPTER_NAMES: + return f'{name[:-4]}-omni' + return name diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py b/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py index 7344c4b4b..7d97cac81 100644 --- a/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py @@ -5,6 +5,8 @@ import traceback import typing import aiocqhttp + +from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpEventConverter as LegacyAiocqhttpEventConverter import pydantic import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter @@ -23,6 +25,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter() + _lookup: typing.Any = pydantic.PrivateAttr(default_factory=LegacyAiocqhttpEventConverter) config: dict listeners: dict[ typing.Type[platform_events.Event], @@ -121,6 +124,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat await self.bot._server_app.run_task(**self.config) async def kill(self) -> bool: + self._lookup.clear() return False def _register_native_handlers(self): @@ -150,7 +154,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat if getattr(event, 'type', None) == 'message' and ( platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners ): - legacy_event = await self.event_converter.target2legacy(event, self.bot) + legacy_event = await self.event_converter.target2legacy(event, self.bot, self._lookup) if legacy_event: callback = self.listeners.get(type(legacy_event)) if callback: @@ -160,7 +164,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat await self.logger.error(f'Error in aiocqhttp native event: {traceback.format_exc()}') async def _dispatch_native_event(self, event: aiocqhttp.Event): - eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id) + eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id, self._lookup) if eba_event: await self._dispatch_eba_event(eba_event) diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py b/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py index 752dd04dc..d2c950c5f 100644 --- a/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py @@ -4,6 +4,11 @@ import typing import aiocqhttp +from langbot.pkg.platform.sources.aiocqhttp import ( + AiocqhttpEventConverter as LegacyAiocqhttpEventConverter, + _get_group_name_placeholder, +) + import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter from langbot_plugin.api.entities.builtin.platform import entities as platform_entities @@ -20,10 +25,11 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): event: aiocqhttp.Event, bot: aiocqhttp.CQHttp | None = None, bot_user_id: int | str | None = None, + lookup: LegacyAiocqhttpEventConverter | None = None, ) -> platform_events.Event | None: event_type = getattr(event, 'type', None) if event_type == 'message': - return await AiocqhttpEventConverter.message_to_eba(event, bot) + return await AiocqhttpEventConverter.message_to_eba(event, bot, lookup) if event_type == 'notice': return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id) if event_type == 'request': @@ -36,8 +42,9 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): async def target2legacy( event: aiocqhttp.Event, bot: aiocqhttp.CQHttp | None = None, + lookup: LegacyAiocqhttpEventConverter | None = None, ) -> platform_events.FriendMessage | platform_events.GroupMessage | None: - eba_event = await AiocqhttpEventConverter.message_to_eba(event, bot) + eba_event = await AiocqhttpEventConverter.message_to_eba(event, bot, lookup) if eba_event: return eba_event.to_legacy_event() return None @@ -46,6 +53,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): async def message_to_eba( event: aiocqhttp.Event, bot: aiocqhttp.CQHttp | None = None, + lookup: LegacyAiocqhttpEventConverter | None = None, ) -> platform_events.MessageReceivedEvent: message_chain = await AiocqhttpMessageConverter.target2yiri( getattr(event, 'message', []), @@ -64,6 +72,12 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): sender = AiocqhttpEventConverter.user_from_sender(event) sender_data = getattr(event, 'sender', {}) or {} + if group is not None: + lookup = lookup if lookup is not None else LegacyAiocqhttpEventConverter() + group.name = await lookup._get_group_name(group.id, bot) or _get_group_name_placeholder(group.id) + if not sender_data.get('title'): + info = await lookup._get_group_member_info(group.id, sender.id, bot) + sender_data = {**sender_data, 'title': info.get('title', '')} role = sender_data.get('role', 'member') membership = None if group is not None: @@ -76,7 +90,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): ) return platform_events.MessageReceivedEvent( type='message.received', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', message_id=getattr(event, 'message_id', ''), message_chain=message_chain, sender=sender, @@ -97,7 +111,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if notice_type in ('group_recall', 'friend_recall'): return platform_events.MessageDeletedEvent( type='message.deleted', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', message_id=getattr(event, 'message_id', ''), operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)), chat_type=platform_entities.ChatType.GROUP @@ -115,7 +129,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): return platform_events.BotInvitedToGroupEvent( type='bot.invited_to_group', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', group=group, inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None, timestamp=float(getattr(event, 'time', 0) or 0), @@ -123,7 +137,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): ) return platform_events.MemberJoinedEvent( type='group.member_joined', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', group=group, member=user, inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None, @@ -137,7 +151,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): return platform_events.BotRemovedFromGroupEvent( type='bot.removed_from_group', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', group=group, operator=operator, timestamp=float(getattr(event, 'time', 0) or 0), @@ -145,7 +159,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): ) return platform_events.MemberLeftEvent( type='group.member_left', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', group=group, member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), is_kicked=getattr(event, 'sub_type', '') in ('kick', 'kick_me'), @@ -161,7 +175,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): event_cls = platform_events.BotMutedEvent if duration > 0 else platform_events.BotUnmutedEvent kwargs: dict[str, typing.Any] = { 'type': 'bot.muted' if duration > 0 else 'bot.unmuted', - 'adapter_name': 'aiocqhttp', + 'adapter_name': 'aiocqhttp-omni', 'group': group, 'operator': operator, 'timestamp': float(getattr(event, 'time', 0) or 0), @@ -173,7 +187,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if duration > 0: return platform_events.MemberBannedEvent( type='group.member_banned', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', group=group, member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), operator=operator, @@ -184,7 +198,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if notice_type == 'friend_add': return platform_events.FriendAddedEvent( type='friend.added', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), timestamp=float(getattr(event, 'time', 0) or 0), source_platform_object=event, @@ -197,7 +211,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if request_type == 'friend': return platform_events.FriendRequestReceivedEvent( type='friend.request_received', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', request_id=getattr(event, 'flag', ''), user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), message=getattr(event, 'comment', None), @@ -207,7 +221,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if request_type == 'group' and getattr(event, 'sub_type', '') == 'invite': return platform_events.BotInvitedToGroupEvent( type='bot.invited_to_group', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', group=AiocqhttpEventConverter.group_from_event(event), inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), request_id=getattr(event, 'flag', ''), @@ -244,7 +258,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): def platform_specific(event: aiocqhttp.Event, action: str) -> platform_events.PlatformSpecificEvent: return platform_events.PlatformSpecificEvent( type='platform.specific', - adapter_name='aiocqhttp', + adapter_name='aiocqhttp-omni', action=action, data={key: value for key, value in dict(event).items() if key not in {'message'}}, timestamp=float(getattr(event, 'time', 0) or 0), diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml b/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml index e31733c90..bb0f4c8d5 100644 --- a/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: aiocqhttp-eba + name: aiocqhttp-omni label: en_US: OneBot v11 zh_Hans: OneBot v11 @@ -17,9 +17,9 @@ spec: categories: - protocol help_links: - zh: https://link.langbot.app/zh/platforms/aiocqhttp - en: https://link.langbot.app/en/platforms/aiocqhttp - ja: https://link.langbot.app/ja/platforms/aiocqhttp + zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat + en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat + ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat config: - name: host label: diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/message_converter.py b/src/langbot/pkg/platform/adapters/aiocqhttp/message_converter.py index 0682d5cec..a8e2fe2a2 100644 --- a/src/langbot/pkg/platform/adapters/aiocqhttp/message_converter.py +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/message_converter.py @@ -1,6 +1,9 @@ from __future__ import annotations import datetime +import json + +from langbot.pkg.platform.sources.aiocqhttp import _normalize_base64_payload import typing import aiocqhttp @@ -100,7 +103,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert if file_arg: target.append(aiocqhttp.MessageSegment.record(file_arg)) elif isinstance(component, platform_message.File): - file_arg = component.url or component.path or component.base64 or component.id + file_arg = AiocqhttpMessageConverter._file_arg(component) or component.id target.append( aiocqhttp.MessageSegment( type_='file', @@ -210,6 +213,30 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert face_name='骰子', ) ) + elif segment.type == 'json': + try: + raw = segment.data.get('data', {}) + if isinstance(raw, str): + raw = json.loads(raw) + if isinstance(raw, dict): + _meta = raw.get('meta', {}) or {} + if isinstance(_meta, dict): + _detail = _meta.get('detail_1') or _meta.get('music') or _meta.get('news') or {} + else: + _detail = {} + if isinstance(_detail, dict): + preview = _detail.get('preview', '') + title = _detail.get('desc', '') or _detail.get('title', '') + url = _detail.get('qqdocurl', '') or _detail.get('jumpUrl', '') + else: + preview = title = url = '' + text = ' '.join([f'[{raw.get("app", "")}]', preview, title, url]).strip() + components.append(platform_message.Plain(text=text or '[收到一张JSON卡片]')) + else: + components.append(platform_message.Plain(text=str(raw))) + except Exception: + components.append(platform_message.Plain(text='[收到一张JSON卡片]')) + else: components.append(platform_message.Unknown(text=f'{segment.type}:{segment.data}')) @@ -218,8 +245,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert @staticmethod def _file_arg(component: platform_message.Image | platform_message.Voice) -> str: if component.base64: - _, _, payload = component.base64.partition(',') - return f'base64://{payload or component.base64}' + return f'base64://{_normalize_base64_payload(component.base64)}' if component.url: return component.url if component.path: diff --git a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py index 5bc33919a..230959719 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py @@ -189,6 +189,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo self.card_instance_id_dict.pop(message_id) async def create_message_card(self, message_id, event): + while len(self.card_instance_id_dict) >= 1000: + self.card_instance_id_dict.pop(next(iter(self.card_instance_id_dict)), None) card_template_id = self.config['card_template_id'] incoming_message = event.source_platform_object.incoming_message card_auto_layout = self.config.get('card_auto_layout', False) @@ -232,11 +234,16 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo self.listeners.pop(event_type, None) async def run_async(self): - await self.logger.info('DingTalk EBA adapter starting') + await self.logger.info('DingTalk Omni adapter starting') await self.bot.start() async def kill(self) -> bool: + self.card_instance_id_dict.clear() + self.interaction_callback_contexts.clear() await self.bot.stop() + self._message_cache.clear() + self._user_cache.clear() + self._group_cache.clear() return True async def is_muted(self, group_id: int | None = None) -> bool: @@ -260,7 +267,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo await self._dispatch_eba_event(interaction_event) return await self.logger.debug( - 'DingTalk EBA event received: ' + 'DingTalk event received: ' f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}' ) if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: @@ -291,3 +298,10 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo self._user_cache[str(event.sender.id)] = event.sender if event.group: self._group_cache[str(event.group.id)] = event.group + for cache in ( + self._message_cache, + self._user_cache, + self._group_cache, + ): + while len(cache) > 4096: + cache.pop(next(iter(cache)), None) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py index 2e513a8a0..db9185292 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py @@ -332,7 +332,7 @@ def interaction_event_from_native( raise ValueError('DingTalk interaction callback has no delivery space') return platform_events.PlatformSpecificEvent( type='platform.specific', - adapter_name='dingtalk-eba', + adapter_name='dingtalk-omni', action='interaction.submitted', data={ **parsed, diff --git a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml index c4d60c15f..6485b846f 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: dingtalk-eba + name: dingtalk-omni label: en_US: DingTalk zh_Hans: 钉钉 @@ -17,10 +17,46 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/dingtalk - en: https://link.langbot.app/en/platforms/dingtalk - ja: https://link.langbot.app/ja/platforms/dingtalk + zh: https://langbot.app/docs/zh/usage/platforms/dingtalk + en: https://langbot.app/docs/en/usage/platforms/dingtalk + ja: https://langbot.app/docs/ja/usage/platforms/dingtalk config: + - name: one-click-create + label: + en_US: One-Click Create App + zh_Hans: 一键创建应用 + zh_Hant: 一鍵建立應用 + description: + en_US: 'Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be + obtained automatically, you need to copy it from the DingTalk Developer Backend manually.' + zh_Hans: 使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。 + zh_Hant: 使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。 + type: qr-code-login + login_platform: dingtalk + required: false + - name: human_input_card_template_download + label: + en_US: Download Human Input Card Template + zh_Hans: 下载人工输入卡片模板 + zh_Hant: 下載人工輸入卡片範本 + description: + en_US: Download the template, import it in DingTalk Card Platform, then enter its template ID below to enable native interactions. + zh_Hans: 下载模板并导入钉钉卡片平台,再将模板 ID 填入下方以启用原生交互。 + zh_Hant: 下載範本並匯入釘釘卡片平台,再將範本 ID 填入下方以啟用原生互動。 + type: download-link + required: false + default: '' + url: /api/v1/platform/adapters/dingtalk/human-input-card-template + download_filename: dingtalk_human_input_card.json + help_links: + zh: https://open-dev.dingtalk.com/fe/card + en: https://open-dev.dingtalk.com/fe/card + ja: https://open-dev.dingtalk.com/fe/card + help_label: + en_US: Import Guide + zh_Hans: 导入指引 + zh_Hant: 匯入指引 + ja_JP: インポート手順 - name: client_id label: en_US: Client ID diff --git a/src/langbot/pkg/platform/adapters/dingtalk/types.py b/src/langbot/pkg/platform/adapters/dingtalk/types.py index c12b72acc..264a85cd0 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/types.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/types.py @@ -1,3 +1,3 @@ from __future__ import annotations -ADAPTER_NAME = 'dingtalk-eba' +ADAPTER_NAME = 'dingtalk-omni' diff --git a/src/langbot/pkg/platform/adapters/discord/adapter.py b/src/langbot/pkg/platform/adapters/discord/adapter.py index 6aaf97164..8ace25ba7 100644 --- a/src/langbot/pkg/platform/adapters/discord/adapter.py +++ b/src/langbot/pkg/platform/adapters/discord/adapter.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import time import traceback import typing @@ -29,6 +30,8 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform message_converter: DiscordMessageConverter = DiscordMessageConverter() event_converter: DiscordEventConverter = DiscordEventConverter() + _stream_buffer: dict = pydantic.PrivateAttr(default_factory=dict) + _STREAM_EDIT_INTERVAL: typing.ClassVar[int] = 8 config: dict listeners: dict[ typing.Type[platform_events.Event], @@ -276,9 +279,107 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform raise NotSupportedError(f'call_platform_api:{action}') return await handler(self.bot, params) + def _prune_streams(self): + now = time.time() + for key, state in tuple(self._stream_buffer.items()): + if now - state['updated_at'] > 1800: + self._stream_buffer.pop(key, None) + while len(self._stream_buffer) >= 100: + self._stream_buffer.pop(next(iter(self._stream_buffer)), None) + + async def is_stream_output_supported(self) -> bool: + return True + + async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool: + """Set up a stream context for progressive editing. + + The first non-empty reply_message_chunk will send the initial + message; subsequent chunks edit it in place. + """ + source = event.source_platform_object + if not isinstance(source, discord.Message): + return False + self._prune_streams() + self._stream_buffer[message_id] = { + 'channel': source.channel, + 'sent_message': None, # discord.Message set on first send + 'last_content': '', + 'chunk_count': 0, + 'updated_at': time.time(), + } + return True + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message: typing.Any, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + msg_id = ( + bot_message.get('resp_message_id') + if isinstance(bot_message, dict) + else getattr(bot_message, 'resp_message_id', None) + ) + + text_parts = [m.text for m in message if isinstance(m, platform_message.Plain)] + chunk_text = '\n\n'.join(t for t in text_parts if t) + + ctx = self._stream_buffer.get(msg_id) if msg_id else None + if ctx is not None: + ctx['updated_at'] = time.time() + + if ctx is None: + try: + if is_final and chunk_text: + await self.reply_message(message_source, message, quote_origin) + finally: + self._stream_buffer.pop(msg_id, None) + return + + # Progressive streaming path: send first chunk, edit subsequent. + ctx['chunk_count'] += 1 + + # Runner yields the full accumulated text on each chunk, so we + # always replace (not append). + if chunk_text: + ctx['last_content'] = chunk_text[:2000] + + sent = ctx['sent_message'] + + if sent is None: + # First non-empty chunk — send the initial message. + if not ctx['last_content']: + return # No content yet, wait for next chunk. + try: + sent = await ctx['channel'].send(ctx['last_content']) + ctx['sent_message'] = sent + except Exception: + await self.logger.error(f'Discord stream send failed: {traceback.format_exc()}') + self._stream_buffer.pop(msg_id, None) + return + + if is_final: + # Final chunk — edit to the full content, then clean up. + if ctx['last_content'] and ctx['last_content'] != sent.content: + try: + await sent.edit(content=ctx['last_content'][:2000]) + except Exception: + pass # Best-effort + self._stream_buffer.pop(msg_id, None) + elif (ctx['chunk_count'] % self._STREAM_EDIT_INTERVAL) == 0: + # Intermediate edit — throttle to avoid rate limits. + if ctx['last_content'] and ctx['last_content'] != sent.content: + try: + await sent.edit(content=ctx['last_content'][:2000]) + except Exception: + pass # Rate-limited or deleted — ignore. + async def run_async(self): await self.bot.start(self.config['token'], reconnect=True) async def kill(self) -> bool: + self._stream_buffer.clear() await self.bot.close() return True diff --git a/src/langbot/pkg/platform/adapters/discord/api_impl.py b/src/langbot/pkg/platform/adapters/discord/api_impl.py index 7a308ec9e..767ae9a27 100644 --- a/src/langbot/pkg/platform/adapters/discord/api_impl.py +++ b/src/langbot/pkg/platform/adapters/discord/api_impl.py @@ -100,7 +100,7 @@ class DiscordAPIMixin: until = None if duration > 0: until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=duration) - await member.timeout(until, reason='LangBot EBA mute_member') + await member.timeout(until, reason='LangBot Omni mute_member') async def unmute_member( self, @@ -109,7 +109,7 @@ class DiscordAPIMixin: ) -> None: guild = await self._get_guild(group_id) member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id)) - await member.timeout(None, reason='LangBot EBA unmute_member') + await member.timeout(None, reason='LangBot Omni unmute_member') async def kick_member( self, @@ -118,7 +118,7 @@ class DiscordAPIMixin: ) -> None: guild = await self._get_guild(group_id) member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id)) - await member.kick(reason='LangBot EBA kick_member') + await member.kick(reason='LangBot Omni kick_member') async def leave_group(self, group_id: typing.Union[int, str]) -> None: guild = await self._get_guild(group_id) diff --git a/src/langbot/pkg/platform/adapters/discord/event_converter.py b/src/langbot/pkg/platform/adapters/discord/event_converter.py index 045488a4e..d34839725 100644 --- a/src/langbot/pkg/platform/adapters/discord/event_converter.py +++ b/src/langbot/pkg/platform/adapters/discord/event_converter.py @@ -54,7 +54,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): group = DiscordEventConverter.group_from_message(message) return platform_events.MessageReceivedEvent( type='message.received', - adapter_name='discord', + adapter_name='discord-omni', message_id=message.id, message_chain=message_chain, sender=DiscordEventConverter.user_from_author(message.author), @@ -73,7 +73,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): ) -> platform_events.MessageEditedEvent: return platform_events.MessageEditedEvent( type='message.edited', - adapter_name='discord', + adapter_name='discord-omni', message_id=after.id, new_content=await DiscordMessageConverter.target2yiri(after), editor=DiscordEventConverter.user_from_author(after.author), @@ -90,7 +90,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): async def message_delete_to_eba(message: discord.Message) -> platform_events.MessageDeletedEvent: return platform_events.MessageDeletedEvent( type='message.deleted', - adapter_name='discord', + adapter_name='discord-omni', message_id=message.id, operator=None, chat_type=platform_entities.ChatType.PRIVATE @@ -106,7 +106,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): def raw_message_delete_to_eba(payload: discord.RawMessageDeleteEvent) -> platform_events.MessageDeletedEvent: return platform_events.MessageDeletedEvent( type='message.deleted', - adapter_name='discord', + adapter_name='discord-omni', message_id=payload.message_id, operator=None, chat_type=platform_entities.ChatType.PRIVATE @@ -126,7 +126,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): message = reaction.message return platform_events.MessageReactionEvent( type='message.reaction', - adapter_name='discord', + adapter_name='discord-omni', message_id=message.id, user=DiscordEventConverter.user_from_author(user), reaction=str(reaction.emoji), @@ -152,7 +152,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): user = DiscordEventConverter.user_from_author(user) return platform_events.MessageReactionEvent( type='message.reaction', - adapter_name='discord', + adapter_name='discord-omni', message_id=payload.message_id, user=user, reaction=str(payload.emoji), @@ -175,7 +175,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): if bot_user_id is not None and member.id == bot_user_id: return platform_events.BotInvitedToGroupEvent( type='bot.invited_to_group', - adapter_name='discord', + adapter_name='discord-omni', group=group, inviter=None, timestamp=member.joined_at.timestamp() if member.joined_at else 0.0, @@ -183,7 +183,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): ) return platform_events.MemberJoinedEvent( type='group.member_joined', - adapter_name='discord', + adapter_name='discord-omni', group=group, member=user, inviter=None, @@ -202,14 +202,14 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): if bot_user_id is not None and member.id == bot_user_id: return platform_events.BotRemovedFromGroupEvent( type='bot.removed_from_group', - adapter_name='discord', + adapter_name='discord-omni', group=group, operator=None, source_platform_object=member, ) return platform_events.MemberLeftEvent( type='group.member_left', - adapter_name='discord', + adapter_name='discord-omni', group=group, member=user, is_kicked=False, @@ -221,7 +221,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): def guild_join_to_eba(guild: discord.Guild) -> platform_events.BotInvitedToGroupEvent: return platform_events.BotInvitedToGroupEvent( type='bot.invited_to_group', - adapter_name='discord', + adapter_name='discord-omni', group=DiscordEventConverter.group_from_guild(guild), inviter=None, source_platform_object=guild, @@ -231,7 +231,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): def guild_remove_to_eba(guild: discord.Guild) -> platform_events.BotRemovedFromGroupEvent: return platform_events.BotRemovedFromGroupEvent( type='bot.removed_from_group', - adapter_name='discord', + adapter_name='discord-omni', group=DiscordEventConverter.group_from_guild(guild), operator=None, source_platform_object=guild, diff --git a/src/langbot/pkg/platform/adapters/discord/interaction.py b/src/langbot/pkg/platform/adapters/discord/interaction.py index 91831599d..a66e29808 100644 --- a/src/langbot/pkg/platform/adapters/discord/interaction.py +++ b/src/langbot/pkg/platform/adapters/discord/interaction.py @@ -118,7 +118,7 @@ def interaction_event_from_component( raise ValueError('Discord interaction has no actor or channel') return platform_events.PlatformSpecificEvent( type='platform.specific', - adapter_name='discord', + adapter_name='discord-omni', action='interaction.submitted', data={ **parsed, diff --git a/src/langbot/pkg/platform/adapters/discord/manifest.yaml b/src/langbot/pkg/platform/adapters/discord/manifest.yaml index e323b9ebd..0ddd1c02a 100644 --- a/src/langbot/pkg/platform/adapters/discord/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/discord/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: discord-eba + name: discord-omni label: en_US: Discord zh_Hans: Discord @@ -15,6 +15,10 @@ spec: categories: - popular - global + help_links: + zh: https://langbot.app/docs/zh/usage/platforms/discord + en: https://langbot.app/docs/en/usage/platforms/discord + ja: https://langbot.app/docs/ja/usage/platforms/discord config: - name: client_id label: diff --git a/src/langbot/pkg/platform/adapters/discord/message_converter.py b/src/langbot/pkg/platform/adapters/discord/message_converter.py index d62a56788..82bb5aa6c 100644 --- a/src/langbot/pkg/platform/adapters/discord/message_converter.py +++ b/src/langbot/pkg/platform/adapters/discord/message_converter.py @@ -1,6 +1,8 @@ from __future__ import annotations -import base64 +import asyncio + +from langbot.pkg.platform.sources.discord import _MAX_DISCORD_MEDIA_BYTES, _decode_discord_base64_limited import datetime import io import os @@ -13,6 +15,14 @@ from langbot.pkg.utils import httpclient from langbot_plugin.api.entities.builtin.platform import message as platform_message +def _read_file_limited(path: str) -> bytes: + with open(path, 'rb') as stream: + data = stream.read(_MAX_DISCORD_MEDIA_BYTES + 1) + if len(data) > _MAX_DISCORD_MEDIA_BYTES: + raise ValueError('Discord media exceeds the size limit') + return data + + class DiscordMessageConverter: @staticmethod async def yiri2target( @@ -101,7 +111,7 @@ class DiscordMessageConverter: filename = f'{uuid.uuid4()}.gif' elif 'webp' in header: filename = f'{uuid.uuid4()}.webp' - return base64.b64decode(data), filename + return await asyncio.to_thread(_decode_discord_base64_limited, data), filename if element.url: data, content_type = await DiscordMessageConverter._download(element.url) if 'jpeg' in content_type or 'jpg' in content_type: @@ -115,8 +125,7 @@ class DiscordMessageConverter: path = os.path.abspath(element.path.replace('\x00', '')) if not os.path.exists(path): return None, filename - with open(path, 'rb') as fp: - data = fp.read() + data = await asyncio.to_thread(_read_file_limited, path) ext = os.path.splitext(path)[1] if ext: filename = f'{uuid.uuid4()}{ext}' @@ -133,7 +142,7 @@ class DiscordMessageConverter: if ext in header: filename = f'{uuid.uuid4()}.{ext}' break - return base64.b64decode(data), filename + return await asyncio.to_thread(_decode_discord_base64_limited, data), filename if element.url: data, _ = await DiscordMessageConverter._download(element.url) return data, filename @@ -142,17 +151,21 @@ class DiscordMessageConverter: @staticmethod async def _load_file(element: platform_message.File) -> bytes | None: if element.base64: - return base64.b64decode(element.base64.split(',')[-1]) + return await asyncio.to_thread(_decode_discord_base64_limited, element.base64) if element.url: data, _ = await DiscordMessageConverter._download(element.url) return data + if element.path: + return await asyncio.to_thread(_read_file_limited, element.path) return None @staticmethod async def _download(url: str) -> tuple[bytes, str]: session = httpclient.get_session(trust_env=True) async with session.get(url) as response: - return await response.read(), response.headers.get('Content-Type', '') + return await httpclient.read_limited(response, max_bytes=_MAX_DISCORD_MEDIA_BYTES), response.headers.get( + 'Content-Type', '' + ) @staticmethod def _is_image_attachment(attachment: discord.Attachment) -> bool: diff --git a/src/langbot/pkg/platform/adapters/discord/platform_api.py b/src/langbot/pkg/platform/adapters/discord/platform_api.py index 4c39e8f3c..4f889f812 100644 --- a/src/langbot/pkg/platform/adapters/discord/platform_api.py +++ b/src/langbot/pkg/platform/adapters/discord/platform_api.py @@ -37,7 +37,7 @@ async def create_invite(bot: discord.Client, params: dict) -> dict: max_age=params.get('max_age', 0), max_uses=params.get('max_uses', 0), unique=params.get('unique', True), - reason=params.get('reason', 'LangBot EBA create_invite'), + reason=params.get('reason', 'LangBot Omni create_invite'), ) return {'url': invite.url, 'code': invite.code} @@ -45,14 +45,14 @@ async def create_invite(bot: discord.Client, params: dict) -> dict: async def pin_message(bot: discord.Client, params: dict) -> dict: channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) message = await channel.fetch_message(int(params['message_id'])) - await message.pin(reason=params.get('reason', 'LangBot EBA pin_message')) + await message.pin(reason=params.get('reason', 'LangBot Omni pin_message')) return {'ok': True} async def unpin_message(bot: discord.Client, params: dict) -> dict: channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id'])) message = await channel.fetch_message(int(params['message_id'])) - await message.unpin(reason=params.get('reason', 'LangBot EBA unpin_message')) + await message.unpin(reason=params.get('reason', 'LangBot Omni unpin_message')) return {'ok': True} diff --git a/src/langbot/pkg/platform/adapters/discord/voice.py b/src/langbot/pkg/platform/adapters/discord/voice.py index 0cf83e6fd..3b9e9bf32 100644 --- a/src/langbot/pkg/platform/adapters/discord/voice.py +++ b/src/langbot/pkg/platform/adapters/discord/voice.py @@ -1,5 +1,5 @@ from __future__ import annotations # Voice support is still implemented by the legacy Discord source adapter. The -# EBA adapter exposes text, guild, member, moderation, and platform-specific APIs +# Omni adapter exposes text, guild, member, moderation, and platform-specific APIs # first; voice-specific EBA actions will move here when that surface is migrated. diff --git a/src/langbot/pkg/platform/adapters/kook/adapter.py b/src/langbot/pkg/platform/adapters/kook/adapter.py index 00eb3fc8a..b89bcb3f9 100644 --- a/src/langbot/pkg/platform/adapters/kook/adapter.py +++ b/src/langbot/pkg/platform/adapters/kook/adapter.py @@ -1,10 +1,11 @@ from __future__ import annotations +from langbot.pkg.platform.sources.kook import _decode_gateway_message + import asyncio import json import traceback import typing -import zlib import aiohttp import pydantic @@ -129,7 +130,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter): async def run_async(self): self.running = True self.http_session = httpclient.get_session() - await self.logger.info('KOOK EBA adapter starting') + await self.logger.info('KOOK Omni adapter starting') try: bot_info = await self._get_bot_user_info() @@ -154,7 +155,10 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter): pass if self.ws: await self.ws.close() - await self.logger.info('KOOK EBA adapter stopped') + await self.logger.info('KOOK Omni adapter stopped') + self._message_cache.clear() + self._user_cache.clear() + self._group_cache.clear() return True async def is_muted(self, group_id: int | None = None) -> bool: @@ -205,6 +209,13 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter): self._user_cache[str(event.sender.id)] = event.sender if event.group: self._group_cache[str(event.group.id)] = event.group + for cache in ( + self._message_cache, + self._user_cache, + self._group_cache, + ): + while len(cache) > 4096: + cache.pop(next(iter(cache)), None) async def _websocket_loop(self): retry_count = 0 @@ -221,14 +232,14 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter): self.heartbeat_task = asyncio.create_task(self._heartbeat_loop()) hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0) - hello_data = json.loads(self._decode_ws_message(hello_msg)) + hello_data = await asyncio.to_thread(_decode_gateway_message, hello_msg) if hello_data.get('s') != 1: raise Exception(f'Expected KOOK HELLO signal, got {hello_data.get("s")}') await self._handle_hello(hello_data.get('d') or {}) retry_count = 0 async for message in ws: - msg_data = json.loads(self._decode_ws_message(message)) + msg_data = await asyncio.to_thread(_decode_gateway_message, message) signal = msg_data.get('s') if signal == 0: await self._handle_event(msg_data.get('d') or {}, int(msg_data.get('sn') or 0)) @@ -301,7 +312,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter): request_kwargs['data'] = data async with session.request(method, url, **request_kwargs) as response: - payload = await response.json(content_type=None) + payload = await httpclient.read_json_limited(response) if response.status != 200: raise Exception(f'KOOK API HTTP {response.status}: {payload}') if payload.get('code') != 0: @@ -310,9 +321,4 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter): @staticmethod def _decode_ws_message(message) -> str: - if isinstance(message, bytes): - try: - return zlib.decompress(message).decode('utf-8') - except Exception: - return message.decode('utf-8') - return str(message) + return json.dumps(_decode_gateway_message(message)) diff --git a/src/langbot/pkg/platform/adapters/kook/event_converter.py b/src/langbot/pkg/platform/adapters/kook/event_converter.py index 5f29defae..5c7f8f4f2 100644 --- a/src/langbot/pkg/platform/adapters/kook/event_converter.py +++ b/src/langbot/pkg/platform/adapters/kook/event_converter.py @@ -24,7 +24,7 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.PlatformSpecificEvent( type='platform.specific', - adapter_name='kook', + adapter_name='kook-omni', action=str(kook_event.get('type') or 'gateway_event'), data=KookEventConverter._compact_data(kook_event), timestamp=KookEventConverter._timestamp(kook_event), @@ -43,7 +43,7 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.MessageReceivedEvent( type='message.received', - adapter_name='kook', + adapter_name='kook-omni', message_id=str(kook_event.get('msg_id') or ''), message_chain=await KookMessageConverter.target2yiri(kook_event, bot_account_id), sender=author, diff --git a/src/langbot/pkg/platform/adapters/kook/manifest.yaml b/src/langbot/pkg/platform/adapters/kook/manifest.yaml index 79eeebc52..7a1abbe50 100644 --- a/src/langbot/pkg/platform/adapters/kook/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/kook/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: kook-eba + name: kook-omni label: en_US: KOOK zh_Hans: KOOK @@ -20,6 +20,10 @@ metadata: spec: categories: - global + help_links: + zh: https://langbot.app/docs/zh/usage/platforms/kook + en: https://langbot.app/docs/en/usage/platforms/kook + ja: https://langbot.app/docs/ja/usage/platforms/kook config: - name: token label: diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py index cee3838b5..776c0b7b0 100644 --- a/src/langbot/pkg/platform/adapters/lark/adapter.py +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -1,5 +1,11 @@ from __future__ import annotations +from langbot.pkg.platform.sources.lark import ( + LarkAdapter as LegacyLarkAdapter, + NonBlockingLarkWSClient, +) + +import threading import asyncio import base64 import hashlib @@ -106,6 +112,10 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte quart_app: quart.Quart = pydantic.Field(exclude=True) cipher: AESCipher = pydantic.Field(exclude=True) + inbound_event_tasks: set[asyncio.Task] = pydantic.Field(default_factory=set, exclude=True) + threadsafe_event_futures: set[typing.Any] = pydantic.Field(default_factory=set, exclude=True) + threadsafe_event_lock: typing.Any = pydantic.Field(default_factory=threading.Lock, exclude=True) + _MAX_INBOUND_EVENTS: typing.ClassVar[int] = 100 config: dict lark_tenant_key: str = pydantic.Field(exclude=True, default='') app_ticket: str | None = None @@ -143,7 +153,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte api_client = self.build_api_client(config) event_handler = self._build_event_handler() - bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler) + bot = NonBlockingLarkWSClient( + config['app_id'], + config['app_secret'], + event_handler=event_handler, + domain=LegacyLarkAdapter._resolve_domain(config), + ) cipher = AESCipher(config.get('encrypt-key', '')) super().__init__( @@ -216,7 +231,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte return platform_message.MessageChain([platform_message.Plain(text=text)]) def build_api_client(self, config: dict) -> lark_oapi.Client: - builder = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret']) + builder = ( + lark_oapi.Client.builder() + .app_id(config['app_id']) + .app_secret(config['app_secret']) + .domain(LegacyLarkAdapter._resolve_domain(config)) + ) if config.get('app_type', 'self') == 'isv': builder = builder.app_type(lark_oapi.AppType.ISV) return builder.build() @@ -291,6 +311,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte 'token': content['tenant_access_token'], 'expire_at': int(time.time()) + content['expire'] - 300, } + now = int(time.time()) + for key, token in tuple(self.tenant_access_tokens.items()): + if int(token.get('expire_at', 0)) <= now: + self.tenant_access_tokens.pop(key, None) + while len(self.tenant_access_tokens) > 1024: + self.tenant_access_tokens.pop(next(iter(self.tenant_access_tokens)), None) def get_tenant_access_token(self, tenant_key: str | None): if self.config.get('app_type', 'self') != 'isv' or not tenant_key: @@ -372,7 +398,19 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte payloads: list[tuple[str, dict]] = [] if text_elements: needs_post = any(ele.get('tag') == 'at' for paragraph in text_elements for ele in paragraph) - if needs_post: + if LegacyLarkAdapter._has_markdown_table(text_elements): + text = '\n\n'.join(''.join(ele.get('text', '') for ele in row) for row in text_elements) + payloads.append( + ( + 'interactive', + { + 'schema': '2.0', + 'config': {'wide_screen_mode': True}, + 'body': {'elements': [{'tag': 'markdown', 'content': text}]}, + }, + ) + ) + elif needs_post: payloads.append(('post', {'zh_Hans': {'title': '', 'content': text_elements}})) else: parts = [] @@ -390,8 +428,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte async def on_monitoring_message_created(self, query, monitoring_message_id: str): user_msg_id = getattr(query.message_event, 'message_id', None) + if not user_msg_id: + user_msg_id = getattr(getattr(query.message_event, 'message_chain', None), 'message_id', None) if user_msg_id: self.pending_monitoring_msg[str(user_msg_id)] = monitoring_message_id + while len(self.pending_monitoring_msg) > 1000: + self.pending_monitoring_msg.pop(next(iter(self.pending_monitoring_msg)), None) async def create_message_card(self, message_id, event) -> bool: card_id = await self.create_card_id(message_id) @@ -409,9 +451,26 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte ) if not response.success(): raise RuntimeError(f'Lark create_message_card failed: {response.code} {response.msg}') + user_msg_id = self._message_id_from_source(event) + reply_msg_id = getattr(response.data, 'message_id', None) + monitoring_msg_id = self.pending_monitoring_msg.pop(str(user_msg_id), None) + if reply_msg_id and monitoring_msg_id: + self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time()) + now = time.time() + for key, (_, timestamp) in tuple(self.reply_to_monitoring_msg.items()): + if now - timestamp > self._monitoring_mapping_ttl: + self.reply_to_monitoring_msg.pop(key, None) + while len(self.reply_to_monitoring_msg) > 1000: + self.reply_to_monitoring_msg.pop(next(iter(self.reply_to_monitoring_msg)), None) return True async def create_card_id(self, message_id) -> str: + while len(self.card_id_dict) >= 1000: + old_key = next(iter(self.card_id_dict)) + old_card = self.card_id_dict.pop(old_key) + self.card_sequence_dict.pop(old_card, None) + self.card_last_update_dict.pop(old_card, None) + self.closed_streaming_cards.discard(old_card) card_data = { 'schema': '2.0', 'config': { @@ -504,7 +563,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte ''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'}) for paragraph in text_elements ) - if card_id in self.closed_streaming_cards: + if (is_final and not bot_message.tool_calls) or card_id in self.closed_streaming_cards: await self._replace_streaming_card(card_id, content) else: sequence = self._next_card_sequence(card_id) @@ -515,7 +574,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte .request_body(ContentCardElementRequestBody.builder().content(content).sequence(sequence).build()) .build() ) - response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content( + response: ContentCardElementResponse = await self.api_client.cardkit.v1.card_element.acontent( request, self.request_option(self._tenant_key_from_source(message_source)) ) if not response.success(): @@ -646,9 +705,29 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte await asyncio.sleep(1) async def kill(self) -> bool: + with self.threadsafe_event_lock: + futures = list(self.threadsafe_event_futures) + for future in futures: + future.cancel() + tasks = list(self.inbound_event_tasks) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self.inbound_event_tasks.clear() + self.pending_monitoring_msg.clear() + self.reply_to_monitoring_msg.clear() + self.tenant_access_tokens.clear() + self.card_id_dict.clear() + self.card_sequence_dict.clear() + self.card_last_update_dict.clear() + self.closed_streaming_cards.clear() self.bot._auto_reconnect = False await self.bot._disconnect() await _cancel_ws_cache_task(self.bot) + self._message_cache.clear() + self._user_cache.clear() + self._group_cache.clear() return True async def is_muted(self, group_id: int | None = None) -> bool: @@ -681,6 +760,13 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte self._user_cache[str(event.sender.id)] = event.sender if event.group: self._group_cache[str(event.group.id)] = event.group + for cache in ( + self._message_cache, + self._user_cache, + self._group_cache, + ): + while len(cache) > 4096: + cache.pop(next(iter(cache)), None) def _handle_card_action_sync(self, event): interaction_event = interaction_event_from_callback(event) @@ -716,18 +802,57 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte } return response + def _schedule_inbound_event(self, coro) -> None: + for task in tuple(self.inbound_event_tasks): + if task.done(): + self.inbound_event_tasks.discard(task) + if len(self.inbound_event_tasks) >= self._MAX_INBOUND_EVENTS: + coro.close() + return + task = asyncio.create_task(coro) + self.inbound_event_tasks.add(task) + + def done(done_task: asyncio.Task) -> None: + self.inbound_event_tasks.discard(done_task) + if not done_task.cancelled(): + done_task.exception() + + task.add_done_callback(done) + + def _schedule_threadsafe_event(self, coro): + """Submit one bounded callback from the Lark SDK's sync boundary.""" + + with self.threadsafe_event_lock: + for future in tuple(self.threadsafe_event_futures): + if future.done(): + self.threadsafe_event_futures.discard(future) + if len(self.threadsafe_event_futures) >= self._MAX_INBOUND_EVENTS: + coro.close() + return None + future = asyncio.run_coroutine_threadsafe(coro, self.event_loop) + self.threadsafe_event_futures.add(future) + + def done(done_future) -> None: + with self.threadsafe_event_lock: + self.threadsafe_event_futures.discard(done_future) + if not done_future.cancelled(): + done_future.exception() + + future.add_done_callback(done) + return future + def _submit_coro(self, coro): try: loop = asyncio.get_running_loop() except RuntimeError: loop = self.event_loop if loop and loop.is_running(): - asyncio.run_coroutine_threadsafe(coro, loop) + self._schedule_threadsafe_event(coro) return coro.close() raise else: - loop.create_task(coro) + self._schedule_inbound_event(coro) def _feedback_event_from_callback(self, event) -> platform_events.FeedbackEvent | None: value = getattr(getattr(event.event, 'action', None), 'value', {}) or {} diff --git a/src/langbot/pkg/platform/adapters/lark/api_impl.py b/src/langbot/pkg/platform/adapters/lark/api_impl.py index 9bcf11af7..ae2ff23d5 100644 --- a/src/langbot/pkg/platform/adapters/lark/api_impl.py +++ b/src/langbot/pkg/platform/adapters/lark/api_impl.py @@ -39,7 +39,7 @@ class LarkAPIMixin: message_chain = await LarkMessageConverter.target2yiri(event_message, self.api_client) event = platform_events.MessageReceivedEvent( type='message.received', - adapter_name='lark-eba', + adapter_name='lark-omni', message_id=str(message_id), message_chain=message_chain, sender=platform_entities.User(id=''), diff --git a/src/langbot/pkg/platform/adapters/lark/interaction.py b/src/langbot/pkg/platform/adapters/lark/interaction.py index 05601ca76..9c9873125 100644 --- a/src/langbot/pkg/platform/adapters/lark/interaction.py +++ b/src/langbot/pkg/platform/adapters/lark/interaction.py @@ -533,7 +533,7 @@ def _event_from_parts( data['values'] = _form_submission_values(action, payload) return platform_events.PlatformSpecificEvent( type='platform.specific', - adapter_name='lark-eba', + adapter_name='lark-omni', action='interaction.submitted', data=data, timestamp=time.time(), diff --git a/src/langbot/pkg/platform/adapters/lark/manifest.yaml b/src/langbot/pkg/platform/adapters/lark/manifest.yaml index 567f81a2d..cfdea896a 100644 --- a/src/langbot/pkg/platform/adapters/lark/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/lark/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: lark-eba + name: lark-omni label: en_US: Lark / Feishu zh_Hans: 飞书 @@ -21,10 +21,75 @@ spec: - china - global help_links: - zh: https://link.langbot.app/zh/platforms/lark - en: https://link.langbot.app/en/platforms/lark - ja: https://link.langbot.app/ja/platforms/lark + zh: https://langbot.app/docs/zh/usage/platforms/lark + en: https://langbot.app/docs/en/usage/platforms/lark + ja: https://langbot.app/docs/ja/usage/platforms/lark config: + - name: domain + label: + en_US: Platform Domain + zh_Hans: 平台域名 + zh_Hant: 平台域名 + ja_JP: プラットフォームドメイン + description: + en_US: Select the open platform domain. Use Feishu for Chinese mainland, Lark for international + zh_Hans: 选择开放平台域名,国内使用飞书,海外使用 Lark + zh_Hant: 選擇開放平台域名,國內使用飛書,海外使用 Lark + ja_JP: オープンプラットフォームのドメインを選択。中国国内は飛書、海外は Lark を使用 + type: select + options: + - name: https://open.feishu.cn + label: + en_US: Feishu (open.feishu.cn) + zh_Hans: 飞书 (open.feishu.cn) + zh_Hant: 飛書 (open.feishu.cn) + ja_JP: 飛書 (open.feishu.cn) + - name: https://open.larksuite.com + label: + en_US: Lark (open.larksuite.com) + zh_Hans: Lark (open.larksuite.com) + zh_Hant: Lark (open.larksuite.com) + ja_JP: Lark (open.larksuite.com) + - name: custom + label: + en_US: Custom + zh_Hans: 自定义 + zh_Hant: 自定義 + ja_JP: カスタム + required: false + default: https://open.feishu.cn + - name: custom_domain + label: + en_US: Custom Domain + zh_Hans: 自定义域名 + zh_Hant: 自定義域名 + ja_JP: カスタムドメイン + description: + en_US: Enter the full domain URL, e.g. https://open.example.com + zh_Hans: 输入完整的域名 URL,例如 https://open.example.com + zh_Hant: 輸入完整的域名 URL,例如 https://open.example.com + ja_JP: '完全なドメイン URL を入力(例: https://open.example.com)' + type: string + required: false + default: '' + show_if: + field: domain + operator: eq + value: custom + - name: one-click-create + label: + en_US: One-Click Create App + zh_Hans: 一键创建应用 + zh_Hant: 一鍵建立應用 + ja_JP: ワンクリックでアプリ作成 + description: + en_US: Scan QR code to automatically create a Feishu app and fill in credentials + zh_Hans: 扫码自动创建飞书应用并填写凭据 + zh_Hant: 掃碼自動建立飛書應用並填寫憑證 + ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力 + type: qr-code-login + login_platform: feishu + required: false - name: app_id label: en_US: App ID diff --git a/src/langbot/pkg/platform/adapters/lark/message_converter.py b/src/langbot/pkg/platform/adapters/lark/message_converter.py index 63dc64f05..117740d01 100644 --- a/src/langbot/pkg/platform/adapters/lark/message_converter.py +++ b/src/langbot/pkg/platform/adapters/lark/message_converter.py @@ -1,20 +1,24 @@ from __future__ import annotations import base64 +import asyncio + +from langbot.pkg.platform.sources.lark import ( + LarkMessageConverter as LegacyLarkMessageConverter, + _decode_lark_base64_limited, + _read_lark_path_limited, + _read_lark_response_file_limited, + _MAX_LARK_MEDIA_BYTES, +) import datetime import json import mimetypes import os import re import tempfile -import traceback import lark_oapi from lark_oapi.api.im.v1 import ( - CreateFileRequest, - CreateFileRequestBody, - CreateImageRequest, - CreateImageRequestBody, EventMessage, GetMessageResourceRequest, GetMessageResourceResponse, @@ -26,78 +30,9 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): - @staticmethod - async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> str | None: - image_bytes = await LarkMessageConverter._get_component_bytes(msg) - if image_bytes is None: - return None + upload_image_to_lark = staticmethod(LegacyLarkMessageConverter.upload_image_to_lark) - temp_file_path = '' - try: - with tempfile.NamedTemporaryFile(delete=False) as temp_file: - temp_file.write(image_bytes) - temp_file.flush() - temp_file_path = temp_file.name - - request = ( - CreateImageRequest.builder() - .request_body( - CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build() - ) - .build() - ) - response = await api_client.im.v1.image.acreate(request) - if not response.success(): - return None - return response.data.image_key - except Exception: - traceback.print_exc() - return None - finally: - if temp_file_path: - try: - os.unlink(temp_file_path) - except FileNotFoundError: - pass - - @staticmethod - async def upload_file_to_lark( - file_bytes: bytes, - api_client: lark_oapi.Client, - file_type: str, - file_name: str = 'file', - duration: int | None = None, - ) -> str | None: - temp_file_path = '' - try: - with tempfile.NamedTemporaryFile(delete=False) as temp_file: - temp_file.write(file_bytes) - temp_file.flush() - temp_file_path = temp_file.name - - body_builder = ( - CreateFileRequestBody.builder() - .file_type(file_type) - .file_name(file_name) - .file(open(temp_file_path, 'rb')) - ) - if duration is not None: - body_builder = body_builder.duration(duration) - - request = CreateFileRequest.builder().request_body(body_builder.build()).build() - response = await api_client.im.v1.file.acreate(request) - if not response.success(): - return None - return response.data.file_key - except Exception: - traceback.print_exc() - return None - finally: - if temp_file_path: - try: - os.unlink(temp_file_path) - except FileNotFoundError: - pass + upload_file_to_lark = staticmethod(LegacyLarkMessageConverter.upload_file_to_lark) @staticmethod async def _get_component_bytes( @@ -108,24 +43,22 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): base64_data = msg.base64 if ',' in base64_data: base64_data = base64_data.split(',', 1)[1] - return base64.b64decode(base64_data) + return await asyncio.to_thread(_decode_lark_base64_limited, base64_data) except Exception: return None if getattr(msg, 'url', None): try: if str(msg.url).startswith('file://'): - with open(str(msg.url)[7:], 'rb') as f: - return f.read() + return await asyncio.to_thread(_read_lark_path_limited, str(msg.url)[7:]) session = httpclient.get_session() async with session.get(msg.url) as response: if response.status == 200: - return await response.read() + return await httpclient.read_limited(response, max_bytes=_MAX_LARK_MEDIA_BYTES) except Exception: return None if getattr(msg, 'path', None): try: - with open(msg.path, 'rb') as f: - return f.read() + return await asyncio.to_thread(_read_lark_path_limited, str(msg.path)) except Exception: return None return None @@ -375,9 +308,9 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request) if not response.success(): return {} - data = response.file.read() + data = await asyncio.to_thread(_read_lark_response_file_limited, response) content_type = response.raw.headers.get('content-type', 'application/octet-stream') - base64_data = base64.b64encode(data).decode() + base64_data = (await asyncio.to_thread(base64.b64encode, data)).decode() ext = mimetypes.guess_extension(content_type.split(';')[0].strip()) or '.bin' temp_path = os.path.join(tempfile.gettempdir(), f'lark_{file_key}{ext}') with open(temp_path, 'wb') as f: diff --git a/src/langbot/pkg/platform/adapters/lark/types.py b/src/langbot/pkg/platform/adapters/lark/types.py index bce1e8764..b5d9255f3 100644 --- a/src/langbot/pkg/platform/adapters/lark/types.py +++ b/src/langbot/pkg/platform/adapters/lark/types.py @@ -1,3 +1,3 @@ from __future__ import annotations -ADAPTER_NAME = 'lark-eba' +ADAPTER_NAME = 'lark-omni' diff --git a/src/langbot/pkg/platform/adapters/officialaccount/adapter.py b/src/langbot/pkg/platform/adapters/officialaccount/adapter.py index 42350dd9f..124230aad 100644 --- a/src/langbot/pkg/platform/adapters/officialaccount/adapter.py +++ b/src/langbot/pkg/platform/adapters/officialaccount/adapter.py @@ -42,7 +42,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter. required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode'] missing_keys = [key for key in required_keys if not config.get(key)] if missing_keys: - raise Exception(f'OfficialAccount EBA adapter missing config: {missing_keys}') + raise Exception(f'OfficialAccount Omni adapter missing config: {missing_keys}') mode = config['Mode'] common_kwargs = { @@ -154,10 +154,13 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter. while True: await asyncio.sleep(1) - await self.logger.info('OfficialAccount EBA adapter running in unified webhook mode') + await self.logger.info('OfficialAccount Omni adapter running in unified webhook mode') await keep_alive() async def kill(self) -> bool: + self.bot.clear() + self._message_cache.clear() + self._user_cache.clear() return True async def is_muted(self, group_id: int | None = None) -> bool: @@ -193,3 +196,9 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter. if isinstance(event, platform_events.MessageReceivedEvent): self._message_cache[str(event.message_id)] = event self._user_cache[str(event.sender.id)] = event.sender + for cache in ( + self._message_cache, + self._user_cache, + ): + while len(cache) > 4096: + cache.pop(next(iter(cache)), None) diff --git a/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml b/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml index 80b3ecd31..033c00811 100644 --- a/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: officialaccount-eba + name: officialaccount-omni label: en_US: Official Account zh_Hans: 微信公众号 @@ -17,10 +17,23 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/officialaccount - en: https://link.langbot.app/en/platforms/officialaccount - ja: https://link.langbot.app/ja/platforms/officialaccount + zh: https://langbot.app/docs/zh/usage/platforms/wxoa + en: https://langbot.app/docs/en/usage/platforms/wxoa + ja: https://langbot.app/docs/ja/usage/platforms/wxoa config: + - name: __system.outbound_ips + label: + en_US: IP Whitelist + zh_Hans: IP 白名单 + zh_Hant: IP 白名單 + description: + en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the "Basic Configuration" of the WeChat Official + Account platform + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到微信公众平台「基本配置」中的 IP 白名单 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入微信公眾平台「基本配置」中的 IP 白名單 + type: array[string] + required: false + default: [] - name: webhook_url label: en_US: Webhook Callback URL diff --git a/src/langbot/pkg/platform/adapters/officialaccount/types.py b/src/langbot/pkg/platform/adapters/officialaccount/types.py index d120f6b13..9cf414b9d 100644 --- a/src/langbot/pkg/platform/adapters/officialaccount/types.py +++ b/src/langbot/pkg/platform/adapters/officialaccount/types.py @@ -1,3 +1,3 @@ from __future__ import annotations -ADAPTER_NAME = 'officialaccount-eba' +ADAPTER_NAME = 'officialaccount-omni' diff --git a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py index cc6726a9c..7703a5935 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py @@ -64,16 +64,16 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl arbitrary_types_allowed = True def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): - required_keys = ['appid', 'secret', 'token'] + required_keys = ['appid', 'secret'] missing_keys = [key for key in required_keys if not config.get(key)] if missing_keys: - raise Exception(f'QQOfficial EBA adapter missing config: {missing_keys}') + raise Exception(f'QQOfficial Omni adapter missing config: {missing_keys}') enable_webhook = config.get('enable-webhook', config.get('enable_webhook', False)) bot = QQOfficialClient( app_id=config['appid'], secret=config['secret'], - token=config['token'], + token=config.get('token', ''), logger=logger, unified_mode=enable_webhook, ) @@ -169,6 +169,39 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl raise NotSupportedError(f'call_platform_api:{action}') return await handler(self, dict(params or {})) + async def _send_c2c_or_group_text_reply( + self, + target_type: str, + target_id: str, + content: str, + *, + msg_id: typing.Optional[str] = None, + event_id: typing.Optional[str] = None, + msg_seq: int = 1, + ) -> typing.Any: + """Send a text reply using the configured C2C/group render mode.""" + use_markdown = self.config.get('enable-markdown-rendering', False) + if target_type == 'c2c': + send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg + return await send( + user_openid=target_id, + content=content, + msg_id=msg_id, + event_id=event_id, + msg_seq=msg_seq, + ) + elif target_type == 'group': + send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg + return await send( + group_openid=target_id, + content=content, + msg_id=msg_id, + event_id=event_id, + msg_seq=msg_seq, + ) + else: + raise ValueError(f'Unsupported QQ Official text reply target: {target_type}') + def register_listener( self, event_type: typing.Type[platform_events.Event], @@ -194,7 +227,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl async def run_async(self): if self.enable_webhook: - await self.logger.info('QQ Official EBA adapter running in unified webhook mode') + await self.logger.info('QQ Official Omni adapter running in unified webhook mode') while True: await asyncio.sleep(1) else: @@ -208,6 +241,15 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl except asyncio.CancelledError: pass self._ws_task = None + await self.bot.close() + self._stream_ctx.clear() + self._stream_ctx_ts.clear() + self._fallback_text.clear() + self._fallback_text_ts.clear() + self._message_cache.clear() + self._user_cache.clear() + self._group_cache.clear() + self._member_cache.clear() return True async def is_muted(self, group_id: int | None = None) -> bool: @@ -220,6 +262,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl source = event.source_platform_object if not isinstance(source, QQOfficialEvent) or source.t != 'C2C_MESSAGE_CREATE': return False + await self._cleanup_stale_streams() self._stream_ctx[message_id] = { 'user_openid': source.user_openid, 'msg_id': source.d_id, @@ -253,10 +296,11 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl ) if not message_id or message_id not in self._stream_ctx: if chunk_text: - self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text + self._fallback_text[message_id] = chunk_text[:200000] self._fallback_text_ts[message_id] = time.time() if is_final: full_text = self._fallback_text.pop(message_id, '') + self._fallback_text_ts.pop(message_id, None) if full_text: await self.reply_message( message_source, @@ -267,14 +311,14 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl ctx = self._stream_ctx[message_id] if chunk_text: - ctx['accumulated_text'] += chunk_text + ctx['accumulated_text'] = chunk_text[:200000] if not ctx['session_started']: if not ctx['accumulated_text']: return ctx['session_started'] = True - content_to_send = ctx['accumulated_text'][ctx['sent_length'] :] - if not content_to_send and not is_final: + content_to_send = ctx['accumulated_text'] + if len(content_to_send) <= ctx['sent_length'] and not is_final: return now = time.time() if not is_final and (now - ctx['last_update_ts']) < 0.5: @@ -355,9 +399,17 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl role=platform_entities.MemberRole.MEMBER, display_name=event.sender.nickname, ) + for cache in ( + self._message_cache, + self._user_cache, + self._group_cache, + self._member_cache, + ): + while len(cache) > 4096: + cache.pop(next(iter(cache)), None) async def _run_websocket(self): - await self.logger.info('QQ Official EBA adapter starting in WebSocket mode') + await self.logger.info('QQ Official Omni adapter starting in WebSocket mode') async def on_ready(): await self.logger.info('QQ Official WebSocket connected and ready') @@ -413,12 +465,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl results.append({'type': content_type, 'raw': raw}) continue if content_type == 'text': - if target_type == 'c2c': - raw = await self.bot.send_private_text_msg(target_id, content.get('content', ''), msg_id) - elif target_type == 'group': - raw = await self.bot.send_group_text_msg(target_id, content.get('content', ''), msg_id) - else: - raise NotSupportedError(f'send_message:{target_type}') + raw = await self._send_c2c_or_group_text_reply( + target_type, target_id, content.get('content', ''), msg_id=msg_id + ) results.append({'type': content_type, 'raw': raw}) elif content_type == 'image': raw = await self.bot.send_image_msg( @@ -462,3 +511,12 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl for message_id in [key for key, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL]: self._fallback_text.pop(message_id, None) self._fallback_text_ts.pop(message_id, None) + + for values, timestamps in ( + (self._stream_ctx, self._stream_ctx_ts), + (self._fallback_text, self._fallback_text_ts), + ): + while len(values) > 1000: + oldest = next(iter(values)) + values.pop(oldest, None) + timestamps.pop(oldest, None) diff --git a/src/langbot/pkg/platform/adapters/qqofficial/interaction.py b/src/langbot/pkg/platform/adapters/qqofficial/interaction.py index 1d84fea02..be28492fa 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/interaction.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/interaction.py @@ -163,7 +163,7 @@ def interaction_event_from_payload( ) return platform_events.PlatformSpecificEvent( type='platform.specific', - adapter_name='qqofficial-eba', + adapter_name='qqofficial-omni', action='interaction.submitted', data={ **parsed, diff --git a/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml b/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml index e2e148745..53ec224d0 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: qqofficial-eba + name: qqofficial-omni label: en_US: QQ Official API zh_Hans: QQ 官方 API @@ -17,10 +17,47 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/qqofficial - en: https://link.langbot.app/en/platforms/qqofficial - ja: https://link.langbot.app/ja/platforms/qqofficial + zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook + en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook + ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook config: + - name: __system.outbound_ips + label: + en_US: IP Whitelist + zh_Hans: IP 白名单 + zh_Hant: IP 白名單 + description: + en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the development settings of the QQ Open Platform + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到 QQ 开放平台开发设置中的 IP 白名单 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入 QQ 開放平台開發設定中的 IP 白名單 + type: array[string] + required: false + default: [] + - name: one-click-bind + label: + en_US: One-Click QR Binding + zh_Hans: 一键扫码绑定 + zh_Hant: 一鍵掃碼綁定 + description: + en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token is not used and can be left blank) + zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(当前未使用 Token,可留空) + zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(目前未使用 Token,可留空) + type: qr-code-login + login_platform: qqofficial + required: false + - name: enable-markdown-rendering + label: + en_US: Enable Markdown Rendering + zh_Hans: 启用 Markdown 渲染 + zh_Hant: 啟用 Markdown 渲染 + description: + en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not + affected by this setting. + zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。 + zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。 + type: boolean + required: true + default: false - name: appid label: en_US: App ID @@ -43,7 +80,7 @@ spec: zh_Hans: 令牌 zh_Hant: 令牌 type: string - required: true + required: false default: "" - name: enable-webhook label: diff --git a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py index 16b43ab90..943068e60 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py @@ -79,16 +79,14 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver pic_url=event.attachments, content_type=event.content_type, ) - components.append(platform_message.Image(base64=base64_url)) + components.append(platform_message.Image(url=event.attachments, base64=base64_url)) except Exception: components.append(platform_message.Image(url=event.attachments)) if event.content: components.append(platform_message.Plain(text=event.content)) - if len(components) == 1 or ( - len(components) == 2 and isinstance(components[1], platform_message.At) - ): + if len(components) == 1 or (len(components) == 2 and isinstance(components[1], platform_message.At)): components.append(platform_message.Unknown(text=f'[unsupported qqofficial event: {event.t or "unknown"}]')) return platform_message.MessageChain(components) @@ -101,4 +99,3 @@ def _parse_timestamp(value: str) -> datetime.datetime: return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z') except (TypeError, ValueError): return datetime.datetime.now() - diff --git a/src/langbot/pkg/platform/adapters/qqofficial/types.py b/src/langbot/pkg/platform/adapters/qqofficial/types.py index e5fb8f895..c3cc995fd 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/types.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/types.py @@ -2,7 +2,7 @@ from __future__ import annotations import pydantic -ADAPTER_NAME = 'qqofficial-eba' +ADAPTER_NAME = 'qqofficial-omni' class QQOfficialAdapterConfig(pydantic.BaseModel): @@ -11,4 +11,3 @@ class QQOfficialAdapterConfig(pydantic.BaseModel): token: str enable_webhook: bool = False enable_stream_reply: bool = False - diff --git a/src/langbot/pkg/platform/adapters/slack/adapter.py b/src/langbot/pkg/platform/adapters/slack/adapter.py index e751a5a6f..c2ee91a8d 100644 --- a/src/langbot/pkg/platform/adapters/slack/adapter.py +++ b/src/langbot/pkg/platform/adapters/slack/adapter.py @@ -44,7 +44,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap required_keys = ['bot_token', 'signing_secret'] missing_keys = [key for key in required_keys if not config.get(key)] if missing_keys: - raise Exception(f'Slack EBA adapter missing config: {missing_keys}') + raise Exception(f'Slack Omni adapter missing config: {missing_keys}') bot = SlackClient( bot_token=config['bot_token'], @@ -144,11 +144,15 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap return await self.bot.handle_unified_webhook(request) async def run_async(self): - await self.logger.info('Slack EBA adapter running in unified webhook mode') + await self.logger.info('Slack Omni adapter running in unified webhook mode') while True: await asyncio.sleep(1) async def kill(self) -> bool: + self._message_cache.clear() + self._user_cache.clear() + self._group_cache.clear() + self._member_cache.clear() return True async def is_muted(self, group_id: int | None = None) -> bool: @@ -192,6 +196,14 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap role=platform_entities.MemberRole.MEMBER, display_name=event.sender.nickname, ) + for cache in ( + self._message_cache, + self._user_cache, + self._group_cache, + self._member_cache, + ): + while len(cache) > 4096: + cache.pop(next(iter(cache)), None) async def _send_text(self, target_type: str, target_id: str, content: str) -> dict: target_type = self._normalize_target_type(target_type) diff --git a/src/langbot/pkg/platform/adapters/slack/manifest.yaml b/src/langbot/pkg/platform/adapters/slack/manifest.yaml index 4e72eae7e..b5163f55e 100644 --- a/src/langbot/pkg/platform/adapters/slack/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/slack/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: slack-eba + name: slack-omni label: en_US: Slack zh_Hans: Slack @@ -18,9 +18,9 @@ spec: - popular - global help_links: - zh: https://link.langbot.app/zh/platforms/slack - en: https://link.langbot.app/en/platforms/slack - ja: https://link.langbot.app/ja/platforms/slack + zh: https://langbot.app/docs/zh/usage/platforms/slack + en: https://langbot.app/docs/en/usage/platforms/slack + ja: https://langbot.app/docs/ja/usage/platforms/slack config: - name: webhook_url label: diff --git a/src/langbot/pkg/platform/adapters/slack/message_converter.py b/src/langbot/pkg/platform/adapters/slack/message_converter.py index 3702c5e59..07a246dae 100644 --- a/src/langbot/pkg/platform/adapters/slack/message_converter.py +++ b/src/langbot/pkg/platform/adapters/slack/message_converter.py @@ -57,16 +57,18 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter): if event.pic_url: try: - components.append(platform_message.Image(base64=await image.get_slack_image_to_base64(event.pic_url, bot_token))) + components.append( + platform_message.Image( + url=event.pic_url, base64=await image.get_slack_image_to_base64(event.pic_url, bot_token) + ) + ) except Exception: components.append(platform_message.Image(url=event.pic_url)) if event.text: components.append(platform_message.Plain(text=event.text)) - if len(components) == 1 or ( - len(components) == 2 and isinstance(components[1], platform_message.At) - ): + if len(components) == 1 or (len(components) == 2 and isinstance(components[1], platform_message.At)): components.append(platform_message.Unknown(text=f'[unsupported slack event: {event.type or "unknown"}]')) return platform_message.MessageChain(components) diff --git a/src/langbot/pkg/platform/adapters/slack/types.py b/src/langbot/pkg/platform/adapters/slack/types.py index cb8668168..1a0f4c2ab 100644 --- a/src/langbot/pkg/platform/adapters/slack/types.py +++ b/src/langbot/pkg/platform/adapters/slack/types.py @@ -1,3 +1,3 @@ from __future__ import annotations -ADAPTER_NAME = 'slack-eba' +ADAPTER_NAME = 'slack-omni' diff --git a/src/langbot/pkg/platform/adapters/telegram/adapter.py b/src/langbot/pkg/platform/adapters/telegram/adapter.py index 615c2ecde..7debca097 100644 --- a/src/langbot/pkg/platform/adapters/telegram/adapter.py +++ b/src/langbot/pkg/platform/adapters/telegram/adapter.py @@ -6,7 +6,6 @@ Preserves all existing functionality (messaging, streaming output, markdown card from __future__ import annotations -import time import typing import traceback @@ -302,23 +301,33 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo args['parse_mode'] = 'MarkdownV2' return args + _MAX_STREAM_STATES: typing.ClassVar[int] = 1000 + + def _cap_stream_states(self) -> None: + while len(self.msg_stream_id) > self._MAX_STREAM_STATES: + self.msg_stream_id.pop(next(iter(self.msg_stream_id)), None) + + @staticmethod + def _is_form_placeholder_chunk(text: str) -> bool: + """Return True for invisible placeholder chunks used to carry forms.""" + + if not text: + return True + + cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip() + return cleaned == '' + async def create_message_card(self, message_id, event): assert isinstance(event.source_platform_object, Update) update = event.source_platform_object chat_id = update.effective_chat.id - chat_type = update.effective_chat.type - message_thread_id = update.message.message_thread_id + effective_message = update.effective_message + message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None - if chat_type == 'private': - draft_id = int(time.time() * 1000) - self.msg_stream_id[message_id] = ('private', draft_id) - - args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id) - await self.bot.send_message_draft(**args) - else: - args = self._build_message_args(chat_id, 'Thinking...', message_thread_id) - send_msg = await self.bot.send_message(**args) - self.msg_stream_id[message_id] = ('group', send_msg.message_id) + args = self._build_message_args(chat_id, 'Thinking...', message_thread_id) + send_msg = await self.bot.send_message(**args) + self.msg_stream_id[message_id] = ('message', send_msg.message_id, False) + self._cap_stream_states() return True @@ -335,12 +344,15 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo assert isinstance(message_source.source_platform_object, Update) update = message_source.source_platform_object chat_id = update.effective_chat.id - message_thread_id = update.message.message_thread_id + effective_message = update.effective_message + message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None if message_id not in self.msg_stream_id: return - chat_mode, draft_id = self.msg_stream_id[message_id] + stream_state = self.msg_stream_id[message_id] + chat_mode, stream_id = stream_state[:2] + has_visible_content = len(stream_state) > 2 and stream_state[2] components = await TelegramMessageConverter.yiri2target(message, self.bot) if not components or components[0]['type'] != 'text': @@ -349,17 +361,58 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo return content = components[0]['text'] + if self._is_form_placeholder_chunk(content): + if is_final and bot_message.tool_calls is None and not has_visible_content: + await self._delete_group_stream_message(chat_mode, chat_id, stream_id) + self.msg_stream_id.pop(message_id, None) + return if chat_mode == 'private': - args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id) - await self.bot.send_message_draft(**args) + # Streaming via draft (ephemeral preview in the chat input area) + if (msg_seq - 1) % 8 == 0 or is_final: + args = self._build_message_args(chat_id, content, message_thread_id, draft_id=stream_id) + try: + await self.bot.send_message_draft(**args) + except telegram.error.BadRequest as exc: + if 'Message_too_long' in str(exc): + args['text'] = content[:4000] + '\n\n… (truncated)' + try: + await self.bot.send_message_draft(**args) + except telegram.error.RetryAfter: + pass + else: + pass # Ignore other draft errors (cosmetic) + self.msg_stream_id[message_id] = (chat_mode, stream_id, True) if is_final and bot_message.tool_calls is None: - del args['draft_id'] - await self.bot.send_message(**args) + # Finalise: send the real message, discard the draft + args = self._build_message_args(chat_id, content, message_thread_id) + try: + await self.bot.send_message(**args) + except telegram.error.BadRequest as exc: + if 'Message_too_long' in str(exc): + args['text'] = content[:4000] + '\n\n… (truncated)' + await self.bot.send_message(**args) + else: + raise self.msg_stream_id.pop(message_id) else: - stream_id = draft_id - if (msg_seq - 1) % 8 == 0 or is_final: + # Streaming via edit_message_text (persistent message) + if stream_id is None: + args = self._build_message_args(chat_id, content, message_thread_id) + try: + send_msg = await self.bot.send_message(**args) + except telegram.error.BadRequest as exc: + if 'Message_too_long' in str(exc): + args['text'] = self._process_markdown(content[:4000] + '\n\n… (truncated)') + send_msg = await self.bot.send_message(**args) + else: + raise + self.msg_stream_id[message_id] = (chat_mode, send_msg.message_id, True) + if is_final and bot_message.tool_calls is None: + self.msg_stream_id.pop(message_id, None) + return + + if not has_visible_content or (msg_seq - 1) % 8 == 0 or is_final: args = { 'message_id': stream_id, 'chat_id': chat_id, @@ -367,7 +420,15 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo } if self.config.get('markdown_card', False): args['parse_mode'] = 'MarkdownV2' - await self.bot.edit_message_text(**args) + try: + await self.bot.edit_message_text(**args) + except telegram.error.BadRequest as exc: + if 'Message_too_long' in str(exc): + args['text'] = self._process_markdown(content[:4000] + '\n\n… (truncated)') + await self.bot.edit_message_text(**args) + else: + raise + self.msg_stream_id[message_id] = (chat_mode, stream_id, True) if is_final and bot_message.tool_calls is None: self.msg_stream_id.pop(message_id) @@ -461,4 +522,6 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo if self.application.updater: await self.application.updater.stop() await self.logger.info('Telegram adapter stopped') + await self.application.shutdown() + self.msg_stream_id.clear() return True diff --git a/src/langbot/pkg/platform/adapters/telegram/event_converter.py b/src/langbot/pkg/platform/adapters/telegram/event_converter.py index fc818bfe6..53a717c48 100644 --- a/src/langbot/pkg/platform/adapters/telegram/event_converter.py +++ b/src/langbot/pkg/platform/adapters/telegram/event_converter.py @@ -92,7 +92,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.PlatformSpecificEvent( type='platform.specific', timestamp=time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', action='callback_query', data={ 'callback_query_id': update.callback_query.id, @@ -111,7 +111,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.PlatformSpecificEvent( type='platform.specific', timestamp=time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', action='unknown_update', data={'update_id': update.update_id}, source_platform_object=update, @@ -138,7 +138,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.MessageReceivedEvent( type='message.received', timestamp=message.date.timestamp() if message.date else 0.0, - adapter_name='telegram', + adapter_name='telegram-omni', message_id=message.message_id, message_chain=lb_message, sender=sender, @@ -169,7 +169,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.MessageEditedEvent( type='message.edited', timestamp=message.edit_date.timestamp() if message.edit_date else 0.0, - adapter_name='telegram', + adapter_name='telegram-omni', message_id=message.message_id, new_content=lb_message, editor=editor, @@ -203,7 +203,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.MemberJoinedEvent( type='group.member_joined', timestamp=cm.date.timestamp() if cm.date else time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', group=group, member=member, inviter=inviter, @@ -217,7 +217,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.MemberLeftEvent( type='group.member_left', timestamp=cm.date.timestamp() if cm.date else time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', group=group, member=member, is_kicked=is_kicked, @@ -235,7 +235,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.MemberBannedEvent( type='group.member_banned', timestamp=cm.date.timestamp() if cm.date else time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', group=group, member=member, operator=inviter, @@ -247,7 +247,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.PlatformSpecificEvent( type='platform.specific', timestamp=cm.date.timestamp() if cm.date else time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', action='chat_member_updated', data={ 'old_status': old_status, @@ -276,7 +276,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.BotInvitedToGroupEvent( type='bot.invited_to_group', timestamp=mcm.date.timestamp() if mcm.date else time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', group=group, inviter=inviter, source_platform_object=update, @@ -287,7 +287,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.BotRemovedFromGroupEvent( type='bot.removed_from_group', timestamp=mcm.date.timestamp() if mcm.date else time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', group=group, operator=inviter, source_platform_object=update, @@ -303,7 +303,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.BotMutedEvent( type='bot.muted', timestamp=mcm.date.timestamp() if mcm.date else time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', group=group, operator=inviter, duration=duration, @@ -314,7 +314,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.BotUnmutedEvent( type='bot.unmuted', timestamp=mcm.date.timestamp() if mcm.date else time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', group=group, operator=inviter, source_platform_object=update, @@ -323,7 +323,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.PlatformSpecificEvent( type='platform.specific', timestamp=mcm.date.timestamp() if mcm.date else time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', action='my_chat_member_updated', data={ 'old_status': old_status, @@ -360,7 +360,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.MessageReactionEvent( type='message.reaction', timestamp=reaction.date.timestamp() if reaction.date else time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', message_id=reaction.message_id, user=user, reaction=new_emojis[0] if new_emojis else '', diff --git a/src/langbot/pkg/platform/adapters/telegram/interaction.py b/src/langbot/pkg/platform/adapters/telegram/interaction.py index def46b1be..38f5a5584 100644 --- a/src/langbot/pkg/platform/adapters/telegram/interaction.py +++ b/src/langbot/pkg/platform/adapters/telegram/interaction.py @@ -156,7 +156,7 @@ def interaction_event_from_update( return platform_events.PlatformSpecificEvent( type='platform.specific', timestamp=time.time(), - adapter_name='telegram', + adapter_name='telegram-omni', action='interaction.submitted', data=data, source_platform_object=update, diff --git a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml index 0cae35b50..4d48d4af6 100644 --- a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: telegram-eba + name: telegram-omni label: en_US: Telegram zh_Hans: 电报 @@ -15,6 +15,10 @@ spec: categories: - popular - global + help_links: + zh: https://langbot.app/docs/zh/usage/platforms/telegram + en: https://langbot.app/docs/en/usage/platforms/telegram + ja: https://langbot.app/docs/ja/usage/platforms/telegram config: - name: token label: diff --git a/src/langbot/pkg/platform/adapters/telegram/message_converter.py b/src/langbot/pkg/platform/adapters/telegram/message_converter.py index cea28a5b0..22a80b31a 100644 --- a/src/langbot/pkg/platform/adapters/telegram/message_converter.py +++ b/src/langbot/pkg/platform/adapters/telegram/message_converter.py @@ -1,145 +1,5 @@ -"""Telegram message chain converter. +"""Shared Telegram conversion for legacy and Omni event delivery.""" -Migrated from the original sources/telegram.py TelegramMessageConverter. Logic unchanged. -""" +from langbot.pkg.platform.sources.telegram import TelegramMessageConverter -from __future__ import annotations - -import base64 - -import telegram - -from langbot.pkg.utils import httpclient -import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter -import langbot_plugin.api.entities.builtin.platform.message as platform_message - - -class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter): - @staticmethod - async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]: - """Convert a LangBot MessageChain to a list of Telegram-sendable components.""" - components = [] - - for component in message_chain: - if isinstance(component, platform_message.Plain): - components.append({'type': 'text', 'text': component.text}) - elif isinstance(component, platform_message.Image): - photo_bytes = None - - if component.base64: - b64_data = component.base64 - if ';base64,' in b64_data: - b64_data = b64_data.split(';base64,', 1)[1] - photo_bytes = base64.b64decode(b64_data) - elif component.url: - session = httpclient.get_session() - async with session.get(component.url) as response: - photo_bytes = await response.read() - elif component.path: - with open(component.path, 'rb') as f: - photo_bytes = f.read() - - components.append({'type': 'photo', 'photo': photo_bytes}) - elif isinstance(component, platform_message.File): - file_bytes = None - - if component.base64: - b64_data = component.base64 - if ';base64,' in b64_data: - b64_data = b64_data.split(';base64,', 1)[1] - file_bytes = base64.b64decode(b64_data) - elif component.url: - session = httpclient.get_session() - async with session.get(component.url) as response: - file_bytes = await response.read() - elif component.path: - with open(component.path, 'rb') as f: - file_bytes = f.read() - - file_name = getattr(component, 'name', None) or 'file' - components.append({'type': 'document', 'document': file_bytes, 'filename': file_name}) - elif isinstance(component, platform_message.Forward): - for node in component.node_list: - components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot)) - - return components - - @staticmethod - async def target2yiri(message: telegram.Message, bot: telegram.Bot, bot_account_id: str): - """Convert a Telegram Message to a LangBot MessageChain.""" - message_components = [] - - def parse_message_text(text: str) -> list[platform_message.MessageComponent]: - msg_components = [] - - if f'@{bot_account_id}' in text: - msg_components.append(platform_message.At(target=bot_account_id)) - text = text.replace(f'@{bot_account_id}', '') - msg_components.append(platform_message.Plain(text=text)) - - return msg_components - - if message.text: - message_text = message.text - message_components.extend(parse_message_text(message_text)) - - if message.photo: - if message.caption: - message_components.extend(parse_message_text(message.caption)) - - file = await message.photo[-1].get_file() - - file_bytes = None - file_format = '' - - async with httpclient.get_session(trust_env=True).get(file.file_path) as response: - file_bytes = await response.read() - file_format = 'image/jpeg' - - message_components.append( - platform_message.Image( - base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}' - ) - ) - - if message.voice: - if message.caption: - message_components.extend(parse_message_text(message.caption)) - - file = await message.voice.get_file() - - file_bytes = None - file_format = message.voice.mime_type or 'audio/ogg' - - async with httpclient.get_session(trust_env=True).get(file.file_path) as response: - file_bytes = await response.read() - - message_components.append( - platform_message.Voice( - base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}', - length=message.voice.duration, - ) - ) - - if message.document: - if message.caption: - message_components.extend(parse_message_text(message.caption)) - - file = await message.document.get_file() - file_name = message.document.file_name or 'document' - file_size = message.document.file_size or 0 - file_format = message.document.mime_type or 'application/octet-stream' - - file_bytes = None - async with httpclient.get_session(trust_env=True).get(file.file_path) as response: - file_bytes = await response.read() - - message_components.append( - platform_message.File( - name=file_name, - size=file_size, - base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}', - ) - ) - - return platform_message.MessageChain(message_components) +__all__ = ['TelegramMessageConverter'] diff --git a/src/langbot/pkg/platform/adapters/wecom/adapter.py b/src/langbot/pkg/platform/adapters/wecom/adapter.py index 50fd5ff3c..7096e8d80 100644 --- a/src/langbot/pkg/platform/adapters/wecom/adapter.py +++ b/src/langbot/pkg/platform/adapters/wecom/adapter.py @@ -155,10 +155,13 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap while True: await asyncio.sleep(1) - await self.logger.info('WeCom EBA adapter running in unified webhook mode') + await self.logger.info('WeCom Omni adapter running in unified webhook mode') await keep_alive() async def kill(self) -> bool: + await self.bot.close() + self._message_cache.clear() + self._user_cache.clear() return True async def is_muted(self, group_id: int | None = None) -> bool: @@ -200,6 +203,12 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap return self._message_cache[str(event.message_id)] = event self._user_cache[str(event.sender.id)] = event.sender + for cache in ( + self._message_cache, + self._user_cache, + ): + while len(cache) > 4096: + cache.pop(next(iter(cache)), None) async def _send_content(self, user_id: str, agent_id: int, content: dict): content_type = content.get('type') diff --git a/src/langbot/pkg/platform/adapters/wecom/manifest.yaml b/src/langbot/pkg/platform/adapters/wecom/manifest.yaml index 650e0ef45..fd63e8348 100644 --- a/src/langbot/pkg/platform/adapters/wecom/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/wecom/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: wecom-eba + name: wecom-omni label: en_US: WeCom zh_Hans: 企业微信 @@ -18,10 +18,22 @@ spec: - popular - china help_links: - zh: https://link.langbot.app/zh/platforms/wecom - en: https://link.langbot.app/en/platforms/wecom - ja: https://link.langbot.app/ja/platforms/wecom + zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom + en: https://langbot.app/docs/en/usage/platforms/wecom/wecom + ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom config: + - name: __system.outbound_ips + label: + en_US: Trusted IPs + zh_Hans: 企业可信 IP + zh_Hant: 企業可信 IP + description: + en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of your app in the WeCom admin console + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台应用详情页的「企业可信 IP」中 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台應用詳情頁的「企業可信 IP」中 + type: array[string] + required: false + default: [] - name: webhook_url label: en_US: Webhook Callback URL diff --git a/src/langbot/pkg/platform/adapters/wecom/message_converter.py b/src/langbot/pkg/platform/adapters/wecom/message_converter.py index e742778d1..447f2e6d9 100644 --- a/src/langbot/pkg/platform/adapters/wecom/message_converter.py +++ b/src/langbot/pkg/platform/adapters/wecom/message_converter.py @@ -77,6 +77,6 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter): return platform_message.MessageChain( [ platform_message.Source(id=message_id, time=datetime.datetime.now()), - platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'), + platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}'), ] ) diff --git a/src/langbot/pkg/platform/adapters/wecom/types.py b/src/langbot/pkg/platform/adapters/wecom/types.py index 596459e0a..f7d7b0690 100644 --- a/src/langbot/pkg/platform/adapters/wecom/types.py +++ b/src/langbot/pkg/platform/adapters/wecom/types.py @@ -1,10 +1,10 @@ from __future__ import annotations -ADAPTER_NAME = 'wecom-eba' +ADAPTER_NAME = 'wecom-omni' def make_private_chat_id(user_id: str | int | None, agent_id: str | int | None) -> str: - """Build the routable private chat id used by the WeCom EBA adapter.""" + """Build the routable private chat id used by the WeCom Omni adapter.""" user = str(user_id or '') agent = str(agent_id or '') if not user or not agent: diff --git a/src/langbot/pkg/platform/adapters/wecombot/adapter.py b/src/langbot/pkg/platform/adapters/wecombot/adapter.py index 9cf055e6a..38fb5e8df 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/adapter.py +++ b/src/langbot/pkg/platform/adapters/wecombot/adapter.py @@ -1,5 +1,7 @@ from __future__ import annotations +from langbot.pkg.platform.sources.wecombot import WecomBotAdapter as LegacyWecomBotAdapter + import asyncio import time import traceback @@ -127,6 +129,10 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo def _plain_message(text: str) -> platform_message.MessageChain: return platform_message.MessageChain([platform_message.Plain(text=text)]) + _join_text_components = staticmethod(LegacyWecomBotAdapter._join_text_components) + _iter_media_components = staticmethod(LegacyWecomBotAdapter._iter_media_components) + _send_media = staticmethod(LegacyWecomBotAdapter._send_media) + async def send_message( self, target_type: str, @@ -137,7 +143,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo raise NotSupportedError('send_message:webhook_mode') if target_type not in ('person', 'private', 'group'): raise NotSupportedError(f'send_message:{target_type}') - content = await WecomBotMessageConverter.yiri2target(message) + items = await WecomBotMessageConverter.yiri2target(message) + content = self._join_text_components(items) raw = await self.bot.send_message(str(target_id), content) return platform_events.MessageResult(raw={'result': raw}) @@ -150,9 +157,14 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo event = await WecomBotEventConverter.yiri2target(message_source) if not isinstance(event, WecomBotEvent): raise ValueError('WeComBot reply_message requires a WecomBotEvent source object') - content = await WecomBotMessageConverter.yiri2target(message) + items = await WecomBotMessageConverter.yiri2target(message) + content = self._join_text_components(items) + raw = None if not self.config.get('enable-webhook', False) and event.get('req_id'): - raw = await self.bot.reply_text(event.get('req_id'), content) + if content: + raw = await self.bot.reply_text(event.get('req_id'), content) + for item in self._iter_media_components(items): + await self._send_media(self.bot, event.get('req_id'), item) else: raw = await self.bot.set_message(event.message_id, content) return platform_events.MessageResult(message_id=event.message_id, raw={'result': raw}) @@ -168,10 +180,17 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo event = await WecomBotEventConverter.yiri2target(message_source) if not isinstance(event, WecomBotEvent): raise ValueError('WeComBot reply_message_chunk requires a WecomBotEvent source object') - content = await WecomBotMessageConverter.yiri2target(message) + items = await WecomBotMessageConverter.yiri2target(message) + content = self._join_text_components(items) success = await self.bot.push_stream_chunk(event.message_id, content, is_final=is_final) if not success and is_final and not self.config.get('enable-webhook', False) and event.get('req_id'): await self.bot.reply_text(event.get('req_id'), content) + if is_final: + if not self.config.get('enable-webhook', False) and event.get('req_id'): + for item in self._iter_media_components(items): + await self._send_media(self.bot, event.get('req_id'), item) + elif not success: + await self.bot.set_message(event.message_id, content) return {'stream': success} async def is_stream_output_supported(self) -> bool: @@ -219,12 +238,16 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo while True: await asyncio.sleep(1) - await self.logger.info('WeComBot EBA adapter running in unified webhook mode') + await self.logger.info('WeComBot Omni adapter running in unified webhook mode') await keep_alive() async def kill(self) -> bool: if not self.config.get('enable-webhook', False): await self.bot.disconnect() + self._message_cache.clear() + self._user_cache.clear() + self._group_cache.clear() + self._member_cache.clear() return True async def is_muted(self, group_id: int | None = None) -> bool: @@ -300,6 +323,14 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo role=platform_entities.MemberRole.MEMBER, display_name=event.sender.nickname, ) + for cache in ( + self._message_cache, + self._user_cache, + self._group_cache, + self._member_cache, + ): + while len(cache) > 4096: + cache.pop(next(iter(cache)), None) def _cleanup_stream_mapping(self): now = time.time() diff --git a/src/langbot/pkg/platform/adapters/wecombot/interaction.py b/src/langbot/pkg/platform/adapters/wecombot/interaction.py index fdcbd9fbd..46e46dd00 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/interaction.py +++ b/src/langbot/pkg/platform/adapters/wecombot/interaction.py @@ -154,7 +154,7 @@ def interaction_event_from_native( target_id = str(event.chatid or event.userid or '') return platform_events.PlatformSpecificEvent( type='platform.specific', - adapter_name='wecombot-eba', + adapter_name='wecombot-omni', action='interaction.submitted', data={ **parsed, diff --git a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml index cef86d4bb..e2dba9fad 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: wecombot-eba + name: wecombot-omni label: en_US: WeComBot zh_Hans: 企业微信智能机器人 @@ -17,10 +17,36 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/wecombot - en: https://link.langbot.app/en/platforms/wecombot - ja: https://link.langbot.app/ja/platforms/wecombot + zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot + en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot + ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot config: + - name: one-click-create + label: + en_US: One-Click Create Bot + zh_Hans: 一键创建机器人 + zh_Hant: 一鍵建立機器人 + description: + en_US: 'Scan QR code with WeCom to automatically create a bot and fill in BotId and Secret. Note: Robot Name needs to + be filled in manually.' + zh_Hans: 使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。注意:机器人名称需手动填写。 + zh_Hant: 使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。注意:機器人名稱需手動填寫。 + type: qr-code-login + login_platform: wecombot + required: false + - name: __system.outbound_ips + label: + en_US: Trusted IPs + zh_Hans: 企业可信 IP + zh_Hant: 企業可信 IP + description: + en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of the bot configuration in the WeCom + admin console + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台智能机器人配置的「企业可信 IP」中 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台智慧機器人設定的「企業可信 IP」中 + type: array[string] + required: false + default: [] - name: BotId label: en_US: BotId diff --git a/src/langbot/pkg/platform/adapters/wecombot/message_converter.py b/src/langbot/pkg/platform/adapters/wecombot/message_converter.py index dae78b0a4..fca3e1f5d 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/message_converter.py +++ b/src/langbot/pkg/platform/adapters/wecombot/message_converter.py @@ -9,35 +9,36 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod - async def yiri2target(message_chain: platform_message.MessageChain) -> str: - content_parts: list[str] = [] + async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]: + items: list[dict] = [] for msg in message_chain: if isinstance(msg, platform_message.Source): continue if isinstance(msg, platform_message.Plain): - content_parts.append(msg.text) + items.append({'type': 'text', 'text': msg.text}) + elif isinstance(msg, (platform_message.Image, platform_message.Voice, platform_message.File)): + kind = ( + 'image' + if isinstance(msg, platform_message.Image) + else ('voice' if isinstance(msg, platform_message.Voice) else 'file') + ) + items.append({'type': kind, 'base64': msg.base64 or '', 'name': getattr(msg, 'name', '') or ''}) elif isinstance(msg, platform_message.At): - content_parts.append(f'@{msg.display or msg.target}') + items.append({'type': 'text', 'text': f'@{msg.display or msg.target}'}) elif isinstance(msg, platform_message.AtAll): - content_parts.append('@all') - elif isinstance(msg, platform_message.Image): - content_parts.append('[Image]') - elif isinstance(msg, platform_message.Voice): - content_parts.append('[Voice]') - elif isinstance(msg, platform_message.File): - content_parts.append(f'[File: {msg.name or msg.file_id or msg.url or "file"}]') + items.append({'type': 'text', 'text': '@all'}) elif isinstance(msg, platform_message.Quote): if msg.id is not None: - content_parts.append(f'[Quote {msg.id}]') + items.append({'type': 'text', 'text': f'[Quote {msg.id}]'}) if msg.origin: - content_parts.append(await WecomBotMessageConverter.yiri2target(msg.origin)) + items.extend(await WecomBotMessageConverter.yiri2target(msg.origin)) elif isinstance(msg, platform_message.Forward): for node in msg.node_list: if node.message_chain: - content_parts.append(await WecomBotMessageConverter.yiri2target(node.message_chain)) + items.extend(await WecomBotMessageConverter.yiri2target(node.message_chain)) else: - content_parts.append(str(msg)) - return '\n'.join(part for part in content_parts if part) + items.append({'type': 'text', 'text': str(msg)}) + return items @staticmethod async def target2yiri(event: WecomBotEvent, bot_name: str = '') -> platform_message.MessageChain: @@ -61,8 +62,12 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte WecomBotMessageConverter._append_link(components, event.link) WecomBotMessageConverter._append_quote(components, event.quote) - if not any(not isinstance(component, (platform_message.Source, platform_message.At)) for component in components): - components.append(platform_message.Unknown(text=f'[unsupported wecombot msgtype: {event.msgtype or "unknown"}]')) + if not any( + not isinstance(component, (platform_message.Source, platform_message.At)) for component in components + ): + components.append( + platform_message.Unknown(text=f'[unsupported wecombot msgtype: {event.msgtype or "unknown"}]') + ) return platform_message.MessageChain(components) @@ -76,7 +81,9 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte def _append_file(components: list[platform_message.MessageComponent], file_info: dict | None): if not file_info: return - file_url = file_info.get('download_url') or file_info.get('url') or file_info.get('fileurl') or file_info.get('path') + file_url = ( + file_info.get('download_url') or file_info.get('url') or file_info.get('fileurl') or file_info.get('path') + ) file_base64 = file_info.get('base64') file_name = file_info.get('filename') or file_info.get('name') file_size = file_info.get('filesize') or file_info.get('size') @@ -140,7 +147,9 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte if not link: return summary = '\n'.join( - filter(None, [link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')]) + filter( + None, [link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')] + ) ) if summary: components.append(platform_message.Plain(text=f'{prefix}{summary}')) @@ -152,7 +161,9 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte origin: list[platform_message.MessageComponent] = [] if quote_info.get('content'): origin.append(platform_message.Plain(text=quote_info.get('content'))) - WecomBotMessageConverter._append_images(origin, quote_info.get('images') or ([quote_info.get('picurl')] if quote_info.get('picurl') else [])) + WecomBotMessageConverter._append_images( + origin, quote_info.get('images') or ([quote_info.get('picurl')] if quote_info.get('picurl') else []) + ) WecomBotMessageConverter._append_file(origin, quote_info.get('file')) WecomBotMessageConverter._append_voice(origin, quote_info.get('voice')) WecomBotMessageConverter._append_video(origin, quote_info.get('video')) diff --git a/src/langbot/pkg/platform/adapters/wecombot/types.py b/src/langbot/pkg/platform/adapters/wecombot/types.py index 8e0d98dfa..c81bd17a7 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/types.py +++ b/src/langbot/pkg/platform/adapters/wecombot/types.py @@ -1,3 +1,3 @@ from __future__ import annotations -ADAPTER_NAME = 'wecombot-eba' +ADAPTER_NAME = 'wecombot-omni' diff --git a/src/langbot/pkg/platform/adapters/wecomcs/adapter.py b/src/langbot/pkg/platform/adapters/wecomcs/adapter.py index 017482b5d..2fb12ef7f 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/adapter.py +++ b/src/langbot/pkg/platform/adapters/wecomcs/adapter.py @@ -105,7 +105,9 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform content_list = await WecomCSMessageConverter.yiri2target(message, self.bot) raw_results = [] for content in content_list: - raw_results.append(await self._send_content(open_kfid, external_userid, self._make_outbound_msgid(), content)) + raw_results.append( + await self._send_content(open_kfid, external_userid, self._make_outbound_msgid(), content) + ) return platform_events.MessageResult(raw={'results': raw_results}) async def reply_message( @@ -164,10 +166,14 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform while True: await asyncio.sleep(1) - await self.logger.info('WeComCS EBA adapter running in unified webhook mode') + await self.logger.info('WeComCS Omni adapter running in unified webhook mode') await keep_alive() async def kill(self) -> bool: + self.bot.clear() + await self.bot.close() + self._message_cache.clear() + self._user_cache.clear() return True async def is_muted(self, group_id: int | None = None) -> bool: @@ -213,6 +219,12 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform return self._message_cache[str(event.message_id)] = event self._user_cache[str(event.sender.id)] = event.sender + for cache in ( + self._message_cache, + self._user_cache, + ): + while len(cache) > 4096: + cache.pop(next(iter(cache)), None) async def _send_content(self, open_kfid: str, external_userid: str, msgid: str, content: dict): content_type = content.get('type') diff --git a/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml b/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml index 2e70e52f5..d1f04c7cc 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: wecomcs-eba + name: wecomcs-omni label: en_US: WeCom Customer Service zh_Hans: 企业微信客服 @@ -17,10 +17,23 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/wecomcs - en: https://link.langbot.app/en/platforms/wecomcs - ja: https://link.langbot.app/ja/platforms/wecomcs + zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs + en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs + ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs config: + - name: __system.outbound_ips + label: + en_US: Trusted IPs + zh_Hans: 企业可信 IP + zh_Hant: 企業可信 IP + description: + en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of WeChat Customer Service in the + WeCom admin console + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台微信客服的「企业可信 IP」中 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台微信客服的「企業可信 IP」中 + type: array[string] + required: false + default: [] - name: webhook_url label: en_US: Webhook Callback URL diff --git a/src/langbot/pkg/platform/adapters/wecomcs/types.py b/src/langbot/pkg/platform/adapters/wecomcs/types.py index ce8c7e457..e32f28d36 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/types.py +++ b/src/langbot/pkg/platform/adapters/wecomcs/types.py @@ -1,10 +1,10 @@ from __future__ import annotations -ADAPTER_NAME = 'wecomcs-eba' +ADAPTER_NAME = 'wecomcs-omni' def make_private_chat_id(user_id: str | int | None, open_kfid: str | int | None) -> str: - """Build the routable private chat id used by the WeCom CS EBA adapter.""" + """Build the routable private chat id used by the WeCom CS Omni adapter.""" user = str(user_id or '') kfid = str(open_kfid or '') if not user or not kfid: diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 4fe623d6f..0f1591b48 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -35,6 +35,7 @@ from ..api.http.authz import WorkspaceRequiredError from ..workspace.errors import WorkspaceInvariantError from .logger import EventLogger +from .adapter_names import canonical_adapter_name import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.message as provider_message @@ -932,7 +933,7 @@ class RuntimeBot: binding=event_binding, target_type=target_type, target_uuid=event_binding.get('target_uuid'), - text=f'EBA event {event_type} matched route {event_binding.get("id") or ""}'.strip(), + text=f'Event {event_type} matched route {event_binding.get("id") or ""}'.strip(), ) if target_type == 'discard': if isinstance(event, platform_events.MessageReceivedEvent): @@ -947,14 +948,14 @@ class RuntimeBot: status='discarded', binding=event_binding, target_type=target_type, - text=f'EBA event {event_type} discarded by event binding', + text=f'Event {event_type} discarded by event binding', ) return await self._record_event_route_trace( event_type=event_type, status='discarded', binding=event_binding, target_type=target_type, - text=f'EBA event {event_type} discarded by event binding', + text=f'Event {event_type} discarded by event binding', ) if target_type == 'pipeline': if not self._is_message_event_type(event_type): @@ -967,7 +968,7 @@ class RuntimeBot: target_uuid=event_binding.get('target_uuid'), failure_code='processor_incompatible', reason='Pipeline targets only support message events', - text=f'EBA event {event_type} ignored Pipeline target for non-message event', + text=f'Event {event_type} ignored Pipeline target for non-message event', ) await self._dispatch_eba_message_to_pipeline( event, @@ -981,7 +982,7 @@ class RuntimeBot: binding=event_binding, target_type=target_type, target_uuid=event_binding.get('target_uuid'), - text=f'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(), + text=f'Event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(), ) if target_type not in {'agent', 'event_processor'}: return await self._record_event_route_trace( @@ -993,7 +994,7 @@ class RuntimeBot: target_uuid=event_binding.get('target_uuid'), failure_code='processor_incompatible', reason=f'Unsupported event binding target type: {target_type}', - text=f'EBA event {event_type} ignored unsupported target type {target_type}', + text=f'Event {event_type} ignored unsupported target type {target_type}', ) target_uuid = event_binding.get('target_uuid') @@ -1008,7 +1009,7 @@ class RuntimeBot: target_uuid=target_uuid, failure_code='processor_not_found', reason='Agent target not found', - text=f'EBA event {event_type} target agent not found: {target_uuid}', + text=f'Event {event_type} target agent not found: {target_uuid}', ) if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type): return await self._record_event_route_trace( @@ -1019,7 +1020,7 @@ class RuntimeBot: target_uuid=target_uuid, failure_code='processor_incompatible', reason='Agent target does not support this event type', - text=f'EBA event {event_type} target agent does not support this event: {target_uuid}', + text=f'Event {event_type} target agent does not support this event: {target_uuid}', ) try: @@ -1046,7 +1047,7 @@ class RuntimeBot: target_uuid=target_uuid, failure_code='processor_not_found', reason='Agent target has no runner', - text=f'EBA event {event_type} target agent has no runner: {target_uuid}', + text=f'Event {event_type} target agent has no runner: {target_uuid}', ) envelope = self._eba_event_to_agent_envelope(event, adapter) @@ -1096,7 +1097,7 @@ class RuntimeBot: binding=event_binding, target_type=target_type, target_uuid=target_uuid, - text=f'EBA event {event_type} delivered to Agent {target_uuid}', + text=f'Event {event_type} delivered to Agent {target_uuid}', ) def resolve_event_pipeline_uuid( @@ -1475,7 +1476,7 @@ class RuntimeBot: ) -> None: if not isinstance(event, platform_events.MessageReceivedEvent): event_type = getattr(event, 'type', None) or event.__class__.__name__ - await self.logger.warning(f'EBA event {event_type} cannot be dispatched to legacy Pipeline') + await self.logger.warning(f'Event {event_type} cannot be dispatched to legacy Pipeline') return await self._handle_legacy_message_event( @@ -1806,7 +1807,10 @@ class PlatformManager: # delete all bot log images await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images') - disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or [] + disabled_adapters = { + canonical_adapter_name(name) + for name in (self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []) + } self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter') adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {} @@ -2103,6 +2107,7 @@ class PlatformManager: owner=bot_entity.uuid, ) + bot_entity.adapter = canonical_adapter_name(bot_entity.adapter) if bot_entity.adapter not in self.adapter_dict: raise platform_errors.AdapterNotFoundError(bot_entity.adapter) @@ -2226,12 +2231,14 @@ class PlatformManager: ] def get_available_adapter_info_by_name(self, name: str) -> dict | None: + name = canonical_adapter_name(name) for component in self.adapter_components: if component.metadata.name == name: return component.to_plain_dict() return None def get_available_adapter_manifest_by_name(self, name: str) -> engine.Component | None: + name = canonical_adapter_name(name) for component in self.adapter_components: if component.metadata.name == name: return component diff --git a/src/langbot/pkg/platform/sources/aiocqhttp.yaml b/src/langbot/pkg/platform/sources/aiocqhttp.yaml index 18b233750..d9880bcfe 100644 --- a/src/langbot/pkg/platform/sources/aiocqhttp.yaml +++ b/src/langbot/pkg/platform/sources/aiocqhttp.yaml @@ -16,9 +16,9 @@ spec: categories: - protocol help_links: - zh: https://link.langbot.app/zh/platforms/aiocqhttp - en: https://link.langbot.app/en/platforms/aiocqhttp - ja: https://link.langbot.app/ja/platforms/aiocqhttp + zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat + en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat + ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat config: - name: host label: diff --git a/src/langbot/pkg/platform/sources/dingtalk.yaml b/src/langbot/pkg/platform/sources/dingtalk.yaml index 20528ef77..7ced7de90 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.yaml +++ b/src/langbot/pkg/platform/sources/dingtalk.yaml @@ -16,9 +16,9 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/dingtalk - en: https://link.langbot.app/en/platforms/dingtalk - ja: https://link.langbot.app/ja/platforms/dingtalk + zh: https://langbot.app/docs/zh/usage/platforms/dingtalk + en: https://langbot.app/docs/en/usage/platforms/dingtalk + ja: https://langbot.app/docs/ja/usage/platforms/dingtalk config: - name: one-click-create label: diff --git a/src/langbot/pkg/platform/sources/discord.py b/src/langbot/pkg/platform/sources/discord.py index 69f349056..e7119e391 100644 --- a/src/langbot/pkg/platform/sources/discord.py +++ b/src/langbot/pkg/platform/sources/discord.py @@ -25,7 +25,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger -from ..logger import EventLogger +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger as EventLogger _MAX_DISCORD_MEDIA_BYTES = 10 * 1024 * 1024 diff --git a/src/langbot/pkg/platform/sources/discord.yaml b/src/langbot/pkg/platform/sources/discord.yaml index c781110bd..e06093c87 100644 --- a/src/langbot/pkg/platform/sources/discord.yaml +++ b/src/langbot/pkg/platform/sources/discord.yaml @@ -25,9 +25,9 @@ spec: - popular - global help_links: - zh: https://link.langbot.app/zh/platforms/discord - en: https://link.langbot.app/en/platforms/discord - ja: https://link.langbot.app/ja/platforms/discord + zh: https://langbot.app/docs/zh/usage/platforms/discord + en: https://langbot.app/docs/en/usage/platforms/discord + ja: https://langbot.app/docs/ja/usage/platforms/discord config: - name: client_id label: diff --git a/src/langbot/pkg/platform/sources/http_bot.yaml b/src/langbot/pkg/platform/sources/http_bot.yaml index 56ef57f26..97392a444 100644 --- a/src/langbot/pkg/platform/sources/http_bot.yaml +++ b/src/langbot/pkg/platform/sources/http_bot.yaml @@ -18,9 +18,9 @@ spec: - popular - global help_links: - zh: https://docs.langbot.app/zh/platforms/http-bot - en: https://docs.langbot.app/en/platforms/http-bot - ja: https://docs.langbot.app/ja/platforms/http-bot + zh: https://langbot.app/docs/zh/platforms/http-bot + en: https://langbot.app/docs/en/platforms/http-bot + ja: https://langbot.app/docs/ja/platforms/http-bot config: - name: webhook_url label: diff --git a/src/langbot/pkg/platform/sources/kook.yaml b/src/langbot/pkg/platform/sources/kook.yaml index e485190de..cb65c5aa9 100644 --- a/src/langbot/pkg/platform/sources/kook.yaml +++ b/src/langbot/pkg/platform/sources/kook.yaml @@ -16,9 +16,9 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/kook - en: https://link.langbot.app/en/platforms/kook - ja: https://link.langbot.app/ja/platforms/kook + zh: https://langbot.app/docs/zh/usage/platforms/kook + en: https://langbot.app/docs/en/usage/platforms/kook + ja: https://langbot.app/docs/ja/usage/platforms/kook config: - name: token label: diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 5eff8dd7d..b4c4dc827 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -170,6 +170,29 @@ def _lark_should_update_stream_element( return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final) +def _lark_final_layout_texts( + *, + resume_from: bool, + text_message: str, + pre_pause_cached: str | None, + resume_cached: str, +) -> tuple[str, str]: + """Return (main_text, resume_placeholder_text) for the final card update. + + Non-resume round: the full reply belongs in the main streaming element + only — also rendering the resume placeholder duplicates the reply, since + both hold the same accumulated text. Resume round (Dify HITL): keep the + pre-pause text in the main element and the resumed text in the + placeholder, as they are distinct segments. + """ + if resume_from: + # An empty pre-pause cache is valid (Dify paused before emitting any + # text); only a missing entry (None) falls back to the full text. + main_text = text_message if pre_pause_cached is None else pre_pause_cached + return main_text, resume_cached + return text_message, '' + + def _lark_display_input_value(field: dict, value: typing.Any) -> str: field_type = _dify_field_type(field) if field_type == 'file': @@ -2368,16 +2391,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data) self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) else: - # Normal finish: keep pre-pause + resume content visible, - # remove buttons/notice, drop the resume placeholder. + # Normal finish: remove buttons/notice and finalize the card. + main_text, resume_text = _lark_final_layout_texts( + resume_from=resume_from, + text_message=text_message, + pre_pause_cached=self.card_pre_pause_text.get(card_id), + resume_cached=resume_cached, + ) await self._update_card_layout( card_id=card_id, message_source=message_source, - text_message=pre_pause, + text_message=main_text, sequence=final_seq, form_data=None, notice_text=selected_notice if resume_from else '', - resume_placeholder_text=resume_cached, + resume_placeholder_text=resume_text, ) self._drop_card_state(card_id) self.card_id_dict.pop(message_id, None) diff --git a/src/langbot/pkg/platform/sources/lark.yaml b/src/langbot/pkg/platform/sources/lark.yaml index acde1da56..6c3a59636 100644 --- a/src/langbot/pkg/platform/sources/lark.yaml +++ b/src/langbot/pkg/platform/sources/lark.yaml @@ -20,9 +20,9 @@ spec: - china - global help_links: - zh: https://link.langbot.app/zh/platforms/lark - en: https://link.langbot.app/en/platforms/lark - ja: https://link.langbot.app/ja/platforms/lark + zh: https://langbot.app/docs/zh/usage/platforms/lark + en: https://langbot.app/docs/en/usage/platforms/lark + ja: https://langbot.app/docs/ja/usage/platforms/lark config: - name: domain label: diff --git a/src/langbot/pkg/platform/sources/line.yaml b/src/langbot/pkg/platform/sources/line.yaml index 5ee538121..83f520286 100644 --- a/src/langbot/pkg/platform/sources/line.yaml +++ b/src/langbot/pkg/platform/sources/line.yaml @@ -22,9 +22,9 @@ spec: categories: - global help_links: - zh: https://link.langbot.app/zh/platforms/line - en: https://link.langbot.app/en/platforms/line - ja: https://link.langbot.app/ja/platforms/line + zh: https://langbot.app/docs/zh/usage/platforms/line + en: https://langbot.app/docs/en/usage/platforms/line + ja: https://langbot.app/docs/ja/usage/platforms/line config: - name: webhook_url label: diff --git a/src/langbot/pkg/platform/sources/matrix.py b/src/langbot/pkg/platform/sources/matrix.py index 7cf39dd86..f0a9822e3 100644 --- a/src/langbot/pkg/platform/sources/matrix.py +++ b/src/langbot/pkg/platform/sources/matrix.py @@ -682,8 +682,8 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)') continue - # Use configured logout command, fallback to deriving from login command - logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') + # Use configured logout command, fallback to deriving from login command + logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...') # Cancel existing tasks diff --git a/src/langbot/pkg/platform/sources/mattermost.py b/src/langbot/pkg/platform/sources/mattermost.py new file mode 100644 index 000000000..d9ae84c2c --- /dev/null +++ b/src/langbot/pkg/platform/sources/mattermost.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import asyncio +import json +import re +import typing +from urllib.parse import urlsplit, urlunsplit + +import aiohttp + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +_MATTERMOST_MAX_POST_LENGTH = 16_383 +_MENTION_BOUNDARY = r'(? str: + """Return a validated Mattermost server URL without a trailing slash.""" + + url = server_url.strip().rstrip('/') + parsed = urlsplit(url) + if parsed.scheme not in {'http', 'https'} or not parsed.netloc: + raise ValueError('Mattermost server_url must be an absolute HTTP(S) URL') + return url + + +def _websocket_url(server_url: str) -> str: + parsed = urlsplit(server_url) + scheme = 'wss' if parsed.scheme == 'https' else 'ws' + return urlunsplit((scheme, parsed.netloc, f'{parsed.path}/api/v4/websocket', '', '')) + + +class MattermostMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + """Translate Mattermost post text to and from LangBot message chains.""" + + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> str: + parts: list[str] = [] + for component in message_chain: + if isinstance(component, platform_message.Plain): + parts.append(component.text) + elif isinstance(component, platform_message.Image) and component.url: + # Mattermost renders image URLs in Markdown messages. + parts.append(component.url) + elif isinstance(component, platform_message.File) and component.url: + parts.append(component.url) + return ''.join(parts) + + @staticmethod + async def target2yiri(post: dict, bot_username: str) -> platform_message.MessageChain: + text = str(post.get('message') or '') + components: list[typing.Any] = [ + platform_message.Source( + id=str(post.get('id') or ''), + time=float(post.get('create_at') or 0) / 1000, + ) + ] + if bot_username: + mention_pattern = re.compile(_MENTION_BOUNDARY.format(username=re.escape(bot_username)), re.IGNORECASE) + if mention_pattern.search(text): + components.append(platform_message.At(target=bot_username)) + text = mention_pattern.sub('', text).strip() + if text: + components.append(platform_message.Plain(text=text)) + return platform_message.MessageChain(components) + + +class MattermostEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.MessageEvent) -> dict: + return event.source_platform_object + + @staticmethod + async def target2yiri( + post: dict, + channel: dict, + sender_name: str, + bot_username: str, + ) -> platform_events.MessageEvent: + message_chain = await MattermostMessageConverter.target2yiri(post, bot_username) + timestamp = float(post.get('create_at') or 0) / 1000 + sender_id = str(post.get('user_id') or '') + channel_type = channel.get('type') + + if channel_type == 'D': + return platform_events.FriendMessage( + sender=platform_entities.Friend(id=sender_id, nickname=sender_name or sender_id, remark=''), + message_chain=message_chain, + time=timestamp, + source_platform_object={'post': post, 'channel': channel}, + ) + + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=sender_id, + member_name=sender_name or sender_id, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=str(post.get('channel_id') or ''), + name=str(channel.get('display_name') or channel.get('name') or post.get('channel_id') or ''), + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=timestamp, + source_platform_object={'post': post, 'channel': channel}, + ) + + +class MattermostAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """Mattermost Bot Account adapter using the v4 REST and WebSocket APIs.""" + + server_url: str = '' + access_token: str = '' + session: aiohttp.ClientSession | None = None + listeners: dict[typing.Type[platform_events.Event], typing.Callable] = {} + channel_cache: dict[str, dict] = {} + stream_post_ids: dict[str, str] = {} + bot_username: str = '' + _running: bool = False + + message_converter: MattermostMessageConverter = MattermostMessageConverter() + event_converter: MattermostEventConverter = MattermostEventConverter() + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + server_url = _normalize_server_url(str(config.get('server_url') or '')) + access_token = str(config.get('access_token') or '').strip() + if not access_token: + raise ValueError('Mattermost adapter requires an access_token') + + super().__init__( + config=config, + logger=logger, + server_url=server_url, + access_token=access_token, + bot_account_id='', + session=None, + listeners={}, + channel_cache={}, + stream_post_ids={}, + bot_username='', + _running=False, + ) + + async def _get_session(self) -> aiohttp.ClientSession: + if self.session is None or self.session.closed: + self.session = aiohttp.ClientSession( + headers={'Authorization': f'Bearer {self.access_token}'}, + raise_for_status=False, + ) + return self.session + + async def _api_request( + self, + method: str, + path: str, + *, + payload: dict | None = None, + ) -> dict: + session = await self._get_session() + async with session.request(method, f'{self.server_url}/api/v4{path}', json=payload) as response: + raw_body = await response.text() + if response.status >= 400: + # Mattermost returns a useful JSON error, but never include request headers/tokens in errors. + try: + error = json.loads(raw_body).get('message', raw_body) + except json.JSONDecodeError: + error = raw_body + raise RuntimeError(f'Mattermost API {method} {path} failed ({response.status}): {error}') + if not raw_body: + return {} + return json.loads(raw_body) + + async def _load_bot_identity(self) -> None: + user = await self._api_request('GET', '/users/me') + self.bot_account_id = str(user.get('id') or '') + self.bot_username = str(user.get('username') or '') + if not self.bot_account_id: + raise RuntimeError('Mattermost API did not return a bot user ID') + + async def _get_channel(self, channel_id: str) -> dict: + if channel_id not in self.channel_cache: + self.channel_cache[channel_id] = await self._api_request('GET', f'/channels/{channel_id}') + return self.channel_cache[channel_id] + + async def _post_message(self, channel_id: str, text: str, root_id: str = '') -> dict: + if not text: + return {} + if len(text) > _MATTERMOST_MAX_POST_LENGTH: + raise ValueError(f'Mattermost messages cannot exceed {_MATTERMOST_MAX_POST_LENGTH} characters') + payload = {'channel_id': channel_id, 'message': text} + if root_id: + payload['root_id'] = root_id + return await self._api_request('POST', '/posts', payload=payload) + + async def _get_direct_channel_id(self, user_id: str) -> str: + if not self.bot_account_id: + await self._load_bot_identity() + channel = await self._api_request( + 'POST', + '/channels/direct', + payload={'user_ids': [self.bot_account_id, user_id]}, + ) + channel_id = str(channel.get('id') or '') + if not channel_id: + raise RuntimeError('Mattermost did not return a direct-message channel ID') + self.channel_cache[channel_id] = channel + return channel_id + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + if target_type not in {'person', 'group'}: + raise ValueError("Mattermost target_type must be 'person' or 'group'") + text = await self.message_converter.yiri2target(message) + channel_id = str(target_id) + if target_type == 'person': + channel_id = await self._get_direct_channel_id(channel_id) + await self._post_message(channel_id, text) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + source = await self.event_converter.yiri2target(message_source) + post = source['post'] + text = await self.message_converter.yiri2target(message) + # A message received inside a Mattermost thread must remain in that thread. When + # quote_origin is requested, make the response a reply to the source root post. + root_id = str(post.get('root_id') or '') + if quote_origin and not root_id: + root_id = str(post.get('id') or '') + await self._post_message(str(post['channel_id']), text, root_id) + + async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool: + source = await self.event_converter.yiri2target(event) + post = source['post'] + root_id = str(post.get('root_id') or post.get('id') or '') + created = await self._post_message(str(post['channel_id']), 'Thinking…', root_id) + if created.get('id'): + self.stream_post_ids[str(message_id)] = str(created['id']) + return True + return False + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + response_id = str(bot_message.resp_message_id) + text = await self.message_converter.yiri2target(message) + if not text: + return + + post_id = self.stream_post_ids.get(response_id) + if post_id: + await self._api_request('PUT', f'/posts/{post_id}', payload={'id': post_id, 'message': text}) + else: + source = await self.event_converter.yiri2target(message_source) + post = source['post'] + root_id = str(post.get('root_id') or '') + if quote_origin and not root_id: + root_id = str(post.get('id') or '') + created = await self._post_message(str(post['channel_id']), text, root_id) + post_id = str(created.get('id') or '') + if post_id: + self.stream_post_ids[response_id] = post_id + + if is_final and getattr(bot_message, 'tool_calls', None) is None: + self.stream_post_ids.pop(response_id, None) + + async def is_stream_output_supported(self) -> bool: + return bool(self.config.get('enable_stream_reply', True)) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None] + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None] + ], + ): + self.listeners.pop(event_type, None) + + async def _dispatch_post(self, payload: dict) -> None: + data = payload.get('data') or {} + try: + post = json.loads(data.get('post') or '{}') + except (TypeError, json.JSONDecodeError): + await self.logger.error('Mattermost received a posted event with an invalid post payload') + return + + if not post or str(post.get('user_id') or '') == self.bot_account_id: + return + channel_id = str(post.get('channel_id') or '') + if not channel_id: + return + + try: + channel = await self._get_channel(channel_id) + event = await self.event_converter.target2yiri( + post, + channel, + str(data.get('sender_name') or post.get('user_id') or ''), + self.bot_username, + ) + callback = self.listeners.get(type(event)) + if callback: + result = callback(event, self) + if asyncio.iscoroutine(result): + await result + except Exception as exc: + await self.logger.error(f'Error handling Mattermost post: {exc}') + + async def _run_websocket_once(self) -> None: + session = await self._get_session() + async with session.ws_connect(_websocket_url(self.server_url), heartbeat=30) as websocket: + await websocket.send_json( + { + 'seq': 1, + 'action': 'authentication_challenge', + 'data': {'token': self.access_token}, + } + ) + async for message in websocket: + if message.type == aiohttp.WSMsgType.TEXT: + try: + payload = json.loads(message.data) + except json.JSONDecodeError: + continue + if payload.get('event') == 'posted': + await self._dispatch_post(payload) + elif message.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR}: + break + + async def run_async(self): + self._running = True + await self._load_bot_identity() + await self.logger.info(f'Mattermost bot connected: @{self.bot_username} ({self.bot_account_id})') + + retry_delay = 1 + while self._running: + try: + await self._run_websocket_once() + retry_delay = 1 + except asyncio.CancelledError: + raise + except Exception as exc: + if self._running: + await self.logger.error(f'Mattermost WebSocket disconnected: {exc}') + await asyncio.sleep(retry_delay) + retry_delay = min(retry_delay * 2, 30) + + async def kill(self) -> bool: + self._running = False + if self.session and not self.session.closed: + await self.session.close() + return True diff --git a/src/langbot/pkg/platform/sources/mattermost.svg b/src/langbot/pkg/platform/sources/mattermost.svg new file mode 100644 index 000000000..d185ef27d --- /dev/null +++ b/src/langbot/pkg/platform/sources/mattermost.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/langbot/pkg/platform/sources/mattermost.yaml b/src/langbot/pkg/platform/sources/mattermost.yaml new file mode 100644 index 000000000..391f06902 --- /dev/null +++ b/src/langbot/pkg/platform/sources/mattermost.yaml @@ -0,0 +1,75 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: mattermost + label: + en_US: Mattermost + zh_Hans: Mattermost + zh_Hant: Mattermost + ja_JP: Mattermost + th_TH: Mattermost + vi_VN: Mattermost + es_ES: Mattermost + icon: mattermost.svg + description: + en_US: Mattermost Bot Account adapter using the v4 REST and WebSocket APIs. Add me to the teams and channels where you want me to interact. Please use a browser or desktop application to do this. + zh_Hans: 使用 Mattermost v4 REST API 与 WebSocket 的 Bot Account 适配器。请将我添加到您想要我互动的团队与频道。请使用浏览器或桌面应用进行操作。 + zh_Hant: 使用 Mattermost v4 REST API 與 WebSocket 的 Bot Account 介面卡。請將我加入您希望我互動的團隊與頻道。請使用瀏覽器或桌面應用程式操作。 + ja_JP: Mattermost v4 REST API と WebSocket を使用する Bot Account アダプター。利用させたいチームとチャンネルに私を追加してください。ブラウザまたはデスクトップアプリで操作してください。 + th_TH: อะแดปเตอร์ Bot Account ของ Mattermost ผ่าน v4 REST API และ WebSocket โปรดเพิ่มฉันไปยังทีมและช่องที่คุณต้องการให้ฉันโต้ตอบ โปรดดำเนินการผ่านเบราว์เซอร์หรือแอปเดสก์ท็อป + vi_VN: Bộ điều hợp Bot Account Mattermost sử dụng REST API v4 và WebSocket. Hãy thêm tôi vào các nhóm và kênh mà bạn muốn tôi tương tác. Vui lòng thao tác bằng trình duyệt hoặc ứng dụng máy tính để bàn. + es_ES: Adaptador de Bot Account de Mattermost mediante REST API v4 y WebSocket. Añádeme a los equipos y canales en los que quieras que interactúe. Hazlo desde un navegador o la aplicación de escritorio. +spec: + categories: + - global + - popular + config: + - name: server_url + label: + en_US: Mattermost Server URL + zh_Hans: Mattermost 服务器地址 + zh_Hant: 位址伺服器 Mattermost + ja_JP: Mattermost サーバー URL + th_TH: URL เซิร์ฟเวอร์ Mattermost + vi_VN: URL máy chủ Mattermost + es_ES: URL del servidor Mattermost + description: + en_US: The base URL of the Mattermost server, for example https://mattermost.example.com + zh_Hans: Mattermost 服务器基础地址,例如 https://mattermost.example.com + type: string + required: true + default: "" + - name: access_token + label: + en_US: Bot Access Token + zh_Hans: Bot 访问令牌 + zh_Hant: Bot 存取權杖 + ja_JP: Bot アクセストークン + th_TH: โทเค็นการเข้าถึงของบอต + vi_VN: Mã truy cập Bot + es_ES: Token de acceso del bot + description: + en_US: The personal access token generated for the Mattermost Bot Account + zh_Hans: 为 Mattermost Bot Account 生成的个人访问令牌 + type: string + required: true + default: "" + - name: enable_stream_reply + label: + en_US: Enable Stream Reply + zh_Hans: 启用流式回复 + zh_Hant: 啟用串流回覆 + ja_JP: ストリーミング返信を有効化 + th_TH: เปิดใช้งานการตอบกลับแบบสตรีม + vi_VN: Bật phản hồi luồng + es_ES: Activar respuesta en streaming + description: + en_US: Update a Mattermost post while LangBot generates a response + zh_Hans: 在 LangBot 生成回复时持续更新同一条 Mattermost 消息 + type: boolean + required: false + default: true +execution: + python: + path: ./mattermost.py + attr: MattermostAdapter diff --git a/src/langbot/pkg/platform/sources/officialaccount.yaml b/src/langbot/pkg/platform/sources/officialaccount.yaml index 19b7c8621..7ad8ea8ef 100644 --- a/src/langbot/pkg/platform/sources/officialaccount.yaml +++ b/src/langbot/pkg/platform/sources/officialaccount.yaml @@ -16,9 +16,9 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/officialaccount - en: https://link.langbot.app/en/platforms/officialaccount - ja: https://link.langbot.app/ja/platforms/officialaccount + zh: https://langbot.app/docs/zh/usage/platforms/wxoa + en: https://langbot.app/docs/en/usage/platforms/wxoa + ja: https://langbot.app/docs/ja/usage/platforms/wxoa config: - name: webhook_url label: diff --git a/src/langbot/pkg/platform/sources/openclaw_weixin.yaml b/src/langbot/pkg/platform/sources/openclaw_weixin.yaml index a8dec6444..963b6da7a 100644 --- a/src/langbot/pkg/platform/sources/openclaw_weixin.yaml +++ b/src/langbot/pkg/platform/sources/openclaw_weixin.yaml @@ -16,9 +16,9 @@ spec: - popular - china help_links: - zh: https://link.langbot.app/zh/platforms/openclaw_weixin - en: https://link.langbot.app/en/platforms/openclaw_weixin - ja: https://link.langbot.app/ja/platforms/openclaw_weixin + zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin + en: https://langbot.app/docs/en/usage/platforms/readme + ja: https://langbot.app/docs/ja/usage/platforms/readme config: - name: base_url label: diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index f65a9683e..f012b07f9 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter bot = QQOfficialClient( app_id=config['appid'], secret=config['secret'], - token=config['token'], + token=config.get('token', ''), logger=logger, unified_mode=enable_webhook, ) diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml index aacc2ab24..46842b1f5 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.yaml +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -16,9 +16,9 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/qqofficial - en: https://link.langbot.app/en/platforms/qqofficial - ja: https://link.langbot.app/ja/platforms/qqofficial + zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook + en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook + ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook config: - name: __system.outbound_ips label: diff --git a/src/langbot/pkg/platform/sources/satori.yaml b/src/langbot/pkg/platform/sources/satori.yaml index a2506bb62..2ddc40f48 100644 --- a/src/langbot/pkg/platform/sources/satori.yaml +++ b/src/langbot/pkg/platform/sources/satori.yaml @@ -21,9 +21,9 @@ spec: categories: - protocol help_links: - zh: https://link.langbot.app/zh/platforms/satori - en: https://link.langbot.app/en/platforms/satori - ja: https://link.langbot.app/ja/platforms/satori + zh: https://langbot.app/docs/zh/usage/platforms/readme + en: https://langbot.app/docs/en/usage/platforms/readme + ja: https://langbot.app/docs/ja/usage/platforms/readme config: - name: platform label: diff --git a/src/langbot/pkg/platform/sources/slack.yaml b/src/langbot/pkg/platform/sources/slack.yaml index 2827276a2..44fd4b337 100644 --- a/src/langbot/pkg/platform/sources/slack.yaml +++ b/src/langbot/pkg/platform/sources/slack.yaml @@ -25,9 +25,9 @@ spec: - popular - global help_links: - zh: https://link.langbot.app/zh/platforms/slack - en: https://link.langbot.app/en/platforms/slack - ja: https://link.langbot.app/ja/platforms/slack + zh: https://langbot.app/docs/zh/usage/platforms/slack + en: https://langbot.app/docs/en/usage/platforms/slack + ja: https://langbot.app/docs/ja/usage/platforms/slack config: - name: webhook_url label: diff --git a/src/langbot/pkg/platform/sources/telegram.yaml b/src/langbot/pkg/platform/sources/telegram.yaml index c3f38d7e3..810d94ac2 100644 --- a/src/langbot/pkg/platform/sources/telegram.yaml +++ b/src/langbot/pkg/platform/sources/telegram.yaml @@ -25,9 +25,9 @@ spec: - popular - global help_links: - zh: https://link.langbot.app/zh/platforms/telegram - en: https://link.langbot.app/en/platforms/telegram - ja: https://link.langbot.app/ja/platforms/telegram + zh: https://langbot.app/docs/zh/usage/platforms/telegram + en: https://langbot.app/docs/en/usage/platforms/telegram + ja: https://langbot.app/docs/ja/usage/platforms/telegram config: - name: token label: diff --git a/src/langbot/pkg/platform/sources/wechatpad.yaml b/src/langbot/pkg/platform/sources/wechatpad.yaml index e02c6aa68..15cc84533 100644 --- a/src/langbot/pkg/platform/sources/wechatpad.yaml +++ b/src/langbot/pkg/platform/sources/wechatpad.yaml @@ -15,9 +15,9 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/wechatpad - en: https://link.langbot.app/en/platforms/wechatpad - ja: https://link.langbot.app/ja/platforms/wechatpad + zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad + en: https://langbot.app/docs/en/usage/platforms/readme + ja: https://langbot.app/docs/ja/usage/platforms/readme config: - name: wechatpad_url label: diff --git a/src/langbot/pkg/platform/sources/wecom.py b/src/langbot/pkg/platform/sources/wecom.py index 93aaf1f92..d555599af 100644 --- a/src/langbot/pkg/platform/sources/wecom.py +++ b/src/langbot/pkg/platform/sources/wecom.py @@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if content['type'] == 'text': await self.bot.send_private_msg(user_id, agent_id, content['content']) if content['type'] == 'image': - await self.bot.send_image(user_id, agent_id, content['media']) + await self.bot.send_image(user_id, agent_id, content['media_id']) if content['type'] == 'voice': - await self.bot.send_voice(user_id, agent_id, content['media']) + await self.bot.send_voice(user_id, agent_id, content['media_id']) if content['type'] == 'file': - await self.bot.send_file(user_id, agent_id, content['media']) + await self.bot.send_file(user_id, agent_id, content['media_id']) def register_listener( self, diff --git a/src/langbot/pkg/platform/sources/wecom.yaml b/src/langbot/pkg/platform/sources/wecom.yaml index 64f4c00e4..34d346e24 100644 --- a/src/langbot/pkg/platform/sources/wecom.yaml +++ b/src/langbot/pkg/platform/sources/wecom.yaml @@ -17,9 +17,9 @@ spec: - popular - china help_links: - zh: https://link.langbot.app/zh/platforms/wecom - en: https://link.langbot.app/en/platforms/wecom - ja: https://link.langbot.app/ja/platforms/wecom + zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom + en: https://langbot.app/docs/en/usage/platforms/wecom/wecom + ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom config: - name: webhook_url label: diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 3e650519a..92d58da7d 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -11,7 +11,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities -from ..logger import EventLogger +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger as EventLogger from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent from langbot.libs.wecom_ai_bot_api.api import ( WecomBotClient, diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index 0dc1f2cd0..a12e6305d 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -16,9 +16,9 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/wecombot - en: https://link.langbot.app/en/platforms/wecombot - ja: https://link.langbot.app/ja/platforms/wecombot + zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot + en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot + ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot config: - name: one-click-create label: diff --git a/src/langbot/pkg/platform/sources/wecomcs.yaml b/src/langbot/pkg/platform/sources/wecomcs.yaml index 5f6fb26ee..e2340fc9e 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.yaml +++ b/src/langbot/pkg/platform/sources/wecomcs.yaml @@ -16,9 +16,9 @@ spec: categories: - china help_links: - zh: https://link.langbot.app/zh/platforms/wecomcs - en: https://link.langbot.app/en/platforms/wecomcs - ja: https://link.langbot.app/ja/platforms/wecomcs + zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs + en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs + ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs config: - name: webhook_url label: diff --git a/tests/e2e/live_discord_eba_probe.py b/tests/e2e/live_discord_eba_probe.py index 68ce69abb..38eabcb4c 100644 --- a/tests/e2e/live_discord_eba_probe.py +++ b/tests/e2e/live_discord_eba_probe.py @@ -149,9 +149,9 @@ async def run_probe( platform_message.Plain(text='Discord EBA live reply: text'), platform_message.Image(base64=base64.b64encode(PNG_1X1).decode()), platform_message.File( - name='discord-eba-live.txt', + name='discord-omni-live.txt', size=16, - base64='data:text/plain;base64,' + base64.b64encode(b'discord-eba-live').decode(), + base64='data:text/plain;base64,' + base64.b64encode(b'discord-omni-live').decode(), ), ] ), @@ -252,7 +252,7 @@ async def run_probe( await run_expected_error( api_results, 'upload_file:not_supported', - lambda: adapter.upload_file(b'discord-eba-upload', 'discord-eba-upload.txt'), + lambda: adapter.upload_file(b'discord-omni-upload', 'discord-omni-upload.txt'), platform_errors.NotSupportedError, ) await run_api(api_results, 'get_file_url', lambda: adapter.get_file_url('https://cdn.discordapp.com/file.txt')) diff --git a/tests/e2e/live_wecom_eba_probe.py b/tests/e2e/live_wecom_eba_probe.py index 2801e1fc2..f4080addc 100644 --- a/tests/e2e/live_wecom_eba_probe.py +++ b/tests/e2e/live_wecom_eba_probe.py @@ -16,8 +16,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes TINY_PNG = ( - 'data:image/png;base64,' - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' ) @@ -178,7 +177,11 @@ async def run_probe(args: argparse.Namespace): ) await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(source.sender.id)) await run_api(api_results, 'get_friend_list', lambda: adapter.get_friend_list()) - await run_api(api_results, 'call_platform_api:check_access_token', lambda: adapter.call_platform_api('check_access_token', {})) + await run_api( + api_results, + 'call_platform_api:check_access_token', + lambda: adapter.call_platform_api('check_access_token', {}), + ) await run_api( api_results, 'call_platform_api:get_user_info', @@ -203,7 +206,7 @@ def main(): parser.add_argument('--port', type=int, default=5312) parser.add_argument('--path', default='/wecom/callback') parser.add_argument('--timeout', type=int, default=180) - parser.add_argument('--bot-uuid', default='wecom-eba-live-probe') + parser.add_argument('--bot-uuid', default='wecom-omni-live-probe') parser.add_argument('--log', default='data/temp/wecom_eba_live_probe.jsonl') parser.add_argument('--skip-api', action='store_true') args = parser.parse_args() diff --git a/tests/e2e/live_wecombot_eba_probe.py b/tests/e2e/live_wecombot_eba_probe.py index 4818dba5a..332634cdf 100644 --- a/tests/e2e/live_wecombot_eba_probe.py +++ b/tests/e2e/live_wecombot_eba_probe.py @@ -32,7 +32,9 @@ class ProbeLogger(AbstractEventLogger): def redact(value: Any) -> Any: if isinstance(value, dict): return { - key: '' if key.lower() in {'secret', 'token', 'encodingaeskey', 'encrypt', 'aeskey'} else redact(item) + key: '' + if key.lower() in {'secret', 'token', 'encodingaeskey', 'encrypt', 'aeskey'} + else redact(item) for key, item in value.items() } if isinstance(value, list): @@ -157,12 +159,22 @@ async def run_probe(args: argparse.Namespace): platform_message.MessageChain([platform_message.Plain(text='WeComBot EBA probe send')]), ), ) - await run_api(api_results, 'get_message', lambda: adapter.get_message(source.chat_type.value, source.chat_id, source.message_id)) + await run_api( + api_results, + 'get_message', + lambda: adapter.get_message(source.chat_type.value, source.chat_id, source.message_id), + ) await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(source.sender.id)) if source.group: await run_api(api_results, 'get_group_info', lambda: adapter.get_group_info(source.group.id)) - await run_api(api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(source.group.id)) - await run_api(api_results, 'call_platform_api:is_websocket_mode', lambda: adapter.call_platform_api('is_websocket_mode', {})) + await run_api( + api_results, 'get_group_member_list', lambda: adapter.get_group_member_list(source.group.id) + ) + await run_api( + api_results, + 'call_platform_api:is_websocket_mode', + lambda: adapter.call_platform_api('is_websocket_mode', {}), + ) await run_api( api_results, 'call_platform_api:get_stream_session_status', @@ -187,12 +199,14 @@ async def run_probe(args: argparse.Namespace): def main(): parser = argparse.ArgumentParser(description='Live WeComBot EBA adapter probe.') - parser.add_argument('--webhook', action='store_true', help='Use webhook mode. Default is WebSocket long connection mode.') + parser.add_argument( + '--webhook', action='store_true', help='Use webhook mode. Default is WebSocket long connection mode.' + ) parser.add_argument('--host', default='0.0.0.0') parser.add_argument('--port', type=int, default=5313) parser.add_argument('--path', default='/wecombot/callback') parser.add_argument('--timeout', type=int, default=180) - parser.add_argument('--bot-uuid', default='wecombot-eba-live-probe') + parser.add_argument('--bot-uuid', default='wecombot-omni-live-probe') parser.add_argument('--log', default='data/temp/wecombot_eba_live_probe.jsonl') parser.add_argument('--skip-api', action='store_true') args = parser.parse_args() diff --git a/tests/e2e/live_wecomcs_eba_probe.py b/tests/e2e/live_wecomcs_eba_probe.py index 5a4b6f67a..c4d8515bb 100644 --- a/tests/e2e/live_wecomcs_eba_probe.py +++ b/tests/e2e/live_wecomcs_eba_probe.py @@ -16,8 +16,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes TINY_PNG = ( - 'data:image/png;base64,' - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' ) @@ -168,7 +167,9 @@ async def run_probe(args: argparse.Namespace): ), ), ) - await run_api(api_results, 'get_message', lambda: adapter.get_message('private', source.chat_id, source.message_id)) + await run_api( + api_results, 'get_message', lambda: adapter.get_message('private', source.chat_id, source.message_id) + ) await run_api(api_results, 'get_user_info', lambda: adapter.get_user_info(source.sender.id)) await run_api(api_results, 'get_friend_list', lambda: adapter.get_friend_list()) await run_api( @@ -200,7 +201,7 @@ def main(): parser.add_argument('--port', type=int, default=5313) parser.add_argument('--path', default='/wecomcs/callback') parser.add_argument('--timeout', type=int, default=180) - parser.add_argument('--bot-uuid', default='wecomcs-eba-live-probe') + parser.add_argument('--bot-uuid', default='wecomcs-omni-live-probe') parser.add_argument('--log', default='data/temp/wecomcs_eba_live_probe.jsonl') parser.add_argument('--skip-api', action='store_true') args = parser.parse_args() diff --git a/tests/unit_tests/api/http/service/test_bot_service.py b/tests/unit_tests/api/http/service/test_bot_service.py index cc63de719..bc1e7ba10 100644 --- a/tests/unit_tests/api/http/service/test_bot_service.py +++ b/tests/unit_tests/api/http/service/test_bot_service.py @@ -35,8 +35,9 @@ class _PersistenceManager: async def test_update_bot_copies_input_before_filtering_legacy_routing_fields(): persistence_mgr = _PersistenceManager() - runtime_bot = SimpleNamespace(enable=False) + runtime_bot = SimpleNamespace(enable=False, bot_entity=SimpleNamespace(name='Test Bot')) platform_mgr = SimpleNamespace( + get_bot_by_uuid=AsyncMock(return_value=runtime_bot), remove_bot=AsyncMock(), load_bot=AsyncMock(return_value=runtime_bot), ) diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index f68d43f21..90d0e21b2 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -351,7 +351,9 @@ class TestBotServiceCreateBot: bot2 = _create_mock_bot(bot_uuid='uuid-2') mock_result = _create_mock_result([bot1, bot2]) ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Bot 1'}) + ap.persistence_mgr.serialize_model = Mock( + return_value={'uuid': 'uuid-1', 'name': 'Bot 1', 'adapter': 'telegram'} + ) service = BotService(ap) @@ -435,6 +437,7 @@ class TestBotServiceCreateBot: return_value={ 'uuid': 'new-uuid', 'name': 'New Bot', + 'adapter': 'telegram', } ) diff --git a/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py b/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py index 5b42aef1d..584d39d91 100644 --- a/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py +++ b/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py @@ -207,7 +207,8 @@ async def test_aiocqhttp_message_converter_maps_onebot_segments_to_chain(): assert chain[10].face_type == 'rps' assert isinstance(chain[11], platform_message.Face) assert chain[11].face_type == 'dice' - assert isinstance(chain[12], platform_message.Unknown) + assert isinstance(chain[12], platform_message.Plain) + assert chain[12].text == '[]' @pytest.mark.asyncio @@ -254,7 +255,7 @@ async def test_aiocqhttp_event_converter_maps_private_and_group_messages(): assert isinstance(private_event, platform_events.MessageReceivedEvent) assert private_event.type == 'message.received' - assert private_event.adapter_name == 'aiocqhttp' + assert private_event.adapter_name == 'aiocqhttp-omni' assert private_event.chat_type == platform_entities.ChatType.PRIVATE assert private_event.chat_id == 10001 assert private_event.sender.nickname == 'Alice' diff --git a/tests/unit_tests/platform/test_botmgr_tenancy.py b/tests/unit_tests/platform/test_botmgr_tenancy.py index c543b8cd7..ac100bd46 100644 --- a/tests/unit_tests/platform/test_botmgr_tenancy.py +++ b/tests/unit_tests/platform/test_botmgr_tenancy.py @@ -11,6 +11,7 @@ from langbot.pkg.api.http.authz import WorkspaceRequiredError from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.entity.persistence.bot import Bot from langbot.pkg.platform.botmgr import PlatformManager, RuntimeBot +from langbot.pkg.platform.adapter_names import OMNI_ADAPTER_NAMES from langbot.pkg.workspace.entities import WorkspaceExecutionBinding from langbot.pkg.workspace.errors import WorkspaceInvariantError import langbot_plugin.api.entities.builtin.platform.events as platform_events @@ -240,7 +241,11 @@ async def test_reload_stops_and_drops_existing_platform_runtimes(): @pytest.mark.asyncio -async def test_cloud_startup_reuses_validated_platform_binding(): +@pytest.mark.parametrize( + ('saved_adapter', 'registered_adapter'), + [('probe', 'probe')] + [(f'{name}-eba', f'{name}-omni') for name in sorted(OMNI_ADAPTER_NAMES)], +) +async def test_cloud_startup_reuses_validated_platform_binding(saved_adapter, registered_adapter): class TenantUow: async def __aenter__(self): return self @@ -270,7 +275,7 @@ async def test_cloud_startup_reuses_validated_platform_binding(): workspace_uuid=WORKSPACE_A, name='Probe', description='', - adapter='probe', + adapter=saved_adapter, adapter_config={}, enable=False, event_bindings=[], @@ -295,11 +300,12 @@ async def test_cloud_startup_reuses_validated_platform_binding(): workspace_service=workspace_service, ) manager = PlatformManager(application) - manager.adapter_dict = {'probe': ProbeAdapter} + manager.adapter_dict = {registered_adapter: ProbeAdapter} await manager.load_bots_from_db() assert len(manager.bots) == 1 + assert manager.bots[0].bot_entity.adapter == registered_adapter workspace_service.get_execution_binding.assert_not_awaited() diff --git a/tests/unit_tests/platform/test_dingtalk_api.py b/tests/unit_tests/platform/test_dingtalk_api.py index 03c84e97b..450128f3c 100644 --- a/tests/unit_tests/platform/test_dingtalk_api.py +++ b/tests/unit_tests/platform/test_dingtalk_api.py @@ -1,8 +1,11 @@ """Tests for DingTalk API payload helpers.""" import json +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock -from langbot.libs.dingtalk_api.api import _stringify_card_param_map +from langbot.libs.dingtalk_api.api import DingTalkClient, _stringify_card_param_map +from langbot.pkg.utils import httpclient def test_dingtalk_card_param_map_stringifies_select_component_arrays(): @@ -40,3 +43,41 @@ def test_dingtalk_card_param_map_stringifies_unregistered_structures(): assert params['other'] == '["A"]' assert params['empty'] == '' + + +async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatch): + response = type('Response', (), {'status_code': 200})() + post = AsyncMock(return_value=response) + + @asynccontextmanager + async def client_context(): + yield type('HttpClient', (), {'post': post})() + + client = object.__new__(DingTalkClient) + client.access_token = 'access-token' + client.robot_code = 'robot-code' + client.key = 'client-id' + client.logger = None + client.check_access_token = AsyncMock(return_value=True) + client._http_client_context = client_context + monkeypatch.setattr(httpclient, 'response_text', AsyncMock(return_value='{}')) + + original_params = {'content': 'hello'} + delivered = await client.create_and_deliver_card( + card_template_id='template-id', + out_track_id='track-id', + open_space_id='dtv1.card//IM_ROBOT.user-id', + is_group=False, + card_param_map=original_params, + card_data_config={'autoLayout': True}, + ) + + request_body = post.await_args.kwargs['json'] + assert delivered is True + assert request_body['cardData'] == { + 'cardParamMap': { + 'content': 'hello', + 'config': '{"autoLayout": true}', + } + } + assert original_params == {'content': 'hello'} diff --git a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py index a05bec330..81ac54c51 100644 --- a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py +++ b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py @@ -254,7 +254,7 @@ async def test_dingtalk_event_converter_maps_group_and_private_message(): group_event = await DingTalkEventConverter.target2yiri(dingtalk_event(), 'LangBot') assert isinstance(group_event, platform_events.MessageReceivedEvent) - assert group_event.adapter_name == 'dingtalk-eba' + assert group_event.adapter_name == 'dingtalk-omni' assert group_event.chat_type == platform_entities.ChatType.GROUP assert group_event.chat_id == 'group-1' assert group_event.group.name == 'LangBot Team' @@ -280,7 +280,7 @@ async def test_dingtalk_event_converter_maps_card_feedback(): ) assert isinstance(feedback, platform_events.FeedbackReceivedEvent) - assert feedback.adapter_name == 'dingtalk-eba' + assert feedback.adapter_name == 'dingtalk-omni' assert feedback.feedback_id == 'feedback-1' assert feedback.feedback_type == 1 assert feedback.user_id == 'user-1' diff --git a/tests/unit_tests/platform/test_discord_eba_adapter.py b/tests/unit_tests/platform/test_discord_eba_adapter.py index 478e3c34d..a4bdc4b08 100644 --- a/tests/unit_tests/platform/test_discord_eba_adapter.py +++ b/tests/unit_tests/platform/test_discord_eba_adapter.py @@ -242,7 +242,7 @@ async def test_discord_converter_maps_message_edit_delete_and_reaction_events(): assert isinstance(received, platform_events.MessageReceivedEvent) assert received.type == 'message.received' - assert received.adapter_name == 'discord' + assert received.adapter_name == 'discord-omni' assert received.chat_type == platform_entities.ChatType.GROUP assert received.chat_id == 789 assert received.group.id == 456 diff --git a/tests/unit_tests/platform/test_kook_eba_adapter.py b/tests/unit_tests/platform/test_kook_eba_adapter.py index bc097acd8..9f16ac547 100644 --- a/tests/unit_tests/platform/test_kook_eba_adapter.py +++ b/tests/unit_tests/platform/test_kook_eba_adapter.py @@ -173,7 +173,7 @@ async def test_kook_event_converter_maps_group_private_and_platform_specific_eve group_event = await KookEventConverter.target2yiri(fake_kook_message(), bot_account_id='bot') assert isinstance(group_event, platform_events.MessageReceivedEvent) assert group_event.type == 'message.received' - assert group_event.adapter_name == 'kook' + assert group_event.adapter_name == 'kook-omni' assert group_event.chat_type == platform_entities.ChatType.GROUP assert group_event.chat_id == 'c1' assert group_event.group.id == 'c1' diff --git a/tests/unit_tests/platform/test_lark_adapter.py b/tests/unit_tests/platform/test_lark_adapter.py index a8e1fe3d9..3b24a7406 100644 --- a/tests/unit_tests/platform/test_lark_adapter.py +++ b/tests/unit_tests/platform/test_lark_adapter.py @@ -1,7 +1,7 @@ """Tests for Lark adapter helper behavior.""" import threading -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -12,6 +12,7 @@ from langbot.pkg.platform.sources.lark import ( _lark_completed_input_lines, _lark_current_input_defs, _lark_extract_action_form_inputs, + _lark_final_layout_texts, _lark_should_update_stream_element, _lark_visible_form_content, ) @@ -221,3 +222,123 @@ def test_lark_completed_input_lines_display_select_value_from_object(): ) assert lines == ['✅ xiala:B'] + + +def test_lark_final_layout_texts_normal_round_drops_resume_placeholder(): + """Non-resume final chunk: the reply must land in the main element only. + + Regression: rendering the resume placeholder too duplicated the reply, + because the accumulated streaming text equals the final text on a normal + round (e.g. 'It is Sep 1, 2026.\nIt is Sep 1, 2026.' in the card). + """ + main_text, resume_text = _lark_final_layout_texts( + resume_from=False, + text_message='It is Sep 1, 2026, 15:09:15.', + pre_pause_cached=None, + resume_cached='It is Sep 1, 2026, 15:09:15.', + ) + + assert main_text == 'It is Sep 1, 2026, 15:09:15.' + assert resume_text == '' + + +def test_lark_final_layout_texts_resume_round_keeps_both_segments(): + """Dify HITL resume final chunk: pre-pause text and resumed text differ, + both segments stay visible.""" + main_text, resume_text = _lark_final_layout_texts( + resume_from=True, + text_message='resumed answer', + pre_pause_cached='partial answer before pause', + resume_cached='resumed answer', + ) + + assert main_text == 'partial answer before pause' + assert resume_text == 'resumed answer' + + +def test_lark_final_layout_texts_resume_round_without_pre_pause_falls_back(): + main_text, resume_text = _lark_final_layout_texts( + resume_from=True, + text_message='answer', + pre_pause_cached=None, + resume_cached='answer', + ) + + assert main_text == 'answer' + assert resume_text == 'answer' + + +def test_lark_final_layout_texts_resume_round_empty_pre_pause_kept_empty(): + """Dify paused before emitting any text: the pre-pause cache is a valid + empty string and must NOT be treated as a cache miss. + + Regression: `pre_pause_cached or text_message` fell back to the full + text, so the final card rendered ('resumed answer', 'resumed answer') + and duplicated the reply. + """ + main_text, resume_text = _lark_final_layout_texts( + resume_from=True, + text_message='resumed answer', + pre_pause_cached='', + resume_cached='resumed answer', + ) + + assert main_text == '' + assert resume_text == 'resumed answer' + + +def _build_resume_final_chunk_adapter(message_text: str): + """Build a LarkAdapter whose card state mimics a Dify HITL round that + paused before emitting any text, then resumed and completed.""" + adapter = LarkAdapter.model_construct( + api_client=MagicMock(), + message_converter=MagicMock( + yiri2target=AsyncMock(return_value=([[{'tag': 'text', 'text': message_text}]], [])) + ), + ) + adapter.config = {'app_type': 'self'} + LarkAdapter.get_app_access_token = lambda self: None + LarkAdapter.get_tenant_access_token = lambda self, tenant_key: None + adapter.card_id_dict = {'msg-1': 'card-1'} + adapter.card_streaming_text = {'card-1': message_text} + adapter.card_pre_pause_text = {'card-1': ''} + adapter.card_resume_transitioned = {'card-1'} + adapter.card_sequence_dict = {} + adapter.card_last_accessed = {} + adapter.card_cleanup_at = 0.0 + adapter.card_id_to_source_ids = {} + adapter.reply_message_card_ids = {} + adapter.card_form_content = {} + adapter.card_form_input_defs = {} + adapter.card_form_inputs = {} + adapter._update_card_layout = AsyncMock() + return adapter + + +@pytest.mark.asyncio +async def test_reply_message_chunk_resume_final_with_empty_pre_pause_keeps_main_empty(): + """End-to-end regression via reply_message_chunk: Dify paused before any + text, so the pre-pause cache is ''. The final card update must render the + resumed answer only once (empty main text + resume placeholder), not + twice as ('resumed answer', 'resumed answer').""" + adapter = _build_resume_final_chunk_adapter('resumed answer') + + bot_message = MagicMock( + resp_message_id='msg-1', + msg_sequence=1, + spec=['resp_message_id', 'msg_sequence', '_resume_from_form'], + ) + bot_message._resume_from_form = True + message_source = MagicMock(source_platform_object=None) + + await adapter.reply_message_chunk( + message_source, + bot_message, + MagicMock(), + is_final=True, + ) + + adapter._update_card_layout.assert_awaited_once() + layout_kwargs = adapter._update_card_layout.await_args.kwargs + assert layout_kwargs['text_message'] == '' + assert layout_kwargs['resume_placeholder_text'] == 'resumed answer' diff --git a/tests/unit_tests/platform/test_lark_eba_adapter.py b/tests/unit_tests/platform/test_lark_eba_adapter.py index 71a4fa70d..827a2f4f6 100644 --- a/tests/unit_tests/platform/test_lark_eba_adapter.py +++ b/tests/unit_tests/platform/test_lark_eba_adapter.py @@ -102,7 +102,7 @@ class DummyAPIClient: acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id'))), aupdate=AsyncMock(return_value=DummyResponse()), ), - card_element=SimpleNamespace(content=MagicMock(return_value=DummyResponse())), + card_element=SimpleNamespace(acontent=AsyncMock(return_value=DummyResponse())), ) ) @@ -283,7 +283,7 @@ async def test_lark_event_converter_maps_group_and_private_message(): group_event = await LarkEventConverter.target2yiri(lark_event('group'), DummyAPIClient()) assert isinstance(group_event, platform_events.MessageReceivedEvent) - assert group_event.adapter_name == 'lark-eba' + assert group_event.adapter_name == 'lark-omni' assert group_event.chat_type == platform_entities.ChatType.GROUP assert group_event.chat_id == 'chat-1' assert group_event.group.id == 'chat-1' @@ -326,7 +326,7 @@ async def test_lark_get_message_fetches_uncached_message(): event = await adapter.get_message('group', 'chat-1', 'msg-remote') - assert event.adapter_name == 'lark-eba' + assert event.adapter_name == 'lark-omni' assert event.message_id == 'msg-remote' assert event.chat_type == platform_entities.ChatType.GROUP assert isinstance(event.message_chain[1], platform_message.Plain) @@ -687,22 +687,25 @@ async def test_lark_streaming_card_uses_strictly_increasing_sequences(): message = platform_message.MessageChain([platform_message.Plain(text='answer')]) await adapter.reply_message_chunk(source, bot_message, message) - first_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + first_request = adapter.api_client.cardkit.v1.card_element.acontent.call_args.args[0] assert first_request.request_body.sequence == 1 bot_message.msg_sequence = 2 await adapter.reply_message_chunk(source, bot_message, message) - assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1 + assert adapter.api_client.cardkit.v1.card_element.acontent.call_count == 1 bot_message.msg_sequence = 8 await adapter.reply_message_chunk(source, bot_message, message) - second_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + second_request = adapter.api_client.cardkit.v1.card_element.acontent.call_args.args[0] assert second_request.request_body.sequence == 2 bot_message.msg_sequence = 9 await adapter.reply_message_chunk(source, bot_message, message, is_final=True) - final_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + final_request = adapter.api_client.cardkit.v1.card.aupdate.call_args.args[0] assert final_request.request_body.sequence == 3 + final_card = json.loads(final_request.request_body.card.data) + assert final_card['body']['elements'] == [{'tag': 'markdown', 'content': 'answer'}] + assert not final_card['config'].get('streaming_mode', False) assert 'response-1' not in adapter.card_id_dict assert 'stream-card-1' not in adapter.card_sequence_dict assert 'stream-card-1' not in adapter.card_last_update_dict @@ -721,7 +724,7 @@ async def test_lark_streaming_card_updates_sparse_chunks_without_waiting_for_eig await adapter.reply_message_chunk(source, bot_message, message) - request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + request = adapter.api_client.cardkit.v1.card_element.acontent.call_args.args[0] assert request.request_body.sequence == 2 @@ -744,7 +747,7 @@ async def test_lark_streaming_card_uses_cumulative_runner_content(): await adapter.reply_message_chunk(source, bot_message, message) - request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + request = adapter.api_client.cardkit.v1.card_element.acontent.call_args.args[0] assert request.request_body.content == 'first chunk\n\nlatest chunk only' adapter.message_converter.yiri2target.assert_not_awaited() @@ -761,7 +764,7 @@ async def test_lark_streaming_card_first_real_runner_chunk_uses_sequence_one(): await adapter.reply_message_chunk(source, bot_message, message, is_final=True) - request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + request = adapter.api_client.cardkit.v1.card.aupdate.call_args.args[0] assert request.request_body.sequence == 1 @@ -774,7 +777,7 @@ async def test_lark_streaming_card_falls_back_to_full_card_update_when_stream_cl closed_response = DummyResponse(ok=False) closed_response.code = 300309 closed_response.msg = 'streaming mode is closed' - adapter.api_client.cardkit.v1.card_element.content.return_value = closed_response + adapter.api_client.cardkit.v1.card_element.acontent.return_value = closed_response adapter.message_converter.yiri2target = AsyncMock( return_value=([[{'tag': 'text', 'text': 'continued progress'}]], []) ) @@ -795,7 +798,7 @@ async def test_lark_streaming_card_falls_back_to_full_card_update_when_stream_cl adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2 await adapter.reply_message_chunk(source, bot_message, message, is_final=True) - assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1 + assert adapter.api_client.cardkit.v1.card_element.acontent.call_count == 1 assert adapter.api_client.cardkit.v1.card.aupdate.await_count == 2 assert 'stream-card-1' not in adapter.closed_streaming_cards diff --git a/tests/unit_tests/platform/test_mattermost_adapter.py b/tests/unit_tests/platform/test_mattermost_adapter.py new file mode 100644 index 000000000..b3aaddb63 --- /dev/null +++ b/tests/unit_tests/platform/test_mattermost_adapter.py @@ -0,0 +1,171 @@ +from types import SimpleNamespace + +import pytest + +from langbot.pkg.platform.sources.mattermost import ( + MattermostAdapter, + MattermostEventConverter, + MattermostMessageConverter, + _normalize_server_url, + _websocket_url, +) +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +class StubLogger: + async def info(self, *_args, **_kwargs): + pass + + async def error(self, *_args, **_kwargs): + pass + + +def _adapter() -> MattermostAdapter: + return MattermostAdapter.model_construct( + config={'enable_stream_reply': True}, + logger=StubLogger(), + server_url='https://mattermost.example.com', + access_token='secret', + bot_account_id='bot-id', + bot_username='langbot', + session=None, + listeners={}, + channel_cache={}, + stream_post_ids={}, + _running=False, + ) + + +def test_server_and_websocket_urls_preserve_subpath(): + server_url = _normalize_server_url('https://example.com/chat/') + assert server_url == 'https://example.com/chat' + assert _websocket_url(server_url) == 'wss://example.com/chat/api/v4/websocket' + + with pytest.raises(ValueError, match='absolute HTTP'): + _normalize_server_url('mattermost.example.com') + + +@pytest.mark.asyncio +async def test_converter_marks_and_removes_bot_mention(): + chain = await MattermostMessageConverter.target2yiri( + {'id': 'post-1', 'create_at': 1_000, 'message': '@langbot hello'}, + 'langbot', + ) + + assert any(isinstance(item, platform_message.At) for item in chain) + assert any(isinstance(item, platform_message.Plain) and item.text == 'hello' for item in chain) + + +@pytest.mark.asyncio +async def test_event_converter_distinguishes_direct_and_group_channels(): + post = {'id': 'post-1', 'channel_id': 'channel-1', 'user_id': 'user-1', 'message': 'hello', 'create_at': 1_000} + direct = await MattermostEventConverter.target2yiri(post, {'type': 'D'}, 'alice', 'langbot') + group = await MattermostEventConverter.target2yiri( + post, + {'type': 'O', 'display_name': 'General'}, + 'alice', + 'langbot', + ) + + assert isinstance(direct, platform_events.FriendMessage) + assert isinstance(group, platform_events.GroupMessage) + assert group.sender.group.name == 'General' + + +@pytest.mark.asyncio +async def test_send_to_person_creates_or_reuses_direct_channel(monkeypatch): + adapter = _adapter() + requests = [] + posted = [] + + async def api_request(method, path, *, payload=None): + requests.append((method, path, payload)) + return {'id': 'direct-channel', 'type': 'D'} + + async def post_message(channel_id, text, root_id=''): + posted.append((channel_id, text, root_id)) + return {'id': 'post-1'} + + monkeypatch.setattr(adapter, '_api_request', api_request) + monkeypatch.setattr(adapter, '_post_message', post_message) + + await adapter.send_message( + 'person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hello')]) + ) + + assert requests == [('POST', '/channels/direct', {'user_ids': ['bot-id', 'user-1']})] + assert posted == [('direct-channel', 'hello', '')] + + +@pytest.mark.asyncio +async def test_reply_keeps_existing_thread(monkeypatch): + adapter = _adapter() + posted = [] + + async def post_message(channel_id, text, root_id=''): + posted.append((channel_id, text, root_id)) + return {'id': 'reply'} + + monkeypatch.setattr(adapter, '_post_message', post_message) + event = platform_events.GroupMessage.model_construct( + source_platform_object={ + 'post': {'id': 'post-1', 'channel_id': 'channel-1', 'root_id': 'thread-root'}, + 'channel': {'type': 'O'}, + } + ) + + await adapter.reply_message(event, platform_message.MessageChain([platform_message.Plain(text='reply')])) + + assert posted == [('channel-1', 'reply', 'thread-root')] + + +@pytest.mark.asyncio +async def test_stream_reply_updates_existing_post(monkeypatch): + adapter = _adapter() + adapter.stream_post_ids['response-1'] = 'post-1' + requests = [] + + async def api_request(method, path, *, payload=None): + requests.append((method, path, payload)) + return {'id': 'post-1'} + + monkeypatch.setattr(adapter, '_api_request', api_request) + message = SimpleNamespace(resp_message_id='response-1', tool_calls=None) + + await adapter.reply_message_chunk( + SimpleNamespace(), + message, + platform_message.MessageChain([platform_message.Plain(text='complete')]), + is_final=True, + ) + + assert requests == [('PUT', '/posts/post-1', {'id': 'post-1', 'message': 'complete'})] + assert 'response-1' not in adapter.stream_post_ids + + +@pytest.mark.asyncio +async def test_posted_event_dispatches_listener(monkeypatch): + adapter = _adapter() + received = [] + + async def get_channel(_channel_id): + return {'type': 'D'} + + async def listener(event, _adapter): + received.append(event) + + monkeypatch.setattr(adapter, '_get_channel', get_channel) + adapter.register_listener(platform_events.FriendMessage, listener) + + await adapter._dispatch_post( + { + 'data': { + 'sender_name': 'alice', + 'post': '{"id":"post-1","channel_id":"channel-1","user_id":"user-1","message":"hello","create_at":1000}', + } + } + ) + + assert len(received) == 1 + assert received[0].sender.nickname == 'alice' diff --git a/tests/unit_tests/platform/test_officialaccount_eba_adapter.py b/tests/unit_tests/platform/test_officialaccount_eba_adapter.py index 8b21103d7..f6d55aa4a 100644 --- a/tests/unit_tests/platform/test_officialaccount_eba_adapter.py +++ b/tests/unit_tests/platform/test_officialaccount_eba_adapter.py @@ -151,7 +151,7 @@ async def test_officialaccount_event_converter_maps_text_image_voice_and_platfor subscribe_event = await OfficialAccountEventConverter().target2yiri(oa_event(msgtype='event', event='subscribe')) assert isinstance(text_event, platform_events.MessageReceivedEvent) - assert text_event.adapter_name == 'officialaccount-eba' + assert text_event.adapter_name == 'officialaccount-omni' assert text_event.chat_type == platform_entities.ChatType.PRIVATE assert text_event.chat_id == 'openid-1' assert str(text_event.message_chain) == 'hello' diff --git a/tests/unit_tests/platform/test_omni_adapter_names.py b/tests/unit_tests/platform/test_omni_adapter_names.py new file mode 100644 index 000000000..5b8aa038c --- /dev/null +++ b/tests/unit_tests/platform/test_omni_adapter_names.py @@ -0,0 +1,70 @@ +"""Saved adapter IDs remain usable while public metadata exposes Omni IDs.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +import yaml + +from langbot.pkg.api.http.service.bot import BotService +from langbot.pkg.platform.adapter_names import OMNI_ADAPTER_NAMES, canonical_adapter_name +from langbot.pkg.platform.botmgr import PlatformManager + + +@pytest.mark.asyncio +@pytest.mark.parametrize('name', sorted(OMNI_ADAPTER_NAMES)) +async def test_saved_adapter_ids_are_normalized_across_bot_api(name): + saved = {'uuid': 'bot', 'adapter': f'{name}-eba', 'adapter_config': {}} + component = SimpleNamespace( + metadata=SimpleNamespace(name=f'{name}-omni'), + spec={'config': [{'type': 'webhook-url'}]}, + to_plain_dict=lambda: {'metadata': {'name': f'{name}-omni'}}, + ) + result = SimpleNamespace(all=lambda: [saved], first=lambda: saved) + application = SimpleNamespace( + discover=SimpleNamespace(get_components_by_kind=lambda _: [component]), + persistence_mgr=SimpleNamespace( + execute_async=AsyncMock(return_value=result), + serialize_model=Mock(side_effect=lambda _model, row, _masked: row.copy()), + ), + ) + service = BotService(application) + assert (await service.get_bots('workspace'))[0]['adapter'] == f'{name}-omni' + assert (await service.get_bot('workspace', 'bot'))['adapter'] == f'{name}-omni' + assert service._adapter_declares_webhook_url(saved['adapter']) + assert (await service._prepare_bot_data('workspace', saved, include_uuid=True))['adapter'] == f'{name}-omni' + assert saved['adapter'] == f'{name}-eba' + manager = PlatformManager(application) + manager.adapter_components = [component] + assert manager.get_available_adapter_manifest_by_name(saved['adapter']) is component + assert manager.get_available_adapter_info_by_name(saved['adapter'])['metadata']['name'] == f'{name}-omni' + + +@pytest.mark.parametrize('name', ['telegram', 'custom-eba', 'my-telegram-eba', 'telegram-omni', 'websocket']) +def test_legacy_and_custom_adapter_ids_are_unchanged(name): + assert canonical_adapter_name(name) == name + + +def test_discovered_manifests_expose_only_omni_names(): + root = Path(__file__).resolve().parents[3] / 'src/langbot/pkg/platform/adapters' + for name in OMNI_ADAPTER_NAMES: + metadata = yaml.safe_load((root / name / 'manifest.yaml').read_text())['metadata'] + assert metadata['name'] == f'{name}-omni' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('disabled_name', ['telegram-eba', 'telegram-omni']) +async def test_old_disabled_adapter_config_still_hides_omni(disabled_name): + component = SimpleNamespace(metadata=SimpleNamespace(name='telegram-omni')) + application = SimpleNamespace( + storage_mgr=SimpleNamespace(storage_provider=SimpleNamespace(delete_dir_recursive=AsyncMock())), + instance_config=SimpleNamespace(data={'system': {'disabled_adapters': [disabled_name]}}), + discover=SimpleNamespace(get_components_by_kind=lambda _: [component]), + workspace_service=SimpleNamespace(get_execution_binding=AsyncMock(side_effect=ValueError('no workspace'))), + ) + manager = PlatformManager(application) + manager.load_bots_from_db = AsyncMock() + await manager.initialize() + assert manager.adapter_dict == {} + assert manager.adapter_components == [] diff --git a/tests/unit_tests/platform/test_omni_mainline_parity.py b/tests/unit_tests/platform/test_omni_mainline_parity.py new file mode 100644 index 000000000..7e1b8c5eb --- /dev/null +++ b/tests/unit_tests/platform/test_omni_mainline_parity.py @@ -0,0 +1,398 @@ +"""Regression coverage for master fixes carried into the Omni adapter path.""" + +import asyncio +import base64 +import importlib +import json +import time +import zlib +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import aiocqhttp +import pytest +import yaml + +from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter +from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter +from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter +from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter +from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter +from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter +from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter +from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter +from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpEventConverter as LegacyOneBotConverter +from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent +from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger +from langbot_plugin.api.entities.builtin.platform import message as pm + +PLATFORM = Path(__file__).parents[3] / 'src/langbot/pkg/platform' +OMNI_NAMES = sorted(p.parent.name for p in (PLATFORM / 'adapters').glob('*/manifest.yaml')) + + +def chain(text): + return pm.MessageChain([pm.Plain(text=text)]) + + +@pytest.mark.parametrize('name', OMNI_NAMES) +def test_omni_config_contains_mainline_options_and_help(name): + legacy = yaml.safe_load((PLATFORM / 'sources' / f'{name}.yaml').read_text())['spec'] + omni = yaml.safe_load((PLATFORM / 'adapters' / name / 'manifest.yaml').read_text())['spec'] + assert {c['name'] for c in legacy['config']} <= {c['name'] for c in omni['config']} + assert omni['help_links'] == legacy['help_links'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('prefix', ['', 'base64://', 'data:image/png;base64,']) +@pytest.mark.parametrize('kind', [pm.Image, pm.Voice, pm.File]) +async def test_onebot_media_base64_is_normalized_once(prefix, kind): + component = kind(base64=prefix + 'YWJj') + output, _, _ = await AiocqhttpMessageConverter.yiri2target(pm.MessageChain([component])) + assert output[0].data['file'] == 'base64://YWJj' + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'raw,expected', + [ + ( + { + 'app': 'com.tencent.structmsg', + 'meta': {'detail_1': {'desc': 'Article', 'qqdocurl': 'https://example.test/a'}}, + }, + 'Article', + ), + ({'app': 'music', 'meta': {'music': {'title': 'Song', 'jumpUrl': 'https://example.test/song'}}}, 'Song'), + ('{invalid', '[收到一张JSON卡片]'), + ], +) +async def test_onebot_json_cards_are_readable(raw, expected): + payload = json.dumps(raw) if isinstance(raw, dict) else raw + result = await AiocqhttpMessageConverter.target2yiri([{'type': 'json', 'data': {'data': payload}}]) + assert expected in result[1].text + + +@pytest.mark.asyncio +async def test_onebot_metadata_lookup_is_cached_and_survives_legacy_conversion(): + bot = SimpleNamespace( + get_group_info=AsyncMock(return_value={'group_name': 'Team'}), + get_group_member_info=AsyncMock(return_value={'title': 'Maintainer'}), + ) + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1, + 'time': 1, + 'group_id': 2, + 'user_id': 3, + 'message': 'hello', + 'sender': {'user_id': 3, 'nickname': 'Alice', 'role': 'admin'}, + } + ) + lookup = LegacyOneBotConverter() + first = await AiocqhttpEventConverter.target2yiri(event, bot, lookup=lookup) + second = await AiocqhttpEventConverter.target2legacy(event, bot, lookup=lookup) + assert first.group.name == second.group.name == 'Team' + assert first.sender_member.title == second.sender.special_title == 'Maintainer' + bot.get_group_info.assert_awaited_once() + bot.get_group_member_info.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', [pm.Image, pm.Voice, pm.File]) +async def test_discord_outbound_base64_obeys_mainline_limit(monkeypatch, kind): + legacy = importlib.import_module('langbot.pkg.platform.sources.discord') + monkeypatch.setattr(legacy, '_MAX_DISCORD_MEDIA_BYTES', 4) + with pytest.raises(ValueError, match='exceeds'): + await DiscordMessageConverter.yiri2target(pm.MessageChain([kind(base64=base64.b64encode(b'12345').decode())])) + + +@pytest.mark.asyncio +async def test_lark_component_loading_obeys_mainline_limit(monkeypatch, tmp_path): + legacy = importlib.import_module('langbot.pkg.platform.sources.lark') + monkeypatch.setattr(legacy, '_MAX_LARK_MEDIA_BYTES', 4) + file = tmp_path / 'oversized.txt' + file.write_bytes(b'12345') + assert await LarkMessageConverter._get_component_bytes(pm.File(path=str(file))) is None + assert await LarkMessageConverter._get_component_bytes(pm.File(url=file.as_uri())) is None + assert await LarkMessageConverter._get_component_bytes(pm.File(base64=base64.b64encode(b'12345').decode())) is None + + +@pytest.mark.asyncio +async def test_lark_callbacks_are_bounded_and_cancelled_on_shutdown(): + bot = SimpleNamespace(_auto_reconnect=True, _disconnect=AsyncMock()) + adapter = LarkAdapter.model_construct(config={}, bot=bot) + completed = [] + + async def work(): + try: + await asyncio.Event().wait() + finally: + completed.append(True) + + for _ in range(105): + adapter._submit_coro(work()) + assert len(adapter.inbound_event_tasks) == 100 + await asyncio.sleep(0) + await adapter.kill() + assert len(completed) == 100 + assert not adapter.inbound_event_tasks + bot._disconnect.assert_awaited_once() + + +def test_lark_domains_and_markdown_table_rendering(): + adapter = LarkAdapter.model_construct(config={}) + for domain in ['https://open.larksuite.com', 'https://open.feishu.cn']: + assert adapter.build_api_client({'app_id': 'a', 'app_secret': 'b', 'domain': domain})._config.domain == domain + table = '| Name |\n| --- |\n| Alice |' + payloads = adapter._outbound_payloads([[{'tag': 'md', 'text': table}]], []) + assert payloads[0][0] == 'interactive' + assert payloads[0][1]['body']['elements'] == [{'tag': 'markdown', 'content': table}] + + +@pytest.mark.asyncio +async def test_qq_optional_token(monkeypatch): + module = importlib.import_module('langbot.pkg.platform.adapters.qqofficial.adapter') + client = MagicMock() + factory = MagicMock(return_value=client) + monkeypatch.setattr(module, 'QQOfficialClient', factory) + QQOfficialAdapter(config={'appid': 'app', 'secret': 'secret'}, logger=MagicMock(spec=AbstractEventLogger)) + assert factory.call_args.kwargs['token'] == '' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('target', ['c2c', 'group']) +@pytest.mark.parametrize('markdown', [False, True]) +async def test_qq_markdown_configuration_reaches_send_api(target, markdown): + bot = SimpleNamespace( + **{ + name: AsyncMock() + for name in [ + 'send_private_text_msg', + 'send_private_markdown_msg', + 'send_group_text_msg', + 'send_group_markdown_msg', + ] + } + ) + adapter = QQOfficialAdapter.model_construct(config={'enable-markdown-rendering': markdown}, bot=bot) + await adapter._send_content_list(target, 'target', [{'type': 'text', 'content': '**hello**'}], msg_id='anchor') + name = f'send_{"private" if target == "c2c" else "group"}_{"markdown" if markdown else "text"}_msg' + assert getattr(bot, name).await_args.kwargs['content'] == '**hello**' + assert getattr(bot, name).await_args.kwargs['msg_id'] == 'anchor' + assert sum(m.await_count for m in vars(bot).values()) == 1 + + +@pytest.mark.asyncio +async def test_qq_stream_sends_snapshots_and_cleans_final_state(): + bot = SimpleNamespace(send_stream_msg=AsyncMock(return_value={'id': 'stream'})) + adapter = QQOfficialAdapter.model_construct(config={}, bot=bot) + adapter._stream_ctx['response'] = { + 'user_openid': 'user', + 'msg_id': 'anchor', + 'stream_msg_id': None, + 'msg_seq': 1, + 'index': 0, + 'last_update_ts': 0, + 'accumulated_text': '', + 'sent_length': 0, + 'session_started': False, + } + adapter._stream_ctx_ts['response'] = time.time() + for text, final in [('Hello', False), ('Hello world', True)]: + await adapter.reply_message_chunk(None, {'resp_message_id': 'response'}, chain(text), is_final=final) + assert [c.kwargs['content'] for c in bot.send_stream_msg.await_args_list] == ['Hello', 'Hello world'] + assert bot.send_stream_msg.await_args.kwargs['input_state'] == 10 + assert not adapter._stream_ctx and not adapter._stream_ctx_ts + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['image', 'voice', 'file']) +@pytest.mark.parametrize('stream', [False, True]) +async def test_wecombot_reply_uploads_media_instead_of_placeholder(kind, stream): + bot = SimpleNamespace( + reply_text=AsyncMock(), + push_stream_chunk=AsyncMock(return_value=True), + upload_media=AsyncMock(return_value={'media_id': 'media'}), + reply_image=AsyncMock(), + reply_voice=AsyncMock(), + reply_file=AsyncMock(), + ) + adapter = WecomBotAdapter.model_construct(config={}, bot=bot) + event = WecomBotEvent({'message_id': 'message', 'req_id': 'request'}) + source = SimpleNamespace(source_platform_object=event) + part = {'image': pm.Image, 'voice': pm.Voice, 'file': pm.File}[kind](base64=base64.b64encode(b'payload').decode()) + message = pm.MessageChain([pm.Plain(text='hello'), part]) + if stream: + await adapter.reply_message_chunk(source, {}, message, is_final=False) + bot.upload_media.assert_not_awaited() + await adapter.reply_message_chunk(source, {}, message, is_final=True) + else: + await adapter.reply_message(source, message) + assert bot.upload_media.await_args.args[0] == b'payload' + assert bot.upload_media.await_args.kwargs['media_type'] == kind + getattr(bot, f'reply_{kind}').assert_awaited_once_with('request', 'media') + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'name,cls', [('wecom', 'WecomAdapter'), ('wecomcs', 'WecomCSAdapter'), ('qqofficial', 'QQOfficialAdapter')] +) +async def test_shutdown_closes_client(name, cls): + module = importlib.import_module(f'langbot.pkg.platform.adapters.{name}.adapter') + bot = SimpleNamespace(close=AsyncMock(), clear=MagicMock()) + adapter = getattr(module, cls).model_construct(bot=bot, config={}) + await adapter.kill() + bot.close.assert_awaited_once() + + +def test_kook_compressed_gateway_limit(monkeypatch): + source = importlib.import_module('langbot.pkg.platform.sources.kook') + omni = importlib.import_module('langbot.pkg.platform.adapters.kook.adapter') + monkeypatch.setattr(source, '_KOOK_MAX_GATEWAY_MESSAGE_BYTES', 32) + assert json.loads(omni.KookAdapter._decode_ws_message(zlib.compress(b'{"s": 1}'))) == {'s': 1} + with pytest.raises(ValueError, match='size limit'): + omni.KookAdapter._decode_ws_message(zlib.compress(b' ' * 1000)) + + +@pytest.mark.asyncio +async def test_discord_stream_edits_one_message_with_complete_text(): + sent = SimpleNamespace(content='first', edit=AsyncMock()) + channel = SimpleNamespace(send=AsyncMock(return_value=sent)) + adapter = DiscordAdapter.model_construct(config={}, bot=None) + adapter._stream_buffer['response'] = { + 'channel': channel, + 'sent_message': None, + 'last_content': '', + 'chunk_count': 0, + } + await adapter.reply_message_chunk(None, {'resp_message_id': 'response'}, chain('first')) + await adapter.reply_message_chunk(None, {'resp_message_id': 'response'}, chain('first second'), is_final=True) + channel.send.assert_awaited_once_with('first') + sent.edit.assert_awaited_once_with(content='first second') + assert not adapter._stream_buffer + + +@pytest.mark.parametrize('name', ['domain', 'custom_domain']) +def test_lark_legacy_and_omni_have_identical_region_fields(name): + legacy = yaml.safe_load((PLATFORM / 'sources/lark.yaml').read_text())['spec']['config'] + omni = yaml.safe_load((PLATFORM / 'adapters/lark/manifest.yaml').read_text())['spec']['config'] + assert next(c for c in legacy if c['name'] == name) == next(c for c in omni if c['name'] == name) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('variant', ['sources.lark', 'adapters.lark.adapter']) +@pytest.mark.parametrize( + 'region,expected', + [ + ({}, 'https://open.feishu.cn'), + ({'domain': 'https://open.feishu.cn'}, 'https://open.feishu.cn'), + ({'domain': 'https://open.larksuite.com'}, 'https://open.larksuite.com'), + ({'domain': 'custom', 'custom_domain': 'https://open.example.test/'}, 'https://open.example.test'), + ], +) +async def test_lark_http_and_websocket_use_selected_region(monkeypatch, variant, region, expected): + import lark_oapi + + module = importlib.import_module(f'langbot.pkg.platform.{variant}') + ws = MagicMock(spec=lark_oapi.ws.Client) + factory = MagicMock(return_value=ws) + monkeypatch.setattr(module, 'NonBlockingLarkWSClient', factory) + adapter = module.LarkAdapter( + config={'app_id': 'app', 'app_secret': 'secret', 'bot_name': 'bot', **region}, + logger=MagicMock(spec=AbstractEventLogger), + ) + assert factory.call_args.kwargs['domain'] == expected + assert adapter.api_client._config.domain == expected + + +@pytest.mark.asyncio +async def test_lark_resource_download_rejects_oversized_platform_response(monkeypatch): + import io + + legacy = importlib.import_module('langbot.pkg.platform.sources.lark') + monkeypatch.setattr(legacy, '_MAX_LARK_MEDIA_BYTES', 4) + response = SimpleNamespace(success=lambda: True, raw=SimpleNamespace(headers={}), file=io.BytesIO(b'12345')) + client = SimpleNamespace( + im=SimpleNamespace(v1=SimpleNamespace(message_resource=SimpleNamespace(aget=AsyncMock(return_value=response)))) + ) + with pytest.raises(ValueError, match='exceeds'): + await LarkMessageConverter._download_resource(client, 'message', 'key', 'image') + + +@pytest.mark.asyncio +async def test_qq_non_stream_fallback_replaces_snapshot(monkeypatch): + reply = AsyncMock() + monkeypatch.setattr(QQOfficialAdapter, 'reply_message', reply) + adapter = QQOfficialAdapter.model_construct(config={}, bot=None) + for text, final in [('A', False), ('AB', False), ('ABC', True)]: + await adapter.reply_message_chunk(None, {'resp_message_id': 'fallback'}, chain(text), is_final=final) + reply.assert_awaited_once() + assert reply.await_args.args[1][0].text == 'ABC' + assert not adapter._fallback_text and not adapter._fallback_text_ts + + +@pytest.mark.asyncio +async def test_telegram_stream_reuses_persistent_message(): + import telegram + from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter + + update = MagicMock(spec=telegram.Update) + update.effective_chat = SimpleNamespace(id=123, type='private') + update.effective_message = SimpleNamespace(message_thread_id=None) + bot = SimpleNamespace( + send_message=AsyncMock(return_value=SimpleNamespace(message_id=456)), edit_message_text=AsyncMock() + ) + adapter = TelegramAdapter.model_construct(config={}, bot=bot, msg_stream_id={}, seq=1) + source = SimpleNamespace(source_platform_object=update) + await adapter.create_message_card('response', source) + await adapter.reply_message_chunk( + source, SimpleNamespace(resp_message_id='response', msg_sequence=1, tool_calls=None), chain('Hello') + ) + await adapter.reply_message_chunk( + source, + SimpleNamespace(resp_message_id='response', msg_sequence=2, tool_calls=None), + chain('Hello world'), + is_final=True, + ) + bot.send_message.assert_awaited_once() + assert [call.kwargs['message_id'] for call in bot.edit_message_text.await_args_list] == [456, 456] + assert bot.edit_message_text.await_args.kwargs['text'] == 'Hello world' + assert not adapter.msg_stream_id + + +@pytest.mark.asyncio +async def test_telegram_inbound_image_does_not_expose_token_url(monkeypatch): + import datetime + from langbot.pkg.platform.sources import telegram as source + from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter + + download = MagicMock() + download.__aenter__ = AsyncMock(return_value=SimpleNamespace()) + download.__aexit__ = AsyncMock() + monkeypatch.setattr(source.httpclient, 'get_session', lambda **kwargs: SimpleNamespace(get=lambda url: download)) + monkeypatch.setattr(source.httpclient, 'read_limited', AsyncMock(return_value=b'image')) + message = SimpleNamespace( + message_id=1, + date=datetime.datetime.now(), + text='', + caption='', + photo=[ + SimpleNamespace( + get_file=AsyncMock( + return_value=SimpleNamespace(file_path='https://api.telegram.org/file/botSECRET/photo') + ) + ) + ], + voice=None, + document=None, + ) + bot = SimpleNamespace( + get_file=AsyncMock(return_value=SimpleNamespace(file_path='https://api.telegram.org/file/botSECRET/photo')) + ) + converted = await TelegramMessageConverter.target2yiri(message, bot, 'bot') + image = next(p for p in converted if isinstance(p, pm.Image)) + assert image.base64 and not image.url + assert 'SECRET' not in image.model_dump_json() diff --git a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py index 44341bf02..a5aee2934 100644 --- a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py +++ b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py @@ -81,11 +81,11 @@ class DummyQQOfficialClient: async def get_gateway_url(self): return 'wss://gateway.example.test' - async def send_private_text_msg(self, user_openid, content, msg_id=None): + async def send_private_text_msg(self, user_openid, content, msg_id=None, event_id=None, msg_seq=1): self.sent.append(('private_text', user_openid, content, msg_id)) return {'id': 'sent-private'} - async def send_group_text_msg(self, group_openid, content, msg_id=None): + async def send_group_text_msg(self, group_openid, content, msg_id=None, event_id=None, msg_seq=1): self.sent.append(('group_text', group_openid, content, msg_id)) return {'id': 'sent-group'} @@ -260,7 +260,7 @@ async def test_qqofficial_event_converter_maps_private_group_and_platform_specif platform_event = await QQOfficialEventConverter().target2yiri(qq_event('UNKNOWN_EVENT')) assert isinstance(private_event, platform_events.MessageReceivedEvent) - assert private_event.adapter_name == 'qqofficial-eba' + assert private_event.adapter_name == 'qqofficial-omni' assert private_event.chat_type == platform_entities.ChatType.PRIVATE assert private_event.chat_id == 'user-openid' assert str(private_event.message_chain) == 'hello' diff --git a/tests/unit_tests/platform/test_slack_eba_adapter.py b/tests/unit_tests/platform/test_slack_eba_adapter.py index e58eec9de..0b8f7deac 100644 --- a/tests/unit_tests/platform/test_slack_eba_adapter.py +++ b/tests/unit_tests/platform/test_slack_eba_adapter.py @@ -179,7 +179,7 @@ async def test_slack_event_converter_maps_private_group_and_platform_specific(): platform_event = await SlackEventConverter().target2yiri(slack_event('file_share')) assert isinstance(private_event, platform_events.MessageReceivedEvent) - assert private_event.adapter_name == 'slack-eba' + assert private_event.adapter_name == 'slack-omni' assert private_event.chat_type == platform_entities.ChatType.PRIVATE assert private_event.chat_id == 'U-1' assert str(private_event.message_chain) == 'hello' @@ -222,11 +222,15 @@ async def test_slack_send_reply_platform_api_and_unsupported(): adapter = make_adapter() source_event = await SlackEventConverter().target2yiri(slack_event('im')) - reply_result = await adapter.reply_message(source_event, platform_message.MessageChain([platform_message.Plain(text='reply')])) + reply_result = await adapter.reply_message( + source_event, platform_message.MessageChain([platform_message.Plain(text='reply')]) + ) assert reply_result.message_id == 'evt-1' assert ('person', 'U-1', 'reply') in adapter.bot.sent - await adapter.send_message('group', 'C-1', platform_message.MessageChain([platform_message.Plain(text='hello channel')])) + await adapter.send_message( + 'group', 'C-1', platform_message.MessageChain([platform_message.Plain(text='hello channel')]) + ) assert ('channel', 'C-1', 'hello channel') in adapter.bot.sent assert await adapter.call_platform_api('get_mode', {}) == { diff --git a/tests/unit_tests/platform/test_wecom_eba_adapter.py b/tests/unit_tests/platform/test_wecom_eba_adapter.py index 9563c39a3..e48d9558e 100644 --- a/tests/unit_tests/platform/test_wecom_eba_adapter.py +++ b/tests/unit_tests/platform/test_wecom_eba_adapter.py @@ -162,7 +162,7 @@ async def test_wecom_event_converter_maps_text_message_to_eba_and_legacy(): event = await WecomEventConverter.target2yiri(wecom_event(), adapter.bot) assert isinstance(event, platform_events.MessageReceivedEvent) - assert event.adapter_name == 'wecom-eba' + assert event.adapter_name == 'wecom-omni' assert event.chat_type == platform_entities.ChatType.PRIVATE assert event.chat_id == 'user-1|1000002' assert event.sender.nickname == 'Alice' @@ -188,7 +188,7 @@ async def test_wecom_event_converter_maps_image_message_to_eba(): ) assert isinstance(event, platform_events.MessageReceivedEvent) - assert event.adapter_name == 'wecom-eba' + assert event.adapter_name == 'wecom-omni' assert event.message_id == 12345 assert isinstance(event.message_chain[1], platform_message.Image) assert event.message_chain[1].base64 == 'data:image/png;base64,AAAA' diff --git a/tests/unit_tests/platform/test_wecom_send_message.py b/tests/unit_tests/platform/test_wecom_send_message.py new file mode 100644 index 000000000..20c9e6976 --- /dev/null +++ b/tests/unit_tests/platform/test_wecom_send_message.py @@ -0,0 +1,59 @@ +"""Tests for WecomAdapter.send_message content-key handling.""" + +import pytest + +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from langbot.pkg.platform.sources.wecom import WecomAdapter + + +class StubWecomClient: + def __init__(self): + self.calls = [] + + async def get_media_id(self, msg): + return 'MEDIA_ID_123' + + async def send_private_msg(self, user_id, agent_id, text): + self.calls.append(('text', user_id, agent_id, text)) + + async def send_image(self, user_id, agent_id, media_id): + self.calls.append(('image', user_id, agent_id, media_id)) + + async def send_voice(self, user_id, agent_id, media_id): + self.calls.append(('voice', user_id, agent_id, media_id)) + + async def send_file(self, user_id, agent_id, media_id): + self.calls.append(('file', user_id, agent_id, media_id)) + + +def _make_adapter(): + adapter = WecomAdapter.model_construct(bot=StubWecomClient()) + return adapter + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('part', 'expected_type'), + [ + (platform_message.Image(url='https://example.com/x.jpg'), 'image'), + (platform_message.Voice(url='https://example.com/x.amr'), 'voice'), + (platform_message.File(url='https://example.com/x.pdf', name='x.pdf'), 'file'), + ], +) +async def test_send_message_dispatches_media_by_id(part, expected_type): + adapter = _make_adapter() + chain = platform_message.MessageChain([part]) + + await adapter.send_message('person', 'USER1|1000001', chain) + + assert adapter.bot.calls == [(expected_type, 'USER1', 1000001, 'MEDIA_ID_123')] + + +@pytest.mark.asyncio +async def test_send_message_text_still_works(): + adapter = _make_adapter() + chain = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + await adapter.send_message('person', 'USER1|1000001', chain) + + assert adapter.bot.calls == [('text', 'USER1', 1000001, 'hello')] diff --git a/tests/unit_tests/platform/test_wecombot_eba_adapter.py b/tests/unit_tests/platform/test_wecombot_eba_adapter.py index 2376d8b10..1952505da 100644 --- a/tests/unit_tests/platform/test_wecombot_eba_adapter.py +++ b/tests/unit_tests/platform/test_wecombot_eba_adapter.py @@ -201,7 +201,7 @@ async def test_wecombot_interaction_delivery_and_callback_event(): @pytest.mark.asyncio -async def test_wecombot_message_converter_maps_outbound_components_to_markdown_text(): +async def test_wecombot_message_converter_preserves_outbound_media(): content = await WecomBotMessageConverter.yiri2target( platform_message.MessageChain( [ @@ -217,12 +217,15 @@ async def test_wecombot_message_converter_maps_outbound_components_to_markdown_t ) ) - assert 'hi' in content - assert '@Alice' in content - assert '[Image]' in content - assert '[File: a.txt]' in content - assert '[Quote origin]' in content - assert 'quoted' in content + assert [item['text'] for item in content if item['type'] == 'text'] == [ + 'hi', + '@Alice', + '[Quote origin]', + 'quoted', + ] + assert content[2] == {'type': 'image', 'base64': 'data:image/png;base64,AAAA', 'name': ''} + assert content[3]['type'] == 'file' + assert content[3]['name'] == 'a.txt' @pytest.mark.asyncio @@ -235,7 +238,7 @@ async def test_wecombot_event_converter_maps_private_and_group_messages_to_eba() ) assert isinstance(private_event, platform_events.MessageReceivedEvent) - assert private_event.adapter_name == 'wecombot-eba' + assert private_event.adapter_name == 'wecombot-omni' assert private_event.chat_type == platform_entities.ChatType.PRIVATE assert private_event.chat_id == 'user-1' assert str(private_event.message_chain) == 'hello' diff --git a/tests/unit_tests/platform/test_wecomcs_eba_adapter.py b/tests/unit_tests/platform/test_wecomcs_eba_adapter.py index 3d147b5d9..e14a32745 100644 --- a/tests/unit_tests/platform/test_wecomcs_eba_adapter.py +++ b/tests/unit_tests/platform/test_wecomcs_eba_adapter.py @@ -176,7 +176,7 @@ async def test_wecomcs_event_converter_maps_text_message_to_eba_and_legacy(): event = await WecomCSEventConverter.target2yiri(wecomcs_event(), adapter.bot) assert isinstance(event, platform_events.MessageReceivedEvent) - assert event.adapter_name == 'wecomcs-eba' + assert event.adapter_name == 'wecomcs-omni' assert event.chat_type == platform_entities.ChatType.PRIVATE assert event.chat_id == 'external-1|kf-1' assert event.sender.nickname == 'Alice' diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index baf8e2c76..fd9b66630 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -611,7 +611,7 @@ const BotForm = forwardRef(function BotForm( {showLegacyAdapters && ( <> -

+

{t('bots.legacyAdaptersHint')}

diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx index 792b02991..fe645d6cb 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginComponentList.tsx @@ -7,6 +7,7 @@ import { FileText, PanelTop, Bot, + Puzzle, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; @@ -33,6 +34,7 @@ export default function PluginComponentList({ Parser: , Page: , AgentRunner: , + EventProcessor: , }; const componentKindList = Object.keys(components || {}); diff --git a/web/src/app/home/plugins/components/plugin-market/PluginComponentIcons.ts b/web/src/app/home/plugins/components/plugin-market/PluginComponentIcons.ts index 81f436a6e..666347c93 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginComponentIcons.ts +++ b/web/src/app/home/plugins/components/plugin-market/PluginComponentIcons.ts @@ -5,6 +5,7 @@ import { Book, FileText, Hash, + Puzzle, Wrench, type LucideIcon, } from 'lucide-react'; @@ -17,4 +18,5 @@ export const pluginComponentIconMap: Record = { Parser: FileText, Page: AppWindow, AgentRunner: Bot, + EventProcessor: Puzzle, }; diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index d9e362a05..153894930 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -72,6 +72,7 @@ const MARKET_COMPONENT_VALUES = [ 'Parser', 'Page', 'AgentRunner', + 'EventProcessor', ]; function getComponentFilterFromQuery( @@ -244,6 +245,11 @@ function MarketPageContent({ label: t('market.componentName.AgentRunner'), icon: Bot, }, + { + value: 'EventProcessor', + label: t('market.componentName.EventProcessor'), + icon: Puzzle, + }, ]; // 获取当前排序参数 diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 3e399d573..c47860f9a 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -366,7 +366,7 @@ const enUS = { legacyAdapters: 'Legacy adapters', legacyAdapterBadge: 'Legacy', legacyAdaptersHint: - 'These adapters have newer event-driven counterparts. They are kept only for existing configurations and are not recommended for new bots.', + 'These adapters have newer event-driven counterparts.\nThey are kept only for existing configurations and are not recommended for new bots.', adapterConfig: 'Adapter Configuration', viewAdapterDocs: 'View Docs', bindPipeline: 'Bind Pipeline', @@ -1088,6 +1088,7 @@ const enUS = { Parser: 'Parser', Page: 'Page', AgentRunner: 'Agent Runner', + EventProcessor: 'Event Processor', }, uploadLocal: 'Upload Local', debugging: 'Debugging', @@ -1275,6 +1276,7 @@ const enUS = { Parser: 'Parser', Page: 'Page', AgentRunner: 'Agent Runner', + EventProcessor: 'Event Processor', }, filterByType: 'Type', allTypes: 'All Types', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index ecc0a5d5a..c5810184e 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -374,7 +374,7 @@ const esES = { legacyAdapters: 'Adaptadores heredados', legacyAdapterBadge: 'Heredado', legacyAdaptersHint: - 'Estos adaptadores tienen versiones nuevas orientadas a eventos. Se mantienen solo para configuraciones existentes y no se recomiendan para Bots nuevos.', + 'Estos adaptadores tienen versiones nuevas orientadas a eventos.\nSe mantienen solo para configuraciones existentes y no se recomiendan para Bots nuevos.', adapterConfig: 'Configuración del adaptador', viewAdapterDocs: 'Ver documentación', bindPipeline: 'Vincular Pipeline', @@ -729,6 +729,7 @@ const esES = { Parser: 'Analizador', Page: 'Página', AgentRunner: 'Ejecutor de agentes', + EventProcessor: 'Procesador de eventos', }, uploadLocal: 'Subir local', debugging: 'Depuración', @@ -911,6 +912,7 @@ const esES = { Parser: 'Analizador', Page: 'Página', AgentRunner: 'Ejecutor de agentes', + EventProcessor: 'Procesador de eventos', }, filterByType: 'Tipo', allTypes: 'Todos los tipos', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index f1d67c974..3f53dc939 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -372,7 +372,7 @@ const jaJP = { legacyAdapters: '旧式アダプター', legacyAdapterBadge: '旧式', legacyAdaptersHint: - 'これらのアダプターには新しいイベント駆動の接続方式があります。既存設定との互換性のために残しており、新規ボットには推奨されません。', + 'これらのアダプターには新しいイベント駆動の接続方式があります。\n既存設定との互換性のために残しており、新規ボットには推奨されません。', adapterConfig: 'アダプター設定', viewAdapterDocs: 'ドキュメントを見る', bindPipeline: 'パイプラインを紐付け', @@ -1008,6 +1008,7 @@ const jaJP = { Parser: 'パーサー', Page: 'ページ', AgentRunner: 'Agent Runner', + EventProcessor: 'イベントプロセッサー', }, uploadLocal: 'ローカルアップロード', debugging: 'デバッグ中', @@ -1196,6 +1197,7 @@ const jaJP = { Parser: 'パーサー', Page: 'ページ', AgentRunner: 'Agent Runner', + EventProcessor: 'イベントプロセッサー', }, filterByType: 'タイプ', allTypes: '全部', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 08cd606d9..dfb91b57d 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -372,7 +372,7 @@ const ruRU = { legacyAdapters: 'Устаревшие адаптеры', legacyAdapterBadge: 'Устаревший', legacyAdaptersHint: - 'У этих адаптеров есть новые событийные версии. Они сохранены только для существующих конфигураций и не рекомендуются для новых ботов.', + 'У этих адаптеров есть новые событийные версии.\nОни сохранены только для существующих конфигураций и не рекомендуются для новых ботов.', adapterConfig: 'Настройка адаптера', viewAdapterDocs: 'Документация', bindPipeline: 'Привязать конвейер', @@ -722,6 +722,7 @@ const ruRU = { Parser: 'Парсер', Page: 'Страница', AgentRunner: 'Исполнитель агента', + EventProcessor: 'Обработчик событий', }, uploadLocal: 'Загрузить локально', debugging: 'Отладка', @@ -903,6 +904,7 @@ const ruRU = { Parser: 'Парсер', Page: 'Страница', AgentRunner: 'Исполнитель агента', + EventProcessor: 'Обработчик событий', }, filterByType: 'Тип', allTypes: 'Все типы', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 52c4cada9..e5b273d53 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -359,7 +359,7 @@ const thTH = { legacyAdapters: 'อะแดปเตอร์รุ่นเก่า', legacyAdapterBadge: 'เก่า', legacyAdaptersHint: - 'อะแดปเตอร์เหล่านี้มีรุ่นใหม่แบบขับเคลื่อนด้วยเหตุการณ์แล้ว และเก็บไว้เพื่อความเข้ากันได้กับการตั้งค่าเดิมเท่านั้น ไม่แนะนำสำหรับ Bot ใหม่', + 'อะแดปเตอร์เหล่านี้มีรุ่นใหม่แบบขับเคลื่อนด้วยเหตุการณ์แล้ว\nและเก็บไว้เพื่อความเข้ากันได้กับการตั้งค่าเดิมเท่านั้น ไม่แนะนำสำหรับ Bot ใหม่', adapterConfig: 'การกำหนดค่าอะแดปเตอร์', viewAdapterDocs: 'ดูเอกสาร', bindPipeline: 'ผูก Pipeline', @@ -704,6 +704,7 @@ const thTH = { Parser: 'ตัวแยกวิเคราะห์', Page: 'หน้า', AgentRunner: 'Agent Runner', + EventProcessor: 'ตัวประมวลผลเหตุการณ์', }, uploadLocal: 'อัปโหลดจากเครื่อง', debugging: 'ดีบัก', @@ -881,6 +882,7 @@ const thTH = { Parser: 'ตัวแยกวิเคราะห์', Page: 'หน้า', AgentRunner: 'Agent Runner', + EventProcessor: 'ตัวประมวลผลเหตุการณ์', }, filterByType: 'ประเภท', allTypes: 'ทุกประเภท', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index cb95a55cc..ad525c42c 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -368,7 +368,7 @@ const viVN = { legacyAdapters: 'Adapter cũ', legacyAdapterBadge: 'Cũ', legacyAdaptersHint: - 'Các adapter này đã có phiên bản hướng sự kiện mới hơn. Chúng chỉ được giữ lại để tương thích với cấu hình hiện có và không được khuyến nghị cho Bot mới.', + 'Các adapter này đã có phiên bản hướng sự kiện mới hơn.\nChúng chỉ được giữ lại để tương thích với cấu hình hiện có và không được khuyến nghị cho Bot mới.', adapterConfig: 'Cấu hình Adapter', viewAdapterDocs: 'Xem tài liệu', bindPipeline: 'Liên kết Pipeline', @@ -715,6 +715,7 @@ const viVN = { Parser: 'Trình phân tích', Page: 'Trang', AgentRunner: 'Trình chạy tác tử', + EventProcessor: 'Bộ xử lý sự kiện', }, uploadLocal: 'Tải lên cục bộ', debugging: 'Gỡ lỗi', @@ -896,6 +897,7 @@ const viVN = { Parser: 'Trình phân tích', Page: 'Trang', AgentRunner: 'Trình chạy tác tử', + EventProcessor: 'Bộ xử lý sự kiện', }, filterByType: 'Loại', allTypes: 'Tất cả loại', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index b17ea511f..694023756 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -350,7 +350,7 @@ const zhHans = { legacyAdapters: '旧版适配器', legacyAdapterBadge: '过时', legacyAdaptersHint: - '这些适配器已有对应的事件驱动接入版本,仅为兼容存量配置保留,不建议新建机器人时使用。', + '这些适配器已有对应的事件驱动接入版本,\n仅为兼容存量配置保留,不建议新建机器人时使用。', adapterConfig: '适配器配置', viewAdapterDocs: '查看文档', bindPipeline: '绑定流水线', @@ -1031,6 +1031,7 @@ const zhHans = { Parser: '解析器', Page: '页面', AgentRunner: 'Agent 运行器', + EventProcessor: '事件处理器', }, uploadLocal: '本地上传', debugging: '调试中', @@ -1212,6 +1213,7 @@ const zhHans = { Parser: '解析器', Page: '页面', AgentRunner: 'Agent 运行器', + EventProcessor: '事件处理器', }, filterByType: '类型', allTypes: '全部类型', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index c7d44b40f..862d88fdb 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -347,7 +347,7 @@ const zhHant = { legacyAdapters: '舊版適配器', legacyAdapterBadge: '過時', legacyAdaptersHint: - '這些適配器已有對應的事件驅動接入版本,僅為相容既有設定保留,不建議新建機器人時使用。', + '這些適配器已有對應的事件驅動接入版本,\n僅為相容既有設定保留,不建議新建機器人時使用。', adapterConfig: '適配器設定', viewAdapterDocs: '查看文檔', bindPipeline: '綁定流程線', @@ -682,6 +682,7 @@ const zhHant = { Parser: '解析器', Page: '擴展頁', AgentRunner: 'Agent 執行器', + EventProcessor: '事件處理器', }, uploadLocal: '本地上傳', debugging: '調試中', @@ -856,6 +857,7 @@ const zhHant = { Parser: '解析器', Page: '擴展頁', AgentRunner: 'Agent 執行器', + EventProcessor: '事件處理器', }, filterByType: '類型', allTypes: '全部類型',