聊天桥接
提供方
标题为“提供方”的部分| 提供方 | 线上值 | 传输方式 | 空间类型 |
|---|---|---|---|
| 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 话题标题和“正在思考…”状态 |
凭据字段(Slack)
标题为“凭据字段(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 创建或编辑机器人。它每次使用都会轮换,轮换后的值会立即存储。
值会被去除首尾空白,未声明的键会被丢弃,前缀不匹配会被拒绝,且客户端与服务器返回同一句话。每次连接都必须提供必需字段;可选字段留空则保留已存储的值。
Slack 应用要求
标题为“Slack 应用要求”的部分机器人 token 权限范围:
app_mentions:readassistant:writechannels:historychat:writecommandsgroups:historyim:historyusers:readusers:read.email机器人事件订阅:
app_home_openedapp_mentionmessage.channelsmessage.groupsmessage.imControl 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)
标题为“提供方能力(Slack)”的部分桥接是照着最丰富的功能面编写的,并逐项能力降级。
| 能力 | 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 事件发生时没有卡片已打开,说明这是一个已预热的空间,会被保持静默;而聊天中无人等待的预配——在应用里打开的对话,或在启动时恢复的滞留对话——完全不产生卡片。
/open 跳转页
标题为“/open 跳转页”的部分聊天产品在链接中只接受 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 |
提供方已验证的邮箱匹配到一位已是工作区成员的用户 |
RPC 操作
标题为“RPC 操作”的部分全部以工作区为范围。服务器是无状态的,因此每次调用都在 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 个字符 |
| 故障隔离 | 单个坏事件绝不会让桥接宕机;连接会继续服务其他所有对话 |
另请参阅
标题为“另请参阅”的部分- 设置 Slack 集成
- 关联你的 Slack 账号
- 自定义聊天机器人
- 聊天桥接
- 护栏——了解这些操作声明的操作类别