跳转至

频道、消息与移动端

iPhone 实时活动更新

手机与主机使用 @openbot/team-client 构建相同的实时活动(Live Activity)视图:dynamic-island-coordinator.ts 提供状态,live-activity-props.ts 将其转换为 widget 显示的 props。当应用运行时,use-live-activity.ts 自行发布这些内容。

iOS 会在后台停止应用及其连接。因此,手机将其活动的推送令牌(push token)注册到活动主机(live-activity-push-v1,POST /v1/live-activity/registration),并在离开前台时设置 away: true。src/main 中的 LiveActivityPushService 会在该会话期间将注册信息保存在内存中。当手机不在前台时,每个 agent 事件(最多每秒一次)会读取成员可见的 agents 的运行时快照以及成员的已读状态,构建 props,并发送变更。状态变更的优先级为 10;状态内部变更等待 5 秒,优先级为 5。未变更的状态每 10 分钟重新发送一次,因此其过期日期会向后推移;休眠的主机会停止此操作,视图随后显示该状态已过期。空闲状态会结束该活动。

live-activity-seal.ts 使用由手机为该主机生成的密钥(手机密钥与服务器 ID 的 HMAC)对 props 进行密封。主机将密封文本连同其机器凭据发送到 POST /v2/remote/hosts/:hostId/live-activity。Worker 检查凭据和每主机速率限制,自行生成 APNs payload 和 provider token,并转发请求。它不存储也不记录任何内容。widget 无法加载库,因此手机使用两个 widget 密钥和 App Group 文件夹来组合布局,live-activity-open.ts 使用自己的 SHA-256 打开密封的 props。更改主机状态的按钮链接带有 HMAC 签名,应用会使用该操作所指向主机的密钥进行校验,因此一个主机无法为另一个主机签署操作。

iPhone 通知

当 agent 完成一轮、因错误停止、无法启动消息或例行程序运行(error,代码为 delivery_start_failed),或等待输入、审批或浏览器中的步骤(browser-takeover-requested,也用于密码或验证码)时,主机会发送 iOS 通知。桌面端和 Web 客户端也会就相同事件发出通知(agentEventCanNotify),三者共同决策:@openbot/team-client 中的 notificationForAgentEvent 会应用服务器级别(all,或针对等待用户的事件使用 needs-me)、agent 的开关以及静默例行程序运行。当 agent-input-resolved 或 browser-takeover-resolved 事件到来时,桌面端和 Web 客户端会关闭请求的通知,即使答案来自其他设备也是如此。标题是 agent 名称。当手机显示预览时,文本是桌面通知的预览:该轮回答的开头、问题或待批准的操作,并隐去机密信息。否则,它是固定的桌面文本,例如 “Finished working.”。

频道是一个例外。桌面端和 Web 客户端从频道列表(channelReplyNotifications)通知频道内的 agent 回复,因为频道服务会消费频道线程的 turn-completed 事件。在支持 channel-attention-v1 的主机上,每个摘要都会列出其最新消息的作者和状态、每位成员的线程,以及 openTasks:其排队、等待和正在运行的任务。已发布的 activeTasks 仅统计正在运行的任务,因此当某个任务在等待其转交的任务时,activeTasks 为 0。当频道工作结束时(其最新消息是 agent 的且已停止流式输出,并且 openTasks 为 0),在频道有未读消息的情况下,回复会通知一次。因此,那些写入多条消息、中途提问,或将部分内容转交给其他 agents 的工作,只会在结束时通知一次。桌面端仅在回复是新的时才读取 agent 列表以进行通知,并且对于回复无法通知的服务器,它不会读取频道列表。频道线程上的提示、审批或接管(takeover)的标题会同时指明 agent 和频道(notification.channelTitle),点击即可打开该频道。

手机不会收到频道回复通知。对于发送 channel-attention-v1 的手机,主机会在频道线程上的提示、审批或接管的标题中指明频道(PushNotificationService 读取 HostService.channelForThread),点按即可打开该频道。旧版手机则和以前一样,以 agent 名称作为标题。

手机会向每个支持 push-notifications-v1(POST /v1/push-notifications/registration)的已连接服务器注册以下信息:应用的 APNs 令牌、该主机的密钥(与 Live Activity 所用的相同)、语言、级别、静默结束时间、预览开关、声音(当“播放声音”关闭时为 none)、该主机 Live Activity 的开始时间,以及 away。一个声音用于通知和 Live Activity。当通知关闭、Live Activities 开启且“播放声音”开启时,它会在没有令牌的情况下注册:主机随后不发送通知,仅使用注册信息让 Live Activity 播放声音。它会在每次连接后以及选项更改时再次告知每个主机,并在该主机没有剩余内容或用户退出登录时移除注册。级别为“Nothing”以及没有结束时间的静默也会移除注册。

src/main 中的 PushNotificationService 将注册信息保存在用户数据文件夹中的 openbot-push-notifications-v1.json 内,仅当前用户可读,因此即使主机重启后手机未重新连接,也仍能收到通知。它仅在手机不在前台或连接关闭时发送。当成员正在使用桌面应用时,不会发送任何事件;桌面应用本身会显示事件并提示输入、审批或浏览器步骤。判断条件是:该计算机在过去 30 秒内有输入(powerMonitor.getSystemIdleState),并且对于所有者而言,主机自身桌面应用的窗口拥有焦点,或以桌面通知形式显示该事件(desktopShowsAgentEvent:桌面通知已开启,并且该计算机服务器的静默和级别设置允许该事件通过);或者成员在过去 30 秒内从其他会话发送、回答或输入过内容(TeamApiServer.lastMemberUseElsewhere,保存在内存中)。这个时间很短,因为成员离开计算机后,任务随时可能结束。

当手机显示该主机的 Live Activity 且 iOS 仍在更新该活动时(从其开始算起的 8 小时内,手机提供该时间,因为重启过的主机并不知道它),主机不会发送通知(LiveActivityPushService.showsEvent)。如果开启了声音(设置 > 通知 > 声音),活动的下一次更新会立即提醒,因此 iOS 会点亮屏幕、显示活动并播放该声音;如果“播放声音”关闭,活动则静默更新。当用户移除活动时,iOS 不会告知主机,因此在应用再次运行之前,此类事件不会显示在任何地方。

push-notification-seal.ts 使用由主机密钥生成的自有密钥来密封通知。主机使用其机器凭据和手机的账户会话,将其发送到 POST /v2/remote/hosts/:hostId/notification。Worker 检查凭据和每主机速率限制;对于已结束的会话、已撤销的成员资格或已退出的设备,它不会发送任何内容,因为主机无法得知这些情况。它构造 APNs 警报本身,只包含文本 “OpenBot”、mutable-content、声音以及密封字节。它不存储也不记录任何内容。来自 Worker 或 Apple 的 410 响应会移除该注册。

应用的通知服务扩展(apps/mobile/plugins/with-notification-service.js、NotificationService.swift)使用应用写入 App Group 文件 push-notifications.json 中的密钥打开密封,并显示代理名称和文本。如果内容指名的服务器不是其密钥所对应的服务器,则拒绝该内容。无法打开的通知只保留应用名称。点击后打开该代理的聊天。

Android 通知

Android 手机通过 Firebase Cloud Messaging,在相同条件下收到相同的通知。Android 没有 Live Activity,因此主机不会检查 Live Activity。手机向每个支持 push-notifications-android-v1 的已连接服务器注册(POST /v1/push-notifications/android-registration):注册内容包括应用的 Firebase 令牌、该主机的密钥、语言、级别、静音结束时间、预览开关和声音。注册始终带有令牌。PushNotificationService 将其保存在同一文件中,作为一个 platform: "android" 的条目;在该能力之前存在的主机在读取文件时会丢弃此类条目。

主机将密封通知发送到 POST /v2/remote/hosts/:hostId/android-notification。Worker 像对 iPhone 一样检查凭据、主机和会话的通知限制。它使用 Firebase 服务账户密钥(FCM_SERVICE_ACCOUNT)对 OAuth 令牌签名,并发送一条高优先级数据消息,包含 sealed,除非通知是静默的,否则还包含 sound。它不存储也不记录任何内容。Firebase 对不再能到达应用的令牌返回 UNREGISTERED 或 SENDER_ID_MISMATCH,Worker 则返回 410,从而移除注册。

OpenBotMessagingService.kt(apps/mobile/modules/android-notifications)接收消息,即使在应用未运行时也是如此。它使用应用写入其 files 文件夹中 push-notifications.json 的密钥打开密封,并将通知交给 expo-notifications,因此点击会像在 iOS 上一样到达应用。Android 会播放通知通道的声音,因此应用为每种声音创建一个通道(android-notification-channels.ts),服务会根据消息中的声音选择对应通道。

通知仅用于告知;在两个平台上都没有按钮。按钮的应答必须通过应用的 WebRTC 连接才能到达计算机,而 Android 不允许已关闭或冻结的应用建立该连接:React Native 在后台停止其计时器和视图更新,持有连接的网页视图在 Android 冻结应用后也不会重新连接。

共享频道聊天

频道与侧边栏分区是分开的。一个频道有一个主机、一个用途、参与的代理、一个选定的主导代理,以及关联的代理会话。代理成员资格决定谁能接收工作。它不限制人类访问:每个已认证的服务器成员都可以阅读和使用其频道。Electron 应用提供频道界面。创建对话框提供成员搜索和可选的协调设置。聊天界面显示每位作者,并将设置保存在侧边面板中。频道使用侧边栏上下文菜单进行管理,并且没有暂停或恢复控件。移动应用有自己的频道界面(见下文)。

ChannelStore 在 SQLite 中存储规范转录文本、频道配置、任务、分配、摘要、执行线程和人类阅读位置。迁移 18 添加了这些投影,而不改变现有的代理数据。频道命令使用编排日志和命令回执。消息具有稳定的 ID 和每个频道内的序号。频道投影可以根据其事件重建。归档会停止频道工作并保留其记录。恢复会使聊天再次可用于新消息。这两个操作都不会移除代理或关联的会话。侧边栏将归档称为“删除”。在桌面端和 Web 端,已删除频道中的行菜单可以恢复频道;该菜单只对有权删除频道的人显示。

每个频道-代理对在 projection_threads 中都有一个单独的执行线程。正常的代理线程永远不会被替换。提供商会话、轮次、问题、审批、附件、压缩和重启恢复都使用显式执行线程。这些内部执行记录不会创建额外的导航条目。每代理的排空调度器仍然对工作拥有最终决定权。

ChannelService 选择一位所有者。选定的收件人具有最高优先级;其次是附加到回复的任务、对没有任务的成员消息的回复、对唯一未完成任务的明确跟进,以及只有一个可用成员的频道。这些选择不使用模型。其他请求使用主导代理的提供商、模型和推理设置,在一个没有工作工具的独立会话中进行。该请求提供已接受的结果模式和频道摘要,而不是转录文本。无效或过时的路由无法广播请求。路由可以选择现有任务、提出问题,或指示不需要任何工作。当选择了所有者或现有任务时,主导代理会添加一条频道消息,因此选择是可见的,用户可以纠正它。确定性选择不会添加消息。在没有主导代理的频道中需要路由的请求会以原因 channelLeadRequired 停止。保存操作如果设置了新的主导代理,会将这些请求重新排队,并由该主导代理进行路由。主导代理变更不会移动已有所有者的任务。成员数据包指定主导代理(leadAgentId)。

通道工具检索历史、分配子任务、转移所有权并报告结果。运行时提供通道和调用者身份。子任务保留其父级所有者;转移会改变它。只有分配和被等待的结果才会启动回合。已完成的子任务结果在所有者返回前合并。每个根请求最多八个自动分配,每个通道最多两个活动分配(CHANNEL_PARALLEL_LIMIT)。

成员还可以在有人请求时更改其通道:channel_add_member、channel_remove_member、channel_set_instructions,以及用于计划例程的 channel_list_routines、channel_create_routine、channel_update_routine、channel_delete_routine。它们只作用于运行中任务的通道(channel-management.ts)。根任务的请求消息必须将人员作为作者。例程运行(作者 id routine:<id>)以及仅由代理编写的请求会被拒绝,因此不受信任的运行输入无法更改通道。成员更改是通过命令队列进行的 save,当轮到它时,草稿从存储的通道中读取。调用者不能移除自身或其上任务的拥有者,移除主导者会清除主导者。人员添加的代理必须是该人员可以聊天的人,而人员移除的代理必须是该人员可以看到的人:主进程回答 personAgentLevel,其级别来自他们自己的 Team API save(HostService.channelPersonAgentLevel)。对于团队成员的请求,例程工具不会列出、创建或更改一次性例程,因为 Team API 不携带这些(HostService.channelPersonIsRemote)。重试的调用会找到其更改的回执,并且不会再次更改任何内容。每次更改都会在同一个提交中写入一条 channel-change 笔记,以代理为作者,源文本指明代理、更改和人员。客户端将其显示为活动行,且它不是未读的,也不在模型包中。

通道工作只占用执行它的代理。代理一次运行一个主线回合:其聊天、通道投递或消息传递投递。当代理有活动分配时,ChannelService.mayDrain 会保持它的其他主线请求。其他代理的工作不会占用它。代理的任务在通道回合旁边运行,并受代理任务限制的约束,而且通道回合不占用任务槽位。当代理的提供者一次运行一个回合时(concurrentTurns: false),其任务回合计为忙碌。只有声明的 browser 和 workspace:<path> 资源会被序列化:同一个浏览器,或两个路径相互包含的工作区。空列表、none 和 host(来自早期版本)不保留任何内容。分配在结束前保持其启动时拥有的资源,并且具有活动分配的任务不会启动第二个所有者。更新仍然会等待主机上的所有通道工作(hasActiveWork)。这些控制不限制提供者进程的权限。

通道回合可以使用 ask_ui、计划卡片(acknowledge_request)和 html_render。问题、卡片和视觉回复会被复制到通道中,因此每个阅读通道的成员都能看到它们并可以回答。通道回合中的屏幕捕获始终会询问,即使是完全访问权限也是如此,并且原因会指明通道(status.agent.screenCaptureChannel)。消息传递链接仍然拒绝这些工具。history_search、history_read 和 start_task 在通道回合中仍然被拒绝;channel_history 提供通道历史记录。

隐藏的主导者不会路由。当用户隐藏代理时,ChannelService.releaseAgent 会将其排队中和启动中的分配归还给通道。任务保持其所有者并获得新的修订版本,因此工作会在恢复或重新分配后继续。当拥有等待中通道工作(ownsWaitingWork)的代理达到其使用限制时,该代理会像处理自己的聊天一样切换到备用模型。模型切换标记会显示在代理聊天和每个受影响的通道中。

通道的一次性例程保留在这台计算机上。已发布的 Team API 通道编解码器不接受其计划,因此桌面 IPC 仅允许其在本地主机上运行。

每个回合都会收到受限的通道上下文:目的、职责、当前请求、源消息和回复、共享决策、最近消息以及附件引用。版本化摘要涵盖较旧的消息,并带有序列和源 ID。完整消息仍可检索。提供者接受游标记录上下文投递。上下文数据包保持自包含,因此提供者替换或压缩不会移除共享决策。无关的服务器对话可通过分页检索获取,并且不会自动插入。代理记忆保持其现有含义。

任务修订可防止旧分配完成已更正的请求。存储的请求保留其自己的文本和文件:只有其首次分派会转换附件草稿,而同一请求的后续分派会再次发送存储的副本。停止会暂停任务及其后代,并中断活动工作。重新分配会等待旧分配完成停止。重启恢复在重试前检查已接受的提供者工作。未知结果需要关注。命令、分配和结果 ID 可防止重复分派和可见结果;外部副作用不具有精确一次保证。

桌面 IPC 和远程桌面传输将 channel-chats-v1 作为可选能力公开,并带有单独的有效载荷编解码器。已发布的 Team API 适配器保持其现有含义。主机仅在其通道服务已连接时才通告该能力。当主机不通告 channel-chats-v1 时,渲染器会在侧边栏、搜索和计划中隐藏通道控件。账户 API 和 Signal 服务不增加通道存储或路由。

移动端仅在通告 channel-chats-v1 的主机上显示通道,并且仅通过 channel-delete-v1 提供删除和恢复。它使用相同的主机通道 ID 和命令。主机数据库存储通道设置、成员、消息、记忆、例程和读取位置。移动端仅保留内存内视图。重新连接时会再次加载主机列表。channels-changed 事件刷新列表和打开的通道历史,每个主机一个请求序列,每次事件突发有一个待处理的刷新。关闭通道会保留一个短消息窗口(最多 50 条消息),就像单聊一样,并释放较大的窗口。只有打开的通道才会刷新其历史。服务器移除会丢弃其缓存的通道和迟到的响应。移动端聊天列表和消息历史使用虚拟化列表。通道成员选择使用静态头像缩略图,没有活动订阅或动画计时器。通道与代理出现在同一列表中,最多有四个静态成员头像,主机离线时会淡出。通道固定共享现有的固定网格和 16 个聊天的限制;本地偏好设置分别保留代理固定和通道固定。隐藏会将通道从首页列表中移除并取消固定;共享的隐藏聊天面板会将其恢复。通道和代理的固定使用相同的测量覆盖层移动,通道使用静态文件夹图稿。代理和通道屏幕使用相同的移动端 ChatView、标题栏、消息列表、回复手势、输入框、相机和键盘运动。它们的数据适配器提供历史、发送和读取位置;通道还提供作者标签和任务操作。通道发送重试保留其操作 ID 和已上传的文件。通道消息会像代理聊天一样显示计划卡片和 ask_ui 问题;它没有反应,因为反应路由是代理路由。当主机接受其操作时,通道发送即完成:主机将重复操作存储一次。然后发送会读取打开通道的历史,因此主机的行替换手机自己的行;读取失败不是发送失败,关闭的通道在打开时会读取其历史。在通道功能回归之前,应用曾搁置在 messages.unreadable-<time>.json 中的通道消息仍留在那里。

在支持 channel-attention-v1 的主机上,每个频道摘要都会列出其成员的线程。频道随后会显示这些线程的审批和浏览器步骤,而 agent 自身的聊天则不会显示。较旧的主机没有线程:频道会像以前一样显示运行其某一任务的成员的审批。在频道中发起请求的成员会显示为在此等待,并附有请求的原因。agent 自身的聊天不会显示另一个线程的轮次(活动记录会保留其事件的 threadId);对于频道线程,聊天下方的一张卡片会标明频道名称并打开该频道。它的主页行显示它在该频道中等待,而不是其最后一条消息(useWaitingChannel)。

频道头部始终有浏览器按钮。它会打开请求浏览器步骤的那个成员的浏览器,否则打开负责人的浏览器,再否则打开第一个成员的浏览器。在包含多个成员的频道中,浏览器头部中的 agent 是一个玻璃按钮,带有一个菜单,可切换到任何成员的浏览器。“已删除频道”面板可为有权删除频道的人恢复频道(restore)。手机不会收到频道回复的本地通知:只有当手机不在线时,主机才会发送通知(请参阅 iPhone 通知)。当手机拥有该服务器的频道列表时,点按通知(其线程为频道线程)会打开该频道。

移动端频道设置使用一个原生面板,其中嵌套堆栈用于记忆和例程。记忆和例程编辑器与 agent 设置共享控件,并使用频道 API 操作。频道设置没有提供者或模型控件,因为每个成员都保留自己的运行时。移动端频道不需要更改账户 API、Signal、IPC 合约或数据库迁移。

消息连接

一台计算机上的 agents 可以在外部聊天平台中回复:Slack、Discord(请参阅下面的 Discord)和 Telegram。messaging.md 包含设置、限制以及如何添加平台。每个工作区都会安装这一个 OpenBot Slack 应用(apps/slack-app),并且该工作区会链接到连接它的主机。人们可以提及 @OpenBot 或向其发送直接消息。工作区的 Slack Orchestrator 是连接对话框添加的一个 agent,它接收每个新对话,询问其队友并发布答案。每个答案都来自 OpenBot。

  • 安装。 桌面端向 POST /v2/slack/authorize 请求 Slack 的安装 URL,并附带一次性主机密钥。Worker 在 /v2/slack/callback 处兑换该代码,因为应用的客户端密钥存在那里。它在 D1 中将工作区链接到主机(slack_workspace_routes:team、host、account;无 token),并将机器人 token 密封到主机密钥(@openbot/contracts/slack-workspace-grant)。页面 /slack/connect 会打开 openbot://slack-workspace。只有链接了工作区的账户才能将其移动到该账户的另一台主机;其他账户会收到 slack_workspace_taken。
  • 事件。 Slack 会将每个工作区的事件和按钮按下发布到同一个 URL:https://signal.openbot.run/v1/slack/events。Signal 使用应用的签名密钥检查 Slack 的签名,应答 url_verification,并且只读取应用 ID 和工作区 ID。每个签名密钥都绑定到其应用,一条路由就是一个应用在一个工作区中,因此生产应用和开发应用可以共享一个工作区。它将原始请求体传递给 ingress socket(main 中的 SignalIngress,一个普通的 ws 客户端:没有 WebRTC,因此没有隐藏窗口),该 socket 持有该工作区的路由票据,并在 2.5 秒内返回主机的应答,或者返回 503 以便 Slack 重新发送。路由票据是一个 ES256 JWT,由 apps/auth-api 使用自己的密钥为证明了其机器 token 的主机签发。它只列出 D1 链接到该主机的工作区,5 分钟后过期,并且主机每次 socket 连接时都会请求一个新的票据。票据中的每个工作区都带有 D1 链接它的时间。当工作区被取消链接或移动时,Worker 会通过已签名的 auth-event 发件箱向 Signal 发送 slack-route-revoked:Signal 会丢弃该路由,并拒绝带有该链接或更旧链接的票据,因此失去工作区的主机无法用它持有的票据继续保留该工作区。Signal 会在内存中保留这些撤销信息,因此在其启动后的一个票据生命周期内,它会询问 Worker(/v2/remote/slack-route/validate,签名方式与 /v2/remote/resume/validate 相同)每个票据的哪些链接 D1 仍然保留。主机信任一次投递是因为 Signal 已检查签名;没有任何主机拥有签名密钥。

Telegram 使用相同的 socket,但有一个区别:一个 OpenBot bot 为所有用户服务,因此它的 token 绝不能到达任何主机。只有 Signal 拥有它(TELEGRAM_BOT_TOKENS),合约是 @openbot/contracts/signal-protocol/telegram-route。

  • 链接。 一个连接就是一个聊天。桌面端向 POST /v2/remote/hosts/<id>/telegram-link(machine token)请求一个一次性代码,并打开 t.me/<bot>?startgroup=<code> 或 ?start=<code>。Telegram 会在添加 bot 的聊天中发送 /start <code>。Signal 请求 Worker(/v2/remote/telegram-route/link,已签名)将该聊天链接到代码所属的主机(telegram_chat_routes、telegram_link_codes:仅有哈希,无聊天名称),立即将该聊天路由到该主机的 socket,并带着 linked: true 传递更新;主机仅凭该标志建立连接,因为被路由聊天中的任何人都可以发送带有任何代码的 /start。这个开放代码会持有主机的 socket,直到链接完成或过期,因为不能有连接持有它。另一个账户的主机会得到 telegram_chat_taken。
  • 更新。 Telegram 会向 https://signal.openbot.run/v1/telegram/updates/<bot ID> 发布更新,并带有一个 Signal 为每个 bot 派生的秘密头部。Signal 只读取聊天 ID、一个链接代码和一个回调查询 ID,并向持有该聊天的 socket 发送 telegram-delivery。它始终返回 200:否则 Telegram 会因一个离线主机而阻塞该 bot 的其他聊天。Telegram 路由票据(hello 中的 telegramRoute,受众为 openbot-telegram-route,以及 Slack 路由密钥)和 telegram-route-revoked 的工作方式与 Slack 的对应机制相同。
  • 调用。 主机只向 ready 中声明 telegram 能力的 Signal 发送 telegram-call,因为 Signal 会在收到无法识别的帧时关闭 socket。Signal 仅接受 TelegramCallParams 中作为严格对象的方法和参数,且仅针对已路由到该 socket 的聊天;answerCallbackQuery 仅接受它已投递到该 socket 的按下事件。它只返回消息 ID 和 token。getFile 和 sendDocument 会为 GET /v1/telegram/files/<token> 和 POST /v1/telegram/uploads/<token>(20 MB)提供签名的一次性 token。
  • 对话。 在超级群组中,每个回复链就是一个对话:Telegram 会为其分配一个 message_thread_id,即其第一条消息的 ID。一个论坛话题是一个对话,一个直接聊天是一个对话,而基本群组会在内存中的回复映射里查找回复链,重启后会遗忘该映射。提及会开始或继续一个对话;没有提及的回复只会继续已链接的对话。Telegram 没有历史 API,因此 TelegramChatState 会在内存中保留它看到的名称和最后消息。一个 Telegram Orchestrator(telegram-orchestrator.ts)会回复每个聊天。

代码分为两半。MessagingThreads(src/backend/messaging/)由 AgentService 与 ChannelService 一起构建,不感知任何平台。MessagingService 在主进程中构建,并通过每个平台一个 MessagingDriver 持有实时连接:一个适配器用于其 API,一个传输层用于其事件。messaging-types.ts 是接缝;核心从不读取平台负载。

  • 存储。 迁移 25 添加了 projection_messaging_connections(每个工作区一条,带有其编排代理)和 projection_messaging_threads(每个外部会话一条,带有回答该会话的代理)。令牌不在数据库中:MessagingCredentialStore 通过 safeStorage 加密保存机器人令牌,以连接为键,并且只有其状态跨 IPC 传输。迁移 36 添加了 projection_messaging_memories,即每个连接的事实(MessagingMemoryStore,与代理和频道记忆相同的 MemoryStore,以连接为所有者)。没有代码删除连接,因此该表没有级联:断开连接会保留事实。
  • 编排代理。 在带有链接的线程中,消息会发送到该链接的代理。新会话会发送到工作区的编排代理;如果没有编排代理,Slack 会被告知没有代理应答。编排代理是一个普通代理(slack-orchestrator.ts):其描述就是它的常设职责,并且它以五个记忆开始,这些记忆仅是事实,因为模型将记忆作为数据读取。它通过 send_message 将工作交给一个队友;请求携带 messagingReturn,因此队友的答案会在同一个 Slack 线程中作为后续回合运行。一个只询问了队友的回合会发布“队友正在处理中”(MessagingThreads.awaitsTeammate)。它位于侧边栏的 Integrations 部分(由 MessagingService 首次创建);渲染器默认折叠显示该部分。断开连接会删除没有被其他已连接位置使用的编排代理(AgentService.deleteAgent,它也会移除其链接),随后,当没有代理分配给该部分时,再删除该部分。它首先检查代理是否可以删除,因此繁忙的编排代理会在令牌被吊销或位置被解除链接之前停止断开连接。
  • 执行线程。 每个 Slack 线程都是一个链接,在 projection_threads 中拥有自己的执行线程,就像一个频道-代理对一样。直接消息会在它下面的一个线程中得到回复,因此每一条都是独立的会话。MessagingThreads.event 接收该线程的会话和回合事件,因此公共聊天、渲染器和 Team 对等方永远不会看到它们。审批仍然会到达主机。
  • 投递。 外部消息是一封来自 user 的邮箱消息,带有 messaging 来源(链接、作者、平台消息)。没有新增发送者类型,因此冻结的 Team 协议编解码器保持不变。队列和公共聊天像隐藏频道工作一样隐藏它。代理从 Slack 回合发出的请求携带 messagingReturn,即该回合的来源;MailboxStore.enqueue 为答案指定该来源,类型为 messaging,因此答案在同一个执行线程中运行,并且它的回合作为后续发布到 Slack。DrainScheduler 向 MessagingThreads.prepare 请求线程和提示,该提示将文本框定为外部输入,并添加该线程的早期消息和连接的事实(每回合重建),因此新事实无需重新加载提供者线程。MessagingService.#receive 使用适配器的名称缓存为每个用户提及(InboundMessage.mentionedUserIds)命名;适配器对上下文消息也这样做。
  • 工具。 messaging_remember、messaging_forget_memory 和 messaging_history 是延迟的 openbot 工具。OpenBotToolRouter 从调用回合的执行线程中找到链接,并拒绝来自任何其他线程的调用;没有参数命名连接或频道。历史工具通过 MessagingThreads.setHistorySource 调用适配器的 recent。不会从直接消息中保存事实。两个出站工具 messaging_list_places 和 messaging_post 是例外:路由器从任何线程将它们传递给 MessagingThreads.outboundTool,它们的参数命名一个已启用的连接和一个位置,MessagingService 在 redactText 之后,通过 MessagingThreads.setOutbound 使用适配器的 places 和 postAnswer 来回答它们。
  • 顺序。 每个代理一次一个回合的规则不变,因此 Slack 请求会在代理自身工作和占用主机的频道工作之后等待。Slack 线程会显示一条等待中的帖子。
  • 回复。 MessagingThreads 报告每个回合的开始和结束。MessagingService 发布一条带有停止按钮的状态,然后用回复替换它,上传代理附加的文件,并设置回应表情。它将同一个会话的帖子串行化,因此快速回合不会与它的状态发生竞争。没有帖子会指明代理。
  • 审批与停止。 消息线程的审批也会以带有按钮的帖子发布。只有其消息启动了该回合的 Slack 用户才能回复或停止它;主机始终可以回复。按钮值是一个仅存在于内存中的随机令牌。
  • 频道。 连接启动时,OpenBot 会加入它尚未加入的每个公开频道(conversations.list、conversations.join,权限范围 channels:join),因此人们无需邀请就可以提及它。它会在 channel_created 时加入每个新的公开频道。私密频道需要 /invite。
  • 去重。 内存中的集合会立即丢弃重新投递的事件;邮箱幂等键覆盖重启,以及 Signal 再次发送的已保留事件。在没有 socket 打开的期间到达的事件,在 Slack 重试之后会丢失,除非托管服务器启动。
  • 界面。 在运行代理的计算机上,Marketplace → Apps → Slack 会显示每个工作区及其编排代理,并提供一个两步对话框,用于连接工作区并在用户选择的模型上添加编排代理(messaging:*)。远程服务器不显示 Slack 页面,因为安装会返回主机自己的浏览器。实时连接不计为使用:托管服务器会休眠,Signal 会在下一条发给 OpenBot 的消息到来时启动它,并将该消息密封保存给主机,直到主机连接(Connector events)。Apps → Discord 是用于 Discord 的相同页面(SlackIntegrationPanel,带有 platform="discord")。Apps → Telegram 有自己的页面(TelegramIntegrationPanel),每个聊天一行,所有聊天共用一个编排代理。

Discord。 每个 guild(即服务器)都会安装同一个 OpenBot Discord 应用(apps/discord-app),一个 guild 就是一个连接,拥有自己的 Discord Orchestrator(discord-orchestrator.ts)。Discord 只通过其 Gateway WebSocket 推送机器人消息,而机器人令牌不能发送到每台主机,因此 Signal 持有令牌并为主机完成平台工作:

  • 安装。 与 Slack 相同:POST /v2/discord/authorize、/v2/discord/callback(作用域 bot;Worker 交换代码、撤销它获得的用户令牌,并在 D1 discord_guild_routes 中关联该 guild)、页面 /discord/connect 以及 openbot://discord-guild。密封授权(@openbot/contracts/discord-guild-grant,位于 host-grant.ts)指定了 guild,但不持有任何令牌。另一个账号会得到 discord_guild_taken。
  • 事件。 Signal 使用 @discordjs/ws 维持一个 Gateway 连接,包含 guild、guild 消息和私聊消息的 intents,且没有特权 intent。它只转发提到机器人的 guild 消息,以及按下 OpenBot 按钮的操作——它会立即向 Discord 确认。它将每一条消息规范化为 DiscordDelivery(@openbot/contracts/signal-protocol/discord-api),移除机器人自己的提及,然后向 ingress socket 发送 discord-delivery 帧,该 socket 持有该 guild 的 Discord 路由票据。该票据与 Slack 的票据对应(受众 openbot-discord-route、/v2/remote/hosts/:id/discord-route、discord-route-revoked、/v2/remote/discord-route/validate)。socket 只会在其连接持有该票据的平台上请求路由。
  • 移除。 当机器人离开一个 guild 时,Signal 丢弃其路由,并要求账号服务将其解除关联(/v2/remote/discord-route/removed,已签名),这样离线主机不会保留它。在 Gateway 列出机器人的所有 guild 之后,并且每 30 分钟一次,Signal 会发送这些 guild ID(/v2/remote/discord-route/reconcile),账号服务会解除每个指向另一个 guild 且超过五分钟的关联。当某个 Gateway 分片处于关闭状态时,它不发送任何内容,因为列表可能遗漏在此期间安装了机器人的 guild。仍然指定机器人已不在的 guild 的路由票据不会被路由。discord-session 帧会列出路由到该 socket 的 guild;其 guild 未出现在列表中的主机连接会停止,Reconnect 会重新开始安装。
  • 调用。 在 ready 之后,Signal 会向 socket 发送 discord-session 令牌。主机的适配器(discord-driver.ts)使用该 bearer 向 POST /v1/discord/api 发送类型化操作(DiscordApiRequest:发布、编辑、删除、回应、列出消息、名称、对按钮按下的私密回复、上传)。Signal 只接受针对已路由到该 socket 的 guild、且仅针对该 guild 的频道的操作,并使用 @discordjs/rest 发起调用。每条发布都不允许提及,因此不会 ping 到任何人。附件是签名的 CDN 地址,主机直接下载。
  • 对话。 一个对话就是一条回复链,以其第一条消息作为键。每条 OpenBot 发布都会回复那条消息,因此对 OpenBot 发布的回复会通过被回复帖子自身的引用指明对话。私聊消息没有 guild,所以 Signal 会用一句固定的话回复一次。

移动端聊天队列

移动端读取主机队列,应用 queue-changed 快照,并在 queue-invalidated 事件上刷新活跃队列查询。这两个事件都会在更新缓存之前取消较早的读取。它不会运行第二个投递循环。已排队和已取消的投递都保留在聊天记录之外。面板使用有界虚拟化列表和一个玻璃表面,带有底部锚定的过渡,并遵循“减少动态效果”设置。其固定的列表视口保持挂载,输入区 inset 每次切换只变化一次。流式输出不会改变面板的输入项。

可选的 queue-edit-v1 能力与桌面端编辑 IPC 提供了相同的主机编辑保持。被保持的投递项会以编辑标记保留在公共队列快照中。私有编辑身份不会暴露。只有匹配的编辑器才能更改被保持的消息。桌面端和移动端在请求保持之前写入编辑身份。每个客户端都只在确认后才启用保存。一次失败的附件保留请求会保留桌面端编辑身份和备份,以便重试。邮箱将保持状态存储在现有投递 JSON 中。第一条被保持的投递会阻止自动队列派发;没有该编辑身份的 steer 和更新会被拒绝。保存会提交替换消息,并在同一个邮箱事务中释放保持。取消会恢复正常的派发,而不更改消息。删除会取消投递、完成其编辑,并在同一个邮箱事务中释放附件所有权。已释放的编辑草稿会一直保留到重启之后,直到被发送或丢弃;因此丢失取消响应不会破坏已保存的输入区备份。普通未保留草稿仍会在主机启动时过期。一个已完成的编辑身份会记录完成它的动作,因此在丢失响应后重复同一动作仍是安全的;而跟随已完成取消的保存会被拒绝,而不是对主机从未采纳的文本报告成功。保持和本地保存的编辑草稿在主机重启和客户端导航后仍然存在;它们没有超时机制,因此不会在有人仍在编辑时发送消息。旧版主机保留队列查看、steer、删除和重排功能,但移动端在没有该能力时会禁用编辑。

移动端队列是一个路由,而不是面板。每个聊天都以其自身身份发布其实时队列控制器,而弹层读取它打开时所使用的身份,因此一个被原生栈保持挂载的聊天无法响应另一个聊天的打开弹层。排队的文件以行形式列出:图片显示其缩略图,其他文件显示文件图标,消息选项可在分享弹层中打开文件。缩略图通过聊天使用的查询键读取附件,因此消息中已读取的文件不会被再次获取。编辑器可以更改文本、移除消息已有的文件,并添加新文件。

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