fix(4.11): align omni adapters and EventProcessor marketplace support

This commit is contained in:
RockChinQ
2026-09-10 15:06:57 +08:00
parent 5ba25c39af
commit d1c79a35e8
137 changed files with 2653 additions and 657 deletions
@@ -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` |
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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.
@@ -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:
+2 -2
View File
@@ -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`.
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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.
+4 -4
View File
@@ -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`.
+2 -2
View File
@@ -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`
@@ -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.
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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,
@@ -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():
+1 -1
View File
@@ -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:
+11 -2
View File
@@ -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,
+1 -1
View File
@@ -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).'
)
+25
View File
@@ -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
@@ -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)
@@ -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),
@@ -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:
@@ -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:
@@ -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)
@@ -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,
@@ -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
@@ -1,3 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'dingtalk-eba'
ADAPTER_NAME = 'dingtalk-omni'
@@ -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
@@ -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)
@@ -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,
@@ -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,
@@ -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:
@@ -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:
@@ -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}
@@ -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.
@@ -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))
@@ -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,
@@ -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:
@@ -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 {}
@@ -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=''),
@@ -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(),
@@ -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
@@ -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:
@@ -1,3 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'lark-eba'
ADAPTER_NAME = 'lark-omni'
@@ -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)
@@ -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
@@ -1,3 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'officialaccount-eba'
ADAPTER_NAME = 'officialaccount-omni'
@@ -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)
@@ -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,
@@ -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:
@@ -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()
@@ -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
@@ -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)
@@ -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:
@@ -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)
@@ -1,3 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'slack-eba'
ADAPTER_NAME = 'slack-omni'
@@ -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
@@ -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 '',
@@ -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,
@@ -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:
@@ -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']
@@ -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')
@@ -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
@@ -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}'),
]
)
@@ -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:
@@ -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()
@@ -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,
@@ -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
@@ -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'))
@@ -1,3 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'wecombot-eba'
ADAPTER_NAME = 'wecombot-omni'
@@ -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')
@@ -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
@@ -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:
+19 -12
View File
@@ -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
@@ -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:
@@ -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:
+1 -1
View File
@@ -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
@@ -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:
@@ -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:
+3 -3
View File
@@ -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:
+32 -4
View File
@@ -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)
+3 -3
View File
@@ -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:
+3 -3
View File
@@ -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:
+2 -2
View File
@@ -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
@@ -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'(?<![\w.-])@{username}(?![\w.-])'
def _normalize_server_url(server_url: str) -> 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
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Artwork" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 140"><defs><style>.cls-1{fill:#1e325c;fill-rule:evenodd;}</style></defs><path class="cls-1" d="M111.11,13.36l.74,14.86c12.04,13.3,16.8,32.15,10.81,49.86-8.95,26.44-38.46,40.33-65.92,31.04-27.46-9.29-42.45-38.26-33.5-64.7,6.01-17.77,21.32-29.87,39.05-33.07L71.87.03C41.99-.77,13.8,17.77,3.72,47.55c-12.4,36.6,7.24,76.33,43.85,88.73,36.6,12.4,76.33-7.24,88.73-43.85,10.07-29.74-1-61.55-25.14-79.07h-.03Z"/><path class="cls-1" d="M93.95,57.21l-.51-20.77-.41-11.95-.28-10.35s.07-4.99-.11-6.16c-.03-.25-.11-.44-.21-.62,0-.03-.02-.05-.03-.07,0-.02-.03-.05-.03-.07-.2-.33-.49-.59-.89-.72s-.8-.1-1.17.05h-.02s-.08.03-.13.07c-.16.08-.34.2-.51.36-.85.82-3.84,4.83-3.84,4.83l-6.5,8.06-7.59,9.25-13.02,16.19s-5.98,7.46-4.65,16.64c1.31,9.18,8.15,13.65,13.43,15.44,5.29,1.79,13.43,2.38,20.05-4.11,6.62-6.49,6.4-16.04,6.4-16.04l.02-.02Z"/></svg>

After

Width:  |  Height:  |  Size: 938 B

@@ -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
@@ -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:
@@ -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:
@@ -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,
)
@@ -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:
+3 -3
View File
@@ -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:
+3 -3
View File
@@ -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:
@@ -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:
@@ -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:
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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:
+1 -1
View File
@@ -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,
@@ -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:

Some files were not shown because too many files have changed in this diff Show More