diff --git a/docs/agent-runner-pluginization/PROTOCOL_V1.md b/docs/agent-runner-pluginization/PROTOCOL_V1.md index 1b6c8aa1c..74decff77 100644 --- a/docs/agent-runner-pluginization/PROTOCOL_V1.md +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -103,6 +103,7 @@ class AgentRunnerCapabilities(BaseModel): skill_authoring: bool = False interrupt: bool = False steering: bool = False + interactions: bool = False model_config = ConfigDict(extra="forbid") ``` @@ -114,6 +115,7 @@ class AgentRunnerCapabilities(BaseModel): - `skill_authoring`:(降级为便捷开关,非访问硬前提)声明该 runner 期望使用 LangBot skill 工具链。skill 本身通过**统一 tool 授权**获得——发现走 `list_skills` / `langbot_list_assets`,激活/注册走 `activate` / `register_skill`,操作走 native exec/read/write,全部计入 `resource_policy.allowed_tool_names`。该 capability 仅作为「一键授权这组 skill tool + sandbox」的便捷开关,不再单独决定 skill 是否可用。 - `interrupt`: runner 支持取消或中断。 - `steering`: runner 支持在 turn 边界通过 Host pull API 消费同 conversation 在途追加消息。 +- `interactions`: runner 可以请求 Host 展示结构化交互,并处理后续 `interaction.submitted` 事件。 Capabilities 字段全部是 `bool`,未知 key 禁止进入 typed manifest。早期草案里的上下文/会话类 capability 已删除;对应语义由 event-first context 和 runner-owned context 原则表达。 @@ -128,11 +130,15 @@ class AgentRunnerPermissions(BaseModel): events: list[Literal["get", "page"]] = [] storage: list[Literal["plugin", "workspace"]] = [] files: list[Literal["config", "knowledge"]] = [] + interactions: list[Literal["request"]] = [] model_config = ConfigDict(extra="forbid") ``` -平台动作执行不属于当前 permissions。Platform action executor / EBA action 分支落地前,runner 只能返回 `action.requested` telemetry,Host 不执行平台动作。 +通用交互投递是当前唯一允许执行的 `action.requested` 白名单动作。Runner 必须同时声明 +`capabilities.interactions=true` 和 `permissions.interactions=["request"]`,Host 还必须将其与 +当前 binding delivery policy、run authorization snapshot 和 adapter delivery capability 求交。 +其它平台动作仍不属于当前 permissions,Host 收到后只记录 telemetry,不得执行。 Runner 实际可用 LangBot 资源来自 Host 在 run 前冻结的授权快照: @@ -275,11 +281,13 @@ class AgentInput(BaseModel): text: str | None = None contents: list[ContentElement] = [] attachments: list[InputAttachment] = [] + interaction: InteractionSubmission | None = None ``` - 文本、多模态、附件都属于当前 event input。 - 大文件、图片、音频、工具大结果应进入授权 sandbox/workspace,input attachment 只携带轻量 metadata/path/url/content。 - 平台原始消息链不属于 SDK `AgentInput`;需要诊断时放在 Host 内部 envelope 或 `ctx.adapter.extra` 的一次性兼容字段中,不作为长期 runner 合同。 +- `interaction` 只在 `event.event_type == "interaction.submitted"` 时出现,承载经过 Host 校验和归一化的用户提交;平台原始 callback payload 不进入该字段。 ### 5.7 DeliveryContext @@ -291,17 +299,80 @@ class DeliveryContext(BaseModel): supports_edit: bool = False supports_reaction: bool = False max_message_size: int | None = None + interactions: InteractionDeliveryCapabilities | None = None platform_capabilities: dict[str, Any] = {} ``` -Runner 可参考 delivery 能力决定返回 `message.delta`、`message.completed` 或 `action.requested`。 +Runner 可参考 delivery 能力决定返回 `message.delta`、`message.completed` 或交互请求。 +`interactions is not None` 表示当前 surface 可以投递结构化交互;其中的 field/action 能力 +决定 Host 可原生渲染的范围。Runner 仍必须提供 `fallback_text`,供不支持富交互或投递失败时降级。 平台事件进入独立 Agent 时,Host 会从当前 adapter 的 `get_supported_apis()` 投影 `supports_edit`、`supports_reaction`,并把去重后的 API 名称写入 `platform_capabilities.supported_apis`。这些字段只描述当前投递表面的能力,不授予 -平台动作权限;`action.requested` 仍受 §7.3 的 reserved 约束。合成路由测试使用的 +任意平台动作权限;交互请求按 §5.8 和 §7.3 的白名单约束执行。合成路由测试使用的 adapter 会过滤所有已知副作用 API,因此测试事件不会向 runner 宣告真实出站能力。 -### 5.8 ContextAccess +### 5.8 Structured Interaction + +```python +InteractionFieldType = Literal[ + "text", "textarea", "select", "multiselect", "number", "boolean", "file" +] + +class InteractionOption(BaseModel): + value: str + label: str + description: str | None = None + +class InteractionField(BaseModel): + id: str + label: str + type: InteractionFieldType + required: bool = False + options: list[InteractionOption] = [] + placeholder: str | None = None + default: JSONValue | None = None + +class InteractionAction(BaseModel): + id: str + label: str + style: Literal["default", "primary", "danger"] = "default" + +class InteractionRequest(BaseModel): + interaction_id: str + kind: Literal["form", "confirmation", "choice"] = "form" + title: str + description: str | None = None + fields: list[InteractionField] = [] + actions: list[InteractionAction] = [] + expires_at: int | None = None + fallback_text: str + +class InteractionSubmission(BaseModel): + interaction_id: str + action_id: str | None = None + values: dict[str, JSONValue] = {} + submitted_at: int | None = None + +class InteractionDeliveryCapabilities(BaseModel): + field_types: list[InteractionFieldType] = [] + action_styles: list[Literal["default", "primary", "danger"]] = [] + supports_updates: bool = False + max_fields: int | None = None +``` + +Runner 使用 `AgentRunResult.interaction_requested()` 生成 +`action.requested(action="interaction.requested")`。Host 只能把请求投递到当前 run 冻结的 +delivery target,Runner 不得通过 `target` 改写 bot、conversation 或用户。Host 为请求保存 +`interaction_id -> processor/binding/conversation/expiry` 关联;平台 callback 必须先经过签名、 +目标、过期和幂等校验,再生成 `interaction.submitted` 事件。 + +Provider 私有 continuation token、workflow id、凭据或原始 callback payload 不属于该协议。 +Runner 应把这些值保存到受授权的 Host state 或 plugin storage,并只用 `interaction_id` 关联提交。 +Host 必须使用服务器接收时间判断 callback 是否过期,并覆盖 `InteractionSubmission.submitted_at`; +平台事件时间只可作为原始事件诊断信息,不能控制 TTL 或进入 Runner 的可信提交字段。 + +### 5.9 ContextAccess ```python class ContextAccess(BaseModel): @@ -463,13 +534,20 @@ Runner 生成的大文件、工具输出和临时产物不通过 result event | `tool.call.started` | 工具调用开始的可观测事件。 | telemetry | | `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry | | `state.updated` | runner 请求更新 host-owned state。 | ✅ | -| `action.requested` | runner 请求 Host 执行平台动作。 | **reserved / 仅 telemetry,不执行** | +| `action.requested` | runner 请求 Host 执行受控动作。 | `interaction.requested` 可执行;其它动作仅 telemetry | | `run.completed` | run 正常结束。 | ✅ | | `run.failed` | run 失败。 | ✅ | -`action.requested` 是为 EBA 和 platform API 保留的协议表面:本分支 Host 收到后只记 telemetry,**不执行**,runner 作者不应在当前 Host 底座中依赖其副作用。真实执行器由外部 EBA / platform action 分支接入;执行模型见 EVENT_BASED_AGENT §6。 +`action.requested` 仍是严格白名单协议表面。当前 Host 只执行 +`action="interaction.requested"`,并要求 payload 可校验为 `InteractionRequest`、Runner 声明 +interaction capability/permission、当前 binding 允许交互且 delivery surface 支持交互。 +Host 忽略 Runner 提供的外部 target,只使用 run authorization snapshot 中冻结的 delivery target。 +其它 action 只记 telemetry,不执行;通用 platform action executor 仍不属于本协议。 -Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。本分支 `action.requested` 仍只记录 telemetry。 +Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。Host 还必须限制 +交互字段/动作数量、字符串长度、payload 总大小、过期时间和 interaction id 唯一性,并对 callback +应用默认 30 分钟、最长 24 小时的有效期;request 与 submission payload 均不得超过 256 KiB。 +执行目标绑定、签名、幂等和过期校验。 除 runner 经 `state.updated` 写之外,Host 自身也可直接写部分 host-owned state。例如 `activate` tool 在 Host 侧执行时,直接把已激活 skill 写入 conversation scope 的 `host.activated_skills` 快照。当 host 直接写与 runner `state.updated` 写到同一 key 时,按 **last-write-wins** 合并——runner 可以覆盖 host 写的快照。 @@ -487,7 +565,7 @@ Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序 { "type": "message.delta", "data": { "chunk": { "role": "assistant", "content": "hel" } } } { "type": "message.completed", "data": { "message": { "role": "assistant", "content": "hello" } } } { "type": "state.updated", "data": { "scope": "conversation", "key": "external.session_id", "value": "abc" } } -{ "type": "action.requested", "data": { "action": "message.edit", "target": {"message_id": "..."}, "payload": {"text": "..."} } } +{ "type": "action.requested", "data": { "action": "interaction.requested", "payload": { "interaction_id": "form_1", "kind": "choice", "title": "Approve?", "actions": [{"id": "approve", "label": "Approve", "style": "primary"}], "fallback_text": "Reply approve or reject." } } } ``` ## 8. AgentRunAPIProxy diff --git a/docs/agent-runner-pluginization/STATUS.md b/docs/agent-runner-pluginization/STATUS.md index 207f35223..85b1f1b79 100644 --- a/docs/agent-runner-pluginization/STATUS.md +++ b/docs/agent-runner-pluginization/STATUS.md @@ -21,11 +21,12 @@ | Steering control path | Done | claim 异常不再逃逸 consumer loop;queue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 | | SDK v1 contract closure | Done | SDK 提供 `AgentAPIError` / `AgentAPIException`、typed `SteeringPullResult`、未知 result type 宽容解析、result `sequence` 注入与取消传播。 | | EBA processor routing | Done; release gate 5/5 pass | Bot `event_bindings`、Pipeline / Agent 平级路由、WebUI dry-run / 合成测试 / 状态、OneBot 非消息事件到 Agent 及平台回复已闭环;隔离空白实例已验证从 Space 安装并注册 LocalAgent。 | +| Structured interactions | Cross-repo unit-pass; provider E2E pending | Host 已完成 `interaction.requested` 白名单、持久化 callback correlation、TTL/作用域/幂等校验和 Pipeline/Agent 原处理器恢复;六个平台已接入按钮/单选投递,Lark 和 DingTalk 进一步支持原生单字段 `text` / `textarea` / `number` / `select` 控件。SDK typed contract、通用 Runner 脚手架和 DifyAgent `workflow_paused` plugin-storage continuation 已落入对应仓库。 | ## Spec 与实现已知差距 -- `action.requested` 仍只作为 telemetry / reserved surface;platform action executor 不在本分支执行。 -- `action.requested` 权限模型完成前,DeliveryContext 的 adapter capability 投影只用于输出决策,不提供平台动作执行权限。 +- `action.requested` 是严格白名单协议面:当前只执行 `interaction.requested`;其它 action 仍只记录 telemetry,不提供通用 platform action executor。 +- 结构化交互 SDK typed contract 与 DifyAgent continuation 已实现;SDK 正式发布、真实 Dify 凭据 E2E,以及需要长驻双向进程的 Claude Code 权限确认仍是后续验收项。Host 不持有 provider 私有 token。 - State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。 - `ToolResource.parameters` 已作为 best-effort full schema 由 Host 在构造 `ctx.resources` 时一次塞齐;无 schema 时 runner 仍需兼容 `parameters=None` 或按需调用 detail API。 - EventLog / Transcript 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和 sandbox/workspace 文件清理仍是运维收尾项,应在 Runtime Control Plane 产品化前补齐。 @@ -40,7 +41,8 @@ | `plugin:langbot-team/LocalAgent/default` | Unit-pass; Marketplace UI pass; Debug Chat E2E pass | 2026-07-12 隔离 first-run 实例从真实 AgentRunner catalog 安装 `langbot-team/LocalAgent` 0.1.0,Host 注册 `plugin:langbot-team/LocalAgent/default`,Wizard 自动选中并解锁后续操作。2026-07-15 `2026-07-15-08-44-10-770-08-00-sandbox-skill-authoring-edit-existing-e2e` 使用真实 `gpt-5.5` 完成 Skill 创建、注册、同 Query 激活、已激活包编辑与脚本执行;三阶段 UI、浏览器诊断和结构化文件系统检查全部通过,每阶段恰好新增一个 Bot 气泡,p95 14.6 秒、错误率 0。 | | `plugin:langbot-team/ACPAgentRunner/default` | Unit-pass; Debug Chat E2E pass | 2026-07-15 从本地 0.1.4 发布包安装并注册 PascalCase runner,remote-ssh Claude ACP 通过反向隧道调用 run-scoped `langbot_get_current_event`,97.8 秒返回可见结果;Host 将增量 delta 和 `message.completed` 聚合为一个完整 Bot 气泡。 | | `plugin:langbot-team/ClaudeCodeAgent/default` / `plugin:langbot-team/CodexAgent/default` | Unit-pass; E2E pending | 通过 runner 仓库单测覆盖 session、run_id 注入和 LangBot MCP gateway;真实 harness E2E 取决于对应运行环境、CLI/daemon 可用性和 provider 登录态。 | -| Dify / n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 | +| Dify | Human-input unit-pass; credential E2E pending | `langbot-agent-runner/dify-agent` 已实现 `workflow_paused`、原子字段/确认交互、plugin-storage continuation、Dify submit/events 恢复与再次暂停;真实 Dify 凭据 E2E 待执行。 | +| n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 | ## Host / SDK 验收状态 diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py index 0ba1917d9..c45179e4c 100644 --- a/src/langbot/libs/qq_official_api/api.py +++ b/src/langbot/libs/qq_official_api/api.py @@ -721,7 +721,6 @@ class QQOfficialClient: """ if not await self.check_access_token(): await self.get_access_token() - if target_type == 'c2c': url = f'{self.base_url}/v2/users/{target_id}/messages' elif target_type == 'group': @@ -730,7 +729,6 @@ class QQOfficialClient: url = f'{self.base_url}/channels/{target_id}/messages' else: raise ValueError(f'Unsupported target_type for markdown+keyboard: {target_type}') - body: dict[str, Any] = { 'msg_type': 2, 'markdown': {'content': markdown_content}, diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py index 6a1769c72..473db2a32 100644 --- a/src/langbot/libs/wecom_ai_bot_api/ws_client.py +++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py @@ -430,6 +430,8 @@ class WecomBotWsClient: card_payload: ``{"msgtype": "template_card", "template_card": {...}}`` as produced by :func:`build_button_interaction_payload`. """ + if card_payload.get('msgtype') != 'template_card' or not isinstance(card_payload.get('template_card'), dict): + raise ValueError('invalid WeCom template_card payload') req_id = _generate_req_id(CMD_SEND_MSG) body = dict(card_payload) body['chatid'] = chat_id diff --git a/src/langbot/pkg/agent/runner/binding_resolver.py b/src/langbot/pkg/agent/runner/binding_resolver.py index fea5ad6b5..97b7d7e71 100644 --- a/src/langbot/pkg/agent/runner/binding_resolver.py +++ b/src/langbot/pkg/agent/runner/binding_resolver.py @@ -30,16 +30,10 @@ class AgentBindingResolver: Agent and does not carry enough scope metadata to make that decision safely here. """ - matches = [ - agent - for agent in agents - if agent.enabled and event.event_type in agent.event_types - ] + matches = [agent for agent in agents if agent.enabled and event.event_type in agent.event_types] if not matches: - raise AgentBindingResolutionError( - f'No Agent binding matches event_type={event.event_type}' - ) + raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}') if len(matches) > 1: agent_ids = ', '.join(agent.agent_id or '' for agent in matches) @@ -57,7 +51,7 @@ class AgentBindingResolver: ) return AgentBinding( - binding_id=f"agent_{agent.agent_id or 'default'}_{agent.runner_id}", + binding_id=f'agent_{agent.agent_id or "default"}_{agent.runner_id}', scope=scope, event_types=list(agent.event_types), runner_id=agent.runner_id, @@ -67,4 +61,6 @@ class AgentBindingResolver: delivery_policy=agent.delivery_policy, enabled=agent.enabled, agent_id=agent.agent_id, + processor_type=agent.processor_type, + processor_id=agent.processor_id or agent.agent_id, ) diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py index 87656d5f9..64ceecce8 100644 --- a/src/langbot/pkg/agent/runner/context_builder.py +++ b/src/langbot/pkg/agent/runner/context_builder.py @@ -45,6 +45,7 @@ class AgentInput(typing.TypedDict): text: str | None contents: list[dict[str, typing.Any]] attachments: list[dict[str, typing.Any]] + interaction: typing.NotRequired[dict[str, typing.Any] | None] class AgentRunState(typing.TypedDict): @@ -299,6 +300,11 @@ class AgentRunContextBuilder: a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments ], } + if getattr(event.input, 'interaction', None) is not None: + interaction = event.input.interaction + input['interaction'] = ( + interaction.model_dump(mode='json') if hasattr(interaction, 'model_dump') else interaction + ) # Build context access (no history inlined by default for Protocol v1) # Populate with actual values from stores @@ -333,6 +339,11 @@ class AgentRunContextBuilder: 'max_message_size': event.delivery.max_message_size, 'platform_capabilities': event.delivery.platform_capabilities, } + if getattr(event.delivery, 'interactions', None) is not None: + interactions = event.delivery.interactions + delivery_context['interactions'] = ( + interactions.model_dump(mode='json') if hasattr(interactions, 'model_dump') else interactions + ) # Build adapter context (empty for event-first) adapter_context = { diff --git a/src/langbot/pkg/agent/runner/events.py b/src/langbot/pkg/agent/runner/events.py index 53ea266e2..cbc1190c3 100644 --- a/src/langbot/pkg/agent/runner/events.py +++ b/src/langbot/pkg/agent/runner/events.py @@ -1,4 +1,5 @@ """Canonical AgentRunner event names reserved for future EBA integration.""" + from __future__ import annotations @@ -14,6 +15,9 @@ GROUP_MEMBER_JOINED = 'group.member_joined' FRIEND_REQUEST_RECEIVED = 'friend.request_received' """A new friend/contact request was received.""" +INTERACTION_SUBMITTED = 'interaction.submitted' +"""A user submitted a Host-rendered structured interaction.""" + RESERVED_EVENT_TYPES = frozenset( { @@ -21,5 +25,6 @@ RESERVED_EVENT_TYPES = frozenset( MESSAGE_RECALLED, GROUP_MEMBER_JOINED, FRIEND_REQUEST_RECEIVED, + INTERACTION_SUBMITTED, } ) diff --git a/src/langbot/pkg/agent/runner/host_models.py b/src/langbot/pkg/agent/runner/host_models.py index 8f881878f..89604525e 100644 --- a/src/langbot/pkg/agent/runner/host_models.py +++ b/src/langbot/pkg/agent/runner/host_models.py @@ -139,6 +139,9 @@ class DeliveryPolicy(pydantic.BaseModel): enable_reply: bool = True """Whether reply is enabled.""" + enable_interactions: bool = False + """Whether the binding permits structured interaction delivery.""" + max_message_size: int | None = None """Maximum message size.""" @@ -154,6 +157,12 @@ class AgentConfig(pydantic.BaseModel): agent_id: str | None = None """Host-side Agent/config identifier.""" + processor_type: typing.Literal['agent', 'pipeline'] = 'agent' + """Product processor kind represented by this runtime config.""" + + processor_id: str | None = None + """Stable product processor identifier.""" + runner_id: str """Runner ID to invoke.""" @@ -215,3 +224,9 @@ class AgentBinding(pydantic.BaseModel): agent_id: str | None = None """Host-side Agent/config identifier for this binding.""" + + processor_type: typing.Literal['agent', 'pipeline'] = 'agent' + """Product processor kind selected for this binding.""" + + processor_id: str | None = None + """Stable product processor identifier used for callback resume.""" diff --git a/src/langbot/pkg/agent/runner/interaction_manager.py b/src/langbot/pkg/agent/runner/interaction_manager.py new file mode 100644 index 000000000..58d7ad9e9 --- /dev/null +++ b/src/langbot/pkg/agent/runner/interaction_manager.py @@ -0,0 +1,441 @@ +"""Host validation, persistence, and delivery for structured interactions.""" + +from __future__ import annotations + +import json +import time +import typing + +import pydantic + +from langbot_plugin.api.entities.builtin.platform import message as platform_message + +from .descriptor import AgentRunnerDescriptor +from .errors import RunnerProtocolError +from .host_models import AgentBinding, AgentEventEnvelope +from .interaction_store import InteractionStore + + +INTERACTION_REQUESTED_ACTION = 'interaction.requested' +INTERACTION_DELIVERY_API = 'interaction.request' +INTERACTION_ACKNOWLEDGE_API = 'interaction.acknowledge' +INTERACTION_PAYLOAD_MAX_BYTES = 256 * 1024 +DEFAULT_INTERACTION_TTL_SECONDS = 30 * 60 +MAX_INTERACTION_TTL_SECONDS = 24 * 60 * 60 + + +class HostInteractionOption(pydantic.BaseModel): + """Host-validated selectable interaction value.""" + + value: str = pydantic.Field(min_length=1, max_length=512) + label: str = pydantic.Field(min_length=1, max_length=512) + description: str | None = pydantic.Field(default=None, max_length=2000) + + model_config = pydantic.ConfigDict(extra='forbid') + + +class HostInteractionField(pydantic.BaseModel): + """Host-validated structured interaction field.""" + + id: str = pydantic.Field(min_length=1, max_length=128) + label: str = pydantic.Field(min_length=1, max_length=512) + type: typing.Literal['text', 'textarea', 'select', 'multiselect', 'number', 'boolean', 'file'] + required: bool = False + options: list[HostInteractionOption] = pydantic.Field(default_factory=list, max_length=100) + placeholder: str | None = pydantic.Field(default=None, max_length=2000) + default: typing.Any = None + + model_config = pydantic.ConfigDict(extra='forbid') + + +class HostInteractionAction(pydantic.BaseModel): + """Host-validated interaction submit action.""" + + id: str = pydantic.Field(min_length=1, max_length=128) + label: str = pydantic.Field(min_length=1, max_length=512) + style: typing.Literal['default', 'primary', 'danger'] = 'default' + + model_config = pydantic.ConfigDict(extra='forbid') + + +class HostInteractionRequest(pydantic.BaseModel): + """Defensive Host projection of the SDK interaction request contract.""" + + interaction_id: str = pydantic.Field(min_length=1, max_length=255) + kind: typing.Literal['form', 'confirmation', 'choice'] = 'form' + title: str = pydantic.Field(min_length=1, max_length=1000) + description: str | None = pydantic.Field(default=None, max_length=10000) + fields: list[HostInteractionField] = pydantic.Field(default_factory=list, max_length=50) + actions: list[HostInteractionAction] = pydantic.Field(default_factory=list, max_length=20) + expires_at: int | None = None + fallback_text: str = pydantic.Field(min_length=1, max_length=20000) + + model_config = pydantic.ConfigDict(extra='forbid') + + +class HostInteractionSubmission(pydantic.BaseModel): + """Host-normalized platform callback payload.""" + + interaction_id: str = pydantic.Field(min_length=1, max_length=255) + action_id: str | None = pydantic.Field(default=None, max_length=128) + values: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + submitted_at: int | None = None + + model_config = pydantic.ConfigDict(extra='forbid') + + +class InteractionManager: + """Consume the interaction action whitelist for one Host application.""" + + def __init__(self, ap: typing.Any, store: InteractionStore | None = None): + self.ap = ap + self.store = store or InteractionStore(ap.persistence_mgr.get_db_engine()) + + async def handle_result( + self, + *, + result_dict: dict[str, typing.Any], + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + run_id: str, + adapter_context: dict[str, typing.Any] | None, + ) -> bool: + """Execute an authorized interaction request; return whether it was consumed.""" + data = result_dict.get('data') + if not isinstance(data, dict) or data.get('action') != INTERACTION_REQUESTED_ACTION: + return False + + self._authorize(descriptor, binding) + payload = data.get('payload') + try: + request = HostInteractionRequest.model_validate(payload) + except pydantic.ValidationError as exc: + raise RunnerProtocolError(descriptor.id, f'invalid interaction request: {exc}') from exc + + processor_id = binding.processor_id or binding.agent_id + if not processor_id: + raise RunnerProtocolError(descriptor.id, 'interaction request has no stable processor identity') + adapter = self._resolve_adapter(adapter_context) + if adapter is None: + raise RunnerProtocolError(descriptor.id, 'interaction request has no delivery adapter') + + request_data = request.model_dump(mode='json') + now = int(time.time()) + expires_at = now + DEFAULT_INTERACTION_TTL_SECONDS if request.expires_at is None else request.expires_at + if expires_at <= now: + raise RunnerProtocolError(descriptor.id, 'interaction request is already expired') + if expires_at > now + MAX_INTERACTION_TTL_SECONDS: + raise RunnerProtocolError(descriptor.id, 'interaction request expiry exceeds 24 hours') + request_data['expires_at'] = expires_at + if len(json.dumps(request_data, ensure_ascii=False).encode('utf-8')) > INTERACTION_PAYLOAD_MAX_BYTES: + raise RunnerProtocolError(descriptor.id, 'interaction request exceeds 256 KiB') + self._validate_request_identity(request, descriptor.id) + reply_target = event.delivery.reply_target or {} + target_type = reply_target.get('target_type') + target_id = reply_target.get('target_id') + callback_conversation_id = f'{target_type}_{target_id}' if target_type and target_id else event.conversation_id + update_target = await self._find_update_target( + event=event, + binding=binding, + descriptor=descriptor, + processor_id=processor_id, + conversation_id=callback_conversation_id, + ) + _, callback_token = await self.store.create_request( + interaction_id=request.interaction_id, + run_id=run_id, + binding_id=binding.binding_id, + runner_id=descriptor.id, + processor_type=binding.processor_type, + processor_id=processor_id, + request=request_data, + delivery_target=reply_target, + replaces_interaction_id=(update_target or {}).get('interaction_id'), + bot_id=event.bot_id, + workspace_id=event.workspace_id, + conversation_id=callback_conversation_id, + thread_id=event.thread_id, + actor_id=event.actor.actor_id if event.actor else None, + expires_at=expires_at, + ) + + try: + if self._supports_interaction_delivery(adapter): + delivery_params = { + 'request': request_data, + 'callback_token': callback_token, + 'reply_target': event.delivery.reply_target, + } + if update_target is not None: + delivery_params['update_target'] = update_target.get('delivery_result') + try: + delivery_result = await adapter.call_platform_api( + INTERACTION_DELIVERY_API, + delivery_params, + ) + except Exception as exc: + if update_target is None: + raise + self._warning(f'Interaction update failed; sending a new presentation: {exc}') + fallback_params = dict(delivery_params) + fallback_params.pop('update_target', None) + delivery_result = await adapter.call_platform_api( + INTERACTION_DELIVERY_API, + fallback_params, + ) + if isinstance(delivery_result, dict): + try: + await self.store.record_delivery_success( + run_id, + request.interaction_id, + delivery_result, + ) + except Exception as exc: + self._warning(f'Failed to persist interaction delivery result: {exc}') + else: + await self._deliver_fallback(adapter, event, request.fallback_text) + except Exception as exc: + await self.store.mark_delivery_failed(run_id, request.interaction_id, str(exc)) + raise + return True + + async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None: + """Best-effort transition of submitted controls into a read-only state.""" + delivery_result = record.get('delivery_result') + if not isinstance(delivery_result, dict) or not self._supports_platform_api( + adapter, INTERACTION_ACKNOWLEDGE_API + ): + return + try: + delivery_result = await adapter.call_platform_api( + INTERACTION_ACKNOWLEDGE_API, + { + 'request': record.get('request') or {}, + 'submission': record.get('submission') or {}, + 'update_target': delivery_result, + }, + ) + if isinstance(delivery_result, dict): + await self.store.record_delivery_success( + record['run_id'], + record['interaction_id'], + delivery_result, + ) + except Exception as exc: + self._warning(f'Failed to acknowledge interaction submission: {exc}') + + async def _find_update_target( + self, + *, + event: AgentEventEnvelope, + binding: AgentBinding, + descriptor: AgentRunnerDescriptor, + processor_id: str, + conversation_id: str | None, + ) -> dict[str, typing.Any] | None: + capabilities = getattr(event.delivery, 'interactions', None) + if not capabilities or not getattr(capabilities, 'supports_updates', False): + return None + prior = getattr(event.input, 'interaction', None) + prior_interaction_id = getattr(prior, 'interaction_id', None) + if not prior_interaction_id: + return None + return await self.store.find_update_target( + interaction_id=str(prior_interaction_id), + binding_id=binding.binding_id, + runner_id=descriptor.id, + processor_type=binding.processor_type, + processor_id=processor_id, + bot_id=event.bot_id, + conversation_id=conversation_id, + actor_id=event.actor.actor_id if event.actor else None, + ) + + @staticmethod + def _validate_request_identity(request: HostInteractionRequest, runner_id: str) -> None: + field_ids = [field.id for field in request.fields] + action_ids = [action.id for action in request.actions] + if len(field_ids) != len(set(field_ids)): + raise RunnerProtocolError(runner_id, 'interaction request has duplicate field IDs') + if len(action_ids) != len(set(action_ids)): + raise RunnerProtocolError(runner_id, 'interaction request has duplicate action IDs') + for field in request.fields: + if field.type in {'select', 'multiselect'} and not field.options: + raise RunnerProtocolError( + runner_id, + f'interaction field {field.id} requires at least one option', + ) + option_values = [option.value for option in field.options] + if len(option_values) != len(set(option_values)): + raise RunnerProtocolError( + runner_id, + f'interaction field {field.id} has duplicate option values', + ) + + async def consume_callback( + self, + *, + callback_token: str, + submission: dict[str, typing.Any], + bot_id: str | None, + conversation_id: str | None, + actor_id: str | None, + ) -> dict[str, typing.Any]: + """Validate a platform callback and return the persisted resume target.""" + pending = await self.store.get_by_callback_token(callback_token) + if pending is None: + from .interaction_store import InteractionNotFoundError + + raise InteractionNotFoundError('Unknown interaction callback token') + normalized_submission = self._resolve_submission_refs(pending, submission) + try: + normalized = HostInteractionSubmission.model_validate(normalized_submission) + except pydantic.ValidationError as exc: + raise ValueError(f'invalid interaction submission: {exc}') from exc + self._validate_submission(pending, normalized) + submission_data = normalized.model_dump(mode='json') + submission_data['submitted_at'] = int(time.time()) + if len(json.dumps(submission_data, ensure_ascii=False).encode('utf-8')) > 256 * 1024: + raise ValueError('interaction submission exceeds 256 KiB') + return await self.store.consume_submission( + callback_token=callback_token, + submission=submission_data, + bot_id=bot_id, + conversation_id=conversation_id, + actor_id=actor_id, + submitted_at=submission_data['submitted_at'], + ) + + @staticmethod + def _resolve_submission_refs( + pending: dict[str, typing.Any], + submission: dict[str, typing.Any], + ) -> dict[str, typing.Any]: + """Resolve compact platform indexes against the persisted request.""" + resolved = dict(submission) + resolved['interaction_id'] = pending['interaction_id'] + request = pending.get('request') if isinstance(pending.get('request'), dict) else {} + + action_ref = resolved.pop('action_ref', None) + if action_ref is not None: + actions = request.get('actions') if isinstance(request.get('actions'), list) else [] + try: + action = actions[InteractionManager._callback_index(action_ref, 'action')] + except (IndexError, TypeError, ValueError) as exc: + raise ValueError('interaction action reference is invalid') from exc + if not isinstance(action, dict) or not action.get('id'): + raise ValueError('interaction action reference has no action ID') + resolved['action_id'] = str(action['id']) + + field_ref = resolved.pop('field_ref', None) + option_ref = resolved.pop('option_ref', None) + if field_ref is not None or option_ref is not None: + fields = request.get('fields') if isinstance(request.get('fields'), list) else [] + try: + field = fields[InteractionManager._callback_index(field_ref, 'field')] + option = field['options'][InteractionManager._callback_index(option_ref, 'option')] + except (IndexError, KeyError, TypeError, ValueError) as exc: + raise ValueError('interaction field option reference is invalid') from exc + if not isinstance(field, dict) or not field.get('id') or not isinstance(option, dict): + raise ValueError('interaction field option reference is malformed') + resolved['values'] = {str(field['id']): option.get('value')} + + return resolved + + @staticmethod + def _callback_index(value: typing.Any, label: str) -> int: + if isinstance(value, bool): + raise ValueError(f'interaction {label} reference is not an integer') + if isinstance(value, int): + index = value + elif isinstance(value, str) and value.isdigit(): + index = int(value) + else: + raise ValueError(f'interaction {label} reference is not an integer') + if index < 0: + raise ValueError(f'interaction {label} reference is negative') + return index + + @staticmethod + def _validate_submission( + pending: dict[str, typing.Any], + submission: HostInteractionSubmission, + ) -> None: + request = pending.get('request') if isinstance(pending.get('request'), dict) else {} + actions = request.get('actions') if isinstance(request.get('actions'), list) else [] + action_ids = {str(action.get('id')) for action in actions if isinstance(action, dict) and action.get('id')} + if submission.action_id is not None and submission.action_id not in action_ids: + raise ValueError('interaction submission action is not present in the request') + + fields = request.get('fields') if isinstance(request.get('fields'), list) else [] + fields_by_id = {str(field.get('id')): field for field in fields if isinstance(field, dict) and field.get('id')} + for field_id, value in submission.values.items(): + field = fields_by_id.get(field_id) + if field is None: + raise ValueError(f'interaction submission field is not present in the request: {field_id}') + field_type = field.get('type') + if field_type not in {'select', 'multiselect'}: + continue + options = field.get('options') if isinstance(field.get('options'), list) else [] + allowed_values = { + option.get('value') + for option in options + if isinstance(option, dict) and option.get('value') is not None + } + selected_values = value if field_type == 'multiselect' and isinstance(value, list) else [value] + if field_type == 'multiselect' and not isinstance(value, list): + raise ValueError(f'interaction multiselect field requires a list: {field_id}') + if any(selected not in allowed_values for selected in selected_values): + raise ValueError(f'interaction submission option is not present in the request: {field_id}') + + @staticmethod + def _authorize(descriptor: AgentRunnerDescriptor, binding: AgentBinding) -> None: + supports_interactions = bool(getattr(descriptor.capabilities, 'interactions', False)) + permissions = set(getattr(descriptor.permissions, 'interactions', []) or []) + if not supports_interactions or 'request' not in permissions: + raise RunnerProtocolError(descriptor.id, 'runner did not declare interaction capability and permission') + if not binding.delivery_policy.enable_interactions: + raise RunnerProtocolError(descriptor.id, 'binding delivery policy does not allow interactions') + + @staticmethod + def _resolve_adapter(adapter_context: dict[str, typing.Any] | None) -> typing.Any | None: + if not adapter_context: + return None + query = adapter_context.get('_query') + if query is not None and getattr(query, 'adapter', None) is not None: + return query.adapter + return adapter_context.get('_delivery_adapter') + + @staticmethod + def _supports_interaction_delivery(adapter: typing.Any) -> bool: + return InteractionManager._supports_platform_api(adapter, INTERACTION_DELIVERY_API) + + @staticmethod + def _supports_platform_api(adapter: typing.Any, api_name: str) -> bool: + get_supported_apis = getattr(adapter, 'get_supported_apis', None) + if not callable(get_supported_apis): + return False + try: + return api_name in (get_supported_apis() or []) + except Exception: + return False + + def _warning(self, message: str) -> None: + logger = getattr(self.ap, 'logger', None) + warning = getattr(logger, 'warning', None) + if callable(warning): + warning(message) + + @staticmethod + async def _deliver_fallback(adapter: typing.Any, event: AgentEventEnvelope, text: str) -> None: + target = event.delivery.reply_target or {} + target_type = target.get('target_type') + target_id = target.get('target_id') + if not target_type or not target_id: + raise ValueError('interaction fallback has no reply target') + await adapter.send_message( + str(target_type), + str(target_id), + platform_message.MessageChain([platform_message.Plain(text=text)]), + ) diff --git a/src/langbot/pkg/agent/runner/interaction_store.py b/src/langbot/pkg/agent/runner/interaction_store.py new file mode 100644 index 000000000..15613376d --- /dev/null +++ b/src/langbot/pkg/agent/runner/interaction_store.py @@ -0,0 +1,407 @@ +"""Persistent Host store for structured interaction requests.""" + +from __future__ import annotations + +import datetime +import hashlib +import json +import secrets +import typing + +import sqlalchemy +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from ...entity.persistence.agent_interaction import AgentInteraction + + +UTC = datetime.timezone.utc +INTERACTION_STATUSES = {'pending', 'submitted', 'expired', 'cancelled', 'delivery_failed'} +PROCESSOR_TYPES = {'agent', 'pipeline'} + + +class InteractionStoreError(Exception): + """Base error for interaction persistence operations.""" + + +class InteractionNotFoundError(InteractionStoreError): + """Raised when a callback token does not identify an interaction.""" + + +class InteractionScopeError(InteractionStoreError): + """Raised when a callback does not belong to the stored delivery scope.""" + + +class InteractionExpiredError(InteractionStoreError): + """Raised when an interaction is no longer accepting submissions.""" + + +class InteractionAlreadySubmittedError(InteractionStoreError): + """Raised when an interaction callback is replayed.""" + + +class DuplicateInteractionError(InteractionStoreError): + """Raised when one run emits the same interaction ID more than once.""" + + +def _utc_now() -> datetime.datetime: + return datetime.datetime.now(UTC) + + +def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _epoch_to_datetime(value: int | float | None) -> datetime.datetime | None: + if value is None: + return None + try: + return datetime.datetime.fromtimestamp(float(value), UTC) + except (TypeError, ValueError, OSError) as exc: + raise ValueError('expires_at must be a valid epoch timestamp') from exc + + +def _datetime_to_epoch(value: datetime.datetime | None) -> int | None: + normalized = _as_utc(value) + return int(normalized.timestamp()) if normalized is not None else None + + +def _json_dumps(value: typing.Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(',', ':')) + + +def _json_loads(value: str | None, default: typing.Any) -> typing.Any: + if not value: + return default + try: + return json.loads(value) + except (TypeError, ValueError): + return default + + +def _token_hash(token: str) -> str: + return hashlib.sha256(token.encode('utf-8')).hexdigest() + + +class InteractionStore: + """Store interaction correlation and consume callbacks exactly once.""" + + def __init__(self, engine: AsyncEngine): + self.engine = engine + self._session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async def create_request( + self, + *, + interaction_id: str, + run_id: str, + binding_id: str, + runner_id: str, + processor_type: str, + processor_id: str, + request: dict[str, typing.Any], + delivery_target: dict[str, typing.Any] | None, + replaces_interaction_id: str | None = None, + bot_id: str | None = None, + workspace_id: str | None = None, + conversation_id: str | None = None, + thread_id: str | None = None, + actor_id: str | None = None, + expires_at: int | float | None = None, + ) -> tuple[dict[str, typing.Any], str]: + """Persist a request and return its record plus one-time callback token.""" + if not interaction_id or not run_id or not binding_id or not runner_id or not processor_id: + raise ValueError('interaction, run, binding, runner, and processor IDs are required') + if processor_type not in PROCESSOR_TYPES: + raise ValueError(f'Unsupported processor type: {processor_type}') + + request_json = _json_dumps(request) + delivery_target_json = _json_dumps(delivery_target) if delivery_target is not None else None + expires_at_dt = _epoch_to_datetime(expires_at) + now = _utc_now() + if expires_at_dt is not None and expires_at_dt <= now: + raise ValueError('expires_at must be in the future') + + callback_token = secrets.token_urlsafe(18) + row = AgentInteraction( + interaction_id=interaction_id, + run_id=run_id, + binding_id=binding_id, + runner_id=runner_id, + processor_type=processor_type, + processor_id=processor_id, + bot_id=bot_id, + workspace_id=workspace_id, + conversation_id=conversation_id, + thread_id=thread_id, + actor_id=actor_id, + status='pending', + request_json=request_json, + delivery_target_json=delivery_target_json, + replaces_interaction_id=replaces_interaction_id, + callback_token_hash=_token_hash(callback_token), + expires_at=expires_at_dt, + created_at=now, + updated_at=now, + ) + + async with self._session_factory() as session: + existing = await self._get_by_run_interaction(session, run_id, interaction_id) + if existing is not None: + raise DuplicateInteractionError(f'Interaction {interaction_id} already exists for run {run_id}') + session.add(row) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise DuplicateInteractionError( + f'Interaction {interaction_id} could not be created for run {run_id}' + ) from exc + return self._to_dict(row), callback_token + + async def record_delivery_success( + self, + run_id: str, + interaction_id: str, + delivery_result: dict[str, typing.Any], + ) -> bool: + """Persist the platform presentation handle returned after delivery.""" + payload = _json_dumps(delivery_result) + if len(payload.encode('utf-8')) > 64 * 1024: + raise ValueError('interaction delivery result exceeds 64 KiB') + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.update(AgentInteraction) + .where( + AgentInteraction.run_id == run_id, + AgentInteraction.interaction_id == interaction_id, + AgentInteraction.status.in_(['pending', 'submitted']), + ) + .values(delivery_result_json=payload, updated_at=_utc_now()) + ) + await session.commit() + return result.rowcount == 1 + + async def find_update_target( + self, + *, + interaction_id: str, + binding_id: str, + runner_id: str, + processor_type: str, + processor_id: str, + bot_id: str | None, + conversation_id: str | None, + actor_id: str | None, + ) -> dict[str, typing.Any] | None: + """Find a submitted interaction whose platform presentation can be replaced.""" + if not interaction_id: + return None + conditions = [ + AgentInteraction.interaction_id == interaction_id, + AgentInteraction.binding_id == binding_id, + AgentInteraction.runner_id == runner_id, + AgentInteraction.processor_type == processor_type, + AgentInteraction.processor_id == processor_id, + AgentInteraction.status == 'submitted', + AgentInteraction.delivery_result_json.is_not(None), + ] + for column, value in ( + (AgentInteraction.bot_id, bot_id), + (AgentInteraction.conversation_id, conversation_id), + (AgentInteraction.actor_id, actor_id), + ): + conditions.append(column.is_(None) if value is None else column == value) + + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(AgentInteraction) + .where(*conditions) + .order_by(AgentInteraction.submitted_at.desc(), AgentInteraction.id.desc()) + .limit(1) + ) + row = result.scalar_one_or_none() + return self._to_dict(row) if row is not None else None + + async def get_request(self, run_id: str, interaction_id: str) -> dict[str, typing.Any] | None: + """Return a request by the runner-visible identity.""" + async with self._session_factory() as session: + row = await self._get_by_run_interaction(session, run_id, interaction_id) + return self._to_dict(row) if row is not None else None + + async def get_by_callback_token(self, callback_token: str) -> dict[str, typing.Any] | None: + """Resolve callback metadata without exposing the stored token hash.""" + if not callback_token: + return None + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(AgentInteraction).where( + AgentInteraction.callback_token_hash == _token_hash(callback_token) + ) + ) + row = result.scalar_one_or_none() + return self._to_dict(row) if row is not None else None + + async def consume_submission( + self, + *, + callback_token: str, + submission: dict[str, typing.Any], + bot_id: str | None, + conversation_id: str | None, + actor_id: str | None, + submitted_at: int | float | None = None, + ) -> dict[str, typing.Any]: + """Validate and atomically consume one platform callback.""" + if not callback_token: + raise InteractionNotFoundError('Missing interaction callback token') + now = _utc_now() + submission_time = _epoch_to_datetime(submitted_at) or now + token_hash = _token_hash(callback_token) + + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.select(AgentInteraction).where(AgentInteraction.callback_token_hash == token_hash) + ) + row = result.scalar_one_or_none() + if row is None: + raise InteractionNotFoundError('Unknown interaction callback token') + if row.status == 'submitted': + raise InteractionAlreadySubmittedError('Interaction was already submitted') + if row.status != 'pending': + raise InteractionExpiredError(f'Interaction is not pending: {row.status}') + + if submission.get('interaction_id') != row.interaction_id: + raise InteractionScopeError('Interaction ID does not match callback token') + + expires_at = _as_utc(row.expires_at) + if expires_at is not None and expires_at <= now: + row.status = 'expired' + row.status_reason = 'interaction expired before submission' + row.updated_at = now + await session.commit() + raise InteractionExpiredError('Interaction has expired') + + self._validate_scope(row, bot_id, conversation_id, actor_id) + + update_result = await session.execute( + sqlalchemy.update(AgentInteraction) + .where( + AgentInteraction.id == row.id, + AgentInteraction.status == 'pending', + ) + .values( + status='submitted', + submitted_at=submission_time, + submission_json=_json_dumps(submission), + updated_at=now, + ) + ) + if update_result.rowcount != 1: + await session.rollback() + raise InteractionAlreadySubmittedError('Interaction callback lost an idempotency race') + await session.commit() + + refreshed = await session.get(AgentInteraction, row.id) + if refreshed is None: + raise InteractionNotFoundError('Interaction disappeared after submission') + return self._to_dict(refreshed) + + async def mark_delivery_failed(self, run_id: str, interaction_id: str, reason: str) -> bool: + """Mark an undelivered pending interaction terminal.""" + now = _utc_now() + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.update(AgentInteraction) + .where( + AgentInteraction.run_id == run_id, + AgentInteraction.interaction_id == interaction_id, + AgentInteraction.status == 'pending', + ) + .values(status='delivery_failed', status_reason=reason, updated_at=now) + ) + await session.commit() + return result.rowcount == 1 + + async def expire_pending(self, *, now: datetime.datetime | None = None) -> int: + """Expire pending interactions whose deadline has elapsed.""" + cutoff = _as_utc(now) or _utc_now() + async with self._session_factory() as session: + result = await session.execute( + sqlalchemy.update(AgentInteraction) + .where( + AgentInteraction.status == 'pending', + AgentInteraction.expires_at.is_not(None), + AgentInteraction.expires_at <= cutoff, + ) + .values( + status='expired', + status_reason='interaction expired', + updated_at=cutoff, + ) + ) + await session.commit() + return int(result.rowcount or 0) + + @staticmethod + def _validate_scope( + row: AgentInteraction, + bot_id: str | None, + conversation_id: str | None, + actor_id: str | None, + ) -> None: + checks = ( + ('bot', row.bot_id, bot_id), + ('conversation', row.conversation_id, conversation_id), + ('actor', row.actor_id, actor_id), + ) + for label, expected, actual in checks: + if expected is not None and expected != actual: + raise InteractionScopeError(f'Interaction {label} scope mismatch') + + @staticmethod + async def _get_by_run_interaction( + session: AsyncSession, + run_id: str, + interaction_id: str, + ) -> AgentInteraction | None: + result = await session.execute( + sqlalchemy.select(AgentInteraction).where( + AgentInteraction.run_id == run_id, + AgentInteraction.interaction_id == interaction_id, + ) + ) + return result.scalar_one_or_none() + + @staticmethod + def _to_dict(row: AgentInteraction) -> dict[str, typing.Any]: + return { + 'id': row.id, + 'interaction_id': row.interaction_id, + 'run_id': row.run_id, + 'binding_id': row.binding_id, + 'runner_id': row.runner_id, + 'processor_type': row.processor_type, + 'processor_id': row.processor_id, + 'bot_id': row.bot_id, + 'workspace_id': row.workspace_id, + 'conversation_id': row.conversation_id, + 'thread_id': row.thread_id, + 'actor_id': row.actor_id, + 'status': row.status, + 'request': _json_loads(row.request_json, {}), + 'delivery_target': _json_loads(row.delivery_target_json, None), + 'delivery_result': _json_loads(row.delivery_result_json, None), + 'replaces_interaction_id': row.replaces_interaction_id, + 'expires_at': _datetime_to_epoch(row.expires_at), + 'submitted_at': _datetime_to_epoch(row.submitted_at), + 'submission': _json_loads(row.submission_json, None), + 'status_reason': row.status_reason, + 'created_at': _datetime_to_epoch(row.created_at), + 'updated_at': _datetime_to_epoch(row.updated_at), + } diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index c2743ecbf..31a578b8d 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -22,6 +22,7 @@ from .execution_context import ( ) from .host_models import AgentBinding, AgentEventEnvelope from .invoker import AgentRunnerInvoker +from .interaction_manager import InteractionManager from .query_bridge import QueryRunBridge from .registry import AgentRunnerRegistry from .resource_builder import AgentResourceBuilder @@ -51,6 +52,7 @@ class AgentRunOrchestrator: binding_resolver: AgentBindingResolver query_bridge: QueryRunBridge invoker: AgentRunnerInvoker + interaction_manager: InteractionManager journal: AgentRunJournal _session_registry: AgentRunSessionRegistry @@ -67,6 +69,7 @@ class AgentRunOrchestrator: self.binding_resolver = AgentBindingResolver() self.query_bridge = QueryRunBridge(self.binding_resolver) self.invoker = AgentRunnerInvoker(ap) + self.interaction_manager = InteractionManager(ap) self.journal = AgentRunJournal(ap) self._session_registry = get_session_registry() @@ -130,6 +133,8 @@ class AgentRunOrchestrator: run_authorization = { 'runner_id': descriptor.id, 'binding_id': binding.binding_id, + 'processor_type': binding.processor_type, + 'processor_id': binding.processor_id, 'plugin_identity': descriptor.get_plugin_id(), 'resources': resources, 'available_apis': available_apis, @@ -252,6 +257,17 @@ class AgentRunOrchestrator: await self.result_normalizer.normalize(result_dict, descriptor) continue + if result_type == 'action.requested': + if await self.interaction_manager.handle_result( + result_dict=result_dict, + event=event, + binding=binding, + descriptor=descriptor, + run_id=run_id, + adapter_context=adapter_context, + ): + continue + if result_type == 'run.completed': terminal_status = 'completed' terminal_reason = ( diff --git a/src/langbot/pkg/agent/runner/query_entry_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py index b72d3949b..e30f80d15 100644 --- a/src/langbot/pkg/agent/runner/query_entry_adapter.py +++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py @@ -7,6 +7,7 @@ Protocol v1 architecture without exposing Query internals to runners. from __future__ import annotations import hashlib +import math import typing from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query @@ -129,16 +130,25 @@ class QueryEntryAdapter: delivery_policy = DeliveryPolicy( enable_streaming=True, enable_reply=True, + enable_interactions=True, + ) + variables = getattr(query, 'variables', None) or {} + event_type = ( + runner_events.INTERACTION_SUBMITTED + if isinstance(variables.get('_interaction_submission'), dict) + else runner_events.MESSAGE_RECEIVED ) return AgentConfig( agent_id=agent_id, + processor_type='pipeline', + processor_id=agent_id, runner_id=runner_id, runner_config=runner_config, resource_policy=resource_policy, state_policy=state_policy, delivery_policy=delivery_policy, - event_types=[runner_events.MESSAGE_RECEIVED], + event_types=[event_type], enabled=True, metadata={'source': 'pipeline_adapter'}, ) @@ -217,22 +227,37 @@ class QueryEntryAdapter: if message_id == -1: message_id = None - event_time = None - if message_event: - event_time = getattr(message_event, 'time', None) - if isinstance(event_time, (int, float)): - event_time = int(event_time) + event_time = cls._normalize_event_time(getattr(message_event, 'time', None) if message_event else None) + + variables = getattr(query, 'variables', None) or {} + interaction = variables.get('_interaction_submission') + event_type = runner_events.MESSAGE_RECEIVED + if isinstance(interaction, dict): + event_type = runner_events.INTERACTION_SUBMITTED + event_data['interaction'] = interaction source_event_id = str(message_id or query.query_id) return AgentEventContext( event_id=cls._build_scoped_event_id(query, source_event_id, event_time), - event_type=runner_events.MESSAGE_RECEIVED, + event_type=event_type, event_time=event_time, source='host_adapter', source_event_type=source_event_type, data=event_data, ) + @staticmethod + def _normalize_event_time(value: typing.Any) -> int | None: + """Normalize legacy second/millisecond/microsecond Unix timestamps.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + timestamp = float(value) + if not math.isfinite(timestamp): + return None + while abs(timestamp) > 10_000_000_000: + timestamp /= 1000 + return int(timestamp) + @classmethod def _compact_event_data( cls, @@ -370,6 +395,15 @@ class QueryEntryAdapter: if launcher_type is not None: launcher_type_value = getattr(launcher_type, 'value', launcher_type) + variables = getattr(query, 'variables', None) or {} + interaction = variables.get('_interaction_submission') + if isinstance(interaction, dict): + return SubjectContext( + subject_type='interaction', + subject_id=str(interaction.get('interaction_id') or ''), + data={'action_id': interaction.get('action_id')}, + ) + return SubjectContext( subject_type='message', subject_id=str(message_id or query_id or ''), @@ -435,11 +469,16 @@ class QueryEntryAdapter: attachments = cls._build_attachments(query, contents) - return AgentInput( - text=text, - contents=contents, - attachments=attachments, - ) + input_data: dict[str, typing.Any] = { + 'text': text, + 'contents': contents, + 'attachments': attachments, + } + variables = getattr(query, 'variables', None) or {} + interaction = variables.get('_interaction_submission') + if isinstance(interaction, dict) and 'interaction' in AgentInput.model_fields: + input_data['interaction'] = interaction + return AgentInput.model_validate(input_data) @classmethod def _build_attachments( @@ -570,16 +609,42 @@ class QueryEntryAdapter: ) -> DeliveryContext: """Build DeliveryContext from Query.""" message_chain = getattr(query, 'message_chain', None) - return DeliveryContext( - surface='platform', - reply_target={ + launcher_type = getattr(query, 'launcher_type', None) + target_type = getattr(launcher_type, 'value', launcher_type) + target_id = getattr(query, 'launcher_id', None) + adapter = getattr(query, 'adapter', None) + supported_apis: list[str] = [] + get_supported_apis = getattr(adapter, 'get_supported_apis', None) + if callable(get_supported_apis): + try: + declared_apis = get_supported_apis() + if isinstance(declared_apis, (list, tuple, set)): + supported_apis = list(dict.fromkeys(api for api in declared_apis if isinstance(api, str) and api)) + except Exception: + pass + + delivery_data: dict[str, typing.Any] = { + 'surface': 'platform', + 'reply_target': { + 'target_type': target_type, + 'target_id': target_id, 'message_id': getattr(message_chain, 'message_id', None), }, - supports_streaming=True, - supports_edit=False, - supports_reaction=False, - platform_capabilities={}, - ) + 'supports_streaming': True, + 'supports_edit': 'edit_message' in supported_apis, + 'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)), + 'platform_capabilities': { + 'adapter': adapter.__class__.__name__ if adapter is not None else None, + 'supported_apis': supported_apis, + }, + } + if 'interactions' in DeliveryContext.model_fields and 'interaction.request' in supported_apis: + get_capabilities = getattr(adapter, 'get_interaction_capabilities', None) + if callable(get_capabilities): + capabilities = get_capabilities() + if isinstance(capabilities, dict): + delivery_data['interactions'] = capabilities + return DeliveryContext.model_validate(delivery_data) @classmethod def _build_raw_ref( diff --git a/src/langbot/pkg/agent/runner/result_normalizer.py b/src/langbot/pkg/agent/runner/result_normalizer.py index 3f4d34d92..bb47c873a 100644 --- a/src/langbot/pkg/agent/runner/result_normalizer.py +++ b/src/langbot/pkg/agent/runner/result_normalizer.py @@ -1,4 +1,5 @@ """Agent result normalizer for converting AgentRunResult to Pipeline messages.""" + from __future__ import annotations import typing @@ -57,7 +58,7 @@ class AgentResultNormalizer: - state.updated - run.completed - run.failed - - action.requested (log only, don't execute) + - action.requested (non-whitelisted actions are telemetry only) """ ap: app.Application @@ -91,6 +92,7 @@ class AgentResultNormalizer: # Validate result size try: import json + result_json = json.dumps(result_dict) if len(result_json) > MAX_RESULT_SIZE_BYTES: self.ap.logger.warning( @@ -120,16 +122,12 @@ class AgentResultNormalizer: elif result_type == 'tool.call.started': # Log only, don't yield to pipeline - self.ap.logger.debug( - f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}' - ) + self.ap.logger.debug(f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}') return None elif result_type == 'tool.call.completed': # Log only, don't yield to pipeline - self.ap.logger.debug( - f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}' - ) + self.ap.logger.debug(f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}') return None elif result_type == 'state.updated': @@ -161,10 +159,9 @@ class AgentResultNormalizer: ) elif result_type == 'action.requested': - # Reserved for EBA - log only, don't execute + # The orchestrator consumes whitelisted actions before normalization. self.ap.logger.info( - f'Runner {descriptor.id} requested action (not executed in current phase): ' - f'{data.get("action", "unknown")}' + f'Runner {descriptor.id} requested unsupported action (not executed): {data.get("action", "unknown")}' ) return None diff --git a/src/langbot/pkg/entity/persistence/agent_interaction.py b/src/langbot/pkg/entity/persistence/agent_interaction.py new file mode 100644 index 000000000..c698f3b80 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/agent_interaction.py @@ -0,0 +1,61 @@ +"""Host-owned structured interaction persistence entities.""" + +from __future__ import annotations + +import datetime + +import sqlalchemy + +from .base import Base + + +class AgentInteraction(Base): + """Persist one interaction request and its eventual submission.""" + + __tablename__ = 'agent_interaction' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + interaction_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + binding_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + processor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) + processor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) + request_json = sqlalchemy.Column(sqlalchemy.Text, nullable=False) + delivery_target_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + delivery_result_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + replaces_interaction_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + callback_token_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=False, unique=True, index=True) + + expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True) + submitted_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + submission_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + status_reason = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + default=datetime.datetime.utcnow, + onupdate=datetime.datetime.utcnow, + ) + + __table_args__ = ( + sqlalchemy.UniqueConstraint('run_id', 'interaction_id', name='uq_agent_interaction_run_interaction'), + sqlalchemy.Index( + 'ix_agent_interaction_scope_status', + 'bot_id', + 'conversation_id', + 'actor_id', + 'status', + ), + sqlalchemy.Index('ix_agent_interaction_processor_status', 'processor_type', 'processor_id', 'status'), + ) diff --git a/src/langbot/pkg/persistence/alembic/env.py b/src/langbot/pkg/persistence/alembic/env.py index 403c6ae51..50e092402 100644 --- a/src/langbot/pkg/persistence/alembic/env.py +++ b/src/langbot/pkg/persistence/alembic/env.py @@ -17,6 +17,7 @@ from langbot.pkg.entity.persistence.base import Base # This is required for autogenerate to detect model changes from langbot.pkg.entity.persistence import ( agent, # noqa: F401 + agent_interaction, # noqa: F401 agent_run, # noqa: F401 agent_runner_state, # noqa: F401 apikey, # noqa: F401 diff --git a/src/langbot/pkg/persistence/alembic/versions/0013_add_agent_interactions.py b/src/langbot/pkg/persistence/alembic/versions/0013_add_agent_interactions.py new file mode 100644 index 000000000..b02566792 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0013_add_agent_interactions.py @@ -0,0 +1,83 @@ +"""Add Host-owned structured interaction records. + +Revision ID: 0013_agent_interactions +Revises: 0012_monitoring_tool_calls +""" + +from alembic import op +import sqlalchemy as sa + + +revision = '0013_agent_interactions' +down_revision = '0012_monitoring_tool_calls' +branch_labels = None +depends_on = None + + +_INDEXES = { + 'ix_agent_interaction_actor_id': (['actor_id'], False), + 'ix_agent_interaction_binding_id': (['binding_id'], False), + 'ix_agent_interaction_bot_id': (['bot_id'], False), + 'ix_agent_interaction_callback_token_hash': (['callback_token_hash'], True), + 'ix_agent_interaction_conversation_id': (['conversation_id'], False), + 'ix_agent_interaction_expires_at': (['expires_at'], False), + 'ix_agent_interaction_processor_id': (['processor_id'], False), + 'ix_agent_interaction_processor_status': (['processor_type', 'processor_id', 'status'], False), + 'ix_agent_interaction_processor_type': (['processor_type'], False), + 'ix_agent_interaction_run_id': (['run_id'], False), + 'ix_agent_interaction_runner_id': (['runner_id'], False), + 'ix_agent_interaction_scope_status': (['bot_id', 'conversation_id', 'actor_id', 'status'], False), + 'ix_agent_interaction_status': (['status'], False), +} + + +def _table_exists() -> bool: + return 'agent_interaction' in sa.inspect(op.get_bind()).get_table_names() + + +def _index_names() -> set[str]: + if not _table_exists(): + return set() + return {index['name'] for index in sa.inspect(op.get_bind()).get_indexes('agent_interaction')} + + +def upgrade() -> None: + if not _table_exists(): + op.create_table( + 'agent_interaction', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('interaction_id', sa.String(length=255), nullable=False), + sa.Column('run_id', sa.String(length=255), nullable=False), + sa.Column('binding_id', sa.String(length=255), nullable=False), + sa.Column('runner_id', sa.String(length=255), nullable=False), + sa.Column('processor_type', sa.String(length=50), nullable=False), + sa.Column('processor_id', sa.String(length=255), nullable=False), + sa.Column('bot_id', sa.String(length=255), nullable=True), + sa.Column('workspace_id', sa.String(length=255), nullable=True), + sa.Column('conversation_id', sa.String(length=255), nullable=True), + sa.Column('thread_id', sa.String(length=255), nullable=True), + sa.Column('actor_id', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('request_json', sa.Text(), nullable=False), + sa.Column('delivery_target_json', sa.Text(), nullable=True), + sa.Column('callback_token_hash', sa.String(length=64), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('submitted_at', sa.DateTime(), nullable=True), + sa.Column('submission_json', sa.Text(), nullable=True), + sa.Column('status_reason', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('callback_token_hash'), + sa.UniqueConstraint('run_id', 'interaction_id', name='uq_agent_interaction_run_interaction'), + ) + + existing_indexes = _index_names() + for index_name, (columns, unique) in _INDEXES.items(): + if index_name not in existing_indexes: + op.create_index(index_name, 'agent_interaction', columns, unique=unique) + + +def downgrade() -> None: + if _table_exists(): + op.drop_table('agent_interaction') diff --git a/src/langbot/pkg/persistence/alembic/versions/0014_add_interaction_delivery_result.py b/src/langbot/pkg/persistence/alembic/versions/0014_add_interaction_delivery_result.py new file mode 100644 index 000000000..35ea08837 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0014_add_interaction_delivery_result.py @@ -0,0 +1,42 @@ +"""Persist interaction presentation handles for in-place updates. + +Revision ID: 0014_interaction_delivery +Revises: 0013_agent_interactions +""" + +from alembic import op +import sqlalchemy as sa + + +revision = '0014_interaction_delivery' +down_revision = '0013_agent_interactions' +branch_labels = None +depends_on = None + + +def _column_names() -> set[str]: + bind = op.get_bind() + if 'agent_interaction' not in sa.inspect(bind).get_table_names(): + return set() + return {column['name'] for column in sa.inspect(bind).get_columns('agent_interaction')} + + +def upgrade() -> None: + columns = _column_names() + if not columns: + return + if 'delivery_result_json' not in columns: + op.add_column('agent_interaction', sa.Column('delivery_result_json', sa.Text(), nullable=True)) + if 'replaces_interaction_id' not in columns: + op.add_column( + 'agent_interaction', + sa.Column('replaces_interaction_id', sa.String(length=255), nullable=True), + ) + + +def downgrade() -> None: + columns = _column_names() + if 'replaces_interaction_id' in columns: + op.drop_column('agent_interaction', 'replaces_interaction_id') + if 'delivery_result_json' in columns: + op.drop_column('agent_interaction', 'delivery_result_json') diff --git a/src/langbot/pkg/pipeline/aggregator.py b/src/langbot/pkg/pipeline/aggregator.py index 96358e329..599fd7e93 100644 --- a/src/langbot/pkg/pipeline/aggregator.py +++ b/src/langbot/pkg/pipeline/aggregator.py @@ -38,6 +38,7 @@ class PendingMessage: adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter pipeline_uuid: typing.Optional[str] routed_by_rule: bool = False + variables: dict[str, typing.Any] | None = None timestamp: float = field(default_factory=time.time) @@ -127,6 +128,7 @@ class MessageAggregator: adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, pipeline_uuid: typing.Optional[str] = None, routed_by_rule: bool = False, + variables: dict[str, typing.Any] | None = None, ) -> None: """Add a message to the aggregation buffer @@ -135,6 +137,8 @@ class MessageAggregator: merged with other messages from the same session. """ enabled, delay = await self._get_aggregation_config(pipeline_uuid) + if variables: + enabled = False if not enabled: # Aggregation disabled, send directly to query pool @@ -148,6 +152,7 @@ class MessageAggregator: adapter=adapter, pipeline_uuid=pipeline_uuid, routed_by_rule=routed_by_rule, + variables=variables, ) return @@ -163,6 +168,7 @@ class MessageAggregator: adapter=adapter, pipeline_uuid=pipeline_uuid, routed_by_rule=routed_by_rule, + variables=variables, ) force_flush = False @@ -222,6 +228,7 @@ class MessageAggregator: adapter=msg.adapter, pipeline_uuid=msg.pipeline_uuid, routed_by_rule=msg.routed_by_rule, + variables=msg.variables, ) return @@ -237,6 +244,7 @@ class MessageAggregator: adapter=merged_msg.adapter, pipeline_uuid=merged_msg.pipeline_uuid, routed_by_rule=merged_msg.routed_by_rule, + variables=merged_msg.variables, ) def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage: @@ -276,6 +284,7 @@ class MessageAggregator: adapter=base_msg.adapter, pipeline_uuid=base_msg.pipeline_uuid, routed_by_rule=any(msg.routed_by_rule for msg in messages), + variables=base_msg.variables, ) async def flush_all(self) -> None: diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index f10c92ed9..ed811adc3 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -179,7 +179,7 @@ class RuntimePipeline: bot_message=query.resp_messages[-1], message=result.user_notice, quote_origin=query.pipeline_config['output']['misc']['quote-origin'], - is_final=[msg.is_final for msg in query.resp_messages][-1], + is_final=query.resp_messages[-1].is_final, ) else: await query.adapter.reply_message( diff --git a/src/langbot/pkg/pipeline/pool.py b/src/langbot/pkg/pipeline/pool.py index 55ce7fe12..620722be9 100644 --- a/src/langbot/pkg/pipeline/pool.py +++ b/src/langbot/pkg/pipeline/pool.py @@ -42,7 +42,7 @@ class QueryPool: adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, pipeline_uuid: typing.Optional[str] = None, routed_by_rule: bool = False, - variables: typing.Optional[dict[str, typing.Any]] = None, + variables: dict[str, typing.Any] | None = None, ) -> pipeline_query.Query: async with self.condition: query_id = self.query_id_counter diff --git a/src/langbot/pkg/pipeline/respback/respback.py b/src/langbot/pkg/pipeline/respback/respback.py index 6d8248aef..d0e8739fc 100644 --- a/src/langbot/pkg/pipeline/respback/respback.py +++ b/src/langbot/pkg/pipeline/respback/respback.py @@ -43,9 +43,12 @@ class SendResponseBackStage(stage.PipelineStage): response_index = len(query.resp_message_chain) - 1 message_chain = query.resp_message_chain[-1] + is_streaming_response = False + is_final = False try: - if await query.adapter.is_stream_output_supported() and has_chunks: - is_final = [msg.is_final for msg in query.resp_messages][-1] + is_streaming_response = await query.adapter.is_stream_output_supported() and has_chunks + if is_streaming_response: + is_final = query.resp_messages[-1].is_final await query.adapter.reply_message_chunk( message_source=query.message_event, bot_message=query.resp_messages[-1], @@ -68,6 +71,24 @@ class SendResponseBackStage(stage.PipelineStage): e, ) plugin_diagnostics.clear_response_source(query, response_index) + if is_streaming_response: + self.ap.logger.warning( + 'Streaming response delivery failed; continuing runner event consumption: %s', + e, + ) + if is_final: + try: + await query.adapter.reply_message( + message_source=query.message_event, + message=message_chain, + quote_origin=quote_origin, + ) + except Exception as fallback_error: + self.ap.logger.warning( + 'Final non-streaming response fallback also failed: %s', + fallback_error, + ) + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) raise plugin_diagnostics.clear_response_source(query, response_index) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py index 8993a696f..5bc33919a 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py @@ -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)}' diff --git a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py new file mode 100644 index 000000000..2e513a8a0 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py @@ -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, + ) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml index c8dc5f117..c4d60c15f 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml @@ -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 diff --git a/src/langbot/pkg/platform/adapters/discord/adapter.py b/src/langbot/pkg/platform/adapters/discord/adapter.py index 6295145f0..6aaf97164 100644 --- a/src/langbot/pkg/platform/adapters/discord/adapter.py +++ b/src/langbot/pkg/platform/adapters/discord/adapter.py @@ -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 diff --git a/src/langbot/pkg/platform/adapters/discord/interaction.py b/src/langbot/pkg/platform/adapters/discord/interaction.py new file mode 100644 index 000000000..91831599d --- /dev/null +++ b/src/langbot/pkg/platform/adapters/discord/interaction.py @@ -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, + ) diff --git a/src/langbot/pkg/platform/adapters/discord/manifest.yaml b/src/langbot/pkg/platform/adapters/discord/manifest.yaml index 3dc285a12..e323b9ebd 100644 --- a/src/langbot/pkg/platform/adapters/discord/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/discord/manifest.yaml @@ -60,6 +60,7 @@ spec: - kick_member - leave_group - call_platform_api + - interaction.request platform_specific_apis: - action: get_channel diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py index 3f2767e48..136211d2d 100644 --- a/src/langbot/pkg/platform/adapters/lark/adapter.py +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -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') diff --git a/src/langbot/pkg/platform/adapters/lark/interaction.py b/src/langbot/pkg/platform/adapters/lark/interaction.py new file mode 100644 index 000000000..05601ca76 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/interaction.py @@ -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'), + ) diff --git a/src/langbot/pkg/platform/adapters/lark/manifest.yaml b/src/langbot/pkg/platform/adapters/lark/manifest.yaml index 9afca06e4..567f81a2d 100644 --- a/src/langbot/pkg/platform/adapters/lark/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/lark/manifest.yaml @@ -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 diff --git a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py index 47831a389..cc6726a9c 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py @@ -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: diff --git a/src/langbot/pkg/platform/adapters/qqofficial/interaction.py b/src/langbot/pkg/platform/adapters/qqofficial/interaction.py new file mode 100644 index 000000000..1d84fea02 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/qqofficial/interaction.py @@ -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, + ) diff --git a/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml b/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml index 66fdd67b4..e2e148745 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml @@ -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 diff --git a/src/langbot/pkg/platform/adapters/telegram/adapter.py b/src/langbot/pkg/platform/adapters/telegram/adapter.py index fc92ab969..615c2ecde 100644 --- a/src/langbot/pkg/platform/adapters/telegram/adapter.py +++ b/src/langbot/pkg/platform/adapters/telegram/adapter.py @@ -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 diff --git a/src/langbot/pkg/platform/adapters/telegram/event_converter.py b/src/langbot/pkg/platform/adapters/telegram/event_converter.py index 10dabe39c..fc818bfe6 100644 --- a/src/langbot/pkg/platform/adapters/telegram/event_converter.py +++ b/src/langbot/pkg/platform/adapters/telegram/event_converter.py @@ -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, diff --git a/src/langbot/pkg/platform/adapters/telegram/interaction.py b/src/langbot/pkg/platform/adapters/telegram/interaction.py new file mode 100644 index 000000000..def46b1be --- /dev/null +++ b/src/langbot/pkg/platform/adapters/telegram/interaction.py @@ -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, + ) diff --git a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml index 7e6daa14f..0cae35b50 100644 --- a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml @@ -72,6 +72,7 @@ spec: - kick_member - leave_group - call_platform_api + - interaction.request platform_specific_apis: - action: pin_message diff --git a/src/langbot/pkg/platform/adapters/wecombot/adapter.py b/src/langbot/pkg/platform/adapters/wecombot/adapter.py index 0ba7864ca..9cf055e6a 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/adapter.py +++ b/src/langbot/pkg/platform/adapters/wecombot/adapter.py @@ -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] diff --git a/src/langbot/pkg/platform/adapters/wecombot/interaction.py b/src/langbot/pkg/platform/adapters/wecombot/interaction.py new file mode 100644 index 000000000..fdcbd9fbd --- /dev/null +++ b/src/langbot/pkg/platform/adapters/wecombot/interaction.py @@ -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, + ) diff --git a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml index e403a1ed9..cef86d4bb 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml @@ -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 diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 50cf4f5f4..3c7da2d2f 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -996,6 +996,27 @@ class RuntimeBot: elif getattr(event, 'session_id', None): conversation_id = str(getattr(event, 'session_id')) + delivery_data: dict[str, typing.Any] = { + 'surface': 'platform', + 'reply_target': { + 'target_type': target_type, + 'target_id': target_id, + 'message_id': getattr(event, 'message_id', None), + **target_metadata, + }, + 'supports_streaming': False, + 'supports_edit': 'edit_message' in supported_apis, + 'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)), + 'platform_capabilities': { + 'adapter': adapter.__class__.__name__, + 'event_type': event_type, + 'supported_apis': supported_apis, + }, + } + interaction_capabilities = self._get_adapter_interaction_capabilities(adapter, supported_apis) + if interaction_capabilities is not None: + delivery_data['interactions'] = interaction_capabilities + return AgentEventEnvelope( event_id=f'platform:{self.bot_entity.uuid}:{event_id}', event_type=event_type, @@ -1009,23 +1030,7 @@ class RuntimeBot: actor=self._infer_actor_context(event), subject=self._infer_subject_context(event), input=self._build_agent_input(event), - delivery=DeliveryContext( - surface='platform', - reply_target={ - 'target_type': target_type, - 'target_id': target_id, - 'message_id': getattr(event, 'message_id', None), - **target_metadata, - }, - supports_streaming=False, - supports_edit='edit_message' in supported_apis, - supports_reaction=bool({'add_reaction', 'remove_reaction'} & set(supported_apis)), - platform_capabilities={ - 'adapter': adapter.__class__.__name__, - 'event_type': event_type, - 'supported_apis': supported_apis, - }, - ), + delivery=DeliveryContext.model_validate(delivery_data), raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None), data=self._compact_event_data(event), ) @@ -1045,6 +1050,22 @@ class RuntimeBot: return [] return list(dict.fromkeys(api_name for api_name in declared_apis if isinstance(api_name, str) and api_name)) + @staticmethod + def _get_adapter_interaction_capabilities( + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + supported_apis: list[str], + ) -> dict[str, typing.Any] | None: + if 'interactions' not in DeliveryContext.model_fields or 'interaction.request' not in supported_apis: + return None + get_capabilities = getattr(adapter, 'get_interaction_capabilities', None) + if not callable(get_capabilities): + return None + try: + capabilities = get_capabilities() + except Exception: + return None + return capabilities if isinstance(capabilities, dict) else None + @staticmethod def _agent_product_to_binding( agent: dict[str, typing.Any], @@ -1068,9 +1089,15 @@ class RuntimeBot: runner_config=runner_config, resource_policy=ResourcePolicyProjector.from_runner_config(runner_config), state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']), - delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True), + delivery_policy=DeliveryPolicy( + enable_streaming=False, + enable_reply=True, + enable_interactions=True, + ), enabled=True, agent_id=agent.get('uuid'), + processor_type='agent', + processor_id=agent.get('uuid'), ) @staticmethod @@ -1137,6 +1164,10 @@ class RuntimeBot: event: platform_events.EBAEvent, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, ) -> None: + if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted': + await self._handle_interaction_submission(event, adapter) + return + event.bot_uuid = self.bot_entity.uuid plugin_event = self._eba_event_to_plugin_event(event) @@ -1303,7 +1334,11 @@ class RuntimeBot: envelope = self._eba_event_to_agent_envelope(event, adapter) outputs: list[provider_message.Message | provider_message.MessageChunk] = [] try: - async for output in self.ap.agent_run_orchestrator.run(envelope, binding): + async for output in self.ap.agent_run_orchestrator.run( + envelope, + binding, + adapter_context={'_delivery_adapter': adapter}, + ): outputs.append(output) except Exception: return await self._record_event_route_trace( @@ -1408,6 +1443,7 @@ class RuntimeBot: adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, pipeline_uuid_override: str | None = None, routed_by_event_binding: bool = False, + variables: dict[str, typing.Any] | None = None, ) -> None: is_group_message = isinstance(event, platform_events.GroupMessage) launcher_kind = 'group' if is_group_message else 'person' @@ -1475,8 +1511,197 @@ class RuntimeBot: adapter=adapter, pipeline_uuid=pipeline_uuid, routed_by_rule=routed_by_rule, + variables=variables, ) + async def _handle_interaction_submission( + self, + event: platform_events.PlatformSpecificEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ) -> None: + """Consume a platform interaction callback and resume its original processor.""" + data = event.data if isinstance(event.data, dict) else {} + callback_token = str(data.get('callback_token') or '') + actor_id = str(data.get('actor_id') or data.get('user_id') or '') or None + target_type = str(data.get('target_type') or '') or None + target_id = str(data.get('target_id') or data.get('chat_id') or '') or None + conversation_id = f'{target_type}_{target_id}' if target_type and target_id else None + submission = { + 'interaction_id': data.get('interaction_id'), + 'action_id': data.get('action_id'), + 'values': data.get('values') if isinstance(data.get('values'), dict) else {}, + 'submitted_at': int(event.timestamp) if event.timestamp else None, + } + for ref_name in ('action_ref', 'field_ref', 'option_ref'): + if data.get(ref_name) is not None: + submission[ref_name] = data[ref_name] + + record = await self.ap.agent_run_orchestrator.interaction_manager.consume_callback( + callback_token=callback_token, + submission=submission, + bot_id=self.bot_entity.uuid, + conversation_id=conversation_id, + actor_id=actor_id, + ) + await self.ap.agent_run_orchestrator.interaction_manager.acknowledge_submission(record, adapter) + + if record['processor_type'] == 'agent': + await self._resume_agent_interaction(record, event, adapter, actor_id) + return + if record['processor_type'] != 'pipeline': + raise ValueError(f'Unsupported interaction processor type: {record["processor_type"]}') + + pipeline = await self.ap.pipeline_service.get_pipeline(record['processor_id']) + if not pipeline: + raise ValueError(f'Interaction target Pipeline is unavailable: {record["processor_id"]}') + current_runner_id = RunnerConfigResolver.resolve_runner_id(pipeline.get('config') or {}) + if current_runner_id != record['runner_id']: + raise ValueError('Interaction target Pipeline runner changed after the request was created') + + message_components: list[platform_message.MessageComponent] = [] + callback_message_id = data.get('message_id') + if callback_message_id: + message_components.append( + platform_message.Source( + id=str(callback_message_id), + time=int(event.timestamp) if event.timestamp else int(time.time()), + ) + ) + message_components.append( + platform_message.Plain(text=str(data.get('display_text') or data.get('action_id') or 'submitted')) + ) + message_chain = platform_message.MessageChain(message_components) + delivery_target = record.get('delivery_target') or {} + original_target_type = delivery_target.get('target_type') + original_target_id = delivery_target.get('target_id') + if original_target_type == 'group': + group = platform_entities.Group( + id=original_target_id, + name='', + permission=platform_entities.Permission.Member, + ) + message_event: platform_events.MessageEvent = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=actor_id or '', + member_name='', + permission=platform_entities.Permission.Member, + group=group, + ), + message_chain=message_chain, + time=event.timestamp, + source_platform_object=event.source_platform_object, + ) + else: + message_event = platform_events.FriendMessage( + sender=platform_entities.Friend(id=actor_id or '', nickname='', remark=''), + message_chain=message_chain, + time=event.timestamp, + source_platform_object=event.source_platform_object, + ) + + launcher_type = ( + provider_session.LauncherTypes.GROUP + if original_target_type == 'group' + else provider_session.LauncherTypes.PERSON + ) + await self.ap.msg_aggregator.add_message( + bot_uuid=self.bot_entity.uuid, + launcher_type=launcher_type, + launcher_id=original_target_id or target_id or actor_id or '', + sender_id=actor_id or '', + message_event=message_event, + message_chain=message_chain, + adapter=adapter, + pipeline_uuid=record['processor_id'], + routed_by_rule=True, + variables={'_interaction_submission': record['submission']}, + ) + + async def _resume_agent_interaction( + self, + record: dict[str, typing.Any], + event: platform_events.PlatformSpecificEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + actor_id: str | None, + ) -> None: + """Resume the exact independent Agent that produced an interaction.""" + agent = await self.ap.agent_service.get_agent(record['processor_id']) + if not agent or agent.get('kind') != 'agent' or not agent.get('enabled', True): + raise ValueError(f'Interaction target Agent is unavailable: {record["processor_id"]}') + + binding = self._agent_product_to_binding( + agent, + {'id': record['binding_id']}, + 'interaction.submitted', + self.bot_entity.uuid, + ) + if binding is None or binding.runner_id != record['runner_id']: + raise ValueError('Interaction target Agent runner changed after the request was created') + binding.binding_id = record['binding_id'] + + submission = record['submission'] + display_text = str( + (submission or {}).get('action_id') + or (event.data if isinstance(event.data, dict) else {}).get('display_text') + or 'submitted' + ) + input_data: dict[str, typing.Any] = { + 'text': display_text, + 'contents': [{'type': 'text', 'text': display_text}], + 'attachments': [], + } + if 'interaction' in AgentInput.model_fields: + input_data['interaction'] = submission + + delivery_target = record.get('delivery_target') or {} + supported_apis = self._get_adapter_supported_apis(adapter) + delivery_data: dict[str, typing.Any] = { + 'surface': 'platform', + 'reply_target': delivery_target, + 'supports_streaming': False, + 'supports_edit': 'edit_message' in supported_apis, + 'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)), + 'platform_capabilities': { + 'adapter': adapter.__class__.__name__, + 'event_type': 'interaction.submitted', + 'supported_apis': supported_apis, + }, + } + interaction_capabilities = self._get_adapter_interaction_capabilities(adapter, supported_apis) + if interaction_capabilities is not None: + delivery_data['interactions'] = interaction_capabilities + + envelope = AgentEventEnvelope( + event_id=f'interaction:{record["id"]}:{uuid.uuid4()}', + event_type='interaction.submitted', + event_time=int(event.timestamp) if event.timestamp else int(time.time()), + source='platform', + source_event_type='interaction.submitted', + bot_id=self.bot_entity.uuid, + workspace_id=record.get('workspace_id'), + conversation_id=record.get('conversation_id'), + thread_id=record.get('thread_id'), + actor=ActorContext(actor_type='user', actor_id=actor_id), + subject=SubjectContext( + subject_type='interaction', + subject_id=record['interaction_id'], + data={'action_id': (submission or {}).get('action_id')}, + ), + input=AgentInput.model_validate(input_data), + delivery=DeliveryContext.model_validate(delivery_data), + raw_ref=RawEventRef(ref_id=f'interaction:{record["id"]}', storage_key=None), + data={'interaction': submission}, + ) + + outputs: list[provider_message.Message | provider_message.MessageChunk] = [] + async for output in self.ap.agent_run_orchestrator.run( + envelope, + binding, + adapter_context={'_delivery_adapter': adapter}, + ): + outputs.append(output) + await self._deliver_agent_outputs(envelope, outputs, adapter=adapter) + async def _dispatch_eba_message_to_pipeline( self, event: platform_events.EBAEvent, @@ -1523,8 +1748,20 @@ class RuntimeBot: return await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter) - self.adapter.register_listener(platform_events.FriendMessage, on_friend_message) - self.adapter.register_listener(platform_events.GroupMessage, on_group_message) + get_supported_events = getattr(self.adapter, 'get_supported_events', None) + supported_events: list[str] = [] + if callable(get_supported_events): + try: + supported_events = list(get_supported_events() or []) + except Exception: + supported_events = [] + + # EBA adapters emit MessageReceivedEvent directly. Registering both + # entry paths would process each native message twice because these + # adapters also expose legacy conversion for compatibility consumers. + if 'message.received' not in supported_events: + self.adapter.register_listener(platform_events.FriendMessage, on_friend_message) + self.adapter.register_listener(platform_events.GroupMessage, on_group_message) # Register feedback listener (only effective on adapters that support it) async def on_feedback( diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 8084c805c..53468e39a 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from langbot.pkg.entity.persistence import ( agent as agent_models, + agent_interaction as agent_interaction_models, agent_run as agent_run_models, agent_runner_state as agent_runner_state_models, bot as bot_models, @@ -57,7 +58,7 @@ class TestAlembicRevisionGraph: revisions = list(script.walk_revisions()) assert script.get_bases() == ['0001_baseline'] - assert script.get_heads() == ['0012_monitoring_tool_calls'] + assert script.get_heads() == ['0014_interaction_delivery'] assert all(len(item.revision) <= 32 for item in revisions), { item.revision: len(item.revision) for item in revisions if len(item.revision) > 32 } @@ -194,6 +195,7 @@ class TestSQLiteMigrationUpgrade: expected_agent_tables = { agent_models.Agent.__tablename__, + agent_interaction_models.AgentInteraction.__tablename__, agent_run_models.AgentRun.__tablename__, agent_run_models.AgentRunEvent.__tablename__, agent_run_models.AgentRuntime.__tablename__, diff --git a/tests/unit_tests/agent/test_context_validation.py b/tests/unit_tests/agent/test_context_validation.py index 5188aefda..09c0b9a9c 100644 --- a/tests/unit_tests/agent/test_context_validation.py +++ b/tests/unit_tests/agent/test_context_validation.py @@ -1,4 +1,5 @@ """Test that LangBot context builder output validates against SDK AgentRunContext.""" + from __future__ import annotations import pytest @@ -31,7 +32,7 @@ class TestContextValidation: """Create a mock application.""" mock_app = MagicMock(spec=app.Application) mock_app.ver_mgr = MagicMock() - mock_app.ver_mgr.get_current_version = MagicMock(return_value="1.0.0") + mock_app.ver_mgr.get_current_version = MagicMock(return_value='1.0.0') mock_app.persistence_mgr = MagicMock() mock_app.persistence_mgr.get_db_engine = MagicMock() mock_app.logger = MagicMock() @@ -44,35 +45,35 @@ class TestContextValidation: from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext return AgentEventEnvelope( - event_id="evt_1", - event_type="message.received", + event_id='evt_1', + event_type='message.received', event_time=1700000000, - source="platform", - source_event_type="platform.message", - bot_id="bot_1", - workspace_id="workspace_1", - conversation_id="conv_1", + source='platform', + source_event_type='platform.message', + bot_id='bot_1', + workspace_id='workspace_1', + conversation_id='conv_1', thread_id=None, actor=ActorContext( - actor_type="user", - actor_id="user_1", - actor_name="Test User", + actor_type='user', + actor_id='user_1', + actor_name='Test User', ), subject=None, - input=EventInput(text="Hello world"), - delivery=DeliveryContext(surface="test"), - data={"platform_event_id": "source_evt_1"}, + input=EventInput(text='Hello world'), + delivery=DeliveryContext(surface='test'), + data={'platform_event_id': 'source_evt_1'}, ) def _make_binding(self) -> AgentBinding: """Create a test binding.""" return AgentBinding( - binding_id="binding_1", - scope=BindingScope(scope_type="agent", scope_id="pipeline_1"), - event_types=["message.received"], - runner_id="plugin:test/plugin/runner", - runner_config={"timeout": 300}, - agent_id="pipeline_1", + binding_id='binding_1', + scope=BindingScope(scope_type='agent', scope_id='pipeline_1'), + event_types=['message.received'], + runner_id='plugin:test/plugin/runner', + runner_config={'timeout': 300}, + agent_id='pipeline_1', enabled=True, ) @@ -91,16 +92,16 @@ class TestContextValidation: def _make_descriptor(self): """Create a mock runner descriptor.""" return AgentRunnerDescriptor( - id="plugin:test/plugin/runner", - source="plugin", - label={"en_US": "Test Runner"}, - plugin_author="test", - plugin_name="plugin", - runner_name="runner", + id='plugin:test/plugin/runner', + source='plugin', + label={'en_US': 'Test Runner'}, + plugin_author='test', + plugin_name='plugin', + runner_name='runner', permissions={ - "history": ["page", "search"], - "events": ["get", "page"], - "storage": ["plugin", "workspace"], + 'history': ['page', 'search'], + 'events': ['get', 'page'], + 'storage': ['plugin', 'workspace'], }, ) @@ -118,12 +119,14 @@ class TestContextValidation: # Mock persistent state store to return empty state snapshot with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: mock_store = AsyncMock() - mock_store.build_snapshot_from_event = AsyncMock(return_value={ - 'conversation': {}, - 'actor': {}, - 'subject': {}, - 'runner': {}, - }) + mock_store.build_snapshot_from_event = AsyncMock( + return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + } + ) mock_get_store.return_value = mock_store # Build context @@ -152,27 +155,69 @@ class TestContextValidation: assert isinstance(validated.resources, AgentResources) assert validated.runtime is not None assert isinstance(validated.runtime, AgentRuntimeContext) - assert "protocol_version" not in validated.runtime.model_dump() - assert "sdk_protocol_version" not in validated.runtime.model_dump() - assert "sdk_protocol_version" not in context_dict["runtime"] + assert 'protocol_version' not in validated.runtime.model_dump() + assert 'sdk_protocol_version' not in validated.runtime.model_dump() + assert 'sdk_protocol_version' not in context_dict['runtime'] # Verify event context - assert validated.event.event_id == "evt_1" - assert validated.event.event_type == "message.received" - assert validated.event.source == "platform" - assert validated.event.source_event_type == "platform.message" - assert validated.event.data == {"platform_event_id": "source_evt_1"} + assert validated.event.event_id == 'evt_1' + assert validated.event.event_type == 'message.received' + assert validated.event.source == 'platform' + assert validated.event.source_event_type == 'platform.message' + assert validated.event.data == {'platform_event_id': 'source_evt_1'} # Verify conversation context uses SDK field names assert validated.conversation is not None - assert validated.conversation.bot_id == "bot_1" - assert validated.conversation.workspace_id == "workspace_1" + assert validated.conversation.bot_id == 'bot_1' + assert validated.conversation.workspace_id == 'workspace_1' # Verify delivery context - assert validated.delivery.surface == "test" + assert validated.delivery.surface == 'test' # Verify input - assert validated.input.text == "Hello world" + assert validated.input.text == 'Hello world' + + @pytest.mark.asyncio + async def test_build_context_preserves_interaction_protocol_fields(self): + """Validated submissions and delivery capabilities survive the final context projection.""" + from langbot_plugin.api.entities.builtin.agent_runner.interaction import ( + InteractionDeliveryCapabilities, + InteractionSubmission, + ) + + mock_app = self._make_mock_app() + builder = AgentRunContextBuilder(mock_app) + event = self._make_event_envelope() + event.event_type = 'interaction.submitted' + event.input.interaction = InteractionSubmission( + interaction_id='form-1', + action_id='approve', + values={'comment': 'looks good'}, + ) + event.delivery.interactions = InteractionDeliveryCapabilities( + field_types=['text', 'select'], + action_styles=['primary'], + ) + + with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: + mock_store = AsyncMock() + mock_store.build_snapshot_from_event = AsyncMock( + return_value={'conversation': {}, 'actor': {}, 'subject': {}, 'runner': {}} + ) + mock_get_store.return_value = mock_store + context_dict = await builder.build_context_from_event( + event=event, + binding=self._make_binding(), + descriptor=self._make_descriptor(), + resources=self._make_resources(), + ) + + validated = AgentRunContext.model_validate(context_dict) + assert validated.input.interaction is not None + assert validated.input.interaction.interaction_id == 'form-1' + assert validated.input.interaction.values == {'comment': 'looks good'} + assert validated.delivery.interactions is not None + assert validated.delivery.interactions.field_types == ['text', 'select'] @pytest.mark.asyncio async def test_build_context_from_event_populates_model_context_window(self): @@ -207,12 +252,14 @@ class TestContextValidation: with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: mock_store = AsyncMock() - mock_store.build_snapshot_from_event = AsyncMock(return_value={ - 'conversation': {}, - 'actor': {}, - 'subject': {}, - 'runner': {}, - }) + mock_store.build_snapshot_from_event = AsyncMock( + return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + } + ) mock_get_store.return_value = mock_store context_dict = await builder.build_context_from_event( @@ -265,37 +312,39 @@ class TestContextValidation: mock_app = self._make_mock_app() builder = AgentRunContextBuilder(mock_app) event = AgentEventEnvelope( - event_id="evt_recall_1", - event_type="message.recalled", + event_id='evt_recall_1', + event_type='message.recalled', event_time=1700000001, - source="platform", - source_event_type="platform.message.recall", - bot_id="bot_1", - workspace_id="workspace_1", - conversation_id="conv_1", - actor=ActorContext(actor_type="user", actor_id="user_1"), + source='platform', + source_event_type='platform.message.recall', + bot_id='bot_1', + workspace_id='workspace_1', + conversation_id='conv_1', + actor=ActorContext(actor_type='user', actor_id='user_1'), subject=SubjectContext( - subject_type="message", - subject_id="message_1", - data={"recalled_message_id": "message_1", "reason": "user_recall"}, + subject_type='message', + subject_id='message_1', + data={'recalled_message_id': 'message_1', 'reason': 'user_recall'}, ), input=EventInput(text=None), - delivery=DeliveryContext(surface="test"), - data={"source_event_id": "source_recall_1"}, + delivery=DeliveryContext(surface='test'), + data={'source_event_id': 'source_recall_1'}, ) binding = self._make_binding() - binding.event_types = ["message.recalled"] + binding.event_types = ['message.recalled'] resources = self._make_resources() descriptor = self._make_descriptor() with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: mock_store = AsyncMock() - mock_store.build_snapshot_from_event = AsyncMock(return_value={ - 'conversation': {}, - 'actor': {}, - 'subject': {}, - 'runner': {}, - }) + mock_store.build_snapshot_from_event = AsyncMock( + return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + } + ) mock_get_store.return_value = mock_store context_dict = await builder.build_context_from_event( @@ -307,12 +356,12 @@ class TestContextValidation: validated = AgentRunContext.model_validate(context_dict) - assert validated.event.event_type == "message.recalled" + assert validated.event.event_type == 'message.recalled' assert validated.input.text is None assert validated.subject is not None - assert validated.subject.subject_type == "message" - assert validated.subject.subject_id == "message_1" - assert validated.subject.data == {"recalled_message_id": "message_1", "reason": "user_recall"} + assert validated.subject.subject_type == 'message' + assert validated.subject.subject_id == 'message_1' + assert validated.subject.data == {'recalled_message_id': 'message_1', 'reason': 'user_recall'} @pytest.mark.asyncio async def test_build_context_from_event_has_no_legacy_top_level_fields(self): @@ -328,12 +377,14 @@ class TestContextValidation: # Mock persistent state store to return empty state snapshot with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: mock_store = AsyncMock() - mock_store.build_snapshot_from_event = AsyncMock(return_value={ - 'conversation': {}, - 'actor': {}, - 'subject': {}, - 'runner': {}, - }) + mock_store.build_snapshot_from_event = AsyncMock( + return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + } + ) mock_get_store.return_value = mock_store context_dict = await builder.build_context_from_event( @@ -344,16 +395,16 @@ class TestContextValidation: ) # Protocol v1 does NOT have these as core fields - assert 'messages' not in context_dict, "messages should not be top-level in Protocol v1" - assert 'prompt' not in context_dict, "prompt should not be top-level in Protocol v1" - assert 'params' not in context_dict, "params should not be top-level in Protocol v1" + assert 'messages' not in context_dict, 'messages should not be top-level in Protocol v1' + assert 'prompt' not in context_dict, 'prompt should not be top-level in Protocol v1' + assert 'params' not in context_dict, 'params should not be top-level in Protocol v1' # Protocol v1 DOES have these - assert 'delivery' in context_dict, "delivery is REQUIRED in Protocol v1" - assert 'context' in context_dict, "context (ContextAccess) is REQUIRED in Protocol v1" - assert 'bootstrap' not in context_dict, "Host must not inline bootstrap/history windows" - assert 'adapter' in context_dict, "adapter should exist" - assert 'metadata' in context_dict, "metadata should exist" + assert 'delivery' in context_dict, 'delivery is REQUIRED in Protocol v1' + assert 'context' in context_dict, 'context (ContextAccess) is REQUIRED in Protocol v1' + assert 'bootstrap' not in context_dict, 'Host must not inline bootstrap/history windows' + assert 'adapter' in context_dict, 'adapter should exist' + assert 'metadata' in context_dict, 'metadata should exist' @pytest.mark.asyncio async def test_build_context_from_event_event_is_not_none(self): @@ -369,12 +420,14 @@ class TestContextValidation: # Mock persistent state store to return empty state snapshot with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: mock_store = AsyncMock() - mock_store.build_snapshot_from_event = AsyncMock(return_value={ - 'conversation': {}, - 'actor': {}, - 'subject': {}, - 'runner': {}, - }) + mock_store.build_snapshot_from_event = AsyncMock( + return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + } + ) mock_get_store.return_value = mock_store context_dict = await builder.build_context_from_event( @@ -385,7 +438,7 @@ class TestContextValidation: ) # event is REQUIRED in Protocol v1 - assert context_dict.get('event') is not None, "event is REQUIRED for Protocol v1" + assert context_dict.get('event') is not None, 'event is REQUIRED for Protocol v1' # Validate validated = AgentRunContext.model_validate(context_dict) @@ -405,12 +458,14 @@ class TestContextValidation: # Mock persistent state store to return empty state snapshot with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: mock_store = AsyncMock() - mock_store.build_snapshot_from_event = AsyncMock(return_value={ - 'conversation': {}, - 'actor': {}, - 'subject': {}, - 'runner': {}, - }) + mock_store.build_snapshot_from_event = AsyncMock( + return_value={ + 'conversation': {}, + 'actor': {}, + 'subject': {}, + 'runner': {}, + } + ) mock_get_store.return_value = mock_store context_dict = await builder.build_context_from_event( @@ -421,7 +476,7 @@ class TestContextValidation: ) # delivery is REQUIRED in Protocol v1 - assert context_dict.get('delivery') is not None, "delivery is REQUIRED for Protocol v1" + assert context_dict.get('delivery') is not None, 'delivery is REQUIRED for Protocol v1' # Validate validated = AgentRunContext.model_validate(context_dict) diff --git a/tests/unit_tests/agent/test_event_first_protocol.py b/tests/unit_tests/agent/test_event_first_protocol.py index 6eee1fce9..00dade85a 100644 --- a/tests/unit_tests/agent/test_event_first_protocol.py +++ b/tests/unit_tests/agent/test_event_first_protocol.py @@ -7,6 +7,7 @@ Tests cover: 4. LangBot Host not defining context-window controls 5. Event-first run() entry point """ + from __future__ import annotations import pytest @@ -38,34 +39,34 @@ class TestQueryToEventEnvelope: """Test basic field conversion from Query to Event envelope.""" event = QueryEntryAdapter.query_to_event(mock_query) - assert event.event_type == "message.received" - assert event.source == "host_adapter" + assert event.event_type == 'message.received' + assert event.source == 'host_adapter' assert event.bot_id == mock_query.bot_uuid assert event.actor is not None - assert event.actor.actor_type == "user" + assert event.actor.actor_type == 'user' def test_query_to_event_input(self, mock_query): """Test input conversion from Query.""" event = QueryEntryAdapter.query_to_event(mock_query) assert event.input is not None - assert event.input.text == "Hello world" - assert "message_chain" not in event.input.model_dump() + assert event.input.text == 'Hello world' + assert 'message_chain' not in event.input.model_dump() def test_query_to_event_conversation(self, mock_query): """Test conversation context extraction.""" event = QueryEntryAdapter.query_to_event(mock_query) - assert event.conversation_id == "conv-uuid-123" + assert event.conversation_id == 'conv-uuid-123' def test_query_to_event_prefers_variable_conversation_id_when_conversation_uuid_missing(self, mock_query): """Pipeline variables can provide the conversation identity for state scope.""" mock_query.session.using_conversation.uuid = None - mock_query.variables["conversation_id"] = "conv-from-vars" + mock_query.variables['conversation_id'] = 'conv-from-vars' event = QueryEntryAdapter.query_to_event(mock_query) - assert event.conversation_id == "conv-from-vars" + assert event.conversation_id == 'conv-from-vars' def test_query_to_event_falls_back_to_launcher_session_for_state_scope(self, mock_query): """Debug Chat and legacy pipeline runs may not have a conversation UUID.""" @@ -73,77 +74,115 @@ class TestQueryToEventEnvelope: event = QueryEntryAdapter.query_to_event(mock_query) - assert event.conversation_id == "person_launcher-123" + assert event.conversation_id == 'person_launcher-123' def test_query_to_event_delivery_context(self, mock_query): """Test delivery context extraction.""" event = QueryEntryAdapter.query_to_event(mock_query) assert event.delivery is not None - assert event.delivery.surface == "platform" + assert event.delivery.surface == 'platform' assert isinstance(event.delivery.supports_streaming, bool) + assert event.delivery.reply_target == { + 'target_type': 'person', + 'target_id': 'launcher-123', + 'message_id': 789, + } def test_query_to_event_preserves_source_event_data(self, mock_query): """Test source event metadata survives the adapter boundary.""" source_event = Mock() - source_event.type = "platform.message.created" + source_event.type = 'platform.message.created' source_event.time = 1700000000 source_event.sender = None - source_event.model_dump = Mock(return_value={ - "type": "platform.message.created", - "message_id": "source-message-1", - "source_platform_object": {"large": "payload"}, - }) + source_event.model_dump = Mock( + return_value={ + 'type': 'platform.message.created', + 'message_id': 'source-message-1', + 'source_platform_object': {'large': 'payload'}, + } + ) mock_query.message_event = source_event event = QueryEntryAdapter.query_to_event(mock_query) - assert event.source_event_type == "platform.message.created" + assert event.source_event_type == 'platform.message.created' assert event.event_time == 1700000000 assert event.data == { - "type": "platform.message.created", - "message_id": "source-message-1", + 'type': 'platform.message.created', + 'message_id': 'source-message-1', } + @pytest.mark.parametrize( + ('source_time', 'expected'), + [ + (1_700_000_000, 1_700_000_000), + (1_700_000_000_123, 1_700_000_000), + (1_700_000_000_123_456, 1_700_000_000), + ], + ) + def test_query_to_event_normalizes_legacy_timestamp_units( + self, + mock_query, + source_time, + expected, + ): + mock_query.message_event = Mock( + type='platform.message.created', + time=source_time, + sender=None, + ) + mock_query.message_event.model_dump = Mock(return_value={}) + + event = QueryEntryAdapter.query_to_event(mock_query) + + assert event.event_time == expected + def test_query_to_event_keeps_large_payloads_out_of_event_data(self, mock_query): """Large or nested platform payloads should not be duplicated into event.data.""" source_event = Mock() - source_event.type = "platform.message.created" + source_event.type = 'platform.message.created' source_event.time = 1700000000 source_event.sender = None - source_event.model_dump = Mock(return_value={ - "type": "platform.message.created", - "message_id": "source-message-1", - "message_chain": [{"type": "Image", "base64": "data:image/png;base64," + ("a" * 1024)}], - "raw_text": "x" * 1024, - "source_platform_object": {"large": "payload"}, - }) + source_event.model_dump = Mock( + return_value={ + 'type': 'platform.message.created', + 'message_id': 'source-message-1', + 'message_chain': [{'type': 'Image', 'base64': 'data:image/png;base64,' + ('a' * 1024)}], + 'raw_text': 'x' * 1024, + 'source_platform_object': {'large': 'payload'}, + } + ) mock_query.message_event = source_event event = QueryEntryAdapter.query_to_event(mock_query) assert event.data == { - "type": "platform.message.created", - "message_id": "source-message-1", + 'type': 'platform.message.created', + 'message_id': 'source-message-1', } def test_query_to_event_handles_missing_message_chain(self, mock_query): """Test delivery context building when Query has no message_chain.""" - delattr(mock_query, "message_chain") + delattr(mock_query, 'message_chain') event = QueryEntryAdapter.query_to_event(mock_query) - assert event.delivery.reply_target == {"message_id": None} + assert event.delivery.reply_target == { + 'target_type': 'person', + 'target_id': 'launcher-123', + 'message_id': None, + } def test_query_to_event_scopes_pipeline_local_event_ids(self, mock_query): """Pipeline-local message IDs must not become global audit IDs.""" first = QueryEntryAdapter.query_to_event(mock_query) - mock_query.launcher_id = "launcher-456" + mock_query.launcher_id = 'launcher-456' second = QueryEntryAdapter.query_to_event(mock_query) - assert first.event_id.startswith("host:") - assert first.event_id != "789" + assert first.event_id.startswith('host:') + assert first.event_id != '789' assert second.event_id != first.event_id @@ -152,21 +191,22 @@ class TestQueryConfigToAgentConfig: def test_config_to_agent_config_runner_id(self, mock_query): """Test AgentConfig runner_id extraction.""" - agent_config = QueryEntryAdapter.config_to_agent_config( - mock_query, "plugin:author/plugin/runner" - ) + agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:author/plugin/runner') - assert agent_config.runner_id == "plugin:author/plugin/runner" + assert agent_config.runner_id == 'plugin:author/plugin/runner' + assert agent_config.processor_type == 'pipeline' + assert agent_config.processor_id == mock_query.pipeline_uuid + assert agent_config.delivery_policy.enable_interactions is True def test_config_to_agent_config_uses_current_runner_config(self, mock_query): """Temporary query adapters use the current runner config container.""" mock_query.pipeline_config = { - "ai": { - "runner": {"id": "plugin:langbot-team/LocalAgent/default"}, - "runner_config": { - "plugin:langbot-team/LocalAgent/default": { - "model": {"primary": "model-primary", "fallbacks": []}, - "knowledge-bases": ["kb-001"], + 'ai': { + 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'model': {'primary': 'model-primary', 'fallbacks': []}, + 'knowledge-bases': ['kb-001'], }, }, } @@ -174,90 +214,109 @@ class TestQueryConfigToAgentConfig: agent_config = QueryEntryAdapter.config_to_agent_config( mock_query, - "plugin:langbot-team/LocalAgent/default", + 'plugin:langbot-team/LocalAgent/default', ) - assert agent_config.runner_config["model"] == {"primary": "model-primary", "fallbacks": []} - assert agent_config.runner_config["knowledge-bases"] == ["kb-001"] + assert agent_config.runner_config['model'] == {'primary': 'model-primary', 'fallbacks': []} + assert agent_config.runner_config['knowledge-bases'] == ['kb-001'] def test_resolver_projects_agent_scope(self, mock_query): """Test binding scope projection through the resolver.""" event = QueryEntryAdapter.query_to_event(mock_query) - agent_config = QueryEntryAdapter.config_to_agent_config( - mock_query, "plugin:test/plugin/runner" - ) + agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:test/plugin/runner') binding = AgentBindingResolver().resolve_one(event, [agent_config]) - assert binding.scope.scope_type == "agent" + assert binding.scope.scope_type == 'agent' assert binding.scope.scope_id == mock_query.pipeline_uuid assert binding.agent_id == mock_query.pipeline_uuid + assert binding.processor_type == 'pipeline' + assert binding.processor_id == mock_query.pipeline_uuid + + def test_interaction_submission_projects_control_event(self, mock_query): + """Pipeline callbacks keep structured submission data and match the runner binding.""" + mock_query.variables = { + '_interaction_submission': { + 'interaction_id': 'form-1', + 'action_id': 'approve', + 'values': {'name': 'Alice'}, + } + } + + event = QueryEntryAdapter.query_to_event(mock_query) + agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:langbot-team/DifyAgent/default') + binding = AgentBindingResolver().resolve_one(event, [agent_config]) + + assert event.event_type == 'interaction.submitted' + assert event.data['interaction']['values'] == {'name': 'Alice'} + assert event.subject.subject_type == 'interaction' + assert agent_config.event_types == ['interaction.submitted'] + assert binding.processor_type == 'pipeline' def test_resolver_rejects_multiple_matching_agents(self, mock_query): """Event dispatch is single-Agent in v1.""" event = QueryEntryAdapter.query_to_event(mock_query) - first = QueryEntryAdapter.config_to_agent_config( - mock_query, "plugin:test/plugin/runner" - ) - second = first.model_copy(update={"agent_id": "agent_2"}) + first = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:test/plugin/runner') + second = first.model_copy(update={'agent_id': 'agent_2'}) with pytest.raises(AgentBindingResolutionError): AgentBindingResolver().resolve_one(event, [first, second]) + class TestAgentRunContextProtocolV1: """Test AgentRunContext Protocol v1 behavior.""" def test_sdk_context_event_required(self): """Test that event is required in Protocol v1 context.""" - trigger = AgentTrigger(type="message.received") + trigger = AgentTrigger(type='message.received') event = AgentEventContext( - event_id="evt_1", - event_type="message.received", - source="platform", + event_id='evt_1', + event_type='message.received', + source='platform', ) - input = AgentInput(text="Hello") + input = AgentInput(text='Hello') from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext ctx = AgentRunContext( - run_id="run_1", + run_id='run_1', trigger=trigger, event=event, input=input, - delivery=DeliveryContext(surface="platform"), + delivery=DeliveryContext(surface='platform'), resources=AgentResources(), runtime=AgentRuntimeContext(), ) assert ctx.event is not None - assert ctx.event.event_type == "message.received" + assert ctx.event.event_type == 'message.received' def test_sdk_context_has_no_history_message_fields(self): """AgentRunContext should not expose inline history message fields.""" - trigger = AgentTrigger(type="message.received") + trigger = AgentTrigger(type='message.received') event = AgentEventContext( - event_id="evt_1", - event_type="message.received", - source="platform", + event_id='evt_1', + event_type='message.received', + source='platform', ) - input = AgentInput(text="Hello") + input = AgentInput(text='Hello') from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext ctx = AgentRunContext( - run_id="run_1", + run_id='run_1', trigger=trigger, event=event, input=input, - delivery=DeliveryContext(surface="platform"), + delivery=DeliveryContext(surface='platform'), resources=AgentResources(), runtime=AgentRuntimeContext(), ) - assert "messages" not in AgentRunContext.model_fields - assert "bootstrap" not in AgentRunContext.model_fields - assert not hasattr(ctx, "bootstrap") + assert 'messages' not in AgentRunContext.model_fields + assert 'bootstrap' not in AgentRunContext.model_fields + assert not hasattr(ctx, 'bootstrap') class TestHostManagedHistoryNotInProtocol: @@ -267,7 +326,7 @@ class TestHostManagedHistoryNotInProtocol: """AgentRunContext should not expose top-level history messages.""" ctx_fields = AgentRunContext.model_fields.keys() - assert "messages" not in ctx_fields + assert 'messages' not in ctx_fields class TestSDKResultProtocolV1: @@ -278,11 +337,12 @@ class TestSDKResultProtocolV1: from langbot_plugin.api.entities.builtin.provider.message import Message result = AgentRunResult.message_completed( - run_id="run_1", - message=Message(role="assistant", content="Hello"), + run_id='run_1', + message=Message(role='assistant', content='Hello'), ) - assert result.run_id == "run_1" + assert result.run_id == 'run_1' + # Fixtures @pytest.fixture @@ -290,14 +350,14 @@ def mock_query(): """Create a mock query for testing.""" query = Mock() query.query_id = 123 - query.bot_uuid = "bot-uuid-123" - query.pipeline_uuid = "pipeline-uuid-456" - query.launcher_type = Mock(value="person") - query.launcher_id = "launcher-123" - query.sender_id = "sender-123" + query.bot_uuid = 'bot-uuid-123' + query.pipeline_uuid = 'pipeline-uuid-456' + query.launcher_type = Mock(value='person') + query.launcher_id = 'launcher-123' + query.sender_id = 'sender-123' query.pipeline_config = { - "ai": { - "runner": "plugin:test/plugin/runner", + 'ai': { + 'runner': 'plugin:test/plugin/runner', } } query.variables = {} @@ -321,10 +381,10 @@ def mock_query(): # Mock session with proper conversation query.session = Mock() - query.session.launcher_type = Mock(value="person") - query.session.launcher_id = "launcher-123" + query.session.launcher_type = Mock(value='person') + query.session.launcher_id = 'launcher-123' query.session.using_conversation = Mock() - query.session.using_conversation.uuid = "conv-uuid-123" + query.session.using_conversation.uuid = 'conv-uuid-123' # Mock use_funcs (empty list by default) query.use_funcs = [] @@ -338,14 +398,14 @@ def mock_query_no_session(): """Create a mock Query without session.""" query = Mock() query.query_id = 456 - query.bot_uuid = "bot-uuid-456" - query.pipeline_uuid = "pipeline-uuid-789" - query.launcher_type = Mock(value="person") - query.launcher_id = "launcher-456" - query.sender_id = "sender-456" + query.bot_uuid = 'bot-uuid-456' + query.pipeline_uuid = 'pipeline-uuid-789' + query.launcher_type = Mock(value='person') + query.launcher_id = 'launcher-456' + query.sender_id = 'sender-456' query.pipeline_config = { - "ai": { - "runner": "plugin:test/plugin/runner", + 'ai': { + 'runner': 'plugin:test/plugin/runner', } } query.variables = {} diff --git a/tests/unit_tests/agent/test_interaction_manager.py b/tests/unit_tests/agent/test_interaction_manager.py new file mode 100644 index 000000000..3144ee9ef --- /dev/null +++ b/tests/unit_tests/agent/test_interaction_manager.py @@ -0,0 +1,560 @@ +"""Tests for structured interaction authorization and delivery.""" + +from __future__ import annotations + +from types import SimpleNamespace +import time + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.interaction import ( + InteractionDeliveryCapabilities, + InteractionSubmission, +) + +from langbot.pkg.agent.runner.errors import RunnerProtocolError +from langbot.pkg.agent.runner.host_models import ( + AgentBinding, + AgentEventEnvelope, + BindingScope, + DeliveryPolicy, +) +from langbot.pkg.agent.runner.interaction_manager import InteractionManager +from langbot.pkg.agent.runner.interaction_store import InteractionStore +from langbot.pkg.entity.persistence.base import Base + + +class FakeAdapter: + def __init__(self, *, supports_interactions: bool, fail_updates: bool = False): + self.supports_interactions = supports_interactions + self.fail_updates = fail_updates + self.actions: list[tuple[str, dict]] = [] + self.messages: list[tuple[str, str, object]] = [] + + def get_supported_apis(self): + return ['interaction.request', 'interaction.acknowledge'] if self.supports_interactions else ['send_message'] + + async def call_platform_api(self, action: str, params: dict): + self.actions.append((action, params)) + if action == 'interaction.request' and params.get('update_target') and self.fail_updates: + raise RuntimeError('update unavailable') + update_target = params.get('update_target') or {} + message_id = update_target.get('message_id') or f'message-{len(self.actions)}' + card_id = update_target.get('card_id') or f'card-{len(self.actions)}' + sequence = int(update_target.get('sequence') or 0) + (1 if update_target else 0) + return { + 'ok': True, + 'message_id': message_id, + 'card_id': card_id, + 'sequence': sequence, + 'rich': True, + } + + async def send_message(self, target_type: str, target_id: str, message_chain): + self.messages.append((target_type, target_id, message_chain)) + + +@pytest.fixture +async def store(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager.db"}', echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield InteractionStore(engine) + await engine.dispose() + + +@pytest.fixture +def event(): + return AgentEventEnvelope( + event_id='evt-1', + event_type='message.received', + source='platform', + bot_id='bot-1', + conversation_id='group_chat-1', + actor=ActorContext(actor_type='user', actor_id='user-1'), + input=AgentInput(text='start'), + delivery=DeliveryContext( + surface='platform', + reply_target={'target_type': 'group', 'target_id': 'chat-1'}, + ), + ) + + +@pytest.fixture +def binding(): + return AgentBinding( + binding_id='binding-1', + scope=BindingScope(scope_type='agent', scope_id='pipeline-1'), + runner_id='plugin:test/ApprovalRunner/default', + delivery_policy=DeliveryPolicy(enable_interactions=True), + processor_type='pipeline', + processor_id='pipeline-1', + ) + + +def _descriptor(*, permitted: bool = True): + return SimpleNamespace( + id='plugin:test/ApprovalRunner/default', + capabilities=SimpleNamespace(interactions=permitted), + permissions=SimpleNamespace(interactions=['request'] if permitted else []), + ) + + +def _result(): + return { + 'type': 'action.requested', + 'data': { + 'action': 'interaction.requested', + 'target': {'target_type': 'person', 'target_id': 'attacker-selected'}, + 'payload': { + 'interaction_id': 'form-1', + 'kind': 'choice', + 'title': 'Approve request?', + 'actions': [ + {'id': 'approve', 'label': 'Approve', 'style': 'primary'}, + {'id': 'reject', 'label': 'Reject', 'style': 'danger'}, + ], + 'fallback_text': 'Reply approve or reject.', + }, + }, + } + + +@pytest.mark.asyncio +async def test_structured_delivery_uses_frozen_target_and_persists_request(store, event, binding): + adapter = FakeAdapter(supports_interactions=True) + manager = InteractionManager(SimpleNamespace(), store=store) + + consumed = await manager.handle_result( + result_dict=_result(), + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': adapter}, + ) + + assert consumed is True + assert adapter.actions[0][0] == 'interaction.request' + params = adapter.actions[0][1] + assert params['reply_target'] == {'target_type': 'group', 'target_id': 'chat-1'} + assert params['callback_token'] + assert 'attacker-selected' not in str(params) + + record = await store.get_request('run-1', 'form-1') + assert record is not None + assert record['processor_type'] == 'pipeline' + assert record['processor_id'] == 'pipeline-1' + assert record['actor_id'] == 'user-1' + assert record['expires_at'] is not None + assert 0 < record['expires_at'] - time.time() <= 30 * 60 + assert record['delivery_result']['message_id'] == 'message-1' + + +@pytest.mark.asyncio +async def test_continuous_interaction_reuses_submitted_platform_presentation(store, event, binding): + adapter = FakeAdapter(supports_interactions=True) + manager = InteractionManager(SimpleNamespace(), store=store) + event.delivery.interactions = InteractionDeliveryCapabilities(supports_updates=True) + + await manager.handle_result( + result_dict=_result(), + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': adapter}, + ) + first_token = adapter.actions[0][1]['callback_token'] + submitted = await manager.consume_callback( + callback_token=first_token, + submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + await manager.acknowledge_submission(submitted, adapter) + + event.event_type = 'interaction.submitted' + event.input.interaction = InteractionSubmission( + interaction_id='form-1', + action_id='approve', + ) + second_result = _result() + second_result['data']['payload']['interaction_id'] = 'form-2' + await manager.handle_result( + result_dict=second_result, + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-2', + adapter_context={'_delivery_adapter': adapter}, + ) + + assert [action for action, _ in adapter.actions] == [ + 'interaction.request', + 'interaction.acknowledge', + 'interaction.request', + ] + update_params = adapter.actions[-1][1] + assert update_params['update_target']['message_id'] == 'message-1' + assert update_params['update_target']['sequence'] == 1 + second_record = await store.get_request('run-2', 'form-2') + assert second_record['replaces_interaction_id'] == 'form-1' + assert second_record['delivery_result']['message_id'] == 'message-1' + assert second_record['delivery_result']['sequence'] == 2 + + +@pytest.mark.asyncio +async def test_interaction_update_failure_falls_back_to_new_presentation(store, event, binding): + adapter = FakeAdapter(supports_interactions=True, fail_updates=True) + manager = InteractionManager(SimpleNamespace(), store=store) + event.delivery.interactions = InteractionDeliveryCapabilities(supports_updates=True) + + await manager.handle_result( + result_dict=_result(), + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': adapter}, + ) + first_token = adapter.actions[0][1]['callback_token'] + await manager.consume_callback( + callback_token=first_token, + submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + event.input.interaction = InteractionSubmission(interaction_id='form-1', action_id='approve') + second_result = _result() + second_result['data']['payload']['interaction_id'] = 'form-2' + await manager.handle_result( + result_dict=second_result, + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-2', + adapter_context={'_delivery_adapter': adapter}, + ) + + update_attempt = adapter.actions[-2][1] + fallback_attempt = adapter.actions[-1][1] + assert update_attempt['update_target']['message_id'] == 'message-1' + assert 'update_target' not in fallback_attempt + assert fallback_attempt['callback_token'] == update_attempt['callback_token'] + + +@pytest.mark.asyncio +async def test_callback_scope_uses_frozen_delivery_conversation(store, event, binding): + event.conversation_id = 'runner-owned-external-conversation' + adapter = FakeAdapter(supports_interactions=True) + manager = InteractionManager(SimpleNamespace(), store=store) + + await manager.handle_result( + result_dict=_result(), + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': adapter}, + ) + + record = await store.get_request('run-1', 'form-1') + assert record['conversation_id'] == 'group_chat-1' + + +@pytest.mark.asyncio +async def test_adapter_without_interactions_receives_fallback_text(store, event, binding): + adapter = FakeAdapter(supports_interactions=False) + manager = InteractionManager(SimpleNamespace(), store=store) + + assert await manager.handle_result( + result_dict=_result(), + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': adapter}, + ) + + assert adapter.actions == [] + target_type, target_id, message_chain = adapter.messages[0] + assert (target_type, target_id) == ('group', 'chat-1') + assert str(message_chain) == 'Reply approve or reject.' + + +@pytest.mark.asyncio +async def test_runner_without_interaction_permission_is_rejected(store, event, binding): + manager = InteractionManager(SimpleNamespace(), store=store) + + with pytest.raises(RunnerProtocolError, match='did not declare'): + await manager.handle_result( + result_dict=_result(), + event=event, + binding=binding, + descriptor=_descriptor(permitted=False), + run_id='run-1', + adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + ) + + assert await store.get_request('run-1', 'form-1') is None + + +@pytest.mark.asyncio +async def test_binding_policy_can_disable_interactions(store, event, binding): + manager = InteractionManager(SimpleNamespace(), store=store) + binding.delivery_policy.enable_interactions = False + + with pytest.raises(RunnerProtocolError, match='delivery policy'): + await manager.handle_result( + result_dict=_result(), + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('expires_at', 'error'), + [ + (lambda now: 0, 'already expired'), + (lambda now: now - 1, 'already expired'), + (lambda now: now + 24 * 60 * 60 + 1, 'exceeds 24 hours'), + ], +) +async def test_interaction_expiry_is_bounded(store, event, binding, expires_at, error): + result = _result() + result['data']['payload']['expires_at'] = expires_at(int(time.time())) + manager = InteractionManager(SimpleNamespace(), store=store) + + with pytest.raises(RunnerProtocolError, match=error): + await manager.handle_result( + result_dict=result, + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + ) + + +@pytest.mark.asyncio +async def test_interaction_rejects_duplicate_protocol_ids(store, event, binding): + result = _result() + result['data']['payload']['actions'].append({'id': 'approve', 'label': 'Approve again'}) + manager = InteractionManager(SimpleNamespace(), store=store) + + with pytest.raises(RunnerProtocolError, match='duplicate action IDs'): + await manager.handle_result( + result_dict=result, + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + ) + + +@pytest.mark.asyncio +async def test_interaction_request_payload_is_bounded(store, event, binding): + result = _result() + result['data']['payload']['fields'] = [ + { + 'id': f'field-{field_index}', + 'label': 'Field', + 'type': 'select', + 'options': [ + { + 'value': f'{field_index}-{option_index}', + 'label': 'x' * 512, + } + for option_index in range(100) + ], + } + for field_index in range(10) + ] + manager = InteractionManager(SimpleNamespace(), store=store) + + with pytest.raises(RunnerProtocolError, match='exceeds 256 KiB'): + await manager.handle_result( + result_dict=result, + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + ) + + +@pytest.mark.asyncio +async def test_non_interaction_action_is_not_consumed(store, event, binding): + manager = InteractionManager(SimpleNamespace(), store=store) + result = _result() + result['data']['action'] = 'platform.message.delete' + + assert not await manager.handle_result( + result_dict=result, + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)}, + ) + + +async def _deliver_interaction(store, event, binding, result=None): + adapter = FakeAdapter(supports_interactions=True) + manager = InteractionManager(SimpleNamespace(), store=store) + await manager.handle_result( + result_dict=result or _result(), + event=event, + binding=binding, + descriptor=_descriptor(), + run_id='run-1', + adapter_context={'_delivery_adapter': adapter}, + ) + return manager, adapter.actions[0][1]['callback_token'] + + +@pytest.mark.asyncio +async def test_callback_action_ref_resolves_persisted_action_and_ignores_forged_id(store, event, binding): + manager, callback_token = await _deliver_interaction(store, event, binding) + + resumed = await manager.consume_callback( + callback_token=callback_token, + submission={ + 'interaction_id': 'attacker-selected', + 'action_ref': 1, + 'values': {}, + }, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + assert resumed['submission']['interaction_id'] == 'form-1' + assert resumed['submission']['action_id'] == 'reject' + + +@pytest.mark.asyncio +async def test_callback_submission_time_is_host_owned(store, event, binding): + manager, callback_token = await _deliver_interaction(store, event, binding) + before = int(time.time()) + + resumed = await manager.consume_callback( + callback_token=callback_token, + submission={'action_ref': 0, 'submitted_at': 1}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + assert before <= resumed['submission']['submitted_at'] <= int(time.time()) + assert resumed['submitted_at'] == resumed['submission']['submitted_at'] + + +@pytest.mark.asyncio +async def test_callback_option_refs_resolve_persisted_field_and_value(store, event, binding): + result = _result() + result['data']['payload']['fields'] = [ + { + 'id': 'priority', + 'label': 'Priority', + 'type': 'select', + 'options': [ + {'value': 'normal', 'label': 'Normal'}, + {'value': 'urgent', 'label': 'Urgent'}, + ], + } + ] + manager, callback_token = await _deliver_interaction(store, event, binding, result) + + resumed = await manager.consume_callback( + callback_token=callback_token, + submission={'field_ref': 0, 'option_ref': 1}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + assert resumed['submission']['interaction_id'] == 'form-1' + assert resumed['submission']['values'] == {'priority': 'urgent'} + + +@pytest.mark.asyncio +async def test_callback_rejects_out_of_range_reference(store, event, binding): + manager, callback_token = await _deliver_interaction(store, event, binding) + + with pytest.raises(ValueError, match='action reference is invalid'): + await manager.consume_callback( + callback_token=callback_token, + submission={'action_ref': 99}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + pending = await store.get_request('run-1', 'form-1') + assert pending is not None + assert pending['status'] == 'pending' + + +@pytest.mark.asyncio +async def test_callback_rejects_negative_reference(store, event, binding): + manager, callback_token = await _deliver_interaction(store, event, binding) + + with pytest.raises(ValueError, match='action reference is invalid'): + await manager.consume_callback( + callback_token=callback_token, + submission={'action_ref': -1}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + +@pytest.mark.asyncio +async def test_callback_rejects_action_not_present_in_request(store, event, binding): + manager, callback_token = await _deliver_interaction(store, event, binding) + + with pytest.raises(ValueError, match='action is not present'): + await manager.consume_callback( + callback_token=callback_token, + submission={'action_id': 'attacker-selected'}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + +@pytest.mark.asyncio +async def test_callback_rejects_option_not_present_in_request(store, event, binding): + result = _result() + result['data']['payload']['fields'] = [ + { + 'id': 'priority', + 'label': 'Priority', + 'type': 'select', + 'options': [{'value': 'normal', 'label': 'Normal'}], + } + ] + manager, callback_token = await _deliver_interaction(store, event, binding, result) + + with pytest.raises(ValueError, match='option is not present'): + await manager.consume_callback( + callback_token=callback_token, + submission={'values': {'priority': 'attacker-selected'}}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) diff --git a/tests/unit_tests/agent/test_interaction_store.py b/tests/unit_tests/agent/test_interaction_store.py new file mode 100644 index 000000000..4872be4b1 --- /dev/null +++ b/tests/unit_tests/agent/test_interaction_store.py @@ -0,0 +1,260 @@ +"""Tests for Host-owned structured interaction persistence.""" + +from __future__ import annotations + +import datetime +import hashlib + +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.agent.runner.interaction_store import ( + DuplicateInteractionError, + InteractionAlreadySubmittedError, + InteractionExpiredError, + InteractionScopeError, + InteractionStore, +) +from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction +from langbot.pkg.entity.persistence.base import Base + + +UTC = datetime.timezone.utc + + +@pytest.fixture +async def db_engine(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "interactions.db"}', echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.fixture +def store(db_engine): + return InteractionStore(db_engine) + + +async def _create(store: InteractionStore, **overrides): + values = { + 'interaction_id': 'form-1', + 'run_id': 'run-1', + 'binding_id': 'binding-1', + 'runner_id': 'plugin:test/ApprovalRunner/default', + 'processor_type': 'pipeline', + 'processor_id': 'pipeline-1', + 'request': {'interaction_id': 'form-1', 'title': 'Approve?', 'fallback_text': 'Reply yes or no.'}, + 'delivery_target': {'chat_id': 'chat-1'}, + 'bot_id': 'bot-1', + 'conversation_id': 'group_chat-1', + 'actor_id': 'user-1', + 'expires_at': int((datetime.datetime.now(UTC) + datetime.timedelta(minutes=5)).timestamp()), + } + values.update(overrides) + return await store.create_request(**values) + + +@pytest.mark.asyncio +async def test_create_request_returns_token_but_persists_only_hash(store, db_engine): + record, token = await _create(store) + + assert record['status'] == 'pending' + assert record['processor_type'] == 'pipeline' + assert token + + async with db_engine.connect() as conn: + result = await conn.execute(sqlalchemy.select(AgentInteraction.callback_token_hash)) + stored_hash = result.scalar_one() + + assert stored_hash == hashlib.sha256(token.encode()).hexdigest() + assert stored_hash != token + + +@pytest.mark.asyncio +async def test_submission_is_scope_checked_and_consumed_once(store): + _, token = await _create(store) + + submitted = await store.consume_submission( + callback_token=token, + submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {'name': 'Alice'}}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + assert submitted['status'] == 'submitted' + assert submitted['submission']['action_id'] == 'approve' + assert submitted['submission']['values'] == {'name': 'Alice'} + + with pytest.raises(InteractionAlreadySubmittedError): + await store.consume_submission( + callback_token=token, + submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + +@pytest.mark.asyncio +async def test_delivery_result_can_be_resolved_for_scoped_update(store): + _, token = await _create(store) + assert await store.record_delivery_success( + 'run-1', + 'form-1', + {'message_id': 'message-1', 'rich': True}, + ) + await store.consume_submission( + callback_token=token, + submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + assert await store.record_delivery_success( + 'run-1', + 'form-1', + {'message_id': 'message-1', 'card_id': 'card-1', 'sequence': 1, 'rich': True}, + ) + + target = await store.find_update_target( + interaction_id='form-1', + binding_id='binding-1', + runner_id='plugin:test/ApprovalRunner/default', + processor_type='pipeline', + processor_id='pipeline-1', + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + assert target['delivery_result'] == { + 'message_id': 'message-1', + 'card_id': 'card-1', + 'sequence': 1, + 'rich': True, + } + + assert ( + await store.find_update_target( + interaction_id='form-1', + binding_id='binding-1', + runner_id='plugin:test/ApprovalRunner/default', + processor_type='pipeline', + processor_id='pipeline-1', + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='other-user', + ) + is None + ) + + +@pytest.mark.asyncio +async def test_scope_mismatch_does_not_consume_request(store): + _, token = await _create(store) + + with pytest.raises(InteractionScopeError, match='actor'): + await store.consume_submission( + callback_token=token, + submission={'interaction_id': 'form-1', 'values': {}}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='other-user', + ) + + record = await store.get_request('run-1', 'form-1') + assert record is not None + assert record['status'] == 'pending' + + +@pytest.mark.asyncio +async def test_expired_request_is_marked_terminal(store): + _, token = await _create(store) + cutoff = datetime.datetime.now(UTC) + datetime.timedelta(minutes=10) + assert await store.expire_pending(now=cutoff) == 1 + + with pytest.raises(InteractionExpiredError): + await store.consume_submission( + callback_token=token, + submission={'interaction_id': 'form-1', 'values': {}}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + record = await store.get_request('run-1', 'form-1') + assert record is not None + assert record['status'] == 'expired' + + +@pytest.mark.asyncio +async def test_forged_old_submission_time_cannot_bypass_server_expiry(store, db_engine): + _, token = await _create(store) + expired_at = datetime.datetime.now(UTC) - datetime.timedelta(minutes=1) + async with db_engine.begin() as conn: + await conn.execute( + sqlalchemy.update(AgentInteraction).where(AgentInteraction.run_id == 'run-1').values(expires_at=expired_at) + ) + + with pytest.raises(InteractionExpiredError): + await store.consume_submission( + callback_token=token, + submission={'interaction_id': 'form-1', 'values': {}}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + submitted_at=int((expired_at - datetime.timedelta(minutes=1)).timestamp()), + ) + + record = await store.get_request('run-1', 'form-1') + assert record is not None + assert record['status'] == 'expired' + + +@pytest.mark.asyncio +async def test_interaction_id_is_unique_within_run_only(store): + await _create(store) + + with pytest.raises(DuplicateInteractionError): + await _create(store) + + second, _ = await _create(store, run_id='run-2') + assert second['interaction_id'] == 'form-1' + assert second['run_id'] == 'run-2' + + +@pytest.mark.asyncio +async def test_expire_pending_updates_only_elapsed_rows(store): + now = datetime.datetime.now(UTC) + await _create( + store, + interaction_id='expired', + run_id='run-expired', + expires_at=int((now + datetime.timedelta(minutes=5)).timestamp()), + ) + await _create( + store, + interaction_id='future', + run_id='run-future', + expires_at=int((now + datetime.timedelta(minutes=20)).timestamp()), + ) + cutoff = datetime.datetime.now(UTC) + datetime.timedelta(minutes=10) + + expired = await store.expire_pending(now=cutoff) + + assert expired == 1 + assert (await store.get_request('run-expired', 'expired'))['status'] == 'expired' + assert (await store.get_request('run-future', 'future'))['status'] == 'pending' + + +@pytest.mark.asyncio +async def test_delivery_failure_is_terminal(store): + await _create(store) + + assert await store.mark_delivery_failed('run-1', 'form-1', 'adapter rejected card') + record = await store.get_request('run-1', 'form-1') + assert record is not None + assert record['status'] == 'delivery_failed' + assert record['status_reason'] == 'adapter rejected card' diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index ba770a236..8e3d9d360 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -17,6 +17,7 @@ from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore +from langbot.pkg.agent.runner.interaction_store import InteractionStore from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events @@ -412,6 +413,67 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent assert await get_session_registry().get(context['run_id']) is None +@pytest.mark.asyncio +async def test_orchestrator_consumes_interaction_request_before_message_output(clean_agent_state): + """Whitelisted interaction actions are delivered and never emitted as messages.""" + db_engine = clean_agent_state + descriptor = make_descriptor() + # The local SDK pin predates this contract; these become typed fields with SDK 0.5.0a3. + descriptor.capabilities.__dict__['interactions'] = True + descriptor.permissions.__dict__['interactions'] = ['request'] + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'action.requested', + 'data': { + 'action': 'interaction.requested', + 'payload': { + 'interaction_id': 'approval-1', + 'kind': 'choice', + 'title': 'Approve request?', + 'actions': [ + {'id': 'approve', 'label': 'Approve', 'style': 'primary'}, + {'id': 'reject', 'label': 'Reject', 'style': 'danger'}, + ], + 'fallback_text': 'Reply approve or reject.', + }, + }, + }, + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'Waiting for approval'}}, + }, + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + query.adapter = types.SimpleNamespace( + get_supported_apis=lambda: ['interaction.request'], + call_platform_api=AsyncMock(return_value={'ok': True}), + ) + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert [message.content for message in messages] == ['Waiting for approval'] + query.adapter.call_platform_api.assert_awaited_once() + action, params = query.adapter.call_platform_api.await_args.args + assert action == 'interaction.request' + assert params['reply_target'] == { + 'target_type': 'person', + 'target_id': 'user_001', + 'message_id': 'msg_001', + } + assert params['callback_token'] + + run_id = plugin_connector.contexts[0]['run_id'] + request = await InteractionStore(db_engine).get_request(run_id, 'approval-1') + assert request is not None + assert request['status'] == 'pending' + assert request['processor_type'] == 'pipeline' + assert request['processor_id'] == 'pipeline_001' + + @pytest.mark.asyncio async def test_orchestrator_persists_run_ledger(clean_agent_state): """AgentRunOrchestrator records Host-owned run and result events.""" diff --git a/tests/unit_tests/pipeline/test_aggregator.py b/tests/unit_tests/pipeline/test_aggregator.py index 9eab7615f..328071722 100644 --- a/tests/unit_tests/pipeline/test_aggregator.py +++ b/tests/unit_tests/pipeline/test_aggregator.py @@ -366,6 +366,36 @@ class TestMessageAggregatorAddMessage: # Should have buffered the message assert len(agg.buffers) == 1 + @pytest.mark.asyncio + async def test_control_variables_bypass_aggregation(self): + """Interaction callbacks must not merge with nearby chat messages.""" + aggregator = get_aggregator_module() + app = make_aggregator_app() + mock_pipeline = Mock() + mock_pipeline.pipeline_entity = Mock() + mock_pipeline.pipeline_entity.config = {'trigger': {'message-aggregation': {'enabled': True, 'delay': 10.0}}} + app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline) + agg = aggregator.MessageAggregator(app) + + chain = text_chain('approve') + await agg.add_message( + bot_uuid='test-bot', + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id=12345, + sender_id=12345, + message_event=friend_message_event(chain), + message_chain=chain, + adapter=mock_adapter(), + pipeline_uuid='test-pipeline', + variables={'_interaction_submission': {'interaction_id': 'form-1'}}, + ) + + assert agg.buffers == {} + app.query_pool.add_query.assert_awaited_once() + assert app.query_pool.add_query.call_args.kwargs['variables'] == { + '_interaction_submission': {'interaction_id': 'form-1'} + } + @pytest.mark.asyncio async def test_max_buffer_flushes_immediately(self): """Reaching MAX_BUFFER_MESSAGES should flush immediately.""" diff --git a/tests/unit_tests/pipeline/test_pipelinemgr.py b/tests/unit_tests/pipeline/test_pipelinemgr.py index 3ee9a2477..a86a223d3 100644 --- a/tests/unit_tests/pipeline/test_pipelinemgr.py +++ b/tests/unit_tests/pipeline/test_pipelinemgr.py @@ -164,6 +164,96 @@ async def test_runtime_pipeline_execute(mock_app, sample_query): mock_stage.process.assert_called_once() +@pytest.mark.asyncio +async def test_runtime_pipeline_delivers_latest_chunk_as_final(mock_app, sample_query): + """The terminal chunk, not the first chunk, controls final stream delivery.""" + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + entities = get_entities_module() + provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message') + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = sample_query.pipeline_config + pipeline_entity.extensions_preferences = {'plugins': []} + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + first_chunk = provider_message.MessageChunk(role='assistant', content='Starting', is_final=False) + final_chunk = provider_message.MessageChunk(role='assistant', content='Done', is_final=True) + sample_query.resp_messages = [first_chunk, final_chunk] + sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True) + result = entities.StageProcessResult( + result_type=entities.ResultType.CONTINUE, + new_query=sample_query, + user_notice='StartingDone', + ) + + await runtime_pipeline._check_output(sample_query, result) + + sample_query.adapter.reply_message_chunk.assert_awaited_once() + call = sample_query.adapter.reply_message_chunk.await_args.kwargs + assert call['bot_message'] is final_chunk + assert call['is_final'] is True + + +@pytest.mark.asyncio +async def test_response_back_stage_delivers_latest_chunk_as_final(mock_app, sample_query): + respback = import_module('langbot.pkg.pipeline.respback.respback') + provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message') + platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message') + + first_chunk = provider_message.MessageChunk(role='assistant', content='Starting', is_final=False) + final_chunk = provider_message.MessageChunk(role='assistant', content='Done', is_final=True) + sample_query.resp_messages = [first_chunk, final_chunk] + sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='StartingDone')])] + sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0} + sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True) + + await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back') + + sample_query.adapter.reply_message_chunk.assert_awaited_once() + call = sample_query.adapter.reply_message_chunk.await_args.kwargs + assert call['bot_message'] is final_chunk + assert call['is_final'] is True + + +@pytest.mark.asyncio +async def test_response_back_stage_keeps_consuming_after_stream_delivery_failure(mock_app, sample_query): + respback = import_module('langbot.pkg.pipeline.respback.respback') + provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message') + platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message') + + chunk = provider_message.MessageChunk(role='assistant', content='Progress', is_final=False) + sample_query.resp_messages = [chunk] + sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='Progress')])] + sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0} + sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True) + sample_query.adapter.reply_message_chunk.side_effect = RuntimeError('stream update failed') + + result = await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back') + + assert result.result_type.name == 'CONTINUE' + sample_query.adapter.reply_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_response_back_stage_falls_back_to_plain_message_for_failed_final_chunk(mock_app, sample_query): + respback = import_module('langbot.pkg.pipeline.respback.respback') + provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message') + platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message') + + chunk = provider_message.MessageChunk(role='assistant', content='Final answer', is_final=True) + sample_query.resp_messages = [chunk] + sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='Final answer')])] + sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0} + sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True) + sample_query.adapter.reply_message_chunk.side_effect = RuntimeError('stream update failed') + + result = await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back') + + assert result.result_type.name == 'CONTINUE' + sample_query.adapter.reply_message.assert_awaited_once() + + def test_runtime_pipeline_prefers_runner_mcp_resources(mock_app): """Runner resource selection should override extension preferences.""" pipelinemgr = get_pipelinemgr_module() diff --git a/tests/unit_tests/pipeline/test_pool.py b/tests/unit_tests/pipeline/test_pool.py index 86515e7f0..c72cf5b94 100644 --- a/tests/unit_tests/pipeline/test_pool.py +++ b/tests/unit_tests/pipeline/test_pool.py @@ -199,6 +199,29 @@ class TestQueryPoolAddQuery: call_kwargs = MockQuery.call_args[1] assert call_kwargs['variables']['_routed_by_rule'] is True + async def test_add_query_merges_control_variables(self): + """Caller-provided control variables are preserved with routing metadata.""" + pool = QueryPool() + mock_query = Mock(query_id=0) + + with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery: + MockQuery.return_value = mock_query + await pool.add_query( + bot_uuid='bot1', + launcher_type=Mock(), + launcher_id=1, + sender_id=1, + message_event=Mock(), + message_chain=Mock(), + adapter=Mock(), + routed_by_rule=True, + variables={'_interaction_submission': {'interaction_id': 'form-1'}}, + ) + + variables = MockQuery.call_args.kwargs['variables'] + assert variables['_routed_by_rule'] is True + assert variables['_interaction_submission'] == {'interaction_id': 'form-1'} + async def test_add_query_notifier_condition(self): """add_query notifies waiting consumers.""" pool = QueryPool() diff --git a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py index 2613a1897..a05bec330 100644 --- a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py +++ b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import pathlib from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -13,6 +14,7 @@ from langbot.pkg.platform.adapters.dingtalk.adapter import DingTalkAdapter 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_event_from_native from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events @@ -49,6 +51,7 @@ class DummyDingTalkClient(DingTalkClient): self.download_image = AsyncMock(return_value='data:image/png;base64,BBBB') self.create_and_card = AsyncMock(return_value=('card', 'card-id')) self.send_card_message = AsyncMock() + self.create_and_deliver_card = AsyncMock(return_value=True) self.start = AsyncMock() self.stop = AsyncMock() @@ -71,7 +74,7 @@ def manifest() -> dict: / 'dingtalk' / 'manifest.yaml' ) - return yaml.safe_load(manifest_path.read_text()) + return yaml.safe_load(manifest_path.read_text(encoding='utf-8')) def make_adapter() -> DingTalkAdapter: @@ -84,6 +87,7 @@ def make_adapter() -> DingTalkAdapter: 'enable-stream-reply': False, 'card_auto_layout': False, 'card_template_id': 'template-id', + 'human_input_card_template_id': 'human-input-template-id', } with patch('langbot.pkg.platform.adapters.dingtalk.adapter.DingTalkClient', DummyDingTalkClient): return DingTalkAdapter(config, DummyLogger()) @@ -157,6 +161,36 @@ def test_dingtalk_platform_api_map_matches_manifest(): assert set(PLATFORM_API_MAP) == manifest_actions +def test_dingtalk_human_input_card_template_matches_interaction_contract(): + template_path = ( + pathlib.Path(__file__).parents[3] / 'src' / 'langbot' / 'templates' / 'dingtalk_human_input_card.json' + ) + exported_template = json.loads(template_path.read_text(encoding='utf-8')) + editor_data = json.loads(exported_template['editorData']) + variable_ids = {item['id'] for item in editor_data['variableList']} + serialized_editor = json.dumps(editor_data, ensure_ascii=False) + + assert exported_template['type'] == 'im' + assert exported_template['mode'] == 'card' + assert { + 'content', + 'btns', + 'input_visible', + 'input_title', + 'input_placeholder', + 'input_value', + 'select_visible', + 'select_placeholder', + 'select_options', + 'select_index', + 'index_o', + } <= variable_ids + assert '"componentName": "Input"' in serialized_editor + assert '"componentName": "SelectBlock"' in serialized_editor + assert '__built_in_inputResult__' in serialized_editor + assert '__built_in_selectResult__' in serialized_editor + + @pytest.mark.asyncio async def test_dingtalk_message_converter_maps_outbound_components(): content, at = await DingTalkMessageConverter.yiri2target( @@ -313,3 +347,178 @@ async def test_dingtalk_send_reply_and_platform_api_use_underlying_client(): assert token_status == {'valid': True} assert file_url == {'url': 'https://example.test/file'} + + +@pytest.mark.asyncio +async def test_dingtalk_interaction_delivery_and_callback_event(): + adapter = make_adapter() + result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'callback-token', + 'reply_target': {'target_type': 'group', 'target_id': 'group-1'}, + 'request': { + 'interaction_id': 'form-1', + 'title': 'Approve?', + 'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}], + 'fallback_text': 'Reply approve.', + }, + }, + ) + + assert result['rich'] is True + kwargs = adapter.bot.create_and_deliver_card.await_args.kwargs + assert kwargs['card_template_id'] == 'human-input-template-id' + assert kwargs['open_space_id'] == 'dtv1.card//IM_GROUP.group-1' + assert kwargs['card_param_map']['btns'][0]['event']['params']['actionId'] == 'lbi:callback-token:a:0' + + native = dingtalk_card_callback_event( + content={'actionId': 'lbi:callback-token:a:0'}, + space_id='dtv1.card//IM_GROUP.group-1', + ) + event = interaction_event_from_native(native) + assert event.action == 'interaction.submitted' + assert event.data['action_ref'] == 0 + assert event.data['actor_id'] == 'user-1' + assert event.data['target_id'] == 'group-1' + + +@pytest.mark.asyncio +async def test_dingtalk_native_input_uses_template_variables_and_submits_value(): + adapter = make_adapter() + result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'callback-token', + 'reply_target': {'target_type': 'group', 'target_id': 'group-1'}, + 'request': { + 'interaction_id': 'form-1', + 'title': 'Add context', + 'fields': [ + { + 'id': 'comment', + 'label': 'Comment', + 'type': 'textarea', + 'required': True, + 'placeholder': 'Explain the decision', + } + ], + 'actions': [], + 'fallback_text': 'Reply with a comment.', + }, + }, + ) + + params = adapter.bot.create_and_deliver_card.await_args.kwargs['card_param_map'] + assert params['input_visible'] == 'true' + assert params['input_title'] == 'Comment' + assert params['input_placeholder'] == 'Explain the decision' + assert params['select_visible'] == '' + assert params['btns'] == [] + assert result['message_id'] in adapter.interaction_callback_contexts + + native = dingtalk_card_callback_event( + content={ + 'outTrackId': result['message_id'], + 'params': {'input': 'Looks good'}, + }, + space_id='dtv1.card//IM_GROUP.group-1', + ) + event = interaction_event_from_native(native, adapter.interaction_callback_contexts) + + assert event.data['callback_token'] == 'callback-token' + assert event.data['values'] == {'comment': 'Looks good'} + assert result['message_id'] not in adapter.interaction_callback_contexts + + +@pytest.mark.asyncio +async def test_dingtalk_native_select_uses_select_block_and_normalizes_callback(): + adapter = make_adapter() + result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'callback-token', + 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, + 'request': { + 'interaction_id': 'form-1', + 'title': 'Priority', + 'fields': [ + { + 'id': 'priority', + 'label': 'Priority', + 'type': 'select', + 'options': [ + {'value': 'normal', 'label': 'Normal'}, + {'value': 'urgent', 'label': 'Urgent'}, + ], + } + ], + 'actions': [], + 'fallback_text': 'Choose a priority.', + }, + }, + ) + + params = adapter.bot.create_and_deliver_card.await_args.kwargs['card_param_map'] + assert params['select_visible'] == 'true' + assert [option['value'] for option in params['index_o']] == ['normal', 'urgent'] + assert params['input_visible'] == '' + + native = dingtalk_card_callback_event( + content={ + 'out_track_id': result['message_id'], + 'params': {'select': '{"index": 1, "value": "urgent"}'}, + }, + space_id='dtv1.card//IM_ROBOT.user-1', + ) + event = interaction_event_from_native(native, adapter.interaction_callback_contexts) + + assert event.data['target_type'] == 'person' + assert event.data['target_id'] == 'user-1' + assert event.data['values'] == {'priority': 'urgent'} + + +@pytest.mark.asyncio +async def test_dingtalk_adapter_dispatches_native_input_submission(): + adapter = make_adapter() + calls: list[platform_events.Event] = [] + + async def listener(event, adapter): + calls.append(event) + + adapter.register_listener(platform_events.PlatformSpecificEvent, listener) + result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'callback-token', + 'reply_target': {'target_type': 'group', 'target_id': 'group-1'}, + 'request': { + 'interaction_id': 'form-1', + 'title': 'Score', + 'fields': [ + { + 'id': 'score', + 'label': 'Score', + 'type': 'number', + } + ], + 'actions': [], + 'fallback_text': 'Reply with a score.', + }, + }, + ) + native = dingtalk_card_callback_event( + content={ + 'outTrackId': result['message_id'], + 'params': {'inputResult': '42.5'}, + }, + space_id='dtv1.card//IM_GROUP.group-1', + ) + + await adapter._handle_native_event(native) + + assert len(calls) == 1 + assert calls[0].action == 'interaction.submitted' + assert calls[0].data['callback_token'] == 'callback-token' + assert calls[0].data['values'] == {'score': 42.5} + assert result['message_id'] not in adapter.interaction_callback_contexts diff --git a/tests/unit_tests/platform/test_discord_eba_adapter.py b/tests/unit_tests/platform/test_discord_eba_adapter.py index 67430461c..478e3c34d 100644 --- a/tests/unit_tests/platform/test_discord_eba_adapter.py +++ b/tests/unit_tests/platform/test_discord_eba_adapter.py @@ -13,6 +13,11 @@ import langbot.pkg.platform.adapters.discord.adapter as discord_adapter_module 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 ( + build_interaction_view, + interaction_event_from_component, + parse_interaction_custom_id, +) from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events @@ -48,7 +53,7 @@ def manifest() -> dict: / 'discord' / 'manifest.yaml' ) - return yaml.safe_load(manifest_path.read_text()) + return yaml.safe_load(manifest_path.read_text(encoding='utf-8')) def test_discord_supported_events_match_manifest(): @@ -68,6 +73,64 @@ def test_discord_platform_api_map_matches_manifest(): assert set(PLATFORM_API_MAP) == manifest_actions +def test_discord_interaction_custom_id_parser_uses_host_indexes(): + assert parse_interaction_custom_id('lbi:token:a:2') == { + 'callback_token': 'token', + 'action_ref': 2, + } + assert parse_interaction_custom_id('lbi:token:f:0:3') == { + 'callback_token': 'token', + 'field_ref': 0, + 'option_ref': 3, + } + + +@pytest.mark.asyncio +async def test_discord_interaction_view_and_delivery(): + adapter = make_adapter() + channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=321))) + adapter._get_channel = AsyncMock(return_value=channel) + request = { + 'interaction_id': 'form-1', + 'title': 'Approve?', + 'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}], + 'fallback_text': 'Reply approve.', + } + + view = build_interaction_view(request, 'callback-token') + assert view.children[0].custom_id == 'lbi:callback-token:a:0' + + result = await adapter.call_platform_api( + 'interaction.request', + { + 'request': request, + 'callback_token': 'callback-token', + 'reply_target': {'target_type': 'group', 'target_id': '789'}, + }, + ) + + assert result == {'ok': True, 'message_id': 321, 'rich': True} + assert channel.send.await_args.kwargs['view'].children[0].label == 'Approve' + + +def test_discord_interaction_component_builds_scoped_host_event(): + interaction = SimpleNamespace( + user=SimpleNamespace(id=123), + channel=SimpleNamespace(id=789), + guild=SimpleNamespace(id=456), + ) + + event = interaction_event_from_component( + interaction, + {'callback_token': 'callback-token', 'action_ref': 0}, + ) + + assert event.action == 'interaction.submitted' + assert event.data['actor_id'] == '123' + assert event.data['target_type'] == 'group' + assert event.data['target_id'] == '789' + + @pytest.mark.asyncio async def test_discord_adapter_dispatches_most_specific_eba_listener(): adapter = make_adapter() diff --git a/tests/unit_tests/platform/test_lark_eba_adapter.py b/tests/unit_tests/platform/test_lark_eba_adapter.py index 10b5ed2d8..8cbd65ba7 100644 --- a/tests/unit_tests/platform/test_lark_eba_adapter.py +++ b/tests/unit_tests/platform/test_lark_eba_adapter.py @@ -1,9 +1,11 @@ from __future__ import annotations +import json import pathlib import asyncio +import time from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest import yaml @@ -12,6 +14,11 @@ from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter 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 ( + build_interaction_card, + interaction_delivery_capabilities, + interaction_event_from_callback, +) from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events @@ -66,6 +73,7 @@ class DummyAPIClient: message=SimpleNamespace( acreate=AsyncMock(return_value=DummyResponse()), areply=AsyncMock(return_value=DummyResponse()), + aupdate=AsyncMock(return_value=DummyResponse()), aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(items=[]))), ), chat=SimpleNamespace( @@ -89,8 +97,12 @@ class DummyAPIClient: ) self.cardkit = SimpleNamespace( v1=SimpleNamespace( - card=SimpleNamespace(create=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id')))), - card_element=SimpleNamespace(content=AsyncMock(return_value=DummyResponse())), + card=SimpleNamespace( + create=MagicMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id'))), + acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id'))), + aupdate=AsyncMock(return_value=DummyResponse()), + ), + card_element=SimpleNamespace(content=MagicMock(return_value=DummyResponse())), ) ) @@ -114,7 +126,7 @@ def manifest() -> dict: / 'lark' / 'manifest.yaml' ) - return yaml.safe_load(path.read_text()) + return yaml.safe_load(path.read_text(encoding='utf-8')) def make_adapter(config: dict | None = None) -> LarkAdapter: @@ -324,3 +336,550 @@ async def test_lark_send_reply_platform_api_and_modes(): with pytest.raises(asyncio.CancelledError): await task webhook_adapter.bot._connect.assert_not_awaited() + + +def test_lark_interaction_card_uses_host_callback_token_and_indexes(): + card = build_interaction_card( + { + 'title': 'Approve?', + 'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}], + }, + 'callback-token', + {'target_type': 'group', 'target_id': 'chat-1'}, + ) + + assert card['schema'] == '2.0' + button = card['body']['elements'][-1]['columns'][0]['elements'][0] + assert button['behaviors'][0]['value'] == {'lbi': 'callback-token', 't': 'group', 'ck': 1, 'a': 0} + + +def test_lark_interaction_card_uses_native_text_input_form(): + card = build_interaction_card( + { + 'title': 'Add context', + 'fields': [ + { + 'id': 'comment', + 'label': 'Comment', + 'type': 'textarea', + 'required': True, + 'placeholder': 'Explain the decision', + } + ], + 'actions': [], + }, + 'callback-token', + {'target_type': 'group', 'target_id': 'chat-1'}, + ) + + form = next(element for element in card['body']['elements'] if element['tag'] == 'form') + field, submit = form['elements'] + assert field['tag'] == 'input' + assert field['input_type'] == 'multiline_text' + assert field['required'] is True + assert submit['form_action_type'] == 'submit' + assert submit['behaviors'][0]['value'] == { + 'lbi': 'callback-token', + 't': 'group', + 'ck': 1, + 'fm': {'lbi_field_0': 'comment'}, + 'ft': {'comment': 'textarea'}, + } + + +def test_lark_interaction_card_uses_native_select_and_declares_fields(): + card = build_interaction_card( + { + 'title': 'Priority', + 'fields': [ + { + 'id': 'priority', + 'label': 'Priority', + 'type': 'select', + 'required': True, + 'options': [ + {'value': 'normal', 'label': 'Normal'}, + {'value': 'urgent', 'label': 'Urgent'}, + ], + } + ], + 'actions': [], + }, + 'callback-token', + {'target_type': 'person', 'target_id': 'user-1'}, + ) + + form = next(element for element in card['body']['elements'] if element['tag'] == 'form') + label, select, submit = form['elements'] + assert label == {'tag': 'markdown', 'content': '**Priority***'} + assert select['tag'] == 'select_static' + assert [option['value'] for option in select['options']] == ['normal', 'urgent'] + assert submit['form_action_type'] == 'submit' + assert interaction_delivery_capabilities()['field_types'] == ['text', 'textarea', 'number', 'select'] + assert interaction_delivery_capabilities()['supports_updates'] is True + + +@pytest.mark.asyncio +async def test_lark_interaction_request_sends_interactive_message(): + adapter = make_adapter() + + result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'callback-token', + 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, + 'request': { + 'interaction_id': 'form-1', + 'title': 'Approve?', + 'actions': [{'id': 'approve', 'label': 'Approve'}], + 'fallback_text': 'Reply approve.', + }, + }, + ) + + assert result['rich'] is True + assert result['card_id'] == 'card-id' + adapter.api_client.cardkit.v1.card.acreate.assert_awaited_once() + request = adapter.api_client.im.v1.message.acreate.await_args.args[0] + assert request.receive_id_type == 'open_id' + assert request.request_body.msg_type == 'interactive' + assert json.loads(request.request_body.content) == {'type': 'card', 'data': {'card_id': 'card-id'}} + + +@pytest.mark.asyncio +async def test_lark_interaction_request_updates_existing_message(): + adapter = make_adapter() + + result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'next-token', + 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, + 'update_target': { + 'message_id': 'card-message-1', + 'card_id': 'card-id', + 'sequence': 0, + 'rich': True, + }, + 'request': { + 'interaction_id': 'form-2', + 'title': 'Choose action', + 'actions': [{'id': 'approve', 'label': 'Approve'}], + 'fallback_text': 'Reply approve.', + }, + }, + ) + + assert result == { + 'ok': True, + 'message_id': 'card-message-1', + 'card_id': 'card-id', + 'sequence': 1, + 'rich': True, + 'updated': True, + } + adapter.api_client.im.v1.message.acreate.assert_not_awaited() + update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0] + assert update_request.card_id == 'card-id' + assert update_request.request_body.sequence == 1 + card = json.loads(update_request.request_body.card.data) + assert card['header']['title']['content'] == 'Choose action' + button = card['body']['elements'][-1]['columns'][0]['elements'][0] + assert button['behaviors'][0]['value']['lbi'] == 'next-token' + + +@pytest.mark.asyncio +async def test_lark_interaction_acknowledge_replaces_controls_with_summary(): + adapter = make_adapter() + + result = await adapter.call_platform_api( + 'interaction.acknowledge', + { + 'update_target': { + 'message_id': 'card-message-1', + 'card_id': 'card-id', + 'sequence': 0, + 'rich': True, + }, + 'request': { + 'title': 'Manual review', + 'fields': [{'id': 'comment', 'label': 'Comment'}], + }, + 'submission': {'values': {'comment': 'Looks good'}}, + }, + ) + + assert result['updated'] is True + update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0] + card = json.loads(update_request.request_body.card.data) + content = card['body']['elements'][-1]['content'] + assert content == '✅ Comment:Looks good' + + +@pytest.mark.asyncio +async def test_lark_interaction_keeps_prior_values_through_next_action_and_final_acknowledgement(): + adapter = make_adapter() + update_target = { + 'message_id': 'card-message-1', + 'card_id': 'card-id', + 'sequence': 1, + 'rich': True, + 'submitted_values': [{'label': 'Question', 'value': '123'}], + } + + action_result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'next-token', + 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, + 'update_target': update_target, + 'request': { + 'interaction_id': 'action-1', + 'title': 'Manual review', + 'actions': [{'id': 'or', 'label': 'or'}], + 'fallback_text': 'Reply or.', + }, + }, + ) + + action_update = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0] + action_card = json.loads(action_update.request_body.card.data) + assert '✅ Question:123' in action_card['body']['elements'][0]['content'] + assert action_result['submitted_values'] == [{'label': 'Question', 'value': '123'}] + + final_result = await adapter.call_platform_api( + 'interaction.acknowledge', + { + 'update_target': action_result, + 'request': { + 'title': 'Manual review', + 'actions': [{'id': 'or', 'label': 'or'}], + }, + 'submission': {'action_id': 'or', 'values': {}}, + }, + ) + + final_update = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0] + final_card = json.loads(final_update.request_body.card.data) + final_content = final_card['body']['elements'][-1]['content'] + assert '✅ Action:or' in final_content + assert '✅ Question:123' in final_card['body']['elements'][0]['content'] + assert final_result['submitted_values'] == [ + {'label': 'Question', 'value': '123'}, + {'label': 'Action', 'value': 'or'}, + ] + + +def test_lark_dify_layout_keeps_prompts_next_to_submitted_values(): + prior_values = [ + { + 'description': '11\n请输入你的问题', + 'label': 'us_input', + 'value': '回复我你好', + } + ] + select_card = build_interaction_card( + { + 'title': '人工介入', + 'description': '请选择你的答案', + 'fields': [ + { + 'id': 'field_2', + 'label': 'xiala', + 'type': 'select', + 'options': [ + {'value': '1', 'label': '1'}, + {'value': '2', 'label': '2'}, + ], + } + ], + 'actions': [], + }, + 'callback-token', + {'target_type': 'person', 'target_id': 'user-1'}, + prior_values, + ) + + select_elements = select_card['body']['elements'] + assert select_elements[0]['content'] == '11\n请输入你的问题\n✅ us_input:回复我你好' + assert select_elements[1]['content'] == '请选择你的答案' + assert select_elements[2]['tag'] == 'form' + + action_card = build_interaction_card( + { + 'title': '人工介入', + 'description': None, + 'fields': [], + 'actions': [{'id': 'action_1', 'label': 'or'}], + }, + 'callback-token', + {'target_type': 'person', 'target_id': 'user-1'}, + [ + *prior_values, + { + 'description': '请选择你的答案', + 'label': 'xiala', + 'value': '1', + }, + ], + ) + + action_elements = action_card['body']['elements'] + assert [element['content'] for element in action_elements[:2]] == [ + '11\n请输入你的问题\n✅ us_input:回复我你好', + '请选择你的答案\n✅ xiala:1', + ] + assert action_elements[2]['columns'][0]['elements'][0]['text']['content'] == 'or' + + +def test_lark_message_id_from_card_callback_source(): + adapter = make_adapter() + callback_source = SimpleNamespace( + event=SimpleNamespace(context=SimpleNamespace(open_message_id='card-message-1')), + ) + + assert ( + adapter._message_id_from_source(SimpleNamespace(message_id=None, source_platform_object=callback_source)) + == 'card-message-1' + ) + assert ( + adapter._message_id_from_source( + SimpleNamespace( + message_id=None, + source_platform_object={'event': {'context': {'open_message_id': 'webhook-message-1'}}}, + ) + ) + == 'webhook-message-1' + ) + + +@pytest.mark.asyncio +async def test_lark_streaming_card_uses_strictly_increasing_sequences(): + adapter = make_adapter() + adapter.card_id_dict['response-1'] = 'stream-card-1' + adapter.card_sequence_dict['stream-card-1'] = 0 + adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'answer'}]], [])) + bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=1, tool_calls=None) + source = SimpleNamespace(source_platform_object=None) + message = platform_message.MessageChain([platform_message.Plain(text='answer')]) + + await adapter.reply_message_chunk(source, bot_message, message) + first_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + assert first_request.request_body.sequence == 1 + + bot_message.msg_sequence = 2 + await adapter.reply_message_chunk(source, bot_message, message) + assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1 + + bot_message.msg_sequence = 8 + await adapter.reply_message_chunk(source, bot_message, message) + second_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + assert second_request.request_body.sequence == 2 + + bot_message.msg_sequence = 9 + await adapter.reply_message_chunk(source, bot_message, message, is_final=True) + final_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + assert final_request.request_body.sequence == 3 + assert 'response-1' not in adapter.card_id_dict + assert 'stream-card-1' not in adapter.card_sequence_dict + assert 'stream-card-1' not in adapter.card_last_update_dict + + +@pytest.mark.asyncio +async def test_lark_streaming_card_updates_sparse_chunks_without_waiting_for_eighth(): + adapter = make_adapter() + adapter.card_id_dict['response-1'] = 'stream-card-1' + adapter.card_sequence_dict['stream-card-1'] = 1 + adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2 + adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'new progress'}]], [])) + bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=2, tool_calls=None) + source = SimpleNamespace(source_platform_object=None) + message = platform_message.MessageChain([platform_message.Plain(text='new progress')]) + + await adapter.reply_message_chunk(source, bot_message, message) + + request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + assert request.request_body.sequence == 2 + + +@pytest.mark.asyncio +async def test_lark_streaming_card_uses_cumulative_runner_content(): + adapter = make_adapter() + adapter.card_id_dict['response-1'] = 'stream-card-1' + adapter.card_sequence_dict['stream-card-1'] = 0 + adapter.message_converter.yiri2target = AsyncMock( + return_value=([[{'tag': 'text', 'text': 'latest chunk only'}]], []) + ) + bot_message = SimpleNamespace( + resp_message_id='response-1', + msg_sequence=2, + all_content='first chunk\n\nlatest chunk only', + tool_calls=None, + ) + source = SimpleNamespace(source_platform_object=None) + message = platform_message.MessageChain([platform_message.Plain(text='latest chunk only')]) + + await adapter.reply_message_chunk(source, bot_message, message) + + request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + assert request.request_body.content == 'first chunk\n\nlatest chunk only' + adapter.message_converter.yiri2target.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_lark_streaming_card_first_real_runner_chunk_uses_sequence_one(): + adapter = make_adapter() + adapter.card_id_dict['response-1'] = 'stream-card-1' + adapter.card_sequence_dict['stream-card-1'] = 0 + adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'answer'}]], [])) + bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=1, tool_calls=None) + source = SimpleNamespace(source_platform_object=None) + message = platform_message.MessageChain([platform_message.Plain(text='answer')]) + + await adapter.reply_message_chunk(source, bot_message, message, is_final=True) + + request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0] + assert request.request_body.sequence == 1 + + +@pytest.mark.asyncio +async def test_lark_streaming_card_falls_back_to_full_card_update_when_stream_closes(): + adapter = make_adapter() + adapter.card_id_dict['response-1'] = 'stream-card-1' + adapter.card_sequence_dict['stream-card-1'] = 1 + adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2 + closed_response = DummyResponse(ok=False) + closed_response.code = 300309 + closed_response.msg = 'streaming mode is closed' + adapter.api_client.cardkit.v1.card_element.content.return_value = closed_response + adapter.message_converter.yiri2target = AsyncMock( + return_value=([[{'tag': 'text', 'text': 'continued progress'}]], []) + ) + bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=2, tool_calls=None) + source = SimpleNamespace(source_platform_object=None) + message = platform_message.MessageChain([platform_message.Plain(text='continued progress')]) + + await adapter.reply_message_chunk(source, bot_message, message) + + update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0] + updated_card = json.loads(update_request.request_body.card.data) + assert update_request.request_body.sequence == 3 + assert updated_card['config'] == {'update_multi': True} + assert updated_card['body']['elements'][0]['content'] == 'continued progress' + assert 'stream-card-1' in adapter.closed_streaming_cards + + bot_message.msg_sequence = 3 + adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2 + await adapter.reply_message_chunk(source, bot_message, message, is_final=True) + + assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1 + assert adapter.api_client.cardkit.v1.card.aupdate.await_count == 2 + assert 'stream-card-1' not in adapter.closed_streaming_cards + + +def test_lark_interaction_callback_builds_scoped_host_event(): + callback = SimpleNamespace( + event=SimpleNamespace( + action=SimpleNamespace(value={'lbi': 'callback-token', 't': 'group', 'a': 1}), + operator=SimpleNamespace(open_id='user-1'), + context=SimpleNamespace(open_chat_id='chat-1', open_message_id='card-message-1'), + ) + ) + + event = interaction_event_from_callback(callback) + + assert event.action == 'interaction.submitted' + assert event.data['callback_token'] == 'callback-token' + assert event.data['action_ref'] == 1 + assert event.data['actor_id'] == 'user-1' + assert event.data['target_id'] == 'chat-1' + assert event.data['message_id'] == 'card-message-1' + + +def test_lark_native_form_callback_submits_typed_field_value(): + callback = SimpleNamespace( + event=SimpleNamespace( + action=SimpleNamespace( + value={ + 'lbi': 'callback-token', + 't': 'person', + 'fm': {'lbi_field_0': 'score'}, + 'ft': {'score': 'number'}, + }, + form_value={'lbi_field_0': '42.5'}, + ), + operator=SimpleNamespace(open_id='user-1'), + context=SimpleNamespace(open_chat_id='chat-1'), + ) + ) + + event = interaction_event_from_callback(callback) + + assert event.data['callback_token'] == 'callback-token' + assert event.data['target_type'] == 'person' + assert event.data['target_id'] == 'user-1' + assert event.data['values'] == {'score': 42.5} + + +@pytest.mark.asyncio +async def test_lark_webhook_dispatches_native_form_submission(): + adapter = make_adapter({'enable-webhook': True}) + calls: list[platform_events.Event] = [] + + async def listener(event, adapter): + calls.append(event) + + adapter.register_listener(platform_events.PlatformSpecificEvent, listener) + payload = { + 'schema': '2.0', + 'header': {'event_type': 'card.action.trigger'}, + 'event': { + 'action': { + 'value': { + 'lbi': 'callback-token', + 't': 'group', + 'fm': {'lbi_field_0': 'comment'}, + 'ft': {'comment': 'textarea'}, + }, + 'form_value': {'lbi_field_0': 'Looks good'}, + }, + 'operator': {'open_id': 'user-1'}, + 'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'}, + }, + } + request = SimpleNamespace(json=asyncio.sleep(0, result=payload)) + + response = await adapter.handle_unified_webhook('bot-1', '', request) + + assert response['toast'] == {'type': 'success', 'content': 'Submitted / 已提交'} + assert response['card']['type'] == 'raw' + assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted / 已提交**' + assert len(calls) == 1 + assert calls[0].action == 'interaction.submitted' + assert calls[0].data['callback_token'] == 'callback-token' + assert calls[0].data['values'] == {'comment': 'Looks good'} + assert calls[0].data['message_id'] == 'card-message-1' + + +@pytest.mark.asyncio +async def test_lark_webhook_cardkit_submission_uses_async_card_update_only(): + adapter = make_adapter({'enable-webhook': True}) + + async def listener(event, adapter): + pass + + adapter.register_listener(platform_events.PlatformSpecificEvent, listener) + payload = { + 'schema': '2.0', + 'header': {'event_type': 'card.action.trigger'}, + 'event': { + 'action': {'value': {'lbi': 'callback-token', 't': 'group', 'ck': 1, 'a': 0}}, + 'operator': {'open_id': 'user-1'}, + 'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'}, + }, + } + request = SimpleNamespace(json=asyncio.sleep(0, result=payload)) + + response = await adapter.handle_unified_webhook('bot-1', '', request) + + assert response == {'toast': {'type': 'success', 'content': 'Submitted / 已提交'}} diff --git a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py index d979a033e..44341bf02 100644 --- a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py +++ b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py @@ -13,6 +13,10 @@ from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter 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 ( + build_interaction_keyboard, + interaction_event_from_payload, +) from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events @@ -49,6 +53,9 @@ class DummyQQOfficialClient: self.access_token_expiry_time = None self.handle_unified_webhook = AsyncMock(return_value='success') self.connect_gateway_loop = AsyncMock() + self.send_markdown_keyboard = AsyncMock(return_value={'id': 'keyboard-message'}) + self.ack_interaction = AsyncMock() + self._interaction_handler = None def on_message(self, msg_type: str): def decorator(func): @@ -57,6 +64,13 @@ class DummyQQOfficialClient: return decorator + def on_interaction(self): + def decorator(func): + self._interaction_handler = func + return func + + return decorator + async def check_access_token(self): return bool(self.access_token) @@ -111,7 +125,7 @@ def manifest() -> dict: / 'qqofficial' / 'manifest.yaml' ) - return yaml.safe_load(manifest_path.read_text()) + return yaml.safe_load(manifest_path.read_text(encoding='utf-8')) def make_adapter(enable_webhook: bool = True) -> QQOfficialAdapter: @@ -164,6 +178,54 @@ def test_qqofficial_platform_api_map_matches_manifest(): assert set(PLATFORM_API_MAP) == manifest_actions +def test_qqofficial_interaction_keyboard_contains_only_host_token_and_index(): + keyboard = build_interaction_keyboard( + {'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}]}, + 'callback-token', + ) + + button = keyboard['content']['rows'][0]['buttons'][0] + assert button['action']['data'] == 'lbi:callback-token:a:0' + + +@pytest.mark.asyncio +async def test_qqofficial_interaction_delivery_and_callback_event(): + adapter = make_adapter() + result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'callback-token', + 'reply_target': { + 'target_type': 'group', + 'target_id': 'group-openid', + 'message_id': 'msg-1', + }, + 'request': { + 'interaction_id': 'form-1', + 'title': 'Approve?', + 'actions': [{'id': 'approve', 'label': 'Approve'}], + 'fallback_text': 'Reply approve.', + }, + }, + ) + + assert result == {'ok': True, 'message_id': 'keyboard-message', 'rich': True} + assert adapter.bot.send_markdown_keyboard.await_args.kwargs['msg_id'] == 'msg-1' + + event = interaction_event_from_payload( + { + 'id': 'interaction-id', + 'chat_type': 1, + 'group_openid': 'group-openid', + 'member_openid': 'user-openid', + 'data': {'resolved': {'button_data': 'lbi:callback-token:a:0'}}, + } + ) + assert event.action == 'interaction.submitted' + assert event.data['action_ref'] == 0 + assert event.data['actor_id'] == 'user-openid' + + @pytest.mark.asyncio async def test_qqofficial_message_converter_maps_common_components_to_send_payloads(): payload = await QQOfficialMessageConverter.yiri2target( diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index 32846bfb8..fbc6d4942 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -73,7 +73,7 @@ class TestEventRouteTrace: captured_envelopes = [] - async def fake_run(envelope, binding): + async def fake_run(envelope, binding, adapter_context=None): captured_envelopes.append(envelope) yield provider_message.Message(role='assistant', content='test response') @@ -157,7 +157,7 @@ class TestEventRouteTrace: } runner_calls = [] - async def fake_run(envelope, binding): + async def fake_run(envelope, binding, adapter_context=None): runner_calls.append((envelope, binding)) if False: yield None @@ -378,6 +378,53 @@ class TestRuntimeBotLifecycle: bot.adapter.kill.assert_awaited_once() task_mgr.cancel_task.assert_not_called() + @pytest.mark.asyncio + async def test_initialize_uses_only_eba_message_listener_for_eba_adapter(self): + """An EBA-native message must not also enter through the legacy compatibility listener.""" + from langbot.pkg.platform.botmgr import RuntimeBot + from langbot_plugin.api.entities.builtin.platform import events as platform_events + + listeners = {} + adapter = SimpleNamespace( + get_supported_events=Mock(return_value=['message.received']), + register_listener=Mock(side_effect=lambda event_type, callback: listeners.setdefault(event_type, callback)), + ) + bot = RuntimeBot( + ap=SimpleNamespace(), + bot_entity=SimpleNamespace(enable=True), + adapter=adapter, + logger=Mock(), + ) + + await bot.initialize() + + assert platform_events.EBAEvent in listeners + assert platform_events.FriendMessage not in listeners + assert platform_events.GroupMessage not in listeners + + @pytest.mark.asyncio + async def test_initialize_keeps_legacy_message_listeners_for_legacy_adapter(self): + """Adapters without EBA message support retain the compatibility entry path.""" + from langbot.pkg.platform.botmgr import RuntimeBot + from langbot_plugin.api.entities.builtin.platform import events as platform_events + + listeners = {} + adapter = SimpleNamespace( + register_listener=Mock(side_effect=lambda event_type, callback: listeners.setdefault(event_type, callback)), + ) + bot = RuntimeBot( + ap=SimpleNamespace(), + bot_entity=SimpleNamespace(enable=True), + adapter=adapter, + logger=Mock(), + ) + + await bot.initialize() + + assert platform_events.FriendMessage in listeners + assert platform_events.GroupMessage in listeners + assert platform_events.EBAEvent in listeners + class TestEBAEventBindings: """Test RuntimeBot EBA event binding helpers.""" @@ -482,8 +529,11 @@ class TestEBAEventBindings: assert binding.resource_policy.allowed_tool_names is None assert binding.delivery_policy.enable_streaming is False assert binding.delivery_policy.enable_reply is True + assert binding.delivery_policy.enable_interactions is True assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner'] assert binding.agent_id == 'agent-1' + assert binding.processor_type == 'agent' + assert binding.processor_id == 'agent-1' def test_agent_product_to_binding_projects_selected_tool_policy(self): """Independent Agents use the same standard runner resource fields as Pipelines.""" @@ -513,6 +563,174 @@ class TestEBAEventBindings: assert binding.resource_policy.allowed_tool_names == ['exec', 'plugin_tool'] assert binding.resource_policy.allowed_kb_uuids == ['kb-1'] + +class TestInteractionResumeRouting: + """Interaction callbacks resume the processor that created the request.""" + + @staticmethod + def _event(): + from langbot_plugin.api.entities.builtin.platform.events import PlatformSpecificEvent + + return PlatformSpecificEvent( + action='interaction.submitted', + timestamp=1234, + data={ + 'callback_token': 'callback-token', + 'interaction_id': 'form-1', + 'action_id': 'approve', + 'values': {'name': 'Alice'}, + 'actor_id': 'user-1', + 'target_type': 'group', + 'target_id': 'chat-1', + 'message_id': 'card-message-1', + }, + ) + + @staticmethod + def _record(processor_type: str): + return { + 'id': 1, + 'interaction_id': 'form-1', + 'binding_id': 'binding-1', + 'runner_id': 'plugin:test/Dify/default', + 'processor_type': processor_type, + 'processor_id': f'{processor_type}-1', + 'workspace_id': None, + 'conversation_id': 'group_chat-1', + 'thread_id': None, + 'delivery_target': {'target_type': 'group', 'target_id': 'chat-1'}, + 'submission': { + 'interaction_id': 'form-1', + 'action_id': 'approve', + 'values': {'name': 'Alice'}, + 'submitted_at': 1234, + }, + } + + @staticmethod + def _make_bot(record): + from langbot.pkg.platform.botmgr import RuntimeBot + + bot = object.__new__(RuntimeBot) + bot.bot_entity = SimpleNamespace(uuid='bot-1', name='Test', event_bindings=[]) + interaction_manager = SimpleNamespace( + consume_callback=AsyncMock(return_value=record), + acknowledge_submission=AsyncMock(), + ) + bot.ap = SimpleNamespace( + agent_run_orchestrator=SimpleNamespace(interaction_manager=interaction_manager), + msg_aggregator=SimpleNamespace(add_message=AsyncMock()), + pipeline_service=SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'uuid': record['processor_id'], + 'config': { + 'ai': { + 'runner': {'id': record['runner_id']}, + 'runner_config': {record['runner_id']: {}}, + } + }, + } + ) + ), + ) + return bot + + @pytest.mark.asyncio + async def test_pipeline_callback_bypasses_route_table_and_targets_original_pipeline(self): + bot = self._make_bot(self._record('pipeline')) + adapter = SimpleNamespace() + + await bot._handle_interaction_submission(self._event(), adapter) + + bot.ap.msg_aggregator.add_message.assert_awaited_once() + kwargs = bot.ap.msg_aggregator.add_message.await_args.kwargs + assert kwargs['pipeline_uuid'] == 'pipeline-1' + assert kwargs['routed_by_rule'] is True + assert kwargs['variables']['_interaction_submission']['interaction_id'] == 'form-1' + assert kwargs['message_chain'].message_id == 'card-message-1' + assert kwargs['message_event'].message_chain.message_id == 'card-message-1' + manager = bot.ap.agent_run_orchestrator.interaction_manager + manager.consume_callback.assert_awaited_once_with( + callback_token='callback-token', + submission={ + 'interaction_id': 'form-1', + 'action_id': 'approve', + 'values': {'name': 'Alice'}, + 'submitted_at': 1234, + }, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + + @pytest.mark.asyncio + async def test_callback_forwards_compact_platform_references(self): + bot = self._make_bot(self._record('pipeline')) + adapter = SimpleNamespace() + event = self._event() + event.data.pop('interaction_id') + event.data.pop('action_id') + event.data['action_ref'] = 1 + + await bot._handle_interaction_submission(event, adapter) + + manager = bot.ap.agent_run_orchestrator.interaction_manager + submission = manager.consume_callback.await_args.kwargs['submission'] + assert submission['action_ref'] == 1 + assert submission['interaction_id'] is None + + @pytest.mark.asyncio + async def test_pipeline_callback_rejects_changed_runner(self): + bot = self._make_bot(self._record('pipeline')) + bot.ap.pipeline_service.get_pipeline.return_value['config']['ai']['runner']['id'] = 'plugin:test/Other/default' + + with pytest.raises(ValueError, match='Pipeline runner changed'): + await bot._handle_interaction_submission(self._event(), SimpleNamespace()) + + bot.ap.msg_aggregator.add_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_agent_callback_targets_original_agent_and_runner(self): + import langbot_plugin.api.entities.builtin.provider.message as provider_message + + captured = [] + + async def fake_run(envelope, binding, adapter_context=None): + captured.append((envelope, binding, adapter_context)) + yield provider_message.Message(role='assistant', content='approved') + + bot = self._make_bot(self._record('agent')) + bot.ap.agent_service = SimpleNamespace( + get_agent=AsyncMock( + return_value={ + 'uuid': 'agent-1', + 'kind': 'agent', + 'enabled': True, + 'config': { + 'runner': {'id': 'plugin:test/Dify/default'}, + 'runner_config': {'plugin:test/Dify/default': {}}, + }, + } + ) + ) + bot.ap.agent_run_orchestrator.run = fake_run + adapter = SimpleNamespace( + get_supported_apis=Mock(return_value=['send_message']), + send_message=AsyncMock(), + ) + + await bot._handle_interaction_submission(self._event(), adapter) + + envelope, binding, adapter_context = captured[0] + assert envelope.event_type == 'interaction.submitted' + assert envelope.data['interaction']['action_id'] == 'approve' + assert binding.binding_id == 'binding-1' + assert binding.processor_type == 'agent' + assert binding.processor_id == 'agent-1' + assert adapter_context == {'_delivery_adapter': adapter} + adapter.send_message.assert_awaited_once() + def test_agent_product_to_binding_does_not_fallback_to_component_ref(self): """An empty config runner stays unconfigured even if component_ref is stale.""" from langbot.pkg.platform.botmgr import RuntimeBot diff --git a/tests/unit_tests/platform/test_telegram_eba_adapter.py b/tests/unit_tests/platform/test_telegram_eba_adapter.py index c703e9a7d..8fa5f7617 100644 --- a/tests/unit_tests/platform/test_telegram_eba_adapter.py +++ b/tests/unit_tests/platform/test_telegram_eba_adapter.py @@ -14,6 +14,10 @@ from telegram.ext import CallbackQueryHandler, ChatMemberHandler, MessageHandler from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter +from langbot.pkg.platform.adapters.telegram.interaction import ( + interaction_event_from_update, + parse_interaction_callback, +) from langbot.pkg.platform.botmgr import RuntimeBot from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger from langbot_plugin.api.entities.builtin.platform import entities as platform_entities @@ -152,13 +156,159 @@ def test_telegram_supported_events_match_manifest(): / 'telegram' / 'manifest.yaml' ) - manifest_events = yaml.safe_load(manifest_path.read_text())['spec']['supported_events'] + manifest_events = yaml.safe_load(manifest_path.read_text(encoding='utf-8'))['spec']['supported_events'] assert adapter_events == manifest_events assert 'message.deleted' not in adapter_events assert 'group.info_updated' not in adapter_events +def test_telegram_interaction_callback_parser_uses_compact_indexes(): + assert parse_interaction_callback('lbi:token:a:2') == { + 'callback_token': 'token', + 'action_ref': 2, + } + assert parse_interaction_callback('lbi:token:f:1:3') == { + 'callback_token': 'token', + 'field_ref': 1, + 'option_ref': 3, + } + assert parse_interaction_callback('ordinary-button') is None + with pytest.raises(ValueError, match='invalid Telegram interaction callback'): + parse_interaction_callback('lbi:token:a:not-an-index') + + +@pytest.mark.asyncio +async def test_telegram_interaction_request_renders_action_buttons_and_thread_target(): + adapter = make_adapter() + sent = SimpleNamespace(message_id=321) + bot = SimpleNamespace(send_message=AsyncMock(return_value=sent)) + object.__setattr__(adapter, 'bot', bot) + + result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'callback-token', + 'reply_target': {'target_type': 'group', 'target_id': '-1001#7'}, + 'request': { + 'interaction_id': 'form-1', + 'kind': 'choice', + 'title': 'Approve?', + 'actions': [ + {'id': 'approve', 'label': 'Approve', 'style': 'primary'}, + {'id': 'reject', 'label': 'Reject', 'style': 'danger'}, + ], + 'fallback_text': 'Reply approve or reject.', + }, + }, + ) + + assert result == {'ok': True, 'message_id': 321, 'rich': True} + kwargs = bot.send_message.await_args.kwargs + assert kwargs['chat_id'] == -1001 + assert kwargs['message_thread_id'] == 7 + buttons = kwargs['reply_markup'].inline_keyboard + assert buttons[0][0].callback_data == 'lbi:callback-token:a:0' + assert buttons[1][0].callback_data == 'lbi:callback-token:a:1' + + +@pytest.mark.asyncio +async def test_telegram_interaction_request_renders_select_or_falls_back(): + adapter = make_adapter() + bot = SimpleNamespace(send_message=AsyncMock(return_value=SimpleNamespace(message_id=1))) + object.__setattr__(adapter, 'bot', bot) + base_params = { + 'callback_token': 'callback-token', + 'reply_target': {'target_type': 'person', 'target_id': '123'}, + 'request': { + 'interaction_id': 'form-1', + 'title': 'Choose priority', + 'fields': [ + { + 'id': 'priority', + 'label': 'Priority', + 'type': 'select', + 'options': [{'value': 'urgent', 'label': 'Urgent'}], + } + ], + 'fallback_text': 'Reply with a priority.', + }, + } + + result = await adapter.call_platform_api('interaction.request', base_params) + assert result['rich'] is True + button = bot.send_message.await_args.kwargs['reply_markup'].inline_keyboard[0][0] + assert button.callback_data == 'lbi:callback-token:f:0:0' + + base_params['request']['fields'][0]['type'] = 'text' + result = await adapter.call_platform_api('interaction.request', base_params) + assert result['rich'] is False + kwargs = bot.send_message.await_args.kwargs + assert 'reply_markup' not in kwargs + assert 'Reply with a priority.' in kwargs['text'] + + +def test_telegram_interaction_callback_builds_scoped_host_event(): + update = SimpleNamespace( + callback_query=SimpleNamespace( + message=SimpleNamespace( + chat=SimpleNamespace(id=-1001, type='supergroup'), + message_thread_id=7, + ), + from_user=SimpleNamespace(id=456), + ) + ) + + event = interaction_event_from_update( + update, + {'callback_token': 'callback-token', 'action_ref': 1}, + ) + + assert event.action == 'interaction.submitted' + assert event.data['callback_token'] == 'callback-token' + assert event.data['action_ref'] == 1 + assert event.data['actor_id'] == '456' + assert event.data['target_type'] == 'group' + assert event.data['target_id'] == '-1001#7' + + +@pytest.mark.asyncio +async def test_telegram_interaction_callback_answers_and_dispatches_once(): + adapter = make_adapter() + listener = AsyncMock() + adapter.register_listener(platform_events.EBAEvent, listener) + query = SimpleNamespace( + data='lbi:callback-token:a:0', + answer=AsyncMock(), + edit_message_reply_markup=AsyncMock(), + message=SimpleNamespace( + chat=SimpleNamespace(id=123, type='private'), + message_thread_id=None, + ), + from_user=SimpleNamespace(id=456), + ) + update = SimpleNamespace( + message=None, + edited_message=None, + chat_member=None, + my_chat_member=None, + callback_query=query, + message_reaction=None, + ) + callback_handler = next( + handler for handler in adapter.application.handlers[0] if isinstance(handler, CallbackQueryHandler) + ) + + await callback_handler.callback(update, None) + + query.answer.assert_awaited_once_with() + query.edit_message_reply_markup.assert_awaited_once_with(reply_markup=None) + listener.assert_awaited_once() + event = listener.await_args.args[0] + assert event.action == 'interaction.submitted' + assert event.data['action_ref'] == 0 + + @pytest.mark.asyncio async def test_telegram_converter_maps_message_and_edited_message_events(): update = make_update({'message': base_message_payload(text='hello @test_bot')}) @@ -187,6 +337,19 @@ async def test_telegram_converter_maps_message_and_edited_message_events(): assert str(edited_event.new_content) == 'edited' +@pytest.mark.asyncio +async def test_telegram_legacy_group_converter_preserves_actor_identity(): + adapter = make_adapter() + group_chat = {'id': -100123, 'type': 'supergroup', 'title': 'Test Group'} + update = make_update({'message': base_message_payload(chat=group_chat)}) + + event = await adapter.legacy_event_converter.target2yiri(update, adapter.bot, 'test_bot') + + assert isinstance(event, platform_events.GroupMessage) + assert event.sender.id == 456 + assert event.sender.group.id == -100123 + + @pytest.mark.asyncio async def test_telegram_converter_maps_non_message_updates(): chat_member = make_update( diff --git a/tests/unit_tests/platform/test_wecombot_eba_adapter.py b/tests/unit_tests/platform/test_wecombot_eba_adapter.py index e284abea9..2376d8b10 100644 --- a/tests/unit_tests/platform/test_wecombot_eba_adapter.py +++ b/tests/unit_tests/platform/test_wecombot_eba_adapter.py @@ -11,6 +11,10 @@ from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter 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 ( + build_interaction_card, + interaction_event_from_native, +) from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events @@ -44,6 +48,7 @@ class DummyWecomBotWsClient: self.reply_text = AsyncMock(return_value={'reply': True}) self.push_stream_chunk = AsyncMock(return_value=True) self.set_message = AsyncMock(return_value={'set': True}) + self.send_template_card = AsyncMock(return_value={'sent': True}) def on_message(self, msg_type: str): def decorator(func): @@ -82,7 +87,7 @@ def manifest() -> dict: / 'wecombot' / 'manifest.yaml' ) - return yaml.safe_load(manifest_path.read_text()) + return yaml.safe_load(manifest_path.read_text(encoding='utf-8')) def make_adapter(enable_webhook: bool = False) -> WecomBotAdapter: @@ -149,6 +154,52 @@ def test_wecombot_platform_api_map_matches_manifest(): assert set(PLATFORM_API_MAP) == manifest_actions +def test_wecombot_interaction_card_uses_host_callback_token_and_index(): + card = build_interaction_card( + {'title': 'Approve?', 'actions': [{'id': 'approve', 'label': 'Approve'}]}, + 'callback-token', + ) + + button = card['template_card']['button_list'][0] + assert button['key'] == 'lbi:callback-token:a:0' + + +@pytest.mark.asyncio +async def test_wecombot_interaction_delivery_and_callback_event(): + adapter = make_adapter() + result = await adapter.call_platform_api( + 'interaction.request', + { + 'callback_token': 'callback-token', + 'reply_target': {'target_type': 'group', 'target_id': 'group-1'}, + 'request': { + 'interaction_id': 'form-1', + 'title': 'Approve?', + 'actions': [{'id': 'approve', 'label': 'Approve'}], + 'fallback_text': 'Reply approve.', + }, + }, + ) + + assert result['rich'] is True + adapter.bot.send_template_card.assert_awaited_once() + + native = wecombot_event(type='group', msgtype='event') + native['event'] = { + 'eventtype': 'template_card_event', + 'template_card_event': {'EventKey': 'lbi:callback-token:a:0'}, + } + event = interaction_event_from_native(native) + assert event.action == 'interaction.submitted' + assert event.data['action_ref'] == 0 + assert event.data['target_id'] == 'group-1' + + native['event']['template_card_event'] = {'button': {'key': 'lbi:callback-token:f:0:2'}} + nested_event = interaction_event_from_native(native) + assert nested_event.data['field_ref'] == 0 + assert nested_event.data['option_ref'] == 2 + + @pytest.mark.asyncio async def test_wecombot_message_converter_maps_outbound_components_to_markdown_text(): content = await WecomBotMessageConverter.yiri2target( @@ -271,4 +322,6 @@ async def test_wecombot_send_reply_feedback_and_platform_api_use_underlying_clie async def test_wecombot_webhook_mode_rejects_proactive_send(): adapter = make_adapter(enable_webhook=True) with pytest.raises(NotSupportedError): - await adapter.send_message('person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')])) + await adapter.send_message( + 'person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')]) + ) diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 5ca785b77..c444cb249 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -6,6 +6,7 @@ import { PipelineConfigStage, } from '@/app/infra/entities/pipeline'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; +import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig'; import { Button } from '@/components/ui/button'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -350,10 +351,38 @@ export default function PipelineFormComponent({ const currentValues = (form.getValues(formName) as Record) || {}; - form.setValue(formName, { + const nextValues: Record = { ...currentValues, [stageName]: values, - }); + }; + + if (formName === 'ai' && stageName === 'runner') { + const selectedRunner = (values as Record).id; + const runnerConfigs = + currentValues.runner_config && + typeof currentValues.runner_config === 'object' && + !Array.isArray(currentValues.runner_config) + ? (currentValues.runner_config as Record) + : {}; + + if ( + typeof selectedRunner === 'string' && + selectedRunner && + !(selectedRunner in runnerConfigs) + ) { + const runnerStage = aiConfigTabSchema?.stages.find( + (stage) => stage.name === selectedRunner, + ); + if (runnerStage) { + nextValues.runner_config = { + ...runnerConfigs, + [selectedRunner]: getDefaultValues(runnerStage.config), + }; + } + } + } + + form.setValue(formName, nextValues); if (isFirstEmission) { initializedStagesRef.current.add(stageKey);