mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(4.11): align omni adapters and EventProcessor marketplace support
This commit is contained in:
@@ -4,17 +4,17 @@ Date: May 10, 2026
|
|||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
|
|
||||||
- `telegram-eba`
|
- `telegram-omni`
|
||||||
- `discord-eba`
|
- `discord-omni`
|
||||||
- `aiocqhttp-eba`
|
- `aiocqhttp-omni`
|
||||||
- `dingtalk-eba`
|
- `dingtalk-omni`
|
||||||
- `lark-eba`
|
- `lark-omni`
|
||||||
- `wecom-eba`
|
- `wecom-omni`
|
||||||
- `wecombot-eba`
|
- `wecombot-omni`
|
||||||
- `wecomcs-eba`
|
- `wecomcs-omni`
|
||||||
- `officialaccount-eba`
|
- `officialaccount-omni`
|
||||||
- `qqofficial-eba`
|
- `qqofficial-omni`
|
||||||
- `slack-eba`
|
- `slack-omni`
|
||||||
|
|
||||||
This report follows `acceptance-checklist.md`. Evidence levels are intentionally strict:
|
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 |
|
| Requirement | Telegram | Discord | aiocqhttp | DingTalk | Lark / Feishu |
|
||||||
|-------------|----------|---------|-----------|----------|---------------|
|
|-------------|----------|---------|-----------|----------|---------------|
|
||||||
| `bot_uuid` filled | plugin-e2e | plugin-e2e | plugin-e2e | plugin-e2e | live plugin-e2e pending |
|
| `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 `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 |
|
| 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` |
|
| 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
|
└── 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
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ src/langbot/pkg/platform/adapters/discord/
|
|||||||
└── voice.py
|
└── voice.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The adapter is registered as `discord-eba`.
|
The adapter is registered as `discord-omni`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ src/langbot/pkg/platform/adapters/kook/
|
|||||||
└── types.py
|
└── types.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The adapter is registered as `kook-eba`.
|
The adapter is registered as `kook-omni`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- `api_impl.py` for common EBA API implementations.
|
||||||
- `platform_api.py` for Feishu-specific `call_platform_api` actions.
|
- `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
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Adapter directory: `src/langbot/pkg/platform/adapters/officialaccount/`
|
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.
|
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:
|
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`.
|
- 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`.
|
- 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.
|
- `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/`
|
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`.
|
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
|
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:
|
Observed evidence:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- `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.
|
- `api_impl.py` provides cache-backed common read APIs.
|
||||||
- `platform_api.py` declares safe Slack-specific API actions.
|
- `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.
|
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:
|
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`.
|
- 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.
|
- 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`.
|
- Declared Slack platform APIs passed: `get_mode`, `auth_test`.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ src/langbot/pkg/platform/adapters/telegram/
|
|||||||
└── types.py
|
└── types.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The adapter is registered as `telegram-eba`.
|
The adapter is registered as `telegram-omni`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ src/langbot/pkg/platform/adapters/wecom/
|
|||||||
└── types.py
|
└── 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
|
## Configuration
|
||||||
|
|
||||||
@@ -102,13 +102,13 @@ uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.c
|
|||||||
Evidence:
|
Evidence:
|
||||||
|
|
||||||
- JSONL: `data/temp/wecom_eba_plugin_probe.jsonl`
|
- JSONL: `data/temp/wecom_eba_plugin_probe.jsonl`
|
||||||
- Bot: `wecom-eba`
|
- Bot: `wecom-omni`
|
||||||
- Client: real WeCom desktop client
|
- Client: real WeCom desktop client
|
||||||
- Environment: `dev.rockchin.top` test server
|
- Environment: `dev.rockchin.top` test server
|
||||||
|
|
||||||
Observed and verified:
|
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.
|
- 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.
|
- Safe adapter API checks succeeded through the plugin path for cached message/user data and declared safe platform API actions.
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ src/langbot/pkg/platform/adapters/wecombot/
|
|||||||
└── types.py
|
└── 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
|
## Configuration
|
||||||
|
|
||||||
@@ -118,13 +118,13 @@ Evidence:
|
|||||||
|
|
||||||
- JSONL: `data/temp/wecombot_eba_plugin_probe.jsonl`
|
- JSONL: `data/temp/wecombot_eba_plugin_probe.jsonl`
|
||||||
- Bot UUID: `9f5d4125-7b6d-4c98-8ca2-111111111111`
|
- Bot UUID: `9f5d4125-7b6d-4c98-8ca2-111111111111`
|
||||||
- Adapter: `wecombot-eba`
|
- Adapter: `wecombot-omni`
|
||||||
- Client: real WeCom desktop client, private `LangBot` BOT chat
|
- Client: real WeCom desktop client, private `LangBot` BOT chat
|
||||||
- Mode: WebSocket long connection (`enable-webhook=false`)
|
- Mode: WebSocket long connection (`enable-webhook=false`)
|
||||||
|
|
||||||
Observed and verified:
|
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.
|
- 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.
|
- 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`.
|
- Declared WeComBot platform APIs succeeded through `plugin.call_platform_api`: `is_websocket_mode`, `get_stream_session_status`, and `send_markdown`.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ src/langbot/pkg/platform/adapters/wecomcs/
|
|||||||
└── types.py
|
└── 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
|
## Configuration
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ Evidence:
|
|||||||
- Server JSONL: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl`
|
- Server JSONL: `/home/wgc/LangBotxg/LangBotEbaTest/data/temp/wecomcs_eba_plugin_probe.jsonl`
|
||||||
- Trigger text: `EBA wecomcs dedupe probe 2026-05-27`
|
- Trigger text: `EBA wecomcs dedupe probe 2026-05-27`
|
||||||
- `bot_uuid`: `cc810d2c-91f3-4f92-8f27-e1bf9f7b6cb4`
|
- `bot_uuid`: `cc810d2c-91f3-4f92-8f27-e1bf9f7b6cb4`
|
||||||
- `adapter_name`: `wecomcs-eba`
|
- `adapter_name`: `wecomcs-omni`
|
||||||
- Observed common event: `MessageReceived`, `event.type=message.received`
|
- Observed common event: `MessageReceived`, `event.type=message.received`
|
||||||
- Observed message chain: `Source + Plain`
|
- Observed message chain: `Source + Plain`
|
||||||
- Observed chat: `chat_type=private`, `chat_id=external_userid|open_kfid`
|
- 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.
|
||||||
@@ -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
|
`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"`
|
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.
|
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.
|
Legacy EventListener plugins remain in the Pipeline lifecycle.
|
||||||
|
|
||||||
`list_processor_runs` includes `created_at_ms`, `started_at_ms`, and
|
`list_processor_runs` includes `created_at_ms`, `started_at_ms`, and
|
||||||
|
|||||||
@@ -707,9 +707,10 @@ class DingTalkClient:
|
|||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
await self.get_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:
|
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 = {
|
body: dict = {
|
||||||
'cardTemplateId': card_template_id,
|
'cardTemplateId': card_template_id,
|
||||||
|
|||||||
@@ -295,32 +295,6 @@ class WecomCSClient:
|
|||||||
raise Exception(f'Failed to send message: {data}')
|
raise Exception(f'Failed to send message: {data}')
|
||||||
return 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
|
@_bounded_token_retry
|
||||||
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ class AgentService:
|
|||||||
raise ValueError('EventProcessor component is unavailable') from exc
|
raise ValueError('EventProcessor component is unavailable') from exc
|
||||||
raise
|
raise
|
||||||
if descriptor.component_kind != 'EventProcessor' or not descriptor.supported_event_patterns:
|
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}
|
config['runner'] = {'id': component_ref}
|
||||||
parameters = data.get('parameters')
|
parameters = data.get('parameters')
|
||||||
if parameters is None:
|
if parameters is None:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ....workspace.errors import WorkspaceNotFoundError
|
|||||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||||
from ....utils import httpclient
|
from ....utils import httpclient
|
||||||
from ....platform.sources import http_bot_signing
|
from ....platform.sources import http_bot_signing
|
||||||
|
from ....platform.adapter_names import canonical_adapter_name
|
||||||
|
|
||||||
|
|
||||||
class BotService:
|
class BotService:
|
||||||
@@ -41,6 +42,7 @@ class BotService:
|
|||||||
|
|
||||||
def _get_adapter_component(self, adapter_name: str) -> engine.Component | None:
|
def _get_adapter_component(self, adapter_name: str) -> engine.Component | None:
|
||||||
"""Return the discovered platform adapter component for an adapter name."""
|
"""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'):
|
for component in self.ap.discover.get_components_by_kind('MessagePlatformAdapter'):
|
||||||
if component.metadata.name == adapter_name:
|
if component.metadata.name == adapter_name:
|
||||||
return component
|
return component
|
||||||
@@ -555,6 +557,8 @@ class BotService:
|
|||||||
update_data.pop('uuid', None)
|
update_data.pop('uuid', None)
|
||||||
|
|
||||||
update_data = {key: value for key, value in update_data.items() if key in self.BOT_FIELDS}
|
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:
|
if 'event_bindings' in update_data:
|
||||||
update_data['event_bindings'] = await self._normalize_event_bindings(
|
update_data['event_bindings'] = await self._normalize_event_bindings(
|
||||||
context, update_data.get('event_bindings')
|
context, update_data.get('event_bindings')
|
||||||
@@ -573,7 +577,10 @@ class BotService:
|
|||||||
if not include_secret:
|
if not include_secret:
|
||||||
masked_columns = ['adapter_config']
|
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:
|
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:
|
if not include_secret:
|
||||||
masked_columns = ['adapter_config']
|
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(
|
async def get_runtime_bot_info(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ class LangBotMCPServer:
|
|||||||
'Run a synthetic event against an Agent or Event processor without platform delivery. '
|
'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. '
|
'Returns final text and execution_events containing reported messages/thinking and tool calls. '
|
||||||
'Platform tools use mock adapters; other tools execute normally. '
|
'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 '
|
'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).'
|
'mock (errors/results keyed by platform tool name; unsupported_apis lists unavailable platform APIs).'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 typing
|
||||||
|
|
||||||
import aiocqhttp
|
import aiocqhttp
|
||||||
|
|
||||||
|
from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpEventConverter as LegacyAiocqhttpEventConverter
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
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()
|
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
||||||
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
|
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
|
||||||
|
|
||||||
|
_lookup: typing.Any = pydantic.PrivateAttr(default_factory=LegacyAiocqhttpEventConverter)
|
||||||
config: dict
|
config: dict
|
||||||
listeners: dict[
|
listeners: dict[
|
||||||
typing.Type[platform_events.Event],
|
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)
|
await self.bot._server_app.run_task(**self.config)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
|
self._lookup.clear()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _register_native_handlers(self):
|
def _register_native_handlers(self):
|
||||||
@@ -150,7 +154,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
|||||||
if getattr(event, 'type', None) == 'message' and (
|
if getattr(event, 'type', None) == 'message' and (
|
||||||
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
|
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:
|
if legacy_event:
|
||||||
callback = self.listeners.get(type(legacy_event))
|
callback = self.listeners.get(type(legacy_event))
|
||||||
if callback:
|
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()}')
|
await self.logger.error(f'Error in aiocqhttp native event: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _dispatch_native_event(self, event: aiocqhttp.Event):
|
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:
|
if eba_event:
|
||||||
await self._dispatch_eba_event(eba_event)
|
await self._dispatch_eba_event(eba_event)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import typing
|
|||||||
|
|
||||||
import aiocqhttp
|
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
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
|
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
|
||||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
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,
|
event: aiocqhttp.Event,
|
||||||
bot: aiocqhttp.CQHttp | None = None,
|
bot: aiocqhttp.CQHttp | None = None,
|
||||||
bot_user_id: int | str | None = None,
|
bot_user_id: int | str | None = None,
|
||||||
|
lookup: LegacyAiocqhttpEventConverter | None = None,
|
||||||
) -> platform_events.Event | None:
|
) -> platform_events.Event | None:
|
||||||
event_type = getattr(event, 'type', None)
|
event_type = getattr(event, 'type', None)
|
||||||
if event_type == 'message':
|
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':
|
if event_type == 'notice':
|
||||||
return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id)
|
return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id)
|
||||||
if event_type == 'request':
|
if event_type == 'request':
|
||||||
@@ -36,8 +42,9 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
async def target2legacy(
|
async def target2legacy(
|
||||||
event: aiocqhttp.Event,
|
event: aiocqhttp.Event,
|
||||||
bot: aiocqhttp.CQHttp | None = None,
|
bot: aiocqhttp.CQHttp | None = None,
|
||||||
|
lookup: LegacyAiocqhttpEventConverter | None = None,
|
||||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | 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:
|
if eba_event:
|
||||||
return eba_event.to_legacy_event()
|
return eba_event.to_legacy_event()
|
||||||
return None
|
return None
|
||||||
@@ -46,6 +53,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
async def message_to_eba(
|
async def message_to_eba(
|
||||||
event: aiocqhttp.Event,
|
event: aiocqhttp.Event,
|
||||||
bot: aiocqhttp.CQHttp | None = None,
|
bot: aiocqhttp.CQHttp | None = None,
|
||||||
|
lookup: LegacyAiocqhttpEventConverter | None = None,
|
||||||
) -> platform_events.MessageReceivedEvent:
|
) -> platform_events.MessageReceivedEvent:
|
||||||
message_chain = await AiocqhttpMessageConverter.target2yiri(
|
message_chain = await AiocqhttpMessageConverter.target2yiri(
|
||||||
getattr(event, 'message', []),
|
getattr(event, 'message', []),
|
||||||
@@ -64,6 +72,12 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
|
|
||||||
sender = AiocqhttpEventConverter.user_from_sender(event)
|
sender = AiocqhttpEventConverter.user_from_sender(event)
|
||||||
sender_data = getattr(event, 'sender', {}) or {}
|
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')
|
role = sender_data.get('role', 'member')
|
||||||
membership = None
|
membership = None
|
||||||
if group is not None:
|
if group is not None:
|
||||||
@@ -76,7 +90,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
return platform_events.MessageReceivedEvent(
|
return platform_events.MessageReceivedEvent(
|
||||||
type='message.received',
|
type='message.received',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
message_id=getattr(event, 'message_id', ''),
|
message_id=getattr(event, 'message_id', ''),
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
@@ -97,7 +111,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
if notice_type in ('group_recall', 'friend_recall'):
|
if notice_type in ('group_recall', 'friend_recall'):
|
||||||
return platform_events.MessageDeletedEvent(
|
return platform_events.MessageDeletedEvent(
|
||||||
type='message.deleted',
|
type='message.deleted',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
message_id=getattr(event, 'message_id', ''),
|
message_id=getattr(event, 'message_id', ''),
|
||||||
operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)),
|
operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)),
|
||||||
chat_type=platform_entities.ChatType.GROUP
|
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):
|
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
|
||||||
return platform_events.BotInvitedToGroupEvent(
|
return platform_events.BotInvitedToGroupEvent(
|
||||||
type='bot.invited_to_group',
|
type='bot.invited_to_group',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
group=group,
|
group=group,
|
||||||
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
||||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||||
@@ -123,7 +137,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
return platform_events.MemberJoinedEvent(
|
return platform_events.MemberJoinedEvent(
|
||||||
type='group.member_joined',
|
type='group.member_joined',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
group=group,
|
group=group,
|
||||||
member=user,
|
member=user,
|
||||||
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
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):
|
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
|
||||||
return platform_events.BotRemovedFromGroupEvent(
|
return platform_events.BotRemovedFromGroupEvent(
|
||||||
type='bot.removed_from_group',
|
type='bot.removed_from_group',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
group=group,
|
group=group,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||||
@@ -145,7 +159,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
return platform_events.MemberLeftEvent(
|
return platform_events.MemberLeftEvent(
|
||||||
type='group.member_left',
|
type='group.member_left',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
group=group,
|
group=group,
|
||||||
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||||
is_kicked=getattr(event, 'sub_type', '') in ('kick', 'kick_me'),
|
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
|
event_cls = platform_events.BotMutedEvent if duration > 0 else platform_events.BotUnmutedEvent
|
||||||
kwargs: dict[str, typing.Any] = {
|
kwargs: dict[str, typing.Any] = {
|
||||||
'type': 'bot.muted' if duration > 0 else 'bot.unmuted',
|
'type': 'bot.muted' if duration > 0 else 'bot.unmuted',
|
||||||
'adapter_name': 'aiocqhttp',
|
'adapter_name': 'aiocqhttp-omni',
|
||||||
'group': group,
|
'group': group,
|
||||||
'operator': operator,
|
'operator': operator,
|
||||||
'timestamp': float(getattr(event, 'time', 0) or 0),
|
'timestamp': float(getattr(event, 'time', 0) or 0),
|
||||||
@@ -173,7 +187,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
if duration > 0:
|
if duration > 0:
|
||||||
return platform_events.MemberBannedEvent(
|
return platform_events.MemberBannedEvent(
|
||||||
type='group.member_banned',
|
type='group.member_banned',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
group=group,
|
group=group,
|
||||||
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||||
operator=operator,
|
operator=operator,
|
||||||
@@ -184,7 +198,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
if notice_type == 'friend_add':
|
if notice_type == 'friend_add':
|
||||||
return platform_events.FriendAddedEvent(
|
return platform_events.FriendAddedEvent(
|
||||||
type='friend.added',
|
type='friend.added',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
@@ -197,7 +211,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
if request_type == 'friend':
|
if request_type == 'friend':
|
||||||
return platform_events.FriendRequestReceivedEvent(
|
return platform_events.FriendRequestReceivedEvent(
|
||||||
type='friend.request_received',
|
type='friend.request_received',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
request_id=getattr(event, 'flag', ''),
|
request_id=getattr(event, 'flag', ''),
|
||||||
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||||
message=getattr(event, 'comment', None),
|
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':
|
if request_type == 'group' and getattr(event, 'sub_type', '') == 'invite':
|
||||||
return platform_events.BotInvitedToGroupEvent(
|
return platform_events.BotInvitedToGroupEvent(
|
||||||
type='bot.invited_to_group',
|
type='bot.invited_to_group',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
group=AiocqhttpEventConverter.group_from_event(event),
|
group=AiocqhttpEventConverter.group_from_event(event),
|
||||||
inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||||
request_id=getattr(event, 'flag', ''),
|
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:
|
def platform_specific(event: aiocqhttp.Event, action: str) -> platform_events.PlatformSpecificEvent:
|
||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
adapter_name='aiocqhttp',
|
adapter_name='aiocqhttp-omni',
|
||||||
action=action,
|
action=action,
|
||||||
data={key: value for key, value in dict(event).items() if key not in {'message'}},
|
data={key: value for key, value in dict(event).items() if key not in {'message'}},
|
||||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: aiocqhttp-eba
|
name: aiocqhttp-omni
|
||||||
label:
|
label:
|
||||||
en_US: OneBot v11
|
en_US: OneBot v11
|
||||||
zh_Hans: OneBot v11
|
zh_Hans: OneBot v11
|
||||||
@@ -17,9 +17,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- protocol
|
- protocol
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||||
config:
|
config:
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
from langbot.pkg.platform.sources.aiocqhttp import _normalize_base64_payload
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import aiocqhttp
|
import aiocqhttp
|
||||||
@@ -100,7 +103,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
if file_arg:
|
if file_arg:
|
||||||
target.append(aiocqhttp.MessageSegment.record(file_arg))
|
target.append(aiocqhttp.MessageSegment.record(file_arg))
|
||||||
elif isinstance(component, platform_message.File):
|
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(
|
target.append(
|
||||||
aiocqhttp.MessageSegment(
|
aiocqhttp.MessageSegment(
|
||||||
type_='file',
|
type_='file',
|
||||||
@@ -210,6 +213,30 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
face_name='骰子',
|
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:
|
else:
|
||||||
components.append(platform_message.Unknown(text=f'{segment.type}:{segment.data}'))
|
components.append(platform_message.Unknown(text=f'{segment.type}:{segment.data}'))
|
||||||
|
|
||||||
@@ -218,8 +245,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _file_arg(component: platform_message.Image | platform_message.Voice) -> str:
|
def _file_arg(component: platform_message.Image | platform_message.Voice) -> str:
|
||||||
if component.base64:
|
if component.base64:
|
||||||
_, _, payload = component.base64.partition(',')
|
return f'base64://{_normalize_base64_payload(component.base64)}'
|
||||||
return f'base64://{payload or component.base64}'
|
|
||||||
if component.url:
|
if component.url:
|
||||||
return component.url
|
return component.url
|
||||||
if component.path:
|
if component.path:
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
self.card_instance_id_dict.pop(message_id)
|
self.card_instance_id_dict.pop(message_id)
|
||||||
|
|
||||||
async def create_message_card(self, message_id, event):
|
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']
|
card_template_id = self.config['card_template_id']
|
||||||
incoming_message = event.source_platform_object.incoming_message
|
incoming_message = event.source_platform_object.incoming_message
|
||||||
card_auto_layout = self.config.get('card_auto_layout', False)
|
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)
|
self.listeners.pop(event_type, None)
|
||||||
|
|
||||||
async def run_async(self):
|
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()
|
await self.bot.start()
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
|
self.card_instance_id_dict.clear()
|
||||||
|
self.interaction_callback_contexts.clear()
|
||||||
await self.bot.stop()
|
await self.bot.stop()
|
||||||
|
self._message_cache.clear()
|
||||||
|
self._user_cache.clear()
|
||||||
|
self._group_cache.clear()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
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)
|
await self._dispatch_eba_event(interaction_event)
|
||||||
return
|
return
|
||||||
await self.logger.debug(
|
await self.logger.debug(
|
||||||
'DingTalk EBA event received: '
|
'DingTalk event received: '
|
||||||
f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}'
|
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:
|
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
|
self._user_cache[str(event.sender.id)] = event.sender
|
||||||
if event.group:
|
if event.group:
|
||||||
self._group_cache[str(event.group.id)] = 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')
|
raise ValueError('DingTalk interaction callback has no delivery space')
|
||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
adapter_name='dingtalk-eba',
|
adapter_name='dingtalk-omni',
|
||||||
action='interaction.submitted',
|
action='interaction.submitted',
|
||||||
data={
|
data={
|
||||||
**parsed,
|
**parsed,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: dingtalk-eba
|
name: dingtalk-omni
|
||||||
label:
|
label:
|
||||||
en_US: DingTalk
|
en_US: DingTalk
|
||||||
zh_Hans: 钉钉
|
zh_Hans: 钉钉
|
||||||
@@ -17,10 +17,46 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
|
||||||
en: https://link.langbot.app/en/platforms/dingtalk
|
en: https://langbot.app/docs/en/usage/platforms/dingtalk
|
||||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
|
||||||
config:
|
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
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
ADAPTER_NAME = 'dingtalk-eba'
|
ADAPTER_NAME = 'dingtalk-omni'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
message_converter: DiscordMessageConverter = DiscordMessageConverter()
|
message_converter: DiscordMessageConverter = DiscordMessageConverter()
|
||||||
event_converter: DiscordEventConverter = DiscordEventConverter()
|
event_converter: DiscordEventConverter = DiscordEventConverter()
|
||||||
|
|
||||||
|
_stream_buffer: dict = pydantic.PrivateAttr(default_factory=dict)
|
||||||
|
_STREAM_EDIT_INTERVAL: typing.ClassVar[int] = 8
|
||||||
config: dict
|
config: dict
|
||||||
listeners: dict[
|
listeners: dict[
|
||||||
typing.Type[platform_events.Event],
|
typing.Type[platform_events.Event],
|
||||||
@@ -276,9 +279,107 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
raise NotSupportedError(f'call_platform_api:{action}')
|
raise NotSupportedError(f'call_platform_api:{action}')
|
||||||
return await handler(self.bot, params)
|
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):
|
async def run_async(self):
|
||||||
await self.bot.start(self.config['token'], reconnect=True)
|
await self.bot.start(self.config['token'], reconnect=True)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
|
self._stream_buffer.clear()
|
||||||
await self.bot.close()
|
await self.bot.close()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class DiscordAPIMixin:
|
|||||||
until = None
|
until = None
|
||||||
if duration > 0:
|
if duration > 0:
|
||||||
until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=duration)
|
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(
|
async def unmute_member(
|
||||||
self,
|
self,
|
||||||
@@ -109,7 +109,7 @@ class DiscordAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
guild = await self._get_guild(group_id)
|
guild = await self._get_guild(group_id)
|
||||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_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(
|
async def kick_member(
|
||||||
self,
|
self,
|
||||||
@@ -118,7 +118,7 @@ class DiscordAPIMixin:
|
|||||||
) -> None:
|
) -> None:
|
||||||
guild = await self._get_guild(group_id)
|
guild = await self._get_guild(group_id)
|
||||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_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:
|
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||||
guild = await self._get_guild(group_id)
|
guild = await self._get_guild(group_id)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
group = DiscordEventConverter.group_from_message(message)
|
group = DiscordEventConverter.group_from_message(message)
|
||||||
return platform_events.MessageReceivedEvent(
|
return platform_events.MessageReceivedEvent(
|
||||||
type='message.received',
|
type='message.received',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
message_id=message.id,
|
message_id=message.id,
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
sender=DiscordEventConverter.user_from_author(message.author),
|
sender=DiscordEventConverter.user_from_author(message.author),
|
||||||
@@ -73,7 +73,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
) -> platform_events.MessageEditedEvent:
|
) -> platform_events.MessageEditedEvent:
|
||||||
return platform_events.MessageEditedEvent(
|
return platform_events.MessageEditedEvent(
|
||||||
type='message.edited',
|
type='message.edited',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
message_id=after.id,
|
message_id=after.id,
|
||||||
new_content=await DiscordMessageConverter.target2yiri(after),
|
new_content=await DiscordMessageConverter.target2yiri(after),
|
||||||
editor=DiscordEventConverter.user_from_author(after.author),
|
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:
|
async def message_delete_to_eba(message: discord.Message) -> platform_events.MessageDeletedEvent:
|
||||||
return platform_events.MessageDeletedEvent(
|
return platform_events.MessageDeletedEvent(
|
||||||
type='message.deleted',
|
type='message.deleted',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
message_id=message.id,
|
message_id=message.id,
|
||||||
operator=None,
|
operator=None,
|
||||||
chat_type=platform_entities.ChatType.PRIVATE
|
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:
|
def raw_message_delete_to_eba(payload: discord.RawMessageDeleteEvent) -> platform_events.MessageDeletedEvent:
|
||||||
return platform_events.MessageDeletedEvent(
|
return platform_events.MessageDeletedEvent(
|
||||||
type='message.deleted',
|
type='message.deleted',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
message_id=payload.message_id,
|
message_id=payload.message_id,
|
||||||
operator=None,
|
operator=None,
|
||||||
chat_type=platform_entities.ChatType.PRIVATE
|
chat_type=platform_entities.ChatType.PRIVATE
|
||||||
@@ -126,7 +126,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
message = reaction.message
|
message = reaction.message
|
||||||
return platform_events.MessageReactionEvent(
|
return platform_events.MessageReactionEvent(
|
||||||
type='message.reaction',
|
type='message.reaction',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
message_id=message.id,
|
message_id=message.id,
|
||||||
user=DiscordEventConverter.user_from_author(user),
|
user=DiscordEventConverter.user_from_author(user),
|
||||||
reaction=str(reaction.emoji),
|
reaction=str(reaction.emoji),
|
||||||
@@ -152,7 +152,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
user = DiscordEventConverter.user_from_author(user)
|
user = DiscordEventConverter.user_from_author(user)
|
||||||
return platform_events.MessageReactionEvent(
|
return platform_events.MessageReactionEvent(
|
||||||
type='message.reaction',
|
type='message.reaction',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
message_id=payload.message_id,
|
message_id=payload.message_id,
|
||||||
user=user,
|
user=user,
|
||||||
reaction=str(payload.emoji),
|
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:
|
if bot_user_id is not None and member.id == bot_user_id:
|
||||||
return platform_events.BotInvitedToGroupEvent(
|
return platform_events.BotInvitedToGroupEvent(
|
||||||
type='bot.invited_to_group',
|
type='bot.invited_to_group',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
group=group,
|
group=group,
|
||||||
inviter=None,
|
inviter=None,
|
||||||
timestamp=member.joined_at.timestamp() if member.joined_at else 0.0,
|
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(
|
return platform_events.MemberJoinedEvent(
|
||||||
type='group.member_joined',
|
type='group.member_joined',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
group=group,
|
group=group,
|
||||||
member=user,
|
member=user,
|
||||||
inviter=None,
|
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:
|
if bot_user_id is not None and member.id == bot_user_id:
|
||||||
return platform_events.BotRemovedFromGroupEvent(
|
return platform_events.BotRemovedFromGroupEvent(
|
||||||
type='bot.removed_from_group',
|
type='bot.removed_from_group',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
group=group,
|
group=group,
|
||||||
operator=None,
|
operator=None,
|
||||||
source_platform_object=member,
|
source_platform_object=member,
|
||||||
)
|
)
|
||||||
return platform_events.MemberLeftEvent(
|
return platform_events.MemberLeftEvent(
|
||||||
type='group.member_left',
|
type='group.member_left',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
group=group,
|
group=group,
|
||||||
member=user,
|
member=user,
|
||||||
is_kicked=False,
|
is_kicked=False,
|
||||||
@@ -221,7 +221,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
def guild_join_to_eba(guild: discord.Guild) -> platform_events.BotInvitedToGroupEvent:
|
def guild_join_to_eba(guild: discord.Guild) -> platform_events.BotInvitedToGroupEvent:
|
||||||
return platform_events.BotInvitedToGroupEvent(
|
return platform_events.BotInvitedToGroupEvent(
|
||||||
type='bot.invited_to_group',
|
type='bot.invited_to_group',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
group=DiscordEventConverter.group_from_guild(guild),
|
group=DiscordEventConverter.group_from_guild(guild),
|
||||||
inviter=None,
|
inviter=None,
|
||||||
source_platform_object=guild,
|
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:
|
def guild_remove_to_eba(guild: discord.Guild) -> platform_events.BotRemovedFromGroupEvent:
|
||||||
return platform_events.BotRemovedFromGroupEvent(
|
return platform_events.BotRemovedFromGroupEvent(
|
||||||
type='bot.removed_from_group',
|
type='bot.removed_from_group',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
group=DiscordEventConverter.group_from_guild(guild),
|
group=DiscordEventConverter.group_from_guild(guild),
|
||||||
operator=None,
|
operator=None,
|
||||||
source_platform_object=guild,
|
source_platform_object=guild,
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ def interaction_event_from_component(
|
|||||||
raise ValueError('Discord interaction has no actor or channel')
|
raise ValueError('Discord interaction has no actor or channel')
|
||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
adapter_name='discord',
|
adapter_name='discord-omni',
|
||||||
action='interaction.submitted',
|
action='interaction.submitted',
|
||||||
data={
|
data={
|
||||||
**parsed,
|
**parsed,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: discord-eba
|
name: discord-omni
|
||||||
label:
|
label:
|
||||||
en_US: Discord
|
en_US: Discord
|
||||||
zh_Hans: Discord
|
zh_Hans: Discord
|
||||||
@@ -15,6 +15,10 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- popular
|
- popular
|
||||||
- global
|
- 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:
|
config:
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
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 datetime
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
@@ -13,6 +15,14 @@ from langbot.pkg.utils import httpclient
|
|||||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
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:
|
class DiscordMessageConverter:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(
|
async def yiri2target(
|
||||||
@@ -101,7 +111,7 @@ class DiscordMessageConverter:
|
|||||||
filename = f'{uuid.uuid4()}.gif'
|
filename = f'{uuid.uuid4()}.gif'
|
||||||
elif 'webp' in header:
|
elif 'webp' in header:
|
||||||
filename = f'{uuid.uuid4()}.webp'
|
filename = f'{uuid.uuid4()}.webp'
|
||||||
return base64.b64decode(data), filename
|
return await asyncio.to_thread(_decode_discord_base64_limited, data), filename
|
||||||
if element.url:
|
if element.url:
|
||||||
data, content_type = await DiscordMessageConverter._download(element.url)
|
data, content_type = await DiscordMessageConverter._download(element.url)
|
||||||
if 'jpeg' in content_type or 'jpg' in content_type:
|
if 'jpeg' in content_type or 'jpg' in content_type:
|
||||||
@@ -115,8 +125,7 @@ class DiscordMessageConverter:
|
|||||||
path = os.path.abspath(element.path.replace('\x00', ''))
|
path = os.path.abspath(element.path.replace('\x00', ''))
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return None, filename
|
return None, filename
|
||||||
with open(path, 'rb') as fp:
|
data = await asyncio.to_thread(_read_file_limited, path)
|
||||||
data = fp.read()
|
|
||||||
ext = os.path.splitext(path)[1]
|
ext = os.path.splitext(path)[1]
|
||||||
if ext:
|
if ext:
|
||||||
filename = f'{uuid.uuid4()}{ext}'
|
filename = f'{uuid.uuid4()}{ext}'
|
||||||
@@ -133,7 +142,7 @@ class DiscordMessageConverter:
|
|||||||
if ext in header:
|
if ext in header:
|
||||||
filename = f'{uuid.uuid4()}.{ext}'
|
filename = f'{uuid.uuid4()}.{ext}'
|
||||||
break
|
break
|
||||||
return base64.b64decode(data), filename
|
return await asyncio.to_thread(_decode_discord_base64_limited, data), filename
|
||||||
if element.url:
|
if element.url:
|
||||||
data, _ = await DiscordMessageConverter._download(element.url)
|
data, _ = await DiscordMessageConverter._download(element.url)
|
||||||
return data, filename
|
return data, filename
|
||||||
@@ -142,17 +151,21 @@ class DiscordMessageConverter:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def _load_file(element: platform_message.File) -> bytes | None:
|
async def _load_file(element: platform_message.File) -> bytes | None:
|
||||||
if element.base64:
|
if element.base64:
|
||||||
return base64.b64decode(element.base64.split(',')[-1])
|
return await asyncio.to_thread(_decode_discord_base64_limited, element.base64)
|
||||||
if element.url:
|
if element.url:
|
||||||
data, _ = await DiscordMessageConverter._download(element.url)
|
data, _ = await DiscordMessageConverter._download(element.url)
|
||||||
return data
|
return data
|
||||||
|
if element.path:
|
||||||
|
return await asyncio.to_thread(_read_file_limited, element.path)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _download(url: str) -> tuple[bytes, str]:
|
async def _download(url: str) -> tuple[bytes, str]:
|
||||||
session = httpclient.get_session(trust_env=True)
|
session = httpclient.get_session(trust_env=True)
|
||||||
async with session.get(url) as response:
|
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
|
@staticmethod
|
||||||
def _is_image_attachment(attachment: discord.Attachment) -> bool:
|
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_age=params.get('max_age', 0),
|
||||||
max_uses=params.get('max_uses', 0),
|
max_uses=params.get('max_uses', 0),
|
||||||
unique=params.get('unique', True),
|
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}
|
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:
|
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']))
|
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']))
|
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}
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
async def unpin_message(bot: discord.Client, params: dict) -> dict:
|
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']))
|
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']))
|
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}
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Voice support is still implemented by the legacy Discord source adapter. The
|
# 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.
|
# first; voice-specific EBA actions will move here when that surface is migrated.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.platform.sources.kook import _decode_gateway_message
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
import zlib
|
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -129,7 +130,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
|||||||
async def run_async(self):
|
async def run_async(self):
|
||||||
self.running = True
|
self.running = True
|
||||||
self.http_session = httpclient.get_session()
|
self.http_session = httpclient.get_session()
|
||||||
await self.logger.info('KOOK EBA adapter starting')
|
await self.logger.info('KOOK Omni adapter starting')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bot_info = await self._get_bot_user_info()
|
bot_info = await self._get_bot_user_info()
|
||||||
@@ -154,7 +155,10 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
|||||||
pass
|
pass
|
||||||
if self.ws:
|
if self.ws:
|
||||||
await self.ws.close()
|
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
|
return True
|
||||||
|
|
||||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
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
|
self._user_cache[str(event.sender.id)] = event.sender
|
||||||
if event.group:
|
if event.group:
|
||||||
self._group_cache[str(event.group.id)] = 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):
|
async def _websocket_loop(self):
|
||||||
retry_count = 0
|
retry_count = 0
|
||||||
@@ -221,14 +232,14 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
|||||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||||
|
|
||||||
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
|
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:
|
if hello_data.get('s') != 1:
|
||||||
raise Exception(f'Expected KOOK HELLO signal, got {hello_data.get("s")}')
|
raise Exception(f'Expected KOOK HELLO signal, got {hello_data.get("s")}')
|
||||||
await self._handle_hello(hello_data.get('d') or {})
|
await self._handle_hello(hello_data.get('d') or {})
|
||||||
retry_count = 0
|
retry_count = 0
|
||||||
|
|
||||||
async for message in ws:
|
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')
|
signal = msg_data.get('s')
|
||||||
if signal == 0:
|
if signal == 0:
|
||||||
await self._handle_event(msg_data.get('d') or {}, int(msg_data.get('sn') or 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
|
request_kwargs['data'] = data
|
||||||
|
|
||||||
async with session.request(method, url, **request_kwargs) as response:
|
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:
|
if response.status != 200:
|
||||||
raise Exception(f'KOOK API HTTP {response.status}: {payload}')
|
raise Exception(f'KOOK API HTTP {response.status}: {payload}')
|
||||||
if payload.get('code') != 0:
|
if payload.get('code') != 0:
|
||||||
@@ -310,9 +321,4 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _decode_ws_message(message) -> str:
|
def _decode_ws_message(message) -> str:
|
||||||
if isinstance(message, bytes):
|
return json.dumps(_decode_gateway_message(message))
|
||||||
try:
|
|
||||||
return zlib.decompress(message).decode('utf-8')
|
|
||||||
except Exception:
|
|
||||||
return message.decode('utf-8')
|
|
||||||
return str(message)
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
|
|
||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
adapter_name='kook',
|
adapter_name='kook-omni',
|
||||||
action=str(kook_event.get('type') or 'gateway_event'),
|
action=str(kook_event.get('type') or 'gateway_event'),
|
||||||
data=KookEventConverter._compact_data(kook_event),
|
data=KookEventConverter._compact_data(kook_event),
|
||||||
timestamp=KookEventConverter._timestamp(kook_event),
|
timestamp=KookEventConverter._timestamp(kook_event),
|
||||||
@@ -43,7 +43,7 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
|
|
||||||
return platform_events.MessageReceivedEvent(
|
return platform_events.MessageReceivedEvent(
|
||||||
type='message.received',
|
type='message.received',
|
||||||
adapter_name='kook',
|
adapter_name='kook-omni',
|
||||||
message_id=str(kook_event.get('msg_id') or ''),
|
message_id=str(kook_event.get('msg_id') or ''),
|
||||||
message_chain=await KookMessageConverter.target2yiri(kook_event, bot_account_id),
|
message_chain=await KookMessageConverter.target2yiri(kook_event, bot_account_id),
|
||||||
sender=author,
|
sender=author,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: kook-eba
|
name: kook-omni
|
||||||
label:
|
label:
|
||||||
en_US: KOOK
|
en_US: KOOK
|
||||||
zh_Hans: KOOK
|
zh_Hans: KOOK
|
||||||
@@ -20,6 +20,10 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
categories:
|
categories:
|
||||||
- global
|
- 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:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.platform.sources.lark import (
|
||||||
|
LarkAdapter as LegacyLarkAdapter,
|
||||||
|
NonBlockingLarkWSClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
import threading
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -106,6 +112,10 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
quart_app: quart.Quart = pydantic.Field(exclude=True)
|
quart_app: quart.Quart = pydantic.Field(exclude=True)
|
||||||
cipher: AESCipher = 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
|
config: dict
|
||||||
lark_tenant_key: str = pydantic.Field(exclude=True, default='')
|
lark_tenant_key: str = pydantic.Field(exclude=True, default='')
|
||||||
app_ticket: str | None = None
|
app_ticket: str | None = None
|
||||||
@@ -143,7 +153,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
|
|
||||||
api_client = self.build_api_client(config)
|
api_client = self.build_api_client(config)
|
||||||
event_handler = self._build_event_handler()
|
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', ''))
|
cipher = AESCipher(config.get('encrypt-key', ''))
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -216,7 +231,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||||
|
|
||||||
def build_api_client(self, config: dict) -> lark_oapi.Client:
|
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':
|
if config.get('app_type', 'self') == 'isv':
|
||||||
builder = builder.app_type(lark_oapi.AppType.ISV)
|
builder = builder.app_type(lark_oapi.AppType.ISV)
|
||||||
return builder.build()
|
return builder.build()
|
||||||
@@ -291,6 +311,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
'token': content['tenant_access_token'],
|
'token': content['tenant_access_token'],
|
||||||
'expire_at': int(time.time()) + content['expire'] - 300,
|
'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):
|
def get_tenant_access_token(self, tenant_key: str | None):
|
||||||
if self.config.get('app_type', 'self') != 'isv' or not tenant_key:
|
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]] = []
|
payloads: list[tuple[str, dict]] = []
|
||||||
if text_elements:
|
if text_elements:
|
||||||
needs_post = any(ele.get('tag') == 'at' for paragraph in text_elements for ele in paragraph)
|
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}}))
|
payloads.append(('post', {'zh_Hans': {'title': '', 'content': text_elements}}))
|
||||||
else:
|
else:
|
||||||
parts = []
|
parts = []
|
||||||
@@ -390,8 +428,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
|
|
||||||
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
|
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
|
||||||
user_msg_id = getattr(query.message_event, 'message_id', None)
|
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:
|
if user_msg_id:
|
||||||
self.pending_monitoring_msg[str(user_msg_id)] = monitoring_message_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:
|
async def create_message_card(self, message_id, event) -> bool:
|
||||||
card_id = await self.create_card_id(message_id)
|
card_id = await self.create_card_id(message_id)
|
||||||
@@ -409,9 +451,26 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
)
|
)
|
||||||
if not response.success():
|
if not response.success():
|
||||||
raise RuntimeError(f'Lark create_message_card failed: {response.code} {response.msg}')
|
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
|
return True
|
||||||
|
|
||||||
async def create_card_id(self, message_id) -> str:
|
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 = {
|
card_data = {
|
||||||
'schema': '2.0',
|
'schema': '2.0',
|
||||||
'config': {
|
'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'})
|
''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'})
|
||||||
for paragraph in text_elements
|
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)
|
await self._replace_streaming_card(card_id, content)
|
||||||
else:
|
else:
|
||||||
sequence = self._next_card_sequence(card_id)
|
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())
|
.request_body(ContentCardElementRequestBody.builder().content(content).sequence(sequence).build())
|
||||||
.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))
|
request, self.request_option(self._tenant_key_from_source(message_source))
|
||||||
)
|
)
|
||||||
if not response.success():
|
if not response.success():
|
||||||
@@ -646,9 +705,29 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
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
|
self.bot._auto_reconnect = False
|
||||||
await self.bot._disconnect()
|
await self.bot._disconnect()
|
||||||
await _cancel_ws_cache_task(self.bot)
|
await _cancel_ws_cache_task(self.bot)
|
||||||
|
self._message_cache.clear()
|
||||||
|
self._user_cache.clear()
|
||||||
|
self._group_cache.clear()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
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
|
self._user_cache[str(event.sender.id)] = event.sender
|
||||||
if event.group:
|
if event.group:
|
||||||
self._group_cache[str(event.group.id)] = 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):
|
def _handle_card_action_sync(self, event):
|
||||||
interaction_event = interaction_event_from_callback(event)
|
interaction_event = interaction_event_from_callback(event)
|
||||||
@@ -716,18 +802,57 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
|||||||
}
|
}
|
||||||
return response
|
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):
|
def _submit_coro(self, coro):
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
loop = self.event_loop
|
loop = self.event_loop
|
||||||
if loop and loop.is_running():
|
if loop and loop.is_running():
|
||||||
asyncio.run_coroutine_threadsafe(coro, loop)
|
self._schedule_threadsafe_event(coro)
|
||||||
return
|
return
|
||||||
coro.close()
|
coro.close()
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
loop.create_task(coro)
|
self._schedule_inbound_event(coro)
|
||||||
|
|
||||||
def _feedback_event_from_callback(self, event) -> platform_events.FeedbackEvent | None:
|
def _feedback_event_from_callback(self, event) -> platform_events.FeedbackEvent | None:
|
||||||
value = getattr(getattr(event.event, 'action', None), 'value', {}) or {}
|
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)
|
message_chain = await LarkMessageConverter.target2yiri(event_message, self.api_client)
|
||||||
event = platform_events.MessageReceivedEvent(
|
event = platform_events.MessageReceivedEvent(
|
||||||
type='message.received',
|
type='message.received',
|
||||||
adapter_name='lark-eba',
|
adapter_name='lark-omni',
|
||||||
message_id=str(message_id),
|
message_id=str(message_id),
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
sender=platform_entities.User(id=''),
|
sender=platform_entities.User(id=''),
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ def _event_from_parts(
|
|||||||
data['values'] = _form_submission_values(action, payload)
|
data['values'] = _form_submission_values(action, payload)
|
||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
adapter_name='lark-eba',
|
adapter_name='lark-omni',
|
||||||
action='interaction.submitted',
|
action='interaction.submitted',
|
||||||
data=data,
|
data=data,
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: lark-eba
|
name: lark-omni
|
||||||
label:
|
label:
|
||||||
en_US: Lark / Feishu
|
en_US: Lark / Feishu
|
||||||
zh_Hans: 飞书
|
zh_Hans: 飞书
|
||||||
@@ -21,10 +21,75 @@ spec:
|
|||||||
- china
|
- china
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/lark
|
zh: https://langbot.app/docs/zh/usage/platforms/lark
|
||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://langbot.app/docs/en/usage/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://langbot.app/docs/ja/usage/platforms/lark
|
||||||
config:
|
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
|
- name: app_id
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
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 datetime
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import traceback
|
|
||||||
|
|
||||||
import lark_oapi
|
import lark_oapi
|
||||||
from lark_oapi.api.im.v1 import (
|
from lark_oapi.api.im.v1 import (
|
||||||
CreateFileRequest,
|
|
||||||
CreateFileRequestBody,
|
|
||||||
CreateImageRequest,
|
|
||||||
CreateImageRequestBody,
|
|
||||||
EventMessage,
|
EventMessage,
|
||||||
GetMessageResourceRequest,
|
GetMessageResourceRequest,
|
||||||
GetMessageResourceResponse,
|
GetMessageResourceResponse,
|
||||||
@@ -26,78 +30,9 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
|
|||||||
|
|
||||||
|
|
||||||
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
upload_image_to_lark = staticmethod(LegacyLarkMessageConverter.upload_image_to_lark)
|
||||||
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
|
|
||||||
|
|
||||||
temp_file_path = ''
|
upload_file_to_lark = staticmethod(LegacyLarkMessageConverter.upload_file_to_lark)
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_component_bytes(
|
async def _get_component_bytes(
|
||||||
@@ -108,24 +43,22 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
base64_data = msg.base64
|
base64_data = msg.base64
|
||||||
if ',' in base64_data:
|
if ',' in base64_data:
|
||||||
base64_data = base64_data.split(',', 1)[1]
|
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:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
if getattr(msg, 'url', None):
|
if getattr(msg, 'url', None):
|
||||||
try:
|
try:
|
||||||
if str(msg.url).startswith('file://'):
|
if str(msg.url).startswith('file://'):
|
||||||
with open(str(msg.url)[7:], 'rb') as f:
|
return await asyncio.to_thread(_read_lark_path_limited, str(msg.url)[7:])
|
||||||
return f.read()
|
|
||||||
session = httpclient.get_session()
|
session = httpclient.get_session()
|
||||||
async with session.get(msg.url) as response:
|
async with session.get(msg.url) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
return await response.read()
|
return await httpclient.read_limited(response, max_bytes=_MAX_LARK_MEDIA_BYTES)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
if getattr(msg, 'path', None):
|
if getattr(msg, 'path', None):
|
||||||
try:
|
try:
|
||||||
with open(msg.path, 'rb') as f:
|
return await asyncio.to_thread(_read_lark_path_limited, str(msg.path))
|
||||||
return f.read()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
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)
|
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
|
||||||
if not response.success():
|
if not response.success():
|
||||||
return {}
|
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')
|
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'
|
ext = mimetypes.guess_extension(content_type.split(';')[0].strip()) or '.bin'
|
||||||
temp_path = os.path.join(tempfile.gettempdir(), f'lark_{file_key}{ext}')
|
temp_path = os.path.join(tempfile.gettempdir(), f'lark_{file_key}{ext}')
|
||||||
with open(temp_path, 'wb') as f:
|
with open(temp_path, 'wb') as f:
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from __future__ import annotations
|
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']
|
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
|
||||||
missing_keys = [key for key in required_keys if not config.get(key)]
|
missing_keys = [key for key in required_keys if not config.get(key)]
|
||||||
if missing_keys:
|
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']
|
mode = config['Mode']
|
||||||
common_kwargs = {
|
common_kwargs = {
|
||||||
@@ -154,10 +154,13 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
|||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1)
|
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()
|
await keep_alive()
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
|
self.bot.clear()
|
||||||
|
self._message_cache.clear()
|
||||||
|
self._user_cache.clear()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
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):
|
if isinstance(event, platform_events.MessageReceivedEvent):
|
||||||
self._message_cache[str(event.message_id)] = event
|
self._message_cache[str(event.message_id)] = event
|
||||||
self._user_cache[str(event.sender.id)] = event.sender
|
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
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: officialaccount-eba
|
name: officialaccount-omni
|
||||||
label:
|
label:
|
||||||
en_US: Official Account
|
en_US: Official Account
|
||||||
zh_Hans: 微信公众号
|
zh_Hans: 微信公众号
|
||||||
@@ -17,10 +17,23 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/officialaccount
|
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
|
||||||
en: https://link.langbot.app/en/platforms/officialaccount
|
en: https://langbot.app/docs/en/usage/platforms/wxoa
|
||||||
ja: https://link.langbot.app/ja/platforms/officialaccount
|
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
|
||||||
config:
|
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
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
en_US: Webhook Callback URL
|
en_US: Webhook Callback URL
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from __future__ import annotations
|
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
|
arbitrary_types_allowed = True
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
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)]
|
missing_keys = [key for key in required_keys if not config.get(key)]
|
||||||
if missing_keys:
|
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))
|
enable_webhook = config.get('enable-webhook', config.get('enable_webhook', False))
|
||||||
bot = QQOfficialClient(
|
bot = QQOfficialClient(
|
||||||
app_id=config['appid'],
|
app_id=config['appid'],
|
||||||
secret=config['secret'],
|
secret=config['secret'],
|
||||||
token=config['token'],
|
token=config.get('token', ''),
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=enable_webhook,
|
unified_mode=enable_webhook,
|
||||||
)
|
)
|
||||||
@@ -169,6 +169,39 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
raise NotSupportedError(f'call_platform_api:{action}')
|
raise NotSupportedError(f'call_platform_api:{action}')
|
||||||
return await handler(self, dict(params or {}))
|
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(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
event_type: typing.Type[platform_events.Event],
|
event_type: typing.Type[platform_events.Event],
|
||||||
@@ -194,7 +227,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
|
|
||||||
async def run_async(self):
|
async def run_async(self):
|
||||||
if self.enable_webhook:
|
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:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
else:
|
else:
|
||||||
@@ -208,6 +241,15 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
self._ws_task = None
|
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
|
return True
|
||||||
|
|
||||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
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
|
source = event.source_platform_object
|
||||||
if not isinstance(source, QQOfficialEvent) or source.t != 'C2C_MESSAGE_CREATE':
|
if not isinstance(source, QQOfficialEvent) or source.t != 'C2C_MESSAGE_CREATE':
|
||||||
return False
|
return False
|
||||||
|
await self._cleanup_stale_streams()
|
||||||
self._stream_ctx[message_id] = {
|
self._stream_ctx[message_id] = {
|
||||||
'user_openid': source.user_openid,
|
'user_openid': source.user_openid,
|
||||||
'msg_id': source.d_id,
|
'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 not message_id or message_id not in self._stream_ctx:
|
||||||
if chunk_text:
|
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()
|
self._fallback_text_ts[message_id] = time.time()
|
||||||
if is_final:
|
if is_final:
|
||||||
full_text = self._fallback_text.pop(message_id, '')
|
full_text = self._fallback_text.pop(message_id, '')
|
||||||
|
self._fallback_text_ts.pop(message_id, None)
|
||||||
if full_text:
|
if full_text:
|
||||||
await self.reply_message(
|
await self.reply_message(
|
||||||
message_source,
|
message_source,
|
||||||
@@ -267,14 +311,14 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
|
|
||||||
ctx = self._stream_ctx[message_id]
|
ctx = self._stream_ctx[message_id]
|
||||||
if chunk_text:
|
if chunk_text:
|
||||||
ctx['accumulated_text'] += chunk_text
|
ctx['accumulated_text'] = chunk_text[:200000]
|
||||||
if not ctx['session_started']:
|
if not ctx['session_started']:
|
||||||
if not ctx['accumulated_text']:
|
if not ctx['accumulated_text']:
|
||||||
return
|
return
|
||||||
ctx['session_started'] = True
|
ctx['session_started'] = True
|
||||||
|
|
||||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
content_to_send = ctx['accumulated_text']
|
||||||
if not content_to_send and not is_final:
|
if len(content_to_send) <= ctx['sent_length'] and not is_final:
|
||||||
return
|
return
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if not is_final and (now - ctx['last_update_ts']) < 0.5:
|
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,
|
role=platform_entities.MemberRole.MEMBER,
|
||||||
display_name=event.sender.nickname,
|
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):
|
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():
|
async def on_ready():
|
||||||
await self.logger.info('QQ Official WebSocket connected and 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})
|
results.append({'type': content_type, 'raw': raw})
|
||||||
continue
|
continue
|
||||||
if content_type == 'text':
|
if content_type == 'text':
|
||||||
if target_type == 'c2c':
|
raw = await self._send_c2c_or_group_text_reply(
|
||||||
raw = await self.bot.send_private_text_msg(target_id, content.get('content', ''), msg_id)
|
target_type, target_id, content.get('content', ''), msg_id=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}')
|
|
||||||
results.append({'type': content_type, 'raw': raw})
|
results.append({'type': content_type, 'raw': raw})
|
||||||
elif content_type == 'image':
|
elif content_type == 'image':
|
||||||
raw = await self.bot.send_image_msg(
|
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]:
|
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.pop(message_id, None)
|
||||||
self._fallback_text_ts.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(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
adapter_name='qqofficial-eba',
|
adapter_name='qqofficial-omni',
|
||||||
action='interaction.submitted',
|
action='interaction.submitted',
|
||||||
data={
|
data={
|
||||||
**parsed,
|
**parsed,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: qqofficial-eba
|
name: qqofficial-omni
|
||||||
label:
|
label:
|
||||||
en_US: QQ Official API
|
en_US: QQ Official API
|
||||||
zh_Hans: QQ 官方 API
|
zh_Hans: QQ 官方 API
|
||||||
@@ -17,10 +17,47 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/qqofficial
|
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
|
||||||
en: https://link.langbot.app/en/platforms/qqofficial
|
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
|
||||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
|
||||||
config:
|
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
|
- name: appid
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
@@ -43,7 +80,7 @@ spec:
|
|||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
zh_Hant: 令牌
|
zh_Hant: 令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
- name: enable-webhook
|
- name: enable-webhook
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -79,16 +79,14 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
|
|||||||
pic_url=event.attachments,
|
pic_url=event.attachments,
|
||||||
content_type=event.content_type,
|
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:
|
except Exception:
|
||||||
components.append(platform_message.Image(url=event.attachments))
|
components.append(platform_message.Image(url=event.attachments))
|
||||||
|
|
||||||
if event.content:
|
if event.content:
|
||||||
components.append(platform_message.Plain(text=event.content))
|
components.append(platform_message.Plain(text=event.content))
|
||||||
|
|
||||||
if len(components) == 1 or (
|
if len(components) == 1 or (len(components) == 2 and isinstance(components[1], platform_message.At)):
|
||||||
len(components) == 2 and isinstance(components[1], platform_message.At)
|
|
||||||
):
|
|
||||||
components.append(platform_message.Unknown(text=f'[unsupported qqofficial event: {event.t or "unknown"}]'))
|
components.append(platform_message.Unknown(text=f'[unsupported qqofficial event: {event.t or "unknown"}]'))
|
||||||
|
|
||||||
return platform_message.MessageChain(components)
|
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')
|
return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z')
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return datetime.datetime.now()
|
return datetime.datetime.now()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
ADAPTER_NAME = 'qqofficial-eba'
|
ADAPTER_NAME = 'qqofficial-omni'
|
||||||
|
|
||||||
|
|
||||||
class QQOfficialAdapterConfig(pydantic.BaseModel):
|
class QQOfficialAdapterConfig(pydantic.BaseModel):
|
||||||
@@ -11,4 +11,3 @@ class QQOfficialAdapterConfig(pydantic.BaseModel):
|
|||||||
token: str
|
token: str
|
||||||
enable_webhook: bool = False
|
enable_webhook: bool = False
|
||||||
enable_stream_reply: bool = False
|
enable_stream_reply: bool = False
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
required_keys = ['bot_token', 'signing_secret']
|
required_keys = ['bot_token', 'signing_secret']
|
||||||
missing_keys = [key for key in required_keys if not config.get(key)]
|
missing_keys = [key for key in required_keys if not config.get(key)]
|
||||||
if missing_keys:
|
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 = SlackClient(
|
||||||
bot_token=config['bot_token'],
|
bot_token=config['bot_token'],
|
||||||
@@ -144,11 +144,15 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
return await self.bot.handle_unified_webhook(request)
|
return await self.bot.handle_unified_webhook(request)
|
||||||
|
|
||||||
async def run_async(self):
|
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:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
|
self._message_cache.clear()
|
||||||
|
self._user_cache.clear()
|
||||||
|
self._group_cache.clear()
|
||||||
|
self._member_cache.clear()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
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,
|
role=platform_entities.MemberRole.MEMBER,
|
||||||
display_name=event.sender.nickname,
|
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:
|
async def _send_text(self, target_type: str, target_id: str, content: str) -> dict:
|
||||||
target_type = self._normalize_target_type(target_type)
|
target_type = self._normalize_target_type(target_type)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: slack-eba
|
name: slack-omni
|
||||||
label:
|
label:
|
||||||
en_US: Slack
|
en_US: Slack
|
||||||
zh_Hans: Slack
|
zh_Hans: Slack
|
||||||
@@ -18,9 +18,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/slack
|
zh: https://langbot.app/docs/zh/usage/platforms/slack
|
||||||
en: https://link.langbot.app/en/platforms/slack
|
en: https://langbot.app/docs/en/usage/platforms/slack
|
||||||
ja: https://link.langbot.app/ja/platforms/slack
|
ja: https://langbot.app/docs/ja/usage/platforms/slack
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -57,16 +57,18 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
|
|
||||||
if event.pic_url:
|
if event.pic_url:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
components.append(platform_message.Image(url=event.pic_url))
|
components.append(platform_message.Image(url=event.pic_url))
|
||||||
|
|
||||||
if event.text:
|
if event.text:
|
||||||
components.append(platform_message.Plain(text=event.text))
|
components.append(platform_message.Plain(text=event.text))
|
||||||
|
|
||||||
if len(components) == 1 or (
|
if len(components) == 1 or (len(components) == 2 and isinstance(components[1], platform_message.At)):
|
||||||
len(components) == 2 and isinstance(components[1], platform_message.At)
|
|
||||||
):
|
|
||||||
components.append(platform_message.Unknown(text=f'[unsupported slack event: {event.type or "unknown"}]'))
|
components.append(platform_message.Unknown(text=f'[unsupported slack event: {event.type or "unknown"}]'))
|
||||||
|
|
||||||
return platform_message.MessageChain(components)
|
return platform_message.MessageChain(components)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from __future__ import annotations
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
|
||||||
import typing
|
import typing
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
@@ -302,23 +301,33 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
args['parse_mode'] = 'MarkdownV2'
|
args['parse_mode'] = 'MarkdownV2'
|
||||||
return args
|
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):
|
async def create_message_card(self, message_id, event):
|
||||||
assert isinstance(event.source_platform_object, Update)
|
assert isinstance(event.source_platform_object, Update)
|
||||||
update = event.source_platform_object
|
update = event.source_platform_object
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
chat_type = update.effective_chat.type
|
effective_message = update.effective_message
|
||||||
message_thread_id = update.message.message_thread_id
|
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||||
|
|
||||||
if chat_type == 'private':
|
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
||||||
draft_id = int(time.time() * 1000)
|
send_msg = await self.bot.send_message(**args)
|
||||||
self.msg_stream_id[message_id] = ('private', draft_id)
|
self.msg_stream_id[message_id] = ('message', send_msg.message_id, False)
|
||||||
|
self._cap_stream_states()
|
||||||
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)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -335,12 +344,15 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
assert isinstance(message_source.source_platform_object, Update)
|
assert isinstance(message_source.source_platform_object, Update)
|
||||||
update = message_source.source_platform_object
|
update = message_source.source_platform_object
|
||||||
chat_id = update.effective_chat.id
|
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:
|
if message_id not in self.msg_stream_id:
|
||||||
return
|
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)
|
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||||
|
|
||||||
if not components or components[0]['type'] != 'text':
|
if not components or components[0]['type'] != 'text':
|
||||||
@@ -349,17 +361,58 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
return
|
return
|
||||||
|
|
||||||
content = components[0]['text']
|
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':
|
if chat_mode == 'private':
|
||||||
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id)
|
# Streaming via draft (ephemeral preview in the chat input area)
|
||||||
await self.bot.send_message_draft(**args)
|
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:
|
if is_final and bot_message.tool_calls is None:
|
||||||
del args['draft_id']
|
# Finalise: send the real message, discard the draft
|
||||||
await self.bot.send_message(**args)
|
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)
|
self.msg_stream_id.pop(message_id)
|
||||||
else:
|
else:
|
||||||
stream_id = draft_id
|
# Streaming via edit_message_text (persistent message)
|
||||||
if (msg_seq - 1) % 8 == 0 or is_final:
|
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 = {
|
args = {
|
||||||
'message_id': stream_id,
|
'message_id': stream_id,
|
||||||
'chat_id': chat_id,
|
'chat_id': chat_id,
|
||||||
@@ -367,7 +420,15 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
}
|
}
|
||||||
if self.config.get('markdown_card', False):
|
if self.config.get('markdown_card', False):
|
||||||
args['parse_mode'] = 'MarkdownV2'
|
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:
|
if is_final and bot_message.tool_calls is None:
|
||||||
self.msg_stream_id.pop(message_id)
|
self.msg_stream_id.pop(message_id)
|
||||||
@@ -461,4 +522,6 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
if self.application.updater:
|
if self.application.updater:
|
||||||
await self.application.updater.stop()
|
await self.application.updater.stop()
|
||||||
await self.logger.info('Telegram adapter stopped')
|
await self.logger.info('Telegram adapter stopped')
|
||||||
|
await self.application.shutdown()
|
||||||
|
self.msg_stream_id.clear()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
action='callback_query',
|
action='callback_query',
|
||||||
data={
|
data={
|
||||||
'callback_query_id': update.callback_query.id,
|
'callback_query_id': update.callback_query.id,
|
||||||
@@ -111,7 +111,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
action='unknown_update',
|
action='unknown_update',
|
||||||
data={'update_id': update.update_id},
|
data={'update_id': update.update_id},
|
||||||
source_platform_object=update,
|
source_platform_object=update,
|
||||||
@@ -138,7 +138,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.MessageReceivedEvent(
|
return platform_events.MessageReceivedEvent(
|
||||||
type='message.received',
|
type='message.received',
|
||||||
timestamp=message.date.timestamp() if message.date else 0.0,
|
timestamp=message.date.timestamp() if message.date else 0.0,
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
message_id=message.message_id,
|
message_id=message.message_id,
|
||||||
message_chain=lb_message,
|
message_chain=lb_message,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
@@ -169,7 +169,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.MessageEditedEvent(
|
return platform_events.MessageEditedEvent(
|
||||||
type='message.edited',
|
type='message.edited',
|
||||||
timestamp=message.edit_date.timestamp() if message.edit_date else 0.0,
|
timestamp=message.edit_date.timestamp() if message.edit_date else 0.0,
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
message_id=message.message_id,
|
message_id=message.message_id,
|
||||||
new_content=lb_message,
|
new_content=lb_message,
|
||||||
editor=editor,
|
editor=editor,
|
||||||
@@ -203,7 +203,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.MemberJoinedEvent(
|
return platform_events.MemberJoinedEvent(
|
||||||
type='group.member_joined',
|
type='group.member_joined',
|
||||||
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
group=group,
|
group=group,
|
||||||
member=member,
|
member=member,
|
||||||
inviter=inviter,
|
inviter=inviter,
|
||||||
@@ -217,7 +217,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.MemberLeftEvent(
|
return platform_events.MemberLeftEvent(
|
||||||
type='group.member_left',
|
type='group.member_left',
|
||||||
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
group=group,
|
group=group,
|
||||||
member=member,
|
member=member,
|
||||||
is_kicked=is_kicked,
|
is_kicked=is_kicked,
|
||||||
@@ -235,7 +235,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.MemberBannedEvent(
|
return platform_events.MemberBannedEvent(
|
||||||
type='group.member_banned',
|
type='group.member_banned',
|
||||||
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
group=group,
|
group=group,
|
||||||
member=member,
|
member=member,
|
||||||
operator=inviter,
|
operator=inviter,
|
||||||
@@ -247,7 +247,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
action='chat_member_updated',
|
action='chat_member_updated',
|
||||||
data={
|
data={
|
||||||
'old_status': old_status,
|
'old_status': old_status,
|
||||||
@@ -276,7 +276,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.BotInvitedToGroupEvent(
|
return platform_events.BotInvitedToGroupEvent(
|
||||||
type='bot.invited_to_group',
|
type='bot.invited_to_group',
|
||||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
group=group,
|
group=group,
|
||||||
inviter=inviter,
|
inviter=inviter,
|
||||||
source_platform_object=update,
|
source_platform_object=update,
|
||||||
@@ -287,7 +287,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.BotRemovedFromGroupEvent(
|
return platform_events.BotRemovedFromGroupEvent(
|
||||||
type='bot.removed_from_group',
|
type='bot.removed_from_group',
|
||||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
group=group,
|
group=group,
|
||||||
operator=inviter,
|
operator=inviter,
|
||||||
source_platform_object=update,
|
source_platform_object=update,
|
||||||
@@ -303,7 +303,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.BotMutedEvent(
|
return platform_events.BotMutedEvent(
|
||||||
type='bot.muted',
|
type='bot.muted',
|
||||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
group=group,
|
group=group,
|
||||||
operator=inviter,
|
operator=inviter,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
@@ -314,7 +314,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.BotUnmutedEvent(
|
return platform_events.BotUnmutedEvent(
|
||||||
type='bot.unmuted',
|
type='bot.unmuted',
|
||||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
group=group,
|
group=group,
|
||||||
operator=inviter,
|
operator=inviter,
|
||||||
source_platform_object=update,
|
source_platform_object=update,
|
||||||
@@ -323,7 +323,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
action='my_chat_member_updated',
|
action='my_chat_member_updated',
|
||||||
data={
|
data={
|
||||||
'old_status': old_status,
|
'old_status': old_status,
|
||||||
@@ -360,7 +360,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return platform_events.MessageReactionEvent(
|
return platform_events.MessageReactionEvent(
|
||||||
type='message.reaction',
|
type='message.reaction',
|
||||||
timestamp=reaction.date.timestamp() if reaction.date else time.time(),
|
timestamp=reaction.date.timestamp() if reaction.date else time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
message_id=reaction.message_id,
|
message_id=reaction.message_id,
|
||||||
user=user,
|
user=user,
|
||||||
reaction=new_emojis[0] if new_emojis else '',
|
reaction=new_emojis[0] if new_emojis else '',
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ def interaction_event_from_update(
|
|||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
adapter_name='telegram',
|
adapter_name='telegram-omni',
|
||||||
action='interaction.submitted',
|
action='interaction.submitted',
|
||||||
data=data,
|
data=data,
|
||||||
source_platform_object=update,
|
source_platform_object=update,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: telegram-eba
|
name: telegram-omni
|
||||||
label:
|
label:
|
||||||
en_US: Telegram
|
en_US: Telegram
|
||||||
zh_Hans: 电报
|
zh_Hans: 电报
|
||||||
@@ -15,6 +15,10 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- popular
|
- popular
|
||||||
- global
|
- 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:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
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
|
__all__ = ['TelegramMessageConverter']
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -155,10 +155,13 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1)
|
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()
|
await keep_alive()
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
|
await self.bot.close()
|
||||||
|
self._message_cache.clear()
|
||||||
|
self._user_cache.clear()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||||
@@ -200,6 +203,12 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
|||||||
return
|
return
|
||||||
self._message_cache[str(event.message_id)] = event
|
self._message_cache[str(event.message_id)] = event
|
||||||
self._user_cache[str(event.sender.id)] = event.sender
|
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):
|
async def _send_content(self, user_id: str, agent_id: int, content: dict):
|
||||||
content_type = content.get('type')
|
content_type = content.get('type')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: wecom-eba
|
name: wecom-omni
|
||||||
label:
|
label:
|
||||||
en_US: WeCom
|
en_US: WeCom
|
||||||
zh_Hans: 企业微信
|
zh_Hans: 企业微信
|
||||||
@@ -18,10 +18,22 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecom
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
|
||||||
en: https://link.langbot.app/en/platforms/wecom
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
|
||||||
ja: https://link.langbot.app/ja/platforms/wecom
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
|
||||||
config:
|
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
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
en_US: Webhook Callback URL
|
en_US: Webhook Callback URL
|
||||||
|
|||||||
@@ -77,6 +77,6 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
return platform_message.MessageChain(
|
return platform_message.MessageChain(
|
||||||
[
|
[
|
||||||
platform_message.Source(id=message_id, time=datetime.datetime.now()),
|
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
|
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:
|
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 '')
|
user = str(user_id or '')
|
||||||
agent = str(agent_id or '')
|
agent = str(agent_id or '')
|
||||||
if not user or not agent:
|
if not user or not agent:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter as LegacyWecomBotAdapter
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -127,6 +129,10 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
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(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -137,7 +143,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
raise NotSupportedError('send_message:webhook_mode')
|
raise NotSupportedError('send_message:webhook_mode')
|
||||||
if target_type not in ('person', 'private', 'group'):
|
if target_type not in ('person', 'private', 'group'):
|
||||||
raise NotSupportedError(f'send_message:{target_type}')
|
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)
|
raw = await self.bot.send_message(str(target_id), content)
|
||||||
return platform_events.MessageResult(raw={'result': raw})
|
return platform_events.MessageResult(raw={'result': raw})
|
||||||
|
|
||||||
@@ -150,9 +157,14 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
event = await WecomBotEventConverter.yiri2target(message_source)
|
event = await WecomBotEventConverter.yiri2target(message_source)
|
||||||
if not isinstance(event, WecomBotEvent):
|
if not isinstance(event, WecomBotEvent):
|
||||||
raise ValueError('WeComBot reply_message requires a WecomBotEvent source object')
|
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'):
|
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:
|
else:
|
||||||
raw = await self.bot.set_message(event.message_id, content)
|
raw = await self.bot.set_message(event.message_id, content)
|
||||||
return platform_events.MessageResult(message_id=event.message_id, raw={'result': raw})
|
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)
|
event = await WecomBotEventConverter.yiri2target(message_source)
|
||||||
if not isinstance(event, WecomBotEvent):
|
if not isinstance(event, WecomBotEvent):
|
||||||
raise ValueError('WeComBot reply_message_chunk requires a WecomBotEvent source object')
|
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)
|
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'):
|
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)
|
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}
|
return {'stream': success}
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
@@ -219,12 +238,16 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
|||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1)
|
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()
|
await keep_alive()
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
if not self.config.get('enable-webhook', False):
|
if not self.config.get('enable-webhook', False):
|
||||||
await self.bot.disconnect()
|
await self.bot.disconnect()
|
||||||
|
self._message_cache.clear()
|
||||||
|
self._user_cache.clear()
|
||||||
|
self._group_cache.clear()
|
||||||
|
self._member_cache.clear()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
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,
|
role=platform_entities.MemberRole.MEMBER,
|
||||||
display_name=event.sender.nickname,
|
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):
|
def _cleanup_stream_mapping(self):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ def interaction_event_from_native(
|
|||||||
target_id = str(event.chatid or event.userid or '')
|
target_id = str(event.chatid or event.userid or '')
|
||||||
return platform_events.PlatformSpecificEvent(
|
return platform_events.PlatformSpecificEvent(
|
||||||
type='platform.specific',
|
type='platform.specific',
|
||||||
adapter_name='wecombot-eba',
|
adapter_name='wecombot-omni',
|
||||||
action='interaction.submitted',
|
action='interaction.submitted',
|
||||||
data={
|
data={
|
||||||
**parsed,
|
**parsed,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: wecombot-eba
|
name: wecombot-omni
|
||||||
label:
|
label:
|
||||||
en_US: WeComBot
|
en_US: WeComBot
|
||||||
zh_Hans: 企业微信智能机器人
|
zh_Hans: 企业微信智能机器人
|
||||||
@@ -17,10 +17,36 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecombot
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
|
||||||
en: https://link.langbot.app/en/platforms/wecombot
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
|
||||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
|
||||||
config:
|
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
|
- name: BotId
|
||||||
label:
|
label:
|
||||||
en_US: BotId
|
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):
|
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain) -> str:
|
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
|
||||||
content_parts: list[str] = []
|
items: list[dict] = []
|
||||||
for msg in message_chain:
|
for msg in message_chain:
|
||||||
if isinstance(msg, platform_message.Source):
|
if isinstance(msg, platform_message.Source):
|
||||||
continue
|
continue
|
||||||
if isinstance(msg, platform_message.Plain):
|
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):
|
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):
|
elif isinstance(msg, platform_message.AtAll):
|
||||||
content_parts.append('@all')
|
items.append({'type': 'text', 'text': '@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"}]')
|
|
||||||
elif isinstance(msg, platform_message.Quote):
|
elif isinstance(msg, platform_message.Quote):
|
||||||
if msg.id is not None:
|
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:
|
if msg.origin:
|
||||||
content_parts.append(await WecomBotMessageConverter.yiri2target(msg.origin))
|
items.extend(await WecomBotMessageConverter.yiri2target(msg.origin))
|
||||||
elif isinstance(msg, platform_message.Forward):
|
elif isinstance(msg, platform_message.Forward):
|
||||||
for node in msg.node_list:
|
for node in msg.node_list:
|
||||||
if node.message_chain:
|
if node.message_chain:
|
||||||
content_parts.append(await WecomBotMessageConverter.yiri2target(node.message_chain))
|
items.extend(await WecomBotMessageConverter.yiri2target(node.message_chain))
|
||||||
else:
|
else:
|
||||||
content_parts.append(str(msg))
|
items.append({'type': 'text', 'text': str(msg)})
|
||||||
return '\n'.join(part for part in content_parts if part)
|
return items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomBotEvent, bot_name: str = '') -> platform_message.MessageChain:
|
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_link(components, event.link)
|
||||||
WecomBotMessageConverter._append_quote(components, event.quote)
|
WecomBotMessageConverter._append_quote(components, event.quote)
|
||||||
|
|
||||||
if not any(not isinstance(component, (platform_message.Source, platform_message.At)) for component in components):
|
if not any(
|
||||||
components.append(platform_message.Unknown(text=f'[unsupported wecombot msgtype: {event.msgtype or "unknown"}]'))
|
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)
|
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):
|
def _append_file(components: list[platform_message.MessageComponent], file_info: dict | None):
|
||||||
if not file_info:
|
if not file_info:
|
||||||
return
|
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_base64 = file_info.get('base64')
|
||||||
file_name = file_info.get('filename') or file_info.get('name')
|
file_name = file_info.get('filename') or file_info.get('name')
|
||||||
file_size = file_info.get('filesize') or file_info.get('size')
|
file_size = file_info.get('filesize') or file_info.get('size')
|
||||||
@@ -140,7 +147,9 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if not link:
|
if not link:
|
||||||
return
|
return
|
||||||
summary = '\n'.join(
|
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:
|
if summary:
|
||||||
components.append(platform_message.Plain(text=f'{prefix}{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] = []
|
origin: list[platform_message.MessageComponent] = []
|
||||||
if quote_info.get('content'):
|
if quote_info.get('content'):
|
||||||
origin.append(platform_message.Plain(text=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_file(origin, quote_info.get('file'))
|
||||||
WecomBotMessageConverter._append_voice(origin, quote_info.get('voice'))
|
WecomBotMessageConverter._append_voice(origin, quote_info.get('voice'))
|
||||||
WecomBotMessageConverter._append_video(origin, quote_info.get('video'))
|
WecomBotMessageConverter._append_video(origin, quote_info.get('video'))
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from __future__ import annotations
|
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)
|
content_list = await WecomCSMessageConverter.yiri2target(message, self.bot)
|
||||||
raw_results = []
|
raw_results = []
|
||||||
for content in content_list:
|
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})
|
return platform_events.MessageResult(raw={'results': raw_results})
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
@@ -164,10 +166,14 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1)
|
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()
|
await keep_alive()
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
|
self.bot.clear()
|
||||||
|
await self.bot.close()
|
||||||
|
self._message_cache.clear()
|
||||||
|
self._user_cache.clear()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||||
@@ -213,6 +219,12 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
|||||||
return
|
return
|
||||||
self._message_cache[str(event.message_id)] = event
|
self._message_cache[str(event.message_id)] = event
|
||||||
self._user_cache[str(event.sender.id)] = event.sender
|
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):
|
async def _send_content(self, open_kfid: str, external_userid: str, msgid: str, content: dict):
|
||||||
content_type = content.get('type')
|
content_type = content.get('type')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: MessagePlatformAdapter
|
kind: MessagePlatformAdapter
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
name: wecomcs-eba
|
name: wecomcs-omni
|
||||||
label:
|
label:
|
||||||
en_US: WeCom Customer Service
|
en_US: WeCom Customer Service
|
||||||
zh_Hans: 企业微信客服
|
zh_Hans: 企业微信客服
|
||||||
@@ -17,10 +17,23 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecomcs
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
|
||||||
en: https://link.langbot.app/en/platforms/wecomcs
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
|
||||||
ja: https://link.langbot.app/ja/platforms/wecomcs
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
|
||||||
config:
|
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
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
en_US: Webhook Callback URL
|
en_US: Webhook Callback URL
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
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:
|
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 '')
|
user = str(user_id or '')
|
||||||
kfid = str(open_kfid or '')
|
kfid = str(open_kfid or '')
|
||||||
if not user or not kfid:
|
if not user or not kfid:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from ..api.http.authz import WorkspaceRequiredError
|
|||||||
from ..workspace.errors import WorkspaceInvariantError
|
from ..workspace.errors import WorkspaceInvariantError
|
||||||
|
|
||||||
from .logger import EventLogger
|
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.session as provider_session
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
@@ -932,7 +933,7 @@ class RuntimeBot:
|
|||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
target_type=target_type,
|
target_type=target_type,
|
||||||
target_uuid=event_binding.get('target_uuid'),
|
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 target_type == 'discard':
|
||||||
if isinstance(event, platform_events.MessageReceivedEvent):
|
if isinstance(event, platform_events.MessageReceivedEvent):
|
||||||
@@ -947,14 +948,14 @@ class RuntimeBot:
|
|||||||
status='discarded',
|
status='discarded',
|
||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
target_type=target_type,
|
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(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
status='discarded',
|
status='discarded',
|
||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
target_type=target_type,
|
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 target_type == 'pipeline':
|
||||||
if not self._is_message_event_type(event_type):
|
if not self._is_message_event_type(event_type):
|
||||||
@@ -967,7 +968,7 @@ class RuntimeBot:
|
|||||||
target_uuid=event_binding.get('target_uuid'),
|
target_uuid=event_binding.get('target_uuid'),
|
||||||
failure_code='processor_incompatible',
|
failure_code='processor_incompatible',
|
||||||
reason='Pipeline targets only support message events',
|
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(
|
await self._dispatch_eba_message_to_pipeline(
|
||||||
event,
|
event,
|
||||||
@@ -981,7 +982,7 @@ class RuntimeBot:
|
|||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
target_type=target_type,
|
target_type=target_type,
|
||||||
target_uuid=event_binding.get('target_uuid'),
|
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'}:
|
if target_type not in {'agent', 'event_processor'}:
|
||||||
return await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
@@ -993,7 +994,7 @@ class RuntimeBot:
|
|||||||
target_uuid=event_binding.get('target_uuid'),
|
target_uuid=event_binding.get('target_uuid'),
|
||||||
failure_code='processor_incompatible',
|
failure_code='processor_incompatible',
|
||||||
reason=f'Unsupported event binding target type: {target_type}',
|
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')
|
target_uuid = event_binding.get('target_uuid')
|
||||||
@@ -1008,7 +1009,7 @@ class RuntimeBot:
|
|||||||
target_uuid=target_uuid,
|
target_uuid=target_uuid,
|
||||||
failure_code='processor_not_found',
|
failure_code='processor_not_found',
|
||||||
reason='Agent target 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):
|
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
|
||||||
return await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
@@ -1019,7 +1020,7 @@ class RuntimeBot:
|
|||||||
target_uuid=target_uuid,
|
target_uuid=target_uuid,
|
||||||
failure_code='processor_incompatible',
|
failure_code='processor_incompatible',
|
||||||
reason='Agent target does not support this event type',
|
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:
|
try:
|
||||||
@@ -1046,7 +1047,7 @@ class RuntimeBot:
|
|||||||
target_uuid=target_uuid,
|
target_uuid=target_uuid,
|
||||||
failure_code='processor_not_found',
|
failure_code='processor_not_found',
|
||||||
reason='Agent target has no runner',
|
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)
|
envelope = self._eba_event_to_agent_envelope(event, adapter)
|
||||||
@@ -1096,7 +1097,7 @@ class RuntimeBot:
|
|||||||
binding=event_binding,
|
binding=event_binding,
|
||||||
target_type=target_type,
|
target_type=target_type,
|
||||||
target_uuid=target_uuid,
|
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(
|
def resolve_event_pipeline_uuid(
|
||||||
@@ -1475,7 +1476,7 @@ class RuntimeBot:
|
|||||||
) -> None:
|
) -> None:
|
||||||
if not isinstance(event, platform_events.MessageReceivedEvent):
|
if not isinstance(event, platform_events.MessageReceivedEvent):
|
||||||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
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
|
return
|
||||||
|
|
||||||
await self._handle_legacy_message_event(
|
await self._handle_legacy_message_event(
|
||||||
@@ -1806,7 +1807,10 @@ class PlatformManager:
|
|||||||
# delete all bot log images
|
# delete all bot log images
|
||||||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('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')
|
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||||||
@@ -2103,6 +2107,7 @@ class PlatformManager:
|
|||||||
owner=bot_entity.uuid,
|
owner=bot_entity.uuid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
bot_entity.adapter = canonical_adapter_name(bot_entity.adapter)
|
||||||
if bot_entity.adapter not in self.adapter_dict:
|
if bot_entity.adapter not in self.adapter_dict:
|
||||||
raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
|
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:
|
def get_available_adapter_info_by_name(self, name: str) -> dict | None:
|
||||||
|
name = canonical_adapter_name(name)
|
||||||
for component in self.adapter_components:
|
for component in self.adapter_components:
|
||||||
if component.metadata.name == name:
|
if component.metadata.name == name:
|
||||||
return component.to_plain_dict()
|
return component.to_plain_dict()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_available_adapter_manifest_by_name(self, name: str) -> engine.Component | 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:
|
for component in self.adapter_components:
|
||||||
if component.metadata.name == name:
|
if component.metadata.name == name:
|
||||||
return component
|
return component
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- protocol
|
- protocol
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||||
config:
|
config:
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
|
||||||
en: https://link.langbot.app/en/platforms/dingtalk
|
en: https://langbot.app/docs/en/usage/platforms/dingtalk
|
||||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
|
||||||
config:
|
config:
|
||||||
- name: one-click-create
|
- name: one-click-create
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -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.events as platform_events
|
||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
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
|
_MAX_DISCORD_MEDIA_BYTES = 10 * 1024 * 1024
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/discord
|
zh: https://langbot.app/docs/zh/usage/platforms/discord
|
||||||
en: https://link.langbot.app/en/platforms/discord
|
en: https://langbot.app/docs/en/usage/platforms/discord
|
||||||
ja: https://link.langbot.app/ja/platforms/discord
|
ja: https://langbot.app/docs/ja/usage/platforms/discord
|
||||||
config:
|
config:
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||||
en: https://docs.langbot.app/en/platforms/http-bot
|
en: https://langbot.app/docs/en/platforms/http-bot
|
||||||
ja: https://docs.langbot.app/ja/platforms/http-bot
|
ja: https://langbot.app/docs/ja/platforms/http-bot
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/kook
|
zh: https://langbot.app/docs/zh/usage/platforms/kook
|
||||||
en: https://link.langbot.app/en/platforms/kook
|
en: https://langbot.app/docs/en/usage/platforms/kook
|
||||||
ja: https://link.langbot.app/ja/platforms/kook
|
ja: https://langbot.app/docs/ja/usage/platforms/kook
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -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)
|
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:
|
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
||||||
field_type = _dify_field_type(field)
|
field_type = _dify_field_type(field)
|
||||||
if field_type == 'file':
|
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_input_defs[card_id] = _lark_form_input_defs(form_data)
|
||||||
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
||||||
else:
|
else:
|
||||||
# Normal finish: keep pre-pause + resume content visible,
|
# Normal finish: remove buttons/notice and finalize the card.
|
||||||
# remove buttons/notice, drop the resume placeholder.
|
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(
|
await self._update_card_layout(
|
||||||
card_id=card_id,
|
card_id=card_id,
|
||||||
message_source=message_source,
|
message_source=message_source,
|
||||||
text_message=pre_pause,
|
text_message=main_text,
|
||||||
sequence=final_seq,
|
sequence=final_seq,
|
||||||
form_data=None,
|
form_data=None,
|
||||||
notice_text=selected_notice if resume_from else '',
|
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._drop_card_state(card_id)
|
||||||
self.card_id_dict.pop(message_id, None)
|
self.card_id_dict.pop(message_id, None)
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ spec:
|
|||||||
- china
|
- china
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/lark
|
zh: https://langbot.app/docs/zh/usage/platforms/lark
|
||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://langbot.app/docs/en/usage/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://langbot.app/docs/ja/usage/platforms/lark
|
||||||
config:
|
config:
|
||||||
- name: domain
|
- name: domain
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/line
|
zh: https://langbot.app/docs/zh/usage/platforms/line
|
||||||
en: https://link.langbot.app/en/platforms/line
|
en: https://langbot.app/docs/en/usage/platforms/line
|
||||||
ja: https://link.langbot.app/ja/platforms/line
|
ja: https://langbot.app/docs/ja/usage/platforms/line
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -682,8 +682,8 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Use configured logout command, fallback to deriving from login command
|
# Use configured logout command, fallback to deriving from login command
|
||||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||||
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
||||||
|
|
||||||
# Cancel existing tasks
|
# 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:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/officialaccount
|
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
|
||||||
en: https://link.langbot.app/en/platforms/officialaccount
|
en: https://langbot.app/docs/en/usage/platforms/wxoa
|
||||||
ja: https://link.langbot.app/ja/platforms/officialaccount
|
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
|
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
|
||||||
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
bot = QQOfficialClient(
|
bot = QQOfficialClient(
|
||||||
app_id=config['appid'],
|
app_id=config['appid'],
|
||||||
secret=config['secret'],
|
secret=config['secret'],
|
||||||
token=config['token'],
|
token=config.get('token', ''),
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=enable_webhook,
|
unified_mode=enable_webhook,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/qqofficial
|
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
|
||||||
en: https://link.langbot.app/en/platforms/qqofficial
|
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
|
||||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
|
||||||
config:
|
config:
|
||||||
- name: __system.outbound_ips
|
- name: __system.outbound_ips
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- protocol
|
- protocol
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/satori
|
zh: https://langbot.app/docs/zh/usage/platforms/readme
|
||||||
en: https://link.langbot.app/en/platforms/satori
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/satori
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: platform
|
- name: platform
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/slack
|
zh: https://langbot.app/docs/zh/usage/platforms/slack
|
||||||
en: https://link.langbot.app/en/platforms/slack
|
en: https://langbot.app/docs/en/usage/platforms/slack
|
||||||
ja: https://link.langbot.app/ja/platforms/slack
|
ja: https://langbot.app/docs/ja/usage/platforms/slack
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/telegram
|
zh: https://langbot.app/docs/zh/usage/platforms/telegram
|
||||||
en: https://link.langbot.app/en/platforms/telegram
|
en: https://langbot.app/docs/en/usage/platforms/telegram
|
||||||
ja: https://link.langbot.app/ja/platforms/telegram
|
ja: https://langbot.app/docs/ja/usage/platforms/telegram
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wechatpad
|
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
|
||||||
en: https://link.langbot.app/en/platforms/wechatpad
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/wechatpad
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: wechatpad_url
|
- name: wechatpad_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
||||||
if content['type'] == 'image':
|
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':
|
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':
|
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(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecom
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
|
||||||
en: https://link.langbot.app/en/platforms/wecom
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
|
||||||
ja: https://link.langbot.app/ja/platforms/wecom
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -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.message as platform_message
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
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.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.wecombotevent import WecomBotEvent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import (
|
from langbot.libs.wecom_ai_bot_api.api import (
|
||||||
WecomBotClient,
|
WecomBotClient,
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecombot
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
|
||||||
en: https://link.langbot.app/en/platforms/wecombot
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
|
||||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
|
||||||
config:
|
config:
|
||||||
- name: one-click-create
|
- name: one-click-create
|
||||||
label:
|
label:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user