Files
LangBot/src/langbot/pkg/platform/sources/wecombot.py
T
dadachann 0559d9d441 Merge master into dev/4.11.x
# Conflicts:
#	pyproject.toml
#	uv.lock
2026-08-24 02:34:14 +00:00

1052 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import typing
import asyncio
import time
import traceback
import base64
import datetime
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
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.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.libs.wecom_ai_bot_api.api import (
WecomBotClient,
extract_template_card_action,
extract_template_card_event_payload,
extract_template_card_selections,
parse_select_button_action,
)
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain):
"""Convert a MessageChain into a list of component dicts.
Each dict has a ``type`` key (``'text'``, ``'image'``,
``'voice'``, ``'file'``). Text items carry ``text``; media
items carry ``base64`` (may include a ``data:...;base64,``
prefix) and optionally ``name``.
"""
items: list[dict] = []
for msg in message_chain:
if type(msg) is platform_message.Plain:
items.append({'type': 'text', 'text': msg.text})
elif type(msg) is platform_message.Image:
items.append({'type': 'image', 'base64': msg.base64 or ''})
elif type(msg) is platform_message.Voice:
items.append({'type': 'voice', 'base64': msg.base64 or ''})
elif type(msg) is platform_message.File:
items.append({'type': 'file', 'base64': msg.base64 or '', 'name': msg.name or ''})
return items
@staticmethod
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
yiri_msg_list = []
if event.type == 'group':
yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))
yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now()))
if event.content:
content = event.content
if bot_name:
content = content.replace(f'@{bot_name}', '').strip()
yiri_msg_list.append(platform_message.Plain(text=content))
images = []
if event.images:
images.extend([img for img in event.images if img])
if not images and event.picurl:
images.append(event.picurl)
for image_base64 in images:
if image_base64:
yiri_msg_list.append(platform_message.Image(base64=image_base64))
file_info = event.file or {}
if file_info:
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')
file_data = file_url or file_base64
if file_data or file_name:
file_kwargs = {}
if file_data:
file_kwargs['url'] = file_data
if file_name:
file_kwargs['name'] = file_name
if file_size is not None:
file_kwargs['size'] = file_size
try:
yiri_msg_list.append(platform_message.File(**file_kwargs))
except Exception:
# 兜底
yiri_msg_list.append(platform_message.Unknown(text='[file message unsupported]'))
voice_info = event.voice or {}
if voice_info:
voice_payload = voice_info.get('base64') or voice_info.get('url')
if voice_payload:
if voice_info.get('base64') and not voice_payload.startswith('data:'):
voice_payload = f'data:audio/mpeg;base64,{voice_info.get("base64")}'
try:
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
except Exception:
try:
voice_kwargs = {'url': voice_payload}
voice_name = voice_info.get('filename') or voice_info.get('name')
voice_size = voice_info.get('filesize') or voice_info.get('size')
if voice_name:
voice_kwargs['name'] = voice_name
if voice_size is not None:
voice_kwargs['size'] = voice_size
yiri_msg_list.append(platform_message.File(**voice_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[voice message unsupported]'))
video_info = event.video or {}
if video_info:
video_payload = (
video_info.get('base64')
or video_info.get('url')
or video_info.get('download_url')
or video_info.get('fileurl')
)
if video_payload:
video_kwargs = {'url': video_payload}
video_name = video_info.get('filename') or video_info.get('name')
video_size = video_info.get('filesize') or video_info.get('size')
if video_name:
video_kwargs['name'] = video_name
if video_size is not None:
video_kwargs['size'] = video_size
try:
# 没有专门的视频类型,沿用 File 传递给上层
yiri_msg_list.append(platform_message.File(**video_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[video message unsupported]'))
if event.msgtype == 'link' and event.link:
link = event.link
summary = '\n'.join(
filter(
None,
[link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')],
)
)
if summary:
yiri_msg_list.append(platform_message.Plain(text=summary))
# Handle quoted message (引用消息) - important for group chat file references
# Extract files/images/voice from quote and add them as top-level components
# so that plugins like FileReader can process them the same way as direct messages
quote_info = event.quote or {}
if quote_info:
# Process quote text content - add as Plain for context
if quote_info.get('content'):
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info.get("content")}'))
# Process quote images - add as top-level Image components
quote_images = quote_info.get('images', [])
if not quote_images and quote_info.get('picurl'):
quote_images = [quote_info.get('picurl')]
for img_data in quote_images:
if img_data:
yiri_msg_list.append(platform_message.Image(base64=img_data))
# Process quote file - add as top-level File component (same as private chat)
quote_file = quote_info.get('file') or {}
if quote_file:
file_url = (
quote_file.get('base64')
or quote_file.get('download_url')
or quote_file.get('url')
or quote_file.get('fileurl')
)
file_name = quote_file.get('filename') or quote_file.get('name')
file_size = quote_file.get('filesize') or quote_file.get('size')
if file_url or file_name:
file_kwargs = {}
if file_url:
file_kwargs['url'] = file_url
if file_name:
file_kwargs['name'] = file_name
if file_size is not None:
file_kwargs['size'] = file_size
try:
yiri_msg_list.append(platform_message.File(**file_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted file unsupported]'))
# Process quote voice - add as top-level Voice/File component
quote_voice = quote_info.get('voice') or {}
if quote_voice:
voice_payload = quote_voice.get('base64') or quote_voice.get('url')
if voice_payload:
if quote_voice.get('base64') and not voice_payload.startswith('data:'):
voice_payload = f'data:audio/mpeg;base64,{quote_voice.get("base64")}'
try:
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
except Exception:
try:
voice_kwargs = {'url': voice_payload}
voice_name = quote_voice.get('filename') or quote_voice.get('name')
voice_size = quote_voice.get('filesize') or quote_voice.get('size')
if voice_name:
voice_kwargs['name'] = voice_name
if voice_size is not None:
voice_kwargs['size'] = voice_size
yiri_msg_list.append(platform_message.File(**voice_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted voice unsupported]'))
# Process quote video - add as top-level File component
quote_video = quote_info.get('video') or {}
if quote_video:
video_payload = (
quote_video.get('base64')
or quote_video.get('url')
or quote_video.get('download_url')
or quote_video.get('fileurl')
)
if video_payload:
video_kwargs = {'url': video_payload}
video_name = quote_video.get('filename') or quote_video.get('name')
video_size = quote_video.get('filesize') or quote_video.get('size')
if video_name:
video_kwargs['name'] = video_name
if video_size is not None:
video_kwargs['size'] = video_size
try:
yiri_msg_list.append(platform_message.File(**video_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted video unsupported]'))
# Process quote link - add as Plain text
quote_link = quote_info.get('link') or {}
if quote_link:
link_summary = '\n'.join(
filter(
None,
[
quote_link.get('title', ''),
quote_link.get('description') or quote_link.get('digest', ''),
quote_link.get('url', ''),
],
)
)
if link_summary:
yiri_msg_list.append(platform_message.Plain(text=f'[引用链接] {link_summary}'))
has_content_element = any(
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
)
if not has_content_element:
fallback_type = event.msgtype or 'unknown'
yiri_msg_list.append(platform_message.Unknown(text=f'[unsupported wecom msgtype: {fallback_type}]'))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, bot_name: str = ''):
self.bot_name = bot_name
@staticmethod
async def yiri2target(event: platform_events.MessageEvent):
return event.source_platform_object
async def target2yiri(self, event: WecomBotEvent):
message_chain = await WecomBotMessageConverter.target2yiri(event, bot_name=self.bot_name)
if event.type == 'single':
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event.userid,
nickname=event.username,
remark='',
),
message_chain=message_chain,
time=datetime.datetime.now().timestamp(),
source_platform_object=event,
)
elif event.type == 'group':
try:
sender = platform_entities.GroupMember(
id=event.userid,
permission='MEMBER',
member_name=event.username,
group=platform_entities.Group(
id=str(event.chatid),
name=event.chatname,
permission=platform_entities.Permission.Member,
),
special_title='',
)
time = datetime.datetime.now().timestamp()
return platform_events.GroupMessage(
sender=sender,
message_chain=message_chain,
time=time,
source_platform_object=event,
)
except Exception:
print(traceback.format_exc())
class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: typing.Union[WecomBotClient, WecomBotWsClient]
bot_account_id: str
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
event_converter: WecomBotEventConverter
config: dict
bot_uuid: str = None
_ws_mode: bool = False
bot_name: str = ''
listeners: dict = {}
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
_STREAM_MAPPING_TTL = 600 # 10 minutes
ap: typing.Any = None
def __init__(self, config: dict, logger: EventLogger):
enable_webhook = config.get('enable-webhook', False)
bot_name = config.get('robot_name', '')
if not enable_webhook:
bot = WecomBotWsClient(
bot_id=config['BotId'],
secret=config['Secret'],
logger=logger,
encoding_aes_key=config.get('EncodingAESKey', ''),
)
else:
# Webhook callback mode
required_keys = ['Token', 'EncodingAESKey', 'Corpid']
missing_keys = [key for key in required_keys if key not in config or not config[key]]
if missing_keys:
raise Exception(f'WecomBot webhook mode missing config: {missing_keys}')
bot = WecomBotClient(
Token=config['Token'],
EnCodingAESKey=config['EncodingAESKey'],
Corpid=config['Corpid'],
logger=logger,
unified_mode=True,
)
bot_account_id = config.get('BotId', '')
event_converter = WecomBotEventConverter(bot_name=bot_name)
super().__init__(
config=config,
logger=logger,
bot=bot,
bot_account_id=bot_account_id,
bot_name=bot_name,
event_converter=event_converter,
listeners={},
_stream_to_monitoring_msg={},
)
# Both WecomBotClient (webhook) and WecomBotWsClient (ws long-conn)
# expose ``set_card_action_callback``. Wire the click handler so
# Dify human-input button taps resume the workflow on either mode.
if hasattr(self.bot, 'set_card_action_callback'):
self.bot.set_card_action_callback(self._on_card_action)
# Hand the client a `source` block so every interactive
# template_card it emits carries the LangBot logo + name at the
# top — the WeCom analogue of DingTalk's Avatar header.
# Always on; icon_url accepts plain HTTPS URLs (no upload needed).
if hasattr(self.bot, 'set_card_source'):
self.bot.set_card_source(
{
'icon_url': 'https://raw.githubusercontent.com/RockChinQ/LangBot/master/res/logo-blue.png',
'desc': 'LangBot',
'desc_color': 0,
}
)
@staticmethod
def _join_text_components(items: list[dict]) -> str:
"""Concatenate ``text`` items in order, leaving media items alone."""
return ''.join(item['text'] for item in items if item.get('type') == 'text')
@staticmethod
def _iter_media_components(items: list[dict]):
"""Yield non-text items in order."""
for item in items:
if item.get('type') in {'image', 'voice', 'file'}:
yield item
@staticmethod
async def _send_media(
bot,
req_id: str,
item: dict,
) -> bool:
"""Upload *item* to the WeCom AI Bot CDN and send it as a media reply.
Returns True on success. Falls back to a no-op (with a warning log)
if the SDK does not yet implement ``upload_media`` /
``reply_image`` / ``reply_file`` / ``reply_voice`` — the framework
will keep working, just without image delivery.
"""
kind = item.get('type')
upload = getattr(bot, 'upload_media', None)
if upload is None:
return False
b64_text = item.get('base64') or ''
if not b64_text:
return False
if b64_text.startswith('data:') and ',' in b64_text:
b64_text = b64_text.split(',', 1)[1]
try:
data = base64.b64decode(b64_text, validate=False)
except Exception:
return False
if not data:
return False
try:
upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind)
except Exception:
return False
media_id = getattr(upload_result, 'media_id', None) or (
isinstance(upload_result, dict) and upload_result.get('media_id')
)
if not media_id:
return False
reply_fn = {
'image': getattr(bot, 'reply_image', None),
'file': getattr(bot, 'reply_file', None),
'voice': getattr(bot, 'reply_voice', None),
}.get(kind)
if reply_fn is None:
return False
try:
await reply_fn(req_id, media_id)
return True
except Exception:
return False
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
_ws_mode = not self.config.get('enable-webhook', False)
event = message_source.source_platform_object
# Synthetic events (button-click resume queries) have no inbound
# platform object. Fall back to a proactive send so error
# messages and one-shot replies still reach the user.
if event is None:
if _ws_mode:
if isinstance(message_source, platform_events.GroupMessage):
chat_id = str(message_source.group.id)
else:
chat_id = str(message_source.sender.id)
try:
await self.bot.send_message(chat_id, text)
except Exception:
await self.logger.error(
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
)
else:
await self.logger.warning(
'WeComBot webhook mode cannot reply to a synthetic event '
'(no req_id and no proactive-send credentials); dropping.'
)
return
if _ws_mode:
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
if text:
if req_id:
await self.bot.reply_text(req_id, text)
else:
await self.bot.set_message(event.message_id, text)
for item in self._iter_media_components(items):
await self._send_media(self.bot, req_id, item)
else:
await self.bot.set_message(event.message_id, text)
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,
):
items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
_ws_mode = not self.config.get('enable-webhook', False)
# Synthetic events (e.g. button-click triggered form resume) have
# no inbound platform message — no msg_id, no req_id, no stream
# session. The output must go via the proactive-send path instead
# of the stream/reply path.
spo = message_source.source_platform_object
if spo is None:
return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode)
msg_id = spo.message_id
# Dify human-input pause: when the runner attaches `_form_data` to
# the final chunk, hand the button_interaction card off to the
# underlying client. In webhook mode the card is queued for the
# next followup poll; in ws mode it's sent as a reply frame
# immediately. Falls back to plain text when the bot has no active
# stream session for this msg_id (rare).
form_data = getattr(bot_message, '_form_data', None)
if form_data and is_final:
if hasattr(self.bot, 'push_form_pause'):
ok, stream_id, task_id = await self.bot.push_form_pause(msg_id, form_data)
if ok:
await self.logger.info(
f'WeComBot: pending button_interaction registered '
f'stream_id={stream_id} task_id={task_id} ws_mode={_ws_mode}'
)
return {'stream': True, 'form': True, 'task_id': task_id}
await self.logger.warning(
'WeComBot: cannot register form pause (no active stream session); falling back to plain text'
)
try:
from langbot.pkg.platform.human_input import format_human_input_text
fallback = format_human_input_text(
form_data.get('node_title', ''),
form_data.get('form_content', ''),
form_data.get('actions', []) or [],
)
except Exception:
fallback = text or '(人工输入)'
if _ws_mode:
event = message_source.source_platform_object
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
if req_id:
await self.bot.reply_text(req_id, fallback)
else:
await self.bot.set_message(msg_id, fallback)
return {'stream': False, 'form': True, 'fallback': True}
if _ws_mode:
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
if not success and is_final:
event = message_source.source_platform_object
req_id = event.get('req_id', '')
if req_id:
await self.bot.reply_text(req_id, text)
if is_final:
event = message_source.source_platform_object
req_id = event.get('req_id', '')
for item in self._iter_media_components(items):
await self._send_media(self.bot, req_id, item)
return {'stream': success}
else:
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
if not success and is_final:
await self.bot.set_message(msg_id, text)
return {'stream': success}
async def is_stream_output_supported(self) -> bool:
"""Whether streaming output is enabled for this bot instance."""
return self.config.get('enable-stream-reply', True)
async def _handle_synthetic_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
content: str,
is_final: bool,
ws_mode: bool,
) -> dict:
"""Handle reply_message_chunk for synthetic events (button clicks).
Synthetic events have no inbound message → no msg_id, no req_id,
no stream session. We can't do incremental streaming, so we
buffer chunks per-conversation and flush on ``is_final`` via the
proactive send path.
Buffer keyed by ``(launcher_type, launcher_id)`` from the
synthetic event itself. Only ws mode has a usable proactive-send
path right now (``ws_client.send_message`` /
``ws_client.send_template_card``); webhook mode requires a
corpid/secret we don't have, so it logs and drops.
"""
if isinstance(message_source, platform_events.GroupMessage):
chat_id = str(message_source.group.id)
else:
chat_id = str(message_source.sender.id)
form_data = getattr(bot_message, '_form_data', None)
# Buffer streaming content until is_final.
buf_key = chat_id
if not hasattr(self, '_synthetic_buffers'):
# Attribute-not-declared trick: pydantic forbids dynamic attrs
# on the model, but plain instance dicts via object.__setattr__
# do work. Lazy-create on first call.
object.__setattr__(self, '_synthetic_buffers', {})
buffers: dict[str, str] = self._synthetic_buffers
if buf_key not in buffers and len(buffers) >= 100:
buffers.pop(next(iter(buffers)), None)
if content and not form_data:
previous = buffers.get(buf_key, '')
if previous and content.startswith(previous):
buffers[buf_key] = content
elif previous and previous.endswith(content):
buffers[buf_key] = previous
else:
buffers[buf_key] = previous + content
if len(buffers[buf_key]) > 200000:
buffers[buf_key] = buffers[buf_key][-200000:]
if not is_final:
return {'stream': True, 'synthetic': True, 'buffered': True}
final_content = buffers.pop(buf_key, '')
if content:
if final_content and content.startswith(final_content):
final_content = content
elif final_content and final_content.endswith(content):
pass
else:
final_content = final_content + content
if not ws_mode:
await self.logger.warning(
'WeComBot webhook mode cannot proactively push synthetic-event '
'output (no corpid/secret); the resume reply is dropped. '
f'content_len={len(final_content)} form_data_present={form_data is not None}'
)
return {'stream': False, 'synthetic': True, 'dropped': True}
# ws mode: proactive send.
try:
if form_data:
# Determine user_id / chat_id for the routing context of any
# subsequent click on this card.
if isinstance(message_source, platform_events.GroupMessage):
routing_chat_id = str(message_source.group.id)
routing_user_id = str(message_source.sender.id)
else:
routing_chat_id = ''
routing_user_id = str(message_source.sender.id)
payload = self._build_button_interaction_payload_from_form(
form_data,
user_id=routing_user_id,
chat_id=routing_chat_id,
)
await self.bot.send_template_card(chat_id, payload)
await self.logger.info(
f'WeComBot ws: proactively sent template_card for synthetic event '
f'chat_id={chat_id} form_token={form_data.get("form_token")!r} '
f'workflow_run_id={form_data.get("workflow_run_id")!r}'
)
elif final_content:
await self.bot.send_message(chat_id, final_content)
await self.logger.info(
f'WeComBot ws: proactively sent text for synthetic event chat_id={chat_id} len={len(final_content)}'
)
except Exception:
await self.logger.error(f'WeComBot: synthetic event proactive send failed: {traceback.format_exc()}')
return {'stream': False, 'synthetic': True, 'error': True}
return {'stream': True, 'synthetic': True}
def _build_button_interaction_payload_from_form(
self, form_data: dict, *, user_id: str = '', chat_id: str = ''
) -> dict:
"""Build a button_interaction payload + track task_id for click resolution.
Unlike the inbound-event path (where push_form_pause registers the
task_id with the active stream session), proactive sends still
need the task_id registered so button clicks find pending_form.
For ws mode we stash it directly on the ws_client's pending dict.
"""
from langbot.libs.wecom_ai_bot_api.api import build_human_input_template_card_payload
import secrets as _secrets
task_id = f'dify-{_secrets.token_hex(12)}'
source = getattr(self.bot, 'card_source', None)
payload = build_human_input_template_card_payload(
form_data,
task_id,
source=source,
select_as_buttons=not self.config.get('enable-webhook', False),
)
# Register task_id → form_data so the click callback can find it.
# user_id / chat_id are required so _on_card_action can route the
# resulting synthetic query back to the right user. msg_id / req_id
# / stream_id are intentionally empty — synthetic cards have no
# inbound message to anchor on.
if hasattr(self.bot, '_pending_forms_by_task'):
self.bot._pending_forms_by_task[task_id] = {
'form_data': form_data,
'msg_id': '',
'user_id': user_id,
'chat_id': chat_id,
'stream_id': '',
'req_id': '',
'created_at': time.monotonic(),
}
prune = getattr(self.bot, '_prune_pending_forms', None)
if callable(prune):
prune()
return payload
async def send_message(self, target_type, target_id, message):
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
await self.bot.send_message(target_id, text)
else:
pass
async def on_message(self, event: WecomBotEvent):
try:
lb_event = await self.event_converter.target2yiri(event)
if lb_event:
await self.listeners[type(lb_event)](lb_event, self)
except Exception:
await self.logger.error(f'Error in wecombot callback: {traceback.format_exc()}')
print(traceback.format_exc())
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
try:
if event_type == platform_events.FriendMessage:
self.bot.on_message('single')(self.on_message)
elif event_type == platform_events.GroupMessage:
self.bot.on_message('group')(self.on_message)
elif event_type == platform_events.FeedbackEvent:
if hasattr(self.bot, 'on_feedback'):
self.bot.on_feedback()(self._on_feedback)
except Exception:
print(traceback.format_exc())
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID(用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
"""Called by pipeline after monitoring message is created, to map stream_id to monitoring message ID."""
try:
stream_id = query.message_event.source_platform_object.stream_id
if stream_id:
self._stream_to_monitoring_msg[stream_id] = (monitoring_message_id, time.time())
self._cleanup_stream_mapping()
except Exception as e:
await self.logger.debug(f'Failed to map stream_id to monitoring message: {e}')
def _cleanup_stream_mapping(self):
"""Remove entries older than TTL from the stream_id to monitoring message mapping."""
now = time.time()
expired = [k for k, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL]
for k in expired:
del self._stream_to_monitoring_msg[k]
async def _on_feedback(self, **kwargs):
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
try:
feedback_id = kwargs.get('feedback_id', '')
feedback_type = kwargs.get('feedback_type', 0)
feedback_content = kwargs.get('feedback_content', '') or None
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) or None
# WeChat Work returns integer reason codes, but FeedbackEvent expects strings
if inaccurate_reasons:
inaccurate_reasons = [str(r) for r in inaccurate_reasons]
session = kwargs.get('session')
session_id = None
user_id = None
message_id = None
stream_id = None
if session:
if session.chat_id:
session_id = f'group_{session.chat_id}'
elif session.user_id:
session_id = f'person_{session.user_id}'
user_id = session.user_id
message_id = session.msg_id
stream_id = session.stream_id
# Resolve stream_id to LangBot monitoring message ID if available
monitoring_msg_id = None
if stream_id and stream_id in self._stream_to_monitoring_msg:
monitoring_msg_id = self._stream_to_monitoring_msg[stream_id][0]
await self.logger.info(
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
f'session_id={session_id}, user_id={user_id}, message_id={message_id}'
)
event = platform_events.FeedbackEvent(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
user_id=user_id,
session_id=session_id,
message_id=message_id,
stream_id=monitoring_msg_id or stream_id,
source_platform_object=session,
)
if platform_events.FeedbackEvent in self.listeners:
await self.listeners[platform_events.FeedbackEvent](event, self)
except Exception:
await self.logger.error(f'Error in wecombot feedback callback: {traceback.format_exc()}')
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
return None
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
await self.bot.connect()
else:
async def keep_alive():
while True:
await asyncio.sleep(1)
await keep_alive()
async def kill(self) -> bool:
if hasattr(self, '_synthetic_buffers'):
self._synthetic_buffers.clear()
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
await self.bot.disconnect()
return True
await self.bot.close()
return False
async def unregister_listener(
self,
event_type: type,
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
return super().unregister_listener(event_type, callback)
async def is_muted(self, group_id: int) -> bool:
pass
# ------------------------------------------------------------------
# Dify human-input button-interaction click handling
# ------------------------------------------------------------------
async def _on_card_action(self, session, action_id: str, task_id: str, raw_event: dict) -> None:
"""Translate a button click on a button_interaction card into a
synthetic ``_dify_form_action`` query enqueued on the pool.
Pattern mirrors DingTalk / Lark / Telegram so the runner's
``_merge_pending_form_action`` path resumes the workflow.
"""
import langbot_plugin.api.entities.builtin.provider.session as provider_session
form = session.pending_form or {}
await self.logger.info(
f'WeComBot _on_card_action: task_id={task_id} action_id={action_id!r} '
f'form_token={form.get("form_token")!r} workflow_run_id={form.get("workflow_run_id")!r} '
f'session.user_id={session.user_id!r} session.chat_id={session.chat_id!r}'
)
actions = form.get('actions') or []
tce = extract_template_card_event_payload(raw_event) if isinstance(raw_event, dict) else {}
_, _, card_type = extract_template_card_action(tce)
selections = extract_template_card_selections(tce, form)
if not selections:
selections = parse_select_button_action(action_id, form)
await self.logger.info(
f'WeComBot template_card selections: task_id={task_id} card_type={card_type} selections={selections}'
)
if card_type == 'multiple_interaction' and not selections:
await self.logger.warning(
f'WeComBot: multiple_interaction callback has no parseable selections; raw={str(tce)[:1000]}'
)
return
is_select_submit = card_type == 'multiple_interaction' or bool(selections)
clean_action_id = '' if is_select_submit else (action_id or '').strip()
action_title = clean_action_id
for a in actions:
if str(a.get('id', '')) == clean_action_id:
action_title = a.get('title') or clean_action_id
break
inputs = dict(form.get('inputs') or {})
inputs.update(selections)
def _missing_fields_after_select() -> list[str]:
missing: list[str] = []
for field in form.get('input_defs') or form.get('all_input_defs') or []:
field_name = str(field.get('output_variable_name') or '').strip()
if not field_name:
continue
if inputs.get(field_name) in (None, '', []):
missing.append(field_name)
return missing
input_progress = False
if is_select_submit:
missing_fields = _missing_fields_after_select()
if not missing_fields and len(actions) == 1:
action = actions[0]
clean_action_id = str(action.get('id') or '').strip()
action_title = action.get('title') or clean_action_id
elif not missing_fields and len(actions) > 1:
if not self.config.get('enable-webhook', False):
action_form_data = {
'form_content': form.get('raw_form_content') or form.get('form_content') or '',
'raw_form_content': form.get('raw_form_content') or form.get('form_content') or '',
'input_defs': [],
'all_input_defs': form.get('all_input_defs') or form.get('input_defs') or [],
'inputs': inputs,
'actions': actions,
'node_title': form.get('node_title', ''),
'workflow_run_id': form.get('workflow_run_id', ''),
'form_token': form.get('form_token', ''),
'pipeline_uuid': form.get('pipeline_uuid', ''),
'_action_select_only': True,
}
target_chat_id = session.chat_id or session.user_id or ''
try:
payload = self._build_button_interaction_payload_from_form(
action_form_data,
user_id=session.user_id or '',
chat_id=session.chat_id or '',
)
await self.bot.send_template_card(target_chat_id, payload)
await self.logger.info(
f'WeComBot: sent action-select button card after select submit '
f'task_id={task_id} action_count={len(actions)}'
)
except Exception:
await self.logger.error(
f'WeComBot: failed to send action-select button card: {traceback.format_exc()}'
)
return
await self.logger.warning(
'WeComBot webhook mode cannot proactively send action-select button card after select submit'
)
return
else:
input_progress = True
action_title = 'Submit'
launcher_id = session.user_id or session.chat_id or ''
sender_user_id = session.user_id or launcher_id
# WeCom AI bot has both single-chat and group-chat; chat_id present
# indicates group context.
if session.chat_id:
launcher_type = provider_session.LauncherTypes.GROUP
launcher_id = session.chat_id
else:
launcher_type = provider_session.LauncherTypes.PERSON
launcher_id = session.user_id or ''
form_action_data = {
'form_token': form.get('form_token', ''),
'workflow_run_id': form.get('workflow_run_id', ''),
'action_id': clean_action_id,
'action_title': action_title,
'node_title': form.get('node_title', ''),
'user': f'{launcher_type.value}_{launcher_id}',
'inputs': inputs,
}
if input_progress:
form_action_data['_input_progress'] = True
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_user_id,
member_name='',
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=launcher_id,
name='',
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=int(time.time()),
source_platform_object=None,
)
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=sender_user_id,
nickname='',
remark='',
),
message_chain=message_chain,
time=int(time.time()),
source_platform_object=None,
)
if self.ap is None:
await self.logger.error('WeComBot: ap not injected; cannot enqueue button-click query')
return
bot_uuid = ''
pipeline_uuid = form.get('pipeline_uuid') or None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
try:
await self.ap.query_pool.add_query(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_user_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
pipeline_uuid=pipeline_uuid,
variables={
'_dify_form_action': form_action_data,
'_routed_by_rule': True,
},
)
await self.logger.info(f'WeComBot: button-click query enqueued action_id={clean_action_id!r}')
except Exception:
await self.logger.error(f'WeComBot: enqueue button-click query failed: {traceback.format_exc()}')