mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-19 16:17:20 +00:00
feat(agent): integrate structured runner interactions
This commit is contained in:
@@ -14,6 +14,11 @@ from langbot.pkg.platform.adapters.dingtalk.api_impl import DingTalkAPIMixin
|
||||
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.dingtalk.interaction import (
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_native,
|
||||
send_interaction,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
@@ -27,6 +32,9 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler):
|
||||
|
||||
async def process(self, message: dingtalk_stream.CallbackMessage):
|
||||
callback = dingtalk_stream.CardCallbackMessage.from_dict(message.data or {})
|
||||
content = dict(callback.content) if isinstance(callback.content, dict) else {}
|
||||
if isinstance(message.data, dict):
|
||||
content['_raw_callback'] = message.data
|
||||
event = DingTalkEvent.from_payload(
|
||||
{
|
||||
'conversation_type': 'CardCallback',
|
||||
@@ -35,7 +43,7 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler):
|
||||
'extension': callback.extension,
|
||||
'corp_id': callback.corp_id,
|
||||
'user_id': callback.user_id,
|
||||
'content': callback.content,
|
||||
'content': content,
|
||||
'space_id': callback.space_id,
|
||||
'card_instance_id': callback.card_instance_id,
|
||||
},
|
||||
@@ -61,6 +69,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
|
||||
_user_cache: dict[str, platform_entities.User] = {}
|
||||
_group_cache: dict[str, platform_entities.UserGroup] = {}
|
||||
interaction_callback_contexts: dict[str, dict[str, typing.Any]] = {}
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
@@ -89,6 +98,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
_message_cache={},
|
||||
_user_cache={},
|
||||
_group_cache={},
|
||||
interaction_callback_contexts={},
|
||||
)
|
||||
self._register_native_handlers()
|
||||
|
||||
@@ -100,7 +110,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return [
|
||||
apis = [
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'get_message',
|
||||
@@ -112,6 +122,16 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
'get_file_url',
|
||||
'call_platform_api',
|
||||
]
|
||||
if self.config.get('human_input_card_template_id') and hasattr(self.bot, 'create_and_deliver_card'):
|
||||
apis.append('interaction.request')
|
||||
return apis
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
@staticmethod
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -184,6 +204,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
return bool(self.config.get('enable-stream-reply', False))
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request' and action in self.get_supported_apis():
|
||||
return await send_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
@@ -233,6 +255,10 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
|
||||
async def _handle_native_event(self, event: DingTalkEvent):
|
||||
try:
|
||||
interaction_event = interaction_event_from_native(event, self.interaction_callback_contexts)
|
||||
if interaction_event is not None:
|
||||
await self._dispatch_eba_event(interaction_event)
|
||||
return
|
||||
await self.logger.debug(
|
||||
'DingTalk EBA event received: '
|
||||
f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}'
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"""DingTalk card rendering and callbacks for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
return {
|
||||
'field_types': ['text', 'textarea', 'number', 'select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def _select_options(options: list[dict[str, typing.Any]]) -> list[dict[str, typing.Any]]:
|
||||
locales = ['zh_CN', 'zh_TW', 'en_US', 'ja_JP', 'vi_VN', 'th_TH', 'id_ID', 'ms_MY', 'ko_KR']
|
||||
result = []
|
||||
for option in options:
|
||||
value = str(option.get('value') or '')
|
||||
label = str(option.get('label') or value)
|
||||
if value:
|
||||
result.append({'value': value, 'text': {locale: label for locale in locales}})
|
||||
return result
|
||||
|
||||
|
||||
def _field_card_params(field: dict[str, typing.Any]) -> dict[str, typing.Any] | None:
|
||||
field_type = str(field.get('type') or '')
|
||||
field_id = str(field.get('id') or '')
|
||||
if field_type not in {'text', 'textarea', 'number', 'select'} or not field_id:
|
||||
return None
|
||||
label = str(field.get('label') or field_id)
|
||||
placeholder = str(field.get('placeholder') or label)
|
||||
default = field.get('default')
|
||||
params: dict[str, typing.Any] = {
|
||||
'input_visible': '',
|
||||
'input_title': '',
|
||||
'input_placeholder': '',
|
||||
'input_value': '',
|
||||
'select_visible': '',
|
||||
'select_placeholder': '',
|
||||
'select_options': [],
|
||||
'index_o': [],
|
||||
'select_index': -1,
|
||||
}
|
||||
if field_type == 'select':
|
||||
raw_options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
options = [option for option in raw_options if isinstance(option, dict)]
|
||||
encoded_options = _select_options(options)
|
||||
if not encoded_options:
|
||||
return None
|
||||
selected_index = next(
|
||||
(index for index, option in enumerate(options) if str(option.get('value')) == str(default)),
|
||||
-1,
|
||||
)
|
||||
params.update(
|
||||
{
|
||||
'select_visible': 'true',
|
||||
'select_placeholder': placeholder,
|
||||
'select_options': [str(option.get('value') or '') for option in options],
|
||||
'index_o': encoded_options,
|
||||
'select_index': selected_index,
|
||||
}
|
||||
)
|
||||
else:
|
||||
params.update(
|
||||
{
|
||||
'input_visible': 'true',
|
||||
'input_title': label,
|
||||
'input_placeholder': placeholder,
|
||||
'input_value': '' if default is None else str(default),
|
||||
}
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
def _prune_callback_contexts(adapter: typing.Any, now: float | None = None) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
contexts = adapter.interaction_callback_contexts
|
||||
for key in [key for key, value in contexts.items() if float(value.get('expires_at') or 0) <= now]:
|
||||
contexts.pop(key, None)
|
||||
|
||||
|
||||
def _buttons(request: dict[str, typing.Any], callback_token: str) -> list[dict[str, typing.Any]]:
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
items: list[tuple[str, str, str]] = []
|
||||
if actions and not fields:
|
||||
items = [
|
||||
(
|
||||
str(action.get('label') or action.get('id') or index + 1),
|
||||
f'lbi:{callback_token}:a:{index}',
|
||||
str(action.get('style') or 'default'),
|
||||
)
|
||||
for index, action in enumerate(actions)
|
||||
if isinstance(action, dict)
|
||||
]
|
||||
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
|
||||
field = fields[0]
|
||||
if field.get('type') != 'select':
|
||||
return []
|
||||
options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
items = [
|
||||
(
|
||||
str(option.get('label') or option.get('value') or index + 1),
|
||||
f'lbi:{callback_token}:f:0:{index}',
|
||||
'default',
|
||||
)
|
||||
for index, option in enumerate(options)
|
||||
if isinstance(option, dict)
|
||||
]
|
||||
return [
|
||||
{
|
||||
'text': label,
|
||||
'color': 'blue' if style == 'primary' else 'red' if style == 'danger' else 'gray',
|
||||
'status': 'normal',
|
||||
'event': {
|
||||
'type': 'sendCardRequest',
|
||||
'params': {'actionId': callback_data, 'params': {'action_id': callback_data}},
|
||||
},
|
||||
}
|
||||
for label, callback_data, style in items
|
||||
]
|
||||
|
||||
|
||||
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
target_type = str(reply_target.get('target_type') or '')
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
buttons = _buttons(request, callback_token)
|
||||
field_params = (
|
||||
_field_card_params(fields[0]) if len(fields) == 1 and not actions and isinstance(fields[0], dict) else None
|
||||
)
|
||||
if not buttons and field_params is None:
|
||||
fallback = '\n\n'.join(
|
||||
part
|
||||
for part in (
|
||||
str(request.get('title') or '').strip(),
|
||||
str(request.get('description') or '').strip(),
|
||||
str(request.get('fallback_text') or '').strip(),
|
||||
)
|
||||
if part
|
||||
)
|
||||
result = await adapter.send_message(target_type, target_id, adapter._plain_message(fallback))
|
||||
return {'ok': True, 'message_id': result.message_id, 'rich': False}
|
||||
if target_type not in {'group', 'person'} or not target_id:
|
||||
raise ValueError('interaction.request has an invalid DingTalk target')
|
||||
field_label = str(fields[0].get('label') or '').strip() if fields and isinstance(fields[0], dict) else ''
|
||||
content = '\n\n'.join(
|
||||
part
|
||||
for part in (
|
||||
f'## {str(request.get("title") or "").strip()}',
|
||||
str(request.get('description') or '').strip(),
|
||||
field_label,
|
||||
)
|
||||
if part
|
||||
)
|
||||
out_track_id = uuid.uuid4().hex
|
||||
card_param_map = {'content': content, 'btns': buttons}
|
||||
if field_params is not None:
|
||||
card_param_map.update(field_params)
|
||||
await adapter.bot.create_and_deliver_card(
|
||||
card_template_id=adapter.config['human_input_card_template_id'],
|
||||
out_track_id=out_track_id,
|
||||
open_space_id=(
|
||||
f'dtv1.card//IM_GROUP.{target_id}' if target_type == 'group' else f'dtv1.card//IM_ROBOT.{target_id}'
|
||||
),
|
||||
is_group=target_type == 'group',
|
||||
card_param_map=card_param_map,
|
||||
)
|
||||
if field_params is not None:
|
||||
_prune_callback_contexts(adapter)
|
||||
field = fields[0]
|
||||
adapter.interaction_callback_contexts[out_track_id] = {
|
||||
'callback_token': callback_token,
|
||||
'field_id': str(field.get('id') or ''),
|
||||
'field_type': str(field.get('type') or ''),
|
||||
'options': [
|
||||
str(option.get('value') or '') for option in field.get('options') or [] if isinstance(option, dict)
|
||||
],
|
||||
'expires_at': time.monotonic() + 30 * 60,
|
||||
}
|
||||
return {'ok': True, 'message_id': out_track_id, 'rich': True}
|
||||
|
||||
|
||||
def parse_callback_data(value: str) -> dict[str, typing.Any]:
|
||||
parts = value.split(':')
|
||||
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
|
||||
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
|
||||
if (
|
||||
len(parts) == 5
|
||||
and parts[0] == 'lbi'
|
||||
and parts[1]
|
||||
and parts[2] == 'f'
|
||||
and parts[3].isdigit()
|
||||
and parts[4].isdigit()
|
||||
):
|
||||
return {
|
||||
'callback_token': parts[1],
|
||||
'field_ref': int(parts[3]),
|
||||
'option_ref': int(parts[4]),
|
||||
}
|
||||
raise ValueError('invalid DingTalk interaction callback data')
|
||||
|
||||
|
||||
def _find_callback_data(value: typing.Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value if value.startswith('lbi:') else ''
|
||||
if isinstance(value, dict):
|
||||
for key in ('actionId', 'action_id', 'id'):
|
||||
found = _find_callback_data(value.get(key))
|
||||
if found:
|
||||
return found
|
||||
for child in value.values():
|
||||
found = _find_callback_data(child)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(value, list):
|
||||
for child in value:
|
||||
found = _find_callback_data(child)
|
||||
if found:
|
||||
return found
|
||||
return ''
|
||||
|
||||
|
||||
def _find_named_value(value: typing.Any, names: set[str]) -> typing.Any:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
if key in names and child not in (None, ''):
|
||||
return child
|
||||
for child in value.values():
|
||||
found = _find_named_value(child, names)
|
||||
if found not in (None, ''):
|
||||
return found
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
found = _find_named_value(child, names)
|
||||
if found not in (None, ''):
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _callback_track_id(callback: dict[str, typing.Any]) -> str:
|
||||
value = _find_named_value(callback, {'outTrackId', 'out_track_id', 'outtrackid'})
|
||||
return str(value or '')
|
||||
|
||||
|
||||
def _mapping(value: typing.Any) -> dict[str, typing.Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _native_field_value(callback: dict[str, typing.Any], context: dict[str, typing.Any]) -> typing.Any:
|
||||
field_type = str(context.get('field_type') or '')
|
||||
names = (
|
||||
{'select', 'selectResult', 'select_result', '__built_in_selectResult__'}
|
||||
if field_type == 'select'
|
||||
else {'input', 'inputResult', 'input_result', '__built_in_inputResult__'}
|
||||
)
|
||||
value = _find_named_value(callback, names)
|
||||
parsed = _mapping(value)
|
||||
if parsed:
|
||||
value = parsed.get('value', parsed.get('input', parsed.get('index')))
|
||||
if isinstance(value, dict):
|
||||
value = value.get('value') or value.get('input') or value.get('index')
|
||||
if field_type == 'select' and isinstance(value, int) and not isinstance(value, bool):
|
||||
options = context.get('options') if isinstance(context.get('options'), list) else []
|
||||
if 0 <= value < len(options):
|
||||
value = options[value]
|
||||
if field_type == 'number' and value not in (None, ''):
|
||||
text = str(value).strip()
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError:
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def interaction_event_from_native(
|
||||
event: DingTalkEvent,
|
||||
callback_contexts: dict[str, dict[str, typing.Any]] | None = None,
|
||||
) -> platform_events.PlatformSpecificEvent | None:
|
||||
callback = event.get('CardCallback') or {}
|
||||
callback_data = _find_callback_data(callback)
|
||||
if callback_data:
|
||||
parsed = parse_callback_data(callback_data)
|
||||
else:
|
||||
contexts = callback_contexts if callback_contexts is not None else {}
|
||||
track_id = _callback_track_id(callback)
|
||||
context = contexts.get(track_id)
|
||||
if context is None:
|
||||
return None
|
||||
value = _native_field_value(callback, context)
|
||||
if value in (None, ''):
|
||||
return None
|
||||
parsed = {
|
||||
'callback_token': str(context.get('callback_token') or ''),
|
||||
'values': {str(context.get('field_id') or ''): value},
|
||||
}
|
||||
contexts.pop(track_id, None)
|
||||
space_id = str(callback.get('space_id') or '')
|
||||
if 'IM_GROUP.' in space_id:
|
||||
target_type = 'group'
|
||||
target_id = space_id.split('IM_GROUP.', 1)[1]
|
||||
elif 'IM_ROBOT.' in space_id:
|
||||
target_type = 'person'
|
||||
target_id = space_id.split('IM_ROBOT.', 1)[1]
|
||||
else:
|
||||
raise ValueError('DingTalk interaction callback has no delivery space')
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='dingtalk-eba',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
'actor_id': str(callback.get('user_id') or ''),
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'display_text': 'submitted',
|
||||
},
|
||||
timestamp=time.time(),
|
||||
source_platform_object=event,
|
||||
)
|
||||
@@ -90,6 +90,19 @@ spec:
|
||||
required: true
|
||||
default: "填写你的卡片template_id"
|
||||
|
||||
- name: human_input_card_template_id
|
||||
label:
|
||||
en_US: Human Input Card Template ID
|
||||
zh_Hans: 人工输入卡片模板 ID
|
||||
zh_Hant: 人工輸入卡片範本 ID
|
||||
description:
|
||||
en_US: Import the bundled `src/langbot/templates/dingtalk_human_input_card.json`, then enter its DingTalk template ID here. It contains the `content`, `btns`, `input_*`, `select_*`, and `index_o` variables required for native interactions.
|
||||
zh_Hans: 可选。模板需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 变量以支持原生交互。
|
||||
zh_Hant: 可選。範本需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 變數以支援原生互動。
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
supported_events:
|
||||
- message.received
|
||||
- feedback.received
|
||||
@@ -108,6 +121,7 @@ spec:
|
||||
- get_friend_list
|
||||
- get_file_url
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
|
||||
platform_specific_apis:
|
||||
- action: check_access_token
|
||||
|
||||
@@ -13,6 +13,12 @@ from langbot.pkg.platform.adapters.discord.api_impl import DiscordAPIMixin
|
||||
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
|
||||
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
|
||||
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.discord.interaction import (
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_component,
|
||||
parse_interaction_custom_id,
|
||||
send_interaction,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
@@ -63,6 +69,25 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
except Exception:
|
||||
await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}')
|
||||
|
||||
async def on_interaction(self: discord.Client, interaction: discord.Interaction):
|
||||
custom_id = (interaction.data or {}).get('custom_id') if isinstance(interaction.data, dict) else None
|
||||
try:
|
||||
parsed = parse_interaction_custom_id(custom_id)
|
||||
except ValueError:
|
||||
if not interaction.response.is_done():
|
||||
await interaction.response.send_message('Invalid or expired action', ephemeral=True)
|
||||
return
|
||||
if parsed is None:
|
||||
return
|
||||
if not interaction.response.is_done():
|
||||
await interaction.response.defer()
|
||||
try:
|
||||
await adapter_self._dispatch_eba_event(interaction_event_from_component(interaction, parsed))
|
||||
if interaction.message is not None:
|
||||
await interaction.message.edit(view=None)
|
||||
except Exception:
|
||||
await adapter_self.logger.error(f'Error in Discord interaction callback: {traceback.format_exc()}')
|
||||
|
||||
async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message):
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'message_edit', (before, after), self.user.id if self.user else None
|
||||
@@ -176,8 +201,12 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
'kick_member',
|
||||
'leave_group',
|
||||
'call_platform_api',
|
||||
'interaction.request',
|
||||
]
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
content, files = await self.message_converter.yiri2target(message)
|
||||
channel = await self._get_channel(target_id)
|
||||
@@ -238,6 +267,8 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Discord component rendering and callbacks for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
import discord
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
return {
|
||||
'field_types': ['select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def parse_interaction_custom_id(custom_id: str | None) -> dict[str, typing.Any] | None:
|
||||
if not custom_id or not custom_id.startswith('lbi:'):
|
||||
return None
|
||||
parts = custom_id.split(':')
|
||||
if len(parts) == 4 and parts[1] and parts[2] == 'a' and parts[3].isdigit():
|
||||
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
|
||||
if len(parts) == 5 and parts[1] and parts[2] == 'f' and parts[3].isdigit() and parts[4].isdigit():
|
||||
return {
|
||||
'callback_token': parts[1],
|
||||
'field_ref': int(parts[3]),
|
||||
'option_ref': int(parts[4]),
|
||||
}
|
||||
raise ValueError('invalid Discord interaction custom_id')
|
||||
|
||||
|
||||
def _style(value: str) -> discord.ButtonStyle:
|
||||
if value == 'primary':
|
||||
return discord.ButtonStyle.primary
|
||||
if value == 'danger':
|
||||
return discord.ButtonStyle.danger
|
||||
return discord.ButtonStyle.secondary
|
||||
|
||||
|
||||
def build_interaction_view(request: dict[str, typing.Any], callback_token: str) -> discord.ui.View | None:
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
view = discord.ui.View(timeout=None)
|
||||
|
||||
if actions and not fields:
|
||||
for index, action in enumerate(actions):
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
view.add_item(
|
||||
discord.ui.Button(
|
||||
label=str(action.get('label') or action.get('id') or index + 1)[:80],
|
||||
style=_style(str(action.get('style') or 'default')),
|
||||
custom_id=f'lbi:{callback_token}:a:{index}',
|
||||
)
|
||||
)
|
||||
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
|
||||
field = fields[0]
|
||||
if field.get('type') != 'select':
|
||||
return None
|
||||
options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
for option_index, option in enumerate(options):
|
||||
if not isinstance(option, dict):
|
||||
continue
|
||||
view.add_item(
|
||||
discord.ui.Button(
|
||||
label=str(option.get('label') or option.get('value') or option_index + 1)[:80],
|
||||
style=discord.ButtonStyle.secondary,
|
||||
custom_id=f'lbi:{callback_token}:f:0:{option_index}',
|
||||
)
|
||||
)
|
||||
else:
|
||||
return None
|
||||
return view if view.children else None
|
||||
|
||||
|
||||
def _content(request: dict[str, typing.Any], *, rich: bool) -> str:
|
||||
parts = [str(request.get('title') or '').strip()]
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
parts.append(description)
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
if rich and len(fields) == 1 and isinstance(fields[0], dict):
|
||||
label = str(fields[0].get('label') or '').strip()
|
||||
if label:
|
||||
parts.append(label)
|
||||
if not rich:
|
||||
fallback = str(request.get('fallback_text') or '').strip()
|
||||
if fallback:
|
||||
parts.append(fallback)
|
||||
return '\n\n'.join(part for part in parts if part)[:2000]
|
||||
|
||||
|
||||
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
if not target_id:
|
||||
raise ValueError('interaction.request has no target_id')
|
||||
channel = await adapter._get_channel(target_id)
|
||||
view = build_interaction_view(request, callback_token)
|
||||
sent = await channel.send(content=_content(request, rich=view is not None), view=view)
|
||||
return {'ok': True, 'message_id': sent.id, 'rich': view is not None}
|
||||
|
||||
|
||||
def interaction_event_from_component(
|
||||
interaction: discord.Interaction,
|
||||
parsed: dict[str, typing.Any],
|
||||
) -> platform_events.PlatformSpecificEvent:
|
||||
if interaction.user is None or interaction.channel is None:
|
||||
raise ValueError('Discord interaction has no actor or channel')
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='discord',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
'actor_id': str(interaction.user.id),
|
||||
'target_type': 'group' if interaction.guild is not None else 'person',
|
||||
'target_id': str(interaction.channel.id),
|
||||
'display_text': 'submitted',
|
||||
},
|
||||
timestamp=time.time(),
|
||||
source_platform_object=interaction,
|
||||
)
|
||||
@@ -60,6 +60,7 @@ spec:
|
||||
- kick_member
|
||||
- leave_group
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
|
||||
platform_specific_apis:
|
||||
- action: get_channel
|
||||
|
||||
@@ -23,12 +23,16 @@ from lark_oapi.api.auth.v3 import (
|
||||
ResendAppTicketResponse,
|
||||
)
|
||||
from lark_oapi.api.cardkit.v1 import (
|
||||
Card,
|
||||
ContentCardElementRequest,
|
||||
ContentCardElementRequestBody,
|
||||
ContentCardElementResponse,
|
||||
CreateCardRequest,
|
||||
CreateCardRequestBody,
|
||||
CreateCardResponse,
|
||||
UpdateCardRequest,
|
||||
UpdateCardRequestBody,
|
||||
UpdateCardResponse,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateMessageRequest,
|
||||
@@ -52,6 +56,13 @@ from langbot.pkg.platform.adapters.lark.api_impl import LarkAPIMixin
|
||||
from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter
|
||||
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
|
||||
from langbot.pkg.platform.adapters.lark.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.lark.interaction import (
|
||||
acknowledge_interaction,
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_callback,
|
||||
interaction_event_from_webhook,
|
||||
send_interaction,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
@@ -101,6 +112,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = pydantic.Field(default_factory=dict)
|
||||
card_id_dict: dict[str, str] = pydantic.Field(default_factory=dict)
|
||||
card_sequence_dict: dict[str, int] = pydantic.Field(default_factory=dict)
|
||||
card_last_update_dict: dict[str, float] = pydantic.Field(default_factory=dict)
|
||||
closed_streaming_cards: set[str] = pydantic.Field(default_factory=set)
|
||||
pending_monitoring_msg: dict[str, str] = pydantic.Field(default_factory=dict)
|
||||
reply_to_monitoring_msg: dict[str, tuple[str, float]] = pydantic.Field(default_factory=dict)
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent] = pydantic.PrivateAttr(default_factory=dict)
|
||||
@@ -133,6 +147,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
cipher=cipher,
|
||||
listeners={},
|
||||
card_id_dict={},
|
||||
card_sequence_dict={},
|
||||
card_last_update_dict={},
|
||||
closed_streaming_cards=set(),
|
||||
pending_monitoring_msg={},
|
||||
reply_to_monitoring_msg={},
|
||||
event_loop=None,
|
||||
@@ -177,8 +194,17 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
'get_user_info',
|
||||
'get_file_url',
|
||||
'call_platform_api',
|
||||
'interaction.request',
|
||||
'interaction.acknowledge',
|
||||
]
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
@staticmethod
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
def build_api_client(self, config: dict) -> lark_oapi.Client:
|
||||
builder = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret'])
|
||||
if config.get('app_type', 'self') == 'isv':
|
||||
@@ -378,7 +404,15 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
async def create_card_id(self, message_id) -> str:
|
||||
card_data = {
|
||||
'schema': '2.0',
|
||||
'config': {'update_multi': True, 'streaming_mode': True},
|
||||
'config': {
|
||||
'update_multi': True,
|
||||
'streaming_mode': True,
|
||||
'streaming_config': {
|
||||
'print_step': {'default': 1},
|
||||
'print_frequency_ms': {'default': 70},
|
||||
'print_strategy': 'fast',
|
||||
},
|
||||
},
|
||||
'body': {
|
||||
'direction': 'vertical',
|
||||
'elements': [{'tag': 'markdown', 'content': '', 'element_id': 'streaming_txt'}],
|
||||
@@ -392,8 +426,49 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark create_card failed: {response.code} {response.msg}')
|
||||
self.card_id_dict[str(message_id)] = response.data.card_id
|
||||
return response.data.card_id
|
||||
card_id = str(response.data.card_id)
|
||||
self.card_id_dict[str(message_id)] = card_id
|
||||
self.card_sequence_dict[card_id] = 0
|
||||
self.card_last_update_dict.pop(card_id, None)
|
||||
self.closed_streaming_cards.discard(card_id)
|
||||
return card_id
|
||||
|
||||
def _next_card_sequence(self, card_id: str) -> int:
|
||||
current = self.card_sequence_dict.get(card_id, 0)
|
||||
sequence = current + 1
|
||||
self.card_sequence_dict[card_id] = sequence
|
||||
return sequence
|
||||
|
||||
@staticmethod
|
||||
def _streaming_mode_closed(response: ContentCardElementResponse) -> bool:
|
||||
return response.code == 300309 or 'streaming mode is closed' in str(response.msg).lower()
|
||||
|
||||
async def _replace_streaming_card(self, card_id: str, content: str) -> None:
|
||||
sequence = self._next_card_sequence(card_id)
|
||||
card_data = {
|
||||
'schema': '2.0',
|
||||
'config': {'update_multi': True},
|
||||
'body': {
|
||||
'direction': 'vertical',
|
||||
'elements': [{'tag': 'markdown', 'content': content}],
|
||||
},
|
||||
}
|
||||
request = (
|
||||
UpdateCardRequest.builder()
|
||||
.card_id(card_id)
|
||||
.request_body(
|
||||
UpdateCardRequestBody.builder()
|
||||
.sequence(sequence)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.card(Card.builder().type('card_json').data(json.dumps(card_data, ensure_ascii=False)).build())
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: UpdateCardResponse = await self.api_client.cardkit.v1.card.aupdate(request)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark card update failed: {response.code} {response.msg}')
|
||||
self.closed_streaming_cards.add(card_id)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
@@ -403,31 +478,53 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
if bot_message.msg_sequence % 8 != 0 and not is_final:
|
||||
card_id = self.card_id_dict[bot_message.resp_message_id]
|
||||
has_sent_update = self.card_sequence_dict.get(card_id, 0) > 0
|
||||
now = time.monotonic()
|
||||
last_update = self.card_last_update_dict.get(card_id, 0.0)
|
||||
is_high_frequency_chunk = now - last_update < 1.0
|
||||
if has_sent_update and is_high_frequency_chunk and bot_message.msg_sequence % 8 != 0 and not is_final:
|
||||
return
|
||||
text_elements, _ = await self.message_converter.yiri2target(message, self.api_client)
|
||||
content = '\n\n'.join(
|
||||
''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'})
|
||||
for paragraph in text_elements
|
||||
)
|
||||
request = (
|
||||
ContentCardElementRequest.builder()
|
||||
.card_id(self.card_id_dict[bot_message.resp_message_id])
|
||||
.element_id('streaming_txt')
|
||||
.request_body(
|
||||
ContentCardElementRequestBody.builder().content(content).sequence(bot_message.msg_sequence).build()
|
||||
cumulative_content = getattr(bot_message, 'all_content', None)
|
||||
if isinstance(cumulative_content, str) and cumulative_content:
|
||||
content = cumulative_content
|
||||
else:
|
||||
text_elements, _ = await self.message_converter.yiri2target(message, self.api_client)
|
||||
content = '\n\n'.join(
|
||||
''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'})
|
||||
for paragraph in text_elements
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(
|
||||
request, self.request_option(self._tenant_key_from_source(message_source))
|
||||
)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}')
|
||||
if card_id in self.closed_streaming_cards:
|
||||
await self._replace_streaming_card(card_id, content)
|
||||
else:
|
||||
sequence = self._next_card_sequence(card_id)
|
||||
request = (
|
||||
ContentCardElementRequest.builder()
|
||||
.card_id(card_id)
|
||||
.element_id('streaming_txt')
|
||||
.request_body(ContentCardElementRequestBody.builder().content(content).sequence(sequence).build())
|
||||
.build()
|
||||
)
|
||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(
|
||||
request, self.request_option(self._tenant_key_from_source(message_source))
|
||||
)
|
||||
if not response.success():
|
||||
if self._streaming_mode_closed(response):
|
||||
await self._replace_streaming_card(card_id, content)
|
||||
else:
|
||||
raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}')
|
||||
self.card_last_update_dict[card_id] = now
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
self.card_id_dict.pop(bot_message.resp_message_id, None)
|
||||
self.card_sequence_dict.pop(card_id, None)
|
||||
self.card_last_update_dict.pop(card_id, None)
|
||||
self.closed_streaming_cards.discard(card_id)
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self, params)
|
||||
if action == 'interaction.acknowledge':
|
||||
return await acknowledge_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
@@ -493,6 +590,10 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
await self._dispatch_eba_event(LarkEventConverter.bot_invited_to_group(data, chat_id))
|
||||
return {'code': 200, 'message': 'ok'}
|
||||
if event_type == 'card.action.trigger':
|
||||
interaction_event = interaction_event_from_webhook(data)
|
||||
if interaction_event is not None:
|
||||
await self._dispatch_eba_event(interaction_event)
|
||||
return self._interaction_action_response(interaction_event)
|
||||
feedback_event = self._feedback_event_from_webhook(data)
|
||||
if feedback_event and platform_events.FeedbackEvent in self.listeners:
|
||||
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
|
||||
@@ -571,6 +672,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
self._group_cache[str(event.group.id)] = event.group
|
||||
|
||||
def _handle_card_action_sync(self, event):
|
||||
interaction_event = interaction_event_from_callback(event)
|
||||
if interaction_event is not None:
|
||||
self._submit_coro(self._dispatch_eba_event(interaction_event))
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||
|
||||
return P2CardActionTriggerResponse(self._interaction_action_response(interaction_event))
|
||||
feedback_event = self._feedback_event_from_callback(event)
|
||||
if feedback_event and platform_events.FeedbackEvent in self.listeners:
|
||||
self._submit_coro(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||
@@ -578,6 +685,26 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
|
||||
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
|
||||
|
||||
@staticmethod
|
||||
def _interaction_action_response(event: platform_events.PlatformSpecificEvent) -> dict[str, typing.Any]:
|
||||
response: dict[str, typing.Any] = {
|
||||
'toast': {'type': 'success', 'content': 'Submitted / 已提交'},
|
||||
}
|
||||
if not event.data.get('cardkit'):
|
||||
response['card'] = {
|
||||
'type': 'raw',
|
||||
'data': {
|
||||
'config': {'wide_screen_mode': True},
|
||||
'elements': [
|
||||
{
|
||||
'tag': 'div',
|
||||
'text': {'tag': 'lark_md', 'content': '**Submitted / 已提交**'},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
return response
|
||||
|
||||
def _submit_coro(self, coro):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -681,4 +808,13 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
message_id = getattr(message, 'message_id', None)
|
||||
if message_id:
|
||||
return str(message_id)
|
||||
context = getattr(source_event, 'context', None) if source_event else None
|
||||
message_id = getattr(context, 'open_message_id', None)
|
||||
if message_id:
|
||||
return str(message_id)
|
||||
if isinstance(source, dict):
|
||||
source_event_data = source.get('event') if isinstance(source.get('event'), dict) else source
|
||||
context_data = source_event_data.get('context') if isinstance(source_event_data, dict) else None
|
||||
if isinstance(context_data, dict) and context_data.get('open_message_id'):
|
||||
return str(context_data['open_message_id'])
|
||||
raise RuntimeError('Lark message source does not contain message_id')
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
"""Lark card rendering and callback conversion for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from lark_oapi.api.cardkit.v1 import (
|
||||
Card,
|
||||
CreateCardRequest,
|
||||
CreateCardRequestBody,
|
||||
CreateCardResponse,
|
||||
UpdateCardRequest,
|
||||
UpdateCardRequestBody,
|
||||
UpdateCardResponse,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestBody,
|
||||
CreateMessageResponse,
|
||||
)
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
return {
|
||||
'field_types': ['text', 'textarea', 'number', 'select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def _callback_value(
|
||||
callback_token: str,
|
||||
reply_target: dict[str, typing.Any],
|
||||
**refs: typing.Any,
|
||||
) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'lbi': callback_token,
|
||||
't': str(reply_target.get('target_type') or ''),
|
||||
'ck': 1,
|
||||
**refs,
|
||||
}
|
||||
|
||||
|
||||
def _field_form_elements(
|
||||
field: dict[str, typing.Any],
|
||||
callback_token: str,
|
||||
reply_target: dict[str, typing.Any],
|
||||
) -> list[dict[str, typing.Any]] | None:
|
||||
field_type = str(field.get('type') or '')
|
||||
if field_type not in {'text', 'textarea', 'number', 'select'}:
|
||||
return None
|
||||
field_id = str(field.get('id') or '')
|
||||
if not field_id:
|
||||
return None
|
||||
component_name = 'lbi_field_0'
|
||||
label = str(field.get('label') or field_id)
|
||||
placeholder = str(
|
||||
field.get('placeholder')
|
||||
or ('Select an option / 请选择' if field_type == 'select' else 'Enter a value / 请输入')
|
||||
)
|
||||
required = bool(field.get('required'))
|
||||
if field_type == 'select':
|
||||
raw_options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
options = [
|
||||
{
|
||||
'text': {
|
||||
'tag': 'plain_text',
|
||||
'content': str(option.get('label') or option.get('value') or ''),
|
||||
},
|
||||
'value': str(option.get('value') or ''),
|
||||
}
|
||||
for option in raw_options
|
||||
if isinstance(option, dict) and option.get('value') not in (None, '')
|
||||
]
|
||||
if not options:
|
||||
return None
|
||||
field_element: dict[str, typing.Any] = {
|
||||
'tag': 'select_static',
|
||||
'name': component_name,
|
||||
'label': {'tag': 'plain_text', 'content': label},
|
||||
'placeholder': {'tag': 'plain_text', 'content': placeholder},
|
||||
'options': options,
|
||||
'width': 'fill',
|
||||
'required': required,
|
||||
}
|
||||
default = field.get('default')
|
||||
if default not in (None, ''):
|
||||
field_element['initial_option'] = str(default)
|
||||
else:
|
||||
default = field.get('default')
|
||||
field_element = {
|
||||
'tag': 'input',
|
||||
'name': component_name,
|
||||
'label': {'tag': 'plain_text', 'content': label},
|
||||
'placeholder': {'tag': 'plain_text', 'content': placeholder},
|
||||
'default_value': '' if default is None else str(default),
|
||||
'width': 'fill',
|
||||
'required': required,
|
||||
}
|
||||
if field_type == 'textarea':
|
||||
field_element.update(
|
||||
{
|
||||
'input_type': 'multiline_text',
|
||||
'rows': 3,
|
||||
'auto_resize': True,
|
||||
'max_rows': 6,
|
||||
}
|
||||
)
|
||||
|
||||
submit_value = _callback_value(
|
||||
callback_token,
|
||||
reply_target,
|
||||
fm={component_name: field_id},
|
||||
ft={field_id: field_type},
|
||||
)
|
||||
submit_button = {
|
||||
'tag': 'button',
|
||||
'name': 'lbi_submit',
|
||||
'text': {'tag': 'plain_text', 'content': 'Submit / 提交'},
|
||||
'type': 'primary',
|
||||
'width': 'fill',
|
||||
'form_action_type': 'submit',
|
||||
'behaviors': [{'type': 'callback', 'value': submit_value}],
|
||||
}
|
||||
form_elements: list[dict[str, typing.Any]] = [field_element, submit_button]
|
||||
if field_type == 'select':
|
||||
form_elements.insert(
|
||||
0,
|
||||
{
|
||||
'tag': 'markdown',
|
||||
'content': f'**{label}{"*" if required else ""}**',
|
||||
},
|
||||
)
|
||||
return [
|
||||
{
|
||||
'tag': 'form',
|
||||
'name': 'lbi_form',
|
||||
'direction': 'vertical',
|
||||
'vertical_spacing': '12px',
|
||||
'elements': form_elements,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _render_submitted_value(value: typing.Any) -> str:
|
||||
rendered = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value)
|
||||
rendered = rendered.strip().replace('\n', '\n ')
|
||||
return rendered if len(rendered) <= 2000 else rendered[:1997] + '...'
|
||||
|
||||
|
||||
def _submission_display_values(
|
||||
request: dict[str, typing.Any],
|
||||
submission: dict[str, typing.Any],
|
||||
) -> list[dict[str, str]]:
|
||||
display_values: list[dict[str, str]] = []
|
||||
values = submission.get('values') if isinstance(submission.get('values'), dict) else {}
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
for field in fields:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_id = str(field.get('id') or '')
|
||||
if field_id and field_id in values:
|
||||
display_value = {
|
||||
'label': str(field.get('label') or field_id),
|
||||
'value': _render_submitted_value(values[field_id]),
|
||||
}
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
display_value['description'] = description
|
||||
display_values.append(display_value)
|
||||
|
||||
action_id = submission.get('action_id')
|
||||
if action_id:
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
action_label = next(
|
||||
(
|
||||
str(action.get('label') or action_id)
|
||||
for action in actions
|
||||
if isinstance(action, dict) and str(action.get('id') or '') == str(action_id)
|
||||
),
|
||||
str(action_id),
|
||||
)
|
||||
display_values.append({'label': 'Action', 'value': action_label})
|
||||
return display_values
|
||||
|
||||
|
||||
def _stored_submitted_values(value: typing.Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
stored_values = [
|
||||
{
|
||||
'label': str(item['label'])[:200],
|
||||
'value': str(item['value'])[:2000],
|
||||
**({'description': str(item['description'])[:4000]} if item.get('description') is not None else {}),
|
||||
}
|
||||
for item in value[:50]
|
||||
if isinstance(item, dict) and item.get('label') is not None and item.get('value') is not None
|
||||
]
|
||||
return stored_values
|
||||
|
||||
|
||||
def _submitted_value_elements(values: list[dict[str, str]]) -> list[dict[str, typing.Any]]:
|
||||
elements: list[dict[str, typing.Any]] = []
|
||||
for item in values:
|
||||
lines = []
|
||||
description = str(item.get('description') or '').strip()
|
||||
if description:
|
||||
lines.append(description)
|
||||
lines.append(f'✅ {item["label"]}:{item["value"]}')
|
||||
elements.append({'tag': 'markdown', 'content': '\n'.join(lines)})
|
||||
return elements
|
||||
|
||||
|
||||
def build_interaction_card(
|
||||
request: dict[str, typing.Any],
|
||||
callback_token: str,
|
||||
reply_target: dict[str, typing.Any],
|
||||
submitted_values: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, typing.Any] | None:
|
||||
"""Build the Lark subset that maps to a single atomic submission."""
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
buttons: list[dict[str, typing.Any]] = []
|
||||
field_elements: list[dict[str, typing.Any]] | None = None
|
||||
|
||||
if actions and not fields:
|
||||
for index, action in enumerate(actions):
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
style = str(action.get('style') or 'default')
|
||||
buttons.append(
|
||||
{
|
||||
'tag': 'button',
|
||||
'text': {
|
||||
'tag': 'plain_text',
|
||||
'content': str(action.get('label') or action.get('id') or index + 1),
|
||||
},
|
||||
'type': 'primary' if style == 'primary' else 'danger' if style == 'danger' else 'default',
|
||||
'behaviors': [
|
||||
{
|
||||
'type': 'callback',
|
||||
'value': _callback_value(callback_token, reply_target, a=index),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
|
||||
field = fields[0]
|
||||
field_elements = _field_form_elements(field, callback_token, reply_target)
|
||||
if field_elements is None:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
if not buttons and not field_elements:
|
||||
return None
|
||||
elements: list[dict[str, typing.Any]] = []
|
||||
elements.extend(_submitted_value_elements(submitted_values or []))
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
elements.append({'tag': 'markdown', 'content': description})
|
||||
if field_elements:
|
||||
elements.extend(field_elements)
|
||||
else:
|
||||
elements.append(
|
||||
{
|
||||
'tag': 'column_set',
|
||||
'horizontal_spacing': '8px',
|
||||
'columns': [
|
||||
{
|
||||
'tag': 'column',
|
||||
'width': 'weighted',
|
||||
'weight': 1,
|
||||
'elements': [button],
|
||||
}
|
||||
for button in buttons
|
||||
],
|
||||
}
|
||||
)
|
||||
return {
|
||||
'schema': '2.0',
|
||||
'config': {'update_multi': True},
|
||||
'header': {
|
||||
'title': {'tag': 'plain_text', 'content': str(request.get('title') or '')},
|
||||
},
|
||||
'body': {'direction': 'vertical', 'elements': elements},
|
||||
}
|
||||
|
||||
|
||||
def build_submitted_card(
|
||||
request: dict[str, typing.Any],
|
||||
submission: dict[str, typing.Any],
|
||||
submitted_values: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Render a read-only snapshot after a user submits an interaction."""
|
||||
elements: list[dict[str, typing.Any]] = []
|
||||
all_submitted_values = [
|
||||
*(submitted_values or []),
|
||||
*_submission_display_values(request, submission),
|
||||
]
|
||||
elements.extend(_submitted_value_elements(all_submitted_values))
|
||||
return {
|
||||
'schema': '2.0',
|
||||
'config': {'update_multi': True},
|
||||
'header': {'title': {'tag': 'plain_text', 'content': str(request.get('title') or '')}},
|
||||
'body': {'direction': 'vertical', 'elements': elements},
|
||||
}
|
||||
|
||||
|
||||
async def _update_interaction_card(
|
||||
adapter: typing.Any,
|
||||
update_target: dict[str, typing.Any],
|
||||
card: dict[str, typing.Any],
|
||||
submitted_values: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
message_id = str(update_target.get('message_id') or '')
|
||||
card_id = str(update_target.get('card_id') or '')
|
||||
if not message_id or not card_id or update_target.get('rich') is not True:
|
||||
raise ValueError('Lark interaction update requires a CardKit target')
|
||||
persisted_sequence = int(update_target.get('sequence') or 0)
|
||||
current_sequence = int(adapter.card_sequence_dict.get(card_id, persisted_sequence))
|
||||
sequence = max(persisted_sequence, current_sequence) + 1
|
||||
request = (
|
||||
UpdateCardRequest.builder()
|
||||
.card_id(card_id)
|
||||
.request_body(
|
||||
UpdateCardRequestBody.builder()
|
||||
.sequence(sequence)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.card(Card.builder().type('card_json').data(json.dumps(card, ensure_ascii=False)).build())
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: UpdateCardResponse = await adapter.api_client.cardkit.v1.card.aupdate(request)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark CardKit interaction update failed: {response.code} {response.msg}')
|
||||
adapter.card_sequence_dict[card_id] = sequence
|
||||
result = {
|
||||
'ok': True,
|
||||
'message_id': message_id,
|
||||
'card_id': card_id,
|
||||
'sequence': sequence,
|
||||
'rich': True,
|
||||
'updated': True,
|
||||
}
|
||||
if submitted_values:
|
||||
result['submitted_values'] = submitted_values
|
||||
return result
|
||||
|
||||
|
||||
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
|
||||
update_target = params.get('update_target')
|
||||
submitted_values = _stored_submitted_values(
|
||||
update_target.get('submitted_values') if isinstance(update_target, dict) else None
|
||||
)
|
||||
card = build_interaction_card(request, callback_token, reply_target, submitted_values)
|
||||
if card is None:
|
||||
fallback = str(request.get('fallback_text') or '')
|
||||
result = await adapter.send_message(
|
||||
str(reply_target.get('target_type') or ''),
|
||||
str(reply_target.get('target_id') or ''),
|
||||
adapter._plain_message(fallback),
|
||||
)
|
||||
return {'ok': True, 'message_id': result.message_id, 'rich': False}
|
||||
|
||||
if isinstance(update_target, dict):
|
||||
return await _update_interaction_card(adapter, update_target, card, submitted_values)
|
||||
|
||||
target_type = str(reply_target.get('target_type') or '')
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
if target_type not in {'group', 'person'} or not target_id:
|
||||
raise ValueError('interaction.request has an invalid reply target')
|
||||
card_request = (
|
||||
CreateCardRequest.builder()
|
||||
.request_body(
|
||||
CreateCardRequestBody.builder().type('card_json').data(json.dumps(card, ensure_ascii=False)).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
card_response: CreateCardResponse = await adapter.api_client.cardkit.v1.card.acreate(card_request)
|
||||
if not card_response.success():
|
||||
raise RuntimeError(f'Lark CardKit interaction create failed: {card_response.code} {card_response.msg}')
|
||||
card_id = str(getattr(card_response.data, 'card_id', '') or '')
|
||||
if not card_id:
|
||||
raise RuntimeError('Lark CardKit interaction create returned no card_id')
|
||||
|
||||
message_request = (
|
||||
CreateMessageRequest.builder()
|
||||
.receive_id_type('chat_id' if target_type == 'group' else 'open_id')
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(target_id)
|
||||
.content(json.dumps({'type': 'card', 'data': {'card_id': card_id}}, ensure_ascii=False))
|
||||
.msg_type('interactive')
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: CreateMessageResponse = await adapter.api_client.im.v1.message.acreate(message_request)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark interaction send failed: {response.code} {response.msg}')
|
||||
adapter.card_sequence_dict[card_id] = 0
|
||||
return {
|
||||
'ok': True,
|
||||
'message_id': getattr(response.data, 'message_id', ''),
|
||||
'card_id': card_id,
|
||||
'sequence': 0,
|
||||
'rich': True,
|
||||
}
|
||||
|
||||
|
||||
async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
submission = params.get('submission')
|
||||
update_target = params.get('update_target')
|
||||
if not isinstance(request, dict) or not isinstance(submission, dict) or not isinstance(update_target, dict):
|
||||
raise ValueError('interaction.acknowledge requires request, submission, and update_target')
|
||||
submitted_values = [
|
||||
*_stored_submitted_values(update_target.get('submitted_values')),
|
||||
*_submission_display_values(request, submission),
|
||||
]
|
||||
return await _update_interaction_card(
|
||||
adapter,
|
||||
update_target,
|
||||
build_submitted_card(request, submission, _stored_submitted_values(update_target.get('submitted_values'))),
|
||||
submitted_values,
|
||||
)
|
||||
|
||||
|
||||
def _mapping(value: typing.Any) -> dict[str, typing.Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _action_attr(action: typing.Any, name: str) -> typing.Any:
|
||||
return action.get(name) if isinstance(action, dict) else getattr(action, name, None)
|
||||
|
||||
|
||||
def _coerce_field_value(value: typing.Any, field_type: str) -> typing.Any:
|
||||
if isinstance(value, dict):
|
||||
value = value.get('value') if value.get('value') is not None else value.get('option')
|
||||
if field_type != 'number' or isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError:
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def _form_submission_values(action: typing.Any, payload: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
field_map = payload.get('fm') if isinstance(payload.get('fm'), dict) else {}
|
||||
field_types = payload.get('ft') if isinstance(payload.get('ft'), dict) else {}
|
||||
if not field_map:
|
||||
return {}
|
||||
form_value = _mapping(_action_attr(action, 'form_value'))
|
||||
if not form_value:
|
||||
for key in ('form_value', 'formValue', 'form_values', 'formValues'):
|
||||
form_value = _mapping(payload.get(key))
|
||||
if form_value:
|
||||
break
|
||||
if not form_value:
|
||||
action_name = _action_attr(action, 'name')
|
||||
input_value = _action_attr(action, 'input_value')
|
||||
option_value = _action_attr(action, 'option')
|
||||
if action_name and input_value not in (None, ''):
|
||||
form_value = {str(action_name): input_value}
|
||||
elif action_name and option_value not in (None, ''):
|
||||
form_value = {str(action_name): option_value}
|
||||
values: dict[str, typing.Any] = {}
|
||||
for component_name, value in form_value.items():
|
||||
field_id = field_map.get(component_name)
|
||||
if field_id and value not in (None, ''):
|
||||
values[str(field_id)] = _coerce_field_value(value, str(field_types.get(str(field_id)) or ''))
|
||||
return values
|
||||
|
||||
|
||||
def _event_from_parts(
|
||||
*,
|
||||
raw: typing.Any,
|
||||
action: typing.Any,
|
||||
actor_id: typing.Any,
|
||||
chat_id: typing.Any,
|
||||
message_id: typing.Any,
|
||||
) -> platform_events.PlatformSpecificEvent | None:
|
||||
payload = _mapping(_action_attr(action, 'value'))
|
||||
callback_token = str(payload.get('lbi') or '')
|
||||
if not callback_token:
|
||||
return None
|
||||
target_type = str(payload.get('t') or '')
|
||||
target_id = str(chat_id or '') if target_type == 'group' else str(actor_id or '')
|
||||
data: dict[str, typing.Any] = {
|
||||
'callback_token': callback_token,
|
||||
'actor_id': str(actor_id or ''),
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'display_text': 'submitted',
|
||||
}
|
||||
if payload.get('ck') == 1:
|
||||
data['cardkit'] = True
|
||||
if message_id:
|
||||
data['message_id'] = str(message_id)
|
||||
if payload.get('a') is not None:
|
||||
data['action_ref'] = payload['a']
|
||||
if payload.get('f') is not None or payload.get('o') is not None:
|
||||
data['field_ref'] = payload.get('f')
|
||||
data['option_ref'] = payload.get('o')
|
||||
if payload.get('fm'):
|
||||
data['values'] = _form_submission_values(action, payload)
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='lark-eba',
|
||||
action='interaction.submitted',
|
||||
data=data,
|
||||
timestamp=time.time(),
|
||||
source_platform_object=raw,
|
||||
)
|
||||
|
||||
|
||||
def interaction_event_from_callback(event: typing.Any) -> platform_events.PlatformSpecificEvent | None:
|
||||
action = getattr(getattr(event, 'event', None), 'action', None)
|
||||
operator = getattr(getattr(event, 'event', None), 'operator', None)
|
||||
context = getattr(getattr(event, 'event', None), 'context', None)
|
||||
return _event_from_parts(
|
||||
raw=event,
|
||||
action=action,
|
||||
actor_id=getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None),
|
||||
chat_id=getattr(context, 'open_chat_id', None),
|
||||
message_id=getattr(context, 'open_message_id', None),
|
||||
)
|
||||
|
||||
|
||||
def interaction_event_from_webhook(data: dict[str, typing.Any]) -> platform_events.PlatformSpecificEvent | None:
|
||||
event = data.get('event') if isinstance(data.get('event'), dict) else {}
|
||||
action = event.get('action') if isinstance(event.get('action'), dict) else {}
|
||||
operator = event.get('operator') if isinstance(event.get('operator'), dict) else {}
|
||||
context = event.get('context') if isinstance(event.get('context'), dict) else {}
|
||||
return _event_from_parts(
|
||||
raw=data,
|
||||
action=action,
|
||||
actor_id=operator.get('open_id') or operator.get('user_id'),
|
||||
chat_id=context.get('open_chat_id'),
|
||||
message_id=context.get('open_message_id'),
|
||||
)
|
||||
@@ -164,6 +164,8 @@ spec:
|
||||
- get_user_info
|
||||
- get_file_url
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
- interaction.acknowledge
|
||||
|
||||
platform_specific_apis:
|
||||
- action: check_tenant_access_token
|
||||
|
||||
@@ -23,6 +23,11 @@ from langbot.pkg.platform.adapters.qqofficial.event_converter import (
|
||||
)
|
||||
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
|
||||
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.qqofficial.interaction import (
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_payload,
|
||||
send_interaction,
|
||||
)
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
@@ -117,8 +122,16 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
'get_group_member_list',
|
||||
'get_group_member_info',
|
||||
'call_platform_api',
|
||||
'interaction.request',
|
||||
]
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
@staticmethod
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -149,6 +162,8 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw})
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
@@ -296,6 +311,15 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
):
|
||||
self.bot.on_message(event_type)(self._handle_native_event)
|
||||
|
||||
@self.bot.on_interaction()
|
||||
async def on_interaction(event_data: dict, ws_event_id: str | None):
|
||||
interaction_id = str(event_data.get('id') or '')
|
||||
if interaction_id:
|
||||
await self.bot.ack_interaction(interaction_id)
|
||||
event = interaction_event_from_payload(event_data)
|
||||
if event is not None:
|
||||
await self._dispatch_eba_event(event)
|
||||
|
||||
async def _handle_native_event(self, event: QQOfficialEvent):
|
||||
self.bot_account_id = self.config.get('appid', self.bot_account_id)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""QQ Official keyboard rendering and interaction callback conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
return {
|
||||
'field_types': ['select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def _buttons(request: dict[str, typing.Any], callback_token: str) -> list[tuple[str, str, int]]:
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
if actions and not fields:
|
||||
return [
|
||||
(
|
||||
str(action.get('label') or action.get('id') or index + 1),
|
||||
f'lbi:{callback_token}:a:{index}',
|
||||
1 if action.get('style') == 'primary' else 0,
|
||||
)
|
||||
for index, action in enumerate(actions[:25])
|
||||
if isinstance(action, dict)
|
||||
]
|
||||
if len(fields) == 1 and not actions and isinstance(fields[0], dict):
|
||||
field = fields[0]
|
||||
if field.get('type') != 'select':
|
||||
return []
|
||||
options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
return [
|
||||
(
|
||||
str(option.get('label') or option.get('value') or index + 1),
|
||||
f'lbi:{callback_token}:f:0:{index}',
|
||||
0,
|
||||
)
|
||||
for index, option in enumerate(options[:25])
|
||||
if isinstance(option, dict)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def build_interaction_keyboard(request: dict[str, typing.Any], callback_token: str) -> dict[str, typing.Any] | None:
|
||||
buttons = _buttons(request, callback_token)
|
||||
if not buttons:
|
||||
return None
|
||||
rows = []
|
||||
for start in range(0, len(buttons), 2):
|
||||
rows.append(
|
||||
{
|
||||
'buttons': [
|
||||
{
|
||||
'id': str(start + offset + 1),
|
||||
'render_data': {
|
||||
'label': label,
|
||||
'visited_label': f'✓ {label}',
|
||||
'style': style,
|
||||
},
|
||||
'action': {
|
||||
'type': 1,
|
||||
'permission': {'type': 2},
|
||||
'data': callback_data,
|
||||
'unsupport_tips': 'Please update QQ to use this action.',
|
||||
},
|
||||
}
|
||||
for offset, (label, callback_data, style) in enumerate(buttons[start : start + 2])
|
||||
]
|
||||
}
|
||||
)
|
||||
return {'content': {'rows': rows}}
|
||||
|
||||
|
||||
def _text(request: dict[str, typing.Any], rich: bool) -> str:
|
||||
parts = [str(request.get('title') or '').strip()]
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
parts.append(description)
|
||||
if not rich:
|
||||
fallback = str(request.get('fallback_text') or '').strip()
|
||||
if fallback:
|
||||
parts.append(fallback)
|
||||
return '\n\n'.join(part for part in parts if part)
|
||||
|
||||
|
||||
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
target_type = str(reply_target.get('target_type') or '')
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
keyboard = build_interaction_keyboard(request, callback_token)
|
||||
if keyboard is None or target_type not in {'person', 'group'}:
|
||||
result = await adapter.send_message(
|
||||
target_type,
|
||||
target_id,
|
||||
adapter._plain_message(_text(request, False)),
|
||||
)
|
||||
return {'ok': True, 'message_id': result.message_id, 'rich': False}
|
||||
raw = await adapter.bot.send_markdown_keyboard(
|
||||
target_type='c2c' if target_type == 'person' else 'group',
|
||||
target_id=target_id,
|
||||
markdown_content=_text(request, True),
|
||||
keyboard=keyboard,
|
||||
msg_id=reply_target.get('message_id'),
|
||||
)
|
||||
return {'ok': True, 'message_id': raw.get('id') if isinstance(raw, dict) else None, 'rich': True}
|
||||
|
||||
|
||||
def parse_callback_data(value: str) -> dict[str, typing.Any]:
|
||||
parts = value.split(':')
|
||||
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
|
||||
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
|
||||
if (
|
||||
len(parts) == 5
|
||||
and parts[0] == 'lbi'
|
||||
and parts[1]
|
||||
and parts[2] == 'f'
|
||||
and parts[3].isdigit()
|
||||
and parts[4].isdigit()
|
||||
):
|
||||
return {
|
||||
'callback_token': parts[1],
|
||||
'field_ref': int(parts[3]),
|
||||
'option_ref': int(parts[4]),
|
||||
}
|
||||
raise ValueError('invalid QQ interaction callback data')
|
||||
|
||||
|
||||
def interaction_event_from_payload(
|
||||
event_data: dict[str, typing.Any],
|
||||
) -> platform_events.PlatformSpecificEvent | None:
|
||||
resolved = (event_data.get('data') or {}).get('resolved') or {}
|
||||
callback_data = str(resolved.get('button_data') or '')
|
||||
if not callback_data.startswith('lbi:'):
|
||||
return None
|
||||
parsed = parse_callback_data(callback_data)
|
||||
chat_type = event_data.get('chat_type')
|
||||
if chat_type == 2 or event_data.get('user_openid'):
|
||||
target_type = 'person'
|
||||
target_id = str(event_data.get('user_openid') or '')
|
||||
elif chat_type == 1 or event_data.get('group_openid'):
|
||||
target_type = 'group'
|
||||
target_id = str(event_data.get('group_openid') or '')
|
||||
elif chat_type == 0 or event_data.get('channel_id'):
|
||||
target_type = 'group'
|
||||
target_id = str(event_data.get('channel_id') or '')
|
||||
else:
|
||||
raise ValueError('QQ interaction callback has no target')
|
||||
actor_id = str(
|
||||
event_data.get('member_openid')
|
||||
or event_data.get('user_openid')
|
||||
or ((event_data.get('member') or {}).get('user') or {}).get('id')
|
||||
or ''
|
||||
)
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='qqofficial-eba',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
'actor_id': actor_id,
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'display_text': 'submitted',
|
||||
},
|
||||
timestamp=time.time(),
|
||||
source_platform_object=event_data,
|
||||
)
|
||||
@@ -107,6 +107,7 @@ spec:
|
||||
- get_group_member_list
|
||||
- get_group_member_info
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
|
||||
platform_specific_apis:
|
||||
- action: check_access_token
|
||||
|
||||
@@ -34,6 +34,12 @@ from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMes
|
||||
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter, LegacyEventConverter
|
||||
from langbot.pkg.platform.adapters.telegram.api_impl import TelegramAPIMixin
|
||||
from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.telegram.interaction import (
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_update,
|
||||
parse_interaction_callback,
|
||||
send_interaction,
|
||||
)
|
||||
|
||||
|
||||
class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
|
||||
@@ -79,6 +85,23 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
return
|
||||
|
||||
try:
|
||||
if update.callback_query:
|
||||
try:
|
||||
interaction_callback = parse_interaction_callback(update.callback_query.data)
|
||||
except ValueError:
|
||||
await update.callback_query.answer(text='Invalid or expired action', show_alert=True)
|
||||
await self.logger.warning('Rejected malformed Telegram interaction callback')
|
||||
return
|
||||
if interaction_callback is not None:
|
||||
await update.callback_query.answer()
|
||||
event = interaction_event_from_update(update, interaction_callback)
|
||||
await self._dispatch_eba_event(event)
|
||||
try:
|
||||
await update.callback_query.edit_message_reply_markup(reply_markup=None)
|
||||
except Exception:
|
||||
await self.logger.warning('Failed to clear Telegram interaction buttons')
|
||||
return
|
||||
|
||||
# Legacy event type callbacks (compat with existing botmgr FriendMessage / GroupMessage listeners)
|
||||
if update.message and (
|
||||
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
|
||||
@@ -176,8 +199,12 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
'kick_member',
|
||||
'leave_group',
|
||||
'call_platform_api',
|
||||
'interaction.request',
|
||||
]
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
# ---- Message Send / Reply (preserving original logic) ----
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
@@ -410,6 +437,8 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
params: dict = {},
|
||||
) -> dict:
|
||||
"""Call a Telegram-specific platform API."""
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self.bot, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
@@ -406,10 +406,11 @@ class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
source_platform_object=event,
|
||||
)
|
||||
else:
|
||||
sender = event.message.from_user
|
||||
return legacy_events.GroupMessage(
|
||||
sender=legacy_entities.GroupMember(
|
||||
id=event.effective_chat.id,
|
||||
member_name=event.effective_chat.title,
|
||||
id=sender.id if sender else '',
|
||||
member_name=sender.first_name if sender else '',
|
||||
permission=legacy_entities.Permission.Member,
|
||||
group=legacy_entities.Group(
|
||||
id=event.effective_chat.id,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Telegram rendering and callback conversion for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
import telegram
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
|
||||
|
||||
INTERACTION_CALLBACK_PREFIX = 'lbi'
|
||||
|
||||
|
||||
def parse_interaction_callback(data: str | None) -> dict[str, typing.Any] | None:
|
||||
"""Parse a compact interaction callback without trusting platform values."""
|
||||
if not data or not data.startswith(f'{INTERACTION_CALLBACK_PREFIX}:'):
|
||||
return None
|
||||
parts = data.split(':')
|
||||
if len(parts) == 4 and parts[2] == 'a':
|
||||
token, action_ref = parts[1], parts[3]
|
||||
if token and action_ref.isdigit():
|
||||
return {'callback_token': token, 'action_ref': int(action_ref)}
|
||||
if len(parts) == 5 and parts[2] == 'f':
|
||||
token, field_ref, option_ref = parts[1], parts[3], parts[4]
|
||||
if token and field_ref.isdigit() and option_ref.isdigit():
|
||||
return {
|
||||
'callback_token': token,
|
||||
'field_ref': int(field_ref),
|
||||
'option_ref': int(option_ref),
|
||||
}
|
||||
raise ValueError('invalid Telegram interaction callback data')
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
"""Return the structured interaction subset Telegram can render natively."""
|
||||
return {
|
||||
'field_types': ['select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def _callback_data(callback_token: str, *parts: str | int) -> str:
|
||||
value = ':'.join((INTERACTION_CALLBACK_PREFIX, callback_token, *(str(part) for part in parts)))
|
||||
if len(value.encode('utf-8')) > 64:
|
||||
raise ValueError('Telegram interaction callback exceeds 64 bytes')
|
||||
return value
|
||||
|
||||
|
||||
def _build_keyboard(request: dict[str, typing.Any], callback_token: str) -> telegram.InlineKeyboardMarkup | None:
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
|
||||
if actions and not fields:
|
||||
rows = [
|
||||
[
|
||||
telegram.InlineKeyboardButton(
|
||||
str(action.get('label') or action.get('id') or index + 1),
|
||||
callback_data=_callback_data(callback_token, 'a', index),
|
||||
)
|
||||
]
|
||||
for index, action in enumerate(actions)
|
||||
if isinstance(action, dict)
|
||||
]
|
||||
return telegram.InlineKeyboardMarkup(rows) if rows else None
|
||||
|
||||
if len(fields) == 1 and not actions:
|
||||
field = fields[0]
|
||||
if not isinstance(field, dict) or field.get('type') != 'select':
|
||||
return None
|
||||
options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
rows = [
|
||||
[
|
||||
telegram.InlineKeyboardButton(
|
||||
str(option.get('label') or option.get('value') or option_index + 1),
|
||||
callback_data=_callback_data(callback_token, 'f', 0, option_index),
|
||||
)
|
||||
]
|
||||
for option_index, option in enumerate(options)
|
||||
if isinstance(option, dict)
|
||||
]
|
||||
return telegram.InlineKeyboardMarkup(rows) if rows else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _message_text(request: dict[str, typing.Any], *, rich: bool) -> str:
|
||||
parts = [str(request.get('title') or '').strip()]
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
parts.append(description)
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
if rich and len(fields) == 1 and isinstance(fields[0], dict):
|
||||
label = str(fields[0].get('label') or '').strip()
|
||||
if label:
|
||||
parts.append(label)
|
||||
if not rich:
|
||||
fallback = str(request.get('fallback_text') or '').strip()
|
||||
if fallback:
|
||||
parts.append(fallback)
|
||||
return '\n\n'.join(part for part in parts if part)
|
||||
|
||||
|
||||
async def send_interaction(bot: telegram.Bot, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
"""Render a supported interaction or its required plain-text fallback."""
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
if not target_id:
|
||||
raise ValueError('interaction.request has no target_id')
|
||||
chat_id_text, _, thread_id_text = target_id.partition('#')
|
||||
chat_id: int | str = int(chat_id_text) if chat_id_text.lstrip('-').isdigit() else chat_id_text
|
||||
message_thread_id = int(thread_id_text) if thread_id_text.isdigit() else None
|
||||
keyboard = _build_keyboard(request, callback_token)
|
||||
send_args: dict[str, typing.Any] = {
|
||||
'chat_id': chat_id,
|
||||
'text': _message_text(request, rich=keyboard is not None),
|
||||
}
|
||||
if message_thread_id is not None:
|
||||
send_args['message_thread_id'] = message_thread_id
|
||||
if keyboard is not None:
|
||||
send_args['reply_markup'] = keyboard
|
||||
|
||||
sent = await bot.send_message(**send_args)
|
||||
return {'ok': True, 'message_id': getattr(sent, 'message_id', None), 'rich': keyboard is not None}
|
||||
|
||||
|
||||
def interaction_event_from_update(
|
||||
update: telegram.Update,
|
||||
parsed: dict[str, typing.Any],
|
||||
) -> platform_events.PlatformSpecificEvent:
|
||||
"""Convert a trusted callback shape into the Host interaction event."""
|
||||
query = update.callback_query
|
||||
if query is None or query.message is None or query.from_user is None:
|
||||
raise ValueError('Telegram interaction callback has no message or actor')
|
||||
message = query.message
|
||||
target_type = 'person' if message.chat.type == 'private' else 'group'
|
||||
target_id = str(message.chat.id)
|
||||
if message.message_thread_id:
|
||||
target_id = f'{target_id}#{message.message_thread_id}'
|
||||
|
||||
data = {
|
||||
**parsed,
|
||||
'actor_id': str(query.from_user.id),
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'display_text': 'submitted',
|
||||
}
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
timestamp=time.time(),
|
||||
adapter_name='telegram',
|
||||
action='interaction.submitted',
|
||||
data=data,
|
||||
source_platform_object=update,
|
||||
)
|
||||
@@ -72,6 +72,7 @@ spec:
|
||||
- kick_member
|
||||
- leave_group
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
|
||||
platform_specific_apis:
|
||||
- action: pin_message
|
||||
|
||||
@@ -14,6 +14,11 @@ from langbot.pkg.platform.adapters.wecombot.api_impl import WecomBotAPIMixin
|
||||
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
|
||||
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
|
||||
from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.wecombot.interaction import (
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_native,
|
||||
send_interaction,
|
||||
)
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
@@ -100,7 +105,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return [
|
||||
apis = [
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'get_message',
|
||||
@@ -111,6 +116,16 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
'get_group_member_list',
|
||||
'call_platform_api',
|
||||
]
|
||||
if not self.config.get('enable-webhook', False) and hasattr(self.bot, 'send_template_card'):
|
||||
apis.append('interaction.request')
|
||||
return apis
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
@staticmethod
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -163,6 +178,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
return self.config.get('enable-stream-reply', True)
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request' and 'interaction.request' in self.get_supported_apis():
|
||||
return await send_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
@@ -229,6 +246,15 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
self.bot.on_feedback()(self._handle_feedback)
|
||||
if hasattr(self.bot, 'on_message'):
|
||||
self.bot.on_message('event')(self._handle_native_event)
|
||||
self.bot.on_message('template_card_event')(self._handle_interaction_event)
|
||||
|
||||
async def _handle_interaction_event(self, event: WecomBotEvent):
|
||||
try:
|
||||
interaction_event = interaction_event_from_native(event)
|
||||
if interaction_event is not None:
|
||||
await self._dispatch_eba_event(interaction_event)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in WeComBot interaction callback: {traceback.format_exc()}')
|
||||
|
||||
async def _handle_native_event(self, event: WecomBotEvent):
|
||||
try:
|
||||
@@ -277,6 +303,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
|
||||
def _cleanup_stream_mapping(self):
|
||||
now = time.time()
|
||||
expired = [key for key, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL]
|
||||
expired = [
|
||||
key for key, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL
|
||||
]
|
||||
for key in expired:
|
||||
del self._stream_to_monitoring_msg[key]
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""WeCom template-card rendering and callbacks for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
return {
|
||||
'field_types': ['select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def _button_style(style: str) -> int:
|
||||
if style == 'primary':
|
||||
return 1
|
||||
if style == 'danger':
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
def build_interaction_card(request: dict[str, typing.Any], callback_token: str) -> dict[str, typing.Any] | None:
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
buttons: list[dict[str, typing.Any]] = []
|
||||
if actions and not fields:
|
||||
buttons = [
|
||||
{
|
||||
'text': str(action.get('label') or action.get('id') or index + 1),
|
||||
'style': _button_style(str(action.get('style') or 'default')),
|
||||
'key': f'lbi:{callback_token}:a:{index}',
|
||||
}
|
||||
for index, action in enumerate(actions[:6])
|
||||
if isinstance(action, dict)
|
||||
]
|
||||
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
|
||||
field = fields[0]
|
||||
if field.get('type') != 'select':
|
||||
return None
|
||||
options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
buttons = [
|
||||
{
|
||||
'text': str(option.get('label') or option.get('value') or index + 1),
|
||||
'style': 0,
|
||||
'key': f'lbi:{callback_token}:f:0:{index}',
|
||||
}
|
||||
for index, option in enumerate(options[:6])
|
||||
if isinstance(option, dict)
|
||||
]
|
||||
if not buttons:
|
||||
return None
|
||||
description = str(request.get('description') or '').strip()
|
||||
if len(fields) == 1 and isinstance(fields[0], dict):
|
||||
label = str(fields[0].get('label') or '').strip()
|
||||
description = '\n\n'.join(part for part in (description, label) if part)
|
||||
return {
|
||||
'msgtype': 'template_card',
|
||||
'template_card': {
|
||||
'card_type': 'button_interaction',
|
||||
'main_title': {'title': str(request.get('title') or '')},
|
||||
'sub_title_text': description,
|
||||
'button_list': buttons,
|
||||
'task_id': f'lbi-{uuid.uuid4().hex}',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
target_type = str(reply_target.get('target_type') or '')
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
card = build_interaction_card(request, callback_token)
|
||||
if card is None:
|
||||
fallback = '\n\n'.join(
|
||||
part
|
||||
for part in (
|
||||
str(request.get('title') or '').strip(),
|
||||
str(request.get('description') or '').strip(),
|
||||
str(request.get('fallback_text') or '').strip(),
|
||||
)
|
||||
if part
|
||||
)
|
||||
result = await adapter.send_message(target_type, target_id, adapter._plain_message(fallback))
|
||||
return {'ok': True, 'message_id': result.message_id, 'rich': False}
|
||||
raw = await adapter.bot.send_template_card(target_id, card)
|
||||
return {'ok': True, 'message_id': None, 'rich': True, 'raw': raw}
|
||||
|
||||
|
||||
def _template_card_event(event: WecomBotEvent) -> dict[str, typing.Any]:
|
||||
wrapper = event.get('event') or {}
|
||||
if not isinstance(wrapper, dict):
|
||||
return {}
|
||||
for key in ('template_card_event', 'templateCardEvent', 'TemplateCardEvent'):
|
||||
value = wrapper.get(key)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return wrapper
|
||||
|
||||
|
||||
def _callback_key(payload: dict[str, typing.Any]) -> str:
|
||||
value = payload.get('EventKey') or payload.get('event_key') or payload.get('eventKey') or payload.get('key') or ''
|
||||
if value:
|
||||
return str(value)
|
||||
for button_name in ('button', 'Button', 'selected_button', 'selectedButton'):
|
||||
button = payload.get(button_name)
|
||||
if not isinstance(button, dict):
|
||||
continue
|
||||
value = button.get('key') or button.get('Key') or button.get('event_key') or button.get('EventKey') or ''
|
||||
if value:
|
||||
return str(value)
|
||||
return ''
|
||||
|
||||
|
||||
def parse_callback_key(value: str) -> dict[str, typing.Any]:
|
||||
parts = value.split(':')
|
||||
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
|
||||
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
|
||||
if (
|
||||
len(parts) == 5
|
||||
and parts[0] == 'lbi'
|
||||
and parts[1]
|
||||
and parts[2] == 'f'
|
||||
and parts[3].isdigit()
|
||||
and parts[4].isdigit()
|
||||
):
|
||||
return {
|
||||
'callback_token': parts[1],
|
||||
'field_ref': int(parts[3]),
|
||||
'option_ref': int(parts[4]),
|
||||
}
|
||||
raise ValueError('invalid WeCom interaction callback key')
|
||||
|
||||
|
||||
def interaction_event_from_native(
|
||||
event: WecomBotEvent,
|
||||
) -> platform_events.PlatformSpecificEvent | None:
|
||||
callback_key = _callback_key(_template_card_event(event))
|
||||
if not callback_key.startswith('lbi:'):
|
||||
return None
|
||||
parsed = parse_callback_key(callback_key)
|
||||
target_type = 'group' if event.type == 'group' or event.chatid else 'person'
|
||||
target_id = str(event.chatid or event.userid or '')
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='wecombot-eba',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
'actor_id': str(event.userid or ''),
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'display_text': 'submitted',
|
||||
},
|
||||
timestamp=time.time(),
|
||||
source_platform_object=event,
|
||||
)
|
||||
@@ -143,6 +143,7 @@ spec:
|
||||
- get_group_member_info
|
||||
- get_group_member_list
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
|
||||
platform_specific_apis:
|
||||
- action: is_websocket_mode
|
||||
|
||||
Reference in New Issue
Block a user