跳转至

Webhook 例程

一个例程有一个触发器:schedule 或 webhook。Webhook 触发器属于其例程。它具有公共 URL(在账户服务注册路由之前为 null)、一个可选的确切事件类型(null 接受所有类型),以及最多 128 个 JSON Pointer 过滤器。每个过滤器比较一个标量值。所有过滤器必须匹配。过滤器和负载模板不运行代码。Agent 例程和频道例程使用相同的模型。类型:packages/contracts/src/ipc-events.ts。没有共享的、主机级的事件源。用户指南 包含请求格式。

流程为:签名请求 → 路由 → 例程 → 运行。

所有权

数据 所有者 位置
触发器、路由 ID、加密后的密钥、URL 主机 SQLite projection_routine_webhooks、projection_channel_routine_webhooks
工作流触发器路由 ID、加密后的密钥、URL 主机 SQLite workflow_trigger_webhooks(schema v46)
回执(无请求体) 主机 SQLite projection_webhook_receipts、workflow_webhook_receipts
需要在 relay 上撤销的路由 主机 SQLite projection_webhook_route_revocations
路由 ID → 主机和所有者账户、链接时间、撤销时间 账户服务 D1 webhook_routes
路由 ID → 入站套接字 Signal 内存 SignalService

Schema v30(src/backend/openbot-database-schema.ts)仅添加这些表。现有例程和运行不变。

  • RoutineStore(src/backend/routine-store.ts)在同一事务中将触发器行与例程一起写入。更改触发器类型会保留例程 ID 及其运行。
  • WebhookRouteStore(src/backend/webhook-route-store.ts)读取路由、轮换密钥、保留回执,并持有撤销队列。路由的所有者是 WebhookRouteOwner:一个 Agent 或频道例程(EventRoutineOwner),或 { kind: "workflow" },用于工作流的 webhook 触发器。工作流部分保留在后端;事件 API 和 events-v1 编码器仅指明例程所有者。
  • RoutineRecords(src/backend/routine-records.ts)是 main 使用的唯一后端对象。它按所有者选择 Agent 或频道调度器,并持有 webhook 路由存储。
  • webhook-trigger.ts 包含触发器验证、过滤器匹配、运行指令和模板填充。
  • Main 拥有密钥加密算法、签名检查和中继:HostEventsService(src/main/host-events-service.ts)、WebhookRelay、HostEventsRuntime 和 webhook-security.ts。

路由生命周期

后端在例程首次获得 webhook 触发器时创建路由 ID(一个 UUID)。Main 创建密钥 whsec_ 和 base64url 格式的 32 个随机字节,并使用主机密钥加密器(Electron safeStorage)对其进行加密。保存结果一次性返回该密钥。rotateSecret 替换存储的密文;下一个请求使用新密钥,因此旧密钥立即失效。一个主机最多有 WEBHOOK_ROUTES_LIMIT(64)条路由。Main 和账户服务都会检查此限制。

保存后,HostEventsService.syncRoutes 注册每个没有 URL 的路由。账户服务为主机记录该路由。WebhookRelay 从 Signal 来源和 WEBHOOK_EVENTS_PATH(/v1/webhooks/<routeId>)构建 URL。失败的调用会保留本地状态以供下次同步,并且不会使保存失败。调用失败后,同步会在 30 秒后再次运行。延迟会加倍,最多 15 分钟,直到同步成功。保存会在同步之前释放其写锁,因此网络缓慢不会阻塞其他保存。在启动时以及登录账户更改时,主机会重新注册所有路由。令牌刷新不会启动同步。

HostEventsRuntime 一次运行一个同步。同步期间到达的请求会触发另一次同步。例程更改会启动同步。Agent 或频道事件仅在所有者不存在时启动同步,因为轮次和消息也会发送这些事件。当账户服务响应 webhook_route_conflict(ID 已被撤销或属于另一主机或账户)时,后端会为例程分配一个新的路由 ID,保留密钥,并注册新 ID。URL 会更改。

例程删除、更改为 schedule 以及 Agent 或频道删除都会调用 revokeRoutineWebhooks。它会在同一事务中将路由 ID 写入 projection_webhook_route_revocations 并删除触发器行。syncRoutes 会清空队列:账户服务将路由标记为已撤销并向 Signal 发送 webhook-route-revoked。失败的撤销会保留在队列中等待下次同步。账户服务永远不会删除路由行,也永远不会释放其 ID,即使在主机或账户删除后也是如此:发送者仍然可以发布到旧的公共 URL,因此该 ID 绝不能属于另一主机。例程或所有者删除也会移除其回执。更改为 schedule 会保留它们。

入站套接字呈现带有当前路由的签名路由票据,使用远程票据密钥和 webhook 路由受众(WEBHOOK_ROUTE_TTL_SECONDS,5 分钟)签名。路由更改后,主机会打开一个新的套接字以获取新票据。Signal 将撤销保留在内存中。Signal 启动后的 5 分钟内,它会向账户服务确认每个票据路由。主机在至少拥有一条路由时保持 webhook 入站连接打开。当 Slack 或 Discord 共享套接字时,失败的 webhook 票据不会阻止它们:套接字在无 webhook 路由的情况下连接。上线后,主机会以 2 秒的退避重新打开它,退避时间加倍至最多 5 分钟,直到 Signal 确认 webhook 路由。

入站请求

Signal(remote/api/src/app.ts)接受 POST /v1/webhooks/:routeId。它按以下顺序检查:路由 ID 语法(404)、声明的请求体大小(413)、Content-Type: application/json(415)、标头语法(401)、持有该路由的套接字(503)、按路由和客户端地址的速率限制(429)以及请求体大小(413)。因此,未知路由不使用速率限制条目,Signal 也不会读取其请求体。Signal 然后将确切字节和三个标头值发送到主机套接字并等待状态。当主机有太多待处理的请求或未在 2.5 秒内应答时,它也会返回 503。Slack 和 webhook 投递共享每个主机的待处理预算(16 个请求,128 KiB),该预算低于套接字背压限制。Signal 没有队列,也不会存储或记录请求体。标头模式位于 packages/contracts/src/signal-protocol/webhook-route.ts 中。

主机(HostEventsService.receive)返回:

状态 原因
413 请求体大于 WEBHOOK_DELIVERY_BODY_BYTES_LIMIT(64 KiB),或包含事件的运行指令长于 INPUT_LIMITS.messageText;不写入回执
404 未知路由,或该例程不再是 Webhook 例程
401 时间戳、投递 ID 或签名无效(verifyWebhookSignature)
400 请求体不是 UTF-8 JSON { type, occurredAt?, data? }
202 运行已启动,或被忽略:event-type、filter 或 inactive
200 该例程的投递 ID 重复
503 主机没有已登录账户或无法解密密钥,代理被暂停或正在删除,频道已归档或被暂停,或处理器失败

验证先于 JSON 解码。签名为 sha256=HMAC-SHA256(secret, "<timestamp>.<deliveryId>.<body>"),以常量时间比较。时间戳必须在 5 分钟之内。投递 ID 匹配 ^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$。

回执和运行在同一个事务中提交。唯一键 (owner_kind, routine_id, delivery_id) 使重复投递成为空操作。被忽略的请求也会写入回执,因此它们的 ID 也会被去重。超过 7 天的回执不计为重复,并在写入新回执时被清理。事件仅保存在运行指令中,位于 --- external event input --- 标记之间,因此恢复运行的重新启动仍能保留它。运行使用现有的代理和频道队列、提供者限制和审批控制。当重新启动等待空闲代理(holdRoutines)时,主机返回 503 且不写入回执,因此发送方会在重启后重试。

工作流 Webhook 触发器

工作流 Webhook 触发器在获得第一个密钥之前没有路由:rotateWorkflowSecret 同时生成路由 ID 和密钥。工作流路由计入 WEBHOOK_ROUTES_LIMIT。workflows-changed 事件启动 syncRoutes。移除触发器节点的保存操作以及工作流删除操作,会在同一事务中将路由 ID 写入 projection_webhook_route_revocations。HostEventsService 检查签名,然后将 workflow 所有者的请求交给 Workflows.receiveWebhook。回执保存在 workflow_webhook_receipts 中 7 天,不包含请求体。超过 64,000 个字符的请求体返回 413 且不写入回执。有进行中运行的工作流返回 202 ignored,原因为 busy,如同计划跳过一次执行。请求体作为 data 字段,是触发器的输出。工作流触发器没有测试事件。

管理界面

  • 本地桌面:events IPC 组(packages/contracts/src/ipc-endpoints.ts)。
  • 远程主机:events-v1 Team API 能力,EVENTS_ROUTES 位于 packages/contracts/src/team-protocol/events-v1.ts。所有路由都需要主机管理员权限。路由包括状态、例程列表、保存、删除、测试、rotateSecret 和活动。活动是单个例程的回执。带目录文本的失败返回 400。其他失败返回 500,并附带 error.team.requestFailed,由调度器记录。
  • 支持 routine-recovery-v1(ROUTINE_RECOVERY_ROUTES)的远程主机还会应答携带 missedPolicy 的例程列表和保存,以及包含错过的执行记录在内的运行列表。客户端使用这些路由代替 events-v1 的列表和保存。对于不具备该能力的主机,编辑器隐藏错过运行选项,主机保留其已保存的值。
  • 支持 webhook-tests-v1(WEBHOOK_TESTS_ROUTES)的远程主机还会应答测试事件。主机生成一个匹配触发器的事件,使用解密后的密钥为其签名,并将其送入与中继请求相同的验证和回执路径(HostEventsService.sendTestEvent)。响应指明事件类型、回执状态和原因以及运行。密钥保留在主机上。对于不具备该能力的主机,编辑器省略测试步骤。
  • 已发布的纯计划例程视图不显示 Webhook 例程。它们的删除和测试路由对 Webhook 例程返回“例程已消失”,因为它们没有管理员检查。只有计划例程会写入对话事件。
  • 工作流 Webhook 触发器:workflows IPC 组和 workflows-v1 Team API 能力(rotate-webhook-secret)。只有所有者或管理员可以看到 URL 或生成新密钥。
  • 桌面和 Web 使用 packages/ui/src/features/conversation 中共享的例程编辑器组件。移动端使用 apps/mobile/src/features/agents/components 中的原生组件。

威胁模型

不可信输入是公开的请求字节、发送方时钟和投递 ID。例程密钥是入站权威。主机管理员访问是配置权威。有效事件仍然是外部数据:它不能授予工具或跳过审批。中继在没有有效签名的情况下无法启动运行。

控制措施:

  • 签名绑定时间戳、投递 ID 和精确的请求体字节。
  • 密钥由主机生成,仅显示一次,静态加密,并注册用于日志脱敏。
  • 请求体限制、Signal 速率限制和有界待处理请求保护中继和主机。
  • 移除的触发器在同一事务中撤销其路由,中继撤销会重试直至成功。
  • 只有需要主机管理员的事件界面才能更改、测试或删除 Webhook 例程。代理可以使用 openbot.create_webhook_routine 创建例程,并使用 openbot.update_webhook_routine 更改其事件、名称、指令或激活状态,但仅限于由人员消息启动的回合中:例程运行可以携带 Webhook 请求体,这是不可信的。该工具通过 HostEventsService 保存,因此路由限制、加密和中继同步保持一致。该工具会丢弃第一个密钥:结果发送给模型提供者。聊天卡片通过事件界面生成新密钥。

发布

在桌面版发布之前部署账户服务和 Signal。保持 Slack 和 Discord 入口合约不变。迁移必须保留已发布的架构、更新最新架构,并通过架构一致性和回滚检查。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 openbo/openbot