mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 13:26:08 +00:00
feat(agent): integrate structured runner interactions
This commit is contained in:
@@ -103,6 +103,7 @@ class AgentRunnerCapabilities(BaseModel):
|
||||
skill_authoring: bool = False
|
||||
interrupt: bool = False
|
||||
steering: bool = False
|
||||
interactions: bool = False
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
```
|
||||
@@ -114,6 +115,7 @@ class AgentRunnerCapabilities(BaseModel):
|
||||
- `skill_authoring`:(降级为便捷开关,非访问硬前提)声明该 runner 期望使用 LangBot skill 工具链。skill 本身通过**统一 tool 授权**获得——发现走 `list_skills` / `langbot_list_assets`,激活/注册走 `activate` / `register_skill`,操作走 native exec/read/write,全部计入 `resource_policy.allowed_tool_names`。该 capability 仅作为「一键授权这组 skill tool + sandbox」的便捷开关,不再单独决定 skill 是否可用。
|
||||
- `interrupt`: runner 支持取消或中断。
|
||||
- `steering`: runner 支持在 turn 边界通过 Host pull API 消费同 conversation 在途追加消息。
|
||||
- `interactions`: runner 可以请求 Host 展示结构化交互,并处理后续 `interaction.submitted` 事件。
|
||||
|
||||
Capabilities 字段全部是 `bool`,未知 key 禁止进入 typed manifest。早期草案里的上下文/会话类 capability 已删除;对应语义由 event-first context 和 runner-owned context 原则表达。
|
||||
|
||||
@@ -128,11 +130,15 @@ class AgentRunnerPermissions(BaseModel):
|
||||
events: list[Literal["get", "page"]] = []
|
||||
storage: list[Literal["plugin", "workspace"]] = []
|
||||
files: list[Literal["config", "knowledge"]] = []
|
||||
interactions: list[Literal["request"]] = []
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
```
|
||||
|
||||
平台动作执行不属于当前 permissions。Platform action executor / EBA action 分支落地前,runner 只能返回 `action.requested` telemetry,Host 不执行平台动作。
|
||||
通用交互投递是当前唯一允许执行的 `action.requested` 白名单动作。Runner 必须同时声明
|
||||
`capabilities.interactions=true` 和 `permissions.interactions=["request"]`,Host 还必须将其与
|
||||
当前 binding delivery policy、run authorization snapshot 和 adapter delivery capability 求交。
|
||||
其它平台动作仍不属于当前 permissions,Host 收到后只记录 telemetry,不得执行。
|
||||
|
||||
Runner 实际可用 LangBot 资源来自 Host 在 run 前冻结的授权快照:
|
||||
|
||||
@@ -275,11 +281,13 @@ class AgentInput(BaseModel):
|
||||
text: str | None = None
|
||||
contents: list[ContentElement] = []
|
||||
attachments: list[InputAttachment] = []
|
||||
interaction: InteractionSubmission | None = None
|
||||
```
|
||||
|
||||
- 文本、多模态、附件都属于当前 event input。
|
||||
- 大文件、图片、音频、工具大结果应进入授权 sandbox/workspace,input attachment 只携带轻量 metadata/path/url/content。
|
||||
- 平台原始消息链不属于 SDK `AgentInput`;需要诊断时放在 Host 内部 envelope 或 `ctx.adapter.extra` 的一次性兼容字段中,不作为长期 runner 合同。
|
||||
- `interaction` 只在 `event.event_type == "interaction.submitted"` 时出现,承载经过 Host 校验和归一化的用户提交;平台原始 callback payload 不进入该字段。
|
||||
|
||||
### 5.7 DeliveryContext
|
||||
|
||||
@@ -291,17 +299,80 @@ class DeliveryContext(BaseModel):
|
||||
supports_edit: bool = False
|
||||
supports_reaction: bool = False
|
||||
max_message_size: int | None = None
|
||||
interactions: InteractionDeliveryCapabilities | None = None
|
||||
platform_capabilities: dict[str, Any] = {}
|
||||
```
|
||||
|
||||
Runner 可参考 delivery 能力决定返回 `message.delta`、`message.completed` 或 `action.requested`。
|
||||
Runner 可参考 delivery 能力决定返回 `message.delta`、`message.completed` 或交互请求。
|
||||
`interactions is not None` 表示当前 surface 可以投递结构化交互;其中的 field/action 能力
|
||||
决定 Host 可原生渲染的范围。Runner 仍必须提供 `fallback_text`,供不支持富交互或投递失败时降级。
|
||||
平台事件进入独立 Agent 时,Host 会从当前 adapter 的 `get_supported_apis()` 投影
|
||||
`supports_edit`、`supports_reaction`,并把去重后的 API 名称写入
|
||||
`platform_capabilities.supported_apis`。这些字段只描述当前投递表面的能力,不授予
|
||||
平台动作权限;`action.requested` 仍受 §7.3 的 reserved 约束。合成路由测试使用的
|
||||
任意平台动作权限;交互请求按 §5.8 和 §7.3 的白名单约束执行。合成路由测试使用的
|
||||
adapter 会过滤所有已知副作用 API,因此测试事件不会向 runner 宣告真实出站能力。
|
||||
|
||||
### 5.8 ContextAccess
|
||||
### 5.8 Structured Interaction
|
||||
|
||||
```python
|
||||
InteractionFieldType = Literal[
|
||||
"text", "textarea", "select", "multiselect", "number", "boolean", "file"
|
||||
]
|
||||
|
||||
class InteractionOption(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
|
||||
class InteractionField(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
type: InteractionFieldType
|
||||
required: bool = False
|
||||
options: list[InteractionOption] = []
|
||||
placeholder: str | None = None
|
||||
default: JSONValue | None = None
|
||||
|
||||
class InteractionAction(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
style: Literal["default", "primary", "danger"] = "default"
|
||||
|
||||
class InteractionRequest(BaseModel):
|
||||
interaction_id: str
|
||||
kind: Literal["form", "confirmation", "choice"] = "form"
|
||||
title: str
|
||||
description: str | None = None
|
||||
fields: list[InteractionField] = []
|
||||
actions: list[InteractionAction] = []
|
||||
expires_at: int | None = None
|
||||
fallback_text: str
|
||||
|
||||
class InteractionSubmission(BaseModel):
|
||||
interaction_id: str
|
||||
action_id: str | None = None
|
||||
values: dict[str, JSONValue] = {}
|
||||
submitted_at: int | None = None
|
||||
|
||||
class InteractionDeliveryCapabilities(BaseModel):
|
||||
field_types: list[InteractionFieldType] = []
|
||||
action_styles: list[Literal["default", "primary", "danger"]] = []
|
||||
supports_updates: bool = False
|
||||
max_fields: int | None = None
|
||||
```
|
||||
|
||||
Runner 使用 `AgentRunResult.interaction_requested()` 生成
|
||||
`action.requested(action="interaction.requested")`。Host 只能把请求投递到当前 run 冻结的
|
||||
delivery target,Runner 不得通过 `target` 改写 bot、conversation 或用户。Host 为请求保存
|
||||
`interaction_id -> processor/binding/conversation/expiry` 关联;平台 callback 必须先经过签名、
|
||||
目标、过期和幂等校验,再生成 `interaction.submitted` 事件。
|
||||
|
||||
Provider 私有 continuation token、workflow id、凭据或原始 callback payload 不属于该协议。
|
||||
Runner 应把这些值保存到受授权的 Host state 或 plugin storage,并只用 `interaction_id` 关联提交。
|
||||
Host 必须使用服务器接收时间判断 callback 是否过期,并覆盖 `InteractionSubmission.submitted_at`;
|
||||
平台事件时间只可作为原始事件诊断信息,不能控制 TTL 或进入 Runner 的可信提交字段。
|
||||
|
||||
### 5.9 ContextAccess
|
||||
|
||||
```python
|
||||
class ContextAccess(BaseModel):
|
||||
@@ -463,13 +534,20 @@ Runner 生成的大文件、工具输出和临时产物不通过 result event
|
||||
| `tool.call.started` | 工具调用开始的可观测事件。 | telemetry |
|
||||
| `tool.call.completed` | 工具调用完成的可观测事件。 | telemetry |
|
||||
| `state.updated` | runner 请求更新 host-owned state。 | ✅ |
|
||||
| `action.requested` | runner 请求 Host 执行平台动作。 | **reserved / 仅 telemetry,不执行** |
|
||||
| `action.requested` | runner 请求 Host 执行受控动作。 | `interaction.requested` 可执行;其它动作仅 telemetry |
|
||||
| `run.completed` | run 正常结束。 | ✅ |
|
||||
| `run.failed` | run 失败。 | ✅ |
|
||||
|
||||
`action.requested` 是为 EBA 和 platform API 保留的协议表面:本分支 Host 收到后只记 telemetry,**不执行**,runner 作者不应在当前 Host 底座中依赖其副作用。真实执行器由外部 EBA / platform action 分支接入;执行模型见 EVENT_BASED_AGENT §6。
|
||||
`action.requested` 仍是严格白名单协议表面。当前 Host 只执行
|
||||
`action="interaction.requested"`,并要求 payload 可校验为 `InteractionRequest`、Runner 声明
|
||||
interaction capability/permission、当前 binding 允许交互且 delivery surface 支持交互。
|
||||
Host 忽略 Runner 提供的外部 target,只使用 run authorization snapshot 中冻结的 delivery target。
|
||||
其它 action 只记 telemetry,不执行;通用 platform action executor 仍不属于本协议。
|
||||
|
||||
Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。本分支 `action.requested` 仍只记录 telemetry。
|
||||
Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序列化性。Host 还必须限制
|
||||
交互字段/动作数量、字符串长度、payload 总大小、过期时间和 interaction id 唯一性,并对 callback
|
||||
应用默认 30 分钟、最长 24 小时的有效期;request 与 submission payload 均不得超过 256 KiB。
|
||||
执行目标绑定、签名、幂等和过期校验。
|
||||
|
||||
除 runner 经 `state.updated` 写之外,Host 自身也可直接写部分 host-owned state。例如 `activate` tool 在 Host 侧执行时,直接把已激活 skill 写入 conversation scope 的 `host.activated_skills` 快照。当 host 直接写与 runner `state.updated` 写到同一 key 时,按 **last-write-wins** 合并——runner 可以覆盖 host 写的快照。
|
||||
|
||||
@@ -487,7 +565,7 @@ Host 必须校验 `state.updated` 的 scope、key、value 大小和 JSON 可序
|
||||
{ "type": "message.delta", "data": { "chunk": { "role": "assistant", "content": "hel" } } }
|
||||
{ "type": "message.completed", "data": { "message": { "role": "assistant", "content": "hello" } } }
|
||||
{ "type": "state.updated", "data": { "scope": "conversation", "key": "external.session_id", "value": "abc" } }
|
||||
{ "type": "action.requested", "data": { "action": "message.edit", "target": {"message_id": "..."}, "payload": {"text": "..."} } }
|
||||
{ "type": "action.requested", "data": { "action": "interaction.requested", "payload": { "interaction_id": "form_1", "kind": "choice", "title": "Approve?", "actions": [{"id": "approve", "label": "Approve", "style": "primary"}], "fallback_text": "Reply approve or reject." } } }
|
||||
```
|
||||
|
||||
## 8. AgentRunAPIProxy
|
||||
|
||||
@@ -21,11 +21,12 @@
|
||||
| Steering control path | Done | claim 异常不再逃逸 consumer loop;queue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 |
|
||||
| SDK v1 contract closure | Done | SDK 提供 `AgentAPIError` / `AgentAPIException`、typed `SteeringPullResult`、未知 result type 宽容解析、result `sequence` 注入与取消传播。 |
|
||||
| EBA processor routing | Done; release gate 5/5 pass | Bot `event_bindings`、Pipeline / Agent 平级路由、WebUI dry-run / 合成测试 / 状态、OneBot 非消息事件到 Agent 及平台回复已闭环;隔离空白实例已验证从 Space 安装并注册 LocalAgent。 |
|
||||
| Structured interactions | Cross-repo unit-pass; provider E2E pending | Host 已完成 `interaction.requested` 白名单、持久化 callback correlation、TTL/作用域/幂等校验和 Pipeline/Agent 原处理器恢复;六个平台已接入按钮/单选投递,Lark 和 DingTalk 进一步支持原生单字段 `text` / `textarea` / `number` / `select` 控件。SDK typed contract、通用 Runner 脚手架和 DifyAgent `workflow_paused` plugin-storage continuation 已落入对应仓库。 |
|
||||
|
||||
## Spec 与实现已知差距
|
||||
|
||||
- `action.requested` 仍只作为 telemetry / reserved surface;platform action executor 不在本分支执行。
|
||||
- `action.requested` 权限模型完成前,DeliveryContext 的 adapter capability 投影只用于输出决策,不提供平台动作执行权限。
|
||||
- `action.requested` 是严格白名单协议面:当前只执行 `interaction.requested`;其它 action 仍只记录 telemetry,不提供通用 platform action executor。
|
||||
- 结构化交互 SDK typed contract 与 DifyAgent continuation 已实现;SDK 正式发布、真实 Dify 凭据 E2E,以及需要长驻双向进程的 Claude Code 权限确认仍是后续验收项。Host 不持有 provider 私有 token。
|
||||
- State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。
|
||||
- `ToolResource.parameters` 已作为 best-effort full schema 由 Host 在构造 `ctx.resources` 时一次塞齐;无 schema 时 runner 仍需兼容 `parameters=None` 或按需调用 detail API。
|
||||
- EventLog / Transcript 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和 sandbox/workspace 文件清理仍是运维收尾项,应在 Runtime Control Plane 产品化前补齐。
|
||||
@@ -40,7 +41,8 @@
|
||||
| `plugin:langbot-team/LocalAgent/default` | Unit-pass; Marketplace UI pass; Debug Chat E2E pass | 2026-07-12 隔离 first-run 实例从真实 AgentRunner catalog 安装 `langbot-team/LocalAgent` 0.1.0,Host 注册 `plugin:langbot-team/LocalAgent/default`,Wizard 自动选中并解锁后续操作。2026-07-15 `2026-07-15-08-44-10-770-08-00-sandbox-skill-authoring-edit-existing-e2e` 使用真实 `gpt-5.5` 完成 Skill 创建、注册、同 Query 激活、已激活包编辑与脚本执行;三阶段 UI、浏览器诊断和结构化文件系统检查全部通过,每阶段恰好新增一个 Bot 气泡,p95 14.6 秒、错误率 0。 |
|
||||
| `plugin:langbot-team/ACPAgentRunner/default` | Unit-pass; Debug Chat E2E pass | 2026-07-15 从本地 0.1.4 发布包安装并注册 PascalCase runner,remote-ssh Claude ACP 通过反向隧道调用 run-scoped `langbot_get_current_event`,97.8 秒返回可见结果;Host 将增量 delta 和 `message.completed` 聚合为一个完整 Bot 气泡。 |
|
||||
| `plugin:langbot-team/ClaudeCodeAgent/default` / `plugin:langbot-team/CodexAgent/default` | Unit-pass; E2E pending | 通过 runner 仓库单测覆盖 session、run_id 注入和 LangBot MCP gateway;真实 harness E2E 取决于对应运行环境、CLI/daemon 可用性和 provider 登录态。 |
|
||||
| Dify / n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 |
|
||||
| Dify | Human-input unit-pass; credential E2E pending | `langbot-agent-runner/dify-agent` 已实现 `workflow_paused`、原子字段/确认交互、plugin-storage continuation、Dify submit/events 恢复与再次暂停;真实 Dify 凭据 E2E 待执行。 |
|
||||
| n8n / Coze / DashScope / Langflow / Tbox / DeerFlow / WeKnora | Unit-pass; credential smoke optional | 2026-06-13 plugin layout / parser tests 通过;真实服务凭据 smoke 非每轮必跑。 |
|
||||
|
||||
## Host / SDK 验收状态
|
||||
|
||||
|
||||
@@ -721,7 +721,6 @@ class QQOfficialClient:
|
||||
"""
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
if target_type == 'c2c':
|
||||
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||
elif target_type == 'group':
|
||||
@@ -730,7 +729,6 @@ class QQOfficialClient:
|
||||
url = f'{self.base_url}/channels/{target_id}/messages'
|
||||
else:
|
||||
raise ValueError(f'Unsupported target_type for markdown+keyboard: {target_type}')
|
||||
|
||||
body: dict[str, Any] = {
|
||||
'msg_type': 2,
|
||||
'markdown': {'content': markdown_content},
|
||||
|
||||
@@ -430,6 +430,8 @@ class WecomBotWsClient:
|
||||
card_payload: ``{"msgtype": "template_card", "template_card": {...}}``
|
||||
as produced by :func:`build_button_interaction_payload`.
|
||||
"""
|
||||
if card_payload.get('msgtype') != 'template_card' or not isinstance(card_payload.get('template_card'), dict):
|
||||
raise ValueError('invalid WeCom template_card payload')
|
||||
req_id = _generate_req_id(CMD_SEND_MSG)
|
||||
body = dict(card_payload)
|
||||
body['chatid'] = chat_id
|
||||
|
||||
@@ -30,16 +30,10 @@ class AgentBindingResolver:
|
||||
Agent and does not carry enough scope metadata to make that decision
|
||||
safely here.
|
||||
"""
|
||||
matches = [
|
||||
agent
|
||||
for agent in agents
|
||||
if agent.enabled and event.event_type in agent.event_types
|
||||
]
|
||||
matches = [agent for agent in agents if agent.enabled and event.event_type in agent.event_types]
|
||||
|
||||
if not matches:
|
||||
raise AgentBindingResolutionError(
|
||||
f'No Agent binding matches event_type={event.event_type}'
|
||||
)
|
||||
raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}')
|
||||
|
||||
if len(matches) > 1:
|
||||
agent_ids = ', '.join(agent.agent_id or '<anonymous>' for agent in matches)
|
||||
@@ -57,7 +51,7 @@ class AgentBindingResolver:
|
||||
)
|
||||
|
||||
return AgentBinding(
|
||||
binding_id=f"agent_{agent.agent_id or 'default'}_{agent.runner_id}",
|
||||
binding_id=f'agent_{agent.agent_id or "default"}_{agent.runner_id}',
|
||||
scope=scope,
|
||||
event_types=list(agent.event_types),
|
||||
runner_id=agent.runner_id,
|
||||
@@ -67,4 +61,6 @@ class AgentBindingResolver:
|
||||
delivery_policy=agent.delivery_policy,
|
||||
enabled=agent.enabled,
|
||||
agent_id=agent.agent_id,
|
||||
processor_type=agent.processor_type,
|
||||
processor_id=agent.processor_id or agent.agent_id,
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@ class AgentInput(typing.TypedDict):
|
||||
text: str | None
|
||||
contents: list[dict[str, typing.Any]]
|
||||
attachments: list[dict[str, typing.Any]]
|
||||
interaction: typing.NotRequired[dict[str, typing.Any] | None]
|
||||
|
||||
|
||||
class AgentRunState(typing.TypedDict):
|
||||
@@ -299,6 +300,11 @@ class AgentRunContextBuilder:
|
||||
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
|
||||
],
|
||||
}
|
||||
if getattr(event.input, 'interaction', None) is not None:
|
||||
interaction = event.input.interaction
|
||||
input['interaction'] = (
|
||||
interaction.model_dump(mode='json') if hasattr(interaction, 'model_dump') else interaction
|
||||
)
|
||||
|
||||
# Build context access (no history inlined by default for Protocol v1)
|
||||
# Populate with actual values from stores
|
||||
@@ -333,6 +339,11 @@ class AgentRunContextBuilder:
|
||||
'max_message_size': event.delivery.max_message_size,
|
||||
'platform_capabilities': event.delivery.platform_capabilities,
|
||||
}
|
||||
if getattr(event.delivery, 'interactions', None) is not None:
|
||||
interactions = event.delivery.interactions
|
||||
delivery_context['interactions'] = (
|
||||
interactions.model_dump(mode='json') if hasattr(interactions, 'model_dump') else interactions
|
||||
)
|
||||
|
||||
# Build adapter context (empty for event-first)
|
||||
adapter_context = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Canonical AgentRunner event names reserved for future EBA integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -14,6 +15,9 @@ GROUP_MEMBER_JOINED = 'group.member_joined'
|
||||
FRIEND_REQUEST_RECEIVED = 'friend.request_received'
|
||||
"""A new friend/contact request was received."""
|
||||
|
||||
INTERACTION_SUBMITTED = 'interaction.submitted'
|
||||
"""A user submitted a Host-rendered structured interaction."""
|
||||
|
||||
|
||||
RESERVED_EVENT_TYPES = frozenset(
|
||||
{
|
||||
@@ -21,5 +25,6 @@ RESERVED_EVENT_TYPES = frozenset(
|
||||
MESSAGE_RECALLED,
|
||||
GROUP_MEMBER_JOINED,
|
||||
FRIEND_REQUEST_RECEIVED,
|
||||
INTERACTION_SUBMITTED,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -139,6 +139,9 @@ class DeliveryPolicy(pydantic.BaseModel):
|
||||
enable_reply: bool = True
|
||||
"""Whether reply is enabled."""
|
||||
|
||||
enable_interactions: bool = False
|
||||
"""Whether the binding permits structured interaction delivery."""
|
||||
|
||||
max_message_size: int | None = None
|
||||
"""Maximum message size."""
|
||||
|
||||
@@ -154,6 +157,12 @@ class AgentConfig(pydantic.BaseModel):
|
||||
agent_id: str | None = None
|
||||
"""Host-side Agent/config identifier."""
|
||||
|
||||
processor_type: typing.Literal['agent', 'pipeline'] = 'agent'
|
||||
"""Product processor kind represented by this runtime config."""
|
||||
|
||||
processor_id: str | None = None
|
||||
"""Stable product processor identifier."""
|
||||
|
||||
runner_id: str
|
||||
"""Runner ID to invoke."""
|
||||
|
||||
@@ -215,3 +224,9 @@ class AgentBinding(pydantic.BaseModel):
|
||||
|
||||
agent_id: str | None = None
|
||||
"""Host-side Agent/config identifier for this binding."""
|
||||
|
||||
processor_type: typing.Literal['agent', 'pipeline'] = 'agent'
|
||||
"""Product processor kind selected for this binding."""
|
||||
|
||||
processor_id: str | None = None
|
||||
"""Stable product processor identifier used for callback resume."""
|
||||
|
||||
@@ -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 .invoker import AgentRunnerInvoker
|
||||
from .interaction_manager import InteractionManager
|
||||
from .query_bridge import QueryRunBridge
|
||||
from .registry import AgentRunnerRegistry
|
||||
from .resource_builder import AgentResourceBuilder
|
||||
@@ -51,6 +52,7 @@ class AgentRunOrchestrator:
|
||||
binding_resolver: AgentBindingResolver
|
||||
query_bridge: QueryRunBridge
|
||||
invoker: AgentRunnerInvoker
|
||||
interaction_manager: InteractionManager
|
||||
journal: AgentRunJournal
|
||||
_session_registry: AgentRunSessionRegistry
|
||||
|
||||
@@ -67,6 +69,7 @@ class AgentRunOrchestrator:
|
||||
self.binding_resolver = AgentBindingResolver()
|
||||
self.query_bridge = QueryRunBridge(self.binding_resolver)
|
||||
self.invoker = AgentRunnerInvoker(ap)
|
||||
self.interaction_manager = InteractionManager(ap)
|
||||
self.journal = AgentRunJournal(ap)
|
||||
self._session_registry = get_session_registry()
|
||||
|
||||
@@ -130,6 +133,8 @@ class AgentRunOrchestrator:
|
||||
run_authorization = {
|
||||
'runner_id': descriptor.id,
|
||||
'binding_id': binding.binding_id,
|
||||
'processor_type': binding.processor_type,
|
||||
'processor_id': binding.processor_id,
|
||||
'plugin_identity': descriptor.get_plugin_id(),
|
||||
'resources': resources,
|
||||
'available_apis': available_apis,
|
||||
@@ -252,6 +257,17 @@ class AgentRunOrchestrator:
|
||||
await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
continue
|
||||
|
||||
if result_type == 'action.requested':
|
||||
if await self.interaction_manager.handle_result(
|
||||
result_dict=result_dict,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
run_id=run_id,
|
||||
adapter_context=adapter_context,
|
||||
):
|
||||
continue
|
||||
|
||||
if result_type == 'run.completed':
|
||||
terminal_status = 'completed'
|
||||
terminal_reason = (
|
||||
|
||||
@@ -7,6 +7,7 @@ Protocol v1 architecture without exposing Query internals to runners.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
@@ -129,16 +130,25 @@ class QueryEntryAdapter:
|
||||
delivery_policy = DeliveryPolicy(
|
||||
enable_streaming=True,
|
||||
enable_reply=True,
|
||||
enable_interactions=True,
|
||||
)
|
||||
variables = getattr(query, 'variables', None) or {}
|
||||
event_type = (
|
||||
runner_events.INTERACTION_SUBMITTED
|
||||
if isinstance(variables.get('_interaction_submission'), dict)
|
||||
else runner_events.MESSAGE_RECEIVED
|
||||
)
|
||||
|
||||
return AgentConfig(
|
||||
agent_id=agent_id,
|
||||
processor_type='pipeline',
|
||||
processor_id=agent_id,
|
||||
runner_id=runner_id,
|
||||
runner_config=runner_config,
|
||||
resource_policy=resource_policy,
|
||||
state_policy=state_policy,
|
||||
delivery_policy=delivery_policy,
|
||||
event_types=[runner_events.MESSAGE_RECEIVED],
|
||||
event_types=[event_type],
|
||||
enabled=True,
|
||||
metadata={'source': 'pipeline_adapter'},
|
||||
)
|
||||
@@ -217,22 +227,37 @@ class QueryEntryAdapter:
|
||||
if message_id == -1:
|
||||
message_id = None
|
||||
|
||||
event_time = None
|
||||
if message_event:
|
||||
event_time = getattr(message_event, 'time', None)
|
||||
if isinstance(event_time, (int, float)):
|
||||
event_time = int(event_time)
|
||||
event_time = cls._normalize_event_time(getattr(message_event, 'time', None) if message_event else None)
|
||||
|
||||
variables = getattr(query, 'variables', None) or {}
|
||||
interaction = variables.get('_interaction_submission')
|
||||
event_type = runner_events.MESSAGE_RECEIVED
|
||||
if isinstance(interaction, dict):
|
||||
event_type = runner_events.INTERACTION_SUBMITTED
|
||||
event_data['interaction'] = interaction
|
||||
|
||||
source_event_id = str(message_id or query.query_id)
|
||||
return AgentEventContext(
|
||||
event_id=cls._build_scoped_event_id(query, source_event_id, event_time),
|
||||
event_type=runner_events.MESSAGE_RECEIVED,
|
||||
event_type=event_type,
|
||||
event_time=event_time,
|
||||
source='host_adapter',
|
||||
source_event_type=source_event_type,
|
||||
data=event_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_event_time(value: typing.Any) -> int | None:
|
||||
"""Normalize legacy second/millisecond/microsecond Unix timestamps."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
timestamp = float(value)
|
||||
if not math.isfinite(timestamp):
|
||||
return None
|
||||
while abs(timestamp) > 10_000_000_000:
|
||||
timestamp /= 1000
|
||||
return int(timestamp)
|
||||
|
||||
@classmethod
|
||||
def _compact_event_data(
|
||||
cls,
|
||||
@@ -370,6 +395,15 @@ class QueryEntryAdapter:
|
||||
if launcher_type is not None:
|
||||
launcher_type_value = getattr(launcher_type, 'value', launcher_type)
|
||||
|
||||
variables = getattr(query, 'variables', None) or {}
|
||||
interaction = variables.get('_interaction_submission')
|
||||
if isinstance(interaction, dict):
|
||||
return SubjectContext(
|
||||
subject_type='interaction',
|
||||
subject_id=str(interaction.get('interaction_id') or ''),
|
||||
data={'action_id': interaction.get('action_id')},
|
||||
)
|
||||
|
||||
return SubjectContext(
|
||||
subject_type='message',
|
||||
subject_id=str(message_id or query_id or ''),
|
||||
@@ -435,11 +469,16 @@ class QueryEntryAdapter:
|
||||
|
||||
attachments = cls._build_attachments(query, contents)
|
||||
|
||||
return AgentInput(
|
||||
text=text,
|
||||
contents=contents,
|
||||
attachments=attachments,
|
||||
)
|
||||
input_data: dict[str, typing.Any] = {
|
||||
'text': text,
|
||||
'contents': contents,
|
||||
'attachments': attachments,
|
||||
}
|
||||
variables = getattr(query, 'variables', None) or {}
|
||||
interaction = variables.get('_interaction_submission')
|
||||
if isinstance(interaction, dict) and 'interaction' in AgentInput.model_fields:
|
||||
input_data['interaction'] = interaction
|
||||
return AgentInput.model_validate(input_data)
|
||||
|
||||
@classmethod
|
||||
def _build_attachments(
|
||||
@@ -570,16 +609,42 @@ class QueryEntryAdapter:
|
||||
) -> DeliveryContext:
|
||||
"""Build DeliveryContext from Query."""
|
||||
message_chain = getattr(query, 'message_chain', None)
|
||||
return DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target={
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
target_type = getattr(launcher_type, 'value', launcher_type)
|
||||
target_id = getattr(query, 'launcher_id', None)
|
||||
adapter = getattr(query, 'adapter', None)
|
||||
supported_apis: list[str] = []
|
||||
get_supported_apis = getattr(adapter, 'get_supported_apis', None)
|
||||
if callable(get_supported_apis):
|
||||
try:
|
||||
declared_apis = get_supported_apis()
|
||||
if isinstance(declared_apis, (list, tuple, set)):
|
||||
supported_apis = list(dict.fromkeys(api for api in declared_apis if isinstance(api, str) and api))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
delivery_data: dict[str, typing.Any] = {
|
||||
'surface': 'platform',
|
||||
'reply_target': {
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'message_id': getattr(message_chain, 'message_id', None),
|
||||
},
|
||||
supports_streaming=True,
|
||||
supports_edit=False,
|
||||
supports_reaction=False,
|
||||
platform_capabilities={},
|
||||
)
|
||||
'supports_streaming': True,
|
||||
'supports_edit': 'edit_message' in supported_apis,
|
||||
'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
|
||||
'platform_capabilities': {
|
||||
'adapter': adapter.__class__.__name__ if adapter is not None else None,
|
||||
'supported_apis': supported_apis,
|
||||
},
|
||||
}
|
||||
if 'interactions' in DeliveryContext.model_fields and 'interaction.request' in supported_apis:
|
||||
get_capabilities = getattr(adapter, 'get_interaction_capabilities', None)
|
||||
if callable(get_capabilities):
|
||||
capabilities = get_capabilities()
|
||||
if isinstance(capabilities, dict):
|
||||
delivery_data['interactions'] = capabilities
|
||||
return DeliveryContext.model_validate(delivery_data)
|
||||
|
||||
@classmethod
|
||||
def _build_raw_ref(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Agent result normalizer for converting AgentRunResult to Pipeline messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
@@ -57,7 +58,7 @@ class AgentResultNormalizer:
|
||||
- state.updated
|
||||
- run.completed
|
||||
- run.failed
|
||||
- action.requested (log only, don't execute)
|
||||
- action.requested (non-whitelisted actions are telemetry only)
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
@@ -91,6 +92,7 @@ class AgentResultNormalizer:
|
||||
# Validate result size
|
||||
try:
|
||||
import json
|
||||
|
||||
result_json = json.dumps(result_dict)
|
||||
if len(result_json) > MAX_RESULT_SIZE_BYTES:
|
||||
self.ap.logger.warning(
|
||||
@@ -120,16 +122,12 @@ class AgentResultNormalizer:
|
||||
|
||||
elif result_type == 'tool.call.started':
|
||||
# Log only, don't yield to pipeline
|
||||
self.ap.logger.debug(
|
||||
f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}'
|
||||
)
|
||||
self.ap.logger.debug(f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}')
|
||||
return None
|
||||
|
||||
elif result_type == 'tool.call.completed':
|
||||
# Log only, don't yield to pipeline
|
||||
self.ap.logger.debug(
|
||||
f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}'
|
||||
)
|
||||
self.ap.logger.debug(f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}')
|
||||
return None
|
||||
|
||||
elif result_type == 'state.updated':
|
||||
@@ -161,10 +159,9 @@ class AgentResultNormalizer:
|
||||
)
|
||||
|
||||
elif result_type == 'action.requested':
|
||||
# Reserved for EBA - log only, don't execute
|
||||
# The orchestrator consumes whitelisted actions before normalization.
|
||||
self.ap.logger.info(
|
||||
f'Runner {descriptor.id} requested action (not executed in current phase): '
|
||||
f'{data.get("action", "unknown")}'
|
||||
f'Runner {descriptor.id} requested unsupported action (not executed): {data.get("action", "unknown")}'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -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
|
||||
from langbot.pkg.entity.persistence import (
|
||||
agent, # noqa: F401
|
||||
agent_interaction, # noqa: F401
|
||||
agent_run, # noqa: F401
|
||||
agent_runner_state, # noqa: F401
|
||||
apikey, # noqa: F401
|
||||
|
||||
@@ -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')
|
||||
@@ -38,6 +38,7 @@ class PendingMessage:
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
pipeline_uuid: typing.Optional[str]
|
||||
routed_by_rule: bool = False
|
||||
variables: dict[str, typing.Any] | None = None
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@@ -127,6 +128,7 @@ class MessageAggregator:
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
pipeline_uuid: typing.Optional[str] = None,
|
||||
routed_by_rule: bool = False,
|
||||
variables: dict[str, typing.Any] | None = None,
|
||||
) -> None:
|
||||
"""Add a message to the aggregation buffer
|
||||
|
||||
@@ -135,6 +137,8 @@ class MessageAggregator:
|
||||
merged with other messages from the same session.
|
||||
"""
|
||||
enabled, delay = await self._get_aggregation_config(pipeline_uuid)
|
||||
if variables:
|
||||
enabled = False
|
||||
|
||||
if not enabled:
|
||||
# Aggregation disabled, send directly to query pool
|
||||
@@ -148,6 +152,7 @@ class MessageAggregator:
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
variables=variables,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -163,6 +168,7 @@ class MessageAggregator:
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
force_flush = False
|
||||
@@ -222,6 +228,7 @@ class MessageAggregator:
|
||||
adapter=msg.adapter,
|
||||
pipeline_uuid=msg.pipeline_uuid,
|
||||
routed_by_rule=msg.routed_by_rule,
|
||||
variables=msg.variables,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -237,6 +244,7 @@ class MessageAggregator:
|
||||
adapter=merged_msg.adapter,
|
||||
pipeline_uuid=merged_msg.pipeline_uuid,
|
||||
routed_by_rule=merged_msg.routed_by_rule,
|
||||
variables=merged_msg.variables,
|
||||
)
|
||||
|
||||
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
||||
@@ -276,6 +284,7 @@ class MessageAggregator:
|
||||
adapter=base_msg.adapter,
|
||||
pipeline_uuid=base_msg.pipeline_uuid,
|
||||
routed_by_rule=any(msg.routed_by_rule for msg in messages),
|
||||
variables=base_msg.variables,
|
||||
)
|
||||
|
||||
async def flush_all(self) -> None:
|
||||
|
||||
@@ -179,7 +179,7 @@ class RuntimePipeline:
|
||||
bot_message=query.resp_messages[-1],
|
||||
message=result.user_notice,
|
||||
quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
|
||||
is_final=[msg.is_final for msg in query.resp_messages][-1],
|
||||
is_final=query.resp_messages[-1].is_final,
|
||||
)
|
||||
else:
|
||||
await query.adapter.reply_message(
|
||||
|
||||
@@ -42,7 +42,7 @@ class QueryPool:
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
pipeline_uuid: typing.Optional[str] = None,
|
||||
routed_by_rule: bool = False,
|
||||
variables: typing.Optional[dict[str, typing.Any]] = None,
|
||||
variables: dict[str, typing.Any] | None = None,
|
||||
) -> pipeline_query.Query:
|
||||
async with self.condition:
|
||||
query_id = self.query_id_counter
|
||||
|
||||
@@ -43,9 +43,12 @@ class SendResponseBackStage(stage.PipelineStage):
|
||||
response_index = len(query.resp_message_chain) - 1
|
||||
message_chain = query.resp_message_chain[-1]
|
||||
|
||||
is_streaming_response = False
|
||||
is_final = False
|
||||
try:
|
||||
if await query.adapter.is_stream_output_supported() and has_chunks:
|
||||
is_final = [msg.is_final for msg in query.resp_messages][-1]
|
||||
is_streaming_response = await query.adapter.is_stream_output_supported() and has_chunks
|
||||
if is_streaming_response:
|
||||
is_final = query.resp_messages[-1].is_final
|
||||
await query.adapter.reply_message_chunk(
|
||||
message_source=query.message_event,
|
||||
bot_message=query.resp_messages[-1],
|
||||
@@ -68,6 +71,24 @@ class SendResponseBackStage(stage.PipelineStage):
|
||||
e,
|
||||
)
|
||||
plugin_diagnostics.clear_response_source(query, response_index)
|
||||
if is_streaming_response:
|
||||
self.ap.logger.warning(
|
||||
'Streaming response delivery failed; continuing runner event consumption: %s',
|
||||
e,
|
||||
)
|
||||
if is_final:
|
||||
try:
|
||||
await query.adapter.reply_message(
|
||||
message_source=query.message_event,
|
||||
message=message_chain,
|
||||
quote_origin=quote_origin,
|
||||
)
|
||||
except Exception as fallback_error:
|
||||
self.ap.logger.warning(
|
||||
'Final non-streaming response fallback also failed: %s',
|
||||
fallback_error,
|
||||
)
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
raise
|
||||
plugin_diagnostics.clear_response_source(query, response_index)
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ from langbot.pkg.platform.adapters.dingtalk.api_impl import DingTalkAPIMixin
|
||||
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.dingtalk.interaction import (
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_native,
|
||||
send_interaction,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
@@ -27,6 +32,9 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler):
|
||||
|
||||
async def process(self, message: dingtalk_stream.CallbackMessage):
|
||||
callback = dingtalk_stream.CardCallbackMessage.from_dict(message.data or {})
|
||||
content = dict(callback.content) if isinstance(callback.content, dict) else {}
|
||||
if isinstance(message.data, dict):
|
||||
content['_raw_callback'] = message.data
|
||||
event = DingTalkEvent.from_payload(
|
||||
{
|
||||
'conversation_type': 'CardCallback',
|
||||
@@ -35,7 +43,7 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler):
|
||||
'extension': callback.extension,
|
||||
'corp_id': callback.corp_id,
|
||||
'user_id': callback.user_id,
|
||||
'content': callback.content,
|
||||
'content': content,
|
||||
'space_id': callback.space_id,
|
||||
'card_instance_id': callback.card_instance_id,
|
||||
},
|
||||
@@ -61,6 +69,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
|
||||
_user_cache: dict[str, platform_entities.User] = {}
|
||||
_group_cache: dict[str, platform_entities.UserGroup] = {}
|
||||
interaction_callback_contexts: dict[str, dict[str, typing.Any]] = {}
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
@@ -89,6 +98,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
_message_cache={},
|
||||
_user_cache={},
|
||||
_group_cache={},
|
||||
interaction_callback_contexts={},
|
||||
)
|
||||
self._register_native_handlers()
|
||||
|
||||
@@ -100,7 +110,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return [
|
||||
apis = [
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'get_message',
|
||||
@@ -112,6 +122,16 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
'get_file_url',
|
||||
'call_platform_api',
|
||||
]
|
||||
if self.config.get('human_input_card_template_id') and hasattr(self.bot, 'create_and_deliver_card'):
|
||||
apis.append('interaction.request')
|
||||
return apis
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
@staticmethod
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -184,6 +204,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
return bool(self.config.get('enable-stream-reply', False))
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request' and action in self.get_supported_apis():
|
||||
return await send_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
@@ -233,6 +255,10 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
|
||||
async def _handle_native_event(self, event: DingTalkEvent):
|
||||
try:
|
||||
interaction_event = interaction_event_from_native(event, self.interaction_callback_contexts)
|
||||
if interaction_event is not None:
|
||||
await self._dispatch_eba_event(interaction_event)
|
||||
return
|
||||
await self.logger.debug(
|
||||
'DingTalk EBA event received: '
|
||||
f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}'
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"""DingTalk card rendering and callbacks for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
return {
|
||||
'field_types': ['text', 'textarea', 'number', 'select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def _select_options(options: list[dict[str, typing.Any]]) -> list[dict[str, typing.Any]]:
|
||||
locales = ['zh_CN', 'zh_TW', 'en_US', 'ja_JP', 'vi_VN', 'th_TH', 'id_ID', 'ms_MY', 'ko_KR']
|
||||
result = []
|
||||
for option in options:
|
||||
value = str(option.get('value') or '')
|
||||
label = str(option.get('label') or value)
|
||||
if value:
|
||||
result.append({'value': value, 'text': {locale: label for locale in locales}})
|
||||
return result
|
||||
|
||||
|
||||
def _field_card_params(field: dict[str, typing.Any]) -> dict[str, typing.Any] | None:
|
||||
field_type = str(field.get('type') or '')
|
||||
field_id = str(field.get('id') or '')
|
||||
if field_type not in {'text', 'textarea', 'number', 'select'} or not field_id:
|
||||
return None
|
||||
label = str(field.get('label') or field_id)
|
||||
placeholder = str(field.get('placeholder') or label)
|
||||
default = field.get('default')
|
||||
params: dict[str, typing.Any] = {
|
||||
'input_visible': '',
|
||||
'input_title': '',
|
||||
'input_placeholder': '',
|
||||
'input_value': '',
|
||||
'select_visible': '',
|
||||
'select_placeholder': '',
|
||||
'select_options': [],
|
||||
'index_o': [],
|
||||
'select_index': -1,
|
||||
}
|
||||
if field_type == 'select':
|
||||
raw_options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
options = [option for option in raw_options if isinstance(option, dict)]
|
||||
encoded_options = _select_options(options)
|
||||
if not encoded_options:
|
||||
return None
|
||||
selected_index = next(
|
||||
(index for index, option in enumerate(options) if str(option.get('value')) == str(default)),
|
||||
-1,
|
||||
)
|
||||
params.update(
|
||||
{
|
||||
'select_visible': 'true',
|
||||
'select_placeholder': placeholder,
|
||||
'select_options': [str(option.get('value') or '') for option in options],
|
||||
'index_o': encoded_options,
|
||||
'select_index': selected_index,
|
||||
}
|
||||
)
|
||||
else:
|
||||
params.update(
|
||||
{
|
||||
'input_visible': 'true',
|
||||
'input_title': label,
|
||||
'input_placeholder': placeholder,
|
||||
'input_value': '' if default is None else str(default),
|
||||
}
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
def _prune_callback_contexts(adapter: typing.Any, now: float | None = None) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
contexts = adapter.interaction_callback_contexts
|
||||
for key in [key for key, value in contexts.items() if float(value.get('expires_at') or 0) <= now]:
|
||||
contexts.pop(key, None)
|
||||
|
||||
|
||||
def _buttons(request: dict[str, typing.Any], callback_token: str) -> list[dict[str, typing.Any]]:
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
items: list[tuple[str, str, str]] = []
|
||||
if actions and not fields:
|
||||
items = [
|
||||
(
|
||||
str(action.get('label') or action.get('id') or index + 1),
|
||||
f'lbi:{callback_token}:a:{index}',
|
||||
str(action.get('style') or 'default'),
|
||||
)
|
||||
for index, action in enumerate(actions)
|
||||
if isinstance(action, dict)
|
||||
]
|
||||
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
|
||||
field = fields[0]
|
||||
if field.get('type') != 'select':
|
||||
return []
|
||||
options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
items = [
|
||||
(
|
||||
str(option.get('label') or option.get('value') or index + 1),
|
||||
f'lbi:{callback_token}:f:0:{index}',
|
||||
'default',
|
||||
)
|
||||
for index, option in enumerate(options)
|
||||
if isinstance(option, dict)
|
||||
]
|
||||
return [
|
||||
{
|
||||
'text': label,
|
||||
'color': 'blue' if style == 'primary' else 'red' if style == 'danger' else 'gray',
|
||||
'status': 'normal',
|
||||
'event': {
|
||||
'type': 'sendCardRequest',
|
||||
'params': {'actionId': callback_data, 'params': {'action_id': callback_data}},
|
||||
},
|
||||
}
|
||||
for label, callback_data, style in items
|
||||
]
|
||||
|
||||
|
||||
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
target_type = str(reply_target.get('target_type') or '')
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
buttons = _buttons(request, callback_token)
|
||||
field_params = (
|
||||
_field_card_params(fields[0]) if len(fields) == 1 and not actions and isinstance(fields[0], dict) else None
|
||||
)
|
||||
if not buttons and field_params is None:
|
||||
fallback = '\n\n'.join(
|
||||
part
|
||||
for part in (
|
||||
str(request.get('title') or '').strip(),
|
||||
str(request.get('description') or '').strip(),
|
||||
str(request.get('fallback_text') or '').strip(),
|
||||
)
|
||||
if part
|
||||
)
|
||||
result = await adapter.send_message(target_type, target_id, adapter._plain_message(fallback))
|
||||
return {'ok': True, 'message_id': result.message_id, 'rich': False}
|
||||
if target_type not in {'group', 'person'} or not target_id:
|
||||
raise ValueError('interaction.request has an invalid DingTalk target')
|
||||
field_label = str(fields[0].get('label') or '').strip() if fields and isinstance(fields[0], dict) else ''
|
||||
content = '\n\n'.join(
|
||||
part
|
||||
for part in (
|
||||
f'## {str(request.get("title") or "").strip()}',
|
||||
str(request.get('description') or '').strip(),
|
||||
field_label,
|
||||
)
|
||||
if part
|
||||
)
|
||||
out_track_id = uuid.uuid4().hex
|
||||
card_param_map = {'content': content, 'btns': buttons}
|
||||
if field_params is not None:
|
||||
card_param_map.update(field_params)
|
||||
await adapter.bot.create_and_deliver_card(
|
||||
card_template_id=adapter.config['human_input_card_template_id'],
|
||||
out_track_id=out_track_id,
|
||||
open_space_id=(
|
||||
f'dtv1.card//IM_GROUP.{target_id}' if target_type == 'group' else f'dtv1.card//IM_ROBOT.{target_id}'
|
||||
),
|
||||
is_group=target_type == 'group',
|
||||
card_param_map=card_param_map,
|
||||
)
|
||||
if field_params is not None:
|
||||
_prune_callback_contexts(adapter)
|
||||
field = fields[0]
|
||||
adapter.interaction_callback_contexts[out_track_id] = {
|
||||
'callback_token': callback_token,
|
||||
'field_id': str(field.get('id') or ''),
|
||||
'field_type': str(field.get('type') or ''),
|
||||
'options': [
|
||||
str(option.get('value') or '') for option in field.get('options') or [] if isinstance(option, dict)
|
||||
],
|
||||
'expires_at': time.monotonic() + 30 * 60,
|
||||
}
|
||||
return {'ok': True, 'message_id': out_track_id, 'rich': True}
|
||||
|
||||
|
||||
def parse_callback_data(value: str) -> dict[str, typing.Any]:
|
||||
parts = value.split(':')
|
||||
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
|
||||
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
|
||||
if (
|
||||
len(parts) == 5
|
||||
and parts[0] == 'lbi'
|
||||
and parts[1]
|
||||
and parts[2] == 'f'
|
||||
and parts[3].isdigit()
|
||||
and parts[4].isdigit()
|
||||
):
|
||||
return {
|
||||
'callback_token': parts[1],
|
||||
'field_ref': int(parts[3]),
|
||||
'option_ref': int(parts[4]),
|
||||
}
|
||||
raise ValueError('invalid DingTalk interaction callback data')
|
||||
|
||||
|
||||
def _find_callback_data(value: typing.Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value if value.startswith('lbi:') else ''
|
||||
if isinstance(value, dict):
|
||||
for key in ('actionId', 'action_id', 'id'):
|
||||
found = _find_callback_data(value.get(key))
|
||||
if found:
|
||||
return found
|
||||
for child in value.values():
|
||||
found = _find_callback_data(child)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(value, list):
|
||||
for child in value:
|
||||
found = _find_callback_data(child)
|
||||
if found:
|
||||
return found
|
||||
return ''
|
||||
|
||||
|
||||
def _find_named_value(value: typing.Any, names: set[str]) -> typing.Any:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
if key in names and child not in (None, ''):
|
||||
return child
|
||||
for child in value.values():
|
||||
found = _find_named_value(child, names)
|
||||
if found not in (None, ''):
|
||||
return found
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
found = _find_named_value(child, names)
|
||||
if found not in (None, ''):
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _callback_track_id(callback: dict[str, typing.Any]) -> str:
|
||||
value = _find_named_value(callback, {'outTrackId', 'out_track_id', 'outtrackid'})
|
||||
return str(value or '')
|
||||
|
||||
|
||||
def _mapping(value: typing.Any) -> dict[str, typing.Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _native_field_value(callback: dict[str, typing.Any], context: dict[str, typing.Any]) -> typing.Any:
|
||||
field_type = str(context.get('field_type') or '')
|
||||
names = (
|
||||
{'select', 'selectResult', 'select_result', '__built_in_selectResult__'}
|
||||
if field_type == 'select'
|
||||
else {'input', 'inputResult', 'input_result', '__built_in_inputResult__'}
|
||||
)
|
||||
value = _find_named_value(callback, names)
|
||||
parsed = _mapping(value)
|
||||
if parsed:
|
||||
value = parsed.get('value', parsed.get('input', parsed.get('index')))
|
||||
if isinstance(value, dict):
|
||||
value = value.get('value') or value.get('input') or value.get('index')
|
||||
if field_type == 'select' and isinstance(value, int) and not isinstance(value, bool):
|
||||
options = context.get('options') if isinstance(context.get('options'), list) else []
|
||||
if 0 <= value < len(options):
|
||||
value = options[value]
|
||||
if field_type == 'number' and value not in (None, ''):
|
||||
text = str(value).strip()
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError:
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def interaction_event_from_native(
|
||||
event: DingTalkEvent,
|
||||
callback_contexts: dict[str, dict[str, typing.Any]] | None = None,
|
||||
) -> platform_events.PlatformSpecificEvent | None:
|
||||
callback = event.get('CardCallback') or {}
|
||||
callback_data = _find_callback_data(callback)
|
||||
if callback_data:
|
||||
parsed = parse_callback_data(callback_data)
|
||||
else:
|
||||
contexts = callback_contexts if callback_contexts is not None else {}
|
||||
track_id = _callback_track_id(callback)
|
||||
context = contexts.get(track_id)
|
||||
if context is None:
|
||||
return None
|
||||
value = _native_field_value(callback, context)
|
||||
if value in (None, ''):
|
||||
return None
|
||||
parsed = {
|
||||
'callback_token': str(context.get('callback_token') or ''),
|
||||
'values': {str(context.get('field_id') or ''): value},
|
||||
}
|
||||
contexts.pop(track_id, None)
|
||||
space_id = str(callback.get('space_id') or '')
|
||||
if 'IM_GROUP.' in space_id:
|
||||
target_type = 'group'
|
||||
target_id = space_id.split('IM_GROUP.', 1)[1]
|
||||
elif 'IM_ROBOT.' in space_id:
|
||||
target_type = 'person'
|
||||
target_id = space_id.split('IM_ROBOT.', 1)[1]
|
||||
else:
|
||||
raise ValueError('DingTalk interaction callback has no delivery space')
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='dingtalk-eba',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
'actor_id': str(callback.get('user_id') or ''),
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'display_text': 'submitted',
|
||||
},
|
||||
timestamp=time.time(),
|
||||
source_platform_object=event,
|
||||
)
|
||||
@@ -90,6 +90,19 @@ spec:
|
||||
required: true
|
||||
default: "填写你的卡片template_id"
|
||||
|
||||
- name: human_input_card_template_id
|
||||
label:
|
||||
en_US: Human Input Card Template ID
|
||||
zh_Hans: 人工输入卡片模板 ID
|
||||
zh_Hant: 人工輸入卡片範本 ID
|
||||
description:
|
||||
en_US: Import the bundled `src/langbot/templates/dingtalk_human_input_card.json`, then enter its DingTalk template ID here. It contains the `content`, `btns`, `input_*`, `select_*`, and `index_o` variables required for native interactions.
|
||||
zh_Hans: 可选。模板需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 变量以支持原生交互。
|
||||
zh_Hant: 可選。範本需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 變數以支援原生互動。
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
supported_events:
|
||||
- message.received
|
||||
- feedback.received
|
||||
@@ -108,6 +121,7 @@ spec:
|
||||
- get_friend_list
|
||||
- get_file_url
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
|
||||
platform_specific_apis:
|
||||
- action: check_access_token
|
||||
|
||||
@@ -13,6 +13,12 @@ from langbot.pkg.platform.adapters.discord.api_impl import DiscordAPIMixin
|
||||
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
|
||||
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
|
||||
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.discord.interaction import (
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_component,
|
||||
parse_interaction_custom_id,
|
||||
send_interaction,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
@@ -63,6 +69,25 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
except Exception:
|
||||
await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}')
|
||||
|
||||
async def on_interaction(self: discord.Client, interaction: discord.Interaction):
|
||||
custom_id = (interaction.data or {}).get('custom_id') if isinstance(interaction.data, dict) else None
|
||||
try:
|
||||
parsed = parse_interaction_custom_id(custom_id)
|
||||
except ValueError:
|
||||
if not interaction.response.is_done():
|
||||
await interaction.response.send_message('Invalid or expired action', ephemeral=True)
|
||||
return
|
||||
if parsed is None:
|
||||
return
|
||||
if not interaction.response.is_done():
|
||||
await interaction.response.defer()
|
||||
try:
|
||||
await adapter_self._dispatch_eba_event(interaction_event_from_component(interaction, parsed))
|
||||
if interaction.message is not None:
|
||||
await interaction.message.edit(view=None)
|
||||
except Exception:
|
||||
await adapter_self.logger.error(f'Error in Discord interaction callback: {traceback.format_exc()}')
|
||||
|
||||
async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message):
|
||||
await adapter_self._dispatch_gateway_tuple(
|
||||
'message_edit', (before, after), self.user.id if self.user else None
|
||||
@@ -176,8 +201,12 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
'kick_member',
|
||||
'leave_group',
|
||||
'call_platform_api',
|
||||
'interaction.request',
|
||||
]
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
content, files = await self.message_converter.yiri2target(message)
|
||||
channel = await self._get_channel(target_id)
|
||||
@@ -238,6 +267,8 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Discord component rendering and callbacks for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
import discord
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
return {
|
||||
'field_types': ['select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def parse_interaction_custom_id(custom_id: str | None) -> dict[str, typing.Any] | None:
|
||||
if not custom_id or not custom_id.startswith('lbi:'):
|
||||
return None
|
||||
parts = custom_id.split(':')
|
||||
if len(parts) == 4 and parts[1] and parts[2] == 'a' and parts[3].isdigit():
|
||||
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
|
||||
if len(parts) == 5 and parts[1] and parts[2] == 'f' and parts[3].isdigit() and parts[4].isdigit():
|
||||
return {
|
||||
'callback_token': parts[1],
|
||||
'field_ref': int(parts[3]),
|
||||
'option_ref': int(parts[4]),
|
||||
}
|
||||
raise ValueError('invalid Discord interaction custom_id')
|
||||
|
||||
|
||||
def _style(value: str) -> discord.ButtonStyle:
|
||||
if value == 'primary':
|
||||
return discord.ButtonStyle.primary
|
||||
if value == 'danger':
|
||||
return discord.ButtonStyle.danger
|
||||
return discord.ButtonStyle.secondary
|
||||
|
||||
|
||||
def build_interaction_view(request: dict[str, typing.Any], callback_token: str) -> discord.ui.View | None:
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
view = discord.ui.View(timeout=None)
|
||||
|
||||
if actions and not fields:
|
||||
for index, action in enumerate(actions):
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
view.add_item(
|
||||
discord.ui.Button(
|
||||
label=str(action.get('label') or action.get('id') or index + 1)[:80],
|
||||
style=_style(str(action.get('style') or 'default')),
|
||||
custom_id=f'lbi:{callback_token}:a:{index}',
|
||||
)
|
||||
)
|
||||
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
|
||||
field = fields[0]
|
||||
if field.get('type') != 'select':
|
||||
return None
|
||||
options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
for option_index, option in enumerate(options):
|
||||
if not isinstance(option, dict):
|
||||
continue
|
||||
view.add_item(
|
||||
discord.ui.Button(
|
||||
label=str(option.get('label') or option.get('value') or option_index + 1)[:80],
|
||||
style=discord.ButtonStyle.secondary,
|
||||
custom_id=f'lbi:{callback_token}:f:0:{option_index}',
|
||||
)
|
||||
)
|
||||
else:
|
||||
return None
|
||||
return view if view.children else None
|
||||
|
||||
|
||||
def _content(request: dict[str, typing.Any], *, rich: bool) -> str:
|
||||
parts = [str(request.get('title') or '').strip()]
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
parts.append(description)
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
if rich and len(fields) == 1 and isinstance(fields[0], dict):
|
||||
label = str(fields[0].get('label') or '').strip()
|
||||
if label:
|
||||
parts.append(label)
|
||||
if not rich:
|
||||
fallback = str(request.get('fallback_text') or '').strip()
|
||||
if fallback:
|
||||
parts.append(fallback)
|
||||
return '\n\n'.join(part for part in parts if part)[:2000]
|
||||
|
||||
|
||||
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
if not target_id:
|
||||
raise ValueError('interaction.request has no target_id')
|
||||
channel = await adapter._get_channel(target_id)
|
||||
view = build_interaction_view(request, callback_token)
|
||||
sent = await channel.send(content=_content(request, rich=view is not None), view=view)
|
||||
return {'ok': True, 'message_id': sent.id, 'rich': view is not None}
|
||||
|
||||
|
||||
def interaction_event_from_component(
|
||||
interaction: discord.Interaction,
|
||||
parsed: dict[str, typing.Any],
|
||||
) -> platform_events.PlatformSpecificEvent:
|
||||
if interaction.user is None or interaction.channel is None:
|
||||
raise ValueError('Discord interaction has no actor or channel')
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='discord',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
'actor_id': str(interaction.user.id),
|
||||
'target_type': 'group' if interaction.guild is not None else 'person',
|
||||
'target_id': str(interaction.channel.id),
|
||||
'display_text': 'submitted',
|
||||
},
|
||||
timestamp=time.time(),
|
||||
source_platform_object=interaction,
|
||||
)
|
||||
@@ -60,6 +60,7 @@ spec:
|
||||
- kick_member
|
||||
- leave_group
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
|
||||
platform_specific_apis:
|
||||
- action: get_channel
|
||||
|
||||
@@ -23,12 +23,16 @@ from lark_oapi.api.auth.v3 import (
|
||||
ResendAppTicketResponse,
|
||||
)
|
||||
from lark_oapi.api.cardkit.v1 import (
|
||||
Card,
|
||||
ContentCardElementRequest,
|
||||
ContentCardElementRequestBody,
|
||||
ContentCardElementResponse,
|
||||
CreateCardRequest,
|
||||
CreateCardRequestBody,
|
||||
CreateCardResponse,
|
||||
UpdateCardRequest,
|
||||
UpdateCardRequestBody,
|
||||
UpdateCardResponse,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateMessageRequest,
|
||||
@@ -52,6 +56,13 @@ from langbot.pkg.platform.adapters.lark.api_impl import LarkAPIMixin
|
||||
from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter
|
||||
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
|
||||
from langbot.pkg.platform.adapters.lark.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.lark.interaction import (
|
||||
acknowledge_interaction,
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_callback,
|
||||
interaction_event_from_webhook,
|
||||
send_interaction,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
@@ -101,6 +112,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = pydantic.Field(default_factory=dict)
|
||||
card_id_dict: dict[str, str] = pydantic.Field(default_factory=dict)
|
||||
card_sequence_dict: dict[str, int] = pydantic.Field(default_factory=dict)
|
||||
card_last_update_dict: dict[str, float] = pydantic.Field(default_factory=dict)
|
||||
closed_streaming_cards: set[str] = pydantic.Field(default_factory=set)
|
||||
pending_monitoring_msg: dict[str, str] = pydantic.Field(default_factory=dict)
|
||||
reply_to_monitoring_msg: dict[str, tuple[str, float]] = pydantic.Field(default_factory=dict)
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent] = pydantic.PrivateAttr(default_factory=dict)
|
||||
@@ -133,6 +147,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
cipher=cipher,
|
||||
listeners={},
|
||||
card_id_dict={},
|
||||
card_sequence_dict={},
|
||||
card_last_update_dict={},
|
||||
closed_streaming_cards=set(),
|
||||
pending_monitoring_msg={},
|
||||
reply_to_monitoring_msg={},
|
||||
event_loop=None,
|
||||
@@ -177,8 +194,17 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
'get_user_info',
|
||||
'get_file_url',
|
||||
'call_platform_api',
|
||||
'interaction.request',
|
||||
'interaction.acknowledge',
|
||||
]
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
@staticmethod
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
def build_api_client(self, config: dict) -> lark_oapi.Client:
|
||||
builder = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret'])
|
||||
if config.get('app_type', 'self') == 'isv':
|
||||
@@ -378,7 +404,15 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
async def create_card_id(self, message_id) -> str:
|
||||
card_data = {
|
||||
'schema': '2.0',
|
||||
'config': {'update_multi': True, 'streaming_mode': True},
|
||||
'config': {
|
||||
'update_multi': True,
|
||||
'streaming_mode': True,
|
||||
'streaming_config': {
|
||||
'print_step': {'default': 1},
|
||||
'print_frequency_ms': {'default': 70},
|
||||
'print_strategy': 'fast',
|
||||
},
|
||||
},
|
||||
'body': {
|
||||
'direction': 'vertical',
|
||||
'elements': [{'tag': 'markdown', 'content': '', 'element_id': 'streaming_txt'}],
|
||||
@@ -392,8 +426,49 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark create_card failed: {response.code} {response.msg}')
|
||||
self.card_id_dict[str(message_id)] = response.data.card_id
|
||||
return response.data.card_id
|
||||
card_id = str(response.data.card_id)
|
||||
self.card_id_dict[str(message_id)] = card_id
|
||||
self.card_sequence_dict[card_id] = 0
|
||||
self.card_last_update_dict.pop(card_id, None)
|
||||
self.closed_streaming_cards.discard(card_id)
|
||||
return card_id
|
||||
|
||||
def _next_card_sequence(self, card_id: str) -> int:
|
||||
current = self.card_sequence_dict.get(card_id, 0)
|
||||
sequence = current + 1
|
||||
self.card_sequence_dict[card_id] = sequence
|
||||
return sequence
|
||||
|
||||
@staticmethod
|
||||
def _streaming_mode_closed(response: ContentCardElementResponse) -> bool:
|
||||
return response.code == 300309 or 'streaming mode is closed' in str(response.msg).lower()
|
||||
|
||||
async def _replace_streaming_card(self, card_id: str, content: str) -> None:
|
||||
sequence = self._next_card_sequence(card_id)
|
||||
card_data = {
|
||||
'schema': '2.0',
|
||||
'config': {'update_multi': True},
|
||||
'body': {
|
||||
'direction': 'vertical',
|
||||
'elements': [{'tag': 'markdown', 'content': content}],
|
||||
},
|
||||
}
|
||||
request = (
|
||||
UpdateCardRequest.builder()
|
||||
.card_id(card_id)
|
||||
.request_body(
|
||||
UpdateCardRequestBody.builder()
|
||||
.sequence(sequence)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.card(Card.builder().type('card_json').data(json.dumps(card_data, ensure_ascii=False)).build())
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: UpdateCardResponse = await self.api_client.cardkit.v1.card.aupdate(request)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark card update failed: {response.code} {response.msg}')
|
||||
self.closed_streaming_cards.add(card_id)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
@@ -403,31 +478,53 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
if bot_message.msg_sequence % 8 != 0 and not is_final:
|
||||
card_id = self.card_id_dict[bot_message.resp_message_id]
|
||||
has_sent_update = self.card_sequence_dict.get(card_id, 0) > 0
|
||||
now = time.monotonic()
|
||||
last_update = self.card_last_update_dict.get(card_id, 0.0)
|
||||
is_high_frequency_chunk = now - last_update < 1.0
|
||||
if has_sent_update and is_high_frequency_chunk and bot_message.msg_sequence % 8 != 0 and not is_final:
|
||||
return
|
||||
text_elements, _ = await self.message_converter.yiri2target(message, self.api_client)
|
||||
content = '\n\n'.join(
|
||||
''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'})
|
||||
for paragraph in text_elements
|
||||
)
|
||||
request = (
|
||||
ContentCardElementRequest.builder()
|
||||
.card_id(self.card_id_dict[bot_message.resp_message_id])
|
||||
.element_id('streaming_txt')
|
||||
.request_body(
|
||||
ContentCardElementRequestBody.builder().content(content).sequence(bot_message.msg_sequence).build()
|
||||
cumulative_content = getattr(bot_message, 'all_content', None)
|
||||
if isinstance(cumulative_content, str) and cumulative_content:
|
||||
content = cumulative_content
|
||||
else:
|
||||
text_elements, _ = await self.message_converter.yiri2target(message, self.api_client)
|
||||
content = '\n\n'.join(
|
||||
''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'})
|
||||
for paragraph in text_elements
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(
|
||||
request, self.request_option(self._tenant_key_from_source(message_source))
|
||||
)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}')
|
||||
if card_id in self.closed_streaming_cards:
|
||||
await self._replace_streaming_card(card_id, content)
|
||||
else:
|
||||
sequence = self._next_card_sequence(card_id)
|
||||
request = (
|
||||
ContentCardElementRequest.builder()
|
||||
.card_id(card_id)
|
||||
.element_id('streaming_txt')
|
||||
.request_body(ContentCardElementRequestBody.builder().content(content).sequence(sequence).build())
|
||||
.build()
|
||||
)
|
||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(
|
||||
request, self.request_option(self._tenant_key_from_source(message_source))
|
||||
)
|
||||
if not response.success():
|
||||
if self._streaming_mode_closed(response):
|
||||
await self._replace_streaming_card(card_id, content)
|
||||
else:
|
||||
raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}')
|
||||
self.card_last_update_dict[card_id] = now
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
self.card_id_dict.pop(bot_message.resp_message_id, None)
|
||||
self.card_sequence_dict.pop(card_id, None)
|
||||
self.card_last_update_dict.pop(card_id, None)
|
||||
self.closed_streaming_cards.discard(card_id)
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self, params)
|
||||
if action == 'interaction.acknowledge':
|
||||
return await acknowledge_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
@@ -493,6 +590,10 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
await self._dispatch_eba_event(LarkEventConverter.bot_invited_to_group(data, chat_id))
|
||||
return {'code': 200, 'message': 'ok'}
|
||||
if event_type == 'card.action.trigger':
|
||||
interaction_event = interaction_event_from_webhook(data)
|
||||
if interaction_event is not None:
|
||||
await self._dispatch_eba_event(interaction_event)
|
||||
return self._interaction_action_response(interaction_event)
|
||||
feedback_event = self._feedback_event_from_webhook(data)
|
||||
if feedback_event and platform_events.FeedbackEvent in self.listeners:
|
||||
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
|
||||
@@ -571,6 +672,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
self._group_cache[str(event.group.id)] = event.group
|
||||
|
||||
def _handle_card_action_sync(self, event):
|
||||
interaction_event = interaction_event_from_callback(event)
|
||||
if interaction_event is not None:
|
||||
self._submit_coro(self._dispatch_eba_event(interaction_event))
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||
|
||||
return P2CardActionTriggerResponse(self._interaction_action_response(interaction_event))
|
||||
feedback_event = self._feedback_event_from_callback(event)
|
||||
if feedback_event and platform_events.FeedbackEvent in self.listeners:
|
||||
self._submit_coro(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||
@@ -578,6 +685,26 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
|
||||
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
|
||||
|
||||
@staticmethod
|
||||
def _interaction_action_response(event: platform_events.PlatformSpecificEvent) -> dict[str, typing.Any]:
|
||||
response: dict[str, typing.Any] = {
|
||||
'toast': {'type': 'success', 'content': 'Submitted / 已提交'},
|
||||
}
|
||||
if not event.data.get('cardkit'):
|
||||
response['card'] = {
|
||||
'type': 'raw',
|
||||
'data': {
|
||||
'config': {'wide_screen_mode': True},
|
||||
'elements': [
|
||||
{
|
||||
'tag': 'div',
|
||||
'text': {'tag': 'lark_md', 'content': '**Submitted / 已提交**'},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
return response
|
||||
|
||||
def _submit_coro(self, coro):
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -681,4 +808,13 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
|
||||
message_id = getattr(message, 'message_id', None)
|
||||
if message_id:
|
||||
return str(message_id)
|
||||
context = getattr(source_event, 'context', None) if source_event else None
|
||||
message_id = getattr(context, 'open_message_id', None)
|
||||
if message_id:
|
||||
return str(message_id)
|
||||
if isinstance(source, dict):
|
||||
source_event_data = source.get('event') if isinstance(source.get('event'), dict) else source
|
||||
context_data = source_event_data.get('context') if isinstance(source_event_data, dict) else None
|
||||
if isinstance(context_data, dict) and context_data.get('open_message_id'):
|
||||
return str(context_data['open_message_id'])
|
||||
raise RuntimeError('Lark message source does not contain message_id')
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
"""Lark card rendering and callback conversion for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from lark_oapi.api.cardkit.v1 import (
|
||||
Card,
|
||||
CreateCardRequest,
|
||||
CreateCardRequestBody,
|
||||
CreateCardResponse,
|
||||
UpdateCardRequest,
|
||||
UpdateCardRequestBody,
|
||||
UpdateCardResponse,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestBody,
|
||||
CreateMessageResponse,
|
||||
)
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
return {
|
||||
'field_types': ['text', 'textarea', 'number', 'select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def _callback_value(
|
||||
callback_token: str,
|
||||
reply_target: dict[str, typing.Any],
|
||||
**refs: typing.Any,
|
||||
) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'lbi': callback_token,
|
||||
't': str(reply_target.get('target_type') or ''),
|
||||
'ck': 1,
|
||||
**refs,
|
||||
}
|
||||
|
||||
|
||||
def _field_form_elements(
|
||||
field: dict[str, typing.Any],
|
||||
callback_token: str,
|
||||
reply_target: dict[str, typing.Any],
|
||||
) -> list[dict[str, typing.Any]] | None:
|
||||
field_type = str(field.get('type') or '')
|
||||
if field_type not in {'text', 'textarea', 'number', 'select'}:
|
||||
return None
|
||||
field_id = str(field.get('id') or '')
|
||||
if not field_id:
|
||||
return None
|
||||
component_name = 'lbi_field_0'
|
||||
label = str(field.get('label') or field_id)
|
||||
placeholder = str(
|
||||
field.get('placeholder')
|
||||
or ('Select an option / 请选择' if field_type == 'select' else 'Enter a value / 请输入')
|
||||
)
|
||||
required = bool(field.get('required'))
|
||||
if field_type == 'select':
|
||||
raw_options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
options = [
|
||||
{
|
||||
'text': {
|
||||
'tag': 'plain_text',
|
||||
'content': str(option.get('label') or option.get('value') or ''),
|
||||
},
|
||||
'value': str(option.get('value') or ''),
|
||||
}
|
||||
for option in raw_options
|
||||
if isinstance(option, dict) and option.get('value') not in (None, '')
|
||||
]
|
||||
if not options:
|
||||
return None
|
||||
field_element: dict[str, typing.Any] = {
|
||||
'tag': 'select_static',
|
||||
'name': component_name,
|
||||
'label': {'tag': 'plain_text', 'content': label},
|
||||
'placeholder': {'tag': 'plain_text', 'content': placeholder},
|
||||
'options': options,
|
||||
'width': 'fill',
|
||||
'required': required,
|
||||
}
|
||||
default = field.get('default')
|
||||
if default not in (None, ''):
|
||||
field_element['initial_option'] = str(default)
|
||||
else:
|
||||
default = field.get('default')
|
||||
field_element = {
|
||||
'tag': 'input',
|
||||
'name': component_name,
|
||||
'label': {'tag': 'plain_text', 'content': label},
|
||||
'placeholder': {'tag': 'plain_text', 'content': placeholder},
|
||||
'default_value': '' if default is None else str(default),
|
||||
'width': 'fill',
|
||||
'required': required,
|
||||
}
|
||||
if field_type == 'textarea':
|
||||
field_element.update(
|
||||
{
|
||||
'input_type': 'multiline_text',
|
||||
'rows': 3,
|
||||
'auto_resize': True,
|
||||
'max_rows': 6,
|
||||
}
|
||||
)
|
||||
|
||||
submit_value = _callback_value(
|
||||
callback_token,
|
||||
reply_target,
|
||||
fm={component_name: field_id},
|
||||
ft={field_id: field_type},
|
||||
)
|
||||
submit_button = {
|
||||
'tag': 'button',
|
||||
'name': 'lbi_submit',
|
||||
'text': {'tag': 'plain_text', 'content': 'Submit / 提交'},
|
||||
'type': 'primary',
|
||||
'width': 'fill',
|
||||
'form_action_type': 'submit',
|
||||
'behaviors': [{'type': 'callback', 'value': submit_value}],
|
||||
}
|
||||
form_elements: list[dict[str, typing.Any]] = [field_element, submit_button]
|
||||
if field_type == 'select':
|
||||
form_elements.insert(
|
||||
0,
|
||||
{
|
||||
'tag': 'markdown',
|
||||
'content': f'**{label}{"*" if required else ""}**',
|
||||
},
|
||||
)
|
||||
return [
|
||||
{
|
||||
'tag': 'form',
|
||||
'name': 'lbi_form',
|
||||
'direction': 'vertical',
|
||||
'vertical_spacing': '12px',
|
||||
'elements': form_elements,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _render_submitted_value(value: typing.Any) -> str:
|
||||
rendered = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value)
|
||||
rendered = rendered.strip().replace('\n', '\n ')
|
||||
return rendered if len(rendered) <= 2000 else rendered[:1997] + '...'
|
||||
|
||||
|
||||
def _submission_display_values(
|
||||
request: dict[str, typing.Any],
|
||||
submission: dict[str, typing.Any],
|
||||
) -> list[dict[str, str]]:
|
||||
display_values: list[dict[str, str]] = []
|
||||
values = submission.get('values') if isinstance(submission.get('values'), dict) else {}
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
for field in fields:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_id = str(field.get('id') or '')
|
||||
if field_id and field_id in values:
|
||||
display_value = {
|
||||
'label': str(field.get('label') or field_id),
|
||||
'value': _render_submitted_value(values[field_id]),
|
||||
}
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
display_value['description'] = description
|
||||
display_values.append(display_value)
|
||||
|
||||
action_id = submission.get('action_id')
|
||||
if action_id:
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
action_label = next(
|
||||
(
|
||||
str(action.get('label') or action_id)
|
||||
for action in actions
|
||||
if isinstance(action, dict) and str(action.get('id') or '') == str(action_id)
|
||||
),
|
||||
str(action_id),
|
||||
)
|
||||
display_values.append({'label': 'Action', 'value': action_label})
|
||||
return display_values
|
||||
|
||||
|
||||
def _stored_submitted_values(value: typing.Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
stored_values = [
|
||||
{
|
||||
'label': str(item['label'])[:200],
|
||||
'value': str(item['value'])[:2000],
|
||||
**({'description': str(item['description'])[:4000]} if item.get('description') is not None else {}),
|
||||
}
|
||||
for item in value[:50]
|
||||
if isinstance(item, dict) and item.get('label') is not None and item.get('value') is not None
|
||||
]
|
||||
return stored_values
|
||||
|
||||
|
||||
def _submitted_value_elements(values: list[dict[str, str]]) -> list[dict[str, typing.Any]]:
|
||||
elements: list[dict[str, typing.Any]] = []
|
||||
for item in values:
|
||||
lines = []
|
||||
description = str(item.get('description') or '').strip()
|
||||
if description:
|
||||
lines.append(description)
|
||||
lines.append(f'✅ {item["label"]}:{item["value"]}')
|
||||
elements.append({'tag': 'markdown', 'content': '\n'.join(lines)})
|
||||
return elements
|
||||
|
||||
|
||||
def build_interaction_card(
|
||||
request: dict[str, typing.Any],
|
||||
callback_token: str,
|
||||
reply_target: dict[str, typing.Any],
|
||||
submitted_values: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, typing.Any] | None:
|
||||
"""Build the Lark subset that maps to a single atomic submission."""
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
buttons: list[dict[str, typing.Any]] = []
|
||||
field_elements: list[dict[str, typing.Any]] | None = None
|
||||
|
||||
if actions and not fields:
|
||||
for index, action in enumerate(actions):
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
style = str(action.get('style') or 'default')
|
||||
buttons.append(
|
||||
{
|
||||
'tag': 'button',
|
||||
'text': {
|
||||
'tag': 'plain_text',
|
||||
'content': str(action.get('label') or action.get('id') or index + 1),
|
||||
},
|
||||
'type': 'primary' if style == 'primary' else 'danger' if style == 'danger' else 'default',
|
||||
'behaviors': [
|
||||
{
|
||||
'type': 'callback',
|
||||
'value': _callback_value(callback_token, reply_target, a=index),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
|
||||
field = fields[0]
|
||||
field_elements = _field_form_elements(field, callback_token, reply_target)
|
||||
if field_elements is None:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
if not buttons and not field_elements:
|
||||
return None
|
||||
elements: list[dict[str, typing.Any]] = []
|
||||
elements.extend(_submitted_value_elements(submitted_values or []))
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
elements.append({'tag': 'markdown', 'content': description})
|
||||
if field_elements:
|
||||
elements.extend(field_elements)
|
||||
else:
|
||||
elements.append(
|
||||
{
|
||||
'tag': 'column_set',
|
||||
'horizontal_spacing': '8px',
|
||||
'columns': [
|
||||
{
|
||||
'tag': 'column',
|
||||
'width': 'weighted',
|
||||
'weight': 1,
|
||||
'elements': [button],
|
||||
}
|
||||
for button in buttons
|
||||
],
|
||||
}
|
||||
)
|
||||
return {
|
||||
'schema': '2.0',
|
||||
'config': {'update_multi': True},
|
||||
'header': {
|
||||
'title': {'tag': 'plain_text', 'content': str(request.get('title') or '')},
|
||||
},
|
||||
'body': {'direction': 'vertical', 'elements': elements},
|
||||
}
|
||||
|
||||
|
||||
def build_submitted_card(
|
||||
request: dict[str, typing.Any],
|
||||
submission: dict[str, typing.Any],
|
||||
submitted_values: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Render a read-only snapshot after a user submits an interaction."""
|
||||
elements: list[dict[str, typing.Any]] = []
|
||||
all_submitted_values = [
|
||||
*(submitted_values or []),
|
||||
*_submission_display_values(request, submission),
|
||||
]
|
||||
elements.extend(_submitted_value_elements(all_submitted_values))
|
||||
return {
|
||||
'schema': '2.0',
|
||||
'config': {'update_multi': True},
|
||||
'header': {'title': {'tag': 'plain_text', 'content': str(request.get('title') or '')}},
|
||||
'body': {'direction': 'vertical', 'elements': elements},
|
||||
}
|
||||
|
||||
|
||||
async def _update_interaction_card(
|
||||
adapter: typing.Any,
|
||||
update_target: dict[str, typing.Any],
|
||||
card: dict[str, typing.Any],
|
||||
submitted_values: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
message_id = str(update_target.get('message_id') or '')
|
||||
card_id = str(update_target.get('card_id') or '')
|
||||
if not message_id or not card_id or update_target.get('rich') is not True:
|
||||
raise ValueError('Lark interaction update requires a CardKit target')
|
||||
persisted_sequence = int(update_target.get('sequence') or 0)
|
||||
current_sequence = int(adapter.card_sequence_dict.get(card_id, persisted_sequence))
|
||||
sequence = max(persisted_sequence, current_sequence) + 1
|
||||
request = (
|
||||
UpdateCardRequest.builder()
|
||||
.card_id(card_id)
|
||||
.request_body(
|
||||
UpdateCardRequestBody.builder()
|
||||
.sequence(sequence)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.card(Card.builder().type('card_json').data(json.dumps(card, ensure_ascii=False)).build())
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: UpdateCardResponse = await adapter.api_client.cardkit.v1.card.aupdate(request)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark CardKit interaction update failed: {response.code} {response.msg}')
|
||||
adapter.card_sequence_dict[card_id] = sequence
|
||||
result = {
|
||||
'ok': True,
|
||||
'message_id': message_id,
|
||||
'card_id': card_id,
|
||||
'sequence': sequence,
|
||||
'rich': True,
|
||||
'updated': True,
|
||||
}
|
||||
if submitted_values:
|
||||
result['submitted_values'] = submitted_values
|
||||
return result
|
||||
|
||||
|
||||
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
|
||||
update_target = params.get('update_target')
|
||||
submitted_values = _stored_submitted_values(
|
||||
update_target.get('submitted_values') if isinstance(update_target, dict) else None
|
||||
)
|
||||
card = build_interaction_card(request, callback_token, reply_target, submitted_values)
|
||||
if card is None:
|
||||
fallback = str(request.get('fallback_text') or '')
|
||||
result = await adapter.send_message(
|
||||
str(reply_target.get('target_type') or ''),
|
||||
str(reply_target.get('target_id') or ''),
|
||||
adapter._plain_message(fallback),
|
||||
)
|
||||
return {'ok': True, 'message_id': result.message_id, 'rich': False}
|
||||
|
||||
if isinstance(update_target, dict):
|
||||
return await _update_interaction_card(adapter, update_target, card, submitted_values)
|
||||
|
||||
target_type = str(reply_target.get('target_type') or '')
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
if target_type not in {'group', 'person'} or not target_id:
|
||||
raise ValueError('interaction.request has an invalid reply target')
|
||||
card_request = (
|
||||
CreateCardRequest.builder()
|
||||
.request_body(
|
||||
CreateCardRequestBody.builder().type('card_json').data(json.dumps(card, ensure_ascii=False)).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
card_response: CreateCardResponse = await adapter.api_client.cardkit.v1.card.acreate(card_request)
|
||||
if not card_response.success():
|
||||
raise RuntimeError(f'Lark CardKit interaction create failed: {card_response.code} {card_response.msg}')
|
||||
card_id = str(getattr(card_response.data, 'card_id', '') or '')
|
||||
if not card_id:
|
||||
raise RuntimeError('Lark CardKit interaction create returned no card_id')
|
||||
|
||||
message_request = (
|
||||
CreateMessageRequest.builder()
|
||||
.receive_id_type('chat_id' if target_type == 'group' else 'open_id')
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(target_id)
|
||||
.content(json.dumps({'type': 'card', 'data': {'card_id': card_id}}, ensure_ascii=False))
|
||||
.msg_type('interactive')
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: CreateMessageResponse = await adapter.api_client.im.v1.message.acreate(message_request)
|
||||
if not response.success():
|
||||
raise RuntimeError(f'Lark interaction send failed: {response.code} {response.msg}')
|
||||
adapter.card_sequence_dict[card_id] = 0
|
||||
return {
|
||||
'ok': True,
|
||||
'message_id': getattr(response.data, 'message_id', ''),
|
||||
'card_id': card_id,
|
||||
'sequence': 0,
|
||||
'rich': True,
|
||||
}
|
||||
|
||||
|
||||
async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
submission = params.get('submission')
|
||||
update_target = params.get('update_target')
|
||||
if not isinstance(request, dict) or not isinstance(submission, dict) or not isinstance(update_target, dict):
|
||||
raise ValueError('interaction.acknowledge requires request, submission, and update_target')
|
||||
submitted_values = [
|
||||
*_stored_submitted_values(update_target.get('submitted_values')),
|
||||
*_submission_display_values(request, submission),
|
||||
]
|
||||
return await _update_interaction_card(
|
||||
adapter,
|
||||
update_target,
|
||||
build_submitted_card(request, submission, _stored_submitted_values(update_target.get('submitted_values'))),
|
||||
submitted_values,
|
||||
)
|
||||
|
||||
|
||||
def _mapping(value: typing.Any) -> dict[str, typing.Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _action_attr(action: typing.Any, name: str) -> typing.Any:
|
||||
return action.get(name) if isinstance(action, dict) else getattr(action, name, None)
|
||||
|
||||
|
||||
def _coerce_field_value(value: typing.Any, field_type: str) -> typing.Any:
|
||||
if isinstance(value, dict):
|
||||
value = value.get('value') if value.get('value') is not None else value.get('option')
|
||||
if field_type != 'number' or isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError:
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def _form_submission_values(action: typing.Any, payload: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
field_map = payload.get('fm') if isinstance(payload.get('fm'), dict) else {}
|
||||
field_types = payload.get('ft') if isinstance(payload.get('ft'), dict) else {}
|
||||
if not field_map:
|
||||
return {}
|
||||
form_value = _mapping(_action_attr(action, 'form_value'))
|
||||
if not form_value:
|
||||
for key in ('form_value', 'formValue', 'form_values', 'formValues'):
|
||||
form_value = _mapping(payload.get(key))
|
||||
if form_value:
|
||||
break
|
||||
if not form_value:
|
||||
action_name = _action_attr(action, 'name')
|
||||
input_value = _action_attr(action, 'input_value')
|
||||
option_value = _action_attr(action, 'option')
|
||||
if action_name and input_value not in (None, ''):
|
||||
form_value = {str(action_name): input_value}
|
||||
elif action_name and option_value not in (None, ''):
|
||||
form_value = {str(action_name): option_value}
|
||||
values: dict[str, typing.Any] = {}
|
||||
for component_name, value in form_value.items():
|
||||
field_id = field_map.get(component_name)
|
||||
if field_id and value not in (None, ''):
|
||||
values[str(field_id)] = _coerce_field_value(value, str(field_types.get(str(field_id)) or ''))
|
||||
return values
|
||||
|
||||
|
||||
def _event_from_parts(
|
||||
*,
|
||||
raw: typing.Any,
|
||||
action: typing.Any,
|
||||
actor_id: typing.Any,
|
||||
chat_id: typing.Any,
|
||||
message_id: typing.Any,
|
||||
) -> platform_events.PlatformSpecificEvent | None:
|
||||
payload = _mapping(_action_attr(action, 'value'))
|
||||
callback_token = str(payload.get('lbi') or '')
|
||||
if not callback_token:
|
||||
return None
|
||||
target_type = str(payload.get('t') or '')
|
||||
target_id = str(chat_id or '') if target_type == 'group' else str(actor_id or '')
|
||||
data: dict[str, typing.Any] = {
|
||||
'callback_token': callback_token,
|
||||
'actor_id': str(actor_id or ''),
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'display_text': 'submitted',
|
||||
}
|
||||
if payload.get('ck') == 1:
|
||||
data['cardkit'] = True
|
||||
if message_id:
|
||||
data['message_id'] = str(message_id)
|
||||
if payload.get('a') is not None:
|
||||
data['action_ref'] = payload['a']
|
||||
if payload.get('f') is not None or payload.get('o') is not None:
|
||||
data['field_ref'] = payload.get('f')
|
||||
data['option_ref'] = payload.get('o')
|
||||
if payload.get('fm'):
|
||||
data['values'] = _form_submission_values(action, payload)
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='lark-eba',
|
||||
action='interaction.submitted',
|
||||
data=data,
|
||||
timestamp=time.time(),
|
||||
source_platform_object=raw,
|
||||
)
|
||||
|
||||
|
||||
def interaction_event_from_callback(event: typing.Any) -> platform_events.PlatformSpecificEvent | None:
|
||||
action = getattr(getattr(event, 'event', None), 'action', None)
|
||||
operator = getattr(getattr(event, 'event', None), 'operator', None)
|
||||
context = getattr(getattr(event, 'event', None), 'context', None)
|
||||
return _event_from_parts(
|
||||
raw=event,
|
||||
action=action,
|
||||
actor_id=getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None),
|
||||
chat_id=getattr(context, 'open_chat_id', None),
|
||||
message_id=getattr(context, 'open_message_id', None),
|
||||
)
|
||||
|
||||
|
||||
def interaction_event_from_webhook(data: dict[str, typing.Any]) -> platform_events.PlatformSpecificEvent | None:
|
||||
event = data.get('event') if isinstance(data.get('event'), dict) else {}
|
||||
action = event.get('action') if isinstance(event.get('action'), dict) else {}
|
||||
operator = event.get('operator') if isinstance(event.get('operator'), dict) else {}
|
||||
context = event.get('context') if isinstance(event.get('context'), dict) else {}
|
||||
return _event_from_parts(
|
||||
raw=data,
|
||||
action=action,
|
||||
actor_id=operator.get('open_id') or operator.get('user_id'),
|
||||
chat_id=context.get('open_chat_id'),
|
||||
message_id=context.get('open_message_id'),
|
||||
)
|
||||
@@ -164,6 +164,8 @@ spec:
|
||||
- get_user_info
|
||||
- get_file_url
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
- interaction.acknowledge
|
||||
|
||||
platform_specific_apis:
|
||||
- action: check_tenant_access_token
|
||||
|
||||
@@ -23,6 +23,11 @@ from langbot.pkg.platform.adapters.qqofficial.event_converter import (
|
||||
)
|
||||
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
|
||||
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.qqofficial.interaction import (
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_payload,
|
||||
send_interaction,
|
||||
)
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
@@ -117,8 +122,16 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
'get_group_member_list',
|
||||
'get_group_member_info',
|
||||
'call_platform_api',
|
||||
'interaction.request',
|
||||
]
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
@staticmethod
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -149,6 +162,8 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw})
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
@@ -296,6 +311,15 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
||||
):
|
||||
self.bot.on_message(event_type)(self._handle_native_event)
|
||||
|
||||
@self.bot.on_interaction()
|
||||
async def on_interaction(event_data: dict, ws_event_id: str | None):
|
||||
interaction_id = str(event_data.get('id') or '')
|
||||
if interaction_id:
|
||||
await self.bot.ack_interaction(interaction_id)
|
||||
event = interaction_event_from_payload(event_data)
|
||||
if event is not None:
|
||||
await self._dispatch_eba_event(event)
|
||||
|
||||
async def _handle_native_event(self, event: QQOfficialEvent):
|
||||
self.bot_account_id = self.config.get('appid', self.bot_account_id)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""QQ Official keyboard rendering and interaction callback conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
return {
|
||||
'field_types': ['select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def _buttons(request: dict[str, typing.Any], callback_token: str) -> list[tuple[str, str, int]]:
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
if actions and not fields:
|
||||
return [
|
||||
(
|
||||
str(action.get('label') or action.get('id') or index + 1),
|
||||
f'lbi:{callback_token}:a:{index}',
|
||||
1 if action.get('style') == 'primary' else 0,
|
||||
)
|
||||
for index, action in enumerate(actions[:25])
|
||||
if isinstance(action, dict)
|
||||
]
|
||||
if len(fields) == 1 and not actions and isinstance(fields[0], dict):
|
||||
field = fields[0]
|
||||
if field.get('type') != 'select':
|
||||
return []
|
||||
options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
return [
|
||||
(
|
||||
str(option.get('label') or option.get('value') or index + 1),
|
||||
f'lbi:{callback_token}:f:0:{index}',
|
||||
0,
|
||||
)
|
||||
for index, option in enumerate(options[:25])
|
||||
if isinstance(option, dict)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def build_interaction_keyboard(request: dict[str, typing.Any], callback_token: str) -> dict[str, typing.Any] | None:
|
||||
buttons = _buttons(request, callback_token)
|
||||
if not buttons:
|
||||
return None
|
||||
rows = []
|
||||
for start in range(0, len(buttons), 2):
|
||||
rows.append(
|
||||
{
|
||||
'buttons': [
|
||||
{
|
||||
'id': str(start + offset + 1),
|
||||
'render_data': {
|
||||
'label': label,
|
||||
'visited_label': f'✓ {label}',
|
||||
'style': style,
|
||||
},
|
||||
'action': {
|
||||
'type': 1,
|
||||
'permission': {'type': 2},
|
||||
'data': callback_data,
|
||||
'unsupport_tips': 'Please update QQ to use this action.',
|
||||
},
|
||||
}
|
||||
for offset, (label, callback_data, style) in enumerate(buttons[start : start + 2])
|
||||
]
|
||||
}
|
||||
)
|
||||
return {'content': {'rows': rows}}
|
||||
|
||||
|
||||
def _text(request: dict[str, typing.Any], rich: bool) -> str:
|
||||
parts = [str(request.get('title') or '').strip()]
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
parts.append(description)
|
||||
if not rich:
|
||||
fallback = str(request.get('fallback_text') or '').strip()
|
||||
if fallback:
|
||||
parts.append(fallback)
|
||||
return '\n\n'.join(part for part in parts if part)
|
||||
|
||||
|
||||
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
target_type = str(reply_target.get('target_type') or '')
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
keyboard = build_interaction_keyboard(request, callback_token)
|
||||
if keyboard is None or target_type not in {'person', 'group'}:
|
||||
result = await adapter.send_message(
|
||||
target_type,
|
||||
target_id,
|
||||
adapter._plain_message(_text(request, False)),
|
||||
)
|
||||
return {'ok': True, 'message_id': result.message_id, 'rich': False}
|
||||
raw = await adapter.bot.send_markdown_keyboard(
|
||||
target_type='c2c' if target_type == 'person' else 'group',
|
||||
target_id=target_id,
|
||||
markdown_content=_text(request, True),
|
||||
keyboard=keyboard,
|
||||
msg_id=reply_target.get('message_id'),
|
||||
)
|
||||
return {'ok': True, 'message_id': raw.get('id') if isinstance(raw, dict) else None, 'rich': True}
|
||||
|
||||
|
||||
def parse_callback_data(value: str) -> dict[str, typing.Any]:
|
||||
parts = value.split(':')
|
||||
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
|
||||
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
|
||||
if (
|
||||
len(parts) == 5
|
||||
and parts[0] == 'lbi'
|
||||
and parts[1]
|
||||
and parts[2] == 'f'
|
||||
and parts[3].isdigit()
|
||||
and parts[4].isdigit()
|
||||
):
|
||||
return {
|
||||
'callback_token': parts[1],
|
||||
'field_ref': int(parts[3]),
|
||||
'option_ref': int(parts[4]),
|
||||
}
|
||||
raise ValueError('invalid QQ interaction callback data')
|
||||
|
||||
|
||||
def interaction_event_from_payload(
|
||||
event_data: dict[str, typing.Any],
|
||||
) -> platform_events.PlatformSpecificEvent | None:
|
||||
resolved = (event_data.get('data') or {}).get('resolved') or {}
|
||||
callback_data = str(resolved.get('button_data') or '')
|
||||
if not callback_data.startswith('lbi:'):
|
||||
return None
|
||||
parsed = parse_callback_data(callback_data)
|
||||
chat_type = event_data.get('chat_type')
|
||||
if chat_type == 2 or event_data.get('user_openid'):
|
||||
target_type = 'person'
|
||||
target_id = str(event_data.get('user_openid') or '')
|
||||
elif chat_type == 1 or event_data.get('group_openid'):
|
||||
target_type = 'group'
|
||||
target_id = str(event_data.get('group_openid') or '')
|
||||
elif chat_type == 0 or event_data.get('channel_id'):
|
||||
target_type = 'group'
|
||||
target_id = str(event_data.get('channel_id') or '')
|
||||
else:
|
||||
raise ValueError('QQ interaction callback has no target')
|
||||
actor_id = str(
|
||||
event_data.get('member_openid')
|
||||
or event_data.get('user_openid')
|
||||
or ((event_data.get('member') or {}).get('user') or {}).get('id')
|
||||
or ''
|
||||
)
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='qqofficial-eba',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
'actor_id': actor_id,
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'display_text': 'submitted',
|
||||
},
|
||||
timestamp=time.time(),
|
||||
source_platform_object=event_data,
|
||||
)
|
||||
@@ -107,6 +107,7 @@ spec:
|
||||
- get_group_member_list
|
||||
- get_group_member_info
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
|
||||
platform_specific_apis:
|
||||
- action: check_access_token
|
||||
|
||||
@@ -34,6 +34,12 @@ from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMes
|
||||
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter, LegacyEventConverter
|
||||
from langbot.pkg.platform.adapters.telegram.api_impl import TelegramAPIMixin
|
||||
from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.telegram.interaction import (
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_update,
|
||||
parse_interaction_callback,
|
||||
send_interaction,
|
||||
)
|
||||
|
||||
|
||||
class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
|
||||
@@ -79,6 +85,23 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
return
|
||||
|
||||
try:
|
||||
if update.callback_query:
|
||||
try:
|
||||
interaction_callback = parse_interaction_callback(update.callback_query.data)
|
||||
except ValueError:
|
||||
await update.callback_query.answer(text='Invalid or expired action', show_alert=True)
|
||||
await self.logger.warning('Rejected malformed Telegram interaction callback')
|
||||
return
|
||||
if interaction_callback is not None:
|
||||
await update.callback_query.answer()
|
||||
event = interaction_event_from_update(update, interaction_callback)
|
||||
await self._dispatch_eba_event(event)
|
||||
try:
|
||||
await update.callback_query.edit_message_reply_markup(reply_markup=None)
|
||||
except Exception:
|
||||
await self.logger.warning('Failed to clear Telegram interaction buttons')
|
||||
return
|
||||
|
||||
# Legacy event type callbacks (compat with existing botmgr FriendMessage / GroupMessage listeners)
|
||||
if update.message and (
|
||||
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
|
||||
@@ -176,8 +199,12 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
'kick_member',
|
||||
'leave_group',
|
||||
'call_platform_api',
|
||||
'interaction.request',
|
||||
]
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
# ---- Message Send / Reply (preserving original logic) ----
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
@@ -410,6 +437,8 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
params: dict = {},
|
||||
) -> dict:
|
||||
"""Call a Telegram-specific platform API."""
|
||||
if action == 'interaction.request':
|
||||
return await send_interaction(self.bot, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
@@ -406,10 +406,11 @@ class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
source_platform_object=event,
|
||||
)
|
||||
else:
|
||||
sender = event.message.from_user
|
||||
return legacy_events.GroupMessage(
|
||||
sender=legacy_entities.GroupMember(
|
||||
id=event.effective_chat.id,
|
||||
member_name=event.effective_chat.title,
|
||||
id=sender.id if sender else '',
|
||||
member_name=sender.first_name if sender else '',
|
||||
permission=legacy_entities.Permission.Member,
|
||||
group=legacy_entities.Group(
|
||||
id=event.effective_chat.id,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Telegram rendering and callback conversion for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
|
||||
import telegram
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
|
||||
|
||||
INTERACTION_CALLBACK_PREFIX = 'lbi'
|
||||
|
||||
|
||||
def parse_interaction_callback(data: str | None) -> dict[str, typing.Any] | None:
|
||||
"""Parse a compact interaction callback without trusting platform values."""
|
||||
if not data or not data.startswith(f'{INTERACTION_CALLBACK_PREFIX}:'):
|
||||
return None
|
||||
parts = data.split(':')
|
||||
if len(parts) == 4 and parts[2] == 'a':
|
||||
token, action_ref = parts[1], parts[3]
|
||||
if token and action_ref.isdigit():
|
||||
return {'callback_token': token, 'action_ref': int(action_ref)}
|
||||
if len(parts) == 5 and parts[2] == 'f':
|
||||
token, field_ref, option_ref = parts[1], parts[3], parts[4]
|
||||
if token and field_ref.isdigit() and option_ref.isdigit():
|
||||
return {
|
||||
'callback_token': token,
|
||||
'field_ref': int(field_ref),
|
||||
'option_ref': int(option_ref),
|
||||
}
|
||||
raise ValueError('invalid Telegram interaction callback data')
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
"""Return the structured interaction subset Telegram can render natively."""
|
||||
return {
|
||||
'field_types': ['select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def _callback_data(callback_token: str, *parts: str | int) -> str:
|
||||
value = ':'.join((INTERACTION_CALLBACK_PREFIX, callback_token, *(str(part) for part in parts)))
|
||||
if len(value.encode('utf-8')) > 64:
|
||||
raise ValueError('Telegram interaction callback exceeds 64 bytes')
|
||||
return value
|
||||
|
||||
|
||||
def _build_keyboard(request: dict[str, typing.Any], callback_token: str) -> telegram.InlineKeyboardMarkup | None:
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
|
||||
if actions and not fields:
|
||||
rows = [
|
||||
[
|
||||
telegram.InlineKeyboardButton(
|
||||
str(action.get('label') or action.get('id') or index + 1),
|
||||
callback_data=_callback_data(callback_token, 'a', index),
|
||||
)
|
||||
]
|
||||
for index, action in enumerate(actions)
|
||||
if isinstance(action, dict)
|
||||
]
|
||||
return telegram.InlineKeyboardMarkup(rows) if rows else None
|
||||
|
||||
if len(fields) == 1 and not actions:
|
||||
field = fields[0]
|
||||
if not isinstance(field, dict) or field.get('type') != 'select':
|
||||
return None
|
||||
options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
rows = [
|
||||
[
|
||||
telegram.InlineKeyboardButton(
|
||||
str(option.get('label') or option.get('value') or option_index + 1),
|
||||
callback_data=_callback_data(callback_token, 'f', 0, option_index),
|
||||
)
|
||||
]
|
||||
for option_index, option in enumerate(options)
|
||||
if isinstance(option, dict)
|
||||
]
|
||||
return telegram.InlineKeyboardMarkup(rows) if rows else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _message_text(request: dict[str, typing.Any], *, rich: bool) -> str:
|
||||
parts = [str(request.get('title') or '').strip()]
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
parts.append(description)
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
if rich and len(fields) == 1 and isinstance(fields[0], dict):
|
||||
label = str(fields[0].get('label') or '').strip()
|
||||
if label:
|
||||
parts.append(label)
|
||||
if not rich:
|
||||
fallback = str(request.get('fallback_text') or '').strip()
|
||||
if fallback:
|
||||
parts.append(fallback)
|
||||
return '\n\n'.join(part for part in parts if part)
|
||||
|
||||
|
||||
async def send_interaction(bot: telegram.Bot, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
"""Render a supported interaction or its required plain-text fallback."""
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
if not target_id:
|
||||
raise ValueError('interaction.request has no target_id')
|
||||
chat_id_text, _, thread_id_text = target_id.partition('#')
|
||||
chat_id: int | str = int(chat_id_text) if chat_id_text.lstrip('-').isdigit() else chat_id_text
|
||||
message_thread_id = int(thread_id_text) if thread_id_text.isdigit() else None
|
||||
keyboard = _build_keyboard(request, callback_token)
|
||||
send_args: dict[str, typing.Any] = {
|
||||
'chat_id': chat_id,
|
||||
'text': _message_text(request, rich=keyboard is not None),
|
||||
}
|
||||
if message_thread_id is not None:
|
||||
send_args['message_thread_id'] = message_thread_id
|
||||
if keyboard is not None:
|
||||
send_args['reply_markup'] = keyboard
|
||||
|
||||
sent = await bot.send_message(**send_args)
|
||||
return {'ok': True, 'message_id': getattr(sent, 'message_id', None), 'rich': keyboard is not None}
|
||||
|
||||
|
||||
def interaction_event_from_update(
|
||||
update: telegram.Update,
|
||||
parsed: dict[str, typing.Any],
|
||||
) -> platform_events.PlatformSpecificEvent:
|
||||
"""Convert a trusted callback shape into the Host interaction event."""
|
||||
query = update.callback_query
|
||||
if query is None or query.message is None or query.from_user is None:
|
||||
raise ValueError('Telegram interaction callback has no message or actor')
|
||||
message = query.message
|
||||
target_type = 'person' if message.chat.type == 'private' else 'group'
|
||||
target_id = str(message.chat.id)
|
||||
if message.message_thread_id:
|
||||
target_id = f'{target_id}#{message.message_thread_id}'
|
||||
|
||||
data = {
|
||||
**parsed,
|
||||
'actor_id': str(query.from_user.id),
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'display_text': 'submitted',
|
||||
}
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
timestamp=time.time(),
|
||||
adapter_name='telegram',
|
||||
action='interaction.submitted',
|
||||
data=data,
|
||||
source_platform_object=update,
|
||||
)
|
||||
@@ -72,6 +72,7 @@ spec:
|
||||
- kick_member
|
||||
- leave_group
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
|
||||
platform_specific_apis:
|
||||
- action: pin_message
|
||||
|
||||
@@ -14,6 +14,11 @@ from langbot.pkg.platform.adapters.wecombot.api_impl import WecomBotAPIMixin
|
||||
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
|
||||
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
|
||||
from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.wecombot.interaction import (
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_native,
|
||||
send_interaction,
|
||||
)
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
@@ -100,7 +105,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return [
|
||||
apis = [
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'get_message',
|
||||
@@ -111,6 +116,16 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
'get_group_member_list',
|
||||
'call_platform_api',
|
||||
]
|
||||
if not self.config.get('enable-webhook', False) and hasattr(self.bot, 'send_template_card'):
|
||||
apis.append('interaction.request')
|
||||
return apis
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
return interaction_delivery_capabilities()
|
||||
|
||||
@staticmethod
|
||||
def _plain_message(text: str) -> platform_message.MessageChain:
|
||||
return platform_message.MessageChain([platform_message.Plain(text=text)])
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -163,6 +178,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
return self.config.get('enable-stream-reply', True)
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request' and 'interaction.request' in self.get_supported_apis():
|
||||
return await send_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
@@ -229,6 +246,15 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
self.bot.on_feedback()(self._handle_feedback)
|
||||
if hasattr(self.bot, 'on_message'):
|
||||
self.bot.on_message('event')(self._handle_native_event)
|
||||
self.bot.on_message('template_card_event')(self._handle_interaction_event)
|
||||
|
||||
async def _handle_interaction_event(self, event: WecomBotEvent):
|
||||
try:
|
||||
interaction_event = interaction_event_from_native(event)
|
||||
if interaction_event is not None:
|
||||
await self._dispatch_eba_event(interaction_event)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in WeComBot interaction callback: {traceback.format_exc()}')
|
||||
|
||||
async def _handle_native_event(self, event: WecomBotEvent):
|
||||
try:
|
||||
@@ -277,6 +303,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
|
||||
def _cleanup_stream_mapping(self):
|
||||
now = time.time()
|
||||
expired = [key for key, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL]
|
||||
expired = [
|
||||
key for key, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL
|
||||
]
|
||||
for key in expired:
|
||||
del self._stream_to_monitoring_msg[key]
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""WeCom template-card rendering and callbacks for structured interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
|
||||
return {
|
||||
'field_types': ['select'],
|
||||
'action_styles': ['default', 'primary', 'danger'],
|
||||
'supports_updates': True,
|
||||
'max_fields': 1,
|
||||
}
|
||||
|
||||
|
||||
def _button_style(style: str) -> int:
|
||||
if style == 'primary':
|
||||
return 1
|
||||
if style == 'danger':
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
def build_interaction_card(request: dict[str, typing.Any], callback_token: str) -> dict[str, typing.Any] | None:
|
||||
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
buttons: list[dict[str, typing.Any]] = []
|
||||
if actions and not fields:
|
||||
buttons = [
|
||||
{
|
||||
'text': str(action.get('label') or action.get('id') or index + 1),
|
||||
'style': _button_style(str(action.get('style') or 'default')),
|
||||
'key': f'lbi:{callback_token}:a:{index}',
|
||||
}
|
||||
for index, action in enumerate(actions[:6])
|
||||
if isinstance(action, dict)
|
||||
]
|
||||
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
|
||||
field = fields[0]
|
||||
if field.get('type') != 'select':
|
||||
return None
|
||||
options = field.get('options') if isinstance(field.get('options'), list) else []
|
||||
buttons = [
|
||||
{
|
||||
'text': str(option.get('label') or option.get('value') or index + 1),
|
||||
'style': 0,
|
||||
'key': f'lbi:{callback_token}:f:0:{index}',
|
||||
}
|
||||
for index, option in enumerate(options[:6])
|
||||
if isinstance(option, dict)
|
||||
]
|
||||
if not buttons:
|
||||
return None
|
||||
description = str(request.get('description') or '').strip()
|
||||
if len(fields) == 1 and isinstance(fields[0], dict):
|
||||
label = str(fields[0].get('label') or '').strip()
|
||||
description = '\n\n'.join(part for part in (description, label) if part)
|
||||
return {
|
||||
'msgtype': 'template_card',
|
||||
'template_card': {
|
||||
'card_type': 'button_interaction',
|
||||
'main_title': {'title': str(request.get('title') or '')},
|
||||
'sub_title_text': description,
|
||||
'button_list': buttons,
|
||||
'task_id': f'lbi-{uuid.uuid4().hex}',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
reply_target = params.get('reply_target')
|
||||
callback_token = str(params.get('callback_token') or '')
|
||||
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
|
||||
raise ValueError('interaction.request requires request, reply_target, and callback_token')
|
||||
target_type = str(reply_target.get('target_type') or '')
|
||||
target_id = str(reply_target.get('target_id') or '')
|
||||
card = build_interaction_card(request, callback_token)
|
||||
if card is None:
|
||||
fallback = '\n\n'.join(
|
||||
part
|
||||
for part in (
|
||||
str(request.get('title') or '').strip(),
|
||||
str(request.get('description') or '').strip(),
|
||||
str(request.get('fallback_text') or '').strip(),
|
||||
)
|
||||
if part
|
||||
)
|
||||
result = await adapter.send_message(target_type, target_id, adapter._plain_message(fallback))
|
||||
return {'ok': True, 'message_id': result.message_id, 'rich': False}
|
||||
raw = await adapter.bot.send_template_card(target_id, card)
|
||||
return {'ok': True, 'message_id': None, 'rich': True, 'raw': raw}
|
||||
|
||||
|
||||
def _template_card_event(event: WecomBotEvent) -> dict[str, typing.Any]:
|
||||
wrapper = event.get('event') or {}
|
||||
if not isinstance(wrapper, dict):
|
||||
return {}
|
||||
for key in ('template_card_event', 'templateCardEvent', 'TemplateCardEvent'):
|
||||
value = wrapper.get(key)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return wrapper
|
||||
|
||||
|
||||
def _callback_key(payload: dict[str, typing.Any]) -> str:
|
||||
value = payload.get('EventKey') or payload.get('event_key') or payload.get('eventKey') or payload.get('key') or ''
|
||||
if value:
|
||||
return str(value)
|
||||
for button_name in ('button', 'Button', 'selected_button', 'selectedButton'):
|
||||
button = payload.get(button_name)
|
||||
if not isinstance(button, dict):
|
||||
continue
|
||||
value = button.get('key') or button.get('Key') or button.get('event_key') or button.get('EventKey') or ''
|
||||
if value:
|
||||
return str(value)
|
||||
return ''
|
||||
|
||||
|
||||
def parse_callback_key(value: str) -> dict[str, typing.Any]:
|
||||
parts = value.split(':')
|
||||
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
|
||||
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
|
||||
if (
|
||||
len(parts) == 5
|
||||
and parts[0] == 'lbi'
|
||||
and parts[1]
|
||||
and parts[2] == 'f'
|
||||
and parts[3].isdigit()
|
||||
and parts[4].isdigit()
|
||||
):
|
||||
return {
|
||||
'callback_token': parts[1],
|
||||
'field_ref': int(parts[3]),
|
||||
'option_ref': int(parts[4]),
|
||||
}
|
||||
raise ValueError('invalid WeCom interaction callback key')
|
||||
|
||||
|
||||
def interaction_event_from_native(
|
||||
event: WecomBotEvent,
|
||||
) -> platform_events.PlatformSpecificEvent | None:
|
||||
callback_key = _callback_key(_template_card_event(event))
|
||||
if not callback_key.startswith('lbi:'):
|
||||
return None
|
||||
parsed = parse_callback_key(callback_key)
|
||||
target_type = 'group' if event.type == 'group' or event.chatid else 'person'
|
||||
target_id = str(event.chatid or event.userid or '')
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='wecombot-eba',
|
||||
action='interaction.submitted',
|
||||
data={
|
||||
**parsed,
|
||||
'actor_id': str(event.userid or ''),
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'display_text': 'submitted',
|
||||
},
|
||||
timestamp=time.time(),
|
||||
source_platform_object=event,
|
||||
)
|
||||
@@ -143,6 +143,7 @@ spec:
|
||||
- get_group_member_info
|
||||
- get_group_member_list
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
|
||||
platform_specific_apis:
|
||||
- action: is_websocket_mode
|
||||
|
||||
@@ -996,6 +996,27 @@ class RuntimeBot:
|
||||
elif getattr(event, 'session_id', None):
|
||||
conversation_id = str(getattr(event, 'session_id'))
|
||||
|
||||
delivery_data: dict[str, typing.Any] = {
|
||||
'surface': 'platform',
|
||||
'reply_target': {
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'message_id': getattr(event, 'message_id', None),
|
||||
**target_metadata,
|
||||
},
|
||||
'supports_streaming': False,
|
||||
'supports_edit': 'edit_message' in supported_apis,
|
||||
'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
|
||||
'platform_capabilities': {
|
||||
'adapter': adapter.__class__.__name__,
|
||||
'event_type': event_type,
|
||||
'supported_apis': supported_apis,
|
||||
},
|
||||
}
|
||||
interaction_capabilities = self._get_adapter_interaction_capabilities(adapter, supported_apis)
|
||||
if interaction_capabilities is not None:
|
||||
delivery_data['interactions'] = interaction_capabilities
|
||||
|
||||
return AgentEventEnvelope(
|
||||
event_id=f'platform:{self.bot_entity.uuid}:{event_id}',
|
||||
event_type=event_type,
|
||||
@@ -1009,23 +1030,7 @@ class RuntimeBot:
|
||||
actor=self._infer_actor_context(event),
|
||||
subject=self._infer_subject_context(event),
|
||||
input=self._build_agent_input(event),
|
||||
delivery=DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target={
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'message_id': getattr(event, 'message_id', None),
|
||||
**target_metadata,
|
||||
},
|
||||
supports_streaming=False,
|
||||
supports_edit='edit_message' in supported_apis,
|
||||
supports_reaction=bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
|
||||
platform_capabilities={
|
||||
'adapter': adapter.__class__.__name__,
|
||||
'event_type': event_type,
|
||||
'supported_apis': supported_apis,
|
||||
},
|
||||
),
|
||||
delivery=DeliveryContext.model_validate(delivery_data),
|
||||
raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None),
|
||||
data=self._compact_event_data(event),
|
||||
)
|
||||
@@ -1045,6 +1050,22 @@ class RuntimeBot:
|
||||
return []
|
||||
return list(dict.fromkeys(api_name for api_name in declared_apis if isinstance(api_name, str) and api_name))
|
||||
|
||||
@staticmethod
|
||||
def _get_adapter_interaction_capabilities(
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
supported_apis: list[str],
|
||||
) -> dict[str, typing.Any] | None:
|
||||
if 'interactions' not in DeliveryContext.model_fields or 'interaction.request' not in supported_apis:
|
||||
return None
|
||||
get_capabilities = getattr(adapter, 'get_interaction_capabilities', None)
|
||||
if not callable(get_capabilities):
|
||||
return None
|
||||
try:
|
||||
capabilities = get_capabilities()
|
||||
except Exception:
|
||||
return None
|
||||
return capabilities if isinstance(capabilities, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _agent_product_to_binding(
|
||||
agent: dict[str, typing.Any],
|
||||
@@ -1068,9 +1089,15 @@ class RuntimeBot:
|
||||
runner_config=runner_config,
|
||||
resource_policy=ResourcePolicyProjector.from_runner_config(runner_config),
|
||||
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
|
||||
delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True),
|
||||
delivery_policy=DeliveryPolicy(
|
||||
enable_streaming=False,
|
||||
enable_reply=True,
|
||||
enable_interactions=True,
|
||||
),
|
||||
enabled=True,
|
||||
agent_id=agent.get('uuid'),
|
||||
processor_type='agent',
|
||||
processor_id=agent.get('uuid'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -1137,6 +1164,10 @@ class RuntimeBot:
|
||||
event: platform_events.EBAEvent,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
) -> None:
|
||||
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
|
||||
await self._handle_interaction_submission(event, adapter)
|
||||
return
|
||||
|
||||
event.bot_uuid = self.bot_entity.uuid
|
||||
plugin_event = self._eba_event_to_plugin_event(event)
|
||||
|
||||
@@ -1303,7 +1334,11 @@ class RuntimeBot:
|
||||
envelope = self._eba_event_to_agent_envelope(event, adapter)
|
||||
outputs: list[provider_message.Message | provider_message.MessageChunk] = []
|
||||
try:
|
||||
async for output in self.ap.agent_run_orchestrator.run(envelope, binding):
|
||||
async for output in self.ap.agent_run_orchestrator.run(
|
||||
envelope,
|
||||
binding,
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
):
|
||||
outputs.append(output)
|
||||
except Exception:
|
||||
return await self._record_event_route_trace(
|
||||
@@ -1408,6 +1443,7 @@ class RuntimeBot:
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
pipeline_uuid_override: str | None = None,
|
||||
routed_by_event_binding: bool = False,
|
||||
variables: dict[str, typing.Any] | None = None,
|
||||
) -> None:
|
||||
is_group_message = isinstance(event, platform_events.GroupMessage)
|
||||
launcher_kind = 'group' if is_group_message else 'person'
|
||||
@@ -1475,8 +1511,197 @@ class RuntimeBot:
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
async def _handle_interaction_submission(
|
||||
self,
|
||||
event: platform_events.PlatformSpecificEvent,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
) -> None:
|
||||
"""Consume a platform interaction callback and resume its original processor."""
|
||||
data = event.data if isinstance(event.data, dict) else {}
|
||||
callback_token = str(data.get('callback_token') or '')
|
||||
actor_id = str(data.get('actor_id') or data.get('user_id') or '') or None
|
||||
target_type = str(data.get('target_type') or '') or None
|
||||
target_id = str(data.get('target_id') or data.get('chat_id') or '') or None
|
||||
conversation_id = f'{target_type}_{target_id}' if target_type and target_id else None
|
||||
submission = {
|
||||
'interaction_id': data.get('interaction_id'),
|
||||
'action_id': data.get('action_id'),
|
||||
'values': data.get('values') if isinstance(data.get('values'), dict) else {},
|
||||
'submitted_at': int(event.timestamp) if event.timestamp else None,
|
||||
}
|
||||
for ref_name in ('action_ref', 'field_ref', 'option_ref'):
|
||||
if data.get(ref_name) is not None:
|
||||
submission[ref_name] = data[ref_name]
|
||||
|
||||
record = await self.ap.agent_run_orchestrator.interaction_manager.consume_callback(
|
||||
callback_token=callback_token,
|
||||
submission=submission,
|
||||
bot_id=self.bot_entity.uuid,
|
||||
conversation_id=conversation_id,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
await self.ap.agent_run_orchestrator.interaction_manager.acknowledge_submission(record, adapter)
|
||||
|
||||
if record['processor_type'] == 'agent':
|
||||
await self._resume_agent_interaction(record, event, adapter, actor_id)
|
||||
return
|
||||
if record['processor_type'] != 'pipeline':
|
||||
raise ValueError(f'Unsupported interaction processor type: {record["processor_type"]}')
|
||||
|
||||
pipeline = await self.ap.pipeline_service.get_pipeline(record['processor_id'])
|
||||
if not pipeline:
|
||||
raise ValueError(f'Interaction target Pipeline is unavailable: {record["processor_id"]}')
|
||||
current_runner_id = RunnerConfigResolver.resolve_runner_id(pipeline.get('config') or {})
|
||||
if current_runner_id != record['runner_id']:
|
||||
raise ValueError('Interaction target Pipeline runner changed after the request was created')
|
||||
|
||||
message_components: list[platform_message.MessageComponent] = []
|
||||
callback_message_id = data.get('message_id')
|
||||
if callback_message_id:
|
||||
message_components.append(
|
||||
platform_message.Source(
|
||||
id=str(callback_message_id),
|
||||
time=int(event.timestamp) if event.timestamp else int(time.time()),
|
||||
)
|
||||
)
|
||||
message_components.append(
|
||||
platform_message.Plain(text=str(data.get('display_text') or data.get('action_id') or 'submitted'))
|
||||
)
|
||||
message_chain = platform_message.MessageChain(message_components)
|
||||
delivery_target = record.get('delivery_target') or {}
|
||||
original_target_type = delivery_target.get('target_type')
|
||||
original_target_id = delivery_target.get('target_id')
|
||||
if original_target_type == 'group':
|
||||
group = platform_entities.Group(
|
||||
id=original_target_id,
|
||||
name='',
|
||||
permission=platform_entities.Permission.Member,
|
||||
)
|
||||
message_event: platform_events.MessageEvent = platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=actor_id or '',
|
||||
member_name='',
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=group,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.timestamp,
|
||||
source_platform_object=event.source_platform_object,
|
||||
)
|
||||
else:
|
||||
message_event = platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(id=actor_id or '', nickname='', remark=''),
|
||||
message_chain=message_chain,
|
||||
time=event.timestamp,
|
||||
source_platform_object=event.source_platform_object,
|
||||
)
|
||||
|
||||
launcher_type = (
|
||||
provider_session.LauncherTypes.GROUP
|
||||
if original_target_type == 'group'
|
||||
else provider_session.LauncherTypes.PERSON
|
||||
)
|
||||
await self.ap.msg_aggregator.add_message(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=launcher_type,
|
||||
launcher_id=original_target_id or target_id or actor_id or '',
|
||||
sender_id=actor_id or '',
|
||||
message_event=message_event,
|
||||
message_chain=message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=record['processor_id'],
|
||||
routed_by_rule=True,
|
||||
variables={'_interaction_submission': record['submission']},
|
||||
)
|
||||
|
||||
async def _resume_agent_interaction(
|
||||
self,
|
||||
record: dict[str, typing.Any],
|
||||
event: platform_events.PlatformSpecificEvent,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
actor_id: str | None,
|
||||
) -> None:
|
||||
"""Resume the exact independent Agent that produced an interaction."""
|
||||
agent = await self.ap.agent_service.get_agent(record['processor_id'])
|
||||
if not agent or agent.get('kind') != 'agent' or not agent.get('enabled', True):
|
||||
raise ValueError(f'Interaction target Agent is unavailable: {record["processor_id"]}')
|
||||
|
||||
binding = self._agent_product_to_binding(
|
||||
agent,
|
||||
{'id': record['binding_id']},
|
||||
'interaction.submitted',
|
||||
self.bot_entity.uuid,
|
||||
)
|
||||
if binding is None or binding.runner_id != record['runner_id']:
|
||||
raise ValueError('Interaction target Agent runner changed after the request was created')
|
||||
binding.binding_id = record['binding_id']
|
||||
|
||||
submission = record['submission']
|
||||
display_text = str(
|
||||
(submission or {}).get('action_id')
|
||||
or (event.data if isinstance(event.data, dict) else {}).get('display_text')
|
||||
or 'submitted'
|
||||
)
|
||||
input_data: dict[str, typing.Any] = {
|
||||
'text': display_text,
|
||||
'contents': [{'type': 'text', 'text': display_text}],
|
||||
'attachments': [],
|
||||
}
|
||||
if 'interaction' in AgentInput.model_fields:
|
||||
input_data['interaction'] = submission
|
||||
|
||||
delivery_target = record.get('delivery_target') or {}
|
||||
supported_apis = self._get_adapter_supported_apis(adapter)
|
||||
delivery_data: dict[str, typing.Any] = {
|
||||
'surface': 'platform',
|
||||
'reply_target': delivery_target,
|
||||
'supports_streaming': False,
|
||||
'supports_edit': 'edit_message' in supported_apis,
|
||||
'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
|
||||
'platform_capabilities': {
|
||||
'adapter': adapter.__class__.__name__,
|
||||
'event_type': 'interaction.submitted',
|
||||
'supported_apis': supported_apis,
|
||||
},
|
||||
}
|
||||
interaction_capabilities = self._get_adapter_interaction_capabilities(adapter, supported_apis)
|
||||
if interaction_capabilities is not None:
|
||||
delivery_data['interactions'] = interaction_capabilities
|
||||
|
||||
envelope = AgentEventEnvelope(
|
||||
event_id=f'interaction:{record["id"]}:{uuid.uuid4()}',
|
||||
event_type='interaction.submitted',
|
||||
event_time=int(event.timestamp) if event.timestamp else int(time.time()),
|
||||
source='platform',
|
||||
source_event_type='interaction.submitted',
|
||||
bot_id=self.bot_entity.uuid,
|
||||
workspace_id=record.get('workspace_id'),
|
||||
conversation_id=record.get('conversation_id'),
|
||||
thread_id=record.get('thread_id'),
|
||||
actor=ActorContext(actor_type='user', actor_id=actor_id),
|
||||
subject=SubjectContext(
|
||||
subject_type='interaction',
|
||||
subject_id=record['interaction_id'],
|
||||
data={'action_id': (submission or {}).get('action_id')},
|
||||
),
|
||||
input=AgentInput.model_validate(input_data),
|
||||
delivery=DeliveryContext.model_validate(delivery_data),
|
||||
raw_ref=RawEventRef(ref_id=f'interaction:{record["id"]}', storage_key=None),
|
||||
data={'interaction': submission},
|
||||
)
|
||||
|
||||
outputs: list[provider_message.Message | provider_message.MessageChunk] = []
|
||||
async for output in self.ap.agent_run_orchestrator.run(
|
||||
envelope,
|
||||
binding,
|
||||
adapter_context={'_delivery_adapter': adapter},
|
||||
):
|
||||
outputs.append(output)
|
||||
await self._deliver_agent_outputs(envelope, outputs, adapter=adapter)
|
||||
|
||||
async def _dispatch_eba_message_to_pipeline(
|
||||
self,
|
||||
event: platform_events.EBAEvent,
|
||||
@@ -1523,8 +1748,20 @@ class RuntimeBot:
|
||||
return
|
||||
await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter)
|
||||
|
||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
|
||||
supported_events: list[str] = []
|
||||
if callable(get_supported_events):
|
||||
try:
|
||||
supported_events = list(get_supported_events() or [])
|
||||
except Exception:
|
||||
supported_events = []
|
||||
|
||||
# EBA adapters emit MessageReceivedEvent directly. Registering both
|
||||
# entry paths would process each native message twice because these
|
||||
# adapters also expose legacy conversion for compatibility consumers.
|
||||
if 'message.received' not in supported_events:
|
||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||
|
||||
# Register feedback listener (only effective on adapters that support it)
|
||||
async def on_feedback(
|
||||
|
||||
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.entity.persistence import (
|
||||
agent as agent_models,
|
||||
agent_interaction as agent_interaction_models,
|
||||
agent_run as agent_run_models,
|
||||
agent_runner_state as agent_runner_state_models,
|
||||
bot as bot_models,
|
||||
@@ -57,7 +58,7 @@ class TestAlembicRevisionGraph:
|
||||
revisions = list(script.walk_revisions())
|
||||
|
||||
assert script.get_bases() == ['0001_baseline']
|
||||
assert script.get_heads() == ['0012_monitoring_tool_calls']
|
||||
assert script.get_heads() == ['0014_interaction_delivery']
|
||||
assert all(len(item.revision) <= 32 for item in revisions), {
|
||||
item.revision: len(item.revision) for item in revisions if len(item.revision) > 32
|
||||
}
|
||||
@@ -194,6 +195,7 @@ class TestSQLiteMigrationUpgrade:
|
||||
|
||||
expected_agent_tables = {
|
||||
agent_models.Agent.__tablename__,
|
||||
agent_interaction_models.AgentInteraction.__tablename__,
|
||||
agent_run_models.AgentRun.__tablename__,
|
||||
agent_run_models.AgentRunEvent.__tablename__,
|
||||
agent_run_models.AgentRuntime.__tablename__,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Test that LangBot context builder output validates against SDK AgentRunContext."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -31,7 +32,7 @@ class TestContextValidation:
|
||||
"""Create a mock application."""
|
||||
mock_app = MagicMock(spec=app.Application)
|
||||
mock_app.ver_mgr = MagicMock()
|
||||
mock_app.ver_mgr.get_current_version = MagicMock(return_value="1.0.0")
|
||||
mock_app.ver_mgr.get_current_version = MagicMock(return_value='1.0.0')
|
||||
mock_app.persistence_mgr = MagicMock()
|
||||
mock_app.persistence_mgr.get_db_engine = MagicMock()
|
||||
mock_app.logger = MagicMock()
|
||||
@@ -44,35 +45,35 @@ class TestContextValidation:
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
|
||||
return AgentEventEnvelope(
|
||||
event_id="evt_1",
|
||||
event_type="message.received",
|
||||
event_id='evt_1',
|
||||
event_type='message.received',
|
||||
event_time=1700000000,
|
||||
source="platform",
|
||||
source_event_type="platform.message",
|
||||
bot_id="bot_1",
|
||||
workspace_id="workspace_1",
|
||||
conversation_id="conv_1",
|
||||
source='platform',
|
||||
source_event_type='platform.message',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
conversation_id='conv_1',
|
||||
thread_id=None,
|
||||
actor=ActorContext(
|
||||
actor_type="user",
|
||||
actor_id="user_1",
|
||||
actor_name="Test User",
|
||||
actor_type='user',
|
||||
actor_id='user_1',
|
||||
actor_name='Test User',
|
||||
),
|
||||
subject=None,
|
||||
input=EventInput(text="Hello world"),
|
||||
delivery=DeliveryContext(surface="test"),
|
||||
data={"platform_event_id": "source_evt_1"},
|
||||
input=EventInput(text='Hello world'),
|
||||
delivery=DeliveryContext(surface='test'),
|
||||
data={'platform_event_id': 'source_evt_1'},
|
||||
)
|
||||
|
||||
def _make_binding(self) -> AgentBinding:
|
||||
"""Create a test binding."""
|
||||
return AgentBinding(
|
||||
binding_id="binding_1",
|
||||
scope=BindingScope(scope_type="agent", scope_id="pipeline_1"),
|
||||
event_types=["message.received"],
|
||||
runner_id="plugin:test/plugin/runner",
|
||||
runner_config={"timeout": 300},
|
||||
agent_id="pipeline_1",
|
||||
binding_id='binding_1',
|
||||
scope=BindingScope(scope_type='agent', scope_id='pipeline_1'),
|
||||
event_types=['message.received'],
|
||||
runner_id='plugin:test/plugin/runner',
|
||||
runner_config={'timeout': 300},
|
||||
agent_id='pipeline_1',
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
@@ -91,16 +92,16 @@ class TestContextValidation:
|
||||
def _make_descriptor(self):
|
||||
"""Create a mock runner descriptor."""
|
||||
return AgentRunnerDescriptor(
|
||||
id="plugin:test/plugin/runner",
|
||||
source="plugin",
|
||||
label={"en_US": "Test Runner"},
|
||||
plugin_author="test",
|
||||
plugin_name="plugin",
|
||||
runner_name="runner",
|
||||
id='plugin:test/plugin/runner',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
plugin_author='test',
|
||||
plugin_name='plugin',
|
||||
runner_name='runner',
|
||||
permissions={
|
||||
"history": ["page", "search"],
|
||||
"events": ["get", "page"],
|
||||
"storage": ["plugin", "workspace"],
|
||||
'history': ['page', 'search'],
|
||||
'events': ['get', 'page'],
|
||||
'storage': ['plugin', 'workspace'],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -118,12 +119,14 @@ class TestContextValidation:
|
||||
# Mock persistent state store to return empty state snapshot
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
# Build context
|
||||
@@ -152,27 +155,69 @@ class TestContextValidation:
|
||||
assert isinstance(validated.resources, AgentResources)
|
||||
assert validated.runtime is not None
|
||||
assert isinstance(validated.runtime, AgentRuntimeContext)
|
||||
assert "protocol_version" not in validated.runtime.model_dump()
|
||||
assert "sdk_protocol_version" not in validated.runtime.model_dump()
|
||||
assert "sdk_protocol_version" not in context_dict["runtime"]
|
||||
assert 'protocol_version' not in validated.runtime.model_dump()
|
||||
assert 'sdk_protocol_version' not in validated.runtime.model_dump()
|
||||
assert 'sdk_protocol_version' not in context_dict['runtime']
|
||||
|
||||
# Verify event context
|
||||
assert validated.event.event_id == "evt_1"
|
||||
assert validated.event.event_type == "message.received"
|
||||
assert validated.event.source == "platform"
|
||||
assert validated.event.source_event_type == "platform.message"
|
||||
assert validated.event.data == {"platform_event_id": "source_evt_1"}
|
||||
assert validated.event.event_id == 'evt_1'
|
||||
assert validated.event.event_type == 'message.received'
|
||||
assert validated.event.source == 'platform'
|
||||
assert validated.event.source_event_type == 'platform.message'
|
||||
assert validated.event.data == {'platform_event_id': 'source_evt_1'}
|
||||
|
||||
# Verify conversation context uses SDK field names
|
||||
assert validated.conversation is not None
|
||||
assert validated.conversation.bot_id == "bot_1"
|
||||
assert validated.conversation.workspace_id == "workspace_1"
|
||||
assert validated.conversation.bot_id == 'bot_1'
|
||||
assert validated.conversation.workspace_id == 'workspace_1'
|
||||
|
||||
# Verify delivery context
|
||||
assert validated.delivery.surface == "test"
|
||||
assert validated.delivery.surface == 'test'
|
||||
|
||||
# Verify input
|
||||
assert validated.input.text == "Hello world"
|
||||
assert validated.input.text == 'Hello world'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_preserves_interaction_protocol_fields(self):
|
||||
"""Validated submissions and delivery capabilities survive the final context projection."""
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.interaction import (
|
||||
InteractionDeliveryCapabilities,
|
||||
InteractionSubmission,
|
||||
)
|
||||
|
||||
mock_app = self._make_mock_app()
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
event = self._make_event_envelope()
|
||||
event.event_type = 'interaction.submitted'
|
||||
event.input.interaction = InteractionSubmission(
|
||||
interaction_id='form-1',
|
||||
action_id='approve',
|
||||
values={'comment': 'looks good'},
|
||||
)
|
||||
event.delivery.interactions = InteractionDeliveryCapabilities(
|
||||
field_types=['text', 'select'],
|
||||
action_styles=['primary'],
|
||||
)
|
||||
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={'conversation': {}, 'actor': {}, 'subject': {}, 'runner': {}}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
context_dict = await builder.build_context_from_event(
|
||||
event=event,
|
||||
binding=self._make_binding(),
|
||||
descriptor=self._make_descriptor(),
|
||||
resources=self._make_resources(),
|
||||
)
|
||||
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
assert validated.input.interaction is not None
|
||||
assert validated.input.interaction.interaction_id == 'form-1'
|
||||
assert validated.input.interaction.values == {'comment': 'looks good'}
|
||||
assert validated.delivery.interactions is not None
|
||||
assert validated.delivery.interactions.field_types == ['text', 'select']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_from_event_populates_model_context_window(self):
|
||||
@@ -207,12 +252,14 @@ class TestContextValidation:
|
||||
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
context_dict = await builder.build_context_from_event(
|
||||
@@ -265,37 +312,39 @@ class TestContextValidation:
|
||||
mock_app = self._make_mock_app()
|
||||
builder = AgentRunContextBuilder(mock_app)
|
||||
event = AgentEventEnvelope(
|
||||
event_id="evt_recall_1",
|
||||
event_type="message.recalled",
|
||||
event_id='evt_recall_1',
|
||||
event_type='message.recalled',
|
||||
event_time=1700000001,
|
||||
source="platform",
|
||||
source_event_type="platform.message.recall",
|
||||
bot_id="bot_1",
|
||||
workspace_id="workspace_1",
|
||||
conversation_id="conv_1",
|
||||
actor=ActorContext(actor_type="user", actor_id="user_1"),
|
||||
source='platform',
|
||||
source_event_type='platform.message.recall',
|
||||
bot_id='bot_1',
|
||||
workspace_id='workspace_1',
|
||||
conversation_id='conv_1',
|
||||
actor=ActorContext(actor_type='user', actor_id='user_1'),
|
||||
subject=SubjectContext(
|
||||
subject_type="message",
|
||||
subject_id="message_1",
|
||||
data={"recalled_message_id": "message_1", "reason": "user_recall"},
|
||||
subject_type='message',
|
||||
subject_id='message_1',
|
||||
data={'recalled_message_id': 'message_1', 'reason': 'user_recall'},
|
||||
),
|
||||
input=EventInput(text=None),
|
||||
delivery=DeliveryContext(surface="test"),
|
||||
data={"source_event_id": "source_recall_1"},
|
||||
delivery=DeliveryContext(surface='test'),
|
||||
data={'source_event_id': 'source_recall_1'},
|
||||
)
|
||||
binding = self._make_binding()
|
||||
binding.event_types = ["message.recalled"]
|
||||
binding.event_types = ['message.recalled']
|
||||
resources = self._make_resources()
|
||||
descriptor = self._make_descriptor()
|
||||
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
context_dict = await builder.build_context_from_event(
|
||||
@@ -307,12 +356,12 @@ class TestContextValidation:
|
||||
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
|
||||
assert validated.event.event_type == "message.recalled"
|
||||
assert validated.event.event_type == 'message.recalled'
|
||||
assert validated.input.text is None
|
||||
assert validated.subject is not None
|
||||
assert validated.subject.subject_type == "message"
|
||||
assert validated.subject.subject_id == "message_1"
|
||||
assert validated.subject.data == {"recalled_message_id": "message_1", "reason": "user_recall"}
|
||||
assert validated.subject.subject_type == 'message'
|
||||
assert validated.subject.subject_id == 'message_1'
|
||||
assert validated.subject.data == {'recalled_message_id': 'message_1', 'reason': 'user_recall'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_from_event_has_no_legacy_top_level_fields(self):
|
||||
@@ -328,12 +377,14 @@ class TestContextValidation:
|
||||
# Mock persistent state store to return empty state snapshot
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
context_dict = await builder.build_context_from_event(
|
||||
@@ -344,16 +395,16 @@ class TestContextValidation:
|
||||
)
|
||||
|
||||
# Protocol v1 does NOT have these as core fields
|
||||
assert 'messages' not in context_dict, "messages should not be top-level in Protocol v1"
|
||||
assert 'prompt' not in context_dict, "prompt should not be top-level in Protocol v1"
|
||||
assert 'params' not in context_dict, "params should not be top-level in Protocol v1"
|
||||
assert 'messages' not in context_dict, 'messages should not be top-level in Protocol v1'
|
||||
assert 'prompt' not in context_dict, 'prompt should not be top-level in Protocol v1'
|
||||
assert 'params' not in context_dict, 'params should not be top-level in Protocol v1'
|
||||
|
||||
# Protocol v1 DOES have these
|
||||
assert 'delivery' in context_dict, "delivery is REQUIRED in Protocol v1"
|
||||
assert 'context' in context_dict, "context (ContextAccess) is REQUIRED in Protocol v1"
|
||||
assert 'bootstrap' not in context_dict, "Host must not inline bootstrap/history windows"
|
||||
assert 'adapter' in context_dict, "adapter should exist"
|
||||
assert 'metadata' in context_dict, "metadata should exist"
|
||||
assert 'delivery' in context_dict, 'delivery is REQUIRED in Protocol v1'
|
||||
assert 'context' in context_dict, 'context (ContextAccess) is REQUIRED in Protocol v1'
|
||||
assert 'bootstrap' not in context_dict, 'Host must not inline bootstrap/history windows'
|
||||
assert 'adapter' in context_dict, 'adapter should exist'
|
||||
assert 'metadata' in context_dict, 'metadata should exist'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_from_event_event_is_not_none(self):
|
||||
@@ -369,12 +420,14 @@ class TestContextValidation:
|
||||
# Mock persistent state store to return empty state snapshot
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
context_dict = await builder.build_context_from_event(
|
||||
@@ -385,7 +438,7 @@ class TestContextValidation:
|
||||
)
|
||||
|
||||
# event is REQUIRED in Protocol v1
|
||||
assert context_dict.get('event') is not None, "event is REQUIRED for Protocol v1"
|
||||
assert context_dict.get('event') is not None, 'event is REQUIRED for Protocol v1'
|
||||
|
||||
# Validate
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
@@ -405,12 +458,14 @@ class TestContextValidation:
|
||||
# Mock persistent state store to return empty state snapshot
|
||||
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as mock_get_store:
|
||||
mock_store = AsyncMock()
|
||||
mock_store.build_snapshot_from_event = AsyncMock(return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
})
|
||||
mock_store.build_snapshot_from_event = AsyncMock(
|
||||
return_value={
|
||||
'conversation': {},
|
||||
'actor': {},
|
||||
'subject': {},
|
||||
'runner': {},
|
||||
}
|
||||
)
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
context_dict = await builder.build_context_from_event(
|
||||
@@ -421,7 +476,7 @@ class TestContextValidation:
|
||||
)
|
||||
|
||||
# delivery is REQUIRED in Protocol v1
|
||||
assert context_dict.get('delivery') is not None, "delivery is REQUIRED for Protocol v1"
|
||||
assert context_dict.get('delivery') is not None, 'delivery is REQUIRED for Protocol v1'
|
||||
|
||||
# Validate
|
||||
validated = AgentRunContext.model_validate(context_dict)
|
||||
|
||||
@@ -7,6 +7,7 @@ Tests cover:
|
||||
4. LangBot Host not defining context-window controls
|
||||
5. Event-first run() entry point
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -38,34 +39,34 @@ class TestQueryToEventEnvelope:
|
||||
"""Test basic field conversion from Query to Event envelope."""
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.event_type == "message.received"
|
||||
assert event.source == "host_adapter"
|
||||
assert event.event_type == 'message.received'
|
||||
assert event.source == 'host_adapter'
|
||||
assert event.bot_id == mock_query.bot_uuid
|
||||
assert event.actor is not None
|
||||
assert event.actor.actor_type == "user"
|
||||
assert event.actor.actor_type == 'user'
|
||||
|
||||
def test_query_to_event_input(self, mock_query):
|
||||
"""Test input conversion from Query."""
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.input is not None
|
||||
assert event.input.text == "Hello world"
|
||||
assert "message_chain" not in event.input.model_dump()
|
||||
assert event.input.text == 'Hello world'
|
||||
assert 'message_chain' not in event.input.model_dump()
|
||||
|
||||
def test_query_to_event_conversation(self, mock_query):
|
||||
"""Test conversation context extraction."""
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.conversation_id == "conv-uuid-123"
|
||||
assert event.conversation_id == 'conv-uuid-123'
|
||||
|
||||
def test_query_to_event_prefers_variable_conversation_id_when_conversation_uuid_missing(self, mock_query):
|
||||
"""Pipeline variables can provide the conversation identity for state scope."""
|
||||
mock_query.session.using_conversation.uuid = None
|
||||
mock_query.variables["conversation_id"] = "conv-from-vars"
|
||||
mock_query.variables['conversation_id'] = 'conv-from-vars'
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.conversation_id == "conv-from-vars"
|
||||
assert event.conversation_id == 'conv-from-vars'
|
||||
|
||||
def test_query_to_event_falls_back_to_launcher_session_for_state_scope(self, mock_query):
|
||||
"""Debug Chat and legacy pipeline runs may not have a conversation UUID."""
|
||||
@@ -73,77 +74,115 @@ class TestQueryToEventEnvelope:
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.conversation_id == "person_launcher-123"
|
||||
assert event.conversation_id == 'person_launcher-123'
|
||||
|
||||
def test_query_to_event_delivery_context(self, mock_query):
|
||||
"""Test delivery context extraction."""
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.delivery is not None
|
||||
assert event.delivery.surface == "platform"
|
||||
assert event.delivery.surface == 'platform'
|
||||
assert isinstance(event.delivery.supports_streaming, bool)
|
||||
assert event.delivery.reply_target == {
|
||||
'target_type': 'person',
|
||||
'target_id': 'launcher-123',
|
||||
'message_id': 789,
|
||||
}
|
||||
|
||||
def test_query_to_event_preserves_source_event_data(self, mock_query):
|
||||
"""Test source event metadata survives the adapter boundary."""
|
||||
source_event = Mock()
|
||||
source_event.type = "platform.message.created"
|
||||
source_event.type = 'platform.message.created'
|
||||
source_event.time = 1700000000
|
||||
source_event.sender = None
|
||||
source_event.model_dump = Mock(return_value={
|
||||
"type": "platform.message.created",
|
||||
"message_id": "source-message-1",
|
||||
"source_platform_object": {"large": "payload"},
|
||||
})
|
||||
source_event.model_dump = Mock(
|
||||
return_value={
|
||||
'type': 'platform.message.created',
|
||||
'message_id': 'source-message-1',
|
||||
'source_platform_object': {'large': 'payload'},
|
||||
}
|
||||
)
|
||||
mock_query.message_event = source_event
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.source_event_type == "platform.message.created"
|
||||
assert event.source_event_type == 'platform.message.created'
|
||||
assert event.event_time == 1700000000
|
||||
assert event.data == {
|
||||
"type": "platform.message.created",
|
||||
"message_id": "source-message-1",
|
||||
'type': 'platform.message.created',
|
||||
'message_id': 'source-message-1',
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('source_time', 'expected'),
|
||||
[
|
||||
(1_700_000_000, 1_700_000_000),
|
||||
(1_700_000_000_123, 1_700_000_000),
|
||||
(1_700_000_000_123_456, 1_700_000_000),
|
||||
],
|
||||
)
|
||||
def test_query_to_event_normalizes_legacy_timestamp_units(
|
||||
self,
|
||||
mock_query,
|
||||
source_time,
|
||||
expected,
|
||||
):
|
||||
mock_query.message_event = Mock(
|
||||
type='platform.message.created',
|
||||
time=source_time,
|
||||
sender=None,
|
||||
)
|
||||
mock_query.message_event.model_dump = Mock(return_value={})
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.event_time == expected
|
||||
|
||||
def test_query_to_event_keeps_large_payloads_out_of_event_data(self, mock_query):
|
||||
"""Large or nested platform payloads should not be duplicated into event.data."""
|
||||
source_event = Mock()
|
||||
source_event.type = "platform.message.created"
|
||||
source_event.type = 'platform.message.created'
|
||||
source_event.time = 1700000000
|
||||
source_event.sender = None
|
||||
source_event.model_dump = Mock(return_value={
|
||||
"type": "platform.message.created",
|
||||
"message_id": "source-message-1",
|
||||
"message_chain": [{"type": "Image", "base64": "data:image/png;base64," + ("a" * 1024)}],
|
||||
"raw_text": "x" * 1024,
|
||||
"source_platform_object": {"large": "payload"},
|
||||
})
|
||||
source_event.model_dump = Mock(
|
||||
return_value={
|
||||
'type': 'platform.message.created',
|
||||
'message_id': 'source-message-1',
|
||||
'message_chain': [{'type': 'Image', 'base64': 'data:image/png;base64,' + ('a' * 1024)}],
|
||||
'raw_text': 'x' * 1024,
|
||||
'source_platform_object': {'large': 'payload'},
|
||||
}
|
||||
)
|
||||
mock_query.message_event = source_event
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.data == {
|
||||
"type": "platform.message.created",
|
||||
"message_id": "source-message-1",
|
||||
'type': 'platform.message.created',
|
||||
'message_id': 'source-message-1',
|
||||
}
|
||||
|
||||
def test_query_to_event_handles_missing_message_chain(self, mock_query):
|
||||
"""Test delivery context building when Query has no message_chain."""
|
||||
delattr(mock_query, "message_chain")
|
||||
delattr(mock_query, 'message_chain')
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert event.delivery.reply_target == {"message_id": None}
|
||||
assert event.delivery.reply_target == {
|
||||
'target_type': 'person',
|
||||
'target_id': 'launcher-123',
|
||||
'message_id': None,
|
||||
}
|
||||
|
||||
def test_query_to_event_scopes_pipeline_local_event_ids(self, mock_query):
|
||||
"""Pipeline-local message IDs must not become global audit IDs."""
|
||||
first = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
mock_query.launcher_id = "launcher-456"
|
||||
mock_query.launcher_id = 'launcher-456'
|
||||
second = QueryEntryAdapter.query_to_event(mock_query)
|
||||
|
||||
assert first.event_id.startswith("host:")
|
||||
assert first.event_id != "789"
|
||||
assert first.event_id.startswith('host:')
|
||||
assert first.event_id != '789'
|
||||
assert second.event_id != first.event_id
|
||||
|
||||
|
||||
@@ -152,21 +191,22 @@ class TestQueryConfigToAgentConfig:
|
||||
|
||||
def test_config_to_agent_config_runner_id(self, mock_query):
|
||||
"""Test AgentConfig runner_id extraction."""
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(
|
||||
mock_query, "plugin:author/plugin/runner"
|
||||
)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:author/plugin/runner')
|
||||
|
||||
assert agent_config.runner_id == "plugin:author/plugin/runner"
|
||||
assert agent_config.runner_id == 'plugin:author/plugin/runner'
|
||||
assert agent_config.processor_type == 'pipeline'
|
||||
assert agent_config.processor_id == mock_query.pipeline_uuid
|
||||
assert agent_config.delivery_policy.enable_interactions is True
|
||||
|
||||
def test_config_to_agent_config_uses_current_runner_config(self, mock_query):
|
||||
"""Temporary query adapters use the current runner config container."""
|
||||
mock_query.pipeline_config = {
|
||||
"ai": {
|
||||
"runner": {"id": "plugin:langbot-team/LocalAgent/default"},
|
||||
"runner_config": {
|
||||
"plugin:langbot-team/LocalAgent/default": {
|
||||
"model": {"primary": "model-primary", "fallbacks": []},
|
||||
"knowledge-bases": ["kb-001"],
|
||||
'ai': {
|
||||
'runner': {'id': 'plugin:langbot-team/LocalAgent/default'},
|
||||
'runner_config': {
|
||||
'plugin:langbot-team/LocalAgent/default': {
|
||||
'model': {'primary': 'model-primary', 'fallbacks': []},
|
||||
'knowledge-bases': ['kb-001'],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -174,90 +214,109 @@ class TestQueryConfigToAgentConfig:
|
||||
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(
|
||||
mock_query,
|
||||
"plugin:langbot-team/LocalAgent/default",
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
)
|
||||
|
||||
assert agent_config.runner_config["model"] == {"primary": "model-primary", "fallbacks": []}
|
||||
assert agent_config.runner_config["knowledge-bases"] == ["kb-001"]
|
||||
assert agent_config.runner_config['model'] == {'primary': 'model-primary', 'fallbacks': []}
|
||||
assert agent_config.runner_config['knowledge-bases'] == ['kb-001']
|
||||
|
||||
def test_resolver_projects_agent_scope(self, mock_query):
|
||||
"""Test binding scope projection through the resolver."""
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(
|
||||
mock_query, "plugin:test/plugin/runner"
|
||||
)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:test/plugin/runner')
|
||||
binding = AgentBindingResolver().resolve_one(event, [agent_config])
|
||||
|
||||
assert binding.scope.scope_type == "agent"
|
||||
assert binding.scope.scope_type == 'agent'
|
||||
assert binding.scope.scope_id == mock_query.pipeline_uuid
|
||||
assert binding.agent_id == mock_query.pipeline_uuid
|
||||
assert binding.processor_type == 'pipeline'
|
||||
assert binding.processor_id == mock_query.pipeline_uuid
|
||||
|
||||
def test_interaction_submission_projects_control_event(self, mock_query):
|
||||
"""Pipeline callbacks keep structured submission data and match the runner binding."""
|
||||
mock_query.variables = {
|
||||
'_interaction_submission': {
|
||||
'interaction_id': 'form-1',
|
||||
'action_id': 'approve',
|
||||
'values': {'name': 'Alice'},
|
||||
}
|
||||
}
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:langbot-team/DifyAgent/default')
|
||||
binding = AgentBindingResolver().resolve_one(event, [agent_config])
|
||||
|
||||
assert event.event_type == 'interaction.submitted'
|
||||
assert event.data['interaction']['values'] == {'name': 'Alice'}
|
||||
assert event.subject.subject_type == 'interaction'
|
||||
assert agent_config.event_types == ['interaction.submitted']
|
||||
assert binding.processor_type == 'pipeline'
|
||||
|
||||
def test_resolver_rejects_multiple_matching_agents(self, mock_query):
|
||||
"""Event dispatch is single-Agent in v1."""
|
||||
event = QueryEntryAdapter.query_to_event(mock_query)
|
||||
first = QueryEntryAdapter.config_to_agent_config(
|
||||
mock_query, "plugin:test/plugin/runner"
|
||||
)
|
||||
second = first.model_copy(update={"agent_id": "agent_2"})
|
||||
first = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:test/plugin/runner')
|
||||
second = first.model_copy(update={'agent_id': 'agent_2'})
|
||||
|
||||
with pytest.raises(AgentBindingResolutionError):
|
||||
AgentBindingResolver().resolve_one(event, [first, second])
|
||||
|
||||
|
||||
class TestAgentRunContextProtocolV1:
|
||||
"""Test AgentRunContext Protocol v1 behavior."""
|
||||
|
||||
def test_sdk_context_event_required(self):
|
||||
"""Test that event is required in Protocol v1 context."""
|
||||
trigger = AgentTrigger(type="message.received")
|
||||
trigger = AgentTrigger(type='message.received')
|
||||
event = AgentEventContext(
|
||||
event_id="evt_1",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
event_id='evt_1',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
)
|
||||
input = AgentInput(text="Hello")
|
||||
input = AgentInput(text='Hello')
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
|
||||
ctx = AgentRunContext(
|
||||
run_id="run_1",
|
||||
run_id='run_1',
|
||||
trigger=trigger,
|
||||
event=event,
|
||||
input=input,
|
||||
delivery=DeliveryContext(surface="platform"),
|
||||
delivery=DeliveryContext(surface='platform'),
|
||||
resources=AgentResources(),
|
||||
runtime=AgentRuntimeContext(),
|
||||
)
|
||||
|
||||
assert ctx.event is not None
|
||||
assert ctx.event.event_type == "message.received"
|
||||
assert ctx.event.event_type == 'message.received'
|
||||
|
||||
def test_sdk_context_has_no_history_message_fields(self):
|
||||
"""AgentRunContext should not expose inline history message fields."""
|
||||
trigger = AgentTrigger(type="message.received")
|
||||
trigger = AgentTrigger(type='message.received')
|
||||
event = AgentEventContext(
|
||||
event_id="evt_1",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
event_id='evt_1',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
)
|
||||
input = AgentInput(text="Hello")
|
||||
input = AgentInput(text='Hello')
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
|
||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||
|
||||
ctx = AgentRunContext(
|
||||
run_id="run_1",
|
||||
run_id='run_1',
|
||||
trigger=trigger,
|
||||
event=event,
|
||||
input=input,
|
||||
delivery=DeliveryContext(surface="platform"),
|
||||
delivery=DeliveryContext(surface='platform'),
|
||||
resources=AgentResources(),
|
||||
runtime=AgentRuntimeContext(),
|
||||
)
|
||||
|
||||
assert "messages" not in AgentRunContext.model_fields
|
||||
assert "bootstrap" not in AgentRunContext.model_fields
|
||||
assert not hasattr(ctx, "bootstrap")
|
||||
assert 'messages' not in AgentRunContext.model_fields
|
||||
assert 'bootstrap' not in AgentRunContext.model_fields
|
||||
assert not hasattr(ctx, 'bootstrap')
|
||||
|
||||
|
||||
class TestHostManagedHistoryNotInProtocol:
|
||||
@@ -267,7 +326,7 @@ class TestHostManagedHistoryNotInProtocol:
|
||||
"""AgentRunContext should not expose top-level history messages."""
|
||||
ctx_fields = AgentRunContext.model_fields.keys()
|
||||
|
||||
assert "messages" not in ctx_fields
|
||||
assert 'messages' not in ctx_fields
|
||||
|
||||
|
||||
class TestSDKResultProtocolV1:
|
||||
@@ -278,11 +337,12 @@ class TestSDKResultProtocolV1:
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
result = AgentRunResult.message_completed(
|
||||
run_id="run_1",
|
||||
message=Message(role="assistant", content="Hello"),
|
||||
run_id='run_1',
|
||||
message=Message(role='assistant', content='Hello'),
|
||||
)
|
||||
|
||||
assert result.run_id == "run_1"
|
||||
assert result.run_id == 'run_1'
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture
|
||||
@@ -290,14 +350,14 @@ def mock_query():
|
||||
"""Create a mock query for testing."""
|
||||
query = Mock()
|
||||
query.query_id = 123
|
||||
query.bot_uuid = "bot-uuid-123"
|
||||
query.pipeline_uuid = "pipeline-uuid-456"
|
||||
query.launcher_type = Mock(value="person")
|
||||
query.launcher_id = "launcher-123"
|
||||
query.sender_id = "sender-123"
|
||||
query.bot_uuid = 'bot-uuid-123'
|
||||
query.pipeline_uuid = 'pipeline-uuid-456'
|
||||
query.launcher_type = Mock(value='person')
|
||||
query.launcher_id = 'launcher-123'
|
||||
query.sender_id = 'sender-123'
|
||||
query.pipeline_config = {
|
||||
"ai": {
|
||||
"runner": "plugin:test/plugin/runner",
|
||||
'ai': {
|
||||
'runner': 'plugin:test/plugin/runner',
|
||||
}
|
||||
}
|
||||
query.variables = {}
|
||||
@@ -321,10 +381,10 @@ def mock_query():
|
||||
|
||||
# Mock session with proper conversation
|
||||
query.session = Mock()
|
||||
query.session.launcher_type = Mock(value="person")
|
||||
query.session.launcher_id = "launcher-123"
|
||||
query.session.launcher_type = Mock(value='person')
|
||||
query.session.launcher_id = 'launcher-123'
|
||||
query.session.using_conversation = Mock()
|
||||
query.session.using_conversation.uuid = "conv-uuid-123"
|
||||
query.session.using_conversation.uuid = 'conv-uuid-123'
|
||||
|
||||
# Mock use_funcs (empty list by default)
|
||||
query.use_funcs = []
|
||||
@@ -338,14 +398,14 @@ def mock_query_no_session():
|
||||
"""Create a mock Query without session."""
|
||||
query = Mock()
|
||||
query.query_id = 456
|
||||
query.bot_uuid = "bot-uuid-456"
|
||||
query.pipeline_uuid = "pipeline-uuid-789"
|
||||
query.launcher_type = Mock(value="person")
|
||||
query.launcher_id = "launcher-456"
|
||||
query.sender_id = "sender-456"
|
||||
query.bot_uuid = 'bot-uuid-456'
|
||||
query.pipeline_uuid = 'pipeline-uuid-789'
|
||||
query.launcher_type = Mock(value='person')
|
||||
query.launcher_id = 'launcher-456'
|
||||
query.sender_id = 'sender-456'
|
||||
query.pipeline_config = {
|
||||
"ai": {
|
||||
"runner": "plugin:test/plugin/runner",
|
||||
'ai': {
|
||||
'runner': 'plugin:test/plugin/runner',
|
||||
}
|
||||
}
|
||||
query.variables = {}
|
||||
|
||||
@@ -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.session_registry import get_session_registry
|
||||
from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore
|
||||
from langbot.pkg.agent.runner.interaction_store import InteractionStore
|
||||
from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
@@ -412,6 +413,67 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent
|
||||
assert await get_session_registry().get(context['run_id']) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_consumes_interaction_request_before_message_output(clean_agent_state):
|
||||
"""Whitelisted interaction actions are delivered and never emitted as messages."""
|
||||
db_engine = clean_agent_state
|
||||
descriptor = make_descriptor()
|
||||
# The local SDK pin predates this contract; these become typed fields with SDK 0.5.0a3.
|
||||
descriptor.capabilities.__dict__['interactions'] = True
|
||||
descriptor.permissions.__dict__['interactions'] = ['request']
|
||||
plugin_connector = FakePluginConnector(
|
||||
results=[
|
||||
{
|
||||
'type': 'action.requested',
|
||||
'data': {
|
||||
'action': 'interaction.requested',
|
||||
'payload': {
|
||||
'interaction_id': 'approval-1',
|
||||
'kind': 'choice',
|
||||
'title': 'Approve request?',
|
||||
'actions': [
|
||||
{'id': 'approve', 'label': 'Approve', 'style': 'primary'},
|
||||
{'id': 'reject', 'label': 'Reject', 'style': 'danger'},
|
||||
],
|
||||
'fallback_text': 'Reply approve or reject.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'message.completed',
|
||||
'data': {'message': {'role': 'assistant', 'content': 'Waiting for approval'}},
|
||||
},
|
||||
]
|
||||
)
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
query = make_query()
|
||||
query.adapter = types.SimpleNamespace(
|
||||
get_supported_apis=lambda: ['interaction.request'],
|
||||
call_platform_api=AsyncMock(return_value={'ok': True}),
|
||||
)
|
||||
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
assert [message.content for message in messages] == ['Waiting for approval']
|
||||
query.adapter.call_platform_api.assert_awaited_once()
|
||||
action, params = query.adapter.call_platform_api.await_args.args
|
||||
assert action == 'interaction.request'
|
||||
assert params['reply_target'] == {
|
||||
'target_type': 'person',
|
||||
'target_id': 'user_001',
|
||||
'message_id': 'msg_001',
|
||||
}
|
||||
assert params['callback_token']
|
||||
|
||||
run_id = plugin_connector.contexts[0]['run_id']
|
||||
request = await InteractionStore(db_engine).get_request(run_id, 'approval-1')
|
||||
assert request is not None
|
||||
assert request['status'] == 'pending'
|
||||
assert request['processor_type'] == 'pipeline'
|
||||
assert request['processor_id'] == 'pipeline_001'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orchestrator_persists_run_ledger(clean_agent_state):
|
||||
"""AgentRunOrchestrator records Host-owned run and result events."""
|
||||
|
||||
@@ -366,6 +366,36 @@ class TestMessageAggregatorAddMessage:
|
||||
# Should have buffered the message
|
||||
assert len(agg.buffers) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_variables_bypass_aggregation(self):
|
||||
"""Interaction callbacks must not merge with nearby chat messages."""
|
||||
aggregator = get_aggregator_module()
|
||||
app = make_aggregator_app()
|
||||
mock_pipeline = Mock()
|
||||
mock_pipeline.pipeline_entity = Mock()
|
||||
mock_pipeline.pipeline_entity.config = {'trigger': {'message-aggregation': {'enabled': True, 'delay': 10.0}}}
|
||||
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain = text_chain('approve')
|
||||
await agg.add_message(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=friend_message_event(chain),
|
||||
message_chain=chain,
|
||||
adapter=mock_adapter(),
|
||||
pipeline_uuid='test-pipeline',
|
||||
variables={'_interaction_submission': {'interaction_id': 'form-1'}},
|
||||
)
|
||||
|
||||
assert agg.buffers == {}
|
||||
app.query_pool.add_query.assert_awaited_once()
|
||||
assert app.query_pool.add_query.call_args.kwargs['variables'] == {
|
||||
'_interaction_submission': {'interaction_id': 'form-1'}
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_buffer_flushes_immediately(self):
|
||||
"""Reaching MAX_BUFFER_MESSAGES should flush immediately."""
|
||||
|
||||
@@ -164,6 +164,96 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
|
||||
mock_stage.process.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_pipeline_delivers_latest_chunk_as_final(mock_app, sample_query):
|
||||
"""The terminal chunk, not the first chunk, controls final stream delivery."""
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
entities = get_entities_module()
|
||||
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.config = sample_query.pipeline_config
|
||||
pipeline_entity.extensions_preferences = {'plugins': []}
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
|
||||
first_chunk = provider_message.MessageChunk(role='assistant', content='Starting', is_final=False)
|
||||
final_chunk = provider_message.MessageChunk(role='assistant', content='Done', is_final=True)
|
||||
sample_query.resp_messages = [first_chunk, final_chunk]
|
||||
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
result = entities.StageProcessResult(
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
new_query=sample_query,
|
||||
user_notice='StartingDone',
|
||||
)
|
||||
|
||||
await runtime_pipeline._check_output(sample_query, result)
|
||||
|
||||
sample_query.adapter.reply_message_chunk.assert_awaited_once()
|
||||
call = sample_query.adapter.reply_message_chunk.await_args.kwargs
|
||||
assert call['bot_message'] is final_chunk
|
||||
assert call['is_final'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_back_stage_delivers_latest_chunk_as_final(mock_app, sample_query):
|
||||
respback = import_module('langbot.pkg.pipeline.respback.respback')
|
||||
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
|
||||
platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message')
|
||||
|
||||
first_chunk = provider_message.MessageChunk(role='assistant', content='Starting', is_final=False)
|
||||
final_chunk = provider_message.MessageChunk(role='assistant', content='Done', is_final=True)
|
||||
sample_query.resp_messages = [first_chunk, final_chunk]
|
||||
sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='StartingDone')])]
|
||||
sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0}
|
||||
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
|
||||
await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back')
|
||||
|
||||
sample_query.adapter.reply_message_chunk.assert_awaited_once()
|
||||
call = sample_query.adapter.reply_message_chunk.await_args.kwargs
|
||||
assert call['bot_message'] is final_chunk
|
||||
assert call['is_final'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_back_stage_keeps_consuming_after_stream_delivery_failure(mock_app, sample_query):
|
||||
respback = import_module('langbot.pkg.pipeline.respback.respback')
|
||||
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
|
||||
platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message')
|
||||
|
||||
chunk = provider_message.MessageChunk(role='assistant', content='Progress', is_final=False)
|
||||
sample_query.resp_messages = [chunk]
|
||||
sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='Progress')])]
|
||||
sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0}
|
||||
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
sample_query.adapter.reply_message_chunk.side_effect = RuntimeError('stream update failed')
|
||||
|
||||
result = await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back')
|
||||
|
||||
assert result.result_type.name == 'CONTINUE'
|
||||
sample_query.adapter.reply_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_back_stage_falls_back_to_plain_message_for_failed_final_chunk(mock_app, sample_query):
|
||||
respback = import_module('langbot.pkg.pipeline.respback.respback')
|
||||
provider_message = import_module('langbot_plugin.api.entities.builtin.provider.message')
|
||||
platform_message = import_module('langbot_plugin.api.entities.builtin.platform.message')
|
||||
|
||||
chunk = provider_message.MessageChunk(role='assistant', content='Final answer', is_final=True)
|
||||
sample_query.resp_messages = [chunk]
|
||||
sample_query.resp_message_chain = [platform_message.MessageChain([platform_message.Plain(text='Final answer')])]
|
||||
sample_query.pipeline_config['output']['force-delay'] = {'min': 0, 'max': 0}
|
||||
sample_query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
sample_query.adapter.reply_message_chunk.side_effect = RuntimeError('stream update failed')
|
||||
|
||||
result = await respback.SendResponseBackStage(mock_app).process(sample_query, 'response-back')
|
||||
|
||||
assert result.result_type.name == 'CONTINUE'
|
||||
sample_query.adapter.reply_message.assert_awaited_once()
|
||||
|
||||
|
||||
def test_runtime_pipeline_prefers_runner_mcp_resources(mock_app):
|
||||
"""Runner resource selection should override extension preferences."""
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
|
||||
@@ -199,6 +199,29 @@ class TestQueryPoolAddQuery:
|
||||
call_kwargs = MockQuery.call_args[1]
|
||||
assert call_kwargs['variables']['_routed_by_rule'] is True
|
||||
|
||||
async def test_add_query_merges_control_variables(self):
|
||||
"""Caller-provided control variables are preserved with routing metadata."""
|
||||
pool = QueryPool()
|
||||
mock_query = Mock(query_id=0)
|
||||
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
sender_id=1,
|
||||
message_event=Mock(),
|
||||
message_chain=Mock(),
|
||||
adapter=Mock(),
|
||||
routed_by_rule=True,
|
||||
variables={'_interaction_submission': {'interaction_id': 'form-1'}},
|
||||
)
|
||||
|
||||
variables = MockQuery.call_args.kwargs['variables']
|
||||
assert variables['_routed_by_rule'] is True
|
||||
assert variables['_interaction_submission'] == {'interaction_id': 'form-1'}
|
||||
|
||||
async def test_add_query_notifier_condition(self):
|
||||
"""add_query notifies waiting consumers."""
|
||||
pool = QueryPool()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -13,6 +14,7 @@ from langbot.pkg.platform.adapters.dingtalk.adapter import DingTalkAdapter
|
||||
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.dingtalk.interaction import interaction_event_from_native
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
@@ -49,6 +51,7 @@ class DummyDingTalkClient(DingTalkClient):
|
||||
self.download_image = AsyncMock(return_value='data:image/png;base64,BBBB')
|
||||
self.create_and_card = AsyncMock(return_value=('card', 'card-id'))
|
||||
self.send_card_message = AsyncMock()
|
||||
self.create_and_deliver_card = AsyncMock(return_value=True)
|
||||
self.start = AsyncMock()
|
||||
self.stop = AsyncMock()
|
||||
|
||||
@@ -71,7 +74,7 @@ def manifest() -> dict:
|
||||
/ 'dingtalk'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def make_adapter() -> DingTalkAdapter:
|
||||
@@ -84,6 +87,7 @@ def make_adapter() -> DingTalkAdapter:
|
||||
'enable-stream-reply': False,
|
||||
'card_auto_layout': False,
|
||||
'card_template_id': 'template-id',
|
||||
'human_input_card_template_id': 'human-input-template-id',
|
||||
}
|
||||
with patch('langbot.pkg.platform.adapters.dingtalk.adapter.DingTalkClient', DummyDingTalkClient):
|
||||
return DingTalkAdapter(config, DummyLogger())
|
||||
@@ -157,6 +161,36 @@ def test_dingtalk_platform_api_map_matches_manifest():
|
||||
assert set(PLATFORM_API_MAP) == manifest_actions
|
||||
|
||||
|
||||
def test_dingtalk_human_input_card_template_matches_interaction_contract():
|
||||
template_path = (
|
||||
pathlib.Path(__file__).parents[3] / 'src' / 'langbot' / 'templates' / 'dingtalk_human_input_card.json'
|
||||
)
|
||||
exported_template = json.loads(template_path.read_text(encoding='utf-8'))
|
||||
editor_data = json.loads(exported_template['editorData'])
|
||||
variable_ids = {item['id'] for item in editor_data['variableList']}
|
||||
serialized_editor = json.dumps(editor_data, ensure_ascii=False)
|
||||
|
||||
assert exported_template['type'] == 'im'
|
||||
assert exported_template['mode'] == 'card'
|
||||
assert {
|
||||
'content',
|
||||
'btns',
|
||||
'input_visible',
|
||||
'input_title',
|
||||
'input_placeholder',
|
||||
'input_value',
|
||||
'select_visible',
|
||||
'select_placeholder',
|
||||
'select_options',
|
||||
'select_index',
|
||||
'index_o',
|
||||
} <= variable_ids
|
||||
assert '"componentName": "Input"' in serialized_editor
|
||||
assert '"componentName": "SelectBlock"' in serialized_editor
|
||||
assert '__built_in_inputResult__' in serialized_editor
|
||||
assert '__built_in_selectResult__' in serialized_editor
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_message_converter_maps_outbound_components():
|
||||
content, at = await DingTalkMessageConverter.yiri2target(
|
||||
@@ -313,3 +347,178 @@ async def test_dingtalk_send_reply_and_platform_api_use_underlying_client():
|
||||
|
||||
assert token_status == {'valid': True}
|
||||
assert file_url == {'url': 'https://example.test/file'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_interaction_delivery_and_callback_event():
|
||||
adapter = make_adapter()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result['rich'] is True
|
||||
kwargs = adapter.bot.create_and_deliver_card.await_args.kwargs
|
||||
assert kwargs['card_template_id'] == 'human-input-template-id'
|
||||
assert kwargs['open_space_id'] == 'dtv1.card//IM_GROUP.group-1'
|
||||
assert kwargs['card_param_map']['btns'][0]['event']['params']['actionId'] == 'lbi:callback-token:a:0'
|
||||
|
||||
native = dingtalk_card_callback_event(
|
||||
content={'actionId': 'lbi:callback-token:a:0'},
|
||||
space_id='dtv1.card//IM_GROUP.group-1',
|
||||
)
|
||||
event = interaction_event_from_native(native)
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['action_ref'] == 0
|
||||
assert event.data['actor_id'] == 'user-1'
|
||||
assert event.data['target_id'] == 'group-1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_native_input_uses_template_variables_and_submits_value():
|
||||
adapter = make_adapter()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Add context',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'comment',
|
||||
'label': 'Comment',
|
||||
'type': 'textarea',
|
||||
'required': True,
|
||||
'placeholder': 'Explain the decision',
|
||||
}
|
||||
],
|
||||
'actions': [],
|
||||
'fallback_text': 'Reply with a comment.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
params = adapter.bot.create_and_deliver_card.await_args.kwargs['card_param_map']
|
||||
assert params['input_visible'] == 'true'
|
||||
assert params['input_title'] == 'Comment'
|
||||
assert params['input_placeholder'] == 'Explain the decision'
|
||||
assert params['select_visible'] == ''
|
||||
assert params['btns'] == []
|
||||
assert result['message_id'] in adapter.interaction_callback_contexts
|
||||
|
||||
native = dingtalk_card_callback_event(
|
||||
content={
|
||||
'outTrackId': result['message_id'],
|
||||
'params': {'input': 'Looks good'},
|
||||
},
|
||||
space_id='dtv1.card//IM_GROUP.group-1',
|
||||
)
|
||||
event = interaction_event_from_native(native, adapter.interaction_callback_contexts)
|
||||
|
||||
assert event.data['callback_token'] == 'callback-token'
|
||||
assert event.data['values'] == {'comment': 'Looks good'}
|
||||
assert result['message_id'] not in adapter.interaction_callback_contexts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_native_select_uses_select_block_and_normalizes_callback():
|
||||
adapter = make_adapter()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Priority',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'priority',
|
||||
'label': 'Priority',
|
||||
'type': 'select',
|
||||
'options': [
|
||||
{'value': 'normal', 'label': 'Normal'},
|
||||
{'value': 'urgent', 'label': 'Urgent'},
|
||||
],
|
||||
}
|
||||
],
|
||||
'actions': [],
|
||||
'fallback_text': 'Choose a priority.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
params = adapter.bot.create_and_deliver_card.await_args.kwargs['card_param_map']
|
||||
assert params['select_visible'] == 'true'
|
||||
assert [option['value'] for option in params['index_o']] == ['normal', 'urgent']
|
||||
assert params['input_visible'] == ''
|
||||
|
||||
native = dingtalk_card_callback_event(
|
||||
content={
|
||||
'out_track_id': result['message_id'],
|
||||
'params': {'select': '{"index": 1, "value": "urgent"}'},
|
||||
},
|
||||
space_id='dtv1.card//IM_ROBOT.user-1',
|
||||
)
|
||||
event = interaction_event_from_native(native, adapter.interaction_callback_contexts)
|
||||
|
||||
assert event.data['target_type'] == 'person'
|
||||
assert event.data['target_id'] == 'user-1'
|
||||
assert event.data['values'] == {'priority': 'urgent'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_adapter_dispatches_native_input_submission():
|
||||
adapter = make_adapter()
|
||||
calls: list[platform_events.Event] = []
|
||||
|
||||
async def listener(event, adapter):
|
||||
calls.append(event)
|
||||
|
||||
adapter.register_listener(platform_events.PlatformSpecificEvent, listener)
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Score',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'score',
|
||||
'label': 'Score',
|
||||
'type': 'number',
|
||||
}
|
||||
],
|
||||
'actions': [],
|
||||
'fallback_text': 'Reply with a score.',
|
||||
},
|
||||
},
|
||||
)
|
||||
native = dingtalk_card_callback_event(
|
||||
content={
|
||||
'outTrackId': result['message_id'],
|
||||
'params': {'inputResult': '42.5'},
|
||||
},
|
||||
space_id='dtv1.card//IM_GROUP.group-1',
|
||||
)
|
||||
|
||||
await adapter._handle_native_event(native)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].action == 'interaction.submitted'
|
||||
assert calls[0].data['callback_token'] == 'callback-token'
|
||||
assert calls[0].data['values'] == {'score': 42.5}
|
||||
assert result['message_id'] not in adapter.interaction_callback_contexts
|
||||
|
||||
@@ -13,6 +13,11 @@ import langbot.pkg.platform.adapters.discord.adapter as discord_adapter_module
|
||||
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
|
||||
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
|
||||
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.discord.interaction import (
|
||||
build_interaction_view,
|
||||
interaction_event_from_component,
|
||||
parse_interaction_custom_id,
|
||||
)
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
@@ -48,7 +53,7 @@ def manifest() -> dict:
|
||||
/ 'discord'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def test_discord_supported_events_match_manifest():
|
||||
@@ -68,6 +73,64 @@ def test_discord_platform_api_map_matches_manifest():
|
||||
assert set(PLATFORM_API_MAP) == manifest_actions
|
||||
|
||||
|
||||
def test_discord_interaction_custom_id_parser_uses_host_indexes():
|
||||
assert parse_interaction_custom_id('lbi:token:a:2') == {
|
||||
'callback_token': 'token',
|
||||
'action_ref': 2,
|
||||
}
|
||||
assert parse_interaction_custom_id('lbi:token:f:0:3') == {
|
||||
'callback_token': 'token',
|
||||
'field_ref': 0,
|
||||
'option_ref': 3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_interaction_view_and_delivery():
|
||||
adapter = make_adapter()
|
||||
channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=321)))
|
||||
adapter._get_channel = AsyncMock(return_value=channel)
|
||||
request = {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
}
|
||||
|
||||
view = build_interaction_view(request, 'callback-token')
|
||||
assert view.children[0].custom_id == 'lbi:callback-token:a:0'
|
||||
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'request': request,
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': '789'},
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {'ok': True, 'message_id': 321, 'rich': True}
|
||||
assert channel.send.await_args.kwargs['view'].children[0].label == 'Approve'
|
||||
|
||||
|
||||
def test_discord_interaction_component_builds_scoped_host_event():
|
||||
interaction = SimpleNamespace(
|
||||
user=SimpleNamespace(id=123),
|
||||
channel=SimpleNamespace(id=789),
|
||||
guild=SimpleNamespace(id=456),
|
||||
)
|
||||
|
||||
event = interaction_event_from_component(
|
||||
interaction,
|
||||
{'callback_token': 'callback-token', 'action_ref': 0},
|
||||
)
|
||||
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['actor_id'] == '123'
|
||||
assert event.data['target_type'] == 'group'
|
||||
assert event.data['target_id'] == '789'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_adapter_dispatches_most_specific_eba_listener():
|
||||
adapter = make_adapter()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import asyncio
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -12,6 +14,11 @@ from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter
|
||||
from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter
|
||||
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
|
||||
from langbot.pkg.platform.adapters.lark.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.lark.interaction import (
|
||||
build_interaction_card,
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_callback,
|
||||
)
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
@@ -66,6 +73,7 @@ class DummyAPIClient:
|
||||
message=SimpleNamespace(
|
||||
acreate=AsyncMock(return_value=DummyResponse()),
|
||||
areply=AsyncMock(return_value=DummyResponse()),
|
||||
aupdate=AsyncMock(return_value=DummyResponse()),
|
||||
aget=AsyncMock(return_value=DummyResponse(SimpleNamespace(items=[]))),
|
||||
),
|
||||
chat=SimpleNamespace(
|
||||
@@ -89,8 +97,12 @@ class DummyAPIClient:
|
||||
)
|
||||
self.cardkit = SimpleNamespace(
|
||||
v1=SimpleNamespace(
|
||||
card=SimpleNamespace(create=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id')))),
|
||||
card_element=SimpleNamespace(content=AsyncMock(return_value=DummyResponse())),
|
||||
card=SimpleNamespace(
|
||||
create=MagicMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id'))),
|
||||
acreate=AsyncMock(return_value=DummyResponse(SimpleNamespace(card_id='card-id'))),
|
||||
aupdate=AsyncMock(return_value=DummyResponse()),
|
||||
),
|
||||
card_element=SimpleNamespace(content=MagicMock(return_value=DummyResponse())),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -114,7 +126,7 @@ def manifest() -> dict:
|
||||
/ 'lark'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(path.read_text())
|
||||
return yaml.safe_load(path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def make_adapter(config: dict | None = None) -> LarkAdapter:
|
||||
@@ -324,3 +336,550 @@ async def test_lark_send_reply_platform_api_and_modes():
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
webhook_adapter.bot._connect.assert_not_awaited()
|
||||
|
||||
|
||||
def test_lark_interaction_card_uses_host_callback_token_and_indexes():
|
||||
card = build_interaction_card(
|
||||
{
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}],
|
||||
},
|
||||
'callback-token',
|
||||
{'target_type': 'group', 'target_id': 'chat-1'},
|
||||
)
|
||||
|
||||
assert card['schema'] == '2.0'
|
||||
button = card['body']['elements'][-1]['columns'][0]['elements'][0]
|
||||
assert button['behaviors'][0]['value'] == {'lbi': 'callback-token', 't': 'group', 'ck': 1, 'a': 0}
|
||||
|
||||
|
||||
def test_lark_interaction_card_uses_native_text_input_form():
|
||||
card = build_interaction_card(
|
||||
{
|
||||
'title': 'Add context',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'comment',
|
||||
'label': 'Comment',
|
||||
'type': 'textarea',
|
||||
'required': True,
|
||||
'placeholder': 'Explain the decision',
|
||||
}
|
||||
],
|
||||
'actions': [],
|
||||
},
|
||||
'callback-token',
|
||||
{'target_type': 'group', 'target_id': 'chat-1'},
|
||||
)
|
||||
|
||||
form = next(element for element in card['body']['elements'] if element['tag'] == 'form')
|
||||
field, submit = form['elements']
|
||||
assert field['tag'] == 'input'
|
||||
assert field['input_type'] == 'multiline_text'
|
||||
assert field['required'] is True
|
||||
assert submit['form_action_type'] == 'submit'
|
||||
assert submit['behaviors'][0]['value'] == {
|
||||
'lbi': 'callback-token',
|
||||
't': 'group',
|
||||
'ck': 1,
|
||||
'fm': {'lbi_field_0': 'comment'},
|
||||
'ft': {'comment': 'textarea'},
|
||||
}
|
||||
|
||||
|
||||
def test_lark_interaction_card_uses_native_select_and_declares_fields():
|
||||
card = build_interaction_card(
|
||||
{
|
||||
'title': 'Priority',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'priority',
|
||||
'label': 'Priority',
|
||||
'type': 'select',
|
||||
'required': True,
|
||||
'options': [
|
||||
{'value': 'normal', 'label': 'Normal'},
|
||||
{'value': 'urgent', 'label': 'Urgent'},
|
||||
],
|
||||
}
|
||||
],
|
||||
'actions': [],
|
||||
},
|
||||
'callback-token',
|
||||
{'target_type': 'person', 'target_id': 'user-1'},
|
||||
)
|
||||
|
||||
form = next(element for element in card['body']['elements'] if element['tag'] == 'form')
|
||||
label, select, submit = form['elements']
|
||||
assert label == {'tag': 'markdown', 'content': '**Priority***'}
|
||||
assert select['tag'] == 'select_static'
|
||||
assert [option['value'] for option in select['options']] == ['normal', 'urgent']
|
||||
assert submit['form_action_type'] == 'submit'
|
||||
assert interaction_delivery_capabilities()['field_types'] == ['text', 'textarea', 'number', 'select']
|
||||
assert interaction_delivery_capabilities()['supports_updates'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_interaction_request_sends_interactive_message():
|
||||
adapter = make_adapter()
|
||||
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result['rich'] is True
|
||||
assert result['card_id'] == 'card-id'
|
||||
adapter.api_client.cardkit.v1.card.acreate.assert_awaited_once()
|
||||
request = adapter.api_client.im.v1.message.acreate.await_args.args[0]
|
||||
assert request.receive_id_type == 'open_id'
|
||||
assert request.request_body.msg_type == 'interactive'
|
||||
assert json.loads(request.request_body.content) == {'type': 'card', 'data': {'card_id': 'card-id'}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_interaction_request_updates_existing_message():
|
||||
adapter = make_adapter()
|
||||
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'next-token',
|
||||
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
|
||||
'update_target': {
|
||||
'message_id': 'card-message-1',
|
||||
'card_id': 'card-id',
|
||||
'sequence': 0,
|
||||
'rich': True,
|
||||
},
|
||||
'request': {
|
||||
'interaction_id': 'form-2',
|
||||
'title': 'Choose action',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {
|
||||
'ok': True,
|
||||
'message_id': 'card-message-1',
|
||||
'card_id': 'card-id',
|
||||
'sequence': 1,
|
||||
'rich': True,
|
||||
'updated': True,
|
||||
}
|
||||
adapter.api_client.im.v1.message.acreate.assert_not_awaited()
|
||||
update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
|
||||
assert update_request.card_id == 'card-id'
|
||||
assert update_request.request_body.sequence == 1
|
||||
card = json.loads(update_request.request_body.card.data)
|
||||
assert card['header']['title']['content'] == 'Choose action'
|
||||
button = card['body']['elements'][-1]['columns'][0]['elements'][0]
|
||||
assert button['behaviors'][0]['value']['lbi'] == 'next-token'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_interaction_acknowledge_replaces_controls_with_summary():
|
||||
adapter = make_adapter()
|
||||
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.acknowledge',
|
||||
{
|
||||
'update_target': {
|
||||
'message_id': 'card-message-1',
|
||||
'card_id': 'card-id',
|
||||
'sequence': 0,
|
||||
'rich': True,
|
||||
},
|
||||
'request': {
|
||||
'title': 'Manual review',
|
||||
'fields': [{'id': 'comment', 'label': 'Comment'}],
|
||||
},
|
||||
'submission': {'values': {'comment': 'Looks good'}},
|
||||
},
|
||||
)
|
||||
|
||||
assert result['updated'] is True
|
||||
update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
|
||||
card = json.loads(update_request.request_body.card.data)
|
||||
content = card['body']['elements'][-1]['content']
|
||||
assert content == '✅ Comment:Looks good'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_interaction_keeps_prior_values_through_next_action_and_final_acknowledgement():
|
||||
adapter = make_adapter()
|
||||
update_target = {
|
||||
'message_id': 'card-message-1',
|
||||
'card_id': 'card-id',
|
||||
'sequence': 1,
|
||||
'rich': True,
|
||||
'submitted_values': [{'label': 'Question', 'value': '123'}],
|
||||
}
|
||||
|
||||
action_result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'next-token',
|
||||
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
|
||||
'update_target': update_target,
|
||||
'request': {
|
||||
'interaction_id': 'action-1',
|
||||
'title': 'Manual review',
|
||||
'actions': [{'id': 'or', 'label': 'or'}],
|
||||
'fallback_text': 'Reply or.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
action_update = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
|
||||
action_card = json.loads(action_update.request_body.card.data)
|
||||
assert '✅ Question:123' in action_card['body']['elements'][0]['content']
|
||||
assert action_result['submitted_values'] == [{'label': 'Question', 'value': '123'}]
|
||||
|
||||
final_result = await adapter.call_platform_api(
|
||||
'interaction.acknowledge',
|
||||
{
|
||||
'update_target': action_result,
|
||||
'request': {
|
||||
'title': 'Manual review',
|
||||
'actions': [{'id': 'or', 'label': 'or'}],
|
||||
},
|
||||
'submission': {'action_id': 'or', 'values': {}},
|
||||
},
|
||||
)
|
||||
|
||||
final_update = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
|
||||
final_card = json.loads(final_update.request_body.card.data)
|
||||
final_content = final_card['body']['elements'][-1]['content']
|
||||
assert '✅ Action:or' in final_content
|
||||
assert '✅ Question:123' in final_card['body']['elements'][0]['content']
|
||||
assert final_result['submitted_values'] == [
|
||||
{'label': 'Question', 'value': '123'},
|
||||
{'label': 'Action', 'value': 'or'},
|
||||
]
|
||||
|
||||
|
||||
def test_lark_dify_layout_keeps_prompts_next_to_submitted_values():
|
||||
prior_values = [
|
||||
{
|
||||
'description': '11\n请输入你的问题',
|
||||
'label': 'us_input',
|
||||
'value': '回复我你好',
|
||||
}
|
||||
]
|
||||
select_card = build_interaction_card(
|
||||
{
|
||||
'title': '人工介入',
|
||||
'description': '请选择你的答案',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'field_2',
|
||||
'label': 'xiala',
|
||||
'type': 'select',
|
||||
'options': [
|
||||
{'value': '1', 'label': '1'},
|
||||
{'value': '2', 'label': '2'},
|
||||
],
|
||||
}
|
||||
],
|
||||
'actions': [],
|
||||
},
|
||||
'callback-token',
|
||||
{'target_type': 'person', 'target_id': 'user-1'},
|
||||
prior_values,
|
||||
)
|
||||
|
||||
select_elements = select_card['body']['elements']
|
||||
assert select_elements[0]['content'] == '11\n请输入你的问题\n✅ us_input:回复我你好'
|
||||
assert select_elements[1]['content'] == '请选择你的答案'
|
||||
assert select_elements[2]['tag'] == 'form'
|
||||
|
||||
action_card = build_interaction_card(
|
||||
{
|
||||
'title': '人工介入',
|
||||
'description': None,
|
||||
'fields': [],
|
||||
'actions': [{'id': 'action_1', 'label': 'or'}],
|
||||
},
|
||||
'callback-token',
|
||||
{'target_type': 'person', 'target_id': 'user-1'},
|
||||
[
|
||||
*prior_values,
|
||||
{
|
||||
'description': '请选择你的答案',
|
||||
'label': 'xiala',
|
||||
'value': '1',
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
action_elements = action_card['body']['elements']
|
||||
assert [element['content'] for element in action_elements[:2]] == [
|
||||
'11\n请输入你的问题\n✅ us_input:回复我你好',
|
||||
'请选择你的答案\n✅ xiala:1',
|
||||
]
|
||||
assert action_elements[2]['columns'][0]['elements'][0]['text']['content'] == 'or'
|
||||
|
||||
|
||||
def test_lark_message_id_from_card_callback_source():
|
||||
adapter = make_adapter()
|
||||
callback_source = SimpleNamespace(
|
||||
event=SimpleNamespace(context=SimpleNamespace(open_message_id='card-message-1')),
|
||||
)
|
||||
|
||||
assert (
|
||||
adapter._message_id_from_source(SimpleNamespace(message_id=None, source_platform_object=callback_source))
|
||||
== 'card-message-1'
|
||||
)
|
||||
assert (
|
||||
adapter._message_id_from_source(
|
||||
SimpleNamespace(
|
||||
message_id=None,
|
||||
source_platform_object={'event': {'context': {'open_message_id': 'webhook-message-1'}}},
|
||||
)
|
||||
)
|
||||
== 'webhook-message-1'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_streaming_card_uses_strictly_increasing_sequences():
|
||||
adapter = make_adapter()
|
||||
adapter.card_id_dict['response-1'] = 'stream-card-1'
|
||||
adapter.card_sequence_dict['stream-card-1'] = 0
|
||||
adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'answer'}]], []))
|
||||
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=1, tool_calls=None)
|
||||
source = SimpleNamespace(source_platform_object=None)
|
||||
message = platform_message.MessageChain([platform_message.Plain(text='answer')])
|
||||
|
||||
await adapter.reply_message_chunk(source, bot_message, message)
|
||||
first_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
|
||||
assert first_request.request_body.sequence == 1
|
||||
|
||||
bot_message.msg_sequence = 2
|
||||
await adapter.reply_message_chunk(source, bot_message, message)
|
||||
assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1
|
||||
|
||||
bot_message.msg_sequence = 8
|
||||
await adapter.reply_message_chunk(source, bot_message, message)
|
||||
second_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
|
||||
assert second_request.request_body.sequence == 2
|
||||
|
||||
bot_message.msg_sequence = 9
|
||||
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
|
||||
final_request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
|
||||
assert final_request.request_body.sequence == 3
|
||||
assert 'response-1' not in adapter.card_id_dict
|
||||
assert 'stream-card-1' not in adapter.card_sequence_dict
|
||||
assert 'stream-card-1' not in adapter.card_last_update_dict
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_streaming_card_updates_sparse_chunks_without_waiting_for_eighth():
|
||||
adapter = make_adapter()
|
||||
adapter.card_id_dict['response-1'] = 'stream-card-1'
|
||||
adapter.card_sequence_dict['stream-card-1'] = 1
|
||||
adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2
|
||||
adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'new progress'}]], []))
|
||||
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=2, tool_calls=None)
|
||||
source = SimpleNamespace(source_platform_object=None)
|
||||
message = platform_message.MessageChain([platform_message.Plain(text='new progress')])
|
||||
|
||||
await adapter.reply_message_chunk(source, bot_message, message)
|
||||
|
||||
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
|
||||
assert request.request_body.sequence == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_streaming_card_uses_cumulative_runner_content():
|
||||
adapter = make_adapter()
|
||||
adapter.card_id_dict['response-1'] = 'stream-card-1'
|
||||
adapter.card_sequence_dict['stream-card-1'] = 0
|
||||
adapter.message_converter.yiri2target = AsyncMock(
|
||||
return_value=([[{'tag': 'text', 'text': 'latest chunk only'}]], [])
|
||||
)
|
||||
bot_message = SimpleNamespace(
|
||||
resp_message_id='response-1',
|
||||
msg_sequence=2,
|
||||
all_content='first chunk\n\nlatest chunk only',
|
||||
tool_calls=None,
|
||||
)
|
||||
source = SimpleNamespace(source_platform_object=None)
|
||||
message = platform_message.MessageChain([platform_message.Plain(text='latest chunk only')])
|
||||
|
||||
await adapter.reply_message_chunk(source, bot_message, message)
|
||||
|
||||
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
|
||||
assert request.request_body.content == 'first chunk\n\nlatest chunk only'
|
||||
adapter.message_converter.yiri2target.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_streaming_card_first_real_runner_chunk_uses_sequence_one():
|
||||
adapter = make_adapter()
|
||||
adapter.card_id_dict['response-1'] = 'stream-card-1'
|
||||
adapter.card_sequence_dict['stream-card-1'] = 0
|
||||
adapter.message_converter.yiri2target = AsyncMock(return_value=([[{'tag': 'text', 'text': 'answer'}]], []))
|
||||
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=1, tool_calls=None)
|
||||
source = SimpleNamespace(source_platform_object=None)
|
||||
message = platform_message.MessageChain([platform_message.Plain(text='answer')])
|
||||
|
||||
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
|
||||
|
||||
request = adapter.api_client.cardkit.v1.card_element.content.call_args.args[0]
|
||||
assert request.request_body.sequence == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_streaming_card_falls_back_to_full_card_update_when_stream_closes():
|
||||
adapter = make_adapter()
|
||||
adapter.card_id_dict['response-1'] = 'stream-card-1'
|
||||
adapter.card_sequence_dict['stream-card-1'] = 1
|
||||
adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2
|
||||
closed_response = DummyResponse(ok=False)
|
||||
closed_response.code = 300309
|
||||
closed_response.msg = 'streaming mode is closed'
|
||||
adapter.api_client.cardkit.v1.card_element.content.return_value = closed_response
|
||||
adapter.message_converter.yiri2target = AsyncMock(
|
||||
return_value=([[{'tag': 'text', 'text': 'continued progress'}]], [])
|
||||
)
|
||||
bot_message = SimpleNamespace(resp_message_id='response-1', msg_sequence=2, tool_calls=None)
|
||||
source = SimpleNamespace(source_platform_object=None)
|
||||
message = platform_message.MessageChain([platform_message.Plain(text='continued progress')])
|
||||
|
||||
await adapter.reply_message_chunk(source, bot_message, message)
|
||||
|
||||
update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0]
|
||||
updated_card = json.loads(update_request.request_body.card.data)
|
||||
assert update_request.request_body.sequence == 3
|
||||
assert updated_card['config'] == {'update_multi': True}
|
||||
assert updated_card['body']['elements'][0]['content'] == 'continued progress'
|
||||
assert 'stream-card-1' in adapter.closed_streaming_cards
|
||||
|
||||
bot_message.msg_sequence = 3
|
||||
adapter.card_last_update_dict['stream-card-1'] = time.monotonic() - 2
|
||||
await adapter.reply_message_chunk(source, bot_message, message, is_final=True)
|
||||
|
||||
assert adapter.api_client.cardkit.v1.card_element.content.call_count == 1
|
||||
assert adapter.api_client.cardkit.v1.card.aupdate.await_count == 2
|
||||
assert 'stream-card-1' not in adapter.closed_streaming_cards
|
||||
|
||||
|
||||
def test_lark_interaction_callback_builds_scoped_host_event():
|
||||
callback = SimpleNamespace(
|
||||
event=SimpleNamespace(
|
||||
action=SimpleNamespace(value={'lbi': 'callback-token', 't': 'group', 'a': 1}),
|
||||
operator=SimpleNamespace(open_id='user-1'),
|
||||
context=SimpleNamespace(open_chat_id='chat-1', open_message_id='card-message-1'),
|
||||
)
|
||||
)
|
||||
|
||||
event = interaction_event_from_callback(callback)
|
||||
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['callback_token'] == 'callback-token'
|
||||
assert event.data['action_ref'] == 1
|
||||
assert event.data['actor_id'] == 'user-1'
|
||||
assert event.data['target_id'] == 'chat-1'
|
||||
assert event.data['message_id'] == 'card-message-1'
|
||||
|
||||
|
||||
def test_lark_native_form_callback_submits_typed_field_value():
|
||||
callback = SimpleNamespace(
|
||||
event=SimpleNamespace(
|
||||
action=SimpleNamespace(
|
||||
value={
|
||||
'lbi': 'callback-token',
|
||||
't': 'person',
|
||||
'fm': {'lbi_field_0': 'score'},
|
||||
'ft': {'score': 'number'},
|
||||
},
|
||||
form_value={'lbi_field_0': '42.5'},
|
||||
),
|
||||
operator=SimpleNamespace(open_id='user-1'),
|
||||
context=SimpleNamespace(open_chat_id='chat-1'),
|
||||
)
|
||||
)
|
||||
|
||||
event = interaction_event_from_callback(callback)
|
||||
|
||||
assert event.data['callback_token'] == 'callback-token'
|
||||
assert event.data['target_type'] == 'person'
|
||||
assert event.data['target_id'] == 'user-1'
|
||||
assert event.data['values'] == {'score': 42.5}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_webhook_dispatches_native_form_submission():
|
||||
adapter = make_adapter({'enable-webhook': True})
|
||||
calls: list[platform_events.Event] = []
|
||||
|
||||
async def listener(event, adapter):
|
||||
calls.append(event)
|
||||
|
||||
adapter.register_listener(platform_events.PlatformSpecificEvent, listener)
|
||||
payload = {
|
||||
'schema': '2.0',
|
||||
'header': {'event_type': 'card.action.trigger'},
|
||||
'event': {
|
||||
'action': {
|
||||
'value': {
|
||||
'lbi': 'callback-token',
|
||||
't': 'group',
|
||||
'fm': {'lbi_field_0': 'comment'},
|
||||
'ft': {'comment': 'textarea'},
|
||||
},
|
||||
'form_value': {'lbi_field_0': 'Looks good'},
|
||||
},
|
||||
'operator': {'open_id': 'user-1'},
|
||||
'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'},
|
||||
},
|
||||
}
|
||||
request = SimpleNamespace(json=asyncio.sleep(0, result=payload))
|
||||
|
||||
response = await adapter.handle_unified_webhook('bot-1', '', request)
|
||||
|
||||
assert response['toast'] == {'type': 'success', 'content': 'Submitted / 已提交'}
|
||||
assert response['card']['type'] == 'raw'
|
||||
assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted / 已提交**'
|
||||
assert len(calls) == 1
|
||||
assert calls[0].action == 'interaction.submitted'
|
||||
assert calls[0].data['callback_token'] == 'callback-token'
|
||||
assert calls[0].data['values'] == {'comment': 'Looks good'}
|
||||
assert calls[0].data['message_id'] == 'card-message-1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_webhook_cardkit_submission_uses_async_card_update_only():
|
||||
adapter = make_adapter({'enable-webhook': True})
|
||||
|
||||
async def listener(event, adapter):
|
||||
pass
|
||||
|
||||
adapter.register_listener(platform_events.PlatformSpecificEvent, listener)
|
||||
payload = {
|
||||
'schema': '2.0',
|
||||
'header': {'event_type': 'card.action.trigger'},
|
||||
'event': {
|
||||
'action': {'value': {'lbi': 'callback-token', 't': 'group', 'ck': 1, 'a': 0}},
|
||||
'operator': {'open_id': 'user-1'},
|
||||
'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'},
|
||||
},
|
||||
}
|
||||
request = SimpleNamespace(json=asyncio.sleep(0, result=payload))
|
||||
|
||||
response = await adapter.handle_unified_webhook('bot-1', '', request)
|
||||
|
||||
assert response == {'toast': {'type': 'success', 'content': 'Submitted / 已提交'}}
|
||||
|
||||
@@ -13,6 +13,10 @@ from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
|
||||
from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter
|
||||
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
|
||||
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.qqofficial.interaction import (
|
||||
build_interaction_keyboard,
|
||||
interaction_event_from_payload,
|
||||
)
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
@@ -49,6 +53,9 @@ class DummyQQOfficialClient:
|
||||
self.access_token_expiry_time = None
|
||||
self.handle_unified_webhook = AsyncMock(return_value='success')
|
||||
self.connect_gateway_loop = AsyncMock()
|
||||
self.send_markdown_keyboard = AsyncMock(return_value={'id': 'keyboard-message'})
|
||||
self.ack_interaction = AsyncMock()
|
||||
self._interaction_handler = None
|
||||
|
||||
def on_message(self, msg_type: str):
|
||||
def decorator(func):
|
||||
@@ -57,6 +64,13 @@ class DummyQQOfficialClient:
|
||||
|
||||
return decorator
|
||||
|
||||
def on_interaction(self):
|
||||
def decorator(func):
|
||||
self._interaction_handler = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
async def check_access_token(self):
|
||||
return bool(self.access_token)
|
||||
|
||||
@@ -111,7 +125,7 @@ def manifest() -> dict:
|
||||
/ 'qqofficial'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def make_adapter(enable_webhook: bool = True) -> QQOfficialAdapter:
|
||||
@@ -164,6 +178,54 @@ def test_qqofficial_platform_api_map_matches_manifest():
|
||||
assert set(PLATFORM_API_MAP) == manifest_actions
|
||||
|
||||
|
||||
def test_qqofficial_interaction_keyboard_contains_only_host_token_and_index():
|
||||
keyboard = build_interaction_keyboard(
|
||||
{'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}]},
|
||||
'callback-token',
|
||||
)
|
||||
|
||||
button = keyboard['content']['rows'][0]['buttons'][0]
|
||||
assert button['action']['data'] == 'lbi:callback-token:a:0'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qqofficial_interaction_delivery_and_callback_event():
|
||||
adapter = make_adapter()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {
|
||||
'target_type': 'group',
|
||||
'target_id': 'group-openid',
|
||||
'message_id': 'msg-1',
|
||||
},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {'ok': True, 'message_id': 'keyboard-message', 'rich': True}
|
||||
assert adapter.bot.send_markdown_keyboard.await_args.kwargs['msg_id'] == 'msg-1'
|
||||
|
||||
event = interaction_event_from_payload(
|
||||
{
|
||||
'id': 'interaction-id',
|
||||
'chat_type': 1,
|
||||
'group_openid': 'group-openid',
|
||||
'member_openid': 'user-openid',
|
||||
'data': {'resolved': {'button_data': 'lbi:callback-token:a:0'}},
|
||||
}
|
||||
)
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['action_ref'] == 0
|
||||
assert event.data['actor_id'] == 'user-openid'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qqofficial_message_converter_maps_common_components_to_send_payloads():
|
||||
payload = await QQOfficialMessageConverter.yiri2target(
|
||||
|
||||
@@ -73,7 +73,7 @@ class TestEventRouteTrace:
|
||||
|
||||
captured_envelopes = []
|
||||
|
||||
async def fake_run(envelope, binding):
|
||||
async def fake_run(envelope, binding, adapter_context=None):
|
||||
captured_envelopes.append(envelope)
|
||||
yield provider_message.Message(role='assistant', content='test response')
|
||||
|
||||
@@ -157,7 +157,7 @@ class TestEventRouteTrace:
|
||||
}
|
||||
runner_calls = []
|
||||
|
||||
async def fake_run(envelope, binding):
|
||||
async def fake_run(envelope, binding, adapter_context=None):
|
||||
runner_calls.append((envelope, binding))
|
||||
if False:
|
||||
yield None
|
||||
@@ -378,6 +378,53 @@ class TestRuntimeBotLifecycle:
|
||||
bot.adapter.kill.assert_awaited_once()
|
||||
task_mgr.cancel_task.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_uses_only_eba_message_listener_for_eba_adapter(self):
|
||||
"""An EBA-native message must not also enter through the legacy compatibility listener."""
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
listeners = {}
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_events=Mock(return_value=['message.received']),
|
||||
register_listener=Mock(side_effect=lambda event_type, callback: listeners.setdefault(event_type, callback)),
|
||||
)
|
||||
bot = RuntimeBot(
|
||||
ap=SimpleNamespace(),
|
||||
bot_entity=SimpleNamespace(enable=True),
|
||||
adapter=adapter,
|
||||
logger=Mock(),
|
||||
)
|
||||
|
||||
await bot.initialize()
|
||||
|
||||
assert platform_events.EBAEvent in listeners
|
||||
assert platform_events.FriendMessage not in listeners
|
||||
assert platform_events.GroupMessage not in listeners
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_keeps_legacy_message_listeners_for_legacy_adapter(self):
|
||||
"""Adapters without EBA message support retain the compatibility entry path."""
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
listeners = {}
|
||||
adapter = SimpleNamespace(
|
||||
register_listener=Mock(side_effect=lambda event_type, callback: listeners.setdefault(event_type, callback)),
|
||||
)
|
||||
bot = RuntimeBot(
|
||||
ap=SimpleNamespace(),
|
||||
bot_entity=SimpleNamespace(enable=True),
|
||||
adapter=adapter,
|
||||
logger=Mock(),
|
||||
)
|
||||
|
||||
await bot.initialize()
|
||||
|
||||
assert platform_events.FriendMessage in listeners
|
||||
assert platform_events.GroupMessage in listeners
|
||||
assert platform_events.EBAEvent in listeners
|
||||
|
||||
|
||||
class TestEBAEventBindings:
|
||||
"""Test RuntimeBot EBA event binding helpers."""
|
||||
@@ -482,8 +529,11 @@ class TestEBAEventBindings:
|
||||
assert binding.resource_policy.allowed_tool_names is None
|
||||
assert binding.delivery_policy.enable_streaming is False
|
||||
assert binding.delivery_policy.enable_reply is True
|
||||
assert binding.delivery_policy.enable_interactions is True
|
||||
assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner']
|
||||
assert binding.agent_id == 'agent-1'
|
||||
assert binding.processor_type == 'agent'
|
||||
assert binding.processor_id == 'agent-1'
|
||||
|
||||
def test_agent_product_to_binding_projects_selected_tool_policy(self):
|
||||
"""Independent Agents use the same standard runner resource fields as Pipelines."""
|
||||
@@ -513,6 +563,174 @@ class TestEBAEventBindings:
|
||||
assert binding.resource_policy.allowed_tool_names == ['exec', 'plugin_tool']
|
||||
assert binding.resource_policy.allowed_kb_uuids == ['kb-1']
|
||||
|
||||
|
||||
class TestInteractionResumeRouting:
|
||||
"""Interaction callbacks resume the processor that created the request."""
|
||||
|
||||
@staticmethod
|
||||
def _event():
|
||||
from langbot_plugin.api.entities.builtin.platform.events import PlatformSpecificEvent
|
||||
|
||||
return PlatformSpecificEvent(
|
||||
action='interaction.submitted',
|
||||
timestamp=1234,
|
||||
data={
|
||||
'callback_token': 'callback-token',
|
||||
'interaction_id': 'form-1',
|
||||
'action_id': 'approve',
|
||||
'values': {'name': 'Alice'},
|
||||
'actor_id': 'user-1',
|
||||
'target_type': 'group',
|
||||
'target_id': 'chat-1',
|
||||
'message_id': 'card-message-1',
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _record(processor_type: str):
|
||||
return {
|
||||
'id': 1,
|
||||
'interaction_id': 'form-1',
|
||||
'binding_id': 'binding-1',
|
||||
'runner_id': 'plugin:test/Dify/default',
|
||||
'processor_type': processor_type,
|
||||
'processor_id': f'{processor_type}-1',
|
||||
'workspace_id': None,
|
||||
'conversation_id': 'group_chat-1',
|
||||
'thread_id': None,
|
||||
'delivery_target': {'target_type': 'group', 'target_id': 'chat-1'},
|
||||
'submission': {
|
||||
'interaction_id': 'form-1',
|
||||
'action_id': 'approve',
|
||||
'values': {'name': 'Alice'},
|
||||
'submitted_at': 1234,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _make_bot(record):
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
bot = object.__new__(RuntimeBot)
|
||||
bot.bot_entity = SimpleNamespace(uuid='bot-1', name='Test', event_bindings=[])
|
||||
interaction_manager = SimpleNamespace(
|
||||
consume_callback=AsyncMock(return_value=record),
|
||||
acknowledge_submission=AsyncMock(),
|
||||
)
|
||||
bot.ap = SimpleNamespace(
|
||||
agent_run_orchestrator=SimpleNamespace(interaction_manager=interaction_manager),
|
||||
msg_aggregator=SimpleNamespace(add_message=AsyncMock()),
|
||||
pipeline_service=SimpleNamespace(
|
||||
get_pipeline=AsyncMock(
|
||||
return_value={
|
||||
'uuid': record['processor_id'],
|
||||
'config': {
|
||||
'ai': {
|
||||
'runner': {'id': record['runner_id']},
|
||||
'runner_config': {record['runner_id']: {}},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
),
|
||||
)
|
||||
return bot
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_callback_bypasses_route_table_and_targets_original_pipeline(self):
|
||||
bot = self._make_bot(self._record('pipeline'))
|
||||
adapter = SimpleNamespace()
|
||||
|
||||
await bot._handle_interaction_submission(self._event(), adapter)
|
||||
|
||||
bot.ap.msg_aggregator.add_message.assert_awaited_once()
|
||||
kwargs = bot.ap.msg_aggregator.add_message.await_args.kwargs
|
||||
assert kwargs['pipeline_uuid'] == 'pipeline-1'
|
||||
assert kwargs['routed_by_rule'] is True
|
||||
assert kwargs['variables']['_interaction_submission']['interaction_id'] == 'form-1'
|
||||
assert kwargs['message_chain'].message_id == 'card-message-1'
|
||||
assert kwargs['message_event'].message_chain.message_id == 'card-message-1'
|
||||
manager = bot.ap.agent_run_orchestrator.interaction_manager
|
||||
manager.consume_callback.assert_awaited_once_with(
|
||||
callback_token='callback-token',
|
||||
submission={
|
||||
'interaction_id': 'form-1',
|
||||
'action_id': 'approve',
|
||||
'values': {'name': 'Alice'},
|
||||
'submitted_at': 1234,
|
||||
},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_forwards_compact_platform_references(self):
|
||||
bot = self._make_bot(self._record('pipeline'))
|
||||
adapter = SimpleNamespace()
|
||||
event = self._event()
|
||||
event.data.pop('interaction_id')
|
||||
event.data.pop('action_id')
|
||||
event.data['action_ref'] = 1
|
||||
|
||||
await bot._handle_interaction_submission(event, adapter)
|
||||
|
||||
manager = bot.ap.agent_run_orchestrator.interaction_manager
|
||||
submission = manager.consume_callback.await_args.kwargs['submission']
|
||||
assert submission['action_ref'] == 1
|
||||
assert submission['interaction_id'] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_callback_rejects_changed_runner(self):
|
||||
bot = self._make_bot(self._record('pipeline'))
|
||||
bot.ap.pipeline_service.get_pipeline.return_value['config']['ai']['runner']['id'] = 'plugin:test/Other/default'
|
||||
|
||||
with pytest.raises(ValueError, match='Pipeline runner changed'):
|
||||
await bot._handle_interaction_submission(self._event(), SimpleNamespace())
|
||||
|
||||
bot.ap.msg_aggregator.add_message.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_callback_targets_original_agent_and_runner(self):
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
captured = []
|
||||
|
||||
async def fake_run(envelope, binding, adapter_context=None):
|
||||
captured.append((envelope, binding, adapter_context))
|
||||
yield provider_message.Message(role='assistant', content='approved')
|
||||
|
||||
bot = self._make_bot(self._record('agent'))
|
||||
bot.ap.agent_service = SimpleNamespace(
|
||||
get_agent=AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'agent-1',
|
||||
'kind': 'agent',
|
||||
'enabled': True,
|
||||
'config': {
|
||||
'runner': {'id': 'plugin:test/Dify/default'},
|
||||
'runner_config': {'plugin:test/Dify/default': {}},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
bot.ap.agent_run_orchestrator.run = fake_run
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_apis=Mock(return_value=['send_message']),
|
||||
send_message=AsyncMock(),
|
||||
)
|
||||
|
||||
await bot._handle_interaction_submission(self._event(), adapter)
|
||||
|
||||
envelope, binding, adapter_context = captured[0]
|
||||
assert envelope.event_type == 'interaction.submitted'
|
||||
assert envelope.data['interaction']['action_id'] == 'approve'
|
||||
assert binding.binding_id == 'binding-1'
|
||||
assert binding.processor_type == 'agent'
|
||||
assert binding.processor_id == 'agent-1'
|
||||
assert adapter_context == {'_delivery_adapter': adapter}
|
||||
adapter.send_message.assert_awaited_once()
|
||||
|
||||
def test_agent_product_to_binding_does_not_fallback_to_component_ref(self):
|
||||
"""An empty config runner stays unconfigured even if component_ref is stale."""
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
@@ -14,6 +14,10 @@ from telegram.ext import CallbackQueryHandler, ChatMemberHandler, MessageHandler
|
||||
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter
|
||||
from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
||||
from langbot.pkg.platform.adapters.telegram.interaction import (
|
||||
interaction_event_from_update,
|
||||
parse_interaction_callback,
|
||||
)
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
@@ -152,13 +156,159 @@ def test_telegram_supported_events_match_manifest():
|
||||
/ 'telegram'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
manifest_events = yaml.safe_load(manifest_path.read_text())['spec']['supported_events']
|
||||
manifest_events = yaml.safe_load(manifest_path.read_text(encoding='utf-8'))['spec']['supported_events']
|
||||
|
||||
assert adapter_events == manifest_events
|
||||
assert 'message.deleted' not in adapter_events
|
||||
assert 'group.info_updated' not in adapter_events
|
||||
|
||||
|
||||
def test_telegram_interaction_callback_parser_uses_compact_indexes():
|
||||
assert parse_interaction_callback('lbi:token:a:2') == {
|
||||
'callback_token': 'token',
|
||||
'action_ref': 2,
|
||||
}
|
||||
assert parse_interaction_callback('lbi:token:f:1:3') == {
|
||||
'callback_token': 'token',
|
||||
'field_ref': 1,
|
||||
'option_ref': 3,
|
||||
}
|
||||
assert parse_interaction_callback('ordinary-button') is None
|
||||
with pytest.raises(ValueError, match='invalid Telegram interaction callback'):
|
||||
parse_interaction_callback('lbi:token:a:not-an-index')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_interaction_request_renders_action_buttons_and_thread_target():
|
||||
adapter = make_adapter()
|
||||
sent = SimpleNamespace(message_id=321)
|
||||
bot = SimpleNamespace(send_message=AsyncMock(return_value=sent))
|
||||
object.__setattr__(adapter, 'bot', bot)
|
||||
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': '-1001#7'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'kind': 'choice',
|
||||
'title': 'Approve?',
|
||||
'actions': [
|
||||
{'id': 'approve', 'label': 'Approve', 'style': 'primary'},
|
||||
{'id': 'reject', 'label': 'Reject', 'style': 'danger'},
|
||||
],
|
||||
'fallback_text': 'Reply approve or reject.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {'ok': True, 'message_id': 321, 'rich': True}
|
||||
kwargs = bot.send_message.await_args.kwargs
|
||||
assert kwargs['chat_id'] == -1001
|
||||
assert kwargs['message_thread_id'] == 7
|
||||
buttons = kwargs['reply_markup'].inline_keyboard
|
||||
assert buttons[0][0].callback_data == 'lbi:callback-token:a:0'
|
||||
assert buttons[1][0].callback_data == 'lbi:callback-token:a:1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_interaction_request_renders_select_or_falls_back():
|
||||
adapter = make_adapter()
|
||||
bot = SimpleNamespace(send_message=AsyncMock(return_value=SimpleNamespace(message_id=1)))
|
||||
object.__setattr__(adapter, 'bot', bot)
|
||||
base_params = {
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'person', 'target_id': '123'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Choose priority',
|
||||
'fields': [
|
||||
{
|
||||
'id': 'priority',
|
||||
'label': 'Priority',
|
||||
'type': 'select',
|
||||
'options': [{'value': 'urgent', 'label': 'Urgent'}],
|
||||
}
|
||||
],
|
||||
'fallback_text': 'Reply with a priority.',
|
||||
},
|
||||
}
|
||||
|
||||
result = await adapter.call_platform_api('interaction.request', base_params)
|
||||
assert result['rich'] is True
|
||||
button = bot.send_message.await_args.kwargs['reply_markup'].inline_keyboard[0][0]
|
||||
assert button.callback_data == 'lbi:callback-token:f:0:0'
|
||||
|
||||
base_params['request']['fields'][0]['type'] = 'text'
|
||||
result = await adapter.call_platform_api('interaction.request', base_params)
|
||||
assert result['rich'] is False
|
||||
kwargs = bot.send_message.await_args.kwargs
|
||||
assert 'reply_markup' not in kwargs
|
||||
assert 'Reply with a priority.' in kwargs['text']
|
||||
|
||||
|
||||
def test_telegram_interaction_callback_builds_scoped_host_event():
|
||||
update = SimpleNamespace(
|
||||
callback_query=SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
chat=SimpleNamespace(id=-1001, type='supergroup'),
|
||||
message_thread_id=7,
|
||||
),
|
||||
from_user=SimpleNamespace(id=456),
|
||||
)
|
||||
)
|
||||
|
||||
event = interaction_event_from_update(
|
||||
update,
|
||||
{'callback_token': 'callback-token', 'action_ref': 1},
|
||||
)
|
||||
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['callback_token'] == 'callback-token'
|
||||
assert event.data['action_ref'] == 1
|
||||
assert event.data['actor_id'] == '456'
|
||||
assert event.data['target_type'] == 'group'
|
||||
assert event.data['target_id'] == '-1001#7'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_interaction_callback_answers_and_dispatches_once():
|
||||
adapter = make_adapter()
|
||||
listener = AsyncMock()
|
||||
adapter.register_listener(platform_events.EBAEvent, listener)
|
||||
query = SimpleNamespace(
|
||||
data='lbi:callback-token:a:0',
|
||||
answer=AsyncMock(),
|
||||
edit_message_reply_markup=AsyncMock(),
|
||||
message=SimpleNamespace(
|
||||
chat=SimpleNamespace(id=123, type='private'),
|
||||
message_thread_id=None,
|
||||
),
|
||||
from_user=SimpleNamespace(id=456),
|
||||
)
|
||||
update = SimpleNamespace(
|
||||
message=None,
|
||||
edited_message=None,
|
||||
chat_member=None,
|
||||
my_chat_member=None,
|
||||
callback_query=query,
|
||||
message_reaction=None,
|
||||
)
|
||||
callback_handler = next(
|
||||
handler for handler in adapter.application.handlers[0] if isinstance(handler, CallbackQueryHandler)
|
||||
)
|
||||
|
||||
await callback_handler.callback(update, None)
|
||||
|
||||
query.answer.assert_awaited_once_with()
|
||||
query.edit_message_reply_markup.assert_awaited_once_with(reply_markup=None)
|
||||
listener.assert_awaited_once()
|
||||
event = listener.await_args.args[0]
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['action_ref'] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_converter_maps_message_and_edited_message_events():
|
||||
update = make_update({'message': base_message_payload(text='hello @test_bot')})
|
||||
@@ -187,6 +337,19 @@ async def test_telegram_converter_maps_message_and_edited_message_events():
|
||||
assert str(edited_event.new_content) == 'edited'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_legacy_group_converter_preserves_actor_identity():
|
||||
adapter = make_adapter()
|
||||
group_chat = {'id': -100123, 'type': 'supergroup', 'title': 'Test Group'}
|
||||
update = make_update({'message': base_message_payload(chat=group_chat)})
|
||||
|
||||
event = await adapter.legacy_event_converter.target2yiri(update, adapter.bot, 'test_bot')
|
||||
|
||||
assert isinstance(event, platform_events.GroupMessage)
|
||||
assert event.sender.id == 456
|
||||
assert event.sender.group.id == -100123
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_converter_maps_non_message_updates():
|
||||
chat_member = make_update(
|
||||
|
||||
@@ -11,6 +11,10 @@ from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter
|
||||
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
|
||||
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
|
||||
from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.wecombot.interaction import (
|
||||
build_interaction_card,
|
||||
interaction_event_from_native,
|
||||
)
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
@@ -44,6 +48,7 @@ class DummyWecomBotWsClient:
|
||||
self.reply_text = AsyncMock(return_value={'reply': True})
|
||||
self.push_stream_chunk = AsyncMock(return_value=True)
|
||||
self.set_message = AsyncMock(return_value={'set': True})
|
||||
self.send_template_card = AsyncMock(return_value={'sent': True})
|
||||
|
||||
def on_message(self, msg_type: str):
|
||||
def decorator(func):
|
||||
@@ -82,7 +87,7 @@ def manifest() -> dict:
|
||||
/ 'wecombot'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
return yaml.safe_load(manifest_path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def make_adapter(enable_webhook: bool = False) -> WecomBotAdapter:
|
||||
@@ -149,6 +154,52 @@ def test_wecombot_platform_api_map_matches_manifest():
|
||||
assert set(PLATFORM_API_MAP) == manifest_actions
|
||||
|
||||
|
||||
def test_wecombot_interaction_card_uses_host_callback_token_and_index():
|
||||
card = build_interaction_card(
|
||||
{'title': 'Approve?', 'actions': [{'id': 'approve', 'label': 'Approve'}]},
|
||||
'callback-token',
|
||||
)
|
||||
|
||||
button = card['template_card']['button_list'][0]
|
||||
assert button['key'] == 'lbi:callback-token:a:0'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wecombot_interaction_delivery_and_callback_event():
|
||||
adapter = make_adapter()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'callback-token',
|
||||
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
|
||||
'request': {
|
||||
'interaction_id': 'form-1',
|
||||
'title': 'Approve?',
|
||||
'actions': [{'id': 'approve', 'label': 'Approve'}],
|
||||
'fallback_text': 'Reply approve.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result['rich'] is True
|
||||
adapter.bot.send_template_card.assert_awaited_once()
|
||||
|
||||
native = wecombot_event(type='group', msgtype='event')
|
||||
native['event'] = {
|
||||
'eventtype': 'template_card_event',
|
||||
'template_card_event': {'EventKey': 'lbi:callback-token:a:0'},
|
||||
}
|
||||
event = interaction_event_from_native(native)
|
||||
assert event.action == 'interaction.submitted'
|
||||
assert event.data['action_ref'] == 0
|
||||
assert event.data['target_id'] == 'group-1'
|
||||
|
||||
native['event']['template_card_event'] = {'button': {'key': 'lbi:callback-token:f:0:2'}}
|
||||
nested_event = interaction_event_from_native(native)
|
||||
assert nested_event.data['field_ref'] == 0
|
||||
assert nested_event.data['option_ref'] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wecombot_message_converter_maps_outbound_components_to_markdown_text():
|
||||
content = await WecomBotMessageConverter.yiri2target(
|
||||
@@ -271,4 +322,6 @@ async def test_wecombot_send_reply_feedback_and_platform_api_use_underlying_clie
|
||||
async def test_wecombot_webhook_mode_rejects_proactive_send():
|
||||
adapter = make_adapter(enable_webhook=True)
|
||||
with pytest.raises(NotSupportedError):
|
||||
await adapter.send_message('person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')]))
|
||||
await adapter.send_message(
|
||||
'person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')])
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PipelineConfigStage,
|
||||
} from '@/app/infra/entities/pipeline';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -350,10 +351,38 @@ export default function PipelineFormComponent({
|
||||
|
||||
const currentValues =
|
||||
(form.getValues(formName) as Record<string, unknown>) || {};
|
||||
form.setValue(formName, {
|
||||
const nextValues: Record<string, unknown> = {
|
||||
...currentValues,
|
||||
[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) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
|
||||
Reference in New Issue
Block a user