feat(platform): add wecom eba adapters

This commit is contained in:
WangCham
2026-05-27 10:52:17 +08:00
parent 197e117900
commit 7328881e6f
31 changed files with 2842 additions and 17 deletions
+6 -2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio
import base64
import json
@@ -7,7 +9,7 @@ import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
import re
from typing import Any, Callable, Optional, Tuple
from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple
from urllib.parse import unquote
import httpx
@@ -16,7 +18,9 @@ from quart import Quart, request, Response, jsonify
from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
from langbot.pkg.platform.logger import EventLogger
if TYPE_CHECKING:
from langbot.pkg.platform.logger import EventLogger
@dataclass
@@ -15,13 +15,15 @@ import json
import secrets
import time
import traceback
from typing import Any, Callable, Optional
from typing import TYPE_CHECKING, Any, Callable, Optional
import aiohttp
from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
from langbot.pkg.platform.logger import EventLogger
if TYPE_CHECKING:
from langbot.pkg.platform.logger import EventLogger
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
+23 -11
View File
@@ -5,6 +5,7 @@ import sqlalchemy
import typing
from ....core import app
from ....discover import engine
from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline
@@ -17,6 +18,24 @@ class BotService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
def _get_adapter_component(self, adapter_name: str) -> engine.Component | None:
"""Return the discovered platform adapter component for an adapter name."""
for component in self.ap.discover.get_components_by_kind('MessagePlatformAdapter'):
if component.metadata.name == adapter_name:
return component
return None
def _adapter_declares_webhook_url(self, adapter_name: str) -> bool:
"""Whether the adapter manifest declares a generated webhook URL config item."""
component = self._get_adapter_component(adapter_name)
if component is None:
return False
for config_item in component.spec.get('config', []):
if config_item.get('type') == 'webhook-url':
return True
return False
async def get_bots(self, include_secret: bool = True) -> list[dict]:
"""获取所有机器人"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
@@ -58,17 +77,10 @@ class BotService:
if runtime_bot is not None:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
# Webhook URL for unified webhook adapters (independent of bot running state)
if persistence_bot['adapter'] in [
'wecom',
'wecombot',
'officialaccount',
'qqofficial',
'slack',
'wecomcs',
'LINE',
'lark',
]:
# Webhook URL for adapters that declare a generated webhook config item.
# This is manifest-driven so EBA adapters do not need to be mirrored in a
# second hard-coded list.
if self._adapter_declares_webhook_url(persistence_bot['adapter']):
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
webhook_url = f'/bots/{bot_uuid}'
+13 -2
View File
@@ -163,13 +163,21 @@ class PreProcessor(stage.PipelineStage):
plain_text = ''
quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
local_agent_without_vision = (
selected_runner == 'local-agent'
and llm_model
and not llm_model.model_entity.abilities.__contains__('vision')
)
for me in query.message_chain:
if isinstance(me, platform_message.Plain):
content_list.append(provider_message.ContentElement.from_text(me.text))
plain_text += me.text
elif isinstance(me, platform_message.Image):
if selected_runner != 'local-agent' or (
if local_agent_without_vision:
content_list.append(provider_message.ContentElement.from_text('[Image]'))
plain_text += '[Image]'
elif selected_runner != 'local-agent' or (
llm_model and llm_model.model_entity.abilities.__contains__('vision')
):
if me.base64 is not None:
@@ -190,7 +198,10 @@ class PreProcessor(stage.PipelineStage):
if isinstance(msg, platform_message.Plain):
content_list.append(provider_message.ContentElement.from_text(msg.text))
elif isinstance(msg, platform_message.Image):
if selected_runner != 'local-agent' or (
if local_agent_without_vision:
content_list.append(provider_message.ContentElement.from_text('[Image]'))
plain_text += '[Image]'
elif selected_runner != 'local-agent' or (
llm_model and llm_model.model_entity.abilities.__contains__('vision')
):
if msg.base64 is not None:
@@ -0,0 +1 @@
@@ -0,0 +1,221 @@
from __future__ import annotations
import asyncio
import traceback
import typing
import pydantic
from langbot.libs.wecom_api.api import WecomClient
from langbot.libs.wecom_api.wecomevent import WecomEvent
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
from langbot.pkg.platform.adapters.wecom.api_impl import WecomAPIMixin
from langbot.pkg.platform.adapters.wecom.event_converter import WecomEventConverter
from langbot.pkg.platform.adapters.wecom.message_converter import WecomMessageConverter
from langbot.pkg.platform.adapters.wecom.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: WecomClient = pydantic.Field(exclude=True)
message_converter: WecomMessageConverter = WecomMessageConverter()
event_converter: WecomEventConverter = WecomEventConverter()
config: dict
bot_uuid: str | None = None
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, typing.Any] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
required_keys = [
'corpid',
'secret',
'token',
'EncodingAESKey',
]
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise Exception(f'WeCom missing required config fields: {missing_keys}')
bot = WecomClient(
corpid=config['corpid'],
secret=config['secret'],
token=config['token'],
EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config.get('contacts_secret', ''),
logger=logger,
unified_mode=True,
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
)
super().__init__(
config=config,
logger=logger,
bot=bot,
bot_account_id='',
bot_uuid=None,
listeners={},
_message_cache={},
_user_cache={},
)
self._register_native_handlers()
def set_bot_uuid(self, bot_uuid: str):
self.bot_uuid = bot_uuid
def get_supported_events(self) -> list[str]:
return [
'message.received',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_message',
'get_user_info',
'get_friend_list',
'call_platform_api',
]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
if target_type not in ('person', 'private'):
raise NotSupportedError(f'send_message:{target_type}')
user_id, agent_id = self._parse_target_id(target_id)
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
raw_results = []
for content in content_list:
raw_results.append(await self._send_content(user_id, agent_id, content))
return platform_events.MessageResult(raw={'results': raw_results})
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
wecom_event = await WecomEventConverter.yiri2target(message_source)
if not isinstance(wecom_event, WecomEvent):
raise ValueError('WeCom reply_message requires a WecomEvent source object')
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
raw_results = []
for content in content_list:
raw_results.append(await self._send_content(wecom_event.user_id, int(wecom_event.agent_id), content))
return platform_events.MessageResult(message_id=wecom_event.message_id, raw={'results': raw_results})
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self.bot, params)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], 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], None
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
async def keep_alive():
while True:
await asyncio.sleep(1)
await self.logger.info('WeCom EBA adapter running in unified webhook mode')
await keep_alive()
async def kill(self) -> bool:
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
def _register_native_handlers(self):
async def on_message(event: WecomEvent):
await self._handle_native_event(event)
self.bot.on_message('text')(on_message)
self.bot.on_message('image')(on_message)
async def _handle_native_event(self, event: WecomEvent):
self.bot_account_id = event.receiver_id or self.bot_account_id
try:
if platform_events.FriendMessage in self.listeners:
legacy_event = await self.event_converter.target2legacy(event, self.bot)
if legacy_event:
callback = self.listeners.get(type(legacy_event))
if callback:
await callback(legacy_event, self)
eba_event = await self.event_converter.target2yiri(event, self.bot)
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error in wecom native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def _cache_event(self, event: platform_events.Event):
if not isinstance(event, platform_events.MessageReceivedEvent):
return
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
async def _send_content(self, user_id: str, agent_id: int, content: dict):
content_type = content.get('type')
if content_type == 'text':
return await self.bot.send_private_msg(user_id, agent_id, content.get('content', ''))
if content_type == 'image':
return await self.bot.send_image(user_id, agent_id, content['media_id'])
if content_type == 'voice':
return await self.bot.send_voice(user_id, agent_id, content['media_id'])
if content_type == 'file':
return await self.bot.send_file(user_id, agent_id, content['media_id'])
raise NotSupportedError(f'send_content:{content_type}')
@staticmethod
def _parse_target_id(target_id: str) -> tuple[str, int]:
user_id, sep, agent_id = str(target_id).partition('|')
if not user_id or not sep or not agent_id:
raise ValueError('WeCom target_id must be formatted as "user_id|agent_id"')
return user_id, int(agent_id)
@@ -0,0 +1,79 @@
from __future__ import annotations
import typing
from langbot.libs.wecom_api.api import WecomClient
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class WecomAPIMixin:
bot: WecomClient
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
event = self._message_cache.get(str(message_id))
if event is None:
raise NotSupportedError('get_message:message_not_cached')
return event
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
cached = self._user_cache.get(str(user_id))
if cached is not None:
return cached
info = await self.bot.get_user_info(str(user_id))
return platform_entities.User(
id=info.get('userid') or user_id,
nickname=info.get('name') or str(user_id),
username=info.get('alias') or info.get('userid') or None,
)
async def get_friend_list(self) -> list[platform_entities.User]:
return list(self._user_cache.values())
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
raise NotSupportedError('get_file_url')
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
raise NotSupportedError('get_group_info')
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
raise NotSupportedError('get_group_member_list')
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
raise NotSupportedError('get_group_member_info')
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
raise NotSupportedError('edit_message')
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('delete_message')
@@ -0,0 +1,91 @@
from __future__ import annotations
import typing
from langbot.libs.wecom_api.api import WecomClient
from langbot.libs.wecom_api.wecomevent import WecomEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.platform.adapters.wecom.message_converter import WecomMessageConverter
from langbot.pkg.platform.adapters.wecom.types import ADAPTER_NAME, make_private_chat_id
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.Event) -> WecomEvent | None:
return getattr(event, 'source_platform_object', None)
@staticmethod
async def target2legacy(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.FriendMessage | None:
eba_event = await WecomEventConverter.target2yiri(event, bot)
if hasattr(eba_event, 'to_legacy_event'):
return eba_event.to_legacy_event()
if event.type in {'text', 'image'} and eba_event is not None:
friend = platform_entities.Friend(
id=f'u{event.user_id}',
nickname=getattr(getattr(eba_event, 'sender', None), 'nickname', str(event.user_id or '')),
remark='',
)
return platform_events.FriendMessage(
sender=friend,
message_chain=eba_event.message_chain,
time=getattr(eba_event, 'timestamp', None),
source_platform_object=event,
)
return None
@staticmethod
async def target2yiri(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.Event | None:
if event.type in {'text', 'image'}:
return await WecomEventConverter.message_to_eba(event, bot)
return WecomEventConverter.platform_specific(event, f'message.{event.detail_type or event.type or "unknown"}')
@staticmethod
async def message_to_eba(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.MessageReceivedEvent:
if event.type == 'image':
message_chain = await WecomMessageConverter.target2yiri_image(event.picurl, event.message_id)
else:
message_chain = await WecomMessageConverter.target2yiri_text(event.message, event.message_id)
sender = await WecomEventConverter.user_from_event(event, bot)
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name=ADAPTER_NAME,
message_id=event.message_id or '',
message_chain=message_chain,
sender=sender,
chat_type=platform_entities.ChatType.PRIVATE,
chat_id=make_private_chat_id(event.user_id, event.agent_id),
group=None,
timestamp=float(event.timestamp or 0),
source_platform_object=event,
)
@staticmethod
async def user_from_event(event: WecomEvent, bot: WecomClient | None = None) -> platform_entities.User:
nickname = str(event.user_id or '')
raw: dict[str, typing.Any] = {}
if bot and event.user_id:
try:
raw = await bot.get_user_info(event.user_id)
nickname = raw.get('name') or nickname
except Exception:
raw = {}
return platform_entities.User(
id=event.user_id or '',
nickname=nickname,
username=raw.get('alias') or raw.get('userid') or None,
)
@staticmethod
def platform_specific(event: WecomEvent, action: str) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name=ADAPTER_NAME,
action=action,
data=dict(event),
timestamp=float(event.timestamp or 0),
source_platform_object=event,
)
@@ -0,0 +1,117 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: wecom-eba
label:
en_US: WeCom (EBA)
zh_Hans: 企业微信 (EBA)
zh_Hant: 企業微信 (EBA)
description:
en_US: WeCom application message adapter (EBA architecture)
zh_Hans: 企业微信内部应用消息适配器(EBA 架构版本)
zh_Hant: 企業微信內部應用訊息適配器(EBA 架構版本)
icon: wecom.png
spec:
categories:
- popular
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wecom
en: https://link.langbot.app/en/platforms/wecom
ja: https://link.langbot.app/ja/platforms/wecom
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your WeCom app's webhook configuration
zh_Hans: 复制此地址并粘贴到企业微信应用的 Webhook 配置中
zh_Hant: 複製此地址並貼到企業微信應用的 Webhook 設定中
type: webhook-url
required: false
default: ""
- name: corpid
label:
en_US: Corpid
zh_Hans: 企业ID
zh_Hant: 企業ID
type: string
required: true
default: ""
- name: secret
label:
en_US: Secret
zh_Hans: 密钥 (Secret)
zh_Hant: 密鑰 (Secret)
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_Hans: 令牌 (Token)
zh_Hant: 令牌 (Token)
type: string
required: true
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey)
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
type: string
required: true
default: ""
- name: contacts_secret
label:
en_US: Contacts Secret
zh_Hans: 通讯录密钥
zh_Hant: 通訊錄密鑰
type: string
required: false
default: ""
- name: api_base_url
label:
en_US: API Base URL
zh_Hans: API 基础 URL
zh_Hant: API 基礎 URL
description:
en_US: Optional WeCom API base URL for private network or reverse proxy deployments.
zh_Hans: 可选,若部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项
zh_Hant: 可選,若部署在內網環境並透過反向代理存取企業微信 API,可根據文件填寫此項
type: string
required: false
default: "https://qyapi.weixin.qq.com/cgi-bin"
supported_events:
- message.received
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_message
- get_user_info
- get_friend_list
- call_platform_api
platform_specific_apis:
- action: check_access_token
description: { en_US: "Check whether the current WeCom access token is usable", zh_Hans: "检查当前企业微信 access token 是否可用" }
- action: refresh_access_token
description: { en_US: "Refresh the WeCom access token", zh_Hans: "刷新企业微信 access token" }
- action: get_user_info
description: { en_US: "Get WeCom user information by user ID", zh_Hans: "按用户 ID 获取企业微信用户信息" }
- action: send_to_all
description: { en_US: "Send an application text message to all contacts available to the configured contacts secret", zh_Hans: "使用配置的通讯录密钥向可见成员群发应用文本消息" }
execution:
python:
path: ./adapter.py
attr: WecomAdapter
@@ -0,0 +1,82 @@
from __future__ import annotations
import datetime
from langbot.libs.wecom_api.api import WecomClient
from langbot.pkg.utils import image
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import message as platform_message
def split_string_by_bytes(text: str, limit: int = 2048, encoding: str = 'utf-8') -> list[str]:
"""Split text without cutting a multi-byte character in half."""
bytes_data = text.encode(encoding)
total_len = len(bytes_data)
parts: list[str] = []
start = 0
while start < total_len:
end = min(start + limit, total_len)
chunk = bytes_data[start:end]
part = chunk.decode(encoding, errors='ignore')
part_len = len(part.encode(encoding))
if part_len == 0 and end < total_len:
start += 1
continue
parts.append(part)
start += part_len
return parts
class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomClient) -> list[dict]:
content_list: list[dict] = []
for msg in message_chain:
if isinstance(msg, platform_message.Source):
continue
if isinstance(msg, platform_message.Plain):
content_list.extend({'type': 'text', 'content': chunk} for chunk in split_string_by_bytes(msg.text))
elif isinstance(msg, platform_message.Image):
content_list.append({'type': 'image', 'media_id': await bot.get_media_id(msg)})
elif isinstance(msg, platform_message.Voice):
content_list.append({'type': 'voice', 'media_id': await bot.get_media_id(msg)})
elif isinstance(msg, platform_message.File):
content_list.append({'type': 'file', 'media_id': await bot.get_media_id(msg)})
elif isinstance(msg, platform_message.Forward):
for node in msg.node_list:
content_list.extend(await WecomMessageConverter.yiri2target(node.message_chain, bot))
elif isinstance(msg, platform_message.Quote):
if msg.id is not None:
content_list.append({'type': 'text', 'content': f'[Quote {msg.id}] '})
if msg.origin:
content_list.extend(await WecomMessageConverter.yiri2target(msg.origin, bot))
elif isinstance(msg, platform_message.At):
content_list.append({'type': 'text', 'content': f'@{msg.display or msg.target}'})
elif isinstance(msg, platform_message.AtAll):
content_list.append({'type': 'text', 'content': '@all'})
else:
content_list.append({'type': 'text', 'content': str(msg)})
return content_list
@staticmethod
async def target2yiri_text(message: str | None, message_id: int | str | None = -1) -> platform_message.MessageChain:
return platform_message.MessageChain(
[
platform_message.Source(id=message_id, time=datetime.datetime.now()),
platform_message.Plain(text=message or ''),
]
)
@staticmethod
async def target2yiri_image(picurl: str, message_id: int | str | None = -1) -> platform_message.MessageChain:
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
return platform_message.MessageChain(
[
platform_message.Source(id=message_id, time=datetime.datetime.now()),
platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'),
]
)
@@ -0,0 +1,40 @@
from __future__ import annotations
import typing
from langbot.libs.wecom_api.api import WecomClient
async def check_access_token(bot: WecomClient, params: dict) -> dict:
return {'valid': await bot.check_access_token()}
async def refresh_access_token(bot: WecomClient, params: dict) -> dict:
bot.access_token = await bot.get_access_token(bot.secret)
return {'ok': bool(bot.access_token)}
async def get_user_info(bot: WecomClient, params: dict) -> dict:
user_id = params.get('user_id') or params.get('userid')
if not user_id:
raise ValueError('user_id is required')
return await bot.get_user_info(str(user_id))
async def send_to_all(bot: WecomClient, params: dict) -> dict:
content = params.get('content')
agent_id = params.get('agent_id') or params.get('agentid')
if not content:
raise ValueError('content is required')
if agent_id is None:
raise ValueError('agent_id is required')
await bot.send_to_all(str(content), int(agent_id))
return {'ok': True}
PLATFORM_API_MAP: dict[str, typing.Callable[[WecomClient, dict], typing.Awaitable[dict]]] = {
'check_access_token': check_access_token,
'refresh_access_token': refresh_access_token,
'get_user_info': get_user_info,
'send_to_all': send_to_all,
}
@@ -0,0 +1,12 @@
from __future__ import annotations
ADAPTER_NAME = 'wecom-eba'
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."""
user = str(user_id or '')
agent = str(agent_id or '')
if not user or not agent:
return user
return f'{user}|{agent}'
Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

@@ -0,0 +1 @@
from __future__ import annotations
@@ -0,0 +1,282 @@
from __future__ import annotations
import asyncio
import time
import traceback
import typing
import pydantic
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
from langbot.pkg.platform.adapters.wecombot.api_impl import WecomBotAPIMixin
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP
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
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: typing.Any = pydantic.Field(exclude=True)
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
event_converter: WecomBotEventConverter
config: dict
bot_uuid: str | None = None
bot_name: str = ''
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, platform_entities.User] = {}
_group_cache: dict[str, platform_entities.UserGroup] = {}
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] = {}
_stream_to_monitoring_msg: dict[str, tuple[str, float]] = {}
_STREAM_MAPPING_TTL: int = 600
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
enable_webhook = config.get('enable-webhook', False)
bot_name = config.get('robot_name', '')
if not enable_webhook:
required_keys = ['BotId', 'Secret']
missing_keys = [key for key in required_keys if not config.get(key)]
if missing_keys:
raise Exception(f'WeComBot WebSocket mode missing config: {missing_keys}')
bot = WecomBotWsClient(
bot_id=config['BotId'],
secret=config['Secret'],
logger=logger,
encoding_aes_key=config.get('EncodingAESKey', ''),
)
else:
required_keys = ['Token', 'EncodingAESKey', 'Corpid']
missing_keys = [key for key in required_keys if not config.get(key)]
if missing_keys:
raise Exception(f'WeComBot webhook mode missing config: {missing_keys}')
bot = WecomBotClient(
Token=config['Token'],
EnCodingAESKey=config['EncodingAESKey'],
Corpid=config['Corpid'],
logger=logger,
unified_mode=True,
)
super().__init__(
config=config,
logger=logger,
bot=bot,
bot_account_id=config.get('BotId', ''),
bot_uuid=None,
bot_name=bot_name,
event_converter=WecomBotEventConverter(bot_name=bot_name),
listeners={},
_message_cache={},
_user_cache={},
_group_cache={},
_member_cache={},
_stream_to_monitoring_msg={},
)
self._register_native_handlers()
def set_bot_uuid(self, bot_uuid: str):
self.bot_uuid = bot_uuid
def get_supported_events(self) -> list[str]:
return [
'message.received',
'feedback.received',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_message',
'get_user_info',
'get_friend_list',
'get_group_info',
'get_group_member_info',
'get_group_member_list',
'call_platform_api',
]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
if self.config.get('enable-webhook', False):
raise NotSupportedError('send_message:webhook_mode')
if target_type not in ('person', 'private', 'group'):
raise NotSupportedError(f'send_message:{target_type}')
content = await WecomBotMessageConverter.yiri2target(message)
raw = await self.bot.send_message(str(target_id), content)
return platform_events.MessageResult(raw={'result': raw})
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
event = await WecomBotEventConverter.yiri2target(message_source)
if not isinstance(event, WecomBotEvent):
raise ValueError('WeComBot reply_message requires a WecomBotEvent source object')
content = await WecomBotMessageConverter.yiri2target(message)
if not self.config.get('enable-webhook', False) and event.get('req_id'):
raw = await self.bot.reply_text(event.get('req_id'), content)
else:
raw = await self.bot.set_message(event.message_id, content)
return platform_events.MessageResult(message_id=event.message_id, raw={'result': raw})
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,
) -> dict:
event = await WecomBotEventConverter.yiri2target(message_source)
if not isinstance(event, WecomBotEvent):
raise ValueError('WeComBot reply_message_chunk requires a WecomBotEvent source object')
content = await WecomBotMessageConverter.yiri2target(message)
success = await self.bot.push_stream_chunk(event.message_id, content, is_final=is_final)
if not success and is_final and not self.config.get('enable-webhook', False) and event.get('req_id'):
await self.bot.reply_text(event.get('req_id'), content)
return {'stream': success}
async def is_stream_output_supported(self) -> bool:
return self.config.get('enable-stream-reply', True)
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self.bot, params)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], 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], None
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
if not self.config.get('enable-webhook', False):
return None
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
if not self.config.get('enable-webhook', False):
await self.bot.connect()
return
async def keep_alive():
while True:
await asyncio.sleep(1)
await self.logger.info('WeComBot EBA adapter running in unified webhook mode')
await keep_alive()
async def kill(self) -> bool:
if not self.config.get('enable-webhook', False):
await self.bot.disconnect()
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
try:
stream_id = query.message_event.source_platform_object.stream_id
if stream_id:
self._stream_to_monitoring_msg[stream_id] = (monitoring_message_id, time.time())
self._cleanup_stream_mapping()
except Exception as e:
await self.logger.debug(f'Failed to map stream_id to monitoring message: {e}')
def _register_native_handlers(self):
self.bot.on_message('single')(self._handle_native_event)
self.bot.on_message('group')(self._handle_native_event)
if hasattr(self.bot, 'on_feedback'):
self.bot.on_feedback()(self._handle_feedback)
if hasattr(self.bot, 'on_message'):
self.bot.on_message('event')(self._handle_native_event)
async def _handle_native_event(self, event: WecomBotEvent):
try:
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
legacy_event = await self.event_converter.target2legacy(event)
if legacy_event and type(legacy_event) in self.listeners:
await self.listeners[type(legacy_event)](legacy_event, self)
eba_event = await self.event_converter.target2yiri(event)
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error in wecombot native event: {traceback.format_exc()}')
async def _handle_feedback(self, **kwargs):
try:
event = WecomBotEventConverter.feedback_to_eba(**kwargs)
if event.stream_id and event.stream_id in self._stream_to_monitoring_msg:
monitoring_msg_id, _ = self._stream_to_monitoring_msg[event.stream_id]
event.stream_id = monitoring_msg_id
await self._dispatch_eba_event(event)
except Exception:
await self.logger.error(f'Error in wecombot feedback event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def _cache_event(self, event: platform_events.Event):
if not isinstance(event, platform_events.MessageReceivedEvent):
return
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
if event.group:
self._group_cache[str(event.group.id)] = event.group
self._member_cache[(str(event.group.id), str(event.sender.id))] = platform_entities.UserGroupMember(
user=event.sender,
group_id=event.group.id,
role=platform_entities.MemberRole.MEMBER,
display_name=event.sender.nickname,
)
def _cleanup_stream_mapping(self):
now = time.time()
expired = [key for key, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL]
for key in expired:
del self._stream_to_monitoring_msg[key]
@@ -0,0 +1,102 @@
from __future__ import annotations
import typing
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class WecomBotAPIMixin:
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
_group_cache: dict[str, platform_entities.UserGroup]
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
event = self._message_cache.get(str(message_id))
if event is None:
raise NotSupportedError('get_message:message_not_cached')
return event
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
cached = self._user_cache.get(str(user_id))
if cached is None:
raise NotSupportedError('get_user_info:not_cached')
return cached
async def get_friend_list(self) -> list[platform_entities.User]:
return list(self._user_cache.values())
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
cached = self._group_cache.get(str(group_id))
if cached is None:
raise NotSupportedError('get_group_info:not_cached')
return cached
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
cached = self._member_cache.get((str(group_id), str(user_id)))
if cached is None:
raise NotSupportedError('get_group_member_info:not_cached')
return cached
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)]
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
raise NotSupportedError('get_file_url')
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
raise NotSupportedError('edit_message')
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('delete_message')
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
raise NotSupportedError('forward_message')
async def mute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str], duration: int = 0):
raise NotSupportedError('mute_member')
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
raise NotSupportedError('unmute_member')
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
raise NotSupportedError('kick_member')
async def leave_group(self, group_id: typing.Union[int, str]):
raise NotSupportedError('leave_group')
@@ -0,0 +1,124 @@
from __future__ import annotations
import time
import typing
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
from langbot.pkg.platform.adapters.wecombot.types import ADAPTER_NAME
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, bot_name: str = ''):
self.bot_name = bot_name
@staticmethod
async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None)
async def target2legacy(self, event: WecomBotEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None
if eba_event.chat_type == platform_entities.ChatType.PRIVATE:
return platform_events.FriendMessage(
sender=platform_entities.Friend(id=eba_event.sender.id, nickname=eba_event.sender.nickname, remark=''),
message_chain=eba_event.message_chain,
time=eba_event.timestamp,
source_platform_object=event,
)
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=eba_event.sender.id,
permission='MEMBER',
member_name=eba_event.sender.nickname,
group=platform_entities.Group(
id=eba_event.group.id if eba_event.group else eba_event.chat_id,
name=eba_event.group.name if eba_event.group else '',
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=eba_event.message_chain,
time=eba_event.timestamp,
source_platform_object=event,
)
async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event:
if event.type in {'single', 'group'} and event.msgtype != 'event':
return await self.message_to_eba(event)
return self.platform_specific(event, f'wecombot.{event.get("eventtype") or event.msgtype or event.type or "unknown"}')
async def message_to_eba(self, event: WecomBotEvent) -> platform_events.MessageReceivedEvent:
sender = platform_entities.User(id=event.userid, nickname=event.username or event.userid)
group = None
chat_type = platform_entities.ChatType.PRIVATE
chat_id = event.userid
if event.type == 'group':
chat_type = platform_entities.ChatType.GROUP
chat_id = str(event.chatid)
group = platform_entities.UserGroup(id=str(event.chatid), name=event.chatname or str(event.chatid))
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name=ADAPTER_NAME,
message_id=event.message_id or '',
message_chain=await WecomBotMessageConverter.target2yiri(event, self.bot_name),
sender=sender,
chat_type=chat_type,
chat_id=chat_id or '',
group=group,
timestamp=time.time(),
source_platform_object=event,
)
@staticmethod
def feedback_to_eba(
*,
feedback_id: str,
feedback_type: int,
feedback_content: str | None = None,
inaccurate_reasons: list | None = None,
session=None,
) -> platform_events.FeedbackReceivedEvent:
session_id = None
user_id = None
message_id = None
stream_id = None
if session:
if getattr(session, 'chat_id', None):
session_id = f'group_{session.chat_id}'
elif getattr(session, 'user_id', None):
session_id = f'person_{session.user_id}'
user_id = getattr(session, 'user_id', None)
message_id = getattr(session, 'msg_id', None)
stream_id = getattr(session, 'stream_id', None)
return platform_events.FeedbackReceivedEvent(
type='feedback.received',
adapter_name=ADAPTER_NAME,
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=[str(reason) for reason in (inaccurate_reasons or [])] or None,
user_id=user_id,
session_id=session_id,
message_id=message_id,
stream_id=stream_id,
timestamp=time.time(),
source_platform_object=session,
)
@staticmethod
def platform_specific(event: WecomBotEvent, action: str) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name=ADAPTER_NAME,
action=action,
data=dict(event),
timestamp=time.time(),
source_platform_object=event,
)
@@ -0,0 +1,158 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: wecombot-eba
label:
en_US: WeComBot (EBA)
zh_Hans: 企业微信智能机器人 (EBA)
zh_Hant: 企業微信智慧機器人 (EBA)
description:
en_US: WeCom AI Bot adapter with Event-Based Agents support
zh_Hans: 企业微信智能机器人适配器(EBA 架构版本),支持长连接和 Webhook 两种接入方式
zh_Hant: 企業微信智慧機器人適配器(EBA 架構版本),支援長連線和 Webhook 兩種接入方式
icon: wecombot.png
spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wecombot
en: https://link.langbot.app/en/platforms/wecombot
ja: https://link.langbot.app/ja/platforms/wecombot
config:
- name: BotId
label:
en_US: BotId
zh_Hans: 机器人ID (BotId)
zh_Hant: 機器人ID (BotId)
type: string
required: true
default: ""
- name: robot_name
label:
en_US: Robot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
type: string
required: true
default: ""
- name: enable-webhook
label:
en_US: Enable Webhook Mode
zh_Hans: 启用 Webhook 模式
zh_Hant: 啟用 Webhook 模式
description:
en_US: If enabled, the bot will use webhook mode. Otherwise it uses WebSocket long connection mode and does not need a webhook URL.
zh_Hans: 如果启用,机器人将使用 Webhook 模式;否则使用 WebSocket 长连接模式,不需要配置 webhook URL。
zh_Hant: 如果啟用,機器人將使用 Webhook 模式;否則使用 WebSocket 長連線模式,不需要設定 webhook URL。
type: boolean
required: true
default: false
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL into WeComBot callback settings only when webhook mode is enabled.
zh_Hans: 仅在启用 Webhook 模式时复制此地址到企业微信智能机器人回调配置中。
zh_Hant: 僅在啟用 Webhook 模式時複製此地址到企業微信智慧機器人回調設定中。
type: webhook-url
required: false
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: Secret
label:
en_US: Secret
zh_Hans: 机器人密钥 (Secret)
zh_Hant: 機器人密鑰 (Secret)
description:
en_US: Required for WebSocket long connection mode.
zh_Hans: 使用 WebSocket 长连接模式时必填。
zh_Hant: 使用 WebSocket 長連線模式時必填。
type: string
required: false
default: ""
- name: Corpid
label:
en_US: Corpid
zh_Hans: 企业ID
zh_Hant: 企業ID
description:
en_US: Required for webhook mode.
zh_Hans: 使用 Webhook 模式时必填。
zh_Hant: 使用 Webhook 模式時必填。
type: string
required: false
default: ""
- name: Token
label:
en_US: Token
zh_Hans: 令牌 (Token)
zh_Hant: 令牌 (Token)
description:
en_US: Required for webhook mode.
zh_Hans: 使用 Webhook 模式时必填。
zh_Hant: 使用 Webhook 模式時必填。
type: string
required: false
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey)
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
description:
en_US: Required for webhook mode. Optional for WebSocket mode when encrypted files need to be decrypted.
zh_Hans: Webhook 模式必填。WebSocket 模式下如需解密文件则填写。
zh_Hant: Webhook 模式必填。WebSocket 模式下如需解密檔案則填寫。
type: string
required: false
default: ""
- name: enable-stream-reply
label:
en_US: Enable Stream Reply
zh_Hans: 启用流式回复
zh_Hant: 啟用串流回覆
description:
en_US: If enabled, the bot will use WeComBot streaming replies.
zh_Hans: 如果启用,机器人将使用企业微信智能机器人流式回复。
zh_Hant: 如果啟用,機器人將使用企業微信智慧機器人串流回覆。
type: boolean
required: false
default: true
supported_events:
- message.received
- feedback.received
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_message
- get_user_info
- get_friend_list
- get_group_info
- get_group_member_info
- get_group_member_list
- call_platform_api
platform_specific_apis:
- action: is_websocket_mode
description: { en_US: "Return whether the adapter is using WebSocket long connection mode", zh_Hans: "返回当前适配器是否使用 WebSocket 长连接模式" }
- action: get_stream_session_status
description: { en_US: "Inspect stream session state for a received message ID", zh_Hans: "按接收消息 ID 查看流式会话状态" }
- action: send_markdown
description: { en_US: "Send markdown text proactively in WebSocket mode", zh_Hans: "在 WebSocket 模式下主动发送 Markdown 文本" }
execution:
python:
path: ./adapter.py
attr: WecomBotAdapter
@@ -0,0 +1,161 @@
from __future__ import annotations
import datetime
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> str:
content_parts: list[str] = []
for msg in message_chain:
if isinstance(msg, platform_message.Source):
continue
if isinstance(msg, platform_message.Plain):
content_parts.append(msg.text)
elif isinstance(msg, platform_message.At):
content_parts.append(f'@{msg.display or msg.target}')
elif isinstance(msg, platform_message.AtAll):
content_parts.append('@all')
elif isinstance(msg, platform_message.Image):
content_parts.append('[Image]')
elif isinstance(msg, platform_message.Voice):
content_parts.append('[Voice]')
elif isinstance(msg, platform_message.File):
content_parts.append(f'[File: {msg.name or msg.file_id or msg.url or "file"}]')
elif isinstance(msg, platform_message.Quote):
if msg.id is not None:
content_parts.append(f'[Quote {msg.id}]')
if msg.origin:
content_parts.append(await WecomBotMessageConverter.yiri2target(msg.origin))
elif isinstance(msg, platform_message.Forward):
for node in msg.node_list:
if node.message_chain:
content_parts.append(await WecomBotMessageConverter.yiri2target(node.message_chain))
else:
content_parts.append(str(msg))
return '\n'.join(part for part in content_parts if part)
@staticmethod
async def target2yiri(event: WecomBotEvent, bot_name: str = '') -> platform_message.MessageChain:
components: list[platform_message.MessageComponent] = [
platform_message.Source(id=event.message_id, time=datetime.datetime.now()),
]
if event.type == 'group' and event.ai_bot_id:
components.append(platform_message.At(target=event.ai_bot_id))
if event.content:
content = event.content
if bot_name:
content = content.replace(f'@{bot_name}', '').strip()
if content:
components.append(platform_message.Plain(text=content))
WecomBotMessageConverter._append_images(components, event.images or ([event.picurl] if event.picurl else []))
WecomBotMessageConverter._append_file(components, event.file)
WecomBotMessageConverter._append_voice(components, event.voice)
WecomBotMessageConverter._append_video(components, event.video)
WecomBotMessageConverter._append_link(components, event.link)
WecomBotMessageConverter._append_quote(components, event.quote)
if not any(not isinstance(component, (platform_message.Source, platform_message.At)) for component in components):
components.append(platform_message.Unknown(text=f'[unsupported wecombot msgtype: {event.msgtype or "unknown"}]'))
return platform_message.MessageChain(components)
@staticmethod
def _append_images(components: list[platform_message.MessageComponent], images: list[str]):
for image_data in images:
if image_data:
components.append(platform_message.Image(base64=image_data))
@staticmethod
def _append_file(components: list[platform_message.MessageComponent], file_info: dict | None):
if not file_info:
return
file_url = file_info.get('download_url') or file_info.get('url') or file_info.get('fileurl') or file_info.get('path')
file_base64 = file_info.get('base64')
file_name = file_info.get('filename') or file_info.get('name')
file_size = file_info.get('filesize') or file_info.get('size')
try:
kwargs = {}
if file_url:
kwargs['url'] = file_url
if file_base64:
kwargs['base64'] = file_base64
if file_name:
kwargs['name'] = file_name
if file_size is not None:
kwargs['size'] = file_size
if kwargs:
components.append(platform_message.File(**kwargs))
except Exception:
components.append(platform_message.Unknown(text='[file message unsupported]'))
@staticmethod
def _append_voice(components: list[platform_message.MessageComponent], voice_info: dict | None):
if not voice_info:
return
voice_payload = voice_info.get('base64') or voice_info.get('url')
if not voice_payload:
return
if voice_info.get('base64') and not voice_payload.startswith('data:'):
voice_payload = f'data:audio/mpeg;base64,{voice_info.get("base64")}'
try:
if voice_payload.startswith('data:'):
components.append(platform_message.Voice(base64=voice_payload))
else:
components.append(platform_message.Voice(url=voice_payload))
except Exception:
components.append(platform_message.Unknown(text='[voice message unsupported]'))
@staticmethod
def _append_video(components: list[platform_message.MessageComponent], video_info: dict | None):
if not video_info:
return
video_payload = (
video_info.get('base64')
or video_info.get('url')
or video_info.get('download_url')
or video_info.get('fileurl')
)
if not video_payload:
return
try:
components.append(
platform_message.File(
url=video_payload,
name=video_info.get('filename') or video_info.get('name') or 'video',
size=video_info.get('filesize') or video_info.get('size'),
)
)
except Exception:
components.append(platform_message.Unknown(text='[video message unsupported]'))
@staticmethod
def _append_link(components: list[platform_message.MessageComponent], link: dict | None, prefix: str = ''):
if not link:
return
summary = '\n'.join(
filter(None, [link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')])
)
if summary:
components.append(platform_message.Plain(text=f'{prefix}{summary}'))
@staticmethod
def _append_quote(components: list[platform_message.MessageComponent], quote_info: dict | None):
if not quote_info:
return
origin: list[platform_message.MessageComponent] = []
if quote_info.get('content'):
origin.append(platform_message.Plain(text=quote_info.get('content')))
WecomBotMessageConverter._append_images(origin, quote_info.get('images') or ([quote_info.get('picurl')] if quote_info.get('picurl') else []))
WecomBotMessageConverter._append_file(origin, quote_info.get('file'))
WecomBotMessageConverter._append_voice(origin, quote_info.get('voice'))
WecomBotMessageConverter._append_video(origin, quote_info.get('video'))
WecomBotMessageConverter._append_link(origin, quote_info.get('link'))
if origin:
components.append(platform_message.Quote(origin=platform_message.MessageChain(origin)))
@@ -0,0 +1,42 @@
from __future__ import annotations
import typing
async def is_websocket_mode(bot, params: dict) -> dict:
return {'websocket': hasattr(bot, 'bot_id') and not hasattr(bot, 'Token')}
async def get_stream_session_status(bot, params: dict) -> dict:
msg_id = str(params.get('message_id') or params.get('msg_id') or '')
if not msg_id:
raise ValueError('message_id is required')
if hasattr(bot, 'stream_sessions'):
stream_id = bot.stream_sessions.get_stream_id_by_msg(msg_id)
session = bot.stream_sessions.get_session(stream_id) if stream_id else None
return {'stream_id': stream_id, 'active': session is not None}
stream_key = getattr(bot, '_stream_ids', {}).get(msg_id)
if not stream_key:
return {'stream_id': None, 'active': False}
_req_id, _sep, stream_id = stream_key.partition('|')
return {'stream_id': stream_id, 'active': True}
async def send_markdown(bot, params: dict) -> dict:
chat_id = params.get('chat_id') or params.get('chatid') or params.get('target_id')
content = params.get('content')
if not chat_id:
raise ValueError('chat_id is required')
if not content:
raise ValueError('content is required')
if not hasattr(bot, 'send_message'):
raise ValueError('send_markdown is only available in WebSocket mode')
result = await bot.send_message(str(chat_id), str(content), msgtype='markdown')
return {'ok': True, 'raw': result}
PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = {
'is_websocket_mode': is_websocket_mode,
'get_stream_session_status': get_stream_session_status,
'send_markdown': send_markdown,
}
@@ -0,0 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'wecombot-eba'
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB