跳转到内容

聊天桥接

提供方 线上值 传输方式 空间类型
Slack slack Socket Mode(出站 WSS) slack

Slack 是唯一的提供方。 ChatProvider 是单值枚举,cc_server 只注册一个插件。schema、每一次查找和每一个唯一索引都以 provider 为键,因此 id 永不跨提供方,而增加一个提供方只是一次纯服务器端的改动。

每个工作区 × 提供方对应一个提供方侧应用。

命令名称按应用而定(默认为 cc,因此是 /cc)。管理员可以重命名它;此后桥接会使用它最后一次被调用时所用的名称。

命令 效果
/cc help(或不带任何动词的 /cc) 列出机器人能做什么
/cc link <code> 兑换一次性代码,并关联发送者的聊天身份
/cc ticket <title> 登记一张由已关联成员报告的本地工单
/cc ticket <title> | <description> 同上,以第一个 | 之后的所有内容作为描述
未知动词 报告该未知动词,并附上帮助文本

触达智能体的方式:

动作 行为
在频道中 @bot … 打开一个 Slack 话题和一个对应的 Control Center 空间;智能体在 Slack 话题中回复
在已桥接的 Slack 话题中回复 继续同一个 Control Center 空间——无需 @ 提及
频道中的普通消息 被忽略(既未发给机器人,也不在已桥接的 Slack 话题中)
给机器人发私信 每个私信会话对应一个 Control Center 空间;在智能体工作期间设置 Slack 话题标题和“正在思考…”状态

由提供方描述符声明;连接对话框正由它生成,提交内容也由它校验。

字段 id 标签 必需 前缀 所在位置
botToken Bot user OAuth token 是 xoxb- OAuth & Permissions
appToken App-level token 是 xapp- Basic Information → App-Level Tokens,权限范围 connections:write
configRefreshToken App configuration refresh token 否 xoxe api.slack.com/apps 上的 App configuration tokens

configRefreshToken 是应用管理凭据:没有它,连接仍可工作,但无法从 Control Center 创建或编辑机器人。它每次使用都会轮换,轮换后的值会立即存储。

值会被去除首尾空白,未声明的键会被丢弃,前缀不匹配会被拒绝,且客户端与服务器返回同一句话。每次连接都必须提供必需字段;可选字段留空则保留已存储的值。

机器人 token 权限范围:

app_mentions:read
assistant:write
channels:history
chat:write
commands
groups:history
im:history
users:read
users:read.email

机器人事件订阅:

app_home_opened
app_mention
message.channels
message.groups
message.im

Control Center 在创建或编辑应用时强制断言的结构性设置:已启用 Socket Mode、没有请求 URL、Messages 标签页已启用且可写、机器人 always_online、所需的权限范围和事件被合并进应用已有的内容(绝不减去),以及一条斜杠命令,其描述为 File a ticket or link your Control Center account——这是在导出的 manifest 中识别 Control Center 自有命令的标记。

状态 线上值 含义
未连接 disconnected 此工作区 × 提供方未存储任何凭据
连接中… connecting 凭据已存储,传输正在拨号或重新拨号
在线 connected 传输已建立并正在接收事件
连接错误 error 已存储但失败;提供方自身的错误会一并显示

重连采用带抖动的指数退避,上限为 30 秒。由提供方发起的套接字轮换会立即重连,不会抬高退避。被拒绝的凭据(invalid_auth、not_authed、token_revoked、account_inactive、token_expired、invalid_app_id)或缺少权限范围是终态:桥接会停止重试,直到有新凭据到达。

桥接是照着最丰富的功能面编写的,并逐项能力降级。

能力 Slack 缺失时的效果
streaming 是 整条回复在结束时一次性发出
streamingRequiresThread 是 没有话题锚点的回复目标只会发送完整回复
ephemeralMessages 是 没有可用回复句柄的斜杠命令应答会被丢弃(拒绝始终在话题内发出)
threadStatus 是 没有短暂的“正在思考…”状态行
threadTitle 是 聊天话题保留其默认标题
slashCommands 是 没有 /cc 界面
taskCards 是 仅文本——回复上方没有卡片,也没有“View in Control Center”链接
maxMessageLength 39,000 较长的回复会被截断,并给出指向 Control Center 的提示
maxStreamChunkLength 3,800 追加内容会按段落、行或空格边界切分

声明流式输出并不等于承诺流式输出:Slack 的流式输出需要付费套餐中的 Agents & AI Apps 功能。调用时被拒绝(streaming_not_supported、feature_not_enabled、paid_only、……)会在整条连接的生命周期内被记住,并以 streamingAvailable: false 的形式呈现。Slack 的任务卡片与同一次调用绑定,因此,如果一个 Slack 拒绝了卡片的显示模式,它会保留自己的流,只在整条连接的生命周期内失去一次卡片。

流还必须指明它是为哪位读者打开的。Slack 要求同时提供接收者用户及其团队,才能在频道中打开流(私信则不需要),否则会以 missing_recipient_user_id / missing_recipient_team_id 拒绝——这是每次调用都会发生的拒绝,因此桥接会继续尝试,与此同时该轮次会以一条已发布的回复形式到达。接收者就是发起提问的人,取自入站消息(取其团队,而非应用的团队,这样 Slack Connect 频道才能正确解析)。对于没有人从聊天中发起的轮次——即在 Control Center 中针对一个已桥接对话启动的工作——接收者就是通过自身关联创建了该桥接的成员;如果此人此后取消了聊天账号的关联,该轮次就会整体发布,而不是流式发送。

每个智能体轮次一张分组卡片(task_display_mode: plan),每个步骤一行。每一行是一次 task_update,包含 id + title + status,并在思考就绪时附上一次性的 details。相同 id 的 details 和 output 会无分隔符地拼接,因此 details 只发送一次,而 output 从不以流式发送。计划的标题就是请求;回答则是之后单独追加的一段内容,携带一个 markdown_text 分块。

部分 内容
计划标题 所提的请求
初始化行 Working on it…,然后 Cloning …,再然后 Starting the agent…——同一行,标题被替换
思考行 Thinking…,在智能体开始推理时添加;思考内容是这一行的 details,在推理结束时发送一次
工具行 每个工具一行(Bash which swift),id 唯一,因此彼此并排
状态 该行运行期间为进行中;当下一行开始或轮次结束时为完成;失败、中断和达到轮次上限时为错误
行动号召 轮次结束时作为末尾行出现的 View in Control Center——工作仍在进行时不出现,服务器没有可达公网 URL 时也不出现

卡片从不承载:工具输出、白名单之外的参数,或任何从数据库读取的内容。推理文本仅是 Thinking… 行的 details(上限 256 个字符),而不是消息正文。不会重新发送一张说不出新内容的卡片。提及会立即打开流,以免 Slack 话题陷入沉默;如果随后的轮次完全没有产生文本,就会用一条简短说明结束同一张卡片,而不是再发一条消息。在一个从未打开过流(没有卡片,或流式输出已被拒绝)的空间上,无文字的轮次仍会发出一条消息,携带已完成的卡片和那条说明。

流式发送的卡片与发布的卡片是两种不同的载荷:

形式 形态 文本
流式发送 plan_update 加上以 id 为键的 task_update 分块 纯字符串,每块上限 256 个字符
发布 一个 plan 块,包裹以 task_id 为键的 task_card 富文本

失败处理:

情况 行为
提供方在追加时拒绝卡片 该追加会立即在不带卡片的情况下重试;文本仍实时到达,而在整条连接的生命周期内卡片都被放弃
提供方也拒绝了文本 流被关闭,回复整体发布
流在部分回复已到达后中断 流被关闭,且不会重新发布任何内容

关于载荷为何这样拆分,参见聊天桥接。

卡片在提及被接受时打开,此时尚未克隆、也尚未产生第一个 token;随后的轮次沿用同一张卡片和同一条流。之后的叙述来自领域事件总线上的 SpaceProvisioningChanged——与桌面端预配横幅从空间行上读到的进展完全相同。

预配状态 叙述
提及已被接受 Working on it…
步骤 repo Cloning <repo>…
步骤 prCheckout Checking out the pull request in <repo>…
步骤 script Running the setup script for <repo>…
步骤 agent Setting up <agent>…
provisioning,尚无步骤 Preparing the workspace…
ready Starting the agent…
智能体正在推理 Thinking…
failed Workspace setup failed — details are in Control Center.
cancelled Workspace setup was stopped — details are in Control Center.

初始化叙述需要同时满足以下四个条件:

条件
提供方能够渲染卡片
此连接上流式输出可用
该空间有一个正在进行中的聊天请求
该处尚无卡片正在报告某个轮次

请求会一直处于进行中,直到某个轮次开始或设置失败。若 ready 事件发生时没有卡片已打开,说明这是一个已预热的空间,会被保持静默;而聊天中无人等待的预配——在应用里打开的对话,或在启动时恢复的滞留对话——完全不产生卡片。

聊天产品在链接中只接受 http(s),别的都不行,因此行动号召不能直接写出桌面应用的 scheme。

属性 值
路由 GET /open/workspaces/<workspaceId>/(spaces|tickets)/<id>
来源 由服务器的公网 URL 推导(ws → http,wss → https);回环地址会保留
响应 一个很小的 no-store HTML 页面,会跳转到 control-center://workspaces/<workspaceId>/(spaces|tickets)/<id>,并显示一个手动按钮
校验 id 必须匹配 [A-Za-z0-9_.-]{1,128};其他任何情况都是 404
访问 公开且惰性——它不读取任何数据库,因此不是存在性判定的依据;授权由它打开的应用负责
字段 限制 说明
appName 1–35 个字符 默认为 Control Center · <workspace name>
botDisplayName 1–80 个字符 默认为 control-center
description 不超过 140 个字符
agentDescription 不超过 300 个字符 显示在与机器人的私信上方
command 不超过 32 个字符 规范化:去掉开头的斜杠、转为小写、移除 [^a-z0-9_-]、为空则回退为 cc
agentEnabled — 是否开放私信/智能体体验

空输入框意味着“保持原样”,而不是“清空”。这些限制来自 Slack,是被建模产品中最严格的一套。

属性 值
长度 6 个字符
字母表 23456789ABCDEFGHJKLMNPQRSTVWXYZ(不含 0/O、1/I、U)
有效期 15 分钟
使用次数 一次
存储 内存中——服务器重启会使尚未使用的代码失效
范围 一个工作区和一个提供方;生成新代码会使该成员之前的代码失效

兑换时按不区分大小写匹配。未知、已过期、属于其他工作区和来自错误提供方的代码,都会故意以完全相同的方式失败。

方式 线上值 如何
代码 code 成员兑换了 /cc link <code>
邮箱 email 提供方已验证的邮箱匹配到一位已是工作区成员的用户

全部以工作区为范围。服务器是无状态的,因此每次调用都在 args 中携带自己的 workspace_id;调度器会在打开任何数据库之前拒绝省略它的工作区范围操作。凭据只_进去_——状态读取从不返回它们。

操作 类型 最低角色 操作类别
chat.providers read guest —
chat.status read guest —
chat.connect mutate admin secretAccess, networkEgress
chat.disconnect destructive admin secretAccess
chat.listUserLinks read guest —
chat.beginUserLink mutate member —
chat.unlinkUser mutate 对自己为 member,对任何其他人则为 admin —
chat.createApp mutate admin secretAccess, networkEgress
chat.setupLink read admin —
chat.botProfile read admin networkEgress
chat.updateBotProfile mutate admin networkEgress

读操作从其类型继承 guest 下限;没有显式下限的变更操作需要 member。

chat.setupLink 根据机器人资料拼装出提供方的应用创建 URL。它不声明任何操作类别,因为它不读取任何密钥、也不发起任何拨号——唯一的请求由用户自己的浏览器发出。

一个响应式查询补全了整个接口:

查询 类型 发出
chat.watchUserLinks watch 名册,每当有关联建立或移除时重新推送

它之所以存在,是因为关联发生在_另一_端:成员把一次性代码输入到机器人里,因此客户端的任何响应都无法把结果带回来。设置中的名册和打开的“关联我的账号”对话框都跟随它,一旦成员自己的关联建立,对话框就会切换为确认状态。

chat.beginUserLink 始终为调用者自己的身份生成一个代码。chat.updateBotProfile 仅当还有剩余步骤时(通常是重新安装)才返回 remaining_step;没有该字段意味着修改已生效。

chat.providers 为每个提供的提供方返回一个条目——描述符,以及在有连接状态时的连接状态。设置界面渲染的一切都来自该载荷,这正是增加一个提供方只是一次纯服务器端改动的原因。

内容 位置
连接凭据 <dataDir>/<workspaceId>/chat_credentials/<provider>.json
未完成的引导式设置 <dataDir>/<workspaceId>/chat_credentials/<provider>_setup.json
旧版 Slack 文件 <dataDir>/<workspaceId>/slack_credentials.json 和 slack_app_setup.json,读取一次并原地迁移

凭据存放在工作区数据库的旁边,而不是里面:删除工作区会带走它的聊天 token,而 workspace.export 或备份只复制 workspace.db——因此交给别人的工作区不会夹带仍然有效的机器人 token。写入是原子的(临时文件加重命名),并被收紧为仅属主可访问。

两者都存放在工作区数据库中,该数据库带有一份被压缩的 v1 基线——因此跨工作区的读取根本无法编译。

chat_space_links——一个外部对话 ↔ 一个 Control Center 空间:

列 说明
id UUID v4
workspace_id 工作区范围
provider ChatProvider.wire
external_team_id Slack 团队 T…,对没有该概念的提供方为空
external_channel_id Slack 频道 C…、群组 G…、私信 D…
external_thread_id Slack 话题锚点;当整个对话就是锚点时(机器人私信)为 null
cc_space_id REFERENCES spaces(id) ON DELETE CASCADE
created_by_user_id、created_at、last_activity_at

在 (workspace_id, provider, external_channel_id, external_thread_id) 上唯一,并用一个部分索引对 null 话题 id 施加同样的约束(SQLite 把 NULL 视为彼此不同)。

chat_user_links——一个外部成员 ↔ 一个 Control Center 用户:

列 说明
id UUID v4
workspace_id 工作区范围
provider ChatProvider.wire
external_team_id、external_user_id Slack 成员 U…
user_id Control Center 用户(全局,因此没有外键)
method code 或 email
linked_at

在 (workspace_id, provider, external_team_id, external_user_id) 和 (workspace_id, provider, user_id) 上唯一——两个方向的归属都是单值的,而同一个人可以在多个提供方上建立关联。

关联是身份解析,而不是访问授权:成员身份会在每个入站事件上重新检查,因此指向一个后来已被移除之人的关联会解析为一次拒绝。

桥接空间创建时使用提供方的 SpaceKind(slack)。它的行为与面向普通人类的空间一样——像 topic 一样发出通知和分区——而 isExternalChat 是每种行为(镜像、徽标、保留)都要经过的检查。

桥接带进来的每条消息都带有一个 metadata['chat'] 标记:

{
"chat": {
"provider": "slack",
"teamId": "T024BE7LD",
"channelId": "C0G9QF9GW",
"threadId": "1700000000.000100",
"messageId": "1700000000.000200",
"userId": "U024BE7LH",
"userLabel": "sam"
}
}

这个标记同时也是回声防护:出站镜像会跳过任何带有该标记的消息,正是这一点阻止了入站聊天消息被直接原样发回去。

行为 细节
向外转发的内容 仅智能体的文本,加上任务卡片的状态行。推理文本和工具输出留在 Control Center 中
流式输出节奏 轮次运行期间限制为每秒刷新一次;卡片和它所解释的文本在同一次调用中传输
在 Control Center 中输入的人类消息 镜像到已关联的话题,并以作者的显示名称作为前缀
空轮次 以一条简短说明报告(运行失败、被中断,或在其轮次上限处停止),而不是沉默
去重 以提供方自己的事件 id 为键;记住最近 512 条
入口智能体 新的已桥接对话会唤醒工作区的 CEO 智能体,若没有则唤醒其第一个智能体,再没有则谁都不唤醒
空间命名 Slack 频道话题为 #channel · <request>,私信为 Slack DM · <request>,标题上限为 48 个字符
故障隔离 单个坏事件绝不会让桥接宕机;连接会继续服务其他所有对话