feat(agent): integrate structured runner interactions

This commit is contained in:
fdc310
2026-07-22 11:12:26 +08:00
parent e3df35e7f7
commit 6b13c37c3b
58 changed files with 6153 additions and 319 deletions
+86 -8
View File
@@ -103,6 +103,7 @@ class AgentRunnerCapabilities(BaseModel):
skill_authoring: bool = False skill_authoring: bool = False
interrupt: bool = False interrupt: bool = False
steering: bool = False steering: bool = False
interactions: bool = False
model_config = ConfigDict(extra="forbid") 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 是否可用。 - `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 支持取消或中断。 - `interrupt`: runner 支持取消或中断。
- `steering`: runner 支持在 turn 边界通过 Host pull API 消费同 conversation 在途追加消息。 - `steering`: runner 支持在 turn 边界通过 Host pull API 消费同 conversation 在途追加消息。
- `interactions`: runner 可以请求 Host 展示结构化交互,并处理后续 `interaction.submitted` 事件。
Capabilities 字段全部是 `bool`,未知 key 禁止进入 typed manifest。早期草案里的上下文/会话类 capability 已删除;对应语义由 event-first context 和 runner-owned context 原则表达。 Capabilities 字段全部是 `bool`,未知 key 禁止进入 typed manifest。早期草案里的上下文/会话类 capability 已删除;对应语义由 event-first context 和 runner-owned context 原则表达。
@@ -128,11 +130,15 @@ class AgentRunnerPermissions(BaseModel):
events: list[Literal["get", "page"]] = [] events: list[Literal["get", "page"]] = []
storage: list[Literal["plugin", "workspace"]] = [] storage: list[Literal["plugin", "workspace"]] = []
files: list[Literal["config", "knowledge"]] = [] files: list[Literal["config", "knowledge"]] = []
interactions: list[Literal["request"]] = []
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
``` ```
平台动作执行不属于当前 permissions。Platform action executor / EBA action 分支落地前,runner 只能返回 `action.requested` telemetryHost 不执行平台动作。 通用交互投递是当前唯一允许执行的 `action.requested` 白名单动作。Runner 必须同时声明
`capabilities.interactions=true``permissions.interactions=["request"]`Host 还必须将其与
当前 binding delivery policy、run authorization snapshot 和 adapter delivery capability 求交。
其它平台动作仍不属于当前 permissionsHost 收到后只记录 telemetry,不得执行。
Runner 实际可用 LangBot 资源来自 Host 在 run 前冻结的授权快照: Runner 实际可用 LangBot 资源来自 Host 在 run 前冻结的授权快照:
@@ -275,11 +281,13 @@ class AgentInput(BaseModel):
text: str | None = None text: str | None = None
contents: list[ContentElement] = [] contents: list[ContentElement] = []
attachments: list[InputAttachment] = [] attachments: list[InputAttachment] = []
interaction: InteractionSubmission | None = None
``` ```
- 文本、多模态、附件都属于当前 event input。 - 文本、多模态、附件都属于当前 event input。
- 大文件、图片、音频、工具大结果应进入授权 sandbox/workspaceinput attachment 只携带轻量 metadata/path/url/content。 - 大文件、图片、音频、工具大结果应进入授权 sandbox/workspaceinput attachment 只携带轻量 metadata/path/url/content。
- 平台原始消息链不属于 SDK `AgentInput`;需要诊断时放在 Host 内部 envelope 或 `ctx.adapter.extra` 的一次性兼容字段中,不作为长期 runner 合同。 - 平台原始消息链不属于 SDK `AgentInput`;需要诊断时放在 Host 内部 envelope 或 `ctx.adapter.extra` 的一次性兼容字段中,不作为长期 runner 合同。
- `interaction` 只在 `event.event_type == "interaction.submitted"` 时出现,承载经过 Host 校验和归一化的用户提交;平台原始 callback payload 不进入该字段。
### 5.7 DeliveryContext ### 5.7 DeliveryContext
@@ -291,17 +299,80 @@ class DeliveryContext(BaseModel):
supports_edit: bool = False supports_edit: bool = False
supports_reaction: bool = False supports_reaction: bool = False
max_message_size: int | None = None max_message_size: int | None = None
interactions: InteractionDeliveryCapabilities | None = None
platform_capabilities: dict[str, Any] = {} 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()` 投影 平台事件进入独立 Agent 时,Host 会从当前 adapter 的 `get_supported_apis()` 投影
`supports_edit``supports_reaction`,并把去重后的 API 名称写入 `supports_edit``supports_reaction`,并把去重后的 API 名称写入
`platform_capabilities.supported_apis`。这些字段只描述当前投递表面的能力,不授予 `platform_capabilities.supported_apis`。这些字段只描述当前投递表面的能力,不授予
平台动作权限;`action.requested` 仍受 §7.3 的 reserved 约束。合成路由测试使用的 任意平台动作权限;交互请求按 §5.8 和 §7.3 的白名单约束执行。合成路由测试使用的
adapter 会过滤所有已知副作用 API,因此测试事件不会向 runner 宣告真实出站能力。 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 targetRunner 不得通过 `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 ```python
class ContextAccess(BaseModel): class ContextAccess(BaseModel):
@@ -463,13 +534,20 @@ Runner 生成的大文件、工具输出和临时产物不通过 result event
| `tool.call.started` | 工具调用开始的可观测事件。 | telemetry | | `tool.call.started` | 工具调用开始的可观测事件。 | telemetry |
| `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry | | `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry |
| `state.updated` | runner 请求更新 host-owned state。 | ✅ | | `state.updated` | runner 请求更新 host-owned state。 | ✅ |
| `action.requested` | runner 请求 Host 执行平台动作。 | **reserved / 仅 telemetry,不执行** | | `action.requested` | runner 请求 Host 执行受控动作。 | `interaction.requested` 可执行;其它动作仅 telemetry |
| `run.completed` | run 正常结束。 | ✅ | | `run.completed` | run 正常结束。 | ✅ |
| `run.failed` | 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 写的快照。 除 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.delta", "data": { "chunk": { "role": "assistant", "content": "hel" } } }
{ "type": "message.completed", "data": { "message": { "role": "assistant", "content": "hello" } } } { "type": "message.completed", "data": { "message": { "role": "assistant", "content": "hello" } } }
{ "type": "state.updated", "data": { "scope": "conversation", "key": "external.session_id", "value": "abc" } } { "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 ## 8. AgentRunAPIProxy
+5 -3
View File
@@ -21,11 +21,12 @@
| Steering control path | Done | claim 异常不再逃逸 consumer loopqueue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 | | Steering control path | Done | claim 异常不再逃逸 consumer loopqueue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 |
| SDK v1 contract closure | Done | SDK 提供 `AgentAPIError` / `AgentAPIException`、typed `SteeringPullResult`、未知 result type 宽容解析、result `sequence` 注入与取消传播。 | | 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。 | | 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 与实现已知差距 ## Spec 与实现已知差距
- `action.requested` 仍只作为 telemetry / reserved surfaceplatform action executor 不在本分支执行 - `action.requested` 是严格白名单协议面:当前只执行 `interaction.requested`;其它 action 仍只记录 telemetry,不提供通用 platform action executor。
- `action.requested` 权限模型完成前,DeliveryContext 的 adapter capability 投影只用于输出决策,不提供平台动作执行权限 - 结构化交互 SDK typed contract 与 DifyAgent continuation 已实现;SDK 正式发布、真实 Dify 凭据 E2E,以及需要长驻双向进程的 Claude Code 权限确认仍是后续验收项。Host 不持有 provider 私有 token
- State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。 - State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。
- `ToolResource.parameters` 已作为 best-effort full schema 由 Host 在构造 `ctx.resources` 时一次塞齐;无 schema 时 runner 仍需兼容 `parameters=None` 或按需调用 detail 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 产品化前补齐。 - 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.0Host 注册 `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/LocalAgent/default` | Unit-pass; Marketplace UI pass; Debug Chat E2E pass | 2026-07-12 隔离 first-run 实例从真实 AgentRunner catalog 安装 `langbot-team/LocalAgent` 0.1.0Host 注册 `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 runnerremote-ssh Claude ACP 通过反向隧道调用 run-scoped `langbot_get_current_event`,97.8 秒返回可见结果;Host 将增量 delta 和 `message.completed` 聚合为一个完整 Bot 气泡。 | | `plugin:langbot-team/ACPAgentRunner/default` | Unit-pass; Debug Chat E2E pass | 2026-07-15 从本地 0.1.4 发布包安装并注册 PascalCase runnerremote-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 登录态。 | | `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 验收状态 ## Host / SDK 验收状态
-2
View File
@@ -721,7 +721,6 @@ class QQOfficialClient:
""" """
if not await self.check_access_token(): if not await self.check_access_token():
await self.get_access_token() await self.get_access_token()
if target_type == 'c2c': if target_type == 'c2c':
url = f'{self.base_url}/v2/users/{target_id}/messages' url = f'{self.base_url}/v2/users/{target_id}/messages'
elif target_type == 'group': elif target_type == 'group':
@@ -730,7 +729,6 @@ class QQOfficialClient:
url = f'{self.base_url}/channels/{target_id}/messages' url = f'{self.base_url}/channels/{target_id}/messages'
else: else:
raise ValueError(f'Unsupported target_type for markdown+keyboard: {target_type}') raise ValueError(f'Unsupported target_type for markdown+keyboard: {target_type}')
body: dict[str, Any] = { body: dict[str, Any] = {
'msg_type': 2, 'msg_type': 2,
'markdown': {'content': markdown_content}, 'markdown': {'content': markdown_content},
@@ -430,6 +430,8 @@ class WecomBotWsClient:
card_payload: ``{"msgtype": "template_card", "template_card": {...}}`` card_payload: ``{"msgtype": "template_card", "template_card": {...}}``
as produced by :func:`build_button_interaction_payload`. 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) req_id = _generate_req_id(CMD_SEND_MSG)
body = dict(card_payload) body = dict(card_payload)
body['chatid'] = chat_id body['chatid'] = chat_id
@@ -30,16 +30,10 @@ class AgentBindingResolver:
Agent and does not carry enough scope metadata to make that decision Agent and does not carry enough scope metadata to make that decision
safely here. safely here.
""" """
matches = [ matches = [agent for agent in agents if agent.enabled and event.event_type in agent.event_types]
agent
for agent in agents
if agent.enabled and event.event_type in agent.event_types
]
if not matches: if not matches:
raise AgentBindingResolutionError( raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}')
f'No Agent binding matches event_type={event.event_type}'
)
if len(matches) > 1: if len(matches) > 1:
agent_ids = ', '.join(agent.agent_id or '<anonymous>' for agent in matches) agent_ids = ', '.join(agent.agent_id or '<anonymous>' for agent in matches)
@@ -57,7 +51,7 @@ class AgentBindingResolver:
) )
return AgentBinding( 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, scope=scope,
event_types=list(agent.event_types), event_types=list(agent.event_types),
runner_id=agent.runner_id, runner_id=agent.runner_id,
@@ -67,4 +61,6 @@ class AgentBindingResolver:
delivery_policy=agent.delivery_policy, delivery_policy=agent.delivery_policy,
enabled=agent.enabled, enabled=agent.enabled,
agent_id=agent.agent_id, agent_id=agent.agent_id,
processor_type=agent.processor_type,
processor_id=agent.processor_id or agent.agent_id,
) )
@@ -45,6 +45,7 @@ class AgentInput(typing.TypedDict):
text: str | None text: str | None
contents: list[dict[str, typing.Any]] contents: list[dict[str, typing.Any]]
attachments: list[dict[str, typing.Any]] attachments: list[dict[str, typing.Any]]
interaction: typing.NotRequired[dict[str, typing.Any] | None]
class AgentRunState(typing.TypedDict): 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 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) # Build context access (no history inlined by default for Protocol v1)
# Populate with actual values from stores # Populate with actual values from stores
@@ -333,6 +339,11 @@ class AgentRunContextBuilder:
'max_message_size': event.delivery.max_message_size, 'max_message_size': event.delivery.max_message_size,
'platform_capabilities': event.delivery.platform_capabilities, '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) # Build adapter context (empty for event-first)
adapter_context = { adapter_context = {
+5
View File
@@ -1,4 +1,5 @@
"""Canonical AgentRunner event names reserved for future EBA integration.""" """Canonical AgentRunner event names reserved for future EBA integration."""
from __future__ import annotations from __future__ import annotations
@@ -14,6 +15,9 @@ GROUP_MEMBER_JOINED = 'group.member_joined'
FRIEND_REQUEST_RECEIVED = 'friend.request_received' FRIEND_REQUEST_RECEIVED = 'friend.request_received'
"""A new friend/contact request was received.""" """A new friend/contact request was received."""
INTERACTION_SUBMITTED = 'interaction.submitted'
"""A user submitted a Host-rendered structured interaction."""
RESERVED_EVENT_TYPES = frozenset( RESERVED_EVENT_TYPES = frozenset(
{ {
@@ -21,5 +25,6 @@ RESERVED_EVENT_TYPES = frozenset(
MESSAGE_RECALLED, MESSAGE_RECALLED,
GROUP_MEMBER_JOINED, GROUP_MEMBER_JOINED,
FRIEND_REQUEST_RECEIVED, FRIEND_REQUEST_RECEIVED,
INTERACTION_SUBMITTED,
} }
) )
@@ -139,6 +139,9 @@ class DeliveryPolicy(pydantic.BaseModel):
enable_reply: bool = True enable_reply: bool = True
"""Whether reply is enabled.""" """Whether reply is enabled."""
enable_interactions: bool = False
"""Whether the binding permits structured interaction delivery."""
max_message_size: int | None = None max_message_size: int | None = None
"""Maximum message size.""" """Maximum message size."""
@@ -154,6 +157,12 @@ class AgentConfig(pydantic.BaseModel):
agent_id: str | None = None agent_id: str | None = None
"""Host-side Agent/config identifier.""" """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: str
"""Runner ID to invoke.""" """Runner ID to invoke."""
@@ -215,3 +224,9 @@ class AgentBinding(pydantic.BaseModel):
agent_id: str | None = None agent_id: str | None = None
"""Host-side Agent/config identifier for this binding.""" """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."""
@@ -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)]),
)
@@ -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),
}
@@ -22,6 +22,7 @@ from .execution_context import (
) )
from .host_models import AgentBinding, AgentEventEnvelope from .host_models import AgentBinding, AgentEventEnvelope
from .invoker import AgentRunnerInvoker from .invoker import AgentRunnerInvoker
from .interaction_manager import InteractionManager
from .query_bridge import QueryRunBridge from .query_bridge import QueryRunBridge
from .registry import AgentRunnerRegistry from .registry import AgentRunnerRegistry
from .resource_builder import AgentResourceBuilder from .resource_builder import AgentResourceBuilder
@@ -51,6 +52,7 @@ class AgentRunOrchestrator:
binding_resolver: AgentBindingResolver binding_resolver: AgentBindingResolver
query_bridge: QueryRunBridge query_bridge: QueryRunBridge
invoker: AgentRunnerInvoker invoker: AgentRunnerInvoker
interaction_manager: InteractionManager
journal: AgentRunJournal journal: AgentRunJournal
_session_registry: AgentRunSessionRegistry _session_registry: AgentRunSessionRegistry
@@ -67,6 +69,7 @@ class AgentRunOrchestrator:
self.binding_resolver = AgentBindingResolver() self.binding_resolver = AgentBindingResolver()
self.query_bridge = QueryRunBridge(self.binding_resolver) self.query_bridge = QueryRunBridge(self.binding_resolver)
self.invoker = AgentRunnerInvoker(ap) self.invoker = AgentRunnerInvoker(ap)
self.interaction_manager = InteractionManager(ap)
self.journal = AgentRunJournal(ap) self.journal = AgentRunJournal(ap)
self._session_registry = get_session_registry() self._session_registry = get_session_registry()
@@ -130,6 +133,8 @@ class AgentRunOrchestrator:
run_authorization = { run_authorization = {
'runner_id': descriptor.id, 'runner_id': descriptor.id,
'binding_id': binding.binding_id, 'binding_id': binding.binding_id,
'processor_type': binding.processor_type,
'processor_id': binding.processor_id,
'plugin_identity': descriptor.get_plugin_id(), 'plugin_identity': descriptor.get_plugin_id(),
'resources': resources, 'resources': resources,
'available_apis': available_apis, 'available_apis': available_apis,
@@ -252,6 +257,17 @@ class AgentRunOrchestrator:
await self.result_normalizer.normalize(result_dict, descriptor) await self.result_normalizer.normalize(result_dict, descriptor)
continue 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': if result_type == 'run.completed':
terminal_status = 'completed' terminal_status = 'completed'
terminal_reason = ( terminal_reason = (
@@ -7,6 +7,7 @@ Protocol v1 architecture without exposing Query internals to runners.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import math
import typing import typing
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
@@ -129,16 +130,25 @@ class QueryEntryAdapter:
delivery_policy = DeliveryPolicy( delivery_policy = DeliveryPolicy(
enable_streaming=True, enable_streaming=True,
enable_reply=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( return AgentConfig(
agent_id=agent_id, agent_id=agent_id,
processor_type='pipeline',
processor_id=agent_id,
runner_id=runner_id, runner_id=runner_id,
runner_config=runner_config, runner_config=runner_config,
resource_policy=resource_policy, resource_policy=resource_policy,
state_policy=state_policy, state_policy=state_policy,
delivery_policy=delivery_policy, delivery_policy=delivery_policy,
event_types=[runner_events.MESSAGE_RECEIVED], event_types=[event_type],
enabled=True, enabled=True,
metadata={'source': 'pipeline_adapter'}, metadata={'source': 'pipeline_adapter'},
) )
@@ -217,22 +227,37 @@ class QueryEntryAdapter:
if message_id == -1: if message_id == -1:
message_id = None message_id = None
event_time = None event_time = cls._normalize_event_time(getattr(message_event, 'time', None) if message_event else None)
if message_event:
event_time = getattr(message_event, 'time', None) variables = getattr(query, 'variables', None) or {}
if isinstance(event_time, (int, float)): interaction = variables.get('_interaction_submission')
event_time = int(event_time) 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) source_event_id = str(message_id or query.query_id)
return AgentEventContext( return AgentEventContext(
event_id=cls._build_scoped_event_id(query, source_event_id, event_time), 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, event_time=event_time,
source='host_adapter', source='host_adapter',
source_event_type=source_event_type, source_event_type=source_event_type,
data=event_data, 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 @classmethod
def _compact_event_data( def _compact_event_data(
cls, cls,
@@ -370,6 +395,15 @@ class QueryEntryAdapter:
if launcher_type is not None: if launcher_type is not None:
launcher_type_value = getattr(launcher_type, 'value', launcher_type) 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( return SubjectContext(
subject_type='message', subject_type='message',
subject_id=str(message_id or query_id or ''), subject_id=str(message_id or query_id or ''),
@@ -435,11 +469,16 @@ class QueryEntryAdapter:
attachments = cls._build_attachments(query, contents) attachments = cls._build_attachments(query, contents)
return AgentInput( input_data: dict[str, typing.Any] = {
text=text, 'text': text,
contents=contents, 'contents': contents,
attachments=attachments, '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 @classmethod
def _build_attachments( def _build_attachments(
@@ -570,16 +609,42 @@ class QueryEntryAdapter:
) -> DeliveryContext: ) -> DeliveryContext:
"""Build DeliveryContext from Query.""" """Build DeliveryContext from Query."""
message_chain = getattr(query, 'message_chain', None) message_chain = getattr(query, 'message_chain', None)
return DeliveryContext( launcher_type = getattr(query, 'launcher_type', None)
surface='platform', target_type = getattr(launcher_type, 'value', launcher_type)
reply_target={ 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), 'message_id': getattr(message_chain, 'message_id', None),
}, },
supports_streaming=True, 'supports_streaming': True,
supports_edit=False, 'supports_edit': 'edit_message' in supported_apis,
supports_reaction=False, 'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
platform_capabilities={}, '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 @classmethod
def _build_raw_ref( def _build_raw_ref(
@@ -1,4 +1,5 @@
"""Agent result normalizer for converting AgentRunResult to Pipeline messages.""" """Agent result normalizer for converting AgentRunResult to Pipeline messages."""
from __future__ import annotations from __future__ import annotations
import typing import typing
@@ -57,7 +58,7 @@ class AgentResultNormalizer:
- state.updated - state.updated
- run.completed - run.completed
- run.failed - run.failed
- action.requested (log only, don't execute) - action.requested (non-whitelisted actions are telemetry only)
""" """
ap: app.Application ap: app.Application
@@ -91,6 +92,7 @@ class AgentResultNormalizer:
# Validate result size # Validate result size
try: try:
import json import json
result_json = json.dumps(result_dict) result_json = json.dumps(result_dict)
if len(result_json) > MAX_RESULT_SIZE_BYTES: if len(result_json) > MAX_RESULT_SIZE_BYTES:
self.ap.logger.warning( self.ap.logger.warning(
@@ -120,16 +122,12 @@ class AgentResultNormalizer:
elif result_type == 'tool.call.started': elif result_type == 'tool.call.started':
# Log only, don't yield to pipeline # Log only, don't yield to pipeline
self.ap.logger.debug( self.ap.logger.debug(f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}')
f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}'
)
return None return None
elif result_type == 'tool.call.completed': elif result_type == 'tool.call.completed':
# Log only, don't yield to pipeline # Log only, don't yield to pipeline
self.ap.logger.debug( self.ap.logger.debug(f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}')
f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}'
)
return None return None
elif result_type == 'state.updated': elif result_type == 'state.updated':
@@ -161,10 +159,9 @@ class AgentResultNormalizer:
) )
elif result_type == 'action.requested': elif result_type == 'action.requested':
# Reserved for EBA - log only, don't execute # The orchestrator consumes whitelisted actions before normalization.
self.ap.logger.info( self.ap.logger.info(
f'Runner {descriptor.id} requested action (not executed in current phase): ' f'Runner {descriptor.id} requested unsupported action (not executed): {data.get("action", "unknown")}'
f'{data.get("action", "unknown")}'
) )
return None return None
@@ -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'),
)
@@ -17,6 +17,7 @@ from langbot.pkg.entity.persistence.base import Base
# This is required for autogenerate to detect model changes # This is required for autogenerate to detect model changes
from langbot.pkg.entity.persistence import ( from langbot.pkg.entity.persistence import (
agent, # noqa: F401 agent, # noqa: F401
agent_interaction, # noqa: F401
agent_run, # noqa: F401 agent_run, # noqa: F401
agent_runner_state, # noqa: F401 agent_runner_state, # noqa: F401
apikey, # noqa: F401 apikey, # noqa: F401
@@ -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')
@@ -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')
+9
View File
@@ -38,6 +38,7 @@ class PendingMessage:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
pipeline_uuid: typing.Optional[str] pipeline_uuid: typing.Optional[str]
routed_by_rule: bool = False routed_by_rule: bool = False
variables: dict[str, typing.Any] | None = None
timestamp: float = field(default_factory=time.time) timestamp: float = field(default_factory=time.time)
@@ -127,6 +128,7 @@ class MessageAggregator:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None, pipeline_uuid: typing.Optional[str] = None,
routed_by_rule: bool = False, routed_by_rule: bool = False,
variables: dict[str, typing.Any] | None = None,
) -> None: ) -> None:
"""Add a message to the aggregation buffer """Add a message to the aggregation buffer
@@ -135,6 +137,8 @@ class MessageAggregator:
merged with other messages from the same session. merged with other messages from the same session.
""" """
enabled, delay = await self._get_aggregation_config(pipeline_uuid) enabled, delay = await self._get_aggregation_config(pipeline_uuid)
if variables:
enabled = False
if not enabled: if not enabled:
# Aggregation disabled, send directly to query pool # Aggregation disabled, send directly to query pool
@@ -148,6 +152,7 @@ class MessageAggregator:
adapter=adapter, adapter=adapter,
pipeline_uuid=pipeline_uuid, pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule, routed_by_rule=routed_by_rule,
variables=variables,
) )
return return
@@ -163,6 +168,7 @@ class MessageAggregator:
adapter=adapter, adapter=adapter,
pipeline_uuid=pipeline_uuid, pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule, routed_by_rule=routed_by_rule,
variables=variables,
) )
force_flush = False force_flush = False
@@ -222,6 +228,7 @@ class MessageAggregator:
adapter=msg.adapter, adapter=msg.adapter,
pipeline_uuid=msg.pipeline_uuid, pipeline_uuid=msg.pipeline_uuid,
routed_by_rule=msg.routed_by_rule, routed_by_rule=msg.routed_by_rule,
variables=msg.variables,
) )
return return
@@ -237,6 +244,7 @@ class MessageAggregator:
adapter=merged_msg.adapter, adapter=merged_msg.adapter,
pipeline_uuid=merged_msg.pipeline_uuid, pipeline_uuid=merged_msg.pipeline_uuid,
routed_by_rule=merged_msg.routed_by_rule, routed_by_rule=merged_msg.routed_by_rule,
variables=merged_msg.variables,
) )
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage: def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
@@ -276,6 +284,7 @@ class MessageAggregator:
adapter=base_msg.adapter, adapter=base_msg.adapter,
pipeline_uuid=base_msg.pipeline_uuid, pipeline_uuid=base_msg.pipeline_uuid,
routed_by_rule=any(msg.routed_by_rule for msg in messages), routed_by_rule=any(msg.routed_by_rule for msg in messages),
variables=base_msg.variables,
) )
async def flush_all(self) -> None: async def flush_all(self) -> None:
+1 -1
View File
@@ -179,7 +179,7 @@ class RuntimePipeline:
bot_message=query.resp_messages[-1], bot_message=query.resp_messages[-1],
message=result.user_notice, message=result.user_notice,
quote_origin=query.pipeline_config['output']['misc']['quote-origin'], 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: else:
await query.adapter.reply_message( await query.adapter.reply_message(
+1 -1
View File
@@ -42,7 +42,7 @@ class QueryPool:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None, pipeline_uuid: typing.Optional[str] = None,
routed_by_rule: bool = False, routed_by_rule: bool = False,
variables: typing.Optional[dict[str, typing.Any]] = None, variables: dict[str, typing.Any] | None = None,
) -> pipeline_query.Query: ) -> pipeline_query.Query:
async with self.condition: async with self.condition:
query_id = self.query_id_counter query_id = self.query_id_counter
+23 -2
View File
@@ -43,9 +43,12 @@ class SendResponseBackStage(stage.PipelineStage):
response_index = len(query.resp_message_chain) - 1 response_index = len(query.resp_message_chain) - 1
message_chain = query.resp_message_chain[-1] message_chain = query.resp_message_chain[-1]
is_streaming_response = False
is_final = False
try: try:
if await query.adapter.is_stream_output_supported() and has_chunks: is_streaming_response = await query.adapter.is_stream_output_supported() and has_chunks
is_final = [msg.is_final for msg in query.resp_messages][-1] if is_streaming_response:
is_final = query.resp_messages[-1].is_final
await query.adapter.reply_message_chunk( await query.adapter.reply_message_chunk(
message_source=query.message_event, message_source=query.message_event,
bot_message=query.resp_messages[-1], bot_message=query.resp_messages[-1],
@@ -68,6 +71,24 @@ class SendResponseBackStage(stage.PipelineStage):
e, e,
) )
plugin_diagnostics.clear_response_source(query, response_index) 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 raise
plugin_diagnostics.clear_response_source(query, response_index) plugin_diagnostics.clear_response_source(query, response_index)
@@ -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.event_converter import DingTalkEventConverter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter 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.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 entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message 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): async def process(self, message: dingtalk_stream.CallbackMessage):
callback = dingtalk_stream.CardCallbackMessage.from_dict(message.data or {}) 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( event = DingTalkEvent.from_payload(
{ {
'conversation_type': 'CardCallback', 'conversation_type': 'CardCallback',
@@ -35,7 +43,7 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler):
'extension': callback.extension, 'extension': callback.extension,
'corp_id': callback.corp_id, 'corp_id': callback.corp_id,
'user_id': callback.user_id, 'user_id': callback.user_id,
'content': callback.content, 'content': content,
'space_id': callback.space_id, 'space_id': callback.space_id,
'card_instance_id': callback.card_instance_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] = {} _message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, platform_entities.User] = {} _user_cache: dict[str, platform_entities.User] = {}
_group_cache: dict[str, platform_entities.UserGroup] = {} _group_cache: dict[str, platform_entities.UserGroup] = {}
interaction_callback_contexts: dict[str, dict[str, typing.Any]] = {}
class Config: class Config:
arbitrary_types_allowed = True arbitrary_types_allowed = True
@@ -89,6 +98,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
_message_cache={}, _message_cache={},
_user_cache={}, _user_cache={},
_group_cache={}, _group_cache={},
interaction_callback_contexts={},
) )
self._register_native_handlers() self._register_native_handlers()
@@ -100,7 +110,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
] ]
def get_supported_apis(self) -> list[str]: def get_supported_apis(self) -> list[str]:
return [ apis = [
'send_message', 'send_message',
'reply_message', 'reply_message',
'get_message', 'get_message',
@@ -112,6 +122,16 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
'get_file_url', 'get_file_url',
'call_platform_api', '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( async def send_message(
self, self,
@@ -184,6 +204,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
return bool(self.config.get('enable-stream-reply', False)) return bool(self.config.get('enable-stream-reply', False))
async def call_platform_api(self, action: str, params: dict = {}) -> dict: 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) handler = PLATFORM_API_MAP.get(action)
if handler is None: if handler is None:
raise NotSupportedError(f'call_platform_api:{action}') 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): async def _handle_native_event(self, event: DingTalkEvent):
try: 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( await self.logger.debug(
'DingTalk EBA event received: ' 'DingTalk EBA event received: '
f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}' f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}'
@@ -0,0 +1,346 @@
"""DingTalk card rendering and callbacks for structured interactions."""
from __future__ import annotations
import json
import time
import typing
import uuid
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
from langbot_plugin.api.entities.builtin.platform import events as platform_events
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
return {
'field_types': ['text', 'textarea', 'number', 'select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def _select_options(options: list[dict[str, typing.Any]]) -> list[dict[str, typing.Any]]:
locales = ['zh_CN', 'zh_TW', 'en_US', 'ja_JP', 'vi_VN', 'th_TH', 'id_ID', 'ms_MY', 'ko_KR']
result = []
for option in options:
value = str(option.get('value') or '')
label = str(option.get('label') or value)
if value:
result.append({'value': value, 'text': {locale: label for locale in locales}})
return result
def _field_card_params(field: dict[str, typing.Any]) -> dict[str, typing.Any] | None:
field_type = str(field.get('type') or '')
field_id = str(field.get('id') or '')
if field_type not in {'text', 'textarea', 'number', 'select'} or not field_id:
return None
label = str(field.get('label') or field_id)
placeholder = str(field.get('placeholder') or label)
default = field.get('default')
params: dict[str, typing.Any] = {
'input_visible': '',
'input_title': '',
'input_placeholder': '',
'input_value': '',
'select_visible': '',
'select_placeholder': '',
'select_options': [],
'index_o': [],
'select_index': -1,
}
if field_type == 'select':
raw_options = field.get('options') if isinstance(field.get('options'), list) else []
options = [option for option in raw_options if isinstance(option, dict)]
encoded_options = _select_options(options)
if not encoded_options:
return None
selected_index = next(
(index for index, option in enumerate(options) if str(option.get('value')) == str(default)),
-1,
)
params.update(
{
'select_visible': 'true',
'select_placeholder': placeholder,
'select_options': [str(option.get('value') or '') for option in options],
'index_o': encoded_options,
'select_index': selected_index,
}
)
else:
params.update(
{
'input_visible': 'true',
'input_title': label,
'input_placeholder': placeholder,
'input_value': '' if default is None else str(default),
}
)
return params
def _prune_callback_contexts(adapter: typing.Any, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
contexts = adapter.interaction_callback_contexts
for key in [key for key, value in contexts.items() if float(value.get('expires_at') or 0) <= now]:
contexts.pop(key, None)
def _buttons(request: dict[str, typing.Any], callback_token: str) -> list[dict[str, typing.Any]]:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
items: list[tuple[str, str, str]] = []
if actions and not fields:
items = [
(
str(action.get('label') or action.get('id') or index + 1),
f'lbi:{callback_token}:a:{index}',
str(action.get('style') or 'default'),
)
for index, action in enumerate(actions)
if isinstance(action, dict)
]
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
field = fields[0]
if field.get('type') != 'select':
return []
options = field.get('options') if isinstance(field.get('options'), list) else []
items = [
(
str(option.get('label') or option.get('value') or index + 1),
f'lbi:{callback_token}:f:0:{index}',
'default',
)
for index, option in enumerate(options)
if isinstance(option, dict)
]
return [
{
'text': label,
'color': 'blue' if style == 'primary' else 'red' if style == 'danger' else 'gray',
'status': 'normal',
'event': {
'type': 'sendCardRequest',
'params': {'actionId': callback_data, 'params': {'action_id': callback_data}},
},
}
for label, callback_data, style in items
]
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
target_type = str(reply_target.get('target_type') or '')
target_id = str(reply_target.get('target_id') or '')
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
buttons = _buttons(request, callback_token)
field_params = (
_field_card_params(fields[0]) if len(fields) == 1 and not actions and isinstance(fields[0], dict) else None
)
if not buttons and field_params is None:
fallback = '\n\n'.join(
part
for part in (
str(request.get('title') or '').strip(),
str(request.get('description') or '').strip(),
str(request.get('fallback_text') or '').strip(),
)
if part
)
result = await adapter.send_message(target_type, target_id, adapter._plain_message(fallback))
return {'ok': True, 'message_id': result.message_id, 'rich': False}
if target_type not in {'group', 'person'} or not target_id:
raise ValueError('interaction.request has an invalid DingTalk target')
field_label = str(fields[0].get('label') or '').strip() if fields and isinstance(fields[0], dict) else ''
content = '\n\n'.join(
part
for part in (
f'## {str(request.get("title") or "").strip()}',
str(request.get('description') or '').strip(),
field_label,
)
if part
)
out_track_id = uuid.uuid4().hex
card_param_map = {'content': content, 'btns': buttons}
if field_params is not None:
card_param_map.update(field_params)
await adapter.bot.create_and_deliver_card(
card_template_id=adapter.config['human_input_card_template_id'],
out_track_id=out_track_id,
open_space_id=(
f'dtv1.card//IM_GROUP.{target_id}' if target_type == 'group' else f'dtv1.card//IM_ROBOT.{target_id}'
),
is_group=target_type == 'group',
card_param_map=card_param_map,
)
if field_params is not None:
_prune_callback_contexts(adapter)
field = fields[0]
adapter.interaction_callback_contexts[out_track_id] = {
'callback_token': callback_token,
'field_id': str(field.get('id') or ''),
'field_type': str(field.get('type') or ''),
'options': [
str(option.get('value') or '') for option in field.get('options') or [] if isinstance(option, dict)
],
'expires_at': time.monotonic() + 30 * 60,
}
return {'ok': True, 'message_id': out_track_id, 'rich': True}
def parse_callback_data(value: str) -> dict[str, typing.Any]:
parts = value.split(':')
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
if (
len(parts) == 5
and parts[0] == 'lbi'
and parts[1]
and parts[2] == 'f'
and parts[3].isdigit()
and parts[4].isdigit()
):
return {
'callback_token': parts[1],
'field_ref': int(parts[3]),
'option_ref': int(parts[4]),
}
raise ValueError('invalid DingTalk interaction callback data')
def _find_callback_data(value: typing.Any) -> str:
if isinstance(value, str):
return value if value.startswith('lbi:') else ''
if isinstance(value, dict):
for key in ('actionId', 'action_id', 'id'):
found = _find_callback_data(value.get(key))
if found:
return found
for child in value.values():
found = _find_callback_data(child)
if found:
return found
if isinstance(value, list):
for child in value:
found = _find_callback_data(child)
if found:
return found
return ''
def _find_named_value(value: typing.Any, names: set[str]) -> typing.Any:
if isinstance(value, dict):
for key, child in value.items():
if key in names and child not in (None, ''):
return child
for child in value.values():
found = _find_named_value(child, names)
if found not in (None, ''):
return found
elif isinstance(value, list):
for child in value:
found = _find_named_value(child, names)
if found not in (None, ''):
return found
return None
def _callback_track_id(callback: dict[str, typing.Any]) -> str:
value = _find_named_value(callback, {'outTrackId', 'out_track_id', 'outtrackid'})
return str(value or '')
def _mapping(value: typing.Any) -> dict[str, typing.Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _native_field_value(callback: dict[str, typing.Any], context: dict[str, typing.Any]) -> typing.Any:
field_type = str(context.get('field_type') or '')
names = (
{'select', 'selectResult', 'select_result', '__built_in_selectResult__'}
if field_type == 'select'
else {'input', 'inputResult', 'input_result', '__built_in_inputResult__'}
)
value = _find_named_value(callback, names)
parsed = _mapping(value)
if parsed:
value = parsed.get('value', parsed.get('input', parsed.get('index')))
if isinstance(value, dict):
value = value.get('value') or value.get('input') or value.get('index')
if field_type == 'select' and isinstance(value, int) and not isinstance(value, bool):
options = context.get('options') if isinstance(context.get('options'), list) else []
if 0 <= value < len(options):
value = options[value]
if field_type == 'number' and value not in (None, ''):
text = str(value).strip()
try:
return int(text)
except ValueError:
try:
return float(text)
except ValueError:
return value
return value
def interaction_event_from_native(
event: DingTalkEvent,
callback_contexts: dict[str, dict[str, typing.Any]] | None = None,
) -> platform_events.PlatformSpecificEvent | None:
callback = event.get('CardCallback') or {}
callback_data = _find_callback_data(callback)
if callback_data:
parsed = parse_callback_data(callback_data)
else:
contexts = callback_contexts if callback_contexts is not None else {}
track_id = _callback_track_id(callback)
context = contexts.get(track_id)
if context is None:
return None
value = _native_field_value(callback, context)
if value in (None, ''):
return None
parsed = {
'callback_token': str(context.get('callback_token') or ''),
'values': {str(context.get('field_id') or ''): value},
}
contexts.pop(track_id, None)
space_id = str(callback.get('space_id') or '')
if 'IM_GROUP.' in space_id:
target_type = 'group'
target_id = space_id.split('IM_GROUP.', 1)[1]
elif 'IM_ROBOT.' in space_id:
target_type = 'person'
target_id = space_id.split('IM_ROBOT.', 1)[1]
else:
raise ValueError('DingTalk interaction callback has no delivery space')
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='dingtalk-eba',
action='interaction.submitted',
data={
**parsed,
'actor_id': str(callback.get('user_id') or ''),
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
},
timestamp=time.time(),
source_platform_object=event,
)
@@ -90,6 +90,19 @@ spec:
required: true required: true
default: "填写你的卡片template_id" 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: supported_events:
- message.received - message.received
- feedback.received - feedback.received
@@ -108,6 +121,7 @@ spec:
- get_friend_list - get_friend_list
- get_file_url - get_file_url
- call_platform_api - call_platform_api
- interaction.request
platform_specific_apis: platform_specific_apis:
- action: check_access_token - action: check_access_token
@@ -13,6 +13,12 @@ from langbot.pkg.platform.adapters.discord.api_impl import DiscordAPIMixin
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter 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.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 events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message 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: except Exception:
await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}') 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): async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message):
await adapter_self._dispatch_gateway_tuple( await adapter_self._dispatch_gateway_tuple(
'message_edit', (before, after), self.user.id if self.user else None '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', 'kick_member',
'leave_group', 'leave_group',
'call_platform_api', '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): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content, files = await self.message_converter.yiri2target(message) content, files = await self.message_converter.yiri2target(message)
channel = await self._get_channel(target_id) channel = await self._get_channel(target_id)
@@ -238,6 +267,8 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
self.listeners.pop(event_type, None) self.listeners.pop(event_type, None)
async def call_platform_api(self, action: str, params: dict = {}) -> dict: 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) handler = PLATFORM_API_MAP.get(action)
if handler is None: if handler is None:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
@@ -0,0 +1,132 @@
"""Discord component rendering and callbacks for structured interactions."""
from __future__ import annotations
import time
import typing
import discord
from langbot_plugin.api.entities.builtin.platform import events as platform_events
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
return {
'field_types': ['select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def parse_interaction_custom_id(custom_id: str | None) -> dict[str, typing.Any] | None:
if not custom_id or not custom_id.startswith('lbi:'):
return None
parts = custom_id.split(':')
if len(parts) == 4 and parts[1] and parts[2] == 'a' and parts[3].isdigit():
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
if len(parts) == 5 and parts[1] and parts[2] == 'f' and parts[3].isdigit() and parts[4].isdigit():
return {
'callback_token': parts[1],
'field_ref': int(parts[3]),
'option_ref': int(parts[4]),
}
raise ValueError('invalid Discord interaction custom_id')
def _style(value: str) -> discord.ButtonStyle:
if value == 'primary':
return discord.ButtonStyle.primary
if value == 'danger':
return discord.ButtonStyle.danger
return discord.ButtonStyle.secondary
def build_interaction_view(request: dict[str, typing.Any], callback_token: str) -> discord.ui.View | None:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
view = discord.ui.View(timeout=None)
if actions and not fields:
for index, action in enumerate(actions):
if not isinstance(action, dict):
continue
view.add_item(
discord.ui.Button(
label=str(action.get('label') or action.get('id') or index + 1)[:80],
style=_style(str(action.get('style') or 'default')),
custom_id=f'lbi:{callback_token}:a:{index}',
)
)
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
field = fields[0]
if field.get('type') != 'select':
return None
options = field.get('options') if isinstance(field.get('options'), list) else []
for option_index, option in enumerate(options):
if not isinstance(option, dict):
continue
view.add_item(
discord.ui.Button(
label=str(option.get('label') or option.get('value') or option_index + 1)[:80],
style=discord.ButtonStyle.secondary,
custom_id=f'lbi:{callback_token}:f:0:{option_index}',
)
)
else:
return None
return view if view.children else None
def _content(request: dict[str, typing.Any], *, rich: bool) -> str:
parts = [str(request.get('title') or '').strip()]
description = str(request.get('description') or '').strip()
if description:
parts.append(description)
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
if rich and len(fields) == 1 and isinstance(fields[0], dict):
label = str(fields[0].get('label') or '').strip()
if label:
parts.append(label)
if not rich:
fallback = str(request.get('fallback_text') or '').strip()
if fallback:
parts.append(fallback)
return '\n\n'.join(part for part in parts if part)[:2000]
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
target_id = str(reply_target.get('target_id') or '')
if not target_id:
raise ValueError('interaction.request has no target_id')
channel = await adapter._get_channel(target_id)
view = build_interaction_view(request, callback_token)
sent = await channel.send(content=_content(request, rich=view is not None), view=view)
return {'ok': True, 'message_id': sent.id, 'rich': view is not None}
def interaction_event_from_component(
interaction: discord.Interaction,
parsed: dict[str, typing.Any],
) -> platform_events.PlatformSpecificEvent:
if interaction.user is None or interaction.channel is None:
raise ValueError('Discord interaction has no actor or channel')
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='discord',
action='interaction.submitted',
data={
**parsed,
'actor_id': str(interaction.user.id),
'target_type': 'group' if interaction.guild is not None else 'person',
'target_id': str(interaction.channel.id),
'display_text': 'submitted',
},
timestamp=time.time(),
source_platform_object=interaction,
)
@@ -60,6 +60,7 @@ spec:
- kick_member - kick_member
- leave_group - leave_group
- call_platform_api - call_platform_api
- interaction.request
platform_specific_apis: platform_specific_apis:
- action: get_channel - action: get_channel
+158 -22
View File
@@ -23,12 +23,16 @@ from lark_oapi.api.auth.v3 import (
ResendAppTicketResponse, ResendAppTicketResponse,
) )
from lark_oapi.api.cardkit.v1 import ( from lark_oapi.api.cardkit.v1 import (
Card,
ContentCardElementRequest, ContentCardElementRequest,
ContentCardElementRequestBody, ContentCardElementRequestBody,
ContentCardElementResponse, ContentCardElementResponse,
CreateCardRequest, CreateCardRequest,
CreateCardRequestBody, CreateCardRequestBody,
CreateCardResponse, CreateCardResponse,
UpdateCardRequest,
UpdateCardRequestBody,
UpdateCardResponse,
) )
from lark_oapi.api.im.v1 import ( from lark_oapi.api.im.v1 import (
CreateMessageRequest, 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.event_converter import LarkEventConverter
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter 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.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 entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message 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], typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = pydantic.Field(default_factory=dict) ] = pydantic.Field(default_factory=dict)
card_id_dict: dict[str, str] = 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) 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) 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) _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, cipher=cipher,
listeners={}, listeners={},
card_id_dict={}, card_id_dict={},
card_sequence_dict={},
card_last_update_dict={},
closed_streaming_cards=set(),
pending_monitoring_msg={}, pending_monitoring_msg={},
reply_to_monitoring_msg={}, reply_to_monitoring_msg={},
event_loop=None, event_loop=None,
@@ -177,8 +194,17 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
'get_user_info', 'get_user_info',
'get_file_url', 'get_file_url',
'call_platform_api', '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: 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']) builder = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret'])
if config.get('app_type', 'self') == 'isv': 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: async def create_card_id(self, message_id) -> str:
card_data = { card_data = {
'schema': '2.0', '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': { 'body': {
'direction': 'vertical', 'direction': 'vertical',
'elements': [{'tag': 'markdown', 'content': '', 'element_id': 'streaming_txt'}], '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) response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request)
if not response.success(): if not response.success():
raise RuntimeError(f'Lark create_card failed: {response.code} {response.msg}') raise RuntimeError(f'Lark create_card failed: {response.code} {response.msg}')
self.card_id_dict[str(message_id)] = response.data.card_id card_id = str(response.data.card_id)
return 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( async def reply_message_chunk(
self, self,
@@ -403,31 +478,53 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
quote_origin: bool = False, quote_origin: bool = False,
is_final: 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 return
text_elements, _ = await self.message_converter.yiri2target(message, self.api_client) cumulative_content = getattr(bot_message, 'all_content', None)
content = '\n\n'.join( if isinstance(cumulative_content, str) and cumulative_content:
''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'}) content = cumulative_content
for paragraph in text_elements else:
) text_elements, _ = await self.message_converter.yiri2target(message, self.api_client)
request = ( content = '\n\n'.join(
ContentCardElementRequest.builder() ''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'})
.card_id(self.card_id_dict[bot_message.resp_message_id]) for paragraph in text_elements
.element_id('streaming_txt')
.request_body(
ContentCardElementRequestBody.builder().content(content).sequence(bot_message.msg_sequence).build()
) )
.build() if card_id in self.closed_streaming_cards:
) await self._replace_streaming_card(card_id, content)
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content( else:
request, self.request_option(self._tenant_key_from_source(message_source)) sequence = self._next_card_sequence(card_id)
) request = (
if not response.success(): ContentCardElementRequest.builder()
raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}') .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: if is_final and bot_message.tool_calls is None:
self.card_id_dict.pop(bot_message.resp_message_id, 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: 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) handler = PLATFORM_API_MAP.get(action)
if handler is None: if handler is None:
raise NotSupportedError(f'call_platform_api:{action}') 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)) await self._dispatch_eba_event(LarkEventConverter.bot_invited_to_group(data, chat_id))
return {'code': 200, 'message': 'ok'} return {'code': 200, 'message': 'ok'}
if event_type == 'card.action.trigger': 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) feedback_event = self._feedback_event_from_webhook(data)
if feedback_event and platform_events.FeedbackEvent in self.listeners: if feedback_event and platform_events.FeedbackEvent in self.listeners:
await self.listeners[platform_events.FeedbackEvent](feedback_event, self) 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 self._group_cache[str(event.group.id)] = event.group
def _handle_card_action_sync(self, event): 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) feedback_event = self._feedback_event_from_callback(event)
if feedback_event and platform_events.FeedbackEvent in self.listeners: if feedback_event and platform_events.FeedbackEvent in self.listeners:
self._submit_coro(self.listeners[platform_events.FeedbackEvent](feedback_event, self)) 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': '感谢您的反馈'}}) 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): def _submit_coro(self, coro):
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -681,4 +808,13 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
message_id = getattr(message, 'message_id', None) message_id = getattr(message, 'message_id', None)
if message_id: if message_id:
return str(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') raise RuntimeError('Lark message source does not contain message_id')
@@ -0,0 +1,568 @@
"""Lark card rendering and callback conversion for structured interactions."""
from __future__ import annotations
import json
import time
import typing
import uuid
from lark_oapi.api.cardkit.v1 import (
Card,
CreateCardRequest,
CreateCardRequestBody,
CreateCardResponse,
UpdateCardRequest,
UpdateCardRequestBody,
UpdateCardResponse,
)
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
CreateMessageResponse,
)
from langbot_plugin.api.entities.builtin.platform import events as platform_events
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
return {
'field_types': ['text', 'textarea', 'number', 'select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def _callback_value(
callback_token: str,
reply_target: dict[str, typing.Any],
**refs: typing.Any,
) -> dict[str, typing.Any]:
return {
'lbi': callback_token,
't': str(reply_target.get('target_type') or ''),
'ck': 1,
**refs,
}
def _field_form_elements(
field: dict[str, typing.Any],
callback_token: str,
reply_target: dict[str, typing.Any],
) -> list[dict[str, typing.Any]] | None:
field_type = str(field.get('type') or '')
if field_type not in {'text', 'textarea', 'number', 'select'}:
return None
field_id = str(field.get('id') or '')
if not field_id:
return None
component_name = 'lbi_field_0'
label = str(field.get('label') or field_id)
placeholder = str(
field.get('placeholder')
or ('Select an option / 请选择' if field_type == 'select' else 'Enter a value / 请输入')
)
required = bool(field.get('required'))
if field_type == 'select':
raw_options = field.get('options') if isinstance(field.get('options'), list) else []
options = [
{
'text': {
'tag': 'plain_text',
'content': str(option.get('label') or option.get('value') or ''),
},
'value': str(option.get('value') or ''),
}
for option in raw_options
if isinstance(option, dict) and option.get('value') not in (None, '')
]
if not options:
return None
field_element: dict[str, typing.Any] = {
'tag': 'select_static',
'name': component_name,
'label': {'tag': 'plain_text', 'content': label},
'placeholder': {'tag': 'plain_text', 'content': placeholder},
'options': options,
'width': 'fill',
'required': required,
}
default = field.get('default')
if default not in (None, ''):
field_element['initial_option'] = str(default)
else:
default = field.get('default')
field_element = {
'tag': 'input',
'name': component_name,
'label': {'tag': 'plain_text', 'content': label},
'placeholder': {'tag': 'plain_text', 'content': placeholder},
'default_value': '' if default is None else str(default),
'width': 'fill',
'required': required,
}
if field_type == 'textarea':
field_element.update(
{
'input_type': 'multiline_text',
'rows': 3,
'auto_resize': True,
'max_rows': 6,
}
)
submit_value = _callback_value(
callback_token,
reply_target,
fm={component_name: field_id},
ft={field_id: field_type},
)
submit_button = {
'tag': 'button',
'name': 'lbi_submit',
'text': {'tag': 'plain_text', 'content': 'Submit / 提交'},
'type': 'primary',
'width': 'fill',
'form_action_type': 'submit',
'behaviors': [{'type': 'callback', 'value': submit_value}],
}
form_elements: list[dict[str, typing.Any]] = [field_element, submit_button]
if field_type == 'select':
form_elements.insert(
0,
{
'tag': 'markdown',
'content': f'**{label}{"*" if required else ""}**',
},
)
return [
{
'tag': 'form',
'name': 'lbi_form',
'direction': 'vertical',
'vertical_spacing': '12px',
'elements': form_elements,
}
]
def _render_submitted_value(value: typing.Any) -> str:
rendered = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value)
rendered = rendered.strip().replace('\n', '\n ')
return rendered if len(rendered) <= 2000 else rendered[:1997] + '...'
def _submission_display_values(
request: dict[str, typing.Any],
submission: dict[str, typing.Any],
) -> list[dict[str, str]]:
display_values: list[dict[str, str]] = []
values = submission.get('values') if isinstance(submission.get('values'), dict) else {}
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
for field in fields:
if not isinstance(field, dict):
continue
field_id = str(field.get('id') or '')
if field_id and field_id in values:
display_value = {
'label': str(field.get('label') or field_id),
'value': _render_submitted_value(values[field_id]),
}
description = str(request.get('description') or '').strip()
if description:
display_value['description'] = description
display_values.append(display_value)
action_id = submission.get('action_id')
if action_id:
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
action_label = next(
(
str(action.get('label') or action_id)
for action in actions
if isinstance(action, dict) and str(action.get('id') or '') == str(action_id)
),
str(action_id),
)
display_values.append({'label': 'Action', 'value': action_label})
return display_values
def _stored_submitted_values(value: typing.Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
stored_values = [
{
'label': str(item['label'])[:200],
'value': str(item['value'])[:2000],
**({'description': str(item['description'])[:4000]} if item.get('description') is not None else {}),
}
for item in value[:50]
if isinstance(item, dict) and item.get('label') is not None and item.get('value') is not None
]
return stored_values
def _submitted_value_elements(values: list[dict[str, str]]) -> list[dict[str, typing.Any]]:
elements: list[dict[str, typing.Any]] = []
for item in values:
lines = []
description = str(item.get('description') or '').strip()
if description:
lines.append(description)
lines.append(f'{item["label"]}{item["value"]}')
elements.append({'tag': 'markdown', 'content': '\n'.join(lines)})
return elements
def build_interaction_card(
request: dict[str, typing.Any],
callback_token: str,
reply_target: dict[str, typing.Any],
submitted_values: list[dict[str, str]] | None = None,
) -> dict[str, typing.Any] | None:
"""Build the Lark subset that maps to a single atomic submission."""
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
buttons: list[dict[str, typing.Any]] = []
field_elements: list[dict[str, typing.Any]] | None = None
if actions and not fields:
for index, action in enumerate(actions):
if not isinstance(action, dict):
continue
style = str(action.get('style') or 'default')
buttons.append(
{
'tag': 'button',
'text': {
'tag': 'plain_text',
'content': str(action.get('label') or action.get('id') or index + 1),
},
'type': 'primary' if style == 'primary' else 'danger' if style == 'danger' else 'default',
'behaviors': [
{
'type': 'callback',
'value': _callback_value(callback_token, reply_target, a=index),
}
],
}
)
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
field = fields[0]
field_elements = _field_form_elements(field, callback_token, reply_target)
if field_elements is None:
return None
else:
return None
if not buttons and not field_elements:
return None
elements: list[dict[str, typing.Any]] = []
elements.extend(_submitted_value_elements(submitted_values or []))
description = str(request.get('description') or '').strip()
if description:
elements.append({'tag': 'markdown', 'content': description})
if field_elements:
elements.extend(field_elements)
else:
elements.append(
{
'tag': 'column_set',
'horizontal_spacing': '8px',
'columns': [
{
'tag': 'column',
'width': 'weighted',
'weight': 1,
'elements': [button],
}
for button in buttons
],
}
)
return {
'schema': '2.0',
'config': {'update_multi': True},
'header': {
'title': {'tag': 'plain_text', 'content': str(request.get('title') or '')},
},
'body': {'direction': 'vertical', 'elements': elements},
}
def build_submitted_card(
request: dict[str, typing.Any],
submission: dict[str, typing.Any],
submitted_values: list[dict[str, str]] | None = None,
) -> dict[str, typing.Any]:
"""Render a read-only snapshot after a user submits an interaction."""
elements: list[dict[str, typing.Any]] = []
all_submitted_values = [
*(submitted_values or []),
*_submission_display_values(request, submission),
]
elements.extend(_submitted_value_elements(all_submitted_values))
return {
'schema': '2.0',
'config': {'update_multi': True},
'header': {'title': {'tag': 'plain_text', 'content': str(request.get('title') or '')}},
'body': {'direction': 'vertical', 'elements': elements},
}
async def _update_interaction_card(
adapter: typing.Any,
update_target: dict[str, typing.Any],
card: dict[str, typing.Any],
submitted_values: list[dict[str, str]] | None = None,
) -> dict[str, typing.Any]:
message_id = str(update_target.get('message_id') or '')
card_id = str(update_target.get('card_id') or '')
if not message_id or not card_id or update_target.get('rich') is not True:
raise ValueError('Lark interaction update requires a CardKit target')
persisted_sequence = int(update_target.get('sequence') or 0)
current_sequence = int(adapter.card_sequence_dict.get(card_id, persisted_sequence))
sequence = max(persisted_sequence, current_sequence) + 1
request = (
UpdateCardRequest.builder()
.card_id(card_id)
.request_body(
UpdateCardRequestBody.builder()
.sequence(sequence)
.uuid(str(uuid.uuid4()))
.card(Card.builder().type('card_json').data(json.dumps(card, ensure_ascii=False)).build())
.build()
)
.build()
)
response: UpdateCardResponse = await adapter.api_client.cardkit.v1.card.aupdate(request)
if not response.success():
raise RuntimeError(f'Lark CardKit interaction update failed: {response.code} {response.msg}')
adapter.card_sequence_dict[card_id] = sequence
result = {
'ok': True,
'message_id': message_id,
'card_id': card_id,
'sequence': sequence,
'rich': True,
'updated': True,
}
if submitted_values:
result['submitted_values'] = submitted_values
return result
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
update_target = params.get('update_target')
submitted_values = _stored_submitted_values(
update_target.get('submitted_values') if isinstance(update_target, dict) else None
)
card = build_interaction_card(request, callback_token, reply_target, submitted_values)
if card is None:
fallback = str(request.get('fallback_text') or '')
result = await adapter.send_message(
str(reply_target.get('target_type') or ''),
str(reply_target.get('target_id') or ''),
adapter._plain_message(fallback),
)
return {'ok': True, 'message_id': result.message_id, 'rich': False}
if isinstance(update_target, dict):
return await _update_interaction_card(adapter, update_target, card, submitted_values)
target_type = str(reply_target.get('target_type') or '')
target_id = str(reply_target.get('target_id') or '')
if target_type not in {'group', 'person'} or not target_id:
raise ValueError('interaction.request has an invalid reply target')
card_request = (
CreateCardRequest.builder()
.request_body(
CreateCardRequestBody.builder().type('card_json').data(json.dumps(card, ensure_ascii=False)).build()
)
.build()
)
card_response: CreateCardResponse = await adapter.api_client.cardkit.v1.card.acreate(card_request)
if not card_response.success():
raise RuntimeError(f'Lark CardKit interaction create failed: {card_response.code} {card_response.msg}')
card_id = str(getattr(card_response.data, 'card_id', '') or '')
if not card_id:
raise RuntimeError('Lark CardKit interaction create returned no card_id')
message_request = (
CreateMessageRequest.builder()
.receive_id_type('chat_id' if target_type == 'group' else 'open_id')
.request_body(
CreateMessageRequestBody.builder()
.receive_id(target_id)
.content(json.dumps({'type': 'card', 'data': {'card_id': card_id}}, ensure_ascii=False))
.msg_type('interactive')
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
response: CreateMessageResponse = await adapter.api_client.im.v1.message.acreate(message_request)
if not response.success():
raise RuntimeError(f'Lark interaction send failed: {response.code} {response.msg}')
adapter.card_sequence_dict[card_id] = 0
return {
'ok': True,
'message_id': getattr(response.data, 'message_id', ''),
'card_id': card_id,
'sequence': 0,
'rich': True,
}
async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
submission = params.get('submission')
update_target = params.get('update_target')
if not isinstance(request, dict) or not isinstance(submission, dict) or not isinstance(update_target, dict):
raise ValueError('interaction.acknowledge requires request, submission, and update_target')
submitted_values = [
*_stored_submitted_values(update_target.get('submitted_values')),
*_submission_display_values(request, submission),
]
return await _update_interaction_card(
adapter,
update_target,
build_submitted_card(request, submission, _stored_submitted_values(update_target.get('submitted_values'))),
submitted_values,
)
def _mapping(value: typing.Any) -> dict[str, typing.Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _action_attr(action: typing.Any, name: str) -> typing.Any:
return action.get(name) if isinstance(action, dict) else getattr(action, name, None)
def _coerce_field_value(value: typing.Any, field_type: str) -> typing.Any:
if isinstance(value, dict):
value = value.get('value') if value.get('value') is not None else value.get('option')
if field_type != 'number' or isinstance(value, (int, float)) and not isinstance(value, bool):
return value
text = str(value).strip()
try:
return int(text)
except ValueError:
try:
return float(text)
except ValueError:
return value
def _form_submission_values(action: typing.Any, payload: dict[str, typing.Any]) -> dict[str, typing.Any]:
field_map = payload.get('fm') if isinstance(payload.get('fm'), dict) else {}
field_types = payload.get('ft') if isinstance(payload.get('ft'), dict) else {}
if not field_map:
return {}
form_value = _mapping(_action_attr(action, 'form_value'))
if not form_value:
for key in ('form_value', 'formValue', 'form_values', 'formValues'):
form_value = _mapping(payload.get(key))
if form_value:
break
if not form_value:
action_name = _action_attr(action, 'name')
input_value = _action_attr(action, 'input_value')
option_value = _action_attr(action, 'option')
if action_name and input_value not in (None, ''):
form_value = {str(action_name): input_value}
elif action_name and option_value not in (None, ''):
form_value = {str(action_name): option_value}
values: dict[str, typing.Any] = {}
for component_name, value in form_value.items():
field_id = field_map.get(component_name)
if field_id and value not in (None, ''):
values[str(field_id)] = _coerce_field_value(value, str(field_types.get(str(field_id)) or ''))
return values
def _event_from_parts(
*,
raw: typing.Any,
action: typing.Any,
actor_id: typing.Any,
chat_id: typing.Any,
message_id: typing.Any,
) -> platform_events.PlatformSpecificEvent | None:
payload = _mapping(_action_attr(action, 'value'))
callback_token = str(payload.get('lbi') or '')
if not callback_token:
return None
target_type = str(payload.get('t') or '')
target_id = str(chat_id or '') if target_type == 'group' else str(actor_id or '')
data: dict[str, typing.Any] = {
'callback_token': callback_token,
'actor_id': str(actor_id or ''),
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
}
if payload.get('ck') == 1:
data['cardkit'] = True
if message_id:
data['message_id'] = str(message_id)
if payload.get('a') is not None:
data['action_ref'] = payload['a']
if payload.get('f') is not None or payload.get('o') is not None:
data['field_ref'] = payload.get('f')
data['option_ref'] = payload.get('o')
if payload.get('fm'):
data['values'] = _form_submission_values(action, payload)
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='lark-eba',
action='interaction.submitted',
data=data,
timestamp=time.time(),
source_platform_object=raw,
)
def interaction_event_from_callback(event: typing.Any) -> platform_events.PlatformSpecificEvent | None:
action = getattr(getattr(event, 'event', None), 'action', None)
operator = getattr(getattr(event, 'event', None), 'operator', None)
context = getattr(getattr(event, 'event', None), 'context', None)
return _event_from_parts(
raw=event,
action=action,
actor_id=getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None),
chat_id=getattr(context, 'open_chat_id', None),
message_id=getattr(context, 'open_message_id', None),
)
def interaction_event_from_webhook(data: dict[str, typing.Any]) -> platform_events.PlatformSpecificEvent | None:
event = data.get('event') if isinstance(data.get('event'), dict) else {}
action = event.get('action') if isinstance(event.get('action'), dict) else {}
operator = event.get('operator') if isinstance(event.get('operator'), dict) else {}
context = event.get('context') if isinstance(event.get('context'), dict) else {}
return _event_from_parts(
raw=data,
action=action,
actor_id=operator.get('open_id') or operator.get('user_id'),
chat_id=context.get('open_chat_id'),
message_id=context.get('open_message_id'),
)
@@ -164,6 +164,8 @@ spec:
- get_user_info - get_user_info
- get_file_url - get_file_url
- call_platform_api - call_platform_api
- interaction.request
- interaction.acknowledge
platform_specific_apis: platform_specific_apis:
- action: check_tenant_access_token - action: check_tenant_access_token
@@ -23,6 +23,11 @@ from langbot.pkg.platform.adapters.qqofficial.event_converter import (
) )
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP 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.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger 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 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_list',
'get_group_member_info', 'get_group_member_info',
'call_platform_api', '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( async def send_message(
self, self,
target_type: str, 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}) 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: 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) handler = PLATFORM_API_MAP.get(action)
if handler is None: if handler is None:
raise NotSupportedError(f'call_platform_api:{action}') 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_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): async def _handle_native_event(self, event: QQOfficialEvent):
self.bot_account_id = self.config.get('appid', self.bot_account_id) self.bot_account_id = self.config.get('appid', self.bot_account_id)
try: try:
@@ -0,0 +1,177 @@
"""QQ Official keyboard rendering and interaction callback conversion."""
from __future__ import annotations
import time
import typing
from langbot_plugin.api.entities.builtin.platform import events as platform_events
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
return {
'field_types': ['select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def _buttons(request: dict[str, typing.Any], callback_token: str) -> list[tuple[str, str, int]]:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
if actions and not fields:
return [
(
str(action.get('label') or action.get('id') or index + 1),
f'lbi:{callback_token}:a:{index}',
1 if action.get('style') == 'primary' else 0,
)
for index, action in enumerate(actions[:25])
if isinstance(action, dict)
]
if len(fields) == 1 and not actions and isinstance(fields[0], dict):
field = fields[0]
if field.get('type') != 'select':
return []
options = field.get('options') if isinstance(field.get('options'), list) else []
return [
(
str(option.get('label') or option.get('value') or index + 1),
f'lbi:{callback_token}:f:0:{index}',
0,
)
for index, option in enumerate(options[:25])
if isinstance(option, dict)
]
return []
def build_interaction_keyboard(request: dict[str, typing.Any], callback_token: str) -> dict[str, typing.Any] | None:
buttons = _buttons(request, callback_token)
if not buttons:
return None
rows = []
for start in range(0, len(buttons), 2):
rows.append(
{
'buttons': [
{
'id': str(start + offset + 1),
'render_data': {
'label': label,
'visited_label': f'{label}',
'style': style,
},
'action': {
'type': 1,
'permission': {'type': 2},
'data': callback_data,
'unsupport_tips': 'Please update QQ to use this action.',
},
}
for offset, (label, callback_data, style) in enumerate(buttons[start : start + 2])
]
}
)
return {'content': {'rows': rows}}
def _text(request: dict[str, typing.Any], rich: bool) -> str:
parts = [str(request.get('title') or '').strip()]
description = str(request.get('description') or '').strip()
if description:
parts.append(description)
if not rich:
fallback = str(request.get('fallback_text') or '').strip()
if fallback:
parts.append(fallback)
return '\n\n'.join(part for part in parts if part)
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
target_type = str(reply_target.get('target_type') or '')
target_id = str(reply_target.get('target_id') or '')
keyboard = build_interaction_keyboard(request, callback_token)
if keyboard is None or target_type not in {'person', 'group'}:
result = await adapter.send_message(
target_type,
target_id,
adapter._plain_message(_text(request, False)),
)
return {'ok': True, 'message_id': result.message_id, 'rich': False}
raw = await adapter.bot.send_markdown_keyboard(
target_type='c2c' if target_type == 'person' else 'group',
target_id=target_id,
markdown_content=_text(request, True),
keyboard=keyboard,
msg_id=reply_target.get('message_id'),
)
return {'ok': True, 'message_id': raw.get('id') if isinstance(raw, dict) else None, 'rich': True}
def parse_callback_data(value: str) -> dict[str, typing.Any]:
parts = value.split(':')
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
if (
len(parts) == 5
and parts[0] == 'lbi'
and parts[1]
and parts[2] == 'f'
and parts[3].isdigit()
and parts[4].isdigit()
):
return {
'callback_token': parts[1],
'field_ref': int(parts[3]),
'option_ref': int(parts[4]),
}
raise ValueError('invalid QQ interaction callback data')
def interaction_event_from_payload(
event_data: dict[str, typing.Any],
) -> platform_events.PlatformSpecificEvent | None:
resolved = (event_data.get('data') or {}).get('resolved') or {}
callback_data = str(resolved.get('button_data') or '')
if not callback_data.startswith('lbi:'):
return None
parsed = parse_callback_data(callback_data)
chat_type = event_data.get('chat_type')
if chat_type == 2 or event_data.get('user_openid'):
target_type = 'person'
target_id = str(event_data.get('user_openid') or '')
elif chat_type == 1 or event_data.get('group_openid'):
target_type = 'group'
target_id = str(event_data.get('group_openid') or '')
elif chat_type == 0 or event_data.get('channel_id'):
target_type = 'group'
target_id = str(event_data.get('channel_id') or '')
else:
raise ValueError('QQ interaction callback has no target')
actor_id = str(
event_data.get('member_openid')
or event_data.get('user_openid')
or ((event_data.get('member') or {}).get('user') or {}).get('id')
or ''
)
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='qqofficial-eba',
action='interaction.submitted',
data={
**parsed,
'actor_id': actor_id,
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
},
timestamp=time.time(),
source_platform_object=event_data,
)
@@ -107,6 +107,7 @@ spec:
- get_group_member_list - get_group_member_list
- get_group_member_info - get_group_member_info
- call_platform_api - call_platform_api
- interaction.request
platform_specific_apis: platform_specific_apis:
- action: check_access_token - action: check_access_token
@@ -34,6 +34,12 @@ from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMes
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter, LegacyEventConverter from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter, LegacyEventConverter
from langbot.pkg.platform.adapters.telegram.api_impl import TelegramAPIMixin 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.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): class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
@@ -79,6 +85,23 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
return return
try: 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) # Legacy event type callbacks (compat with existing botmgr FriendMessage / GroupMessage listeners)
if update.message and ( if update.message and (
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners 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', 'kick_member',
'leave_group', 'leave_group',
'call_platform_api', 'call_platform_api',
'interaction.request',
] ]
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
return interaction_delivery_capabilities()
# ---- Message Send / Reply (preserving original logic) ---- # ---- Message Send / Reply (preserving original logic) ----
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): 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 = {}, params: dict = {},
) -> dict: ) -> dict:
"""Call a Telegram-specific platform API.""" """Call a Telegram-specific platform API."""
if action == 'interaction.request':
return await send_interaction(self.bot, params)
handler = PLATFORM_API_MAP.get(action) handler = PLATFORM_API_MAP.get(action)
if handler is None: if handler is None:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
@@ -406,10 +406,11 @@ class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event, source_platform_object=event,
) )
else: else:
sender = event.message.from_user
return legacy_events.GroupMessage( return legacy_events.GroupMessage(
sender=legacy_entities.GroupMember( sender=legacy_entities.GroupMember(
id=event.effective_chat.id, id=sender.id if sender else '',
member_name=event.effective_chat.title, member_name=sender.first_name if sender else '',
permission=legacy_entities.Permission.Member, permission=legacy_entities.Permission.Member,
group=legacy_entities.Group( group=legacy_entities.Group(
id=event.effective_chat.id, id=event.effective_chat.id,
@@ -0,0 +1,163 @@
"""Telegram rendering and callback conversion for structured interactions."""
from __future__ import annotations
import time
import typing
import telegram
import langbot_plugin.api.entities.builtin.platform.events as platform_events
INTERACTION_CALLBACK_PREFIX = 'lbi'
def parse_interaction_callback(data: str | None) -> dict[str, typing.Any] | None:
"""Parse a compact interaction callback without trusting platform values."""
if not data or not data.startswith(f'{INTERACTION_CALLBACK_PREFIX}:'):
return None
parts = data.split(':')
if len(parts) == 4 and parts[2] == 'a':
token, action_ref = parts[1], parts[3]
if token and action_ref.isdigit():
return {'callback_token': token, 'action_ref': int(action_ref)}
if len(parts) == 5 and parts[2] == 'f':
token, field_ref, option_ref = parts[1], parts[3], parts[4]
if token and field_ref.isdigit() and option_ref.isdigit():
return {
'callback_token': token,
'field_ref': int(field_ref),
'option_ref': int(option_ref),
}
raise ValueError('invalid Telegram interaction callback data')
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
"""Return the structured interaction subset Telegram can render natively."""
return {
'field_types': ['select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def _callback_data(callback_token: str, *parts: str | int) -> str:
value = ':'.join((INTERACTION_CALLBACK_PREFIX, callback_token, *(str(part) for part in parts)))
if len(value.encode('utf-8')) > 64:
raise ValueError('Telegram interaction callback exceeds 64 bytes')
return value
def _build_keyboard(request: dict[str, typing.Any], callback_token: str) -> telegram.InlineKeyboardMarkup | None:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
if actions and not fields:
rows = [
[
telegram.InlineKeyboardButton(
str(action.get('label') or action.get('id') or index + 1),
callback_data=_callback_data(callback_token, 'a', index),
)
]
for index, action in enumerate(actions)
if isinstance(action, dict)
]
return telegram.InlineKeyboardMarkup(rows) if rows else None
if len(fields) == 1 and not actions:
field = fields[0]
if not isinstance(field, dict) or field.get('type') != 'select':
return None
options = field.get('options') if isinstance(field.get('options'), list) else []
rows = [
[
telegram.InlineKeyboardButton(
str(option.get('label') or option.get('value') or option_index + 1),
callback_data=_callback_data(callback_token, 'f', 0, option_index),
)
]
for option_index, option in enumerate(options)
if isinstance(option, dict)
]
return telegram.InlineKeyboardMarkup(rows) if rows else None
return None
def _message_text(request: dict[str, typing.Any], *, rich: bool) -> str:
parts = [str(request.get('title') or '').strip()]
description = str(request.get('description') or '').strip()
if description:
parts.append(description)
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
if rich and len(fields) == 1 and isinstance(fields[0], dict):
label = str(fields[0].get('label') or '').strip()
if label:
parts.append(label)
if not rich:
fallback = str(request.get('fallback_text') or '').strip()
if fallback:
parts.append(fallback)
return '\n\n'.join(part for part in parts if part)
async def send_interaction(bot: telegram.Bot, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
"""Render a supported interaction or its required plain-text fallback."""
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
target_id = str(reply_target.get('target_id') or '')
if not target_id:
raise ValueError('interaction.request has no target_id')
chat_id_text, _, thread_id_text = target_id.partition('#')
chat_id: int | str = int(chat_id_text) if chat_id_text.lstrip('-').isdigit() else chat_id_text
message_thread_id = int(thread_id_text) if thread_id_text.isdigit() else None
keyboard = _build_keyboard(request, callback_token)
send_args: dict[str, typing.Any] = {
'chat_id': chat_id,
'text': _message_text(request, rich=keyboard is not None),
}
if message_thread_id is not None:
send_args['message_thread_id'] = message_thread_id
if keyboard is not None:
send_args['reply_markup'] = keyboard
sent = await bot.send_message(**send_args)
return {'ok': True, 'message_id': getattr(sent, 'message_id', None), 'rich': keyboard is not None}
def interaction_event_from_update(
update: telegram.Update,
parsed: dict[str, typing.Any],
) -> platform_events.PlatformSpecificEvent:
"""Convert a trusted callback shape into the Host interaction event."""
query = update.callback_query
if query is None or query.message is None or query.from_user is None:
raise ValueError('Telegram interaction callback has no message or actor')
message = query.message
target_type = 'person' if message.chat.type == 'private' else 'group'
target_id = str(message.chat.id)
if message.message_thread_id:
target_id = f'{target_id}#{message.message_thread_id}'
data = {
**parsed,
'actor_id': str(query.from_user.id),
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
}
return platform_events.PlatformSpecificEvent(
type='platform.specific',
timestamp=time.time(),
adapter_name='telegram',
action='interaction.submitted',
data=data,
source_platform_object=update,
)
@@ -72,6 +72,7 @@ spec:
- kick_member - kick_member
- leave_group - leave_group
- call_platform_api - call_platform_api
- interaction.request
platform_specific_apis: platform_specific_apis:
- action: pin_message - action: pin_message
@@ -14,6 +14,11 @@ from langbot.pkg.platform.adapters.wecombot.api_impl import WecomBotAPIMixin
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter 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.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.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger 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 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]: def get_supported_apis(self) -> list[str]:
return [ apis = [
'send_message', 'send_message',
'reply_message', 'reply_message',
'get_message', 'get_message',
@@ -111,6 +116,16 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
'get_group_member_list', 'get_group_member_list',
'call_platform_api', '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( async def send_message(
self, self,
@@ -163,6 +178,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
return self.config.get('enable-stream-reply', True) return self.config.get('enable-stream-reply', True)
async def call_platform_api(self, action: str, params: dict = {}) -> dict: 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) handler = PLATFORM_API_MAP.get(action)
if handler is None: if handler is None:
raise NotSupportedError(f'call_platform_api:{action}') 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) self.bot.on_feedback()(self._handle_feedback)
if hasattr(self.bot, 'on_message'): if hasattr(self.bot, 'on_message'):
self.bot.on_message('event')(self._handle_native_event) 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): async def _handle_native_event(self, event: WecomBotEvent):
try: try:
@@ -277,6 +303,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
def _cleanup_stream_mapping(self): def _cleanup_stream_mapping(self):
now = time.time() 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: for key in expired:
del self._stream_to_monitoring_msg[key] del self._stream_to_monitoring_msg[key]
@@ -0,0 +1,168 @@
"""WeCom template-card rendering and callbacks for structured interactions."""
from __future__ import annotations
import time
import typing
import uuid
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot_plugin.api.entities.builtin.platform import events as platform_events
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
return {
'field_types': ['select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def _button_style(style: str) -> int:
if style == 'primary':
return 1
if style == 'danger':
return 2
return 0
def build_interaction_card(request: dict[str, typing.Any], callback_token: str) -> dict[str, typing.Any] | None:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
buttons: list[dict[str, typing.Any]] = []
if actions and not fields:
buttons = [
{
'text': str(action.get('label') or action.get('id') or index + 1),
'style': _button_style(str(action.get('style') or 'default')),
'key': f'lbi:{callback_token}:a:{index}',
}
for index, action in enumerate(actions[:6])
if isinstance(action, dict)
]
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
field = fields[0]
if field.get('type') != 'select':
return None
options = field.get('options') if isinstance(field.get('options'), list) else []
buttons = [
{
'text': str(option.get('label') or option.get('value') or index + 1),
'style': 0,
'key': f'lbi:{callback_token}:f:0:{index}',
}
for index, option in enumerate(options[:6])
if isinstance(option, dict)
]
if not buttons:
return None
description = str(request.get('description') or '').strip()
if len(fields) == 1 and isinstance(fields[0], dict):
label = str(fields[0].get('label') or '').strip()
description = '\n\n'.join(part for part in (description, label) if part)
return {
'msgtype': 'template_card',
'template_card': {
'card_type': 'button_interaction',
'main_title': {'title': str(request.get('title') or '')},
'sub_title_text': description,
'button_list': buttons,
'task_id': f'lbi-{uuid.uuid4().hex}',
},
}
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
target_type = str(reply_target.get('target_type') or '')
target_id = str(reply_target.get('target_id') or '')
card = build_interaction_card(request, callback_token)
if card is None:
fallback = '\n\n'.join(
part
for part in (
str(request.get('title') or '').strip(),
str(request.get('description') or '').strip(),
str(request.get('fallback_text') or '').strip(),
)
if part
)
result = await adapter.send_message(target_type, target_id, adapter._plain_message(fallback))
return {'ok': True, 'message_id': result.message_id, 'rich': False}
raw = await adapter.bot.send_template_card(target_id, card)
return {'ok': True, 'message_id': None, 'rich': True, 'raw': raw}
def _template_card_event(event: WecomBotEvent) -> dict[str, typing.Any]:
wrapper = event.get('event') or {}
if not isinstance(wrapper, dict):
return {}
for key in ('template_card_event', 'templateCardEvent', 'TemplateCardEvent'):
value = wrapper.get(key)
if isinstance(value, dict):
return value
return wrapper
def _callback_key(payload: dict[str, typing.Any]) -> str:
value = payload.get('EventKey') or payload.get('event_key') or payload.get('eventKey') or payload.get('key') or ''
if value:
return str(value)
for button_name in ('button', 'Button', 'selected_button', 'selectedButton'):
button = payload.get(button_name)
if not isinstance(button, dict):
continue
value = button.get('key') or button.get('Key') or button.get('event_key') or button.get('EventKey') or ''
if value:
return str(value)
return ''
def parse_callback_key(value: str) -> dict[str, typing.Any]:
parts = value.split(':')
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
if (
len(parts) == 5
and parts[0] == 'lbi'
and parts[1]
and parts[2] == 'f'
and parts[3].isdigit()
and parts[4].isdigit()
):
return {
'callback_token': parts[1],
'field_ref': int(parts[3]),
'option_ref': int(parts[4]),
}
raise ValueError('invalid WeCom interaction callback key')
def interaction_event_from_native(
event: WecomBotEvent,
) -> platform_events.PlatformSpecificEvent | None:
callback_key = _callback_key(_template_card_event(event))
if not callback_key.startswith('lbi:'):
return None
parsed = parse_callback_key(callback_key)
target_type = 'group' if event.type == 'group' or event.chatid else 'person'
target_id = str(event.chatid or event.userid or '')
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='wecombot-eba',
action='interaction.submitted',
data={
**parsed,
'actor_id': str(event.userid or ''),
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
},
timestamp=time.time(),
source_platform_object=event,
)
@@ -143,6 +143,7 @@ spec:
- get_group_member_info - get_group_member_info
- get_group_member_list - get_group_member_list
- call_platform_api - call_platform_api
- interaction.request
platform_specific_apis: platform_specific_apis:
- action: is_websocket_mode - action: is_websocket_mode
+258 -21
View File
@@ -996,6 +996,27 @@ class RuntimeBot:
elif getattr(event, 'session_id', None): elif getattr(event, 'session_id', None):
conversation_id = str(getattr(event, 'session_id')) 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( return AgentEventEnvelope(
event_id=f'platform:{self.bot_entity.uuid}:{event_id}', event_id=f'platform:{self.bot_entity.uuid}:{event_id}',
event_type=event_type, event_type=event_type,
@@ -1009,23 +1030,7 @@ class RuntimeBot:
actor=self._infer_actor_context(event), actor=self._infer_actor_context(event),
subject=self._infer_subject_context(event), subject=self._infer_subject_context(event),
input=self._build_agent_input(event), input=self._build_agent_input(event),
delivery=DeliveryContext( delivery=DeliveryContext.model_validate(delivery_data),
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,
},
),
raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None), raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None),
data=self._compact_event_data(event), data=self._compact_event_data(event),
) )
@@ -1045,6 +1050,22 @@ class RuntimeBot:
return [] return []
return list(dict.fromkeys(api_name for api_name in declared_apis if isinstance(api_name, str) and api_name)) 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 @staticmethod
def _agent_product_to_binding( def _agent_product_to_binding(
agent: dict[str, typing.Any], agent: dict[str, typing.Any],
@@ -1068,9 +1089,15 @@ class RuntimeBot:
runner_config=runner_config, runner_config=runner_config,
resource_policy=ResourcePolicyProjector.from_runner_config(runner_config), resource_policy=ResourcePolicyProjector.from_runner_config(runner_config),
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']), 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, enabled=True,
agent_id=agent.get('uuid'), agent_id=agent.get('uuid'),
processor_type='agent',
processor_id=agent.get('uuid'),
) )
@staticmethod @staticmethod
@@ -1137,6 +1164,10 @@ class RuntimeBot:
event: platform_events.EBAEvent, event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> None: ) -> 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 event.bot_uuid = self.bot_entity.uuid
plugin_event = self._eba_event_to_plugin_event(event) plugin_event = self._eba_event_to_plugin_event(event)
@@ -1303,7 +1334,11 @@ class RuntimeBot:
envelope = self._eba_event_to_agent_envelope(event, adapter) envelope = self._eba_event_to_agent_envelope(event, adapter)
outputs: list[provider_message.Message | provider_message.MessageChunk] = [] outputs: list[provider_message.Message | provider_message.MessageChunk] = []
try: 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) outputs.append(output)
except Exception: except Exception:
return await self._record_event_route_trace( return await self._record_event_route_trace(
@@ -1408,6 +1443,7 @@ class RuntimeBot:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid_override: str | None = None, pipeline_uuid_override: str | None = None,
routed_by_event_binding: bool = False, routed_by_event_binding: bool = False,
variables: dict[str, typing.Any] | None = None,
) -> None: ) -> None:
is_group_message = isinstance(event, platform_events.GroupMessage) is_group_message = isinstance(event, platform_events.GroupMessage)
launcher_kind = 'group' if is_group_message else 'person' launcher_kind = 'group' if is_group_message else 'person'
@@ -1475,8 +1511,197 @@ class RuntimeBot:
adapter=adapter, adapter=adapter,
pipeline_uuid=pipeline_uuid, pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule, 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( async def _dispatch_eba_message_to_pipeline(
self, self,
event: platform_events.EBAEvent, event: platform_events.EBAEvent,
@@ -1523,8 +1748,20 @@ class RuntimeBot:
return return
await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter) await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter)
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message) get_supported_events = getattr(self.adapter, 'get_supported_events', None)
self.adapter.register_listener(platform_events.GroupMessage, on_group_message) 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) # Register feedback listener (only effective on adapters that support it)
async def on_feedback( async def on_feedback(
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence import ( from langbot.pkg.entity.persistence import (
agent as agent_models, agent as agent_models,
agent_interaction as agent_interaction_models,
agent_run as agent_run_models, agent_run as agent_run_models,
agent_runner_state as agent_runner_state_models, agent_runner_state as agent_runner_state_models,
bot as bot_models, bot as bot_models,
@@ -57,7 +58,7 @@ class TestAlembicRevisionGraph:
revisions = list(script.walk_revisions()) revisions = list(script.walk_revisions())
assert script.get_bases() == ['0001_baseline'] 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), { assert all(len(item.revision) <= 32 for item in revisions), {
item.revision: len(item.revision) for item in revisions if len(item.revision) > 32 item.revision: len(item.revision) for item in revisions if len(item.revision) > 32
} }
@@ -194,6 +195,7 @@ class TestSQLiteMigrationUpgrade:
expected_agent_tables = { expected_agent_tables = {
agent_models.Agent.__tablename__, agent_models.Agent.__tablename__,
agent_interaction_models.AgentInteraction.__tablename__,
agent_run_models.AgentRun.__tablename__, agent_run_models.AgentRun.__tablename__,
agent_run_models.AgentRunEvent.__tablename__, agent_run_models.AgentRunEvent.__tablename__,
agent_run_models.AgentRuntime.__tablename__, agent_run_models.AgentRuntime.__tablename__,
+160 -105
View File
@@ -1,4 +1,5 @@
"""Test that LangBot context builder output validates against SDK AgentRunContext.""" """Test that LangBot context builder output validates against SDK AgentRunContext."""
from __future__ import annotations from __future__ import annotations
import pytest import pytest
@@ -31,7 +32,7 @@ class TestContextValidation:
"""Create a mock application.""" """Create a mock application."""
mock_app = MagicMock(spec=app.Application) mock_app = MagicMock(spec=app.Application)
mock_app.ver_mgr = MagicMock() 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 = MagicMock()
mock_app.persistence_mgr.get_db_engine = MagicMock() mock_app.persistence_mgr.get_db_engine = MagicMock()
mock_app.logger = MagicMock() mock_app.logger = MagicMock()
@@ -44,35 +45,35 @@ class TestContextValidation:
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
return AgentEventEnvelope( return AgentEventEnvelope(
event_id="evt_1", event_id='evt_1',
event_type="message.received", event_type='message.received',
event_time=1700000000, event_time=1700000000,
source="platform", source='platform',
source_event_type="platform.message", source_event_type='platform.message',
bot_id="bot_1", bot_id='bot_1',
workspace_id="workspace_1", workspace_id='workspace_1',
conversation_id="conv_1", conversation_id='conv_1',
thread_id=None, thread_id=None,
actor=ActorContext( actor=ActorContext(
actor_type="user", actor_type='user',
actor_id="user_1", actor_id='user_1',
actor_name="Test User", actor_name='Test User',
), ),
subject=None, subject=None,
input=EventInput(text="Hello world"), input=EventInput(text='Hello world'),
delivery=DeliveryContext(surface="test"), delivery=DeliveryContext(surface='test'),
data={"platform_event_id": "source_evt_1"}, data={'platform_event_id': 'source_evt_1'},
) )
def _make_binding(self) -> AgentBinding: def _make_binding(self) -> AgentBinding:
"""Create a test binding.""" """Create a test binding."""
return AgentBinding( return AgentBinding(
binding_id="binding_1", binding_id='binding_1',
scope=BindingScope(scope_type="agent", scope_id="pipeline_1"), scope=BindingScope(scope_type='agent', scope_id='pipeline_1'),
event_types=["message.received"], event_types=['message.received'],
runner_id="plugin:test/plugin/runner", runner_id='plugin:test/plugin/runner',
runner_config={"timeout": 300}, runner_config={'timeout': 300},
agent_id="pipeline_1", agent_id='pipeline_1',
enabled=True, enabled=True,
) )
@@ -91,16 +92,16 @@ class TestContextValidation:
def _make_descriptor(self): def _make_descriptor(self):
"""Create a mock runner descriptor.""" """Create a mock runner descriptor."""
return AgentRunnerDescriptor( return AgentRunnerDescriptor(
id="plugin:test/plugin/runner", id='plugin:test/plugin/runner',
source="plugin", source='plugin',
label={"en_US": "Test Runner"}, label={'en_US': 'Test Runner'},
plugin_author="test", plugin_author='test',
plugin_name="plugin", plugin_name='plugin',
runner_name="runner", runner_name='runner',
permissions={ permissions={
"history": ["page", "search"], 'history': ['page', 'search'],
"events": ["get", "page"], 'events': ['get', 'page'],
"storage": ["plugin", "workspace"], 'storage': ['plugin', 'workspace'],
}, },
) )
@@ -118,12 +119,14 @@ class TestContextValidation:
# Mock persistent state store to return empty state snapshot # 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: with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock() mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={ mock_store.build_snapshot_from_event = AsyncMock(
'conversation': {}, return_value={
'actor': {}, 'conversation': {},
'subject': {}, 'actor': {},
'runner': {}, 'subject': {},
}) 'runner': {},
}
)
mock_get_store.return_value = mock_store mock_get_store.return_value = mock_store
# Build context # Build context
@@ -152,27 +155,69 @@ class TestContextValidation:
assert isinstance(validated.resources, AgentResources) assert isinstance(validated.resources, AgentResources)
assert validated.runtime is not None assert validated.runtime is not None
assert isinstance(validated.runtime, AgentRuntimeContext) assert isinstance(validated.runtime, AgentRuntimeContext)
assert "protocol_version" not in validated.runtime.model_dump() 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 validated.runtime.model_dump()
assert "sdk_protocol_version" not in context_dict["runtime"] assert 'sdk_protocol_version' not in context_dict['runtime']
# Verify event context # Verify event context
assert validated.event.event_id == "evt_1" assert validated.event.event_id == 'evt_1'
assert validated.event.event_type == "message.received" assert validated.event.event_type == 'message.received'
assert validated.event.source == "platform" assert validated.event.source == 'platform'
assert validated.event.source_event_type == "platform.message" assert validated.event.source_event_type == 'platform.message'
assert validated.event.data == {"platform_event_id": "source_evt_1"} assert validated.event.data == {'platform_event_id': 'source_evt_1'}
# Verify conversation context uses SDK field names # Verify conversation context uses SDK field names
assert validated.conversation is not None assert validated.conversation is not None
assert validated.conversation.bot_id == "bot_1" assert validated.conversation.bot_id == 'bot_1'
assert validated.conversation.workspace_id == "workspace_1" assert validated.conversation.workspace_id == 'workspace_1'
# Verify delivery context # Verify delivery context
assert validated.delivery.surface == "test" assert validated.delivery.surface == 'test'
# Verify input # 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 @pytest.mark.asyncio
async def test_build_context_from_event_populates_model_context_window(self): 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: with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock() mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={ mock_store.build_snapshot_from_event = AsyncMock(
'conversation': {}, return_value={
'actor': {}, 'conversation': {},
'subject': {}, 'actor': {},
'runner': {}, 'subject': {},
}) 'runner': {},
}
)
mock_get_store.return_value = mock_store mock_get_store.return_value = mock_store
context_dict = await builder.build_context_from_event( context_dict = await builder.build_context_from_event(
@@ -265,37 +312,39 @@ class TestContextValidation:
mock_app = self._make_mock_app() mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app) builder = AgentRunContextBuilder(mock_app)
event = AgentEventEnvelope( event = AgentEventEnvelope(
event_id="evt_recall_1", event_id='evt_recall_1',
event_type="message.recalled", event_type='message.recalled',
event_time=1700000001, event_time=1700000001,
source="platform", source='platform',
source_event_type="platform.message.recall", source_event_type='platform.message.recall',
bot_id="bot_1", bot_id='bot_1',
workspace_id="workspace_1", workspace_id='workspace_1',
conversation_id="conv_1", conversation_id='conv_1',
actor=ActorContext(actor_type="user", actor_id="user_1"), actor=ActorContext(actor_type='user', actor_id='user_1'),
subject=SubjectContext( subject=SubjectContext(
subject_type="message", subject_type='message',
subject_id="message_1", subject_id='message_1',
data={"recalled_message_id": "message_1", "reason": "user_recall"}, data={'recalled_message_id': 'message_1', 'reason': 'user_recall'},
), ),
input=EventInput(text=None), input=EventInput(text=None),
delivery=DeliveryContext(surface="test"), delivery=DeliveryContext(surface='test'),
data={"source_event_id": "source_recall_1"}, data={'source_event_id': 'source_recall_1'},
) )
binding = self._make_binding() binding = self._make_binding()
binding.event_types = ["message.recalled"] binding.event_types = ['message.recalled']
resources = self._make_resources() resources = self._make_resources()
descriptor = self._make_descriptor() descriptor = self._make_descriptor()
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store: with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock() mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={ mock_store.build_snapshot_from_event = AsyncMock(
'conversation': {}, return_value={
'actor': {}, 'conversation': {},
'subject': {}, 'actor': {},
'runner': {}, 'subject': {},
}) 'runner': {},
}
)
mock_get_store.return_value = mock_store mock_get_store.return_value = mock_store
context_dict = await builder.build_context_from_event( context_dict = await builder.build_context_from_event(
@@ -307,12 +356,12 @@ class TestContextValidation:
validated = AgentRunContext.model_validate(context_dict) 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.input.text is None
assert validated.subject is not None assert validated.subject is not None
assert validated.subject.subject_type == "message" assert validated.subject.subject_type == 'message'
assert validated.subject.subject_id == "message_1" assert validated.subject.subject_id == 'message_1'
assert validated.subject.data == {"recalled_message_id": "message_1", "reason": "user_recall"} assert validated.subject.data == {'recalled_message_id': 'message_1', 'reason': 'user_recall'}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_context_from_event_has_no_legacy_top_level_fields(self): 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 # 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: with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock() mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={ mock_store.build_snapshot_from_event = AsyncMock(
'conversation': {}, return_value={
'actor': {}, 'conversation': {},
'subject': {}, 'actor': {},
'runner': {}, 'subject': {},
}) 'runner': {},
}
)
mock_get_store.return_value = mock_store mock_get_store.return_value = mock_store
context_dict = await builder.build_context_from_event( context_dict = await builder.build_context_from_event(
@@ -344,16 +395,16 @@ class TestContextValidation:
) )
# Protocol v1 does NOT have these as core fields # 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 '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 '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 'params' not in context_dict, 'params should not be top-level in Protocol v1'
# Protocol v1 DOES have these # Protocol v1 DOES have these
assert 'delivery' in context_dict, "delivery is REQUIRED in Protocol v1" assert 'delivery' in context_dict, 'delivery is REQUIRED in Protocol v1'
assert 'context' in context_dict, "context (ContextAccess) 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 'bootstrap' not in context_dict, 'Host must not inline bootstrap/history windows'
assert 'adapter' in context_dict, "adapter should exist" assert 'adapter' in context_dict, 'adapter should exist'
assert 'metadata' in context_dict, "metadata should exist" assert 'metadata' in context_dict, 'metadata should exist'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_context_from_event_event_is_not_none(self): 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 # 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: with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock() mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={ mock_store.build_snapshot_from_event = AsyncMock(
'conversation': {}, return_value={
'actor': {}, 'conversation': {},
'subject': {}, 'actor': {},
'runner': {}, 'subject': {},
}) 'runner': {},
}
)
mock_get_store.return_value = mock_store mock_get_store.return_value = mock_store
context_dict = await builder.build_context_from_event( context_dict = await builder.build_context_from_event(
@@ -385,7 +438,7 @@ class TestContextValidation:
) )
# event is REQUIRED in Protocol v1 # 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 # Validate
validated = AgentRunContext.model_validate(context_dict) validated = AgentRunContext.model_validate(context_dict)
@@ -405,12 +458,14 @@ class TestContextValidation:
# Mock persistent state store to return empty state snapshot # 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: with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
mock_store = AsyncMock() mock_store = AsyncMock()
mock_store.build_snapshot_from_event = AsyncMock(return_value={ mock_store.build_snapshot_from_event = AsyncMock(
'conversation': {}, return_value={
'actor': {}, 'conversation': {},
'subject': {}, 'actor': {},
'runner': {}, 'subject': {},
}) 'runner': {},
}
)
mock_get_store.return_value = mock_store mock_get_store.return_value = mock_store
context_dict = await builder.build_context_from_event( context_dict = await builder.build_context_from_event(
@@ -421,7 +476,7 @@ class TestContextValidation:
) )
# delivery is REQUIRED in Protocol v1 # 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 # Validate
validated = AgentRunContext.model_validate(context_dict) validated = AgentRunContext.model_validate(context_dict)
@@ -7,6 +7,7 @@ Tests cover:
4. LangBot Host not defining context-window controls 4. LangBot Host not defining context-window controls
5. Event-first run() entry point 5. Event-first run() entry point
""" """
from __future__ import annotations from __future__ import annotations
import pytest import pytest
@@ -38,34 +39,34 @@ class TestQueryToEventEnvelope:
"""Test basic field conversion from Query to Event envelope.""" """Test basic field conversion from Query to Event envelope."""
event = QueryEntryAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.event_type == "message.received" assert event.event_type == 'message.received'
assert event.source == "host_adapter" assert event.source == 'host_adapter'
assert event.bot_id == mock_query.bot_uuid assert event.bot_id == mock_query.bot_uuid
assert event.actor is not None 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): def test_query_to_event_input(self, mock_query):
"""Test input conversion from Query.""" """Test input conversion from Query."""
event = QueryEntryAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.input is not None assert event.input is not None
assert event.input.text == "Hello world" assert event.input.text == 'Hello world'
assert "message_chain" not in event.input.model_dump() assert 'message_chain' not in event.input.model_dump()
def test_query_to_event_conversation(self, mock_query): def test_query_to_event_conversation(self, mock_query):
"""Test conversation context extraction.""" """Test conversation context extraction."""
event = QueryEntryAdapter.query_to_event(mock_query) 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): 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.""" """Pipeline variables can provide the conversation identity for state scope."""
mock_query.session.using_conversation.uuid = None 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) 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): 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.""" """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) 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): def test_query_to_event_delivery_context(self, mock_query):
"""Test delivery context extraction.""" """Test delivery context extraction."""
event = QueryEntryAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.delivery is not None assert event.delivery is not None
assert event.delivery.surface == "platform" assert event.delivery.surface == 'platform'
assert isinstance(event.delivery.supports_streaming, bool) 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): def test_query_to_event_preserves_source_event_data(self, mock_query):
"""Test source event metadata survives the adapter boundary.""" """Test source event metadata survives the adapter boundary."""
source_event = Mock() source_event = Mock()
source_event.type = "platform.message.created" source_event.type = 'platform.message.created'
source_event.time = 1700000000 source_event.time = 1700000000
source_event.sender = None source_event.sender = None
source_event.model_dump = Mock(return_value={ source_event.model_dump = Mock(
"type": "platform.message.created", return_value={
"message_id": "source-message-1", 'type': 'platform.message.created',
"source_platform_object": {"large": "payload"}, 'message_id': 'source-message-1',
}) 'source_platform_object': {'large': 'payload'},
}
)
mock_query.message_event = source_event mock_query.message_event = source_event
event = QueryEntryAdapter.query_to_event(mock_query) 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.event_time == 1700000000
assert event.data == { assert event.data == {
"type": "platform.message.created", 'type': 'platform.message.created',
"message_id": "source-message-1", '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): 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.""" """Large or nested platform payloads should not be duplicated into event.data."""
source_event = Mock() source_event = Mock()
source_event.type = "platform.message.created" source_event.type = 'platform.message.created'
source_event.time = 1700000000 source_event.time = 1700000000
source_event.sender = None source_event.sender = None
source_event.model_dump = Mock(return_value={ source_event.model_dump = Mock(
"type": "platform.message.created", return_value={
"message_id": "source-message-1", 'type': 'platform.message.created',
"message_chain": [{"type": "Image", "base64": "data:image/png;base64," + ("a" * 1024)}], 'message_id': 'source-message-1',
"raw_text": "x" * 1024, 'message_chain': [{'type': 'Image', 'base64': 'data:image/png;base64,' + ('a' * 1024)}],
"source_platform_object": {"large": "payload"}, 'raw_text': 'x' * 1024,
}) 'source_platform_object': {'large': 'payload'},
}
)
mock_query.message_event = source_event mock_query.message_event = source_event
event = QueryEntryAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
assert event.data == { assert event.data == {
"type": "platform.message.created", 'type': 'platform.message.created',
"message_id": "source-message-1", 'message_id': 'source-message-1',
} }
def test_query_to_event_handles_missing_message_chain(self, mock_query): def test_query_to_event_handles_missing_message_chain(self, mock_query):
"""Test delivery context building when Query has no message_chain.""" """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) 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): def test_query_to_event_scopes_pipeline_local_event_ids(self, mock_query):
"""Pipeline-local message IDs must not become global audit IDs.""" """Pipeline-local message IDs must not become global audit IDs."""
first = QueryEntryAdapter.query_to_event(mock_query) 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) second = QueryEntryAdapter.query_to_event(mock_query)
assert first.event_id.startswith("host:") assert first.event_id.startswith('host:')
assert first.event_id != "789" assert first.event_id != '789'
assert second.event_id != first.event_id assert second.event_id != first.event_id
@@ -152,21 +191,22 @@ class TestQueryConfigToAgentConfig:
def test_config_to_agent_config_runner_id(self, mock_query): def test_config_to_agent_config_runner_id(self, mock_query):
"""Test AgentConfig runner_id extraction.""" """Test AgentConfig runner_id extraction."""
agent_config = QueryEntryAdapter.config_to_agent_config( agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:author/plugin/runner')
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): def test_config_to_agent_config_uses_current_runner_config(self, mock_query):
"""Temporary query adapters use the current runner config container.""" """Temporary query adapters use the current runner config container."""
mock_query.pipeline_config = { mock_query.pipeline_config = {
"ai": { 'ai': {
"runner": {"id": "plugin:langbot-team/LocalAgent/default"}, 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
"runner_config": { 'runner_config': {
"plugin:langbot-team/LocalAgent/default": { 'plugin:langbot-team/LocalAgent/default': {
"model": {"primary": "model-primary", "fallbacks": []}, 'model': {'primary': 'model-primary', 'fallbacks': []},
"knowledge-bases": ["kb-001"], 'knowledge-bases': ['kb-001'],
}, },
}, },
} }
@@ -174,90 +214,109 @@ class TestQueryConfigToAgentConfig:
agent_config = QueryEntryAdapter.config_to_agent_config( agent_config = QueryEntryAdapter.config_to_agent_config(
mock_query, 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['model'] == {'primary': 'model-primary', 'fallbacks': []}
assert agent_config.runner_config["knowledge-bases"] == ["kb-001"] assert agent_config.runner_config['knowledge-bases'] == ['kb-001']
def test_resolver_projects_agent_scope(self, mock_query): def test_resolver_projects_agent_scope(self, mock_query):
"""Test binding scope projection through the resolver.""" """Test binding scope projection through the resolver."""
event = QueryEntryAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
agent_config = QueryEntryAdapter.config_to_agent_config( agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:test/plugin/runner')
mock_query, "plugin:test/plugin/runner"
)
binding = AgentBindingResolver().resolve_one(event, [agent_config]) 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.scope.scope_id == mock_query.pipeline_uuid
assert binding.agent_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): def test_resolver_rejects_multiple_matching_agents(self, mock_query):
"""Event dispatch is single-Agent in v1.""" """Event dispatch is single-Agent in v1."""
event = QueryEntryAdapter.query_to_event(mock_query) event = QueryEntryAdapter.query_to_event(mock_query)
first = QueryEntryAdapter.config_to_agent_config( first = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:test/plugin/runner')
mock_query, "plugin:test/plugin/runner" second = first.model_copy(update={'agent_id': 'agent_2'})
)
second = first.model_copy(update={"agent_id": "agent_2"})
with pytest.raises(AgentBindingResolutionError): with pytest.raises(AgentBindingResolutionError):
AgentBindingResolver().resolve_one(event, [first, second]) AgentBindingResolver().resolve_one(event, [first, second])
class TestAgentRunContextProtocolV1: class TestAgentRunContextProtocolV1:
"""Test AgentRunContext Protocol v1 behavior.""" """Test AgentRunContext Protocol v1 behavior."""
def test_sdk_context_event_required(self): def test_sdk_context_event_required(self):
"""Test that event is required in Protocol v1 context.""" """Test that event is required in Protocol v1 context."""
trigger = AgentTrigger(type="message.received") trigger = AgentTrigger(type='message.received')
event = AgentEventContext( event = AgentEventContext(
event_id="evt_1", event_id='evt_1',
event_type="message.received", event_type='message.received',
source="platform", 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.resources import AgentResources
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
ctx = AgentRunContext( ctx = AgentRunContext(
run_id="run_1", run_id='run_1',
trigger=trigger, trigger=trigger,
event=event, event=event,
input=input, input=input,
delivery=DeliveryContext(surface="platform"), delivery=DeliveryContext(surface='platform'),
resources=AgentResources(), resources=AgentResources(),
runtime=AgentRuntimeContext(), runtime=AgentRuntimeContext(),
) )
assert ctx.event is not None 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): def test_sdk_context_has_no_history_message_fields(self):
"""AgentRunContext should not expose inline history message fields.""" """AgentRunContext should not expose inline history message fields."""
trigger = AgentTrigger(type="message.received") trigger = AgentTrigger(type='message.received')
event = AgentEventContext( event = AgentEventContext(
event_id="evt_1", event_id='evt_1',
event_type="message.received", event_type='message.received',
source="platform", 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.resources import AgentResources
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
ctx = AgentRunContext( ctx = AgentRunContext(
run_id="run_1", run_id='run_1',
trigger=trigger, trigger=trigger,
event=event, event=event,
input=input, input=input,
delivery=DeliveryContext(surface="platform"), delivery=DeliveryContext(surface='platform'),
resources=AgentResources(), resources=AgentResources(),
runtime=AgentRuntimeContext(), runtime=AgentRuntimeContext(),
) )
assert "messages" not in AgentRunContext.model_fields assert 'messages' not in AgentRunContext.model_fields
assert "bootstrap" not in AgentRunContext.model_fields assert 'bootstrap' not in AgentRunContext.model_fields
assert not hasattr(ctx, "bootstrap") assert not hasattr(ctx, 'bootstrap')
class TestHostManagedHistoryNotInProtocol: class TestHostManagedHistoryNotInProtocol:
@@ -267,7 +326,7 @@ class TestHostManagedHistoryNotInProtocol:
"""AgentRunContext should not expose top-level history messages.""" """AgentRunContext should not expose top-level history messages."""
ctx_fields = AgentRunContext.model_fields.keys() ctx_fields = AgentRunContext.model_fields.keys()
assert "messages" not in ctx_fields assert 'messages' not in ctx_fields
class TestSDKResultProtocolV1: class TestSDKResultProtocolV1:
@@ -278,11 +337,12 @@ class TestSDKResultProtocolV1:
from langbot_plugin.api.entities.builtin.provider.message import Message from langbot_plugin.api.entities.builtin.provider.message import Message
result = AgentRunResult.message_completed( result = AgentRunResult.message_completed(
run_id="run_1", run_id='run_1',
message=Message(role="assistant", content="Hello"), message=Message(role='assistant', content='Hello'),
) )
assert result.run_id == "run_1" assert result.run_id == 'run_1'
# Fixtures # Fixtures
@pytest.fixture @pytest.fixture
@@ -290,14 +350,14 @@ def mock_query():
"""Create a mock query for testing.""" """Create a mock query for testing."""
query = Mock() query = Mock()
query.query_id = 123 query.query_id = 123
query.bot_uuid = "bot-uuid-123" query.bot_uuid = 'bot-uuid-123'
query.pipeline_uuid = "pipeline-uuid-456" query.pipeline_uuid = 'pipeline-uuid-456'
query.launcher_type = Mock(value="person") query.launcher_type = Mock(value='person')
query.launcher_id = "launcher-123" query.launcher_id = 'launcher-123'
query.sender_id = "sender-123" query.sender_id = 'sender-123'
query.pipeline_config = { query.pipeline_config = {
"ai": { 'ai': {
"runner": "plugin:test/plugin/runner", 'runner': 'plugin:test/plugin/runner',
} }
} }
query.variables = {} query.variables = {}
@@ -321,10 +381,10 @@ def mock_query():
# Mock session with proper conversation # Mock session with proper conversation
query.session = Mock() query.session = Mock()
query.session.launcher_type = Mock(value="person") query.session.launcher_type = Mock(value='person')
query.session.launcher_id = "launcher-123" query.session.launcher_id = 'launcher-123'
query.session.using_conversation = Mock() 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) # Mock use_funcs (empty list by default)
query.use_funcs = [] query.use_funcs = []
@@ -338,14 +398,14 @@ def mock_query_no_session():
"""Create a mock Query without session.""" """Create a mock Query without session."""
query = Mock() query = Mock()
query.query_id = 456 query.query_id = 456
query.bot_uuid = "bot-uuid-456" query.bot_uuid = 'bot-uuid-456'
query.pipeline_uuid = "pipeline-uuid-789" query.pipeline_uuid = 'pipeline-uuid-789'
query.launcher_type = Mock(value="person") query.launcher_type = Mock(value='person')
query.launcher_id = "launcher-456" query.launcher_id = 'launcher-456'
query.sender_id = "sender-456" query.sender_id = 'sender-456'
query.pipeline_config = { query.pipeline_config = {
"ai": { 'ai': {
"runner": "plugin:test/plugin/runner", 'runner': 'plugin:test/plugin/runner',
} }
} }
query.variables = {} query.variables = {}
@@ -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',
)
@@ -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'
@@ -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.binding_resolver import AgentBindingResolver
from langbot.pkg.agent.runner.session_registry import get_session_registry 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.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.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 entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events 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 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 @pytest.mark.asyncio
async def test_orchestrator_persists_run_ledger(clean_agent_state): async def test_orchestrator_persists_run_ledger(clean_agent_state):
"""AgentRunOrchestrator records Host-owned run and result events.""" """AgentRunOrchestrator records Host-owned run and result events."""
@@ -366,6 +366,36 @@ class TestMessageAggregatorAddMessage:
# Should have buffered the message # Should have buffered the message
assert len(agg.buffers) == 1 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 @pytest.mark.asyncio
async def test_max_buffer_flushes_immediately(self): async def test_max_buffer_flushes_immediately(self):
"""Reaching MAX_BUFFER_MESSAGES should flush immediately.""" """Reaching MAX_BUFFER_MESSAGES should flush immediately."""
@@ -164,6 +164,96 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
mock_stage.process.assert_called_once() 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): def test_runtime_pipeline_prefers_runner_mcp_resources(mock_app):
"""Runner resource selection should override extension preferences.""" """Runner resource selection should override extension preferences."""
pipelinemgr = get_pipelinemgr_module() pipelinemgr = get_pipelinemgr_module()
+23
View File
@@ -199,6 +199,29 @@ class TestQueryPoolAddQuery:
call_kwargs = MockQuery.call_args[1] call_kwargs = MockQuery.call_args[1]
assert call_kwargs['variables']['_routed_by_rule'] is True 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): async def test_add_query_notifier_condition(self):
"""add_query notifies waiting consumers.""" """add_query notifies waiting consumers."""
pool = QueryPool() pool = QueryPool()
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import pathlib import pathlib
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch 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.event_converter import DingTalkEventConverter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter 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.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.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 entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events 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.download_image = AsyncMock(return_value='data:image/png;base64,BBBB')
self.create_and_card = AsyncMock(return_value=('card', 'card-id')) self.create_and_card = AsyncMock(return_value=('card', 'card-id'))
self.send_card_message = AsyncMock() self.send_card_message = AsyncMock()
self.create_and_deliver_card = AsyncMock(return_value=True)
self.start = AsyncMock() self.start = AsyncMock()
self.stop = AsyncMock() self.stop = AsyncMock()
@@ -71,7 +74,7 @@ def manifest() -> dict:
/ 'dingtalk' / 'dingtalk'
/ 'manifest.yaml' / '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: def make_adapter() -> DingTalkAdapter:
@@ -84,6 +87,7 @@ def make_adapter() -> DingTalkAdapter:
'enable-stream-reply': False, 'enable-stream-reply': False,
'card_auto_layout': False, 'card_auto_layout': False,
'card_template_id': 'template-id', 'card_template_id': 'template-id',
'human_input_card_template_id': 'human-input-template-id',
} }
with patch('langbot.pkg.platform.adapters.dingtalk.adapter.DingTalkClient', DummyDingTalkClient): with patch('langbot.pkg.platform.adapters.dingtalk.adapter.DingTalkClient', DummyDingTalkClient):
return DingTalkAdapter(config, DummyLogger()) return DingTalkAdapter(config, DummyLogger())
@@ -157,6 +161,36 @@ def test_dingtalk_platform_api_map_matches_manifest():
assert set(PLATFORM_API_MAP) == manifest_actions 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 @pytest.mark.asyncio
async def test_dingtalk_message_converter_maps_outbound_components(): async def test_dingtalk_message_converter_maps_outbound_components():
content, at = await DingTalkMessageConverter.yiri2target( 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 token_status == {'valid': True}
assert file_url == {'url': 'https://example.test/file'} 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
@@ -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.event_converter import DiscordEventConverter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter 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.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.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 entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import events as platform_events
@@ -48,7 +53,7 @@ def manifest() -> dict:
/ 'discord' / 'discord'
/ 'manifest.yaml' / '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(): 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 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 @pytest.mark.asyncio
async def test_discord_adapter_dispatches_most_specific_eba_listener(): async def test_discord_adapter_dispatches_most_specific_eba_listener():
adapter = make_adapter() adapter = make_adapter()
@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import json
import pathlib import pathlib
import asyncio import asyncio
import time
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import yaml 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.event_converter import LarkEventConverter
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter 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.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.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 entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import events as platform_events
@@ -66,6 +73,7 @@ class DummyAPIClient:
message=SimpleNamespace( message=SimpleNamespace(
acreate=AsyncMock(return_value=DummyResponse()), acreate=AsyncMock(return_value=DummyResponse()),
areply=AsyncMock(return_value=DummyResponse()), areply=AsyncMock(return_value=DummyResponse()),
aupdate=AsyncMock(return_value=DummyResponse()),
aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(items=[]))), aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(items=[]))),
), ),
chat=SimpleNamespace( chat=SimpleNamespace(
@@ -89,8 +97,12 @@ class DummyAPIClient:
) )
self.cardkit = SimpleNamespace( self.cardkit = SimpleNamespace(
v1=SimpleNamespace( v1=SimpleNamespace(
card=SimpleNamespace(create=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id')))), card=SimpleNamespace(
card_element=SimpleNamespace(content=AsyncMock(return_value=DummyResponse())), 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' / 'lark'
/ 'manifest.yaml' / '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: 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): with pytest.raises(asyncio.CancelledError):
await task await task
webhook_adapter.bot._connect.assert_not_awaited() 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 == '✅ CommentLooks 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 '✅ Question123' 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 '✅ Actionor' in final_content
assert '✅ Question123' 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✅ xiala1',
]
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 / 已提交'}}
@@ -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.event_converter import QQOfficialEventConverter
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter 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.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.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 entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events 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.access_token_expiry_time = None
self.handle_unified_webhook = AsyncMock(return_value='success') self.handle_unified_webhook = AsyncMock(return_value='success')
self.connect_gateway_loop = AsyncMock() 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 on_message(self, msg_type: str):
def decorator(func): def decorator(func):
@@ -57,6 +64,13 @@ class DummyQQOfficialClient:
return decorator return decorator
def on_interaction(self):
def decorator(func):
self._interaction_handler = func
return func
return decorator
async def check_access_token(self): async def check_access_token(self):
return bool(self.access_token) return bool(self.access_token)
@@ -111,7 +125,7 @@ def manifest() -> dict:
/ 'qqofficial' / 'qqofficial'
/ 'manifest.yaml' / '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: 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 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 @pytest.mark.asyncio
async def test_qqofficial_message_converter_maps_common_components_to_send_payloads(): async def test_qqofficial_message_converter_maps_common_components_to_send_payloads():
payload = await QQOfficialMessageConverter.yiri2target( payload = await QQOfficialMessageConverter.yiri2target(
+220 -2
View File
@@ -73,7 +73,7 @@ class TestEventRouteTrace:
captured_envelopes = [] captured_envelopes = []
async def fake_run(envelope, binding): async def fake_run(envelope, binding, adapter_context=None):
captured_envelopes.append(envelope) captured_envelopes.append(envelope)
yield provider_message.Message(role='assistant', content='test response') yield provider_message.Message(role='assistant', content='test response')
@@ -157,7 +157,7 @@ class TestEventRouteTrace:
} }
runner_calls = [] runner_calls = []
async def fake_run(envelope, binding): async def fake_run(envelope, binding, adapter_context=None):
runner_calls.append((envelope, binding)) runner_calls.append((envelope, binding))
if False: if False:
yield None yield None
@@ -378,6 +378,53 @@ class TestRuntimeBotLifecycle:
bot.adapter.kill.assert_awaited_once() bot.adapter.kill.assert_awaited_once()
task_mgr.cancel_task.assert_not_called() 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: class TestEBAEventBindings:
"""Test RuntimeBot EBA event binding helpers.""" """Test RuntimeBot EBA event binding helpers."""
@@ -482,8 +529,11 @@ class TestEBAEventBindings:
assert binding.resource_policy.allowed_tool_names is None assert binding.resource_policy.allowed_tool_names is None
assert binding.delivery_policy.enable_streaming is False assert binding.delivery_policy.enable_streaming is False
assert binding.delivery_policy.enable_reply is True 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.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner']
assert binding.agent_id == 'agent-1' 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): def test_agent_product_to_binding_projects_selected_tool_policy(self):
"""Independent Agents use the same standard runner resource fields as Pipelines.""" """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_tool_names == ['exec', 'plugin_tool']
assert binding.resource_policy.allowed_kb_uuids == ['kb-1'] 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): 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.""" """An empty config runner stays unconfigured even if component_ref is stale."""
from langbot.pkg.platform.botmgr import RuntimeBot from langbot.pkg.platform.botmgr import RuntimeBot
@@ -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.event_converter import TelegramEventConverter
from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP 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.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.pkg.platform.botmgr import RuntimeBot
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger 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 entities as platform_entities
@@ -152,13 +156,159 @@ def test_telegram_supported_events_match_manifest():
/ 'telegram' / 'telegram'
/ 'manifest.yaml' / '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 adapter_events == manifest_events
assert 'message.deleted' not in adapter_events assert 'message.deleted' not in adapter_events
assert 'group.info_updated' 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 @pytest.mark.asyncio
async def test_telegram_converter_maps_message_and_edited_message_events(): async def test_telegram_converter_maps_message_and_edited_message_events():
update = make_update({'message': base_message_payload(text='hello @test_bot')}) 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' 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 @pytest.mark.asyncio
async def test_telegram_converter_maps_non_message_updates(): async def test_telegram_converter_maps_non_message_updates():
chat_member = make_update( chat_member = make_update(
@@ -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.event_converter import WecomBotEventConverter
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter 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.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.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 entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events 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.reply_text = AsyncMock(return_value={'reply': True})
self.push_stream_chunk = AsyncMock(return_value=True) self.push_stream_chunk = AsyncMock(return_value=True)
self.set_message = AsyncMock(return_value={'set': 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 on_message(self, msg_type: str):
def decorator(func): def decorator(func):
@@ -82,7 +87,7 @@ def manifest() -> dict:
/ 'wecombot' / 'wecombot'
/ 'manifest.yaml' / '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: 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 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 @pytest.mark.asyncio
async def test_wecombot_message_converter_maps_outbound_components_to_markdown_text(): async def test_wecombot_message_converter_maps_outbound_components_to_markdown_text():
content = await WecomBotMessageConverter.yiri2target( 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(): async def test_wecombot_webhook_mode_rejects_proactive_send():
adapter = make_adapter(enable_webhook=True) adapter = make_adapter(enable_webhook=True)
with pytest.raises(NotSupportedError): 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')])
)
@@ -6,6 +6,7 @@ import {
PipelineConfigStage, PipelineConfigStage,
} from '@/app/infra/entities/pipeline'; } from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; 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 { Button } from '@/components/ui/button';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -350,10 +351,38 @@ export default function PipelineFormComponent({
const currentValues = const currentValues =
(form.getValues(formName) as Record<string, unknown>) || {}; (form.getValues(formName) as Record<string, unknown>) || {};
form.setValue(formName, { const nextValues: Record<string, unknown> = {
...currentValues, ...currentValues,
[stageName]: values, [stageName]: values,
}); };
if (formName === 'ai' && stageName === 'runner') {
const selectedRunner = (values as Record<string, unknown>).id;
const runnerConfigs =
currentValues.runner_config &&
typeof currentValues.runner_config === 'object' &&
!Array.isArray(currentValues.runner_config)
? (currentValues.runner_config as Record<string, unknown>)
: {};
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) { if (isFirstEmission) {
initializedStagesRef.current.add(stageKey); initializedStagesRef.current.add(stageKey);