mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 17:36:07 +00:00
0755beebcd
* feat: Implement workflow form handling for paused workflows - Added module-level storage for pending forms to manage state across sessions. - Introduced functions to set, get, and clear pending forms with expiration handling. - Enhanced DifyServiceAPIRunner to support resuming paused workflows via form actions. - Implemented logic to yield human input requests and display appropriate messages. - Updated workflow submission methods to handle paused states and resume actions. - Ensured proper merging of pending form actions with user inputs for seamless interaction. * feat: Add '_routed_by_rule' variable to form action in Lark and Telegram adapters * feat: Enhance Lark and Telegram adapters with new form handling for paused workflows * feat: Enhance TelegramAdapter to handle form action buttons and message threading * feat: Improve TelegramAdapter message handling with enhanced error management and draft message support * feat: Add the function for formatting human input text to support adapters without rich UI. * feat(dingtalk): implement human input card support and card action handling - Add a new module `card_callback.py` to handle card action button clicks from DingTalk. - Introduce `DingTalkCardActionHandler` to process card action callbacks and extract parameters. - Update `DingTalkAdapter` to manage card state and handle form input through a single card template. - Add configuration for `human_input_card_template_id` in `dingtalk.yaml` to specify the template for human input. - Create a new card template `dingtalk_human_input_card.json` for rendering human input prompts and buttons. * feat(dingtalk): enhance human input card functionality with streaming support and active turn management - Updated the DingTalk card template to enable streaming mode and multi-update configuration. - Removed the obsolete delete_card method from DingTalkClient to streamline card management. - Enhanced DingTalkAdapter to manage active turn cards and accumulated streaming text, ensuring a seamless user experience during human input prompts. - Modified the create_message_card method to utilize existing active cards for resumed workflows, preventing duplication. - Improved the _paint_form_on_card method to update existing cards with human input prompts and buttons dynamically. - Updated the dingtalk_human_input_card.json template to reflect the new streaming capabilities and configuration options. * feat(wecom): implement Dify human input pause handling with button interaction support * feat(qqofficial): implement Dify human input button interaction handling and markdown keyboard support * feat(qqofficial): implement one-click QR binding and enhance localization support * feat(discord): implement Discord form view with button interactions for Dify actions * fix(telegram): correct group chat type check and handle oversized callback data for Telegram actions fix(difysvapi): ensure safe access to remove-think configuration in pipeline settings * feat(dify): add support for chatflow app type and enhance human input handling * feat(telegram): add action title feedback for user selections in Telegram messages * feat(lark): enhance LarkAdapter to store form content for resume notices * feat(dingtalk): update display formatting for card content with HTML line breaks * feat(dingtalk): add feedback functionality to cards with 👍/👎 buttons - Implemented feedback state management for cards, allowing users to provide feedback via thumbs up/down buttons. - Enhanced card rendering to include feedback buttons when appropriate. - Registered feedback listeners to handle feedback events and update card states accordingly. - Updated the card template to support dynamic button rendering for feedback actions. - Improved error handling and logging for feedback actions and card updates. * fix: add Avatar component to dingtalk_human_input_card.json for enhanced user interaction * feat(wecom): add optional source block to interactive template cards for enhanced branding * feat(wecom): add functions for template card action extraction and update, enhance button interaction handling * feat(qqofficial): synchronize passive-reply counter with inbound message sequence * feat(qqofficial): add method to identify invisible form placeholder chunks in messages * feat(dingtalk): add download link for human input card template and enhance dynamic form configuration * feat(telegram): enhance message handling with group stream deletion and form placeholder detection * Add unit tests for DingTalk, Lark, WeComBot, and Dify service API runners - Implement tests for DingTalk adapter helper functions including form content cleaning, input extraction, and completed input lines. - Create unit tests for Lark adapter helper functions focusing on input extraction and completed input lines. - Add tests for WeComBot template card functionalities, including event extraction and payload building for human input. - Enhance Dify service API runner tests to cover human input forms, including input collection, action handling, and form snapshot extraction. * feat: Enhance Telegram and QQ Official adapters with select field handling and form action processing - Added support for select fields in Telegram adapter, including option extraction and callback handling. - Implemented form action processing for Telegram callbacks, improving user interaction feedback. - Introduced new helper functions for building keyboards and resolving select button actions in QQ Official adapter. - Enhanced DifyServiceAPIRunner to handle cumulative streaming responses and improve error handling during workflow resumes. - Added unit tests for new functionalities in Telegram and QQ Official adapters, ensuring robust behavior for select fields and form actions. * feat(lark): add functions for current input definitions and visible form content handling feat(qqofficial): update fallback text handling for non-streaming scenarios feat(difysvapi): enhance form content processing for interactive fields and actions test: add unit tests for Lark and QQ Official adapter functionalities * Add tests for DingTalk adapter content processing and markdown formatting - Updated the assertion in `test_dingtalk_completed_input_lines_include_text_and_select_values` to remove unnecessary markdown formatting. - Added new tests to verify that `_dingtalk_clean_form_content` maintains the order of prompts and completed values in various scenarios. - Introduced `test_dingtalk_card_markdown_preserves_internal_line_breaks` to ensure internal line breaks are correctly converted to HTML line breaks. * feat: Refactor input handling and feedback messages across multiple adapters * feat: Update the human-computer interaction template cards, and optimize the prompt information and content display. * feat: Refactor pending form handling to isolate by bot and pipeline * feat: Enhance error handling and caching for Dify and WeCom interactions * feat: Enhance select input handling and validation in Dify API runner and Telegram adapter * feat: Add missing completed input lines handling in DingTalk adapter * feat: Add pipeline_uuid handling across multiple adapters and update related tests
953 lines
42 KiB
Python
953 lines
42 KiB
Python
from __future__ import annotations
|
||
import typing
|
||
import asyncio
|
||
import time
|
||
import traceback
|
||
|
||
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):
|
||
content = ''
|
||
for msg in message_chain:
|
||
if type(msg) is platform_message.Plain:
|
||
content += msg.text
|
||
return content
|
||
|
||
@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,
|
||
}
|
||
)
|
||
|
||
async def reply_message(
|
||
self,
|
||
message_source: platform_events.MessageEvent,
|
||
message: platform_message.MessageChain,
|
||
quote_origin: bool = False,
|
||
):
|
||
content = await self.message_converter.yiri2target(message)
|
||
_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, content)
|
||
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 req_id:
|
||
await self.bot.reply_text(req_id, content)
|
||
else:
|
||
await self.bot.set_message(event.message_id, content)
|
||
else:
|
||
await self.bot.set_message(event.message_id, content)
|
||
|
||
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,
|
||
):
|
||
content = await self.message_converter.yiri2target(message)
|
||
_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, content, 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.provider.runners.difysvapi 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 = content 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, content, 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, content)
|
||
return {'stream': success}
|
||
else:
|
||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||
if not success and is_final:
|
||
await self.bot.set_message(msg_id, content)
|
||
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 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 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': '',
|
||
}
|
||
return payload
|
||
|
||
async def send_message(self, target_type, target_id, message):
|
||
_ws_mode = not self.config.get('enable-webhook', False)
|
||
if _ws_mode:
|
||
content = await self.message_converter.yiri2target(message)
|
||
await self.bot.send_message(target_id, content)
|
||
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:
|
||
_ws_mode = not self.config.get('enable-webhook', False)
|
||
if _ws_mode:
|
||
await self.bot.disconnect()
|
||
return True
|
||
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()}')
|