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:
@@ -707,9 +707,10 @@ class DingTalkClient:
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
|
||||
template_params = dict(card_param_map or {})
|
||||
if card_data_config is not None:
|
||||
cardData['config'] = json.dumps(card_data_config)
|
||||
template_params['config'] = card_data_config
|
||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
|
||||
|
||||
body: dict = {
|
||||
'cardTemplateId': card_template_id,
|
||||
|
||||
@@ -295,32 +295,6 @@ class WecomCSClient:
|
||||
raise Exception(f'Failed to send message: {data}')
|
||||
return data
|
||||
|
||||
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||
payload = {
|
||||
'touser': external_userid,
|
||||
'open_kfid': open_kfid,
|
||||
'msgid': msgid,
|
||||
'msgtype': 'image',
|
||||
'image': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送图片消息失败:{data}')
|
||||
raise Exception('Failed to send image message')
|
||||
return data
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
|
||||
@@ -502,7 +502,7 @@ class AgentService:
|
||||
raise ValueError('EventProcessor component is unavailable') from exc
|
||||
raise
|
||||
if descriptor.component_kind != 'EventProcessor' or not descriptor.supported_event_patterns:
|
||||
raise ValueError('The component does not declare supported EBA events')
|
||||
raise ValueError('The component does not declare supported events')
|
||||
config['runner'] = {'id': component_ref}
|
||||
parameters = data.get('parameters')
|
||||
if parameters is None:
|
||||
|
||||
@@ -14,6 +14,7 @@ from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
from ....utils import httpclient
|
||||
from ....platform.sources import http_bot_signing
|
||||
from ....platform.adapter_names import canonical_adapter_name
|
||||
|
||||
|
||||
class BotService:
|
||||
@@ -41,6 +42,7 @@ class BotService:
|
||||
|
||||
def _get_adapter_component(self, adapter_name: str) -> engine.Component | None:
|
||||
"""Return the discovered platform adapter component for an adapter name."""
|
||||
adapter_name = canonical_adapter_name(adapter_name)
|
||||
for component in self.ap.discover.get_components_by_kind('MessagePlatformAdapter'):
|
||||
if component.metadata.name == adapter_name:
|
||||
return component
|
||||
@@ -555,6 +557,8 @@ class BotService:
|
||||
update_data.pop('uuid', None)
|
||||
|
||||
update_data = {key: value for key, value in update_data.items() if key in self.BOT_FIELDS}
|
||||
if 'adapter' in update_data:
|
||||
update_data['adapter'] = canonical_adapter_name(update_data['adapter'])
|
||||
if 'event_bindings' in update_data:
|
||||
update_data['event_bindings'] = await self._normalize_event_bindings(
|
||||
context, update_data.get('event_bindings')
|
||||
@@ -573,7 +577,10 @@ class BotService:
|
||||
if not include_secret:
|
||||
masked_columns = ['adapter_config']
|
||||
|
||||
return [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) for bot in bots]
|
||||
serialized = [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) for bot in bots]
|
||||
for bot in serialized:
|
||||
bot['adapter'] = canonical_adapter_name(bot['adapter'])
|
||||
return serialized
|
||||
|
||||
async def get_bot(self, context: TenantContext, bot_uuid: str, include_secret: bool = False) -> dict | None:
|
||||
"""获取机器人"""
|
||||
@@ -594,7 +601,9 @@ class BotService:
|
||||
if not include_secret:
|
||||
masked_columns = ['adapter_config']
|
||||
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns)
|
||||
serialized = self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns)
|
||||
serialized['adapter'] = canonical_adapter_name(serialized['adapter'])
|
||||
return serialized
|
||||
|
||||
async def get_runtime_bot_info(
|
||||
self,
|
||||
|
||||
@@ -244,7 +244,7 @@ class LangBotMCPServer:
|
||||
'Run a synthetic event against an Agent or Event processor without platform delivery. '
|
||||
'Returns final text and execution_events containing reported messages/thinking and tool calls. '
|
||||
'Platform tools use mock adapters; other tools execute normally. '
|
||||
'For Event processors, data contains the complete typed EBA event fields. '
|
||||
'For Event processors, data contains the complete typed event fields. '
|
||||
'Requires runtime.operate; payload accepts event_type, text, data, conversation_id, actor, subject and '
|
||||
'mock (errors/results keyed by platform tool name; unsupported_apis lists unavailable platform APIs).'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Canonical names for built-in Omni adapters and saved pre-release configs."""
|
||||
|
||||
OMNI_ADAPTER_NAMES = frozenset(
|
||||
{
|
||||
'aiocqhttp',
|
||||
'telegram',
|
||||
'discord',
|
||||
'qqofficial',
|
||||
'lark',
|
||||
'dingtalk',
|
||||
'slack',
|
||||
'kook',
|
||||
'wecom',
|
||||
'wecombot',
|
||||
'wecomcs',
|
||||
'officialaccount',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def canonical_adapter_name(name: str) -> str:
|
||||
"""Accept previous built-in IDs without renaming legacy or custom adapters."""
|
||||
if name.endswith('-eba') and name[:-4] in OMNI_ADAPTER_NAMES:
|
||||
return f'{name[:-4]}-omni'
|
||||
return name
|
||||
@@ -5,6 +5,8 @@ import traceback
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpEventConverter as LegacyAiocqhttpEventConverter
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
@@ -23,6 +25,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
||||
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
||||
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
|
||||
|
||||
_lookup: typing.Any = pydantic.PrivateAttr(default_factory=LegacyAiocqhttpEventConverter)
|
||||
config: dict
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
@@ -121,6 +124,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
||||
await self.bot._server_app.run_task(**self.config)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self._lookup.clear()
|
||||
return False
|
||||
|
||||
def _register_native_handlers(self):
|
||||
@@ -150,7 +154,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
||||
if getattr(event, 'type', None) == 'message' and (
|
||||
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
|
||||
):
|
||||
legacy_event = await self.event_converter.target2legacy(event, self.bot)
|
||||
legacy_event = await self.event_converter.target2legacy(event, self.bot, self._lookup)
|
||||
if legacy_event:
|
||||
callback = self.listeners.get(type(legacy_event))
|
||||
if callback:
|
||||
@@ -160,7 +164,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
||||
await self.logger.error(f'Error in aiocqhttp native event: {traceback.format_exc()}')
|
||||
|
||||
async def _dispatch_native_event(self, event: aiocqhttp.Event):
|
||||
eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id)
|
||||
eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id, self._lookup)
|
||||
if eba_event:
|
||||
await self._dispatch_eba_event(eba_event)
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
from langbot.pkg.platform.sources.aiocqhttp import (
|
||||
AiocqhttpEventConverter as LegacyAiocqhttpEventConverter,
|
||||
_get_group_name_placeholder,
|
||||
)
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
@@ -20,10 +25,11 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
bot_user_id: int | str | None = None,
|
||||
lookup: LegacyAiocqhttpEventConverter | None = None,
|
||||
) -> platform_events.Event | None:
|
||||
event_type = getattr(event, 'type', None)
|
||||
if event_type == 'message':
|
||||
return await AiocqhttpEventConverter.message_to_eba(event, bot)
|
||||
return await AiocqhttpEventConverter.message_to_eba(event, bot, lookup)
|
||||
if event_type == 'notice':
|
||||
return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id)
|
||||
if event_type == 'request':
|
||||
@@ -36,8 +42,9 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
async def target2legacy(
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
lookup: LegacyAiocqhttpEventConverter | None = None,
|
||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||
eba_event = await AiocqhttpEventConverter.message_to_eba(event, bot)
|
||||
eba_event = await AiocqhttpEventConverter.message_to_eba(event, bot, lookup)
|
||||
if eba_event:
|
||||
return eba_event.to_legacy_event()
|
||||
return None
|
||||
@@ -46,6 +53,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
async def message_to_eba(
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
lookup: LegacyAiocqhttpEventConverter | None = None,
|
||||
) -> platform_events.MessageReceivedEvent:
|
||||
message_chain = await AiocqhttpMessageConverter.target2yiri(
|
||||
getattr(event, 'message', []),
|
||||
@@ -64,6 +72,12 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
|
||||
sender = AiocqhttpEventConverter.user_from_sender(event)
|
||||
sender_data = getattr(event, 'sender', {}) or {}
|
||||
if group is not None:
|
||||
lookup = lookup if lookup is not None else LegacyAiocqhttpEventConverter()
|
||||
group.name = await lookup._get_group_name(group.id, bot) or _get_group_name_placeholder(group.id)
|
||||
if not sender_data.get('title'):
|
||||
info = await lookup._get_group_member_info(group.id, sender.id, bot)
|
||||
sender_data = {**sender_data, 'title': info.get('title', '')}
|
||||
role = sender_data.get('role', 'member')
|
||||
membership = None
|
||||
if group is not None:
|
||||
@@ -76,7 +90,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
return platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
message_id=getattr(event, 'message_id', ''),
|
||||
message_chain=message_chain,
|
||||
sender=sender,
|
||||
@@ -97,7 +111,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if notice_type in ('group_recall', 'friend_recall'):
|
||||
return platform_events.MessageDeletedEvent(
|
||||
type='message.deleted',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
message_id=getattr(event, 'message_id', ''),
|
||||
operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)),
|
||||
chat_type=platform_entities.ChatType.GROUP
|
||||
@@ -115,7 +129,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
group=group,
|
||||
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
@@ -123,7 +137,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
return platform_events.MemberJoinedEvent(
|
||||
type='group.member_joined',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
group=group,
|
||||
member=user,
|
||||
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
||||
@@ -137,7 +151,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
|
||||
return platform_events.BotRemovedFromGroupEvent(
|
||||
type='bot.removed_from_group',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
group=group,
|
||||
operator=operator,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
@@ -145,7 +159,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
return platform_events.MemberLeftEvent(
|
||||
type='group.member_left',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
group=group,
|
||||
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
is_kicked=getattr(event, 'sub_type', '') in ('kick', 'kick_me'),
|
||||
@@ -161,7 +175,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
event_cls = platform_events.BotMutedEvent if duration > 0 else platform_events.BotUnmutedEvent
|
||||
kwargs: dict[str, typing.Any] = {
|
||||
'type': 'bot.muted' if duration > 0 else 'bot.unmuted',
|
||||
'adapter_name': 'aiocqhttp',
|
||||
'adapter_name': 'aiocqhttp-omni',
|
||||
'group': group,
|
||||
'operator': operator,
|
||||
'timestamp': float(getattr(event, 'time', 0) or 0),
|
||||
@@ -173,7 +187,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if duration > 0:
|
||||
return platform_events.MemberBannedEvent(
|
||||
type='group.member_banned',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
group=group,
|
||||
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
operator=operator,
|
||||
@@ -184,7 +198,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if notice_type == 'friend_add':
|
||||
return platform_events.FriendAddedEvent(
|
||||
type='friend.added',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
@@ -197,7 +211,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if request_type == 'friend':
|
||||
return platform_events.FriendRequestReceivedEvent(
|
||||
type='friend.request_received',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
request_id=getattr(event, 'flag', ''),
|
||||
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
message=getattr(event, 'comment', None),
|
||||
@@ -207,7 +221,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if request_type == 'group' and getattr(event, 'sub_type', '') == 'invite':
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
group=AiocqhttpEventConverter.group_from_event(event),
|
||||
inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
request_id=getattr(event, 'flag', ''),
|
||||
@@ -244,7 +258,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def platform_specific(event: aiocqhttp.Event, action: str) -> platform_events.PlatformSpecificEvent:
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='aiocqhttp',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
action=action,
|
||||
data={key: value for key, value in dict(event).items() if key not in {'message'}},
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: aiocqhttp-eba
|
||||
name: aiocqhttp-omni
|
||||
label:
|
||||
en_US: OneBot v11
|
||||
zh_Hans: OneBot v11
|
||||
@@ -17,9 +17,9 @@ spec:
|
||||
categories:
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
|
||||
from langbot.pkg.platform.sources.aiocqhttp import _normalize_base64_payload
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
@@ -100,7 +103,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
if file_arg:
|
||||
target.append(aiocqhttp.MessageSegment.record(file_arg))
|
||||
elif isinstance(component, platform_message.File):
|
||||
file_arg = component.url or component.path or component.base64 or component.id
|
||||
file_arg = AiocqhttpMessageConverter._file_arg(component) or component.id
|
||||
target.append(
|
||||
aiocqhttp.MessageSegment(
|
||||
type_='file',
|
||||
@@ -210,6 +213,30 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
face_name='骰子',
|
||||
)
|
||||
)
|
||||
elif segment.type == 'json':
|
||||
try:
|
||||
raw = segment.data.get('data', {})
|
||||
if isinstance(raw, str):
|
||||
raw = json.loads(raw)
|
||||
if isinstance(raw, dict):
|
||||
_meta = raw.get('meta', {}) or {}
|
||||
if isinstance(_meta, dict):
|
||||
_detail = _meta.get('detail_1') or _meta.get('music') or _meta.get('news') or {}
|
||||
else:
|
||||
_detail = {}
|
||||
if isinstance(_detail, dict):
|
||||
preview = _detail.get('preview', '')
|
||||
title = _detail.get('desc', '') or _detail.get('title', '')
|
||||
url = _detail.get('qqdocurl', '') or _detail.get('jumpUrl', '')
|
||||
else:
|
||||
preview = title = url = ''
|
||||
text = ' '.join([f'[{raw.get("app", "")}]', preview, title, url]).strip()
|
||||
components.append(platform_message.Plain(text=text or '[收到一张JSON卡片]'))
|
||||
else:
|
||||
components.append(platform_message.Plain(text=str(raw)))
|
||||
except Exception:
|
||||
components.append(platform_message.Plain(text='[收到一张JSON卡片]'))
|
||||
|
||||
else:
|
||||
components.append(platform_message.Unknown(text=f'{segment.type}:{segment.data}'))
|
||||
|
||||
@@ -218,8 +245,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
@staticmethod
|
||||
def _file_arg(component: platform_message.Image | platform_message.Voice) -> str:
|
||||
if component.base64:
|
||||
_, _, payload = component.base64.partition(',')
|
||||
return f'base64://{payload or component.base64}'
|
||||
return f'base64://{_normalize_base64_payload(component.base64)}'
|
||||
if component.url:
|
||||
return component.url
|
||||
if component.path:
|
||||
|
||||
@@ -189,6 +189,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
self.card_instance_id_dict.pop(message_id)
|
||||
|
||||
async def create_message_card(self, message_id, event):
|
||||
while len(self.card_instance_id_dict) >= 1000:
|
||||
self.card_instance_id_dict.pop(next(iter(self.card_instance_id_dict)), None)
|
||||
card_template_id = self.config['card_template_id']
|
||||
incoming_message = event.source_platform_object.incoming_message
|
||||
card_auto_layout = self.config.get('card_auto_layout', False)
|
||||
@@ -232,11 +234,16 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
await self.logger.info('DingTalk EBA adapter starting')
|
||||
await self.logger.info('DingTalk Omni adapter starting')
|
||||
await self.bot.start()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self.card_instance_id_dict.clear()
|
||||
self.interaction_callback_contexts.clear()
|
||||
await self.bot.stop()
|
||||
self._message_cache.clear()
|
||||
self._user_cache.clear()
|
||||
self._group_cache.clear()
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
@@ -260,7 +267,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
await self._dispatch_eba_event(interaction_event)
|
||||
return
|
||||
await self.logger.debug(
|
||||
'DingTalk EBA event received: '
|
||||
'DingTalk event received: '
|
||||
f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}'
|
||||
)
|
||||
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
|
||||
@@ -291,3 +298,10 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
self._user_cache[str(event.sender.id)] = event.sender
|
||||
if event.group:
|
||||
self._group_cache[str(event.group.id)] = event.group
|
||||
for cache in (
|
||||
self._message_cache,
|
||||
self._user_cache,
|
||||
self._group_cache,
|
||||
):
|
||||
while len(cache) > 4096:
|
||||
cache.pop(next(iter(cache)), None)
|
||||
|
||||
@@ -332,7 +332,7 @@ def interaction_event_from_native(
|
||||
raise ValueError('DingTalk interaction callback has no delivery space')
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='dingtalk-eba',
|
||||
adapter_name='dingtalk-omni',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: dingtalk-eba
|
||||
name: dingtalk-omni
|
||||
label:
|
||||
en_US: DingTalk
|
||||
zh_Hans: 钉钉
|
||||
@@ -17,10 +17,46 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
||||
en: https://link.langbot.app/en/platforms/dingtalk
|
||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
|
||||
en: https://langbot.app/docs/en/usage/platforms/dingtalk
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
|
||||
config:
|
||||
- name: one-click-create
|
||||
label:
|
||||
en_US: One-Click Create App
|
||||
zh_Hans: 一键创建应用
|
||||
zh_Hant: 一鍵建立應用
|
||||
description:
|
||||
en_US: 'Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be
|
||||
obtained automatically, you need to copy it from the DingTalk Developer Backend manually.'
|
||||
zh_Hans: 使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。
|
||||
zh_Hant: 使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。
|
||||
type: qr-code-login
|
||||
login_platform: dingtalk
|
||||
required: false
|
||||
- name: human_input_card_template_download
|
||||
label:
|
||||
en_US: Download Human Input Card Template
|
||||
zh_Hans: 下载人工输入卡片模板
|
||||
zh_Hant: 下載人工輸入卡片範本
|
||||
description:
|
||||
en_US: Download the template, import it in DingTalk Card Platform, then enter its template ID below to enable native interactions.
|
||||
zh_Hans: 下载模板并导入钉钉卡片平台,再将模板 ID 填入下方以启用原生交互。
|
||||
zh_Hant: 下載範本並匯入釘釘卡片平台,再將範本 ID 填入下方以啟用原生互動。
|
||||
type: download-link
|
||||
required: false
|
||||
default: ''
|
||||
url: /api/v1/platform/adapters/dingtalk/human-input-card-template
|
||||
download_filename: dingtalk_human_input_card.json
|
||||
help_links:
|
||||
zh: https://open-dev.dingtalk.com/fe/card
|
||||
en: https://open-dev.dingtalk.com/fe/card
|
||||
ja: https://open-dev.dingtalk.com/fe/card
|
||||
help_label:
|
||||
en_US: Import Guide
|
||||
zh_Hans: 导入指引
|
||||
zh_Hant: 匯入指引
|
||||
ja_JP: インポート手順
|
||||
- name: client_id
|
||||
label:
|
||||
en_US: Client ID
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ADAPTER_NAME = 'dingtalk-eba'
|
||||
ADAPTER_NAME = 'dingtalk-omni'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
@@ -29,6 +30,8 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
message_converter: DiscordMessageConverter = DiscordMessageConverter()
|
||||
event_converter: DiscordEventConverter = DiscordEventConverter()
|
||||
|
||||
_stream_buffer: dict = pydantic.PrivateAttr(default_factory=dict)
|
||||
_STREAM_EDIT_INTERVAL: typing.ClassVar[int] = 8
|
||||
config: dict
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
@@ -276,9 +279,107 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
return await handler(self.bot, params)
|
||||
|
||||
def _prune_streams(self):
|
||||
now = time.time()
|
||||
for key, state in tuple(self._stream_buffer.items()):
|
||||
if now - state['updated_at'] > 1800:
|
||||
self._stream_buffer.pop(key, None)
|
||||
while len(self._stream_buffer) >= 100:
|
||||
self._stream_buffer.pop(next(iter(self._stream_buffer)), None)
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return True
|
||||
|
||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||
"""Set up a stream context for progressive editing.
|
||||
|
||||
The first non-empty reply_message_chunk will send the initial
|
||||
message; subsequent chunks edit it in place.
|
||||
"""
|
||||
source = event.source_platform_object
|
||||
if not isinstance(source, discord.Message):
|
||||
return False
|
||||
self._prune_streams()
|
||||
self._stream_buffer[message_id] = {
|
||||
'channel': source.channel,
|
||||
'sent_message': None, # discord.Message set on first send
|
||||
'last_content': '',
|
||||
'chunk_count': 0,
|
||||
'updated_at': time.time(),
|
||||
}
|
||||
return True
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message: typing.Any,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
msg_id = (
|
||||
bot_message.get('resp_message_id')
|
||||
if isinstance(bot_message, dict)
|
||||
else getattr(bot_message, 'resp_message_id', None)
|
||||
)
|
||||
|
||||
text_parts = [m.text for m in message if isinstance(m, platform_message.Plain)]
|
||||
chunk_text = '\n\n'.join(t for t in text_parts if t)
|
||||
|
||||
ctx = self._stream_buffer.get(msg_id) if msg_id else None
|
||||
if ctx is not None:
|
||||
ctx['updated_at'] = time.time()
|
||||
|
||||
if ctx is None:
|
||||
try:
|
||||
if is_final and chunk_text:
|
||||
await self.reply_message(message_source, message, quote_origin)
|
||||
finally:
|
||||
self._stream_buffer.pop(msg_id, None)
|
||||
return
|
||||
|
||||
# Progressive streaming path: send first chunk, edit subsequent.
|
||||
ctx['chunk_count'] += 1
|
||||
|
||||
# Runner yields the full accumulated text on each chunk, so we
|
||||
# always replace (not append).
|
||||
if chunk_text:
|
||||
ctx['last_content'] = chunk_text[:2000]
|
||||
|
||||
sent = ctx['sent_message']
|
||||
|
||||
if sent is None:
|
||||
# First non-empty chunk — send the initial message.
|
||||
if not ctx['last_content']:
|
||||
return # No content yet, wait for next chunk.
|
||||
try:
|
||||
sent = await ctx['channel'].send(ctx['last_content'])
|
||||
ctx['sent_message'] = sent
|
||||
except Exception:
|
||||
await self.logger.error(f'Discord stream send failed: {traceback.format_exc()}')
|
||||
self._stream_buffer.pop(msg_id, None)
|
||||
return
|
||||
|
||||
if is_final:
|
||||
# Final chunk — edit to the full content, then clean up.
|
||||
if ctx['last_content'] and ctx['last_content'] != sent.content:
|
||||
try:
|
||||
await sent.edit(content=ctx['last_content'][:2000])
|
||||
except Exception:
|
||||
pass # Best-effort
|
||||
self._stream_buffer.pop(msg_id, None)
|
||||
elif (ctx['chunk_count'] % self._STREAM_EDIT_INTERVAL) == 0:
|
||||
# Intermediate edit — throttle to avoid rate limits.
|
||||
if ctx['last_content'] and ctx['last_content'] != sent.content:
|
||||
try:
|
||||
await sent.edit(content=ctx['last_content'][:2000])
|
||||
except Exception:
|
||||
pass # Rate-limited or deleted — ignore.
|
||||
|
||||
async def run_async(self):
|
||||
await self.bot.start(self.config['token'], reconnect=True)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self._stream_buffer.clear()
|
||||
await self.bot.close()
|
||||
return True
|
||||
|
||||
@@ -100,7 +100,7 @@ class DiscordAPIMixin:
|
||||
until = None
|
||||
if duration > 0:
|
||||
until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=duration)
|
||||
await member.timeout(until, reason='LangBot EBA mute_member')
|
||||
await member.timeout(until, reason='LangBot Omni mute_member')
|
||||
|
||||
async def unmute_member(
|
||||
self,
|
||||
@@ -109,7 +109,7 @@ class DiscordAPIMixin:
|
||||
) -> None:
|
||||
guild = await self._get_guild(group_id)
|
||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||
await member.timeout(None, reason='LangBot EBA unmute_member')
|
||||
await member.timeout(None, reason='LangBot Omni unmute_member')
|
||||
|
||||
async def kick_member(
|
||||
self,
|
||||
@@ -118,7 +118,7 @@ class DiscordAPIMixin:
|
||||
) -> None:
|
||||
guild = await self._get_guild(group_id)
|
||||
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
|
||||
await member.kick(reason='LangBot EBA kick_member')
|
||||
await member.kick(reason='LangBot Omni kick_member')
|
||||
|
||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||
guild = await self._get_guild(group_id)
|
||||
|
||||
@@ -54,7 +54,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
group = DiscordEventConverter.group_from_message(message)
|
||||
return platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
message_id=message.id,
|
||||
message_chain=message_chain,
|
||||
sender=DiscordEventConverter.user_from_author(message.author),
|
||||
@@ -73,7 +73,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
) -> platform_events.MessageEditedEvent:
|
||||
return platform_events.MessageEditedEvent(
|
||||
type='message.edited',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
message_id=after.id,
|
||||
new_content=await DiscordMessageConverter.target2yiri(after),
|
||||
editor=DiscordEventConverter.user_from_author(after.author),
|
||||
@@ -90,7 +90,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
async def message_delete_to_eba(message: discord.Message) -> platform_events.MessageDeletedEvent:
|
||||
return platform_events.MessageDeletedEvent(
|
||||
type='message.deleted',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
message_id=message.id,
|
||||
operator=None,
|
||||
chat_type=platform_entities.ChatType.PRIVATE
|
||||
@@ -106,7 +106,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def raw_message_delete_to_eba(payload: discord.RawMessageDeleteEvent) -> platform_events.MessageDeletedEvent:
|
||||
return platform_events.MessageDeletedEvent(
|
||||
type='message.deleted',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
message_id=payload.message_id,
|
||||
operator=None,
|
||||
chat_type=platform_entities.ChatType.PRIVATE
|
||||
@@ -126,7 +126,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
message = reaction.message
|
||||
return platform_events.MessageReactionEvent(
|
||||
type='message.reaction',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
message_id=message.id,
|
||||
user=DiscordEventConverter.user_from_author(user),
|
||||
reaction=str(reaction.emoji),
|
||||
@@ -152,7 +152,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
user = DiscordEventConverter.user_from_author(user)
|
||||
return platform_events.MessageReactionEvent(
|
||||
type='message.reaction',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
message_id=payload.message_id,
|
||||
user=user,
|
||||
reaction=str(payload.emoji),
|
||||
@@ -175,7 +175,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if bot_user_id is not None and member.id == bot_user_id:
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
group=group,
|
||||
inviter=None,
|
||||
timestamp=member.joined_at.timestamp() if member.joined_at else 0.0,
|
||||
@@ -183,7 +183,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
return platform_events.MemberJoinedEvent(
|
||||
type='group.member_joined',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
group=group,
|
||||
member=user,
|
||||
inviter=None,
|
||||
@@ -202,14 +202,14 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if bot_user_id is not None and member.id == bot_user_id:
|
||||
return platform_events.BotRemovedFromGroupEvent(
|
||||
type='bot.removed_from_group',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
group=group,
|
||||
operator=None,
|
||||
source_platform_object=member,
|
||||
)
|
||||
return platform_events.MemberLeftEvent(
|
||||
type='group.member_left',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
group=group,
|
||||
member=user,
|
||||
is_kicked=False,
|
||||
@@ -221,7 +221,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def guild_join_to_eba(guild: discord.Guild) -> platform_events.BotInvitedToGroupEvent:
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
group=DiscordEventConverter.group_from_guild(guild),
|
||||
inviter=None,
|
||||
source_platform_object=guild,
|
||||
@@ -231,7 +231,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def guild_remove_to_eba(guild: discord.Guild) -> platform_events.BotRemovedFromGroupEvent:
|
||||
return platform_events.BotRemovedFromGroupEvent(
|
||||
type='bot.removed_from_group',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
group=DiscordEventConverter.group_from_guild(guild),
|
||||
operator=None,
|
||||
source_platform_object=guild,
|
||||
|
||||
@@ -118,7 +118,7 @@ def interaction_event_from_component(
|
||||
raise ValueError('Discord interaction has no actor or channel')
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='discord',
|
||||
adapter_name='discord-omni',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: discord-eba
|
||||
name: discord-omni
|
||||
label:
|
||||
en_US: Discord
|
||||
zh_Hans: Discord
|
||||
@@ -15,6 +15,10 @@ spec:
|
||||
categories:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/discord
|
||||
en: https://langbot.app/docs/en/usage/platforms/discord
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/discord
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import asyncio
|
||||
|
||||
from langbot.pkg.platform.sources.discord import _MAX_DISCORD_MEDIA_BYTES, _decode_discord_base64_limited
|
||||
import datetime
|
||||
import io
|
||||
import os
|
||||
@@ -13,6 +15,14 @@ from langbot.pkg.utils import httpclient
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
def _read_file_limited(path: str) -> bytes:
|
||||
with open(path, 'rb') as stream:
|
||||
data = stream.read(_MAX_DISCORD_MEDIA_BYTES + 1)
|
||||
if len(data) > _MAX_DISCORD_MEDIA_BYTES:
|
||||
raise ValueError('Discord media exceeds the size limit')
|
||||
return data
|
||||
|
||||
|
||||
class DiscordMessageConverter:
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
@@ -101,7 +111,7 @@ class DiscordMessageConverter:
|
||||
filename = f'{uuid.uuid4()}.gif'
|
||||
elif 'webp' in header:
|
||||
filename = f'{uuid.uuid4()}.webp'
|
||||
return base64.b64decode(data), filename
|
||||
return await asyncio.to_thread(_decode_discord_base64_limited, data), filename
|
||||
if element.url:
|
||||
data, content_type = await DiscordMessageConverter._download(element.url)
|
||||
if 'jpeg' in content_type or 'jpg' in content_type:
|
||||
@@ -115,8 +125,7 @@ class DiscordMessageConverter:
|
||||
path = os.path.abspath(element.path.replace('\x00', ''))
|
||||
if not os.path.exists(path):
|
||||
return None, filename
|
||||
with open(path, 'rb') as fp:
|
||||
data = fp.read()
|
||||
data = await asyncio.to_thread(_read_file_limited, path)
|
||||
ext = os.path.splitext(path)[1]
|
||||
if ext:
|
||||
filename = f'{uuid.uuid4()}{ext}'
|
||||
@@ -133,7 +142,7 @@ class DiscordMessageConverter:
|
||||
if ext in header:
|
||||
filename = f'{uuid.uuid4()}.{ext}'
|
||||
break
|
||||
return base64.b64decode(data), filename
|
||||
return await asyncio.to_thread(_decode_discord_base64_limited, data), filename
|
||||
if element.url:
|
||||
data, _ = await DiscordMessageConverter._download(element.url)
|
||||
return data, filename
|
||||
@@ -142,17 +151,21 @@ class DiscordMessageConverter:
|
||||
@staticmethod
|
||||
async def _load_file(element: platform_message.File) -> bytes | None:
|
||||
if element.base64:
|
||||
return base64.b64decode(element.base64.split(',')[-1])
|
||||
return await asyncio.to_thread(_decode_discord_base64_limited, element.base64)
|
||||
if element.url:
|
||||
data, _ = await DiscordMessageConverter._download(element.url)
|
||||
return data
|
||||
if element.path:
|
||||
return await asyncio.to_thread(_read_file_limited, element.path)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _download(url: str) -> tuple[bytes, str]:
|
||||
session = httpclient.get_session(trust_env=True)
|
||||
async with session.get(url) as response:
|
||||
return await response.read(), response.headers.get('Content-Type', '')
|
||||
return await httpclient.read_limited(response, max_bytes=_MAX_DISCORD_MEDIA_BYTES), response.headers.get(
|
||||
'Content-Type', ''
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_image_attachment(attachment: discord.Attachment) -> bool:
|
||||
|
||||
@@ -37,7 +37,7 @@ async def create_invite(bot: discord.Client, params: dict) -> dict:
|
||||
max_age=params.get('max_age', 0),
|
||||
max_uses=params.get('max_uses', 0),
|
||||
unique=params.get('unique', True),
|
||||
reason=params.get('reason', 'LangBot EBA create_invite'),
|
||||
reason=params.get('reason', 'LangBot Omni create_invite'),
|
||||
)
|
||||
return {'url': invite.url, 'code': invite.code}
|
||||
|
||||
@@ -45,14 +45,14 @@ async def create_invite(bot: discord.Client, params: dict) -> dict:
|
||||
async def pin_message(bot: discord.Client, params: dict) -> dict:
|
||||
channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id']))
|
||||
message = await channel.fetch_message(int(params['message_id']))
|
||||
await message.pin(reason=params.get('reason', 'LangBot EBA pin_message'))
|
||||
await message.pin(reason=params.get('reason', 'LangBot Omni pin_message'))
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
async def unpin_message(bot: discord.Client, params: dict) -> dict:
|
||||
channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id']))
|
||||
message = await channel.fetch_message(int(params['message_id']))
|
||||
await message.unpin(reason=params.get('reason', 'LangBot EBA unpin_message'))
|
||||
await message.unpin(reason=params.get('reason', 'LangBot Omni unpin_message'))
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Voice support is still implemented by the legacy Discord source adapter. The
|
||||
# EBA adapter exposes text, guild, member, moderation, and platform-specific APIs
|
||||
# Omni adapter exposes text, guild, member, moderation, and platform-specific APIs
|
||||
# first; voice-specific EBA actions will move here when that surface is migrated.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.platform.sources.kook import _decode_gateway_message
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import traceback
|
||||
import typing
|
||||
import zlib
|
||||
|
||||
import aiohttp
|
||||
import pydantic
|
||||
@@ -129,7 +130,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
async def run_async(self):
|
||||
self.running = True
|
||||
self.http_session = httpclient.get_session()
|
||||
await self.logger.info('KOOK EBA adapter starting')
|
||||
await self.logger.info('KOOK Omni adapter starting')
|
||||
|
||||
try:
|
||||
bot_info = await self._get_bot_user_info()
|
||||
@@ -154,7 +155,10 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
pass
|
||||
if self.ws:
|
||||
await self.ws.close()
|
||||
await self.logger.info('KOOK EBA adapter stopped')
|
||||
await self.logger.info('KOOK Omni adapter stopped')
|
||||
self._message_cache.clear()
|
||||
self._user_cache.clear()
|
||||
self._group_cache.clear()
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
@@ -205,6 +209,13 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
self._user_cache[str(event.sender.id)] = event.sender
|
||||
if event.group:
|
||||
self._group_cache[str(event.group.id)] = event.group
|
||||
for cache in (
|
||||
self._message_cache,
|
||||
self._user_cache,
|
||||
self._group_cache,
|
||||
):
|
||||
while len(cache) > 4096:
|
||||
cache.pop(next(iter(cache)), None)
|
||||
|
||||
async def _websocket_loop(self):
|
||||
retry_count = 0
|
||||
@@ -221,14 +232,14 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
|
||||
hello_data = json.loads(self._decode_ws_message(hello_msg))
|
||||
hello_data = await asyncio.to_thread(_decode_gateway_message, hello_msg)
|
||||
if hello_data.get('s') != 1:
|
||||
raise Exception(f'Expected KOOK HELLO signal, got {hello_data.get("s")}')
|
||||
await self._handle_hello(hello_data.get('d') or {})
|
||||
retry_count = 0
|
||||
|
||||
async for message in ws:
|
||||
msg_data = json.loads(self._decode_ws_message(message))
|
||||
msg_data = await asyncio.to_thread(_decode_gateway_message, message)
|
||||
signal = msg_data.get('s')
|
||||
if signal == 0:
|
||||
await self._handle_event(msg_data.get('d') or {}, int(msg_data.get('sn') or 0))
|
||||
@@ -301,7 +312,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
request_kwargs['data'] = data
|
||||
|
||||
async with session.request(method, url, **request_kwargs) as response:
|
||||
payload = await response.json(content_type=None)
|
||||
payload = await httpclient.read_json_limited(response)
|
||||
if response.status != 200:
|
||||
raise Exception(f'KOOK API HTTP {response.status}: {payload}')
|
||||
if payload.get('code') != 0:
|
||||
@@ -310,9 +321,4 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
|
||||
@staticmethod
|
||||
def _decode_ws_message(message) -> str:
|
||||
if isinstance(message, bytes):
|
||||
try:
|
||||
return zlib.decompress(message).decode('utf-8')
|
||||
except Exception:
|
||||
return message.decode('utf-8')
|
||||
return str(message)
|
||||
return json.dumps(_decode_gateway_message(message))
|
||||
|
||||
@@ -24,7 +24,7 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='kook',
|
||||
adapter_name='kook-omni',
|
||||
action=str(kook_event.get('type') or 'gateway_event'),
|
||||
data=KookEventConverter._compact_data(kook_event),
|
||||
timestamp=KookEventConverter._timestamp(kook_event),
|
||||
@@ -43,7 +43,7 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
|
||||
return platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
adapter_name='kook',
|
||||
adapter_name='kook-omni',
|
||||
message_id=str(kook_event.get('msg_id') or ''),
|
||||
message_chain=await KookMessageConverter.target2yiri(kook_event, bot_account_id),
|
||||
sender=author,
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: kook-eba
|
||||
name: kook-omni
|
||||
label:
|
||||
en_US: KOOK
|
||||
zh_Hans: KOOK
|
||||
@@ -20,6 +20,10 @@ metadata:
|
||||
spec:
|
||||
categories:
|
||||
- global
|
||||
help_links:
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/kook
|
||||
en: https://langbot.app/docs/en/usage/platforms/kook
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/kook
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.platform.sources.lark import (
|
||||
LarkAdapter as LegacyLarkAdapter,
|
||||
NonBlockingLarkWSClient,
|
||||
)
|
||||
|
||||
import threading
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
@@ -106,6 +112,10 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
quart_app: quart.Quart = pydantic.Field(exclude=True)
|
||||
cipher: AESCipher = pydantic.Field(exclude=True)
|
||||
|
||||
inbound_event_tasks: set[asyncio.Task] = pydantic.Field(default_factory=set, exclude=True)
|
||||
threadsafe_event_futures: set[typing.Any] = pydantic.Field(default_factory=set, exclude=True)
|
||||
threadsafe_event_lock: typing.Any = pydantic.Field(default_factory=threading.Lock, exclude=True)
|
||||
_MAX_INBOUND_EVENTS: typing.ClassVar[int] = 100
|
||||
config: dict
|
||||
lark_tenant_key: str = pydantic.Field(exclude=True, default='')
|
||||
app_ticket: str | None = None
|
||||
@@ -143,7 +153,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
|
||||
api_client = self.build_api_client(config)
|
||||
event_handler = self._build_event_handler()
|
||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
|
||||
bot = NonBlockingLarkWSClient(
|
||||
config['app_id'],
|
||||
config['app_secret'],
|
||||
event_handler=event_handler,
|
||||
domain=LegacyLarkAdapter._resolve_domain(config),
|
||||
)
|
||||
cipher = AESCipher(config.get('encrypt-key', ''))
|
||||
|
||||
super().__init__(
|
||||
@@ -216,7 +231,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
def build_api_client(self, config: dict) -> lark_oapi.Client:
|
||||
builder = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret'])
|
||||
builder = (
|
||||
lark_oapi.Client.builder()
|
||||
.app_id(config['app_id'])
|
||||
.app_secret(config['app_secret'])
|
||||
.domain(LegacyLarkAdapter._resolve_domain(config))
|
||||
)
|
||||
if config.get('app_type', 'self') == 'isv':
|
||||
builder = builder.app_type(lark_oapi.AppType.ISV)
|
||||
return builder.build()
|
||||
@@ -291,6 +311,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
'token': content['tenant_access_token'],
|
||||
'expire_at': int(time.time()) + content['expire'] - 300,
|
||||
}
|
||||
now = int(time.time())
|
||||
for key, token in tuple(self.tenant_access_tokens.items()):
|
||||
if int(token.get('expire_at', 0)) <= now:
|
||||
self.tenant_access_tokens.pop(key, None)
|
||||
while len(self.tenant_access_tokens) > 1024:
|
||||
self.tenant_access_tokens.pop(next(iter(self.tenant_access_tokens)), None)
|
||||
|
||||
def get_tenant_access_token(self, tenant_key: str | None):
|
||||
if self.config.get('app_type', 'self') != 'isv' or not tenant_key:
|
||||
@@ -372,7 +398,19 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
payloads: list[tuple[str, dict]] = []
|
||||
if text_elements:
|
||||
needs_post = any(ele.get('tag') == 'at' for paragraph in text_elements for ele in paragraph)
|
||||
if needs_post:
|
||||
if LegacyLarkAdapter._has_markdown_table(text_elements):
|
||||
text = '\n\n'.join(''.join(ele.get('text', '') for ele in row) for row in text_elements)
|
||||
payloads.append(
|
||||
(
|
||||
'interactive',
|
||||
{
|
||||
'schema': '2.0',
|
||||
'config': {'wide_screen_mode': True},
|
||||
'body': {'elements': [{'tag': 'markdown', 'content': text}]},
|
||||
},
|
||||
)
|
||||
)
|
||||
elif needs_post:
|
||||
payloads.append(('post', {'zh_Hans': {'title': '', 'content': text_elements}}))
|
||||
else:
|
||||
parts = []
|
||||
@@ -390,8 +428,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
|
||||
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
|
||||
user_msg_id = getattr(query.message_event, 'message_id', None)
|
||||
if not user_msg_id:
|
||||
user_msg_id = getattr(getattr(query.message_event, 'message_chain', None), 'message_id', None)
|
||||
if user_msg_id:
|
||||
self.pending_monitoring_msg[str(user_msg_id)] = monitoring_message_id
|
||||
while len(self.pending_monitoring_msg) > 1000:
|
||||
self.pending_monitoring_msg.pop(next(iter(self.pending_monitoring_msg)), None)
|
||||
|
||||
async def create_message_card(self, message_id, event) -> bool:
|
||||
card_id = await self.create_card_id(message_id)
|
||||
@@ -409,9 +451,26 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark create_message_card failed: {response.code} {response.msg}')
|
||||
user_msg_id = self._message_id_from_source(event)
|
||||
reply_msg_id = getattr(response.data, 'message_id', None)
|
||||
monitoring_msg_id = self.pending_monitoring_msg.pop(str(user_msg_id), None)
|
||||
if reply_msg_id and monitoring_msg_id:
|
||||
self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time())
|
||||
now = time.time()
|
||||
for key, (_, timestamp) in tuple(self.reply_to_monitoring_msg.items()):
|
||||
if now - timestamp > self._monitoring_mapping_ttl:
|
||||
self.reply_to_monitoring_msg.pop(key, None)
|
||||
while len(self.reply_to_monitoring_msg) > 1000:
|
||||
self.reply_to_monitoring_msg.pop(next(iter(self.reply_to_monitoring_msg)), None)
|
||||
return True
|
||||
|
||||
async def create_card_id(self, message_id) -> str:
|
||||
while len(self.card_id_dict) >= 1000:
|
||||
old_key = next(iter(self.card_id_dict))
|
||||
old_card = self.card_id_dict.pop(old_key)
|
||||
self.card_sequence_dict.pop(old_card, None)
|
||||
self.card_last_update_dict.pop(old_card, None)
|
||||
self.closed_streaming_cards.discard(old_card)
|
||||
card_data = {
|
||||
'schema': '2.0',
|
||||
'config': {
|
||||
@@ -504,7 +563,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'})
|
||||
for paragraph in text_elements
|
||||
)
|
||||
if card_id in self.closed_streaming_cards:
|
||||
if (is_final and not bot_message.tool_calls) or card_id in self.closed_streaming_cards:
|
||||
await self._replace_streaming_card(card_id, content)
|
||||
else:
|
||||
sequence = self._next_card_sequence(card_id)
|
||||
@@ -515,7 +574,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
.request_body(ContentCardElementRequestBody.builder().content(content).sequence(sequence).build())
|
||||
.build()
|
||||
)
|
||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(
|
||||
response: ContentCardElementResponse = await self.api_client.cardkit.v1.card_element.acontent(
|
||||
request, self.request_option(self._tenant_key_from_source(message_source))
|
||||
)
|
||||
if not response.success():
|
||||
@@ -646,9 +705,29 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
with self.threadsafe_event_lock:
|
||||
futures = list(self.threadsafe_event_futures)
|
||||
for future in futures:
|
||||
future.cancel()
|
||||
tasks = list(self.inbound_event_tasks)
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
self.inbound_event_tasks.clear()
|
||||
self.pending_monitoring_msg.clear()
|
||||
self.reply_to_monitoring_msg.clear()
|
||||
self.tenant_access_tokens.clear()
|
||||
self.card_id_dict.clear()
|
||||
self.card_sequence_dict.clear()
|
||||
self.card_last_update_dict.clear()
|
||||
self.closed_streaming_cards.clear()
|
||||
self.bot._auto_reconnect = False
|
||||
await self.bot._disconnect()
|
||||
await _cancel_ws_cache_task(self.bot)
|
||||
self._message_cache.clear()
|
||||
self._user_cache.clear()
|
||||
self._group_cache.clear()
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
@@ -681,6 +760,13 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
self._user_cache[str(event.sender.id)] = event.sender
|
||||
if event.group:
|
||||
self._group_cache[str(event.group.id)] = event.group
|
||||
for cache in (
|
||||
self._message_cache,
|
||||
self._user_cache,
|
||||
self._group_cache,
|
||||
):
|
||||
while len(cache) > 4096:
|
||||
cache.pop(next(iter(cache)), None)
|
||||
|
||||
def _handle_card_action_sync(self, event):
|
||||
interaction_event = interaction_event_from_callback(event)
|
||||
@@ -716,18 +802,57 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
}
|
||||
return response
|
||||
|
||||
def _schedule_inbound_event(self, coro) -> None:
|
||||
for task in tuple(self.inbound_event_tasks):
|
||||
if task.done():
|
||||
self.inbound_event_tasks.discard(task)
|
||||
if len(self.inbound_event_tasks) >= self._MAX_INBOUND_EVENTS:
|
||||
coro.close()
|
||||
return
|
||||
task = asyncio.create_task(coro)
|
||||
self.inbound_event_tasks.add(task)
|
||||
|
||||
def done(done_task: asyncio.Task) -> None:
|
||||
self.inbound_event_tasks.discard(done_task)
|
||||
if not done_task.cancelled():
|
||||
done_task.exception()
|
||||
|
||||
task.add_done_callback(done)
|
||||
|
||||
def _schedule_threadsafe_event(self, coro):
|
||||
"""Submit one bounded callback from the Lark SDK's sync boundary."""
|
||||
|
||||
with self.threadsafe_event_lock:
|
||||
for future in tuple(self.threadsafe_event_futures):
|
||||
if future.done():
|
||||
self.threadsafe_event_futures.discard(future)
|
||||
if len(self.threadsafe_event_futures) >= self._MAX_INBOUND_EVENTS:
|
||||
coro.close()
|
||||
return None
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self.event_loop)
|
||||
self.threadsafe_event_futures.add(future)
|
||||
|
||||
def done(done_future) -> None:
|
||||
with self.threadsafe_event_lock:
|
||||
self.threadsafe_event_futures.discard(done_future)
|
||||
if not done_future.cancelled():
|
||||
done_future.exception()
|
||||
|
||||
future.add_done_callback(done)
|
||||
return future
|
||||
|
||||
def _submit_coro(self, coro):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = self.event_loop
|
||||
if loop and loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
self._schedule_threadsafe_event(coro)
|
||||
return
|
||||
coro.close()
|
||||
raise
|
||||
else:
|
||||
loop.create_task(coro)
|
||||
self._schedule_inbound_event(coro)
|
||||
|
||||
def _feedback_event_from_callback(self, event) -> platform_events.FeedbackEvent | None:
|
||||
value = getattr(getattr(event.event, 'action', None), 'value', {}) or {}
|
||||
|
||||
@@ -39,7 +39,7 @@ class LarkAPIMixin:
|
||||
message_chain = await LarkMessageConverter.target2yiri(event_message, self.api_client)
|
||||
event = platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
adapter_name='lark-eba',
|
||||
adapter_name='lark-omni',
|
||||
message_id=str(message_id),
|
||||
message_chain=message_chain,
|
||||
sender=platform_entities.User(id=''),
|
||||
|
||||
@@ -533,7 +533,7 @@ def _event_from_parts(
|
||||
data['values'] = _form_submission_values(action, payload)
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='lark-eba',
|
||||
adapter_name='lark-omni',
|
||||
action='interaction.submitted',
|
||||
data=data,
|
||||
timestamp=time.time(),
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: lark-eba
|
||||
name: lark-omni
|
||||
label:
|
||||
en_US: Lark / Feishu
|
||||
zh_Hans: 飞书
|
||||
@@ -21,10 +21,75 @@ spec:
|
||||
- china
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/lark
|
||||
en: https://link.langbot.app/en/platforms/lark
|
||||
ja: https://link.langbot.app/ja/platforms/lark
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/lark
|
||||
en: https://langbot.app/docs/en/usage/platforms/lark
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/lark
|
||||
config:
|
||||
- name: domain
|
||||
label:
|
||||
en_US: Platform Domain
|
||||
zh_Hans: 平台域名
|
||||
zh_Hant: 平台域名
|
||||
ja_JP: プラットフォームドメイン
|
||||
description:
|
||||
en_US: Select the open platform domain. Use Feishu for Chinese mainland, Lark for international
|
||||
zh_Hans: 选择开放平台域名,国内使用飞书,海外使用 Lark
|
||||
zh_Hant: 選擇開放平台域名,國內使用飛書,海外使用 Lark
|
||||
ja_JP: オープンプラットフォームのドメインを選択。中国国内は飛書、海外は Lark を使用
|
||||
type: select
|
||||
options:
|
||||
- name: https://open.feishu.cn
|
||||
label:
|
||||
en_US: Feishu (open.feishu.cn)
|
||||
zh_Hans: 飞书 (open.feishu.cn)
|
||||
zh_Hant: 飛書 (open.feishu.cn)
|
||||
ja_JP: 飛書 (open.feishu.cn)
|
||||
- name: https://open.larksuite.com
|
||||
label:
|
||||
en_US: Lark (open.larksuite.com)
|
||||
zh_Hans: Lark (open.larksuite.com)
|
||||
zh_Hant: Lark (open.larksuite.com)
|
||||
ja_JP: Lark (open.larksuite.com)
|
||||
- name: custom
|
||||
label:
|
||||
en_US: Custom
|
||||
zh_Hans: 自定义
|
||||
zh_Hant: 自定義
|
||||
ja_JP: カスタム
|
||||
required: false
|
||||
default: https://open.feishu.cn
|
||||
- name: custom_domain
|
||||
label:
|
||||
en_US: Custom Domain
|
||||
zh_Hans: 自定义域名
|
||||
zh_Hant: 自定義域名
|
||||
ja_JP: カスタムドメイン
|
||||
description:
|
||||
en_US: Enter the full domain URL, e.g. https://open.example.com
|
||||
zh_Hans: 输入完整的域名 URL,例如 https://open.example.com
|
||||
zh_Hant: 輸入完整的域名 URL,例如 https://open.example.com
|
||||
ja_JP: '完全なドメイン URL を入力(例: https://open.example.com)'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: domain
|
||||
operator: eq
|
||||
value: custom
|
||||
- name: one-click-create
|
||||
label:
|
||||
en_US: One-Click Create App
|
||||
zh_Hans: 一键创建应用
|
||||
zh_Hant: 一鍵建立應用
|
||||
ja_JP: ワンクリックでアプリ作成
|
||||
description:
|
||||
en_US: Scan QR code to automatically create a Feishu app and fill in credentials
|
||||
zh_Hans: 扫码自动创建飞书应用并填写凭据
|
||||
zh_Hant: 掃碼自動建立飛書應用並填寫憑證
|
||||
ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力
|
||||
type: qr-code-login
|
||||
login_platform: feishu
|
||||
required: false
|
||||
- name: app_id
|
||||
label:
|
||||
en_US: App ID
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import asyncio
|
||||
|
||||
from langbot.pkg.platform.sources.lark import (
|
||||
LarkMessageConverter as LegacyLarkMessageConverter,
|
||||
_decode_lark_base64_limited,
|
||||
_read_lark_path_limited,
|
||||
_read_lark_response_file_limited,
|
||||
_MAX_LARK_MEDIA_BYTES,
|
||||
)
|
||||
import datetime
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
import lark_oapi
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest,
|
||||
CreateFileRequestBody,
|
||||
CreateImageRequest,
|
||||
CreateImageRequestBody,
|
||||
EventMessage,
|
||||
GetMessageResourceRequest,
|
||||
GetMessageResourceResponse,
|
||||
@@ -26,78 +30,9 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
|
||||
|
||||
|
||||
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> str | None:
|
||||
image_bytes = await LarkMessageConverter._get_component_bytes(msg)
|
||||
if image_bytes is None:
|
||||
return None
|
||||
upload_image_to_lark = staticmethod(LegacyLarkMessageConverter.upload_image_to_lark)
|
||||
|
||||
temp_file_path = ''
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||
temp_file.write(image_bytes)
|
||||
temp_file.flush()
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
request = (
|
||||
CreateImageRequest.builder()
|
||||
.request_body(
|
||||
CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response = await api_client.im.v1.image.acreate(request)
|
||||
if not response.success():
|
||||
return None
|
||||
return response.data.image_key
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return None
|
||||
finally:
|
||||
if temp_file_path:
|
||||
try:
|
||||
os.unlink(temp_file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def upload_file_to_lark(
|
||||
file_bytes: bytes,
|
||||
api_client: lark_oapi.Client,
|
||||
file_type: str,
|
||||
file_name: str = 'file',
|
||||
duration: int | None = None,
|
||||
) -> str | None:
|
||||
temp_file_path = ''
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||
temp_file.write(file_bytes)
|
||||
temp_file.flush()
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
body_builder = (
|
||||
CreateFileRequestBody.builder()
|
||||
.file_type(file_type)
|
||||
.file_name(file_name)
|
||||
.file(open(temp_file_path, 'rb'))
|
||||
)
|
||||
if duration is not None:
|
||||
body_builder = body_builder.duration(duration)
|
||||
|
||||
request = CreateFileRequest.builder().request_body(body_builder.build()).build()
|
||||
response = await api_client.im.v1.file.acreate(request)
|
||||
if not response.success():
|
||||
return None
|
||||
return response.data.file_key
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return None
|
||||
finally:
|
||||
if temp_file_path:
|
||||
try:
|
||||
os.unlink(temp_file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
upload_file_to_lark = staticmethod(LegacyLarkMessageConverter.upload_file_to_lark)
|
||||
|
||||
@staticmethod
|
||||
async def _get_component_bytes(
|
||||
@@ -108,24 +43,22 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
base64_data = msg.base64
|
||||
if ',' in base64_data:
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
return base64.b64decode(base64_data)
|
||||
return await asyncio.to_thread(_decode_lark_base64_limited, base64_data)
|
||||
except Exception:
|
||||
return None
|
||||
if getattr(msg, 'url', None):
|
||||
try:
|
||||
if str(msg.url).startswith('file://'):
|
||||
with open(str(msg.url)[7:], 'rb') as f:
|
||||
return f.read()
|
||||
return await asyncio.to_thread(_read_lark_path_limited, str(msg.url)[7:])
|
||||
session = httpclient.get_session()
|
||||
async with session.get(msg.url) as response:
|
||||
if response.status == 200:
|
||||
return await response.read()
|
||||
return await httpclient.read_limited(response, max_bytes=_MAX_LARK_MEDIA_BYTES)
|
||||
except Exception:
|
||||
return None
|
||||
if getattr(msg, 'path', None):
|
||||
try:
|
||||
with open(msg.path, 'rb') as f:
|
||||
return f.read()
|
||||
return await asyncio.to_thread(_read_lark_path_limited, str(msg.path))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
@@ -375,9 +308,9 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
|
||||
if not response.success():
|
||||
return {}
|
||||
data = response.file.read()
|
||||
data = await asyncio.to_thread(_read_lark_response_file_limited, response)
|
||||
content_type = response.raw.headers.get('content-type', 'application/octet-stream')
|
||||
base64_data = base64.b64encode(data).decode()
|
||||
base64_data = (await asyncio.to_thread(base64.b64encode, data)).decode()
|
||||
ext = mimetypes.guess_extension(content_type.split(';')[0].strip()) or '.bin'
|
||||
temp_path = os.path.join(tempfile.gettempdir(), f'lark_{file_key}{ext}')
|
||||
with open(temp_path, 'wb') as f:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ADAPTER_NAME = 'lark-eba'
|
||||
ADAPTER_NAME = 'lark-omni'
|
||||
|
||||
@@ -42,7 +42,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
||||
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
|
||||
missing_keys = [key for key in required_keys if not config.get(key)]
|
||||
if missing_keys:
|
||||
raise Exception(f'OfficialAccount EBA adapter missing config: {missing_keys}')
|
||||
raise Exception(f'OfficialAccount Omni adapter missing config: {missing_keys}')
|
||||
|
||||
mode = config['Mode']
|
||||
common_kwargs = {
|
||||
@@ -154,10 +154,13 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.logger.info('OfficialAccount EBA adapter running in unified webhook mode')
|
||||
await self.logger.info('OfficialAccount Omni adapter running in unified webhook mode')
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self.bot.clear()
|
||||
self._message_cache.clear()
|
||||
self._user_cache.clear()
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
@@ -193,3 +196,9 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
|
||||
if isinstance(event, platform_events.MessageReceivedEvent):
|
||||
self._message_cache[str(event.message_id)] = event
|
||||
self._user_cache[str(event.sender.id)] = event.sender
|
||||
for cache in (
|
||||
self._message_cache,
|
||||
self._user_cache,
|
||||
):
|
||||
while len(cache) > 4096:
|
||||
cache.pop(next(iter(cache)), None)
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: officialaccount-eba
|
||||
name: officialaccount-omni
|
||||
label:
|
||||
en_US: Official Account
|
||||
zh_Hans: 微信公众号
|
||||
@@ -17,10 +17,23 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/officialaccount
|
||||
en: https://link.langbot.app/en/platforms/officialaccount
|
||||
ja: https://link.langbot.app/ja/platforms/officialaccount
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
|
||||
en: https://langbot.app/docs/en/usage/platforms/wxoa
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
|
||||
config:
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
en_US: IP Whitelist
|
||||
zh_Hans: IP 白名单
|
||||
zh_Hant: IP 白名單
|
||||
description:
|
||||
en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the "Basic Configuration" of the WeChat Official
|
||||
Account platform
|
||||
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到微信公众平台「基本配置」中的 IP 白名单
|
||||
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入微信公眾平台「基本配置」中的 IP 白名單
|
||||
type: array[string]
|
||||
required: false
|
||||
default: []
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ADAPTER_NAME = 'officialaccount-eba'
|
||||
ADAPTER_NAME = 'officialaccount-omni'
|
||||
|
||||
@@ -64,16 +64,16 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
required_keys = ['appid', 'secret', 'token']
|
||||
required_keys = ['appid', 'secret']
|
||||
missing_keys = [key for key in required_keys if not config.get(key)]
|
||||
if missing_keys:
|
||||
raise Exception(f'QQOfficial EBA adapter missing config: {missing_keys}')
|
||||
raise Exception(f'QQOfficial Omni adapter missing config: {missing_keys}')
|
||||
|
||||
enable_webhook = config.get('enable-webhook', config.get('enable_webhook', False))
|
||||
bot = QQOfficialClient(
|
||||
app_id=config['appid'],
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
token=config.get('token', ''),
|
||||
logger=logger,
|
||||
unified_mode=enable_webhook,
|
||||
)
|
||||
@@ -169,6 +169,39 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
return await handler(self, dict(params or {}))
|
||||
|
||||
async def _send_c2c_or_group_text_reply(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
content: str,
|
||||
*,
|
||||
msg_id: typing.Optional[str] = None,
|
||||
event_id: typing.Optional[str] = None,
|
||||
msg_seq: int = 1,
|
||||
) -> typing.Any:
|
||||
"""Send a text reply using the configured C2C/group render mode."""
|
||||
use_markdown = self.config.get('enable-markdown-rendering', False)
|
||||
if target_type == 'c2c':
|
||||
send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
|
||||
return await send(
|
||||
user_openid=target_id,
|
||||
content=content,
|
||||
msg_id=msg_id,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
elif target_type == 'group':
|
||||
send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
|
||||
return await send(
|
||||
group_openid=target_id,
|
||||
content=content,
|
||||
msg_id=msg_id,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
@@ -194,7 +227,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
|
||||
async def run_async(self):
|
||||
if self.enable_webhook:
|
||||
await self.logger.info('QQ Official EBA adapter running in unified webhook mode')
|
||||
await self.logger.info('QQ Official Omni adapter running in unified webhook mode')
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
@@ -208,6 +241,15 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._ws_task = None
|
||||
await self.bot.close()
|
||||
self._stream_ctx.clear()
|
||||
self._stream_ctx_ts.clear()
|
||||
self._fallback_text.clear()
|
||||
self._fallback_text_ts.clear()
|
||||
self._message_cache.clear()
|
||||
self._user_cache.clear()
|
||||
self._group_cache.clear()
|
||||
self._member_cache.clear()
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
@@ -220,6 +262,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
source = event.source_platform_object
|
||||
if not isinstance(source, QQOfficialEvent) or source.t != 'C2C_MESSAGE_CREATE':
|
||||
return False
|
||||
await self._cleanup_stale_streams()
|
||||
self._stream_ctx[message_id] = {
|
||||
'user_openid': source.user_openid,
|
||||
'msg_id': source.d_id,
|
||||
@@ -253,10 +296,11 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
)
|
||||
if not message_id or message_id not in self._stream_ctx:
|
||||
if chunk_text:
|
||||
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
|
||||
self._fallback_text[message_id] = chunk_text[:200000]
|
||||
self._fallback_text_ts[message_id] = time.time()
|
||||
if is_final:
|
||||
full_text = self._fallback_text.pop(message_id, '')
|
||||
self._fallback_text_ts.pop(message_id, None)
|
||||
if full_text:
|
||||
await self.reply_message(
|
||||
message_source,
|
||||
@@ -267,14 +311,14 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
|
||||
ctx = self._stream_ctx[message_id]
|
||||
if chunk_text:
|
||||
ctx['accumulated_text'] += chunk_text
|
||||
ctx['accumulated_text'] = chunk_text[:200000]
|
||||
if not ctx['session_started']:
|
||||
if not ctx['accumulated_text']:
|
||||
return
|
||||
ctx['session_started'] = True
|
||||
|
||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
||||
if not content_to_send and not is_final:
|
||||
content_to_send = ctx['accumulated_text']
|
||||
if len(content_to_send) <= ctx['sent_length'] and not is_final:
|
||||
return
|
||||
now = time.time()
|
||||
if not is_final and (now - ctx['last_update_ts']) < 0.5:
|
||||
@@ -355,9 +399,17 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
role=platform_entities.MemberRole.MEMBER,
|
||||
display_name=event.sender.nickname,
|
||||
)
|
||||
for cache in (
|
||||
self._message_cache,
|
||||
self._user_cache,
|
||||
self._group_cache,
|
||||
self._member_cache,
|
||||
):
|
||||
while len(cache) > 4096:
|
||||
cache.pop(next(iter(cache)), None)
|
||||
|
||||
async def _run_websocket(self):
|
||||
await self.logger.info('QQ Official EBA adapter starting in WebSocket mode')
|
||||
await self.logger.info('QQ Official Omni adapter starting in WebSocket mode')
|
||||
|
||||
async def on_ready():
|
||||
await self.logger.info('QQ Official WebSocket connected and ready')
|
||||
@@ -413,12 +465,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
results.append({'type': content_type, 'raw': raw})
|
||||
continue
|
||||
if content_type == 'text':
|
||||
if target_type == 'c2c':
|
||||
raw = await self.bot.send_private_text_msg(target_id, content.get('content', ''), msg_id)
|
||||
elif target_type == 'group':
|
||||
raw = await self.bot.send_group_text_msg(target_id, content.get('content', ''), msg_id)
|
||||
else:
|
||||
raise NotSupportedError(f'send_message:{target_type}')
|
||||
raw = await self._send_c2c_or_group_text_reply(
|
||||
target_type, target_id, content.get('content', ''), msg_id=msg_id
|
||||
)
|
||||
results.append({'type': content_type, 'raw': raw})
|
||||
elif content_type == 'image':
|
||||
raw = await self.bot.send_image_msg(
|
||||
@@ -462,3 +511,12 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
for message_id in [key for key, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL]:
|
||||
self._fallback_text.pop(message_id, None)
|
||||
self._fallback_text_ts.pop(message_id, None)
|
||||
|
||||
for values, timestamps in (
|
||||
(self._stream_ctx, self._stream_ctx_ts),
|
||||
(self._fallback_text, self._fallback_text_ts),
|
||||
):
|
||||
while len(values) > 1000:
|
||||
oldest = next(iter(values))
|
||||
values.pop(oldest, None)
|
||||
timestamps.pop(oldest, None)
|
||||
|
||||
@@ -163,7 +163,7 @@ def interaction_event_from_payload(
|
||||
)
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='qqofficial-eba',
|
||||
adapter_name='qqofficial-omni',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: qqofficial-eba
|
||||
name: qqofficial-omni
|
||||
label:
|
||||
en_US: QQ Official API
|
||||
zh_Hans: QQ 官方 API
|
||||
@@ -17,10 +17,47 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/qqofficial
|
||||
en: https://link.langbot.app/en/platforms/qqofficial
|
||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
|
||||
config:
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
en_US: IP Whitelist
|
||||
zh_Hans: IP 白名单
|
||||
zh_Hant: IP 白名單
|
||||
description:
|
||||
en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the development settings of the QQ Open Platform
|
||||
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到 QQ 开放平台开发设置中的 IP 白名单
|
||||
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入 QQ 開放平台開發設定中的 IP 白名單
|
||||
type: array[string]
|
||||
required: false
|
||||
default: []
|
||||
- name: one-click-bind
|
||||
label:
|
||||
en_US: One-Click QR Binding
|
||||
zh_Hans: 一键扫码绑定
|
||||
zh_Hant: 一鍵掃碼綁定
|
||||
description:
|
||||
en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token is not used and can be left blank)
|
||||
zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(当前未使用 Token,可留空)
|
||||
zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(目前未使用 Token,可留空)
|
||||
type: qr-code-login
|
||||
login_platform: qqofficial
|
||||
required: false
|
||||
- name: enable-markdown-rendering
|
||||
label:
|
||||
en_US: Enable Markdown Rendering
|
||||
zh_Hans: 启用 Markdown 渲染
|
||||
zh_Hant: 啟用 Markdown 渲染
|
||||
description:
|
||||
en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not
|
||||
affected by this setting.
|
||||
zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。
|
||||
zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: appid
|
||||
label:
|
||||
en_US: App ID
|
||||
@@ -43,7 +80,7 @@ spec:
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
type: string
|
||||
required: true
|
||||
required: false
|
||||
default: ""
|
||||
- name: enable-webhook
|
||||
label:
|
||||
|
||||
@@ -79,16 +79,14 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
|
||||
pic_url=event.attachments,
|
||||
content_type=event.content_type,
|
||||
)
|
||||
components.append(platform_message.Image(base64=base64_url))
|
||||
components.append(platform_message.Image(url=event.attachments, base64=base64_url))
|
||||
except Exception:
|
||||
components.append(platform_message.Image(url=event.attachments))
|
||||
|
||||
if event.content:
|
||||
components.append(platform_message.Plain(text=event.content))
|
||||
|
||||
if len(components) == 1 or (
|
||||
len(components) == 2 and isinstance(components[1], platform_message.At)
|
||||
):
|
||||
if len(components) == 1 or (len(components) == 2 and isinstance(components[1], platform_message.At)):
|
||||
components.append(platform_message.Unknown(text=f'[unsupported qqofficial event: {event.t or "unknown"}]'))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
@@ -101,4 +99,3 @@ def _parse_timestamp(value: str) -> datetime.datetime:
|
||||
return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z')
|
||||
except (TypeError, ValueError):
|
||||
return datetime.datetime.now()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import pydantic
|
||||
|
||||
ADAPTER_NAME = 'qqofficial-eba'
|
||||
ADAPTER_NAME = 'qqofficial-omni'
|
||||
|
||||
|
||||
class QQOfficialAdapterConfig(pydantic.BaseModel):
|
||||
@@ -11,4 +11,3 @@ class QQOfficialAdapterConfig(pydantic.BaseModel):
|
||||
token: str
|
||||
enable_webhook: bool = False
|
||||
enable_stream_reply: bool = False
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
required_keys = ['bot_token', 'signing_secret']
|
||||
missing_keys = [key for key in required_keys if not config.get(key)]
|
||||
if missing_keys:
|
||||
raise Exception(f'Slack EBA adapter missing config: {missing_keys}')
|
||||
raise Exception(f'Slack Omni adapter missing config: {missing_keys}')
|
||||
|
||||
bot = SlackClient(
|
||||
bot_token=config['bot_token'],
|
||||
@@ -144,11 +144,15 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
async def run_async(self):
|
||||
await self.logger.info('Slack EBA adapter running in unified webhook mode')
|
||||
await self.logger.info('Slack Omni adapter running in unified webhook mode')
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self._message_cache.clear()
|
||||
self._user_cache.clear()
|
||||
self._group_cache.clear()
|
||||
self._member_cache.clear()
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
@@ -192,6 +196,14 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
role=platform_entities.MemberRole.MEMBER,
|
||||
display_name=event.sender.nickname,
|
||||
)
|
||||
for cache in (
|
||||
self._message_cache,
|
||||
self._user_cache,
|
||||
self._group_cache,
|
||||
self._member_cache,
|
||||
):
|
||||
while len(cache) > 4096:
|
||||
cache.pop(next(iter(cache)), None)
|
||||
|
||||
async def _send_text(self, target_type: str, target_id: str, content: str) -> dict:
|
||||
target_type = self._normalize_target_type(target_type)
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: slack-eba
|
||||
name: slack-omni
|
||||
label:
|
||||
en_US: Slack
|
||||
zh_Hans: Slack
|
||||
@@ -18,9 +18,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/slack
|
||||
en: https://link.langbot.app/en/platforms/slack
|
||||
ja: https://link.langbot.app/ja/platforms/slack
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/slack
|
||||
en: https://langbot.app/docs/en/usage/platforms/slack
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/slack
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -57,16 +57,18 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
|
||||
if event.pic_url:
|
||||
try:
|
||||
components.append(platform_message.Image(base64=await image.get_slack_image_to_base64(event.pic_url, bot_token)))
|
||||
components.append(
|
||||
platform_message.Image(
|
||||
url=event.pic_url, base64=await image.get_slack_image_to_base64(event.pic_url, bot_token)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
components.append(platform_message.Image(url=event.pic_url))
|
||||
|
||||
if event.text:
|
||||
components.append(platform_message.Plain(text=event.text))
|
||||
|
||||
if len(components) == 1 or (
|
||||
len(components) == 2 and isinstance(components[1], platform_message.At)
|
||||
):
|
||||
if len(components) == 1 or (len(components) == 2 and isinstance(components[1], platform_message.At)):
|
||||
components.append(platform_message.Unknown(text=f'[unsupported slack event: {event.type or "unknown"}]'))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ADAPTER_NAME = 'slack-eba'
|
||||
ADAPTER_NAME = 'slack-omni'
|
||||
|
||||
@@ -6,7 +6,6 @@ Preserves all existing functionality (messaging, streaming output, markdown card
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
import traceback
|
||||
|
||||
@@ -302,23 +301,33 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
args['parse_mode'] = 'MarkdownV2'
|
||||
return args
|
||||
|
||||
_MAX_STREAM_STATES: typing.ClassVar[int] = 1000
|
||||
|
||||
def _cap_stream_states(self) -> None:
|
||||
while len(self.msg_stream_id) > self._MAX_STREAM_STATES:
|
||||
self.msg_stream_id.pop(next(iter(self.msg_stream_id)), None)
|
||||
|
||||
@staticmethod
|
||||
def _is_form_placeholder_chunk(text: str) -> bool:
|
||||
"""Return True for invisible placeholder chunks used to carry forms."""
|
||||
|
||||
if not text:
|
||||
return True
|
||||
|
||||
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
|
||||
return cleaned == ''
|
||||
|
||||
async def create_message_card(self, message_id, event):
|
||||
assert isinstance(event.source_platform_object, Update)
|
||||
update = event.source_platform_object
|
||||
chat_id = update.effective_chat.id
|
||||
chat_type = update.effective_chat.type
|
||||
message_thread_id = update.message.message_thread_id
|
||||
effective_message = update.effective_message
|
||||
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||
|
||||
if chat_type == 'private':
|
||||
draft_id = int(time.time() * 1000)
|
||||
self.msg_stream_id[message_id] = ('private', draft_id)
|
||||
|
||||
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
|
||||
await self.bot.send_message_draft(**args)
|
||||
else:
|
||||
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
||||
send_msg = await self.bot.send_message(**args)
|
||||
self.msg_stream_id[message_id] = ('group', send_msg.message_id)
|
||||
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
||||
send_msg = await self.bot.send_message(**args)
|
||||
self.msg_stream_id[message_id] = ('message', send_msg.message_id, False)
|
||||
self._cap_stream_states()
|
||||
|
||||
return True
|
||||
|
||||
@@ -335,12 +344,15 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
assert isinstance(message_source.source_platform_object, Update)
|
||||
update = message_source.source_platform_object
|
||||
chat_id = update.effective_chat.id
|
||||
message_thread_id = update.message.message_thread_id
|
||||
effective_message = update.effective_message
|
||||
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||
|
||||
if message_id not in self.msg_stream_id:
|
||||
return
|
||||
|
||||
chat_mode, draft_id = self.msg_stream_id[message_id]
|
||||
stream_state = self.msg_stream_id[message_id]
|
||||
chat_mode, stream_id = stream_state[:2]
|
||||
has_visible_content = len(stream_state) > 2 and stream_state[2]
|
||||
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||
|
||||
if not components or components[0]['type'] != 'text':
|
||||
@@ -349,17 +361,58 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
return
|
||||
|
||||
content = components[0]['text']
|
||||
if self._is_form_placeholder_chunk(content):
|
||||
if is_final and bot_message.tool_calls is None and not has_visible_content:
|
||||
await self._delete_group_stream_message(chat_mode, chat_id, stream_id)
|
||||
self.msg_stream_id.pop(message_id, None)
|
||||
return
|
||||
|
||||
if chat_mode == 'private':
|
||||
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id)
|
||||
await self.bot.send_message_draft(**args)
|
||||
# Streaming via draft (ephemeral preview in the chat input area)
|
||||
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=stream_id)
|
||||
try:
|
||||
await self.bot.send_message_draft(**args)
|
||||
except telegram.error.BadRequest as exc:
|
||||
if 'Message_too_long' in str(exc):
|
||||
args['text'] = content[:4000] + '\n\n… (truncated)'
|
||||
try:
|
||||
await self.bot.send_message_draft(**args)
|
||||
except telegram.error.RetryAfter:
|
||||
pass
|
||||
else:
|
||||
pass # Ignore other draft errors (cosmetic)
|
||||
self.msg_stream_id[message_id] = (chat_mode, stream_id, True)
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
del args['draft_id']
|
||||
await self.bot.send_message(**args)
|
||||
# Finalise: send the real message, discard the draft
|
||||
args = self._build_message_args(chat_id, content, message_thread_id)
|
||||
try:
|
||||
await self.bot.send_message(**args)
|
||||
except telegram.error.BadRequest as exc:
|
||||
if 'Message_too_long' in str(exc):
|
||||
args['text'] = content[:4000] + '\n\n… (truncated)'
|
||||
await self.bot.send_message(**args)
|
||||
else:
|
||||
raise
|
||||
self.msg_stream_id.pop(message_id)
|
||||
else:
|
||||
stream_id = draft_id
|
||||
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||
# Streaming via edit_message_text (persistent message)
|
||||
if stream_id is None:
|
||||
args = self._build_message_args(chat_id, content, message_thread_id)
|
||||
try:
|
||||
send_msg = await self.bot.send_message(**args)
|
||||
except telegram.error.BadRequest as exc:
|
||||
if 'Message_too_long' in str(exc):
|
||||
args['text'] = self._process_markdown(content[:4000] + '\n\n… (truncated)')
|
||||
send_msg = await self.bot.send_message(**args)
|
||||
else:
|
||||
raise
|
||||
self.msg_stream_id[message_id] = (chat_mode, send_msg.message_id, True)
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
self.msg_stream_id.pop(message_id, None)
|
||||
return
|
||||
|
||||
if not has_visible_content or (msg_seq - 1) % 8 == 0 or is_final:
|
||||
args = {
|
||||
'message_id': stream_id,
|
||||
'chat_id': chat_id,
|
||||
@@ -367,7 +420,15 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
}
|
||||
if self.config.get('markdown_card', False):
|
||||
args['parse_mode'] = 'MarkdownV2'
|
||||
await self.bot.edit_message_text(**args)
|
||||
try:
|
||||
await self.bot.edit_message_text(**args)
|
||||
except telegram.error.BadRequest as exc:
|
||||
if 'Message_too_long' in str(exc):
|
||||
args['text'] = self._process_markdown(content[:4000] + '\n\n… (truncated)')
|
||||
await self.bot.edit_message_text(**args)
|
||||
else:
|
||||
raise
|
||||
self.msg_stream_id[message_id] = (chat_mode, stream_id, True)
|
||||
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
self.msg_stream_id.pop(message_id)
|
||||
@@ -461,4 +522,6 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
if self.application.updater:
|
||||
await self.application.updater.stop()
|
||||
await self.logger.info('Telegram adapter stopped')
|
||||
await self.application.shutdown()
|
||||
self.msg_stream_id.clear()
|
||||
return True
|
||||
|
||||
@@ -92,7 +92,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
timestamp=time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
action='callback_query',
|
||||
data={
|
||||
'callback_query_id': update.callback_query.id,
|
||||
@@ -111,7 +111,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
timestamp=time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
action='unknown_update',
|
||||
data={'update_id': update.update_id},
|
||||
source_platform_object=update,
|
||||
@@ -138,7 +138,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
timestamp=message.date.timestamp() if message.date else 0.0,
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
message_id=message.message_id,
|
||||
message_chain=lb_message,
|
||||
sender=sender,
|
||||
@@ -169,7 +169,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.MessageEditedEvent(
|
||||
type='message.edited',
|
||||
timestamp=message.edit_date.timestamp() if message.edit_date else 0.0,
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
message_id=message.message_id,
|
||||
new_content=lb_message,
|
||||
editor=editor,
|
||||
@@ -203,7 +203,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.MemberJoinedEvent(
|
||||
type='group.member_joined',
|
||||
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
group=group,
|
||||
member=member,
|
||||
inviter=inviter,
|
||||
@@ -217,7 +217,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.MemberLeftEvent(
|
||||
type='group.member_left',
|
||||
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
group=group,
|
||||
member=member,
|
||||
is_kicked=is_kicked,
|
||||
@@ -235,7 +235,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.MemberBannedEvent(
|
||||
type='group.member_banned',
|
||||
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
group=group,
|
||||
member=member,
|
||||
operator=inviter,
|
||||
@@ -247,7 +247,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
timestamp=cm.date.timestamp() if cm.date else time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
action='chat_member_updated',
|
||||
data={
|
||||
'old_status': old_status,
|
||||
@@ -276,7 +276,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
group=group,
|
||||
inviter=inviter,
|
||||
source_platform_object=update,
|
||||
@@ -287,7 +287,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.BotRemovedFromGroupEvent(
|
||||
type='bot.removed_from_group',
|
||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
group=group,
|
||||
operator=inviter,
|
||||
source_platform_object=update,
|
||||
@@ -303,7 +303,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.BotMutedEvent(
|
||||
type='bot.muted',
|
||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
group=group,
|
||||
operator=inviter,
|
||||
duration=duration,
|
||||
@@ -314,7 +314,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.BotUnmutedEvent(
|
||||
type='bot.unmuted',
|
||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
group=group,
|
||||
operator=inviter,
|
||||
source_platform_object=update,
|
||||
@@ -323,7 +323,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
action='my_chat_member_updated',
|
||||
data={
|
||||
'old_status': old_status,
|
||||
@@ -360,7 +360,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return platform_events.MessageReactionEvent(
|
||||
type='message.reaction',
|
||||
timestamp=reaction.date.timestamp() if reaction.date else time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
message_id=reaction.message_id,
|
||||
user=user,
|
||||
reaction=new_emojis[0] if new_emojis else '',
|
||||
|
||||
@@ -156,7 +156,7 @@ def interaction_event_from_update(
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
timestamp=time.time(),
|
||||
adapter_name='telegram',
|
||||
adapter_name='telegram-omni',
|
||||
action='interaction.submitted',
|
||||
data=data,
|
||||
source_platform_object=update,
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: telegram-eba
|
||||
name: telegram-omni
|
||||
label:
|
||||
en_US: Telegram
|
||||
zh_Hans: 电报
|
||||
@@ -15,6 +15,10 @@ spec:
|
||||
categories:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/telegram
|
||||
en: https://langbot.app/docs/en/usage/platforms/telegram
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/telegram
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
|
||||
@@ -1,145 +1,5 @@
|
||||
"""Telegram message chain converter.
|
||||
"""Shared Telegram conversion for legacy and Omni event delivery."""
|
||||
|
||||
Migrated from the original sources/telegram.py TelegramMessageConverter. Logic unchanged.
|
||||
"""
|
||||
from langbot.pkg.platform.sources.telegram import TelegramMessageConverter
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import telegram
|
||||
|
||||
from langbot.pkg.utils import httpclient
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
|
||||
"""Convert a LangBot MessageChain to a list of Telegram-sendable components."""
|
||||
components = []
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
components.append({'type': 'text', 'text': component.text})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
photo_bytes = None
|
||||
|
||||
if component.base64:
|
||||
b64_data = component.base64
|
||||
if ';base64,' in b64_data:
|
||||
b64_data = b64_data.split(';base64,', 1)[1]
|
||||
photo_bytes = base64.b64decode(b64_data)
|
||||
elif component.url:
|
||||
session = httpclient.get_session()
|
||||
async with session.get(component.url) as response:
|
||||
photo_bytes = await response.read()
|
||||
elif component.path:
|
||||
with open(component.path, 'rb') as f:
|
||||
photo_bytes = f.read()
|
||||
|
||||
components.append({'type': 'photo', 'photo': photo_bytes})
|
||||
elif isinstance(component, platform_message.File):
|
||||
file_bytes = None
|
||||
|
||||
if component.base64:
|
||||
b64_data = component.base64
|
||||
if ';base64,' in b64_data:
|
||||
b64_data = b64_data.split(';base64,', 1)[1]
|
||||
file_bytes = base64.b64decode(b64_data)
|
||||
elif component.url:
|
||||
session = httpclient.get_session()
|
||||
async with session.get(component.url) as response:
|
||||
file_bytes = await response.read()
|
||||
elif component.path:
|
||||
with open(component.path, 'rb') as f:
|
||||
file_bytes = f.read()
|
||||
|
||||
file_name = getattr(component, 'name', None) or 'file'
|
||||
components.append({'type': 'document', 'document': file_bytes, 'filename': file_name})
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot))
|
||||
|
||||
return components
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: telegram.Message, bot: telegram.Bot, bot_account_id: str):
|
||||
"""Convert a Telegram Message to a LangBot MessageChain."""
|
||||
message_components = []
|
||||
|
||||
def parse_message_text(text: str) -> list[platform_message.MessageComponent]:
|
||||
msg_components = []
|
||||
|
||||
if f'@{bot_account_id}' in text:
|
||||
msg_components.append(platform_message.At(target=bot_account_id))
|
||||
text = text.replace(f'@{bot_account_id}', '')
|
||||
msg_components.append(platform_message.Plain(text=text))
|
||||
|
||||
return msg_components
|
||||
|
||||
if message.text:
|
||||
message_text = message.text
|
||||
message_components.extend(parse_message_text(message_text))
|
||||
|
||||
if message.photo:
|
||||
if message.caption:
|
||||
message_components.extend(parse_message_text(message.caption))
|
||||
|
||||
file = await message.photo[-1].get_file()
|
||||
|
||||
file_bytes = None
|
||||
file_format = ''
|
||||
|
||||
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
|
||||
file_bytes = await response.read()
|
||||
file_format = 'image/jpeg'
|
||||
|
||||
message_components.append(
|
||||
platform_message.Image(
|
||||
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}'
|
||||
)
|
||||
)
|
||||
|
||||
if message.voice:
|
||||
if message.caption:
|
||||
message_components.extend(parse_message_text(message.caption))
|
||||
|
||||
file = await message.voice.get_file()
|
||||
|
||||
file_bytes = None
|
||||
file_format = message.voice.mime_type or 'audio/ogg'
|
||||
|
||||
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
|
||||
file_bytes = await response.read()
|
||||
|
||||
message_components.append(
|
||||
platform_message.Voice(
|
||||
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
|
||||
length=message.voice.duration,
|
||||
)
|
||||
)
|
||||
|
||||
if message.document:
|
||||
if message.caption:
|
||||
message_components.extend(parse_message_text(message.caption))
|
||||
|
||||
file = await message.document.get_file()
|
||||
file_name = message.document.file_name or 'document'
|
||||
file_size = message.document.file_size or 0
|
||||
file_format = message.document.mime_type or 'application/octet-stream'
|
||||
|
||||
file_bytes = None
|
||||
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
|
||||
file_bytes = await response.read()
|
||||
|
||||
message_components.append(
|
||||
platform_message.File(
|
||||
name=file_name,
|
||||
size=file_size,
|
||||
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
|
||||
)
|
||||
)
|
||||
|
||||
return platform_message.MessageChain(message_components)
|
||||
__all__ = ['TelegramMessageConverter']
|
||||
|
||||
@@ -155,10 +155,13 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.logger.info('WeCom EBA adapter running in unified webhook mode')
|
||||
await self.logger.info('WeCom Omni adapter running in unified webhook mode')
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
await self.bot.close()
|
||||
self._message_cache.clear()
|
||||
self._user_cache.clear()
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
@@ -200,6 +203,12 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
|
||||
return
|
||||
self._message_cache[str(event.message_id)] = event
|
||||
self._user_cache[str(event.sender.id)] = event.sender
|
||||
for cache in (
|
||||
self._message_cache,
|
||||
self._user_cache,
|
||||
):
|
||||
while len(cache) > 4096:
|
||||
cache.pop(next(iter(cache)), None)
|
||||
|
||||
async def _send_content(self, user_id: str, agent_id: int, content: dict):
|
||||
content_type = content.get('type')
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: wecom-eba
|
||||
name: wecom-omni
|
||||
label:
|
||||
en_US: WeCom
|
||||
zh_Hans: 企业微信
|
||||
@@ -18,10 +18,22 @@ spec:
|
||||
- popular
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecom
|
||||
en: https://link.langbot.app/en/platforms/wecom
|
||||
ja: https://link.langbot.app/ja/platforms/wecom
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
|
||||
config:
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
en_US: Trusted IPs
|
||||
zh_Hans: 企业可信 IP
|
||||
zh_Hant: 企業可信 IP
|
||||
description:
|
||||
en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of your app in the WeCom admin console
|
||||
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台应用详情页的「企业可信 IP」中
|
||||
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台應用詳情頁的「企業可信 IP」中
|
||||
type: array[string]
|
||||
required: false
|
||||
default: []
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
|
||||
@@ -77,6 +77,6 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
return platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Source(id=message_id, time=datetime.datetime.now()),
|
||||
platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'),
|
||||
platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}'),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ADAPTER_NAME = 'wecom-eba'
|
||||
ADAPTER_NAME = 'wecom-omni'
|
||||
|
||||
|
||||
def make_private_chat_id(user_id: str | int | None, agent_id: str | int | None) -> str:
|
||||
"""Build the routable private chat id used by the WeCom EBA adapter."""
|
||||
"""Build the routable private chat id used by the WeCom Omni adapter."""
|
||||
user = str(user_id or '')
|
||||
agent = str(agent_id or '')
|
||||
if not user or not agent:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter as LegacyWecomBotAdapter
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
@@ -127,6 +129,10 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
_join_text_components = staticmethod(LegacyWecomBotAdapter._join_text_components)
|
||||
_iter_media_components = staticmethod(LegacyWecomBotAdapter._iter_media_components)
|
||||
_send_media = staticmethod(LegacyWecomBotAdapter._send_media)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -137,7 +143,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
raise NotSupportedError('send_message:webhook_mode')
|
||||
if target_type not in ('person', 'private', 'group'):
|
||||
raise NotSupportedError(f'send_message:{target_type}')
|
||||
content = await WecomBotMessageConverter.yiri2target(message)
|
||||
items = await WecomBotMessageConverter.yiri2target(message)
|
||||
content = self._join_text_components(items)
|
||||
raw = await self.bot.send_message(str(target_id), content)
|
||||
return platform_events.MessageResult(raw={'result': raw})
|
||||
|
||||
@@ -150,9 +157,14 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
event = await WecomBotEventConverter.yiri2target(message_source)
|
||||
if not isinstance(event, WecomBotEvent):
|
||||
raise ValueError('WeComBot reply_message requires a WecomBotEvent source object')
|
||||
content = await WecomBotMessageConverter.yiri2target(message)
|
||||
items = await WecomBotMessageConverter.yiri2target(message)
|
||||
content = self._join_text_components(items)
|
||||
raw = None
|
||||
if not self.config.get('enable-webhook', False) and event.get('req_id'):
|
||||
raw = await self.bot.reply_text(event.get('req_id'), content)
|
||||
if content:
|
||||
raw = await self.bot.reply_text(event.get('req_id'), content)
|
||||
for item in self._iter_media_components(items):
|
||||
await self._send_media(self.bot, event.get('req_id'), item)
|
||||
else:
|
||||
raw = await self.bot.set_message(event.message_id, content)
|
||||
return platform_events.MessageResult(message_id=event.message_id, raw={'result': raw})
|
||||
@@ -168,10 +180,17 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
event = await WecomBotEventConverter.yiri2target(message_source)
|
||||
if not isinstance(event, WecomBotEvent):
|
||||
raise ValueError('WeComBot reply_message_chunk requires a WecomBotEvent source object')
|
||||
content = await WecomBotMessageConverter.yiri2target(message)
|
||||
items = await WecomBotMessageConverter.yiri2target(message)
|
||||
content = self._join_text_components(items)
|
||||
success = await self.bot.push_stream_chunk(event.message_id, content, is_final=is_final)
|
||||
if not success and is_final and not self.config.get('enable-webhook', False) and event.get('req_id'):
|
||||
await self.bot.reply_text(event.get('req_id'), content)
|
||||
if is_final:
|
||||
if not self.config.get('enable-webhook', False) and event.get('req_id'):
|
||||
for item in self._iter_media_components(items):
|
||||
await self._send_media(self.bot, event.get('req_id'), item)
|
||||
elif not success:
|
||||
await self.bot.set_message(event.message_id, content)
|
||||
return {'stream': success}
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
@@ -219,12 +238,16 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.logger.info('WeComBot EBA adapter running in unified webhook mode')
|
||||
await self.logger.info('WeComBot Omni adapter running in unified webhook mode')
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
if not self.config.get('enable-webhook', False):
|
||||
await self.bot.disconnect()
|
||||
self._message_cache.clear()
|
||||
self._user_cache.clear()
|
||||
self._group_cache.clear()
|
||||
self._member_cache.clear()
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
@@ -300,6 +323,14 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
role=platform_entities.MemberRole.MEMBER,
|
||||
display_name=event.sender.nickname,
|
||||
)
|
||||
for cache in (
|
||||
self._message_cache,
|
||||
self._user_cache,
|
||||
self._group_cache,
|
||||
self._member_cache,
|
||||
):
|
||||
while len(cache) > 4096:
|
||||
cache.pop(next(iter(cache)), None)
|
||||
|
||||
def _cleanup_stream_mapping(self):
|
||||
now = time.time()
|
||||
|
||||
@@ -154,7 +154,7 @@ def interaction_event_from_native(
|
||||
target_id = str(event.chatid or event.userid or '')
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='wecombot-eba',
|
||||
adapter_name='wecombot-omni',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: wecombot-eba
|
||||
name: wecombot-omni
|
||||
label:
|
||||
en_US: WeComBot
|
||||
zh_Hans: 企业微信智能机器人
|
||||
@@ -17,10 +17,36 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecombot
|
||||
en: https://link.langbot.app/en/platforms/wecombot
|
||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
|
||||
config:
|
||||
- name: one-click-create
|
||||
label:
|
||||
en_US: One-Click Create Bot
|
||||
zh_Hans: 一键创建机器人
|
||||
zh_Hant: 一鍵建立機器人
|
||||
description:
|
||||
en_US: 'Scan QR code with WeCom to automatically create a bot and fill in BotId and Secret. Note: Robot Name needs to
|
||||
be filled in manually.'
|
||||
zh_Hans: 使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。注意:机器人名称需手动填写。
|
||||
zh_Hant: 使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。注意:機器人名稱需手動填寫。
|
||||
type: qr-code-login
|
||||
login_platform: wecombot
|
||||
required: false
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
en_US: Trusted IPs
|
||||
zh_Hans: 企业可信 IP
|
||||
zh_Hant: 企業可信 IP
|
||||
description:
|
||||
en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of the bot configuration in the WeCom
|
||||
admin console
|
||||
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台智能机器人配置的「企业可信 IP」中
|
||||
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台智慧機器人設定的「企業可信 IP」中
|
||||
type: array[string]
|
||||
required: false
|
||||
default: []
|
||||
- name: BotId
|
||||
label:
|
||||
en_US: BotId
|
||||
|
||||
@@ -9,35 +9,36 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
|
||||
|
||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> str:
|
||||
content_parts: list[str] = []
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for msg in message_chain:
|
||||
if isinstance(msg, platform_message.Source):
|
||||
continue
|
||||
if isinstance(msg, platform_message.Plain):
|
||||
content_parts.append(msg.text)
|
||||
items.append({'type': 'text', 'text': msg.text})
|
||||
elif isinstance(msg, (platform_message.Image, platform_message.Voice, platform_message.File)):
|
||||
kind = (
|
||||
'image'
|
||||
if isinstance(msg, platform_message.Image)
|
||||
else ('voice' if isinstance(msg, platform_message.Voice) else 'file')
|
||||
)
|
||||
items.append({'type': kind, 'base64': msg.base64 or '', 'name': getattr(msg, 'name', '') or ''})
|
||||
elif isinstance(msg, platform_message.At):
|
||||
content_parts.append(f'@{msg.display or msg.target}')
|
||||
items.append({'type': 'text', 'text': f'@{msg.display or msg.target}'})
|
||||
elif isinstance(msg, platform_message.AtAll):
|
||||
content_parts.append('@all')
|
||||
elif isinstance(msg, platform_message.Image):
|
||||
content_parts.append('[Image]')
|
||||
elif isinstance(msg, platform_message.Voice):
|
||||
content_parts.append('[Voice]')
|
||||
elif isinstance(msg, platform_message.File):
|
||||
content_parts.append(f'[File: {msg.name or msg.file_id or msg.url or "file"}]')
|
||||
items.append({'type': 'text', 'text': '@all'})
|
||||
elif isinstance(msg, platform_message.Quote):
|
||||
if msg.id is not None:
|
||||
content_parts.append(f'[Quote {msg.id}]')
|
||||
items.append({'type': 'text', 'text': f'[Quote {msg.id}]'})
|
||||
if msg.origin:
|
||||
content_parts.append(await WecomBotMessageConverter.yiri2target(msg.origin))
|
||||
items.extend(await WecomBotMessageConverter.yiri2target(msg.origin))
|
||||
elif isinstance(msg, platform_message.Forward):
|
||||
for node in msg.node_list:
|
||||
if node.message_chain:
|
||||
content_parts.append(await WecomBotMessageConverter.yiri2target(node.message_chain))
|
||||
items.extend(await WecomBotMessageConverter.yiri2target(node.message_chain))
|
||||
else:
|
||||
content_parts.append(str(msg))
|
||||
return '\n'.join(part for part in content_parts if part)
|
||||
items.append({'type': 'text', 'text': str(msg)})
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: WecomBotEvent, bot_name: str = '') -> platform_message.MessageChain:
|
||||
@@ -61,8 +62,12 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
WecomBotMessageConverter._append_link(components, event.link)
|
||||
WecomBotMessageConverter._append_quote(components, event.quote)
|
||||
|
||||
if not any(not isinstance(component, (platform_message.Source, platform_message.At)) for component in components):
|
||||
components.append(platform_message.Unknown(text=f'[unsupported wecombot msgtype: {event.msgtype or "unknown"}]'))
|
||||
if not any(
|
||||
not isinstance(component, (platform_message.Source, platform_message.At)) for component in components
|
||||
):
|
||||
components.append(
|
||||
platform_message.Unknown(text=f'[unsupported wecombot msgtype: {event.msgtype or "unknown"}]')
|
||||
)
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
@@ -76,7 +81,9 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
def _append_file(components: list[platform_message.MessageComponent], file_info: dict | None):
|
||||
if not file_info:
|
||||
return
|
||||
file_url = file_info.get('download_url') or file_info.get('url') or file_info.get('fileurl') or file_info.get('path')
|
||||
file_url = (
|
||||
file_info.get('download_url') or file_info.get('url') or file_info.get('fileurl') or file_info.get('path')
|
||||
)
|
||||
file_base64 = file_info.get('base64')
|
||||
file_name = file_info.get('filename') or file_info.get('name')
|
||||
file_size = file_info.get('filesize') or file_info.get('size')
|
||||
@@ -140,7 +147,9 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
if not link:
|
||||
return
|
||||
summary = '\n'.join(
|
||||
filter(None, [link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')])
|
||||
filter(
|
||||
None, [link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')]
|
||||
)
|
||||
)
|
||||
if summary:
|
||||
components.append(platform_message.Plain(text=f'{prefix}{summary}'))
|
||||
@@ -152,7 +161,9 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
origin: list[platform_message.MessageComponent] = []
|
||||
if quote_info.get('content'):
|
||||
origin.append(platform_message.Plain(text=quote_info.get('content')))
|
||||
WecomBotMessageConverter._append_images(origin, quote_info.get('images') or ([quote_info.get('picurl')] if quote_info.get('picurl') else []))
|
||||
WecomBotMessageConverter._append_images(
|
||||
origin, quote_info.get('images') or ([quote_info.get('picurl')] if quote_info.get('picurl') else [])
|
||||
)
|
||||
WecomBotMessageConverter._append_file(origin, quote_info.get('file'))
|
||||
WecomBotMessageConverter._append_voice(origin, quote_info.get('voice'))
|
||||
WecomBotMessageConverter._append_video(origin, quote_info.get('video'))
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ADAPTER_NAME = 'wecombot-eba'
|
||||
ADAPTER_NAME = 'wecombot-omni'
|
||||
|
||||
@@ -105,7 +105,9 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
content_list = await WecomCSMessageConverter.yiri2target(message, self.bot)
|
||||
raw_results = []
|
||||
for content in content_list:
|
||||
raw_results.append(await self._send_content(open_kfid, external_userid, self._make_outbound_msgid(), content))
|
||||
raw_results.append(
|
||||
await self._send_content(open_kfid, external_userid, self._make_outbound_msgid(), content)
|
||||
)
|
||||
return platform_events.MessageResult(raw={'results': raw_results})
|
||||
|
||||
async def reply_message(
|
||||
@@ -164,10 +166,14 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.logger.info('WeComCS EBA adapter running in unified webhook mode')
|
||||
await self.logger.info('WeComCS Omni adapter running in unified webhook mode')
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self.bot.clear()
|
||||
await self.bot.close()
|
||||
self._message_cache.clear()
|
||||
self._user_cache.clear()
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
@@ -213,6 +219,12 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
return
|
||||
self._message_cache[str(event.message_id)] = event
|
||||
self._user_cache[str(event.sender.id)] = event.sender
|
||||
for cache in (
|
||||
self._message_cache,
|
||||
self._user_cache,
|
||||
):
|
||||
while len(cache) > 4096:
|
||||
cache.pop(next(iter(cache)), None)
|
||||
|
||||
async def _send_content(self, open_kfid: str, external_userid: str, msgid: str, content: dict):
|
||||
content_type = content.get('type')
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: wecomcs-eba
|
||||
name: wecomcs-omni
|
||||
label:
|
||||
en_US: WeCom Customer Service
|
||||
zh_Hans: 企业微信客服
|
||||
@@ -17,10 +17,23 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecomcs
|
||||
en: https://link.langbot.app/en/platforms/wecomcs
|
||||
ja: https://link.langbot.app/ja/platforms/wecomcs
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
|
||||
config:
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
en_US: Trusted IPs
|
||||
zh_Hans: 企业可信 IP
|
||||
zh_Hant: 企業可信 IP
|
||||
description:
|
||||
en_US: Add these outbound IPs of the LangBot server to the "Trusted Enterprise IPs" of WeChat Customer Service in the
|
||||
WeCom admin console
|
||||
zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到企业微信管理后台微信客服的「企业可信 IP」中
|
||||
zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入企業微信管理後台微信客服的「企業可信 IP」中
|
||||
type: array[string]
|
||||
required: false
|
||||
default: []
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ADAPTER_NAME = 'wecomcs-eba'
|
||||
ADAPTER_NAME = 'wecomcs-omni'
|
||||
|
||||
|
||||
def make_private_chat_id(user_id: str | int | None, open_kfid: str | int | None) -> str:
|
||||
"""Build the routable private chat id used by the WeCom CS EBA adapter."""
|
||||
"""Build the routable private chat id used by the WeCom CS Omni adapter."""
|
||||
user = str(user_id or '')
|
||||
kfid = str(open_kfid or '')
|
||||
if not user or not kfid:
|
||||
|
||||
@@ -35,6 +35,7 @@ from ..api.http.authz import WorkspaceRequiredError
|
||||
from ..workspace.errors import WorkspaceInvariantError
|
||||
|
||||
from .logger import EventLogger
|
||||
from .adapter_names import canonical_adapter_name
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
@@ -932,7 +933,7 @@ class RuntimeBot:
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=event_binding.get('target_uuid'),
|
||||
text=f'EBA event {event_type} matched route {event_binding.get("id") or ""}'.strip(),
|
||||
text=f'Event {event_type} matched route {event_binding.get("id") or ""}'.strip(),
|
||||
)
|
||||
if target_type == 'discard':
|
||||
if isinstance(event, platform_events.MessageReceivedEvent):
|
||||
@@ -947,14 +948,14 @@ class RuntimeBot:
|
||||
status='discarded',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
text=f'EBA event {event_type} discarded by event binding',
|
||||
text=f'Event {event_type} discarded by event binding',
|
||||
)
|
||||
return await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
status='discarded',
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
text=f'EBA event {event_type} discarded by event binding',
|
||||
text=f'Event {event_type} discarded by event binding',
|
||||
)
|
||||
if target_type == 'pipeline':
|
||||
if not self._is_message_event_type(event_type):
|
||||
@@ -967,7 +968,7 @@ class RuntimeBot:
|
||||
target_uuid=event_binding.get('target_uuid'),
|
||||
failure_code='processor_incompatible',
|
||||
reason='Pipeline targets only support message events',
|
||||
text=f'EBA event {event_type} ignored Pipeline target for non-message event',
|
||||
text=f'Event {event_type} ignored Pipeline target for non-message event',
|
||||
)
|
||||
await self._dispatch_eba_message_to_pipeline(
|
||||
event,
|
||||
@@ -981,7 +982,7 @@ class RuntimeBot:
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=event_binding.get('target_uuid'),
|
||||
text=f'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(),
|
||||
text=f'Event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(),
|
||||
)
|
||||
if target_type not in {'agent', 'event_processor'}:
|
||||
return await self._record_event_route_trace(
|
||||
@@ -993,7 +994,7 @@ class RuntimeBot:
|
||||
target_uuid=event_binding.get('target_uuid'),
|
||||
failure_code='processor_incompatible',
|
||||
reason=f'Unsupported event binding target type: {target_type}',
|
||||
text=f'EBA event {event_type} ignored unsupported target type {target_type}',
|
||||
text=f'Event {event_type} ignored unsupported target type {target_type}',
|
||||
)
|
||||
|
||||
target_uuid = event_binding.get('target_uuid')
|
||||
@@ -1008,7 +1009,7 @@ class RuntimeBot:
|
||||
target_uuid=target_uuid,
|
||||
failure_code='processor_not_found',
|
||||
reason='Agent target not found',
|
||||
text=f'EBA event {event_type} target agent not found: {target_uuid}',
|
||||
text=f'Event {event_type} target agent not found: {target_uuid}',
|
||||
)
|
||||
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
|
||||
return await self._record_event_route_trace(
|
||||
@@ -1019,7 +1020,7 @@ class RuntimeBot:
|
||||
target_uuid=target_uuid,
|
||||
failure_code='processor_incompatible',
|
||||
reason='Agent target does not support this event type',
|
||||
text=f'EBA event {event_type} target agent does not support this event: {target_uuid}',
|
||||
text=f'Event {event_type} target agent does not support this event: {target_uuid}',
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1046,7 +1047,7 @@ class RuntimeBot:
|
||||
target_uuid=target_uuid,
|
||||
failure_code='processor_not_found',
|
||||
reason='Agent target has no runner',
|
||||
text=f'EBA event {event_type} target agent has no runner: {target_uuid}',
|
||||
text=f'Event {event_type} target agent has no runner: {target_uuid}',
|
||||
)
|
||||
|
||||
envelope = self._eba_event_to_agent_envelope(event, adapter)
|
||||
@@ -1096,7 +1097,7 @@ class RuntimeBot:
|
||||
binding=event_binding,
|
||||
target_type=target_type,
|
||||
target_uuid=target_uuid,
|
||||
text=f'EBA event {event_type} delivered to Agent {target_uuid}',
|
||||
text=f'Event {event_type} delivered to Agent {target_uuid}',
|
||||
)
|
||||
|
||||
def resolve_event_pipeline_uuid(
|
||||
@@ -1475,7 +1476,7 @@ class RuntimeBot:
|
||||
) -> None:
|
||||
if not isinstance(event, platform_events.MessageReceivedEvent):
|
||||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||||
await self.logger.warning(f'EBA event {event_type} cannot be dispatched to legacy Pipeline')
|
||||
await self.logger.warning(f'Event {event_type} cannot be dispatched to legacy Pipeline')
|
||||
return
|
||||
|
||||
await self._handle_legacy_message_event(
|
||||
@@ -1806,7 +1807,10 @@ class PlatformManager:
|
||||
# delete all bot log images
|
||||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
||||
|
||||
disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []
|
||||
disabled_adapters = {
|
||||
canonical_adapter_name(name)
|
||||
for name in (self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or [])
|
||||
}
|
||||
|
||||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||||
@@ -2103,6 +2107,7 @@ class PlatformManager:
|
||||
owner=bot_entity.uuid,
|
||||
)
|
||||
|
||||
bot_entity.adapter = canonical_adapter_name(bot_entity.adapter)
|
||||
if bot_entity.adapter not in self.adapter_dict:
|
||||
raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
|
||||
|
||||
@@ -2226,12 +2231,14 @@ class PlatformManager:
|
||||
]
|
||||
|
||||
def get_available_adapter_info_by_name(self, name: str) -> dict | None:
|
||||
name = canonical_adapter_name(name)
|
||||
for component in self.adapter_components:
|
||||
if component.metadata.name == name:
|
||||
return component.to_plain_dict()
|
||||
return None
|
||||
|
||||
def get_available_adapter_manifest_by_name(self, name: str) -> engine.Component | None:
|
||||
name = canonical_adapter_name(name)
|
||||
for component in self.adapter_components:
|
||||
if component.metadata.name == name:
|
||||
return component
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
categories:
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
||||
en: https://link.langbot.app/en/platforms/dingtalk
|
||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
|
||||
en: https://langbot.app/docs/en/usage/platforms/dingtalk
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
|
||||
config:
|
||||
- name: one-click-create
|
||||
label:
|
||||
|
||||
@@ -25,7 +25,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from ..logger import EventLogger
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger as EventLogger
|
||||
|
||||
|
||||
_MAX_DISCORD_MEDIA_BYTES = 10 * 1024 * 1024
|
||||
|
||||
@@ -25,9 +25,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/discord
|
||||
en: https://link.langbot.app/en/platforms/discord
|
||||
ja: https://link.langbot.app/ja/platforms/discord
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/discord
|
||||
en: https://langbot.app/docs/en/usage/platforms/discord
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/discord
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
|
||||
@@ -18,9 +18,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
||||
en: https://docs.langbot.app/en/platforms/http-bot
|
||||
ja: https://docs.langbot.app/ja/platforms/http-bot
|
||||
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||
en: https://langbot.app/docs/en/platforms/http-bot
|
||||
ja: https://langbot.app/docs/ja/platforms/http-bot
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/kook
|
||||
en: https://link.langbot.app/en/platforms/kook
|
||||
ja: https://link.langbot.app/ja/platforms/kook
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/kook
|
||||
en: https://langbot.app/docs/en/usage/platforms/kook
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/kook
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
|
||||
@@ -170,6 +170,29 @@ def _lark_should_update_stream_element(
|
||||
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
|
||||
|
||||
|
||||
def _lark_final_layout_texts(
|
||||
*,
|
||||
resume_from: bool,
|
||||
text_message: str,
|
||||
pre_pause_cached: str | None,
|
||||
resume_cached: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Return (main_text, resume_placeholder_text) for the final card update.
|
||||
|
||||
Non-resume round: the full reply belongs in the main streaming element
|
||||
only — also rendering the resume placeholder duplicates the reply, since
|
||||
both hold the same accumulated text. Resume round (Dify HITL): keep the
|
||||
pre-pause text in the main element and the resumed text in the
|
||||
placeholder, as they are distinct segments.
|
||||
"""
|
||||
if resume_from:
|
||||
# An empty pre-pause cache is valid (Dify paused before emitting any
|
||||
# text); only a missing entry (None) falls back to the full text.
|
||||
main_text = text_message if pre_pause_cached is None else pre_pause_cached
|
||||
return main_text, resume_cached
|
||||
return text_message, ''
|
||||
|
||||
|
||||
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
||||
field_type = _dify_field_type(field)
|
||||
if field_type == 'file':
|
||||
@@ -2368,16 +2391,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
|
||||
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
||||
else:
|
||||
# Normal finish: keep pre-pause + resume content visible,
|
||||
# remove buttons/notice, drop the resume placeholder.
|
||||
# Normal finish: remove buttons/notice and finalize the card.
|
||||
main_text, resume_text = _lark_final_layout_texts(
|
||||
resume_from=resume_from,
|
||||
text_message=text_message,
|
||||
pre_pause_cached=self.card_pre_pause_text.get(card_id),
|
||||
resume_cached=resume_cached,
|
||||
)
|
||||
await self._update_card_layout(
|
||||
card_id=card_id,
|
||||
message_source=message_source,
|
||||
text_message=pre_pause,
|
||||
text_message=main_text,
|
||||
sequence=final_seq,
|
||||
form_data=None,
|
||||
notice_text=selected_notice if resume_from else '',
|
||||
resume_placeholder_text=resume_cached,
|
||||
resume_placeholder_text=resume_text,
|
||||
)
|
||||
self._drop_card_state(card_id)
|
||||
self.card_id_dict.pop(message_id, None)
|
||||
|
||||
@@ -20,9 +20,9 @@ spec:
|
||||
- china
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/lark
|
||||
en: https://link.langbot.app/en/platforms/lark
|
||||
ja: https://link.langbot.app/ja/platforms/lark
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/lark
|
||||
en: https://langbot.app/docs/en/usage/platforms/lark
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/lark
|
||||
config:
|
||||
- name: domain
|
||||
label:
|
||||
|
||||
@@ -22,9 +22,9 @@ spec:
|
||||
categories:
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/line
|
||||
en: https://link.langbot.app/en/platforms/line
|
||||
ja: https://link.langbot.app/ja/platforms/line
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/line
|
||||
en: https://langbot.app/docs/en/usage/platforms/line
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/line
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -682,8 +682,8 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
||||
continue
|
||||
|
||||
# Use configured logout command, fallback to deriving from login command
|
||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||
# Use configured logout command, fallback to deriving from login command
|
||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
||||
|
||||
# Cancel existing tasks
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import typing
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import aiohttp
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
_MATTERMOST_MAX_POST_LENGTH = 16_383
|
||||
_MENTION_BOUNDARY = r'(?<![\w.-])@{username}(?![\w.-])'
|
||||
|
||||
|
||||
def _normalize_server_url(server_url: str) -> str:
|
||||
"""Return a validated Mattermost server URL without a trailing slash."""
|
||||
|
||||
url = server_url.strip().rstrip('/')
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
|
||||
raise ValueError('Mattermost server_url must be an absolute HTTP(S) URL')
|
||||
return url
|
||||
|
||||
|
||||
def _websocket_url(server_url: str) -> str:
|
||||
parsed = urlsplit(server_url)
|
||||
scheme = 'wss' if parsed.scheme == 'https' else 'ws'
|
||||
return urlunsplit((scheme, parsed.netloc, f'{parsed.path}/api/v4/websocket', '', ''))
|
||||
|
||||
|
||||
class MattermostMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Translate Mattermost post text to and from LangBot message chains."""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> str:
|
||||
parts: list[str] = []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
parts.append(component.text)
|
||||
elif isinstance(component, platform_message.Image) and component.url:
|
||||
# Mattermost renders image URLs in Markdown messages.
|
||||
parts.append(component.url)
|
||||
elif isinstance(component, platform_message.File) and component.url:
|
||||
parts.append(component.url)
|
||||
return ''.join(parts)
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(post: dict, bot_username: str) -> platform_message.MessageChain:
|
||||
text = str(post.get('message') or '')
|
||||
components: list[typing.Any] = [
|
||||
platform_message.Source(
|
||||
id=str(post.get('id') or ''),
|
||||
time=float(post.get('create_at') or 0) / 1000,
|
||||
)
|
||||
]
|
||||
if bot_username:
|
||||
mention_pattern = re.compile(_MENTION_BOUNDARY.format(username=re.escape(bot_username)), re.IGNORECASE)
|
||||
if mention_pattern.search(text):
|
||||
components.append(platform_message.At(target=bot_username))
|
||||
text = mention_pattern.sub('', text).strip()
|
||||
if text:
|
||||
components.append(platform_message.Plain(text=text))
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class MattermostEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent) -> dict:
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
post: dict,
|
||||
channel: dict,
|
||||
sender_name: str,
|
||||
bot_username: str,
|
||||
) -> platform_events.MessageEvent:
|
||||
message_chain = await MattermostMessageConverter.target2yiri(post, bot_username)
|
||||
timestamp = float(post.get('create_at') or 0) / 1000
|
||||
sender_id = str(post.get('user_id') or '')
|
||||
channel_type = channel.get('type')
|
||||
|
||||
if channel_type == 'D':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(id=sender_id, nickname=sender_name or sender_id, remark=''),
|
||||
message_chain=message_chain,
|
||||
time=timestamp,
|
||||
source_platform_object={'post': post, 'channel': channel},
|
||||
)
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=sender_id,
|
||||
member_name=sender_name or sender_id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=str(post.get('channel_id') or ''),
|
||||
name=str(channel.get('display_name') or channel.get('name') or post.get('channel_id') or ''),
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=timestamp,
|
||||
source_platform_object={'post': post, 'channel': channel},
|
||||
)
|
||||
|
||||
|
||||
class MattermostAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""Mattermost Bot Account adapter using the v4 REST and WebSocket APIs."""
|
||||
|
||||
server_url: str = ''
|
||||
access_token: str = ''
|
||||
session: aiohttp.ClientSession | None = None
|
||||
listeners: dict[typing.Type[platform_events.Event], typing.Callable] = {}
|
||||
channel_cache: dict[str, dict] = {}
|
||||
stream_post_ids: dict[str, str] = {}
|
||||
bot_username: str = ''
|
||||
_running: bool = False
|
||||
|
||||
message_converter: MattermostMessageConverter = MattermostMessageConverter()
|
||||
event_converter: MattermostEventConverter = MattermostEventConverter()
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
server_url = _normalize_server_url(str(config.get('server_url') or ''))
|
||||
access_token = str(config.get('access_token') or '').strip()
|
||||
if not access_token:
|
||||
raise ValueError('Mattermost adapter requires an access_token')
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
server_url=server_url,
|
||||
access_token=access_token,
|
||||
bot_account_id='',
|
||||
session=None,
|
||||
listeners={},
|
||||
channel_cache={},
|
||||
stream_post_ids={},
|
||||
bot_username='',
|
||||
_running=False,
|
||||
)
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self.session is None or self.session.closed:
|
||||
self.session = aiohttp.ClientSession(
|
||||
headers={'Authorization': f'Bearer {self.access_token}'},
|
||||
raise_for_status=False,
|
||||
)
|
||||
return self.session
|
||||
|
||||
async def _api_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
payload: dict | None = None,
|
||||
) -> dict:
|
||||
session = await self._get_session()
|
||||
async with session.request(method, f'{self.server_url}/api/v4{path}', json=payload) as response:
|
||||
raw_body = await response.text()
|
||||
if response.status >= 400:
|
||||
# Mattermost returns a useful JSON error, but never include request headers/tokens in errors.
|
||||
try:
|
||||
error = json.loads(raw_body).get('message', raw_body)
|
||||
except json.JSONDecodeError:
|
||||
error = raw_body
|
||||
raise RuntimeError(f'Mattermost API {method} {path} failed ({response.status}): {error}')
|
||||
if not raw_body:
|
||||
return {}
|
||||
return json.loads(raw_body)
|
||||
|
||||
async def _load_bot_identity(self) -> None:
|
||||
user = await self._api_request('GET', '/users/me')
|
||||
self.bot_account_id = str(user.get('id') or '')
|
||||
self.bot_username = str(user.get('username') or '')
|
||||
if not self.bot_account_id:
|
||||
raise RuntimeError('Mattermost API did not return a bot user ID')
|
||||
|
||||
async def _get_channel(self, channel_id: str) -> dict:
|
||||
if channel_id not in self.channel_cache:
|
||||
self.channel_cache[channel_id] = await self._api_request('GET', f'/channels/{channel_id}')
|
||||
return self.channel_cache[channel_id]
|
||||
|
||||
async def _post_message(self, channel_id: str, text: str, root_id: str = '') -> dict:
|
||||
if not text:
|
||||
return {}
|
||||
if len(text) > _MATTERMOST_MAX_POST_LENGTH:
|
||||
raise ValueError(f'Mattermost messages cannot exceed {_MATTERMOST_MAX_POST_LENGTH} characters')
|
||||
payload = {'channel_id': channel_id, 'message': text}
|
||||
if root_id:
|
||||
payload['root_id'] = root_id
|
||||
return await self._api_request('POST', '/posts', payload=payload)
|
||||
|
||||
async def _get_direct_channel_id(self, user_id: str) -> str:
|
||||
if not self.bot_account_id:
|
||||
await self._load_bot_identity()
|
||||
channel = await self._api_request(
|
||||
'POST',
|
||||
'/channels/direct',
|
||||
payload={'user_ids': [self.bot_account_id, user_id]},
|
||||
)
|
||||
channel_id = str(channel.get('id') or '')
|
||||
if not channel_id:
|
||||
raise RuntimeError('Mattermost did not return a direct-message channel ID')
|
||||
self.channel_cache[channel_id] = channel
|
||||
return channel_id
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
if target_type not in {'person', 'group'}:
|
||||
raise ValueError("Mattermost target_type must be 'person' or 'group'")
|
||||
text = await self.message_converter.yiri2target(message)
|
||||
channel_id = str(target_id)
|
||||
if target_type == 'person':
|
||||
channel_id = await self._get_direct_channel_id(channel_id)
|
||||
await self._post_message(channel_id, text)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
source = await self.event_converter.yiri2target(message_source)
|
||||
post = source['post']
|
||||
text = await self.message_converter.yiri2target(message)
|
||||
# A message received inside a Mattermost thread must remain in that thread. When
|
||||
# quote_origin is requested, make the response a reply to the source root post.
|
||||
root_id = str(post.get('root_id') or '')
|
||||
if quote_origin and not root_id:
|
||||
root_id = str(post.get('id') or '')
|
||||
await self._post_message(str(post['channel_id']), text, root_id)
|
||||
|
||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||
source = await self.event_converter.yiri2target(event)
|
||||
post = source['post']
|
||||
root_id = str(post.get('root_id') or post.get('id') or '')
|
||||
created = await self._post_message(str(post['channel_id']), 'Thinking…', root_id)
|
||||
if created.get('id'):
|
||||
self.stream_post_ids[str(message_id)] = str(created['id'])
|
||||
return True
|
||||
return False
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
response_id = str(bot_message.resp_message_id)
|
||||
text = await self.message_converter.yiri2target(message)
|
||||
if not text:
|
||||
return
|
||||
|
||||
post_id = self.stream_post_ids.get(response_id)
|
||||
if post_id:
|
||||
await self._api_request('PUT', f'/posts/{post_id}', payload={'id': post_id, 'message': text})
|
||||
else:
|
||||
source = await self.event_converter.yiri2target(message_source)
|
||||
post = source['post']
|
||||
root_id = str(post.get('root_id') or '')
|
||||
if quote_origin and not root_id:
|
||||
root_id = str(post.get('id') or '')
|
||||
created = await self._post_message(str(post['channel_id']), text, root_id)
|
||||
post_id = str(created.get('id') or '')
|
||||
if post_id:
|
||||
self.stream_post_ids[response_id] = post_id
|
||||
|
||||
if is_final and getattr(bot_message, 'tool_calls', None) is None:
|
||||
self.stream_post_ids.pop(response_id, None)
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return bool(self.config.get('enable_stream_reply', True))
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def _dispatch_post(self, payload: dict) -> None:
|
||||
data = payload.get('data') or {}
|
||||
try:
|
||||
post = json.loads(data.get('post') or '{}')
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
await self.logger.error('Mattermost received a posted event with an invalid post payload')
|
||||
return
|
||||
|
||||
if not post or str(post.get('user_id') or '') == self.bot_account_id:
|
||||
return
|
||||
channel_id = str(post.get('channel_id') or '')
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
try:
|
||||
channel = await self._get_channel(channel_id)
|
||||
event = await self.event_converter.target2yiri(
|
||||
post,
|
||||
channel,
|
||||
str(data.get('sender_name') or post.get('user_id') or ''),
|
||||
self.bot_username,
|
||||
)
|
||||
callback = self.listeners.get(type(event))
|
||||
if callback:
|
||||
result = callback(event, self)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as exc:
|
||||
await self.logger.error(f'Error handling Mattermost post: {exc}')
|
||||
|
||||
async def _run_websocket_once(self) -> None:
|
||||
session = await self._get_session()
|
||||
async with session.ws_connect(_websocket_url(self.server_url), heartbeat=30) as websocket:
|
||||
await websocket.send_json(
|
||||
{
|
||||
'seq': 1,
|
||||
'action': 'authentication_challenge',
|
||||
'data': {'token': self.access_token},
|
||||
}
|
||||
)
|
||||
async for message in websocket:
|
||||
if message.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
payload = json.loads(message.data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if payload.get('event') == 'posted':
|
||||
await self._dispatch_post(payload)
|
||||
elif message.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR}:
|
||||
break
|
||||
|
||||
async def run_async(self):
|
||||
self._running = True
|
||||
await self._load_bot_identity()
|
||||
await self.logger.info(f'Mattermost bot connected: @{self.bot_username} ({self.bot_account_id})')
|
||||
|
||||
retry_delay = 1
|
||||
while self._running:
|
||||
try:
|
||||
await self._run_websocket_once()
|
||||
retry_delay = 1
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if self._running:
|
||||
await self.logger.error(f'Mattermost WebSocket disconnected: {exc}')
|
||||
await asyncio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 30)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self._running = False
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
return True
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="Artwork" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 140"><defs><style>.cls-1{fill:#1e325c;fill-rule:evenodd;}</style></defs><path class="cls-1" d="M111.11,13.36l.74,14.86c12.04,13.3,16.8,32.15,10.81,49.86-8.95,26.44-38.46,40.33-65.92,31.04-27.46-9.29-42.45-38.26-33.5-64.7,6.01-17.77,21.32-29.87,39.05-33.07L71.87.03C41.99-.77,13.8,17.77,3.72,47.55c-12.4,36.6,7.24,76.33,43.85,88.73,36.6,12.4,76.33-7.24,88.73-43.85,10.07-29.74-1-61.55-25.14-79.07h-.03Z"/><path class="cls-1" d="M93.95,57.21l-.51-20.77-.41-11.95-.28-10.35s.07-4.99-.11-6.16c-.03-.25-.11-.44-.21-.62,0-.03-.02-.05-.03-.07,0-.02-.03-.05-.03-.07-.2-.33-.49-.59-.89-.72s-.8-.1-1.17.05h-.02s-.08.03-.13.07c-.16.08-.34.2-.51.36-.85.82-3.84,4.83-3.84,4.83l-6.5,8.06-7.59,9.25-13.02,16.19s-5.98,7.46-4.65,16.64c1.31,9.18,8.15,13.65,13.43,15.44,5.29,1.79,13.43,2.38,20.05-4.11,6.62-6.49,6.4-16.04,6.4-16.04l.02-.02Z"/></svg>
|
||||
|
After Width: | Height: | Size: 938 B |
@@ -0,0 +1,75 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: mattermost
|
||||
label:
|
||||
en_US: Mattermost
|
||||
zh_Hans: Mattermost
|
||||
zh_Hant: Mattermost
|
||||
ja_JP: Mattermost
|
||||
th_TH: Mattermost
|
||||
vi_VN: Mattermost
|
||||
es_ES: Mattermost
|
||||
icon: mattermost.svg
|
||||
description:
|
||||
en_US: Mattermost Bot Account adapter using the v4 REST and WebSocket APIs. Add me to the teams and channels where you want me to interact. Please use a browser or desktop application to do this.
|
||||
zh_Hans: 使用 Mattermost v4 REST API 与 WebSocket 的 Bot Account 适配器。请将我添加到您想要我互动的团队与频道。请使用浏览器或桌面应用进行操作。
|
||||
zh_Hant: 使用 Mattermost v4 REST API 與 WebSocket 的 Bot Account 介面卡。請將我加入您希望我互動的團隊與頻道。請使用瀏覽器或桌面應用程式操作。
|
||||
ja_JP: Mattermost v4 REST API と WebSocket を使用する Bot Account アダプター。利用させたいチームとチャンネルに私を追加してください。ブラウザまたはデスクトップアプリで操作してください。
|
||||
th_TH: อะแดปเตอร์ Bot Account ของ Mattermost ผ่าน v4 REST API และ WebSocket โปรดเพิ่มฉันไปยังทีมและช่องที่คุณต้องการให้ฉันโต้ตอบ โปรดดำเนินการผ่านเบราว์เซอร์หรือแอปเดสก์ท็อป
|
||||
vi_VN: Bộ điều hợp Bot Account Mattermost sử dụng REST API v4 và WebSocket. Hãy thêm tôi vào các nhóm và kênh mà bạn muốn tôi tương tác. Vui lòng thao tác bằng trình duyệt hoặc ứng dụng máy tính để bàn.
|
||||
es_ES: Adaptador de Bot Account de Mattermost mediante REST API v4 y WebSocket. Añádeme a los equipos y canales en los que quieras que interactúe. Hazlo desde un navegador o la aplicación de escritorio.
|
||||
spec:
|
||||
categories:
|
||||
- global
|
||||
- popular
|
||||
config:
|
||||
- name: server_url
|
||||
label:
|
||||
en_US: Mattermost Server URL
|
||||
zh_Hans: Mattermost 服务器地址
|
||||
zh_Hant: 位址伺服器 Mattermost
|
||||
ja_JP: Mattermost サーバー URL
|
||||
th_TH: URL เซิร์ฟเวอร์ Mattermost
|
||||
vi_VN: URL máy chủ Mattermost
|
||||
es_ES: URL del servidor Mattermost
|
||||
description:
|
||||
en_US: The base URL of the Mattermost server, for example https://mattermost.example.com
|
||||
zh_Hans: Mattermost 服务器基础地址,例如 https://mattermost.example.com
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: access_token
|
||||
label:
|
||||
en_US: Bot Access Token
|
||||
zh_Hans: Bot 访问令牌
|
||||
zh_Hant: Bot 存取權杖
|
||||
ja_JP: Bot アクセストークン
|
||||
th_TH: โทเค็นการเข้าถึงของบอต
|
||||
vi_VN: Mã truy cập Bot
|
||||
es_ES: Token de acceso del bot
|
||||
description:
|
||||
en_US: The personal access token generated for the Mattermost Bot Account
|
||||
zh_Hans: 为 Mattermost Bot Account 生成的个人访问令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: enable_stream_reply
|
||||
label:
|
||||
en_US: Enable Stream Reply
|
||||
zh_Hans: 启用流式回复
|
||||
zh_Hant: 啟用串流回覆
|
||||
ja_JP: ストリーミング返信を有効化
|
||||
th_TH: เปิดใช้งานการตอบกลับแบบสตรีม
|
||||
vi_VN: Bật phản hồi luồng
|
||||
es_ES: Activar respuesta en streaming
|
||||
description:
|
||||
en_US: Update a Mattermost post while LangBot generates a response
|
||||
zh_Hans: 在 LangBot 生成回复时持续更新同一条 Mattermost 消息
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
execution:
|
||||
python:
|
||||
path: ./mattermost.py
|
||||
attr: MattermostAdapter
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/officialaccount
|
||||
en: https://link.langbot.app/en/platforms/officialaccount
|
||||
ja: https://link.langbot.app/ja/platforms/officialaccount
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
|
||||
en: https://langbot.app/docs/en/usage/platforms/wxoa
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
- popular
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
|
||||
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
||||
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
|
||||
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
|
||||
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
bot = QQOfficialClient(
|
||||
app_id=config['appid'],
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
token=config.get('token', ''),
|
||||
logger=logger,
|
||||
unified_mode=enable_webhook,
|
||||
)
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/qqofficial
|
||||
en: https://link.langbot.app/en/platforms/qqofficial
|
||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
|
||||
config:
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
|
||||
@@ -21,9 +21,9 @@ spec:
|
||||
categories:
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/satori
|
||||
en: https://link.langbot.app/en/platforms/satori
|
||||
ja: https://link.langbot.app/ja/platforms/satori
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/readme
|
||||
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||
config:
|
||||
- name: platform
|
||||
label:
|
||||
|
||||
@@ -25,9 +25,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/slack
|
||||
en: https://link.langbot.app/en/platforms/slack
|
||||
ja: https://link.langbot.app/ja/platforms/slack
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/slack
|
||||
en: https://langbot.app/docs/en/usage/platforms/slack
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/slack
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -25,9 +25,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/telegram
|
||||
en: https://link.langbot.app/en/platforms/telegram
|
||||
ja: https://link.langbot.app/ja/platforms/telegram
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/telegram
|
||||
en: https://langbot.app/docs/en/usage/platforms/telegram
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/telegram
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wechatpad
|
||||
en: https://link.langbot.app/en/platforms/wechatpad
|
||||
ja: https://link.langbot.app/ja/platforms/wechatpad
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
|
||||
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||
config:
|
||||
- name: wechatpad_url
|
||||
label:
|
||||
|
||||
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
||||
if content['type'] == 'image':
|
||||
await self.bot.send_image(user_id, agent_id, content['media'])
|
||||
await self.bot.send_image(user_id, agent_id, content['media_id'])
|
||||
if content['type'] == 'voice':
|
||||
await self.bot.send_voice(user_id, agent_id, content['media'])
|
||||
await self.bot.send_voice(user_id, agent_id, content['media_id'])
|
||||
if content['type'] == 'file':
|
||||
await self.bot.send_file(user_id, agent_id, content['media'])
|
||||
await self.bot.send_file(user_id, agent_id, content['media_id'])
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
|
||||
@@ -17,9 +17,9 @@ spec:
|
||||
- popular
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecom
|
||||
en: https://link.langbot.app/en/platforms/wecom
|
||||
ja: https://link.langbot.app/ja/platforms/wecom
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -11,7 +11,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
from ..logger import EventLogger
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger as EventLogger
|
||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||
from langbot.libs.wecom_ai_bot_api.api import (
|
||||
WecomBotClient,
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecombot
|
||||
en: https://link.langbot.app/en/platforms/wecombot
|
||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
|
||||
config:
|
||||
- name: one-click-create
|
||||
label:
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecomcs
|
||||
en: https://link.langbot.app/en/platforms/wecomcs
|
||||
ja: https://link.langbot.app/ja/platforms/wecomcs
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
Reference in New Issue
Block a user