mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-18 02:16:07 +00:00
feat: add supports for dify hitl (#2226)
* 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
This commit is contained in:
@@ -501,6 +501,8 @@ class PlatformManager:
|
||||
bot_entity.adapter_config,
|
||||
logger,
|
||||
)
|
||||
if hasattr(adapter_inst, 'ap'):
|
||||
adapter_inst.ap = self.ap
|
||||
|
||||
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
||||
if hasattr(adapter_inst, 'set_bot_uuid'):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -103,6 +103,41 @@ spec:
|
||||
type: string
|
||||
required: true
|
||||
default: "填写你的卡片template_id"
|
||||
- name: human_input_card_template_download
|
||||
label:
|
||||
en_US: Download Human Input Card Template
|
||||
zh_Hans: 下载人工输入卡片模板
|
||||
zh_Hant: 下載人工輸入卡片範本
|
||||
description:
|
||||
en_US: "Used as the only card template ID for the whole conversation turn. Download the built-in template, then import the JSON in DingTalk Open Platform > Card Platform / Card Template Management. After DingTalk creates the template, copy its template ID into the field below. The template already wires `content` (MarkdownBlock) and `btns` (ButtonGroup). Leave empty to fall back to the legacy two-card behavior."
|
||||
zh_Hans: "用作整个对话回合唯一卡片的模板 ID。先下载内置模板,再到钉钉开放平台 > 卡片平台 / 卡片模板管理中导入该 JSON;钉钉生成模板后,将模板 ID 填到这里。模板已预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为。"
|
||||
zh_Hant: "用作整個對話回合唯一卡片的範本 ID。先下載內建範本,再到釘釘開放平台 > 卡片平台 / 卡片範本管理中匯入該 JSON;釘釘產生範本後,將範本 ID 填到這裡。範本已預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為。"
|
||||
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: human_input_card_template_id
|
||||
label:
|
||||
en_US: Human Input Card Template ID
|
||||
zh_Hans: 人工输入卡片模板ID
|
||||
zh_Hant: 人工輸入卡片範本ID
|
||||
description:
|
||||
en_US: "Paste the template ID generated after importing the human input card template."
|
||||
zh_Hans: "填写导入人工输入卡片模板后生成的模板 ID。"
|
||||
zh_Hant: "填寫匯入人工輸入卡片範本後產生的範本 ID。"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./dingtalk.py
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import discord
|
||||
from discord import ui as discord_ui
|
||||
|
||||
import typing
|
||||
import re
|
||||
@@ -8,6 +9,8 @@ import base64
|
||||
import uuid
|
||||
import os
|
||||
import datetime
|
||||
import time
|
||||
import traceback
|
||||
|
||||
# 使用BytesIO创建文件对象,避免路径问题
|
||||
import io
|
||||
@@ -824,6 +827,69 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
|
||||
|
||||
class DiscordFormView(discord_ui.View):
|
||||
"""Discord ``ui.View`` that renders one button per Dify form action.
|
||||
|
||||
Each button's click triggers ``adapter._on_form_button_click`` which
|
||||
acks the interaction, locks the buttons in place, and enqueues a
|
||||
synthetic ``_dify_form_action`` query so the runner resumes the
|
||||
workflow.
|
||||
"""
|
||||
|
||||
# Discord button style mapping for Dify ``button_style`` values.
|
||||
_STYLE_MAP: typing.ClassVar[dict] = {
|
||||
'primary': discord.ButtonStyle.primary,
|
||||
'danger': discord.ButtonStyle.danger,
|
||||
'warning': discord.ButtonStyle.danger,
|
||||
'success': discord.ButtonStyle.success,
|
||||
'default': discord.ButtonStyle.secondary,
|
||||
'': discord.ButtonStyle.secondary,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: 'DiscordAdapter',
|
||||
session_key: str,
|
||||
actions: list,
|
||||
timeout: float = 1800,
|
||||
):
|
||||
super().__init__(timeout=timeout)
|
||||
self._adapter = adapter
|
||||
self._session_key = session_key
|
||||
# Discord caps a view at 25 children (5 rows × 5 buttons). Trim
|
||||
# silently — most Dify forms have ≤10 actions in practice.
|
||||
for idx, action in enumerate(actions[:25]):
|
||||
action_id = str(action.get('id') or '')
|
||||
label = str(action.get('title') or action_id or f'Option {idx + 1}')
|
||||
style = self._STYLE_MAP.get(
|
||||
str(action.get('button_style') or '').lower(),
|
||||
discord.ButtonStyle.secondary,
|
||||
)
|
||||
# custom_id must be unique within the view and ≤100 chars.
|
||||
# Encode (session, idx) so we can recover the action even
|
||||
# if Dify ids contain unsafe characters.
|
||||
custom_id = f'lb_form:{idx}:{action_id[:80]}'[:100]
|
||||
button = discord_ui.Button(
|
||||
label=label[:80], # Discord label limit
|
||||
style=style,
|
||||
custom_id=custom_id,
|
||||
)
|
||||
button.callback = self._make_callback(action_id, label)
|
||||
self.add_item(button)
|
||||
|
||||
def _make_callback(self, action_id: str, action_title: str):
|
||||
async def _cb(interaction: discord.Interaction):
|
||||
await self._adapter._on_form_button_click(
|
||||
interaction=interaction,
|
||||
session_key=self._session_key,
|
||||
action_id=action_id,
|
||||
action_title=action_title,
|
||||
view=self,
|
||||
)
|
||||
|
||||
return _cb
|
||||
|
||||
|
||||
class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: discord.Client = pydantic.Field(exclude=True)
|
||||
|
||||
@@ -837,6 +903,10 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None)
|
||||
|
||||
# Injected by botmgr at construction so the form-button callback can
|
||||
# enqueue a synthetic resume query (`_dify_form_action`) on the pool.
|
||||
ap: typing.Any = pydantic.Field(exclude=True, default=None)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
bot_account_id = config['client_id']
|
||||
|
||||
@@ -860,8 +930,18 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
args = {}
|
||||
|
||||
if os.getenv('http_proxy'):
|
||||
args['proxy'] = os.getenv('http_proxy')
|
||||
# Proxy: config > env var > auto-detect.
|
||||
# discord.py uses aiohttp which does NOT respect http_proxy env
|
||||
# vars by default — we must pass proxy= explicitly.
|
||||
proxy = (
|
||||
config.get('proxy')
|
||||
or os.getenv('http_proxy')
|
||||
or os.getenv('HTTP_PROXY')
|
||||
or os.getenv('https_proxy')
|
||||
or os.getenv('HTTPS_PROXY')
|
||||
)
|
||||
if proxy:
|
||||
args['proxy'] = proxy
|
||||
|
||||
bot = MyClient(intents=intents, **args)
|
||||
|
||||
@@ -875,6 +955,19 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Per-resp-message-id buffer for the accumulated text yielded by
|
||||
# the runner. Discord's edit-message ratelimit (5/5s) makes true
|
||||
# progressive streaming impractical, so we collect chunks and
|
||||
# render once on is_final. ``_form_data`` on the final chunk
|
||||
# diverts to the button-view path.
|
||||
self._stream_buffer: dict[str, str] = {}
|
||||
# session_key -> {form_data, channel_id, thread_id, sender_id,
|
||||
# posted_at, view_message_id}
|
||||
# Populated when we send a form view; consumed when the user
|
||||
# clicks a button so we know which workflow_run / form_token to
|
||||
# resume.
|
||||
self._pending_forms: dict[str, dict] = {}
|
||||
|
||||
# Voice functionality methods
|
||||
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
|
||||
"""
|
||||
@@ -1068,7 +1161,12 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
):
|
||||
msg_to_send, files = await self.message_converter.yiri2target(message)
|
||||
|
||||
assert isinstance(message_source.source_platform_object, discord.Message)
|
||||
# Synthetic events (button-click resume) have no inbound discord
|
||||
# Message. Route via the channel we cached when the user clicked.
|
||||
source = message_source.source_platform_object
|
||||
if not isinstance(source, discord.Message):
|
||||
await self._reply_synthetic(message_source, msg_to_send, files)
|
||||
return
|
||||
|
||||
args = {
|
||||
'content': msg_to_send,
|
||||
@@ -1078,7 +1176,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
args['files'] = files
|
||||
|
||||
if quote_origin:
|
||||
args['reference'] = message_source.source_platform_object
|
||||
args['reference'] = source
|
||||
|
||||
has_at = False
|
||||
|
||||
@@ -1090,7 +1188,422 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if has_at:
|
||||
args['mention_author'] = True
|
||||
|
||||
await message_source.source_platform_object.channel.send(**args)
|
||||
await source.channel.send(**args)
|
||||
|
||||
async def _reply_synthetic(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
msg_to_send: str,
|
||||
files: list,
|
||||
) -> None:
|
||||
"""Deliver a reply for a button-click-resumed (synthetic) event.
|
||||
|
||||
We don't have an inbound discord.Message to anchor to; instead
|
||||
look up the channel cached in ``_pending_forms[session_key +
|
||||
'__last_channel']`` from the most recent button click.
|
||||
"""
|
||||
if isinstance(message_source, platform_events.GroupMessage):
|
||||
# _handle_form_chunk uses channel_id alone as the session
|
||||
# scope, and launcher_id was set to channel_id when
|
||||
# synthesizing the event.
|
||||
session_key = f'c:{message_source.group.id}'
|
||||
else:
|
||||
session_key = f'p:{message_source.sender.id}'
|
||||
|
||||
cached = self._pending_forms.get(session_key + '__last_channel') or {}
|
||||
channel = cached.get('channel')
|
||||
if channel is None:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.warning(
|
||||
f'Discord: synthetic reply has no cached channel for '
|
||||
f'{session_key}; dropping content (len={len(msg_to_send)})'
|
||||
)
|
||||
return
|
||||
|
||||
args: dict[str, typing.Any] = {'content': msg_to_send}
|
||||
if files:
|
||||
args['files'] = files
|
||||
try:
|
||||
await channel.send(**args)
|
||||
except Exception:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.error(f'Discord: synthetic reply send failed: {traceback.format_exc()}')
|
||||
|
||||
# Discord allows 5 edits per 5 seconds per message. We throttle
|
||||
# to one edit per 8 runner-chunks (runner already yields every 8
|
||||
# text_chunks internally), which stays comfortably within limits.
|
||||
_STREAM_EDIT_INTERVAL = 8
|
||||
|
||||
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._stream_buffer[message_id] = {
|
||||
'channel': source.channel,
|
||||
'sent_message': None, # discord.Message set on first send
|
||||
'last_content': '',
|
||||
'chunk_count': 0,
|
||||
}
|
||||
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)
|
||||
|
||||
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
|
||||
|
||||
ctx = self._stream_buffer.get(msg_id) if msg_id else None
|
||||
|
||||
# If the stream ctx was not set up (create_message_card wasn't
|
||||
# called, e.g. synthetic event), or the final chunk carries a
|
||||
# form, skip progressive editing entirely.
|
||||
if ctx is None or form_data:
|
||||
try:
|
||||
if form_data and is_final:
|
||||
await self._handle_form_chunk(message_source, form_data)
|
||||
elif is_final and chunk_text:
|
||||
await self.reply_message(
|
||||
message_source,
|
||||
platform_message.MessageChain([platform_message.Plain(text=chunk_text)]),
|
||||
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
|
||||
|
||||
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:
|
||||
if self.ap is not None:
|
||||
self.ap.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 _handle_form_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
form_data: dict,
|
||||
) -> None:
|
||||
"""Render a Dify form pause as a Discord embed + button View.
|
||||
|
||||
Mirrors the QQ / Telegram / Lark form path: the button's click
|
||||
callback synthesizes a ``_dify_form_action`` query so the runner's
|
||||
``_merge_pending_form_action`` resumes the workflow.
|
||||
"""
|
||||
source = message_source.source_platform_object
|
||||
|
||||
actions = form_data.get('actions') or []
|
||||
if not actions:
|
||||
# Nothing clickable — fall back to plain text.
|
||||
if source is not None:
|
||||
await self.reply_message(
|
||||
message_source,
|
||||
platform_message.MessageChain(
|
||||
[platform_message.Plain(text=str(form_data.get('node_title') or ''))]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
node_title = str(form_data.get('node_title') or 'Confirmation needed')
|
||||
form_content = str(form_data.get('form_content') or '').strip()
|
||||
|
||||
# Two paths:
|
||||
# (a) Real message — extract channel from source.
|
||||
# (b) Synthetic event (button-click resume) — no
|
||||
# source_platform_object; recover the channel we cached
|
||||
# when the user clicked.
|
||||
if isinstance(source, discord.Message):
|
||||
channel = source.channel
|
||||
guild_id = str(source.guild.id) if source.guild else ''
|
||||
sender_id = str(source.author.id)
|
||||
channel_id = str(source.channel.id)
|
||||
session_key = f'c:{channel_id}' if guild_id else f'p:{sender_id}'
|
||||
else:
|
||||
# Synthetic event — resolve session_key from event shape,
|
||||
# then look up the cached channel from the click.
|
||||
if isinstance(message_source, platform_events.GroupMessage):
|
||||
# launcher_id was set to channel_id when we synthesized.
|
||||
channel_id = str(message_source.group.id)
|
||||
session_key = f'c:{channel_id}'
|
||||
else:
|
||||
session_key = f'p:{message_source.sender.id}'
|
||||
channel_id = ''
|
||||
|
||||
cached = self._pending_forms.get(session_key + '__last_channel')
|
||||
channel = cached.get('channel') if cached else None
|
||||
guild_id = (cached or {}).get('guild_id', '')
|
||||
sender_id = str(message_source.sender.id) if message_source.sender else ''
|
||||
if channel is None:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.warning(
|
||||
f'Discord: synthetic form chunk has no cached channel for '
|
||||
f'{session_key}; cannot render form buttons'
|
||||
)
|
||||
return
|
||||
|
||||
body_parts: list[str] = []
|
||||
if form_content:
|
||||
body_parts.append(form_content)
|
||||
embed_body = '\n\n'.join(body_parts)
|
||||
# Discord embed.description has a 4096 char limit — defensive trim.
|
||||
if len(embed_body) > 4000:
|
||||
embed_body = embed_body[:3990] + '\n\n…(truncated)'
|
||||
|
||||
embed = discord.Embed(
|
||||
title=node_title[:256],
|
||||
description=embed_body,
|
||||
color=discord.Color.blurple(),
|
||||
)
|
||||
|
||||
view = DiscordFormView(
|
||||
adapter=self,
|
||||
session_key=session_key,
|
||||
actions=actions,
|
||||
timeout=1800, # 30 min — matches Dify form_token TTL
|
||||
)
|
||||
|
||||
try:
|
||||
sent_msg = await channel.send(embed=embed, view=view)
|
||||
except Exception:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.error(f'Discord: form view send failed: {traceback.format_exc()}')
|
||||
return
|
||||
|
||||
self._pending_forms[session_key] = {
|
||||
'form_data': form_data,
|
||||
'channel_id': channel_id,
|
||||
'guild_id': guild_id,
|
||||
'sender_id': sender_id,
|
||||
'view_message_id': str(sent_msg.id),
|
||||
'posted_at': time.time(),
|
||||
}
|
||||
|
||||
if self.ap is not None:
|
||||
self.ap.logger.info(f'Discord: form view posted session={session_key} actions={len(actions)}')
|
||||
|
||||
async def _on_form_button_click(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
session_key: str,
|
||||
action_id: str,
|
||||
action_title: str,
|
||||
view: DiscordFormView,
|
||||
) -> None:
|
||||
"""Handle a click on a form button — ack, resume the workflow,
|
||||
and disable the View buttons so the choice is visually locked in."""
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
# ACK first (3-second deadline before Discord shows "interaction failed").
|
||||
try:
|
||||
await interaction.response.defer()
|
||||
except discord.HTTPException:
|
||||
# Already responded somehow — proceed regardless.
|
||||
pass
|
||||
|
||||
pending = self._pending_forms.get(session_key)
|
||||
if not pending:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.warning(
|
||||
f'Discord: button click on stale session {session_key}; ignoring (action_id={action_id!r})'
|
||||
)
|
||||
await self._lock_view_message(interaction, view, action_title, stale=True)
|
||||
return
|
||||
|
||||
form_data: dict = pending.get('form_data') or {}
|
||||
guild_id = pending.get('guild_id', '')
|
||||
channel_id = pending.get('channel_id', '')
|
||||
initiator_id = str(pending.get('sender_id', '') or '')
|
||||
actor_id = str(interaction.user.id) if interaction.user is not None else initiator_id
|
||||
if not guild_id and initiator_id and actor_id != initiator_id:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.warning(
|
||||
f'Discord: user {actor_id} cannot act on private form created for {initiator_id}'
|
||||
)
|
||||
await self._lock_view_message(interaction, view, action_title, stale=True)
|
||||
return
|
||||
|
||||
self._pending_forms.pop(session_key, None)
|
||||
|
||||
# Lock the buttons in place: disable everything, mark chosen one.
|
||||
await self._lock_view_message(interaction, view, action_title)
|
||||
|
||||
# In group context the launcher remains the channel so Dify resumes
|
||||
# the original group session. The synthetic sender is still the real
|
||||
# clicker, preserving actor identity for auditing and routing rules.
|
||||
if guild_id:
|
||||
launcher_type = provider_session.LauncherTypes.GROUP
|
||||
launcher_id = channel_id
|
||||
else:
|
||||
launcher_type = provider_session.LauncherTypes.PERSON
|
||||
launcher_id = initiator_id or actor_id
|
||||
|
||||
form_action_data = {
|
||||
'form_token': form_data.get('form_token', ''),
|
||||
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
||||
'action_id': action_id,
|
||||
'action_title': action_title,
|
||||
'node_title': form_data.get('node_title', ''),
|
||||
'user': f'{launcher_type.value}_{launcher_id}',
|
||||
'inputs': {},
|
||||
}
|
||||
|
||||
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
|
||||
|
||||
# Synthesize a platform event so the pipeline can run the resume
|
||||
# query. source_platform_object=None signals "no inbound discord
|
||||
# message" — reply_message must tolerate this (it falls through
|
||||
# to channel.send via the cached interaction.channel below).
|
||||
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=actor_id,
|
||||
member_name=interaction.user.display_name if interaction.user else '',
|
||||
permission='MEMBER',
|
||||
group=platform_entities.Group(
|
||||
id=launcher_id,
|
||||
name=channel_id,
|
||||
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=actor_id,
|
||||
nickname=interaction.user.display_name if interaction.user else '',
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=int(time.time()),
|
||||
source_platform_object=None,
|
||||
)
|
||||
|
||||
if self.ap is None:
|
||||
if self.logger:
|
||||
await self.logger.error('Discord: ap not injected; cannot enqueue button-click query')
|
||||
return
|
||||
|
||||
bot_uuid = ''
|
||||
pipeline_uuid = form_data.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
|
||||
|
||||
# Remember the channel so _reply_synthetic and _handle_form_chunk
|
||||
# (synthetic-event path) can find a target. guild_id is needed
|
||||
# to reconstruct the launcher_type on subsequent form pauses.
|
||||
self._pending_forms[session_key + '__last_channel'] = {
|
||||
'channel': interaction.channel,
|
||||
'guild_id': guild_id,
|
||||
'posted_at': time.time(),
|
||||
}
|
||||
|
||||
try:
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=bot_uuid,
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=actor_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,
|
||||
},
|
||||
)
|
||||
if self.ap is not None:
|
||||
self.ap.logger.info(
|
||||
f'Discord: button-click query enqueued action_id={action_id!r} '
|
||||
f'session={session_key} actor_id={actor_id}'
|
||||
)
|
||||
except Exception:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.error(f'Discord: enqueue button-click query failed: {traceback.format_exc()}')
|
||||
|
||||
async def _lock_view_message(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
view: DiscordFormView,
|
||||
chosen_title: str,
|
||||
stale: bool = False,
|
||||
) -> None:
|
||||
"""Disable all buttons on the form view and annotate the chosen
|
||||
one — mirrors DingTalk/Lark's in-card selection feedback."""
|
||||
try:
|
||||
for child in view.children:
|
||||
if not isinstance(child, discord_ui.Button):
|
||||
continue
|
||||
child.disabled = True
|
||||
if not stale and child.label == chosen_title:
|
||||
child.style = discord.ButtonStyle.success
|
||||
if not (child.label or '').startswith('✓ '):
|
||||
child.label = f'✓ {child.label}'
|
||||
view.stop()
|
||||
if interaction.message is not None:
|
||||
await interaction.message.edit(view=view)
|
||||
except Exception:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.warning(f'Discord: lock-view-message failed (non-fatal): {traceback.format_exc()}')
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,13 @@ 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 langbot.libs.qq_official_api.api import QQOfficialClient
|
||||
from langbot.libs.qq_official_api.api import (
|
||||
QQ_SELECT_ACTION_PREFIX,
|
||||
QQOfficialClient,
|
||||
build_keyboard_from_form,
|
||||
build_keyboard_from_select_field,
|
||||
resolve_select_button_action,
|
||||
)
|
||||
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
|
||||
from ...utils import image
|
||||
from ..logger import EventLogger
|
||||
@@ -191,6 +197,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
enable_webhook: bool = False
|
||||
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
||||
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
||||
ap: typing.Any = None
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
enable_webhook = config.get('enable-webhook', False)
|
||||
@@ -216,6 +223,31 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
self._stream_ctx_ts: dict[str, float] = {}
|
||||
self._fallback_text: dict[str, str] = {}
|
||||
self._fallback_text_ts: dict[str, float] = {}
|
||||
# Dify form-action bookkeeping for the human-input button flow.
|
||||
# session_key = "<scene>_<id>" where scene is c2c/group/channel and
|
||||
# id is user_openid / group_openid / channel_id.
|
||||
# session_key -> {form_data, msg_id, event_id, scene, target_id,
|
||||
# sender_id, posted_at}
|
||||
# Set when we send a markdown+keyboard card and consulted when:
|
||||
# (a) INTERACTION_CREATE fires — we look up the form by
|
||||
# session_key (button's `data` carries the action_id),
|
||||
# (b) the resumed-workflow query needs to find a passive-reply
|
||||
# event_id (INTERACTION_CREATE id, 30-min validity).
|
||||
self._pending_forms: dict[str, dict] = {}
|
||||
# session_key -> most recent ``INTERACTION_CREATE`` event_id, used
|
||||
# as the passive event_id for the resumed query's LLM output.
|
||||
self._session_event_ids: dict[str, dict] = {}
|
||||
# Per-anchor msg_seq counter. QQ accepts up to 5 passive replies
|
||||
# per (msg_id|event_id) within 60 min, but each reuse needs a
|
||||
# fresh ``msg_seq`` — re-sending with msg_seq=1 is silently dedup'd.
|
||||
self._anchor_msg_seq: dict[str, int] = {}
|
||||
|
||||
# Wire button-click handler so webhook mode catches INTERACTION_CREATE.
|
||||
# (ws mode is wired separately via on_event in _run_websocket so the
|
||||
# raw payload bypasses get_message's message-only flattening.)
|
||||
@self.bot.on_interaction()
|
||||
async def _on_interaction(event_data: dict, interaction_id: typing.Optional[str]):
|
||||
await self._handle_interaction_create(event_data, interaction_id)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
@@ -227,6 +259,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
message_source,
|
||||
)
|
||||
|
||||
# Synthetic event (button-click resume): no inbound platform
|
||||
# object → no msg_id. Route via the cached INTERACTION_CREATE
|
||||
# event_id (valid 30 min, no quota cost).
|
||||
if qq_official_event is None:
|
||||
await self._reply_synthetic(message_source, message)
|
||||
return
|
||||
|
||||
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
||||
|
||||
# 确定 target_type 和 target_id
|
||||
@@ -376,6 +415,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
await self.logger.info('QQ Official WebSocket connected and ready')
|
||||
|
||||
async def on_event(event_type: str, event_data: dict):
|
||||
# INTERACTION_CREATE is dispatched via bot.on_interaction()
|
||||
# (registered in __init__) so we get the top-level ws_event_id
|
||||
# — needed as the passive-reply event_id. It never reaches here.
|
||||
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
|
||||
message_event_types = {
|
||||
'C2C_MESSAGE_CREATE',
|
||||
@@ -437,12 +479,36 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return self.config.get('enable-stream-reply', False)
|
||||
|
||||
@staticmethod
|
||||
def _is_form_placeholder_chunk(text: str) -> bool:
|
||||
"""Return True for invisible placeholder chunks used to carry forms."""
|
||||
|
||||
if not text:
|
||||
return False
|
||||
|
||||
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
|
||||
# Some Windows consoles/logs display the zero-width placeholder as
|
||||
# mojibake. Treat those variants as the same non-user-facing marker.
|
||||
return cleaned in {'', '鈥?', '​'}
|
||||
|
||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||
source = event.source_platform_object
|
||||
# Synthetic events (button-click resume) have no source object —
|
||||
# they ride a cached INTERACTION_CREATE event_id, not a streamable
|
||||
# msg_id. Skip stream setup; reply_message handles the one-shot
|
||||
# send at is_final.
|
||||
if source is None:
|
||||
return False
|
||||
# Streaming API only supports C2C private chat
|
||||
if source.t != 'C2C_MESSAGE_CREATE':
|
||||
return False
|
||||
|
||||
# The stream endpoint still consumes msg_seq for this inbound msg_id.
|
||||
# Keep the passive-reply counter in sync so a follow-up form card uses
|
||||
# msg_seq=2 instead of being deduplicated by QQ as another seq=1 send.
|
||||
if source.d_id:
|
||||
self._anchor_msg_seq[source.d_id] = max(self._anchor_msg_seq.get(source.d_id, 0), 1)
|
||||
|
||||
ctx = {
|
||||
'user_openid': source.user_openid,
|
||||
'msg_id': source.d_id,
|
||||
@@ -469,12 +535,38 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
):
|
||||
# Periodically clean up stale stream contexts
|
||||
await self._cleanup_stale_streams()
|
||||
|
||||
# Dify human-input pause: when the runner attaches `_form_data` to
|
||||
# the final chunk, finalize any in-flight stream session and send
|
||||
# a markdown + keyboard message instead. Plain-text content from
|
||||
# earlier chunks is already on the stream; we close it cleanly
|
||||
# and the buttons land as a separate reply.
|
||||
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
|
||||
if is_final:
|
||||
_resume = getattr(bot_message, '_resume_from_form', None) if not isinstance(bot_message, dict) else None
|
||||
_open_new = getattr(bot_message, '_open_new_card', None) if not isinstance(bot_message, dict) else None
|
||||
if self.ap is not None:
|
||||
self.ap.logger.info(
|
||||
f'QQ Official reply_message_chunk final: '
|
||||
f'type={type(bot_message).__name__} '
|
||||
f'is_final={is_final} '
|
||||
f'form_data_present={form_data is not None} '
|
||||
f'resume_from_form={_resume} open_new_card={_open_new} '
|
||||
f'content_len={len(getattr(bot_message, "content", "") or "")}'
|
||||
)
|
||||
if form_data and is_final:
|
||||
await self._handle_form_chunk(message_source, message, form_data)
|
||||
return
|
||||
|
||||
# 提取纯文本内容(当前 chunk 的文本)
|
||||
text_parts = []
|
||||
for msg in message:
|
||||
if type(msg) is platform_message.Plain:
|
||||
text_parts.append(msg.text)
|
||||
chunk_text = '\n\n'.join(text_parts)
|
||||
if self._is_form_placeholder_chunk(chunk_text):
|
||||
await self.logger.debug('QQ Official: skipped invisible form placeholder chunk')
|
||||
return
|
||||
|
||||
message_id = (
|
||||
bot_message.get('resp_message_id')
|
||||
@@ -484,7 +576,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
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
|
||||
# Chunks carry the latest full snapshot, not a text delta.
|
||||
self._fallback_text[message_id] = chunk_text
|
||||
self._fallback_text_ts[message_id] = time.time()
|
||||
if is_final:
|
||||
full_text = self._fallback_text.pop(message_id, '')
|
||||
@@ -497,7 +590,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
|
||||
# 累积文本
|
||||
if chunk_text:
|
||||
ctx['accumulated_text'] += chunk_text
|
||||
ctx['accumulated_text'] = chunk_text
|
||||
|
||||
# 未启动会话时,等第一个有内容的 chunk 来建立会话
|
||||
if not ctx['session_started']:
|
||||
@@ -557,3 +650,489 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
],
|
||||
):
|
||||
return super().unregister_listener(event_type, callback)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dify human-input button-interaction support
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_PENDING_FORM_TTL = 1800 # 30 min — matches QQ passive-reply window.
|
||||
_MAX_REPLIES_PER_ANCHOR = 5 # QQ hard limit per msg_id / event_id.
|
||||
|
||||
def _next_msg_seq(self, anchor: str) -> typing.Optional[int]:
|
||||
"""Return the next msg_seq for an anchor, or ``None`` if the
|
||||
anchor has already been used 5 times (further sends would be
|
||||
silently dropped by QQ)."""
|
||||
if not anchor:
|
||||
return 1
|
||||
used = self._anchor_msg_seq.get(anchor, 0)
|
||||
if used >= self._MAX_REPLIES_PER_ANCHOR:
|
||||
return None
|
||||
self._anchor_msg_seq[anchor] = used + 1
|
||||
return used + 1
|
||||
|
||||
async def _reply_synthetic(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
) -> None:
|
||||
"""Deliver a reply for a synthetic (button-click-resume) event.
|
||||
|
||||
Synthetic events have ``source_platform_object=None`` and no
|
||||
fresh inbound msg_id. The previous INTERACTION_CREATE id we
|
||||
cached in :attr:`_session_event_ids` is a valid passive-reply
|
||||
anchor (``event_id``) for up to 30 minutes — use it.
|
||||
"""
|
||||
if isinstance(message_source, platform_events.GroupMessage):
|
||||
target_type = 'group'
|
||||
group = getattr(message_source, 'group', None) or (
|
||||
message_source.sender.group if hasattr(message_source.sender, 'group') else None
|
||||
)
|
||||
target_id = str(group.id) if group else None
|
||||
else:
|
||||
target_type = 'c2c'
|
||||
target_id = str(message_source.sender.id) if message_source.sender else None
|
||||
|
||||
if not target_id:
|
||||
await self.logger.warning('QQ Official: synthetic reply has no target_id; dropping')
|
||||
return
|
||||
|
||||
session_key = f'{target_type}_{target_id}'
|
||||
cached = self._session_event_ids.get(session_key)
|
||||
event_id = cached.get('event_id') if cached else None
|
||||
if cached and (time.time() - cached.get('posted_at', 0)) > self._PENDING_FORM_TTL:
|
||||
event_id = None
|
||||
|
||||
if not event_id:
|
||||
await self.logger.warning(
|
||||
f'QQ Official: no cached event_id for {session_key}; '
|
||||
f'cannot deliver synthetic reply within passive-reply window'
|
||||
)
|
||||
return
|
||||
|
||||
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
||||
text_parts = [c['content'] for c in content_list if c.get('type') == 'text' and c.get('content')]
|
||||
if not text_parts:
|
||||
await self.logger.info('QQ Official: synthetic reply has no text content; skipping')
|
||||
return
|
||||
text = '\n\n'.join(text_parts)
|
||||
|
||||
msg_seq = self._next_msg_seq(event_id)
|
||||
if msg_seq is None:
|
||||
await self.logger.warning(
|
||||
f'QQ Official: anchor {event_id!r} exhausted (>5 passive replies); '
|
||||
f'cannot deliver synthetic reply for {session_key}'
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if target_type == 'c2c':
|
||||
await self.bot.send_private_text_msg(
|
||||
user_openid=target_id,
|
||||
content=text,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
elif target_type == 'group':
|
||||
await self.bot.send_group_text_msg(
|
||||
group_openid=target_id,
|
||||
content=text,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
||||
|
||||
def _resolve_target_from_source(self, source: QQOfficialEvent) -> typing.Optional[tuple[str, str]]:
|
||||
"""Return ``(target_type, target_id)`` for sending a reply, or
|
||||
``None`` if the scene cannot host a markdown+keyboard message."""
|
||||
if source is None:
|
||||
return None
|
||||
if source.t == 'C2C_MESSAGE_CREATE':
|
||||
return 'c2c', source.user_openid
|
||||
if source.t == 'GROUP_AT_MESSAGE_CREATE':
|
||||
return 'group', source.group_openid
|
||||
if source.t == 'AT_MESSAGE_CREATE':
|
||||
return 'channel', source.channel_id
|
||||
# DIRECT_MESSAGE_CREATE uses the guild DM API which does not accept
|
||||
# markdown+keyboard at the time of writing — caller falls back to text.
|
||||
return None
|
||||
|
||||
def _resolve_target_from_event(
|
||||
self, message_source: platform_events.MessageEvent
|
||||
) -> typing.Optional[tuple[str, str]]:
|
||||
"""Resolve ``(target_type, target_id)`` from the public event.
|
||||
|
||||
Prefers the platform-native source when present; falls back to
|
||||
the synthesized event's sender/group fields so button-click
|
||||
resume queries can still find a destination.
|
||||
"""
|
||||
source = message_source.source_platform_object
|
||||
if source is not None:
|
||||
return self._resolve_target_from_source(source)
|
||||
if isinstance(message_source, platform_events.GroupMessage):
|
||||
group = getattr(message_source, 'group', None) or (
|
||||
message_source.sender.group
|
||||
if message_source.sender and hasattr(message_source.sender, 'group')
|
||||
else None
|
||||
)
|
||||
if group and getattr(group, 'id', None):
|
||||
return 'group', str(group.id)
|
||||
if isinstance(message_source, platform_events.FriendMessage):
|
||||
if message_source.sender and getattr(message_source.sender, 'id', None):
|
||||
return 'c2c', str(message_source.sender.id)
|
||||
return None
|
||||
|
||||
def _prune_pending_forms(self) -> None:
|
||||
now = time.time()
|
||||
stale = [k for k, v in self._pending_forms.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL]
|
||||
for k in stale:
|
||||
self._pending_forms.pop(k, None)
|
||||
stale_e = [
|
||||
k for k, v in self._session_event_ids.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL
|
||||
]
|
||||
for k in stale_e:
|
||||
self._session_event_ids.pop(k, None)
|
||||
|
||||
async def _handle_form_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
form_data: dict,
|
||||
) -> None:
|
||||
"""Send the markdown + keyboard form prompt for a Dify pause.
|
||||
|
||||
Called from ``reply_message_chunk`` when the runner attaches
|
||||
``_form_data`` to the final chunk. Replaces what would otherwise
|
||||
be a plain-text numbered-list fallback.
|
||||
"""
|
||||
if self.ap is not None:
|
||||
self.ap.logger.info(
|
||||
f'QQ Official _handle_form_chunk entered; '
|
||||
f'source_present={message_source.source_platform_object is not None} '
|
||||
f'form_actions={len(form_data.get("actions") or [])}'
|
||||
)
|
||||
self._prune_pending_forms()
|
||||
|
||||
source = message_source.source_platform_object
|
||||
scene_target = self._resolve_target_from_event(message_source)
|
||||
if scene_target is None:
|
||||
# No rich-UI fit — fall through to existing text path.
|
||||
await self.logger.info('QQ Official: form chunk on unsupported scene; falling back to text')
|
||||
text_parts = [m.text for m in message if type(m) is platform_message.Plain]
|
||||
fallback_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(text_parts))])
|
||||
try:
|
||||
await self.reply_message(message_source, fallback_msg)
|
||||
except Exception:
|
||||
await self.logger.error(f'QQ Official: form fallback text send failed: {traceback.format_exc()}')
|
||||
return
|
||||
|
||||
target_type, target_id = scene_target
|
||||
session_key = f'{target_type}_{target_id}'
|
||||
|
||||
# Cancel any in-flight stream / fallback ctx so plain-text prefix
|
||||
# doesn't continue alongside the keyboard message.
|
||||
msg_id = getattr(source, 'd_id', '') or '' if source is not None else ''
|
||||
if msg_id:
|
||||
self._stream_ctx.pop(msg_id, None)
|
||||
self._stream_ctx_ts.pop(msg_id, None)
|
||||
self._fallback_text.pop(msg_id, None)
|
||||
self._fallback_text_ts.pop(msg_id, None)
|
||||
|
||||
node_title = form_data.get('node_title') or 'Confirmation needed'
|
||||
form_content = form_data.get('form_content') or ''
|
||||
is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only')
|
||||
parts = [f'### {node_title}']
|
||||
plain_parts = [node_title]
|
||||
if form_content.strip():
|
||||
parts.append(form_content.strip())
|
||||
plain_parts.append(form_content.strip())
|
||||
markdown_content = '\n\n'.join(parts)
|
||||
plain_content = '\n\n'.join(plain_parts)
|
||||
|
||||
keyboard = build_keyboard_from_select_field(form_data) if is_field_step else None
|
||||
is_text_field_step = is_field_step and not keyboard.get('content', {}).get('rows')
|
||||
if is_text_field_step:
|
||||
keyboard = None
|
||||
if keyboard is None and not is_text_field_step:
|
||||
keyboard = build_keyboard_from_form(form_data, buttons_per_row=2)
|
||||
if keyboard is not None and not keyboard.get('content', {}).get('rows') and not is_text_field_step:
|
||||
# No actions to render — fall back to plain text.
|
||||
text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)])
|
||||
try:
|
||||
await self.reply_message(message_source, text_msg)
|
||||
except Exception:
|
||||
await self.logger.error(f'QQ Official: empty-keyboard fallback send failed: {traceback.format_exc()}')
|
||||
return
|
||||
|
||||
# Prefer the inbound msg_id (no quota cost). If the source is a
|
||||
# synthetic event from a prior click, the cached interaction id
|
||||
# serves as event_id for up to 30 min.
|
||||
event_id = None
|
||||
if not msg_id:
|
||||
cached = self._session_event_ids.get(session_key)
|
||||
if cached and (time.time() - cached.get('posted_at', 0)) < self._PENDING_FORM_TTL:
|
||||
event_id = cached.get('event_id')
|
||||
|
||||
anchor = msg_id or event_id or ''
|
||||
msg_seq = self._next_msg_seq(anchor)
|
||||
if msg_seq is None:
|
||||
await self.logger.warning(
|
||||
f'QQ Official: anchor {anchor!r} exhausted (>5 passive replies); '
|
||||
f'cannot deliver form card for session={session_key}'
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.bot.send_markdown_keyboard(
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
markdown_content=markdown_content,
|
||||
keyboard=keyboard,
|
||||
msg_id=msg_id if (msg_id and not event_id) else None,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
if self.ap is not None:
|
||||
self.ap.logger.info(
|
||||
f'QQ Official: form card sent '
|
||||
f'target={target_type}/{target_id} '
|
||||
f'msg_id={msg_id!r} event_id={event_id!r} msg_seq={msg_seq}'
|
||||
)
|
||||
except Exception:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.error(
|
||||
f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}'
|
||||
)
|
||||
await self.logger.error(
|
||||
f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}'
|
||||
)
|
||||
text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)])
|
||||
try:
|
||||
await self.reply_message(message_source, text_msg)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
sender_id = ''
|
||||
if source is not None:
|
||||
sender_id = (
|
||||
getattr(source, 'user_openid', None)
|
||||
or getattr(source, 'member_openid', None)
|
||||
or getattr(source, 'd_author_id', None)
|
||||
or ''
|
||||
)
|
||||
if not sender_id and message_source.sender is not None:
|
||||
sender_id = str(getattr(message_source.sender, 'id', '') or '')
|
||||
self._pending_forms[session_key] = {
|
||||
'form_data': form_data,
|
||||
'msg_id': msg_id,
|
||||
'sender_id': sender_id,
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'source_event_t': source.t if source is not None else None,
|
||||
'posted_at': time.time(),
|
||||
}
|
||||
await self.logger.info(
|
||||
f'QQ Official: form posted session={session_key} actions={len(form_data.get("actions") or [])}'
|
||||
)
|
||||
|
||||
async def _handle_interaction_create(
|
||||
self,
|
||||
event_data: dict,
|
||||
ws_event_id: typing.Optional[str] = None,
|
||||
) -> None:
|
||||
"""Handle a button-click INTERACTION_CREATE event.
|
||||
|
||||
Two IDs at play (QQ keeps them separate):
|
||||
ws_event_id top-level payload ``id`` (or webhook ``X-Bot-
|
||||
Event-Id``). The ONLY value accepted as
|
||||
``event_id`` for subsequent passive replies.
|
||||
d['id'] the interaction id — used for PUT
|
||||
/interactions/{id} ack. Cannot be reused as
|
||||
event_id (QQ returns 40034025 if you try).
|
||||
|
||||
Layout (https://bot.q.qq.com/.../msg-btn.html):
|
||||
chat_type 0 channel / 1 group / 2 c2c
|
||||
data.resolved.button_data what we set as ``action.data``
|
||||
data.resolved.button_id ``id`` field on the button row
|
||||
"""
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
if self.ap is not None:
|
||||
self.ap.logger.info(
|
||||
f'QQ Official _handle_interaction_create entered; '
|
||||
f'ws_event_id={ws_event_id!r} '
|
||||
f'interaction_id={(event_data.get("id") if isinstance(event_data, dict) else None)!r} '
|
||||
f'chat_type={event_data.get("chat_type") if isinstance(event_data, dict) else None}'
|
||||
)
|
||||
|
||||
if not isinstance(event_data, dict):
|
||||
await self.logger.warning(f'QQ Official: INTERACTION_CREATE event_data is not dict: {type(event_data)}')
|
||||
return
|
||||
|
||||
# ACK uses the interaction id, NOT the ws event id.
|
||||
interaction_id = event_data.get('id') or ''
|
||||
if interaction_id:
|
||||
asyncio.create_task(self.bot.ack_interaction(interaction_id, code=0))
|
||||
|
||||
resolved = (event_data.get('data') or {}).get('resolved') or {}
|
||||
action_id = str(resolved.get('button_data') or resolved.get('button_id') or '').strip()
|
||||
if not action_id:
|
||||
await self.logger.warning('QQ Official: INTERACTION_CREATE missing button_data/button_id; ignoring')
|
||||
return
|
||||
|
||||
chat_type = event_data.get('chat_type')
|
||||
scene_target: typing.Optional[tuple[str, str]] = None
|
||||
if chat_type == 2 or event_data.get('user_openid'):
|
||||
scene_target = ('c2c', event_data.get('user_openid') or '')
|
||||
elif chat_type == 1 or event_data.get('group_openid'):
|
||||
scene_target = ('group', event_data.get('group_openid') or '')
|
||||
elif chat_type == 0 or event_data.get('channel_id'):
|
||||
scene_target = ('channel', event_data.get('channel_id') or '')
|
||||
|
||||
if not scene_target or not scene_target[1]:
|
||||
await self.logger.warning(f'QQ Official: INTERACTION_CREATE missing scene/target; raw={event_data}')
|
||||
return
|
||||
|
||||
target_type, target_id = scene_target
|
||||
session_key = f'{target_type}_{target_id}'
|
||||
|
||||
self._prune_pending_forms()
|
||||
pending = self._pending_forms.get(session_key)
|
||||
if not pending:
|
||||
await self.logger.warning(
|
||||
f'QQ Official: no pending form for session {session_key}; click ignored (action_id={action_id!r})'
|
||||
)
|
||||
return
|
||||
|
||||
# Cache ws_event_id so a follow-up pause / text reply can use it
|
||||
# as event_id for passive delivery (30-min window). Falls back to
|
||||
# the interaction_id only if no ws_event_id was provided (e.g.
|
||||
# tests / older payload shape) — QQ will reject that value but
|
||||
# we log so the mismatch is debuggable.
|
||||
cached_event_id = ws_event_id or interaction_id
|
||||
if cached_event_id:
|
||||
self._session_event_ids[session_key] = {
|
||||
'event_id': cached_event_id,
|
||||
'posted_at': time.time(),
|
||||
}
|
||||
# New anchor → fresh 5-reply budget.
|
||||
self._anchor_msg_seq[cached_event_id] = 0
|
||||
if self.ap is not None and not ws_event_id:
|
||||
self.ap.logger.warning(
|
||||
'QQ Official: INTERACTION_CREATE lacked ws_event_id; '
|
||||
'falling back to interaction_id (passive reply may be rejected)'
|
||||
)
|
||||
|
||||
form_data: dict = pending.get('form_data') or {}
|
||||
actions = form_data.get('actions') or []
|
||||
select_choice = resolve_select_button_action(form_data, action_id)
|
||||
if action_id.startswith(QQ_SELECT_ACTION_PREFIX) and select_choice is None:
|
||||
await self.logger.warning(f'QQ Official: invalid select action_id={action_id!r} for {session_key}')
|
||||
return
|
||||
|
||||
matched = None
|
||||
if select_choice is None:
|
||||
matched = next(
|
||||
(a for a in actions if str(a.get('id', '')) == action_id),
|
||||
None,
|
||||
)
|
||||
if matched is None:
|
||||
await self.logger.warning(
|
||||
f'QQ Official: action_id={action_id!r} is not present on pending form for {session_key}'
|
||||
)
|
||||
return
|
||||
self._pending_forms.pop(session_key, None)
|
||||
action_title = select_choice[1] if select_choice else matched.get('title') or action_id
|
||||
|
||||
initiator_id = str(pending.get('sender_id') or '')
|
||||
actor_id = str(event_data.get('member_openid') or event_data.get('user_openid') or initiator_id)
|
||||
|
||||
# Build resume payload matching the shape every other adapter uses
|
||||
# (DingTalk / Lark / Telegram / WeCom). The runner's
|
||||
# _merge_pending_form_action consumes this verbatim.
|
||||
if target_type == 'group' or target_type == 'channel':
|
||||
launcher_type = provider_session.LauncherTypes.GROUP
|
||||
launcher_id = target_id
|
||||
else:
|
||||
launcher_type = provider_session.LauncherTypes.PERSON
|
||||
launcher_id = target_id
|
||||
|
||||
form_action_data = {
|
||||
'form_token': form_data.get('form_token', ''),
|
||||
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
||||
'action_id': '' if select_choice else action_id,
|
||||
'action_title': action_title,
|
||||
'node_title': form_data.get('node_title', ''),
|
||||
'user': f'{launcher_type.value}_{launcher_id}',
|
||||
'inputs': {'select': select_choice[1]} if select_choice else {},
|
||||
}
|
||||
if select_choice:
|
||||
form_action_data['_current_input_field'] = select_choice[0]
|
||||
form_action_data['_input_progress'] = True
|
||||
|
||||
event_label = 'Form Select' if select_choice else 'Form Action'
|
||||
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[{event_label}: {action_title}]')])
|
||||
|
||||
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=actor_id or launcher_id,
|
||||
member_name='',
|
||||
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=actor_id or launcher_id,
|
||||
nickname='',
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=int(time.time()),
|
||||
source_platform_object=None,
|
||||
)
|
||||
|
||||
if self.ap is None:
|
||||
await self.logger.error('QQ Official: ap not injected; cannot enqueue button-click query')
|
||||
return
|
||||
|
||||
bot_uuid = ''
|
||||
pipeline_uuid = form_data.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=actor_id or launcher_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'QQ Official: button-click query enqueued action_id={action_id!r} '
|
||||
f'session={session_key} actor_id={actor_id}'
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(f'QQ Official: enqueue button-click query failed: {traceback.format_exc()}')
|
||||
|
||||
@@ -31,6 +31,18 @@ spec:
|
||||
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: appid
|
||||
label:
|
||||
en_US: App ID
|
||||
@@ -52,8 +64,12 @@ spec:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
description:
|
||||
en_US: Optional. The QR binding cannot return this value; the current adapter implementation does not use it either, so it can be safely left blank.
|
||||
zh_Hans: 可选。扫码绑定无法获取该字段,当前适配器实现也未使用该字段,留空即可。
|
||||
zh_Hant: 可選。掃碼綁定無法取得此欄位,目前介面卡實作亦未使用,留空即可。
|
||||
type: string
|
||||
required: true
|
||||
required: false
|
||||
default: ""
|
||||
- name: enable-webhook
|
||||
label:
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
from __future__ import annotations
|
||||
import time
|
||||
|
||||
|
||||
import telegram
|
||||
import telegram.ext
|
||||
from telegram import Update
|
||||
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters
|
||||
from telegram import ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, CallbackQueryHandler, filters
|
||||
import telegramify_markdown
|
||||
import typing
|
||||
import traceback
|
||||
import json
|
||||
import base64
|
||||
import time
|
||||
import uuid
|
||||
import pydantic
|
||||
|
||||
from langbot.pkg.utils import httpclient
|
||||
@@ -20,6 +22,61 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
def _telegram_select_field_options(form_data: dict) -> tuple[str, list[str]]:
|
||||
"""Return the active select field and its option values."""
|
||||
field_name = str(form_data.get('_current_input_field') or '').strip()
|
||||
if not field_name:
|
||||
return '', []
|
||||
field = next(
|
||||
(
|
||||
item
|
||||
for item in form_data.get('input_defs') or []
|
||||
if str(item.get('output_variable_name') or '').strip() == field_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not field or str(field.get('type') or '').strip().lower() != 'select':
|
||||
return '', []
|
||||
|
||||
source = field.get('option_source') or {}
|
||||
source_value = source.get('value') if isinstance(source, dict) else None
|
||||
if isinstance(source_value, list):
|
||||
return field_name, [str(item) for item in source_value]
|
||||
if isinstance(source_value, str):
|
||||
return field_name, [part.strip() for part in source_value.splitlines() if part.strip()]
|
||||
|
||||
options = field.get('options')
|
||||
if not isinstance(options, list):
|
||||
return field_name, []
|
||||
values = []
|
||||
for item in options:
|
||||
if isinstance(item, dict):
|
||||
values.append(str(item.get('label') or item.get('value') or ''))
|
||||
else:
|
||||
values.append(str(item))
|
||||
return field_name, [value for value in values if value]
|
||||
|
||||
|
||||
def _telegram_form_action_from_callback(data: dict) -> dict | None:
|
||||
"""Translate compact Telegram callback data into a runner form action."""
|
||||
if 'x' not in data:
|
||||
return {
|
||||
'action_id': str(data.get('action_id') or data.get('a') or ''),
|
||||
'inputs': {},
|
||||
}
|
||||
try:
|
||||
option_index = int(data['x'])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if option_index < 0:
|
||||
return None
|
||||
return {
|
||||
'action_id': '',
|
||||
'inputs': {'select': {'index': option_index}},
|
||||
'_input_progress': True,
|
||||
}
|
||||
|
||||
|
||||
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
|
||||
@@ -167,7 +224,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
time=event.message.date.timestamp(),
|
||||
source_platform_object=event,
|
||||
)
|
||||
elif event.effective_chat.type == 'group' or 'supergroup':
|
||||
elif event.effective_chat.type in ('group', 'supergroup'):
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=event.effective_chat.id,
|
||||
@@ -189,6 +246,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: telegram.Bot = pydantic.Field(exclude=True)
|
||||
application: telegram.ext.Application = pydantic.Field(exclude=True)
|
||||
ap: typing.Any = pydantic.Field(exclude=True, default=None)
|
||||
|
||||
message_converter: TelegramMessageConverter = TelegramMessageConverter()
|
||||
event_converter: TelegramEventConverter = TelegramEventConverter()
|
||||
@@ -204,6 +262,48 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
_FORM_ACTION_CACHE_TTL = 30 * 60
|
||||
# callback_data -> (display title, pipeline UUID, expiration time, form group id)
|
||||
_form_action_titles: typing.Dict[str, tuple[str, str, float, str]] = {}
|
||||
|
||||
def _prune_form_action_titles(self, now: float | None = None) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
expired = [key for key, (_, _, expires_at, _) in self._form_action_titles.items() if expires_at <= now]
|
||||
for key in expired:
|
||||
self._form_action_titles.pop(key, None)
|
||||
|
||||
def _cache_form_action_titles(
|
||||
self,
|
||||
mappings: dict[str, str],
|
||||
pipeline_uuid: str = '',
|
||||
now: float | None = None,
|
||||
) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
self._prune_form_action_titles(now)
|
||||
group_id = uuid.uuid4().hex
|
||||
expires_at = now + self._FORM_ACTION_CACHE_TTL
|
||||
self._form_action_titles.update(
|
||||
{callback_data: (title, pipeline_uuid, expires_at, group_id) for callback_data, title in mappings.items()}
|
||||
)
|
||||
|
||||
def _take_form_action_context(self, callback_data: str, now: float | None = None) -> tuple[str, str] | None:
|
||||
"""Consume a callback and invalidate every button from the same form."""
|
||||
self._prune_form_action_titles(now)
|
||||
entry = self._form_action_titles.get(callback_data)
|
||||
if entry is None:
|
||||
return None
|
||||
title, pipeline_uuid, _, group_id = entry
|
||||
group_keys = [
|
||||
key for key, (_, _, _, cached_group_id) in self._form_action_titles.items() if cached_group_id == group_id
|
||||
]
|
||||
for key in group_keys:
|
||||
self._form_action_titles.pop(key, None)
|
||||
return title, pipeline_uuid
|
||||
|
||||
def _take_form_action_title(self, callback_data: str, now: float | None = None) -> str | None:
|
||||
context = self._take_form_action_context(callback_data, now)
|
||||
return context[0] if context else None
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if update.message.from_user.is_bot:
|
||||
@@ -224,6 +324,117 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
telegram_callback,
|
||||
)
|
||||
)
|
||||
|
||||
async def callback_query_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
try:
|
||||
data = json.loads(query.data)
|
||||
if data.get('form_action') or data.get('f'):
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
# workflow_run_id is not in the callback payload (too large
|
||||
# for Telegram's 64-byte limit). Only w_suffix is sent;
|
||||
# the runner resolves the full run id from _PENDING_FORMS.
|
||||
w_suffix = data.get('w', '')
|
||||
session_key = data.get('session_key') or data.get('s', '')
|
||||
callback_action = _telegram_form_action_from_callback(data)
|
||||
action_context = self._take_form_action_context(query.data) if callback_action is not None else None
|
||||
if callback_action is None or action_context is None:
|
||||
await self.logger.warning(f'Invalid or stale Telegram form callback: {query.data!r}')
|
||||
return
|
||||
action_title, pipeline_uuid = action_context
|
||||
# Show selected action feedback by editing the original message
|
||||
try:
|
||||
original_text = query.message.text or ''
|
||||
selected_text = f'{original_text}\n\n✅ {action_title}'
|
||||
await query.edit_message_text(text=selected_text, reply_markup=None)
|
||||
except Exception:
|
||||
# If edit fails (e.g. message too long), just pass
|
||||
pass
|
||||
|
||||
if session_key.startswith('group_') or session_key.startswith('g:'):
|
||||
launcher_type = provider_session.LauncherTypes.GROUP
|
||||
launcher_id = (
|
||||
session_key.split(':', 1)[1]
|
||||
if session_key.startswith('g:')
|
||||
else session_key[len('group_') :]
|
||||
)
|
||||
else:
|
||||
launcher_type = provider_session.LauncherTypes.PERSON
|
||||
launcher_id = (
|
||||
session_key.split(':', 1)[1]
|
||||
if session_key.startswith('p:')
|
||||
else session_key[len('person_') :]
|
||||
)
|
||||
|
||||
user_id = str(query.from_user.id)
|
||||
|
||||
# Find bot_uuid and pipeline_uuid
|
||||
bot_uuid = ''
|
||||
for b in self.ap.platform_mgr.bots:
|
||||
if b.adapter is self:
|
||||
bot_uuid = b.bot_entity.uuid
|
||||
pipeline_uuid = pipeline_uuid or b.bot_entity.use_pipeline_uuid
|
||||
break
|
||||
|
||||
form_action_data = {
|
||||
# workflow_run_id is intentionally omitted; the runner
|
||||
# resolves it from w_suffix via _PENDING_FORMS.
|
||||
'w_suffix': w_suffix,
|
||||
'user': f'{launcher_type.value}_{launcher_id}',
|
||||
**callback_action,
|
||||
}
|
||||
|
||||
event_label = 'Form Select' if callback_action.get('_input_progress') else 'Form Action'
|
||||
message_chain = platform_message.MessageChain(
|
||||
[platform_message.Plain(text=f'[{event_label}: {action_title}]')]
|
||||
)
|
||||
|
||||
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||||
synthetic_event = platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=user_id,
|
||||
member_name='',
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=launcher_id,
|
||||
name='',
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
source_platform_object=update,
|
||||
)
|
||||
else:
|
||||
synthetic_event = platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=user_id,
|
||||
nickname='',
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
source_platform_object=update,
|
||||
)
|
||||
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=bot_uuid,
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=launcher_id,
|
||||
sender_id=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,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in telegram callback query: {traceback.format_exc()}')
|
||||
|
||||
application.add_handler(CallbackQueryHandler(callback_query_handler))
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
@@ -314,23 +525,34 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
args['parse_mode'] = 'MarkdownV2'
|
||||
return args
|
||||
|
||||
async def _delete_group_stream_message(self, chat_mode: str, chat_id: int, stream_id: int | None):
|
||||
if chat_mode != 'group' or stream_id is None:
|
||||
return
|
||||
try:
|
||||
await self.bot.delete_message(chat_id=chat_id, message_id=stream_id)
|
||||
except telegram.error.TelegramError:
|
||||
pass
|
||||
|
||||
@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)
|
||||
|
||||
return True
|
||||
|
||||
@@ -347,12 +569,15 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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':
|
||||
@@ -361,17 +586,68 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
return
|
||||
|
||||
content = components[0]['text']
|
||||
form_data = getattr(bot_message, '_form_data', None)
|
||||
|
||||
if form_data and is_final:
|
||||
if not has_visible_content:
|
||||
await self._send_form_action_buttons(message_source, form_data, edit_message_id=stream_id)
|
||||
else:
|
||||
await self._send_form_action_buttons(message_source, form_data)
|
||||
self.msg_stream_id.pop(message_id, None)
|
||||
return
|
||||
|
||||
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,
|
||||
@@ -379,11 +655,137 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
}
|
||||
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)
|
||||
|
||||
async def _send_form_action_buttons(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
form_data: dict,
|
||||
edit_message_id: int | None = None,
|
||||
):
|
||||
"""Send inline keyboard buttons for Dify form fields or actions."""
|
||||
actions = form_data.get('actions', [])
|
||||
node_title = form_data.get('node_title', '')
|
||||
form_content = form_data.get('form_content', '')
|
||||
workflow_run_id = form_data.get('workflow_run_id', '')
|
||||
# Telegram callback_data is capped at 64 bytes, so we identify the
|
||||
# paused workflow by the last 8 chars of workflow_run_id (unique
|
||||
# within a session with overwhelming probability).
|
||||
w_suffix = workflow_run_id[-8:] if workflow_run_id else ''
|
||||
|
||||
if isinstance(message_source, platform_events.GroupMessage):
|
||||
session_key = f'g:{message_source.group.id}'
|
||||
else:
|
||||
session_key = f'p:{message_source.sender.id}'
|
||||
|
||||
current_field = str(form_data.get('_current_input_field') or '').strip()
|
||||
is_field_step = bool(current_field) and not form_data.get('_action_select_only')
|
||||
select_field, select_options = _telegram_select_field_options(form_data)
|
||||
is_select_field = bool(select_field and select_options)
|
||||
if is_select_field:
|
||||
choices = [(option, {'x': idx}) for idx, option in enumerate(select_options)]
|
||||
elif is_field_step:
|
||||
choices = []
|
||||
else:
|
||||
choices = [(action.get('title', action.get('id', '')), {'a': action.get('id', '')}) for action in actions]
|
||||
|
||||
keyboard = []
|
||||
pending_title_mappings: dict[str, str] = {}
|
||||
oversized = False
|
||||
buttons_per_row = 2 if is_select_field else 1
|
||||
current_row = []
|
||||
for title, choice_data in choices:
|
||||
callback_payload = {'f': 1, **choice_data, 's': session_key}
|
||||
if w_suffix:
|
||||
callback_payload['w'] = w_suffix
|
||||
callback_data = json.dumps(callback_payload, separators=(',', ':'))
|
||||
if len(callback_data.encode('utf-8')) > 64:
|
||||
oversized = True
|
||||
break
|
||||
pending_title_mappings[callback_data] = str(title)
|
||||
current_row.append(InlineKeyboardButton(str(title), callback_data=callback_data))
|
||||
if len(current_row) == buttons_per_row:
|
||||
keyboard.append(current_row)
|
||||
current_row = []
|
||||
if current_row and not oversized:
|
||||
keyboard.append(current_row)
|
||||
|
||||
update = message_source.source_platform_object
|
||||
chat_id = update.effective_chat.id
|
||||
effective_message = update.effective_message
|
||||
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
||||
|
||||
heading = f'[{node_title}]'
|
||||
text_lines = [heading]
|
||||
if form_content:
|
||||
text_lines.append(form_content)
|
||||
|
||||
if oversized:
|
||||
# callback_data exceeds Telegram's 64-byte limit — fall back to
|
||||
# a plain-text numbered list so the user can reply by number.
|
||||
for idx, (title, _) in enumerate(choices, start=1):
|
||||
text_lines.append(f' {idx}. {title}')
|
||||
args = {
|
||||
'chat_id': chat_id,
|
||||
'text': '\n\n'.join(text_lines),
|
||||
}
|
||||
elif keyboard:
|
||||
self._cache_form_action_titles(
|
||||
pending_title_mappings,
|
||||
str(form_data.get('pipeline_uuid') or ''),
|
||||
)
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
args = {
|
||||
'chat_id': chat_id,
|
||||
'text': '\n\n'.join(text_lines),
|
||||
'reply_markup': reply_markup,
|
||||
}
|
||||
elif is_field_step:
|
||||
args = {
|
||||
'chat_id': chat_id,
|
||||
'text': '\n\n'.join(text_lines),
|
||||
# Telegram privacy-mode bots receive replies to ForceReply
|
||||
# prompts even when they cannot read ordinary group messages.
|
||||
'reply_markup': ForceReply(
|
||||
selective=False,
|
||||
input_field_placeholder=current_field,
|
||||
),
|
||||
}
|
||||
else:
|
||||
args = {
|
||||
'chat_id': chat_id,
|
||||
'text': '\n\n'.join(text_lines),
|
||||
}
|
||||
|
||||
if message_thread_id:
|
||||
args['message_thread_id'] = message_thread_id
|
||||
|
||||
if edit_message_id is not None:
|
||||
edit_args = {
|
||||
'chat_id': chat_id,
|
||||
'message_id': edit_message_id,
|
||||
'text': args['text'],
|
||||
}
|
||||
edit_args['reply_markup'] = args.get('reply_markup')
|
||||
try:
|
||||
await self.bot.edit_message_text(**edit_args)
|
||||
return
|
||||
except telegram.error.TelegramError:
|
||||
await self._delete_group_stream_message('group', chat_id, edit_message_id)
|
||||
|
||||
await self.bot.send_message(**args)
|
||||
|
||||
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
||||
if not isinstance(event.source_platform_object, Update):
|
||||
return None
|
||||
|
||||
@@ -11,7 +11,13 @@ 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
|
||||
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
|
||||
|
||||
|
||||
@@ -296,6 +302,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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)
|
||||
@@ -336,6 +343,25 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
_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,
|
||||
@@ -345,15 +371,37 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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:
|
||||
event = message_source.source_platform_object
|
||||
req_id = event.get('req_id', '')
|
||||
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(message_source.source_platform_object.message_id, content)
|
||||
await self.bot.set_message(event.message_id, content)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
@@ -364,9 +412,56 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
is_final: bool = False,
|
||||
):
|
||||
content = await self.message_converter.yiri2target(message)
|
||||
msg_id = message_source.source_platform_object.message_id
|
||||
_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:
|
||||
@@ -385,6 +480,142 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""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:
|
||||
@@ -531,3 +762,191 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
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()}')
|
||||
|
||||
Reference in New Issue
Block a user