diff --git a/docs/README.md b/docs/README.md index 5c8e68f7c6..086ff93d95 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,10 @@ - [Design System](./design-system.md) — UI/UX design system - [Lessons Learned](./public-lessons.md) — Team learnings and best practices +## Reference Notes +- [Huawei Cloud Skills Platform](./references/huawei-cloud-skills.md) — 华为云 Skills 市场、安装命令和 CodeArts 使用前置条件 +- [IM Channel Public Contract](./references/im-channel-public-contract.md) — 跨 cat-cafe / 外部 agent 的 IM 通道协议:哪些 shorthand 该忽略、哪些格式是保证、可信忽略清单(F267 公共契约) + ## Feature Specs See [features/](./features/) for individual feature specifications. diff --git a/docs/features/F267-channel-single-identity.md b/docs/features/F267-channel-single-identity.md new file mode 100644 index 0000000000..1e3e2eb94c --- /dev/null +++ b/docs/features/F267-channel-single-identity.md @@ -0,0 +1,199 @@ +--- +feature_ids: [F267] +related_features: [F088, F134, F157, F252] +topics: [connector, channel-identity, multi-platform, agent-visibility, external-collaboration] +doc_kind: spec +created: 2026-07-19 +status: in-progress +--- + +# F267: Channel-level Single Identity Binding — 渠道级单身份绑定 + +> **Status**: in-progress | **Owner**: 砚砚(spec) + Ragdoll(connector layer review) | **Priority**: P0 | **Source issue**: `issue-im-channel-shorthand-leak.md` + +## Why + +cat-cafe 是多猫协作系统(砚砚/小狸/宪宪/小捷/金哥/烁烁 + Coffee)。在 **cat-cafe 家内 thread / Cat Café Web UI** 里,多猫分开署名有意义: + +- 协作明确:球权归属、谁写的代码、谁 review 的 +- debug 友好:知道哪只猫在干啥 +- 教育用户:用户学 cat-cafe 时知道有多种 cat personality + +但当 cat-cafe 接入 **外部 IM channel**(飞书群、钉钉群、企微群、Telegram 群…)且 **该 channel 含非 cat-cafe roster 的成员**(openclaw / hermes / 真人 / 其他 bot)时,多猫分开署名成为 **协作障碍**: + +1. **外部成员无法区分**:他们只看到「咖啡猫」一个入口,分不清 `砚砚` / `小狸` / `宪宪` 等内部分离名 +2. **署名重复充斥 channel**:当 5 只猫各自前缀【X🐱】发言时,群里 60% 字符是署名 +3. **猫族 ID 暴露为元信号**:外部成员看内部 cat 切换知道「有多只猫但无法区分」 + +operator experience(2026-07-19 14:57 UTC): +> "你们在飞书群里面不停地强调自己是砚砚、是小狸这些外人根本不知道也无法区分的名字,人家只知道你是咖啡猫,就算记住了你的不同猫名称,也只是给我通过飞书渠道的协作添乱。**我要求你们必须更正!**" + +## What + +**Channel-level 单身份绑定**:每个外部 connector channel 配置一个对外 display identity,所有 cat 在该 channel 出站时**强制**使用该 identity 署名。 + +```yaml +# connector config 示例(飞书) +feishu: + chat_groups: + - chatId: 'oc_xxxxx' # 「毅之队」群 + catIdentity: # 渠道级对外身份 + displayName: '咖啡猫' + emoji: '🐱' + exposeInternalNames: false # 内部猫族 ID 是否暴露在 mention 自指 +``` + +### 双层机制(治标 + 治本) + +#### Layer A:Connector 出口强制 identity collapse(P0,机制层) + +**位置**:`OutboundDeliveryHook.executeDelivery()` 出站渲染前。 + +**逻辑**: +- 读 binding 的 `channelIdentityOverride`(per-connector / per-chat 维度) +- 如果存在 → 用 `channelIdentityOverride.displayName` 替换 `catRegistry.tryGet(catId).displayName` +- 如果不存在 → 默认行为(透传 cat 自带 displayName,家内 thread 仍正常) + +**实现点**(`OutboundDeliveryHook.ts:155-160` 当前代码): +```typescript +const entry = catId ? catRegistry.tryGet(catId) : undefined; +const catDisplayName = entry?.config.displayName ?? ''; +// F267: Apply channel-level identity override +const effectiveIdentity = this.resolveChannelIdentity(binding, catDisplayName, '🐱'); +``` + +**API 新增**: +```typescript +interface IOutboundAdapter { + /** + * F267: Per-channel identity override. + * When present, OutboundDeliveryHook uses this displayName for outbound messages + * regardless of which internal cat (砚砚/小狸/宪宪/etc.) produced the message. + */ + readonly channelIdentityOverride?: ChannelIdentity | undefined; +} +interface ChannelIdentity { + readonly displayName: string; // 对外显名,如 "咖啡猫" + readonly emoji?: string; // 对外 emoji + readonly exposeInternalNames?: boolean; // 是否允许内部猫族自指(默认 false) +} +``` + +#### Layer B:Agent prompt identity pin(P0 + P1,prompt 层) + +**位置**:所有 cat 的 system prompt 在 connector channel 段被注入: + +> 你现在代表的对外显示名是:**「咖啡猫」**。 +> 不要在 IM channel 输出里使用 cat-cafe 内部 cat 分离名(如 砚砚/小狸/宪宪 等)作为署名或自称。 +> 你仍然知道自己在家内 thread 的真实身份(砚砚),那是 cat-cafe 家内协作的身份——但当前 channel 的对外界面里,**只显「咖啡猫」**。 + +**实现点**(`SystemPromptBuilder.ts`): +- 在 `WORKFLOW_TRIGGERS` 段新增 `channel.identity_pin` +- 注入规则:cat 当前响应的是 connector channel 时,prompt 加 `ChannelIdentityPin` 段 +- `exposeInternalNames: false` 时强制 + 强化;`true` 时给提示「本 channel 允许自指内部 cat 名」 + +### 不变项 + +- **cat-cafe 家内 thread**:channel 不是 connector → 不走 override,cat 各自署名仍正常 +- **Cat Café Web UI**:同上 +- **🤔 思考中 → 收 receipt 文本(F157)**:仍按 cat personality 词库随机,但**前缀**用 channel identity 显示名 +- **@提及语法**:`@胖胖虾` / `@毅马仕`(外部 bot 名)正常发起;**禁止** cat agent 在群里用 `@砚砚` 这类自指 + +### 迁移路径 + +#### Phase 1:机制层开关上线(P0,本周) + +- `ConnectorRouterBinding` 加 `channelIdentityOverride` 字段(默认 undefined) +- `OutboundDeliveryHook.executeDelivery` 加 channel identity 解析 + 替换 +- IM Hub 飞书群配置面板加 UI(per-chat 勾选「对外显名」+ 输入框) +- 默认值:飞书群 `displayName='咖啡猫'`,DM 仍走 cat 自带(确保 @sender 等老行为不变) + +#### Phase 2:Agent prompt identity pin(P1,下周) + +- `SystemPromptBuilder.ts` 加 channel identity pin 注入 +- 12 只猫的 system prompt 加载时校验「channel identity pin 段已注入」(test) +- 默认 `exposeInternalNames=false`,留一个开关给 operator + +#### Phase 3:观测 + audit(P2,两周后) + +- IM Hub 新 channel identity 看板:飞书群过去 24h 各 cat 名出现频率 +- 检测「在某 IM channel 出现意外 cat 名」→ 报警 +- 看实际效果,决定是否把 default 推到所有 channel + +## Acceptance Criteria + +### Phase 1(机制层,P0) + +- [ ] AC-1A: `OutboundDeliveryHook.executeDelivery` 增加 `channelIdentityOverride` 字段解析 +- [ ] AC-1B: 当 binding 配 `channelIdentityOverride.displayName='咖啡猫'` → 出站 envelope header 强制为 `🐱 咖啡猫`(即使 cat agent 输出 `【砚砚🐱】`) +- [ ] AC-1C: 当 binding 没配 `channelIdentityOverride` → 旧行为(沿用 cat 自带 displayName) +- [ ] AC-1D: 默认配置:飞书群(chatType=='group')→ 自动 fallback 到 `displayName='咖啡猫'`;飞书 DM 不变(保留 cat 自带) +- [ ] AC-1E: IM Hub 飞书群面板加「对外显名」开关(默认 ON,不可关闭直到 Phase 2) +- [ ] AC-1F: 现有 F134 / F157 / F252 单测 + 集成测全绿 +- [ ] AC-1G: 新增单测:channel identity override 替换逻辑纯函数化(`packages/api/test/channel-identity-override.test.js`,≥4 个 case) + +### Phase 2(Prompt pin,P1) + +- [ ] AC-2A: `SystemPromptBuilder` 注入 `ChannelIdentityPin` 段到 cat prompt +- [ ] AC-2B: 注入文本:「你当前 channel 的对外显名是『咖啡猫』。在 IM channel 内,禁止使用猫族分离名作为署名或自称」 +- [ ] AC-2C: cat prompt 加载时校验「identity pin 段已注入」(`channel-identity-prompt-injection.test.js`) + +### Phase 3(观测,P2) + +- [ ] AC-3A: IM Hub 增加 channel identity 看板:列出每个 channel(chat id)过去 24h 各 displayName 出现频次 +- [ ] AC-3B: 检测到「非预期 displayName」→ 写 audit log(不动手阻断,先观测) + +## Dependencies + +- **Preceded by**: F088(multi-platform chat gateway)+ F134(feishu group chat) + F157(receipt ack) +- **Coordinates with**: F252(群 @mention 互通)—— channel identity 不影响 @mention 协议 +- **Side effect on**: F138 / F124 / F068 等所有调用 `catRegistry` displayName 的路径需要 review identity 替换 + +## Risk + +| 风险 | 缓解 | +|---|---| +| 用户看「咖啡猫」但实际是「砚砚」在干活 → 用户困惑 | Phase 2 加 prompt identity pin + IM Hub audit 段显示「当前 cat ID」 | +| DM 行为改变(用户该 + 的 cat 显示名变了)→ 用户错认 | DM 不走 override(AC-1D)—— DM 行为不变 | +| 多只 cat 在同一群协作(猫需要互相区分)→ 用「工号」/内部 mention 替代 displayName(Phase 3 评估) | +| history / log 里看不同 channel 名 → audit 里加 channel identity tag | +| 跨 connector 不一致(飞书 = 咖啡猫,Telegram = 不同名)| 默认值 operator 可配(IM Hub 面板),无需在 spec 写死 | + +## Key Decisions + +| # | 决策 | 理由 | 日期 | +|---|------|------|------| +| KD-1 | 双层机制(connector 强制 + prompt pin)| F167 已证 prompt 层天花板,需要 harness 兜底 | 2026-07-19 | +| KD-2 | DM 保留 cat 自带 displayName,群默认单身份 | 用户对 DM 有「一对一 cat」期望,群里才暴露 | 2026-07-19 | +| KD-3 | 默认 displayName='咖啡猫' 写 spec 不配 env | operator 已有「咖啡猫」对外认知(绑飞书时就这么叫)| 2026-07-19 | +| KD-4 | Phase 1 默认生效 + IM Hub 不可关闭 | "必须更正"是 P0 指令,不需要先观测再开 — 先开 + audit 看不一致 | 2026-07-19 | +| KD-5 | exposeInternalNames 默认 false,仅 Phase 3 后可改 | 内部 cat 名泄漏是协作障碍的根因,禁止 | 2026-07-19 | +| KD-6 | cat agent 自指(如文中提到「我是砚砚」)是否过滤 | KD-5 兜底:prompt 层提示不强,**但消息正文里仍允许**提及「砚砚」做内部引用(不强制改 body)| 2026-07-19 | + +## Design Gate + +- **Phase 1 改动范围**:connector 公共层(`OutboundDeliveryHook` + `ConnectorRouter` binding store + `IM Hub` UI) +- **owner**:Ragdoll(connector layer)+ 砚砚(spec + 测试) +- **跨 cat 协调**:F252 已涉及群 @mention,需合并 review(确认 channel identity 不破坏 F252 AC) + +## Behavioral Evidence + +### Case 1:毅之队飞书群(2026-07-19 实测) + +| 维度 | 当前行为 | Fix 0 后预期 | +|------|---------|--------------| +| 群成员看到署名 | 【砚砚🐱】【小狸🐱】【宪宪🐱】(5 只猫随机)| 【咖啡猫🐱】100% 唯一 | +| 群成员分得清谁是谁 | ❌ 外部 agent 完全不知道 | ✅ 只有 1 个对外名 | +| 群 bandwidth 被署名占用 | 高(约 60% 字符) | 低(单标识符)| +| cat 内部协作可见性(家内 thread)| ✅ 默认保留 | ✅ 不动 | +| 群 @提及外部 bot(@毅马仕/@胖胖虾)| ✅ F252 已工作 | ✅ 不受影响 | + +### Case 2:保留 cat-cafe 家内协作可见性 + +| 场景 | 行为 | +|------|------| +| Cat Café Web UI(html / socket)| cat 自带 displayName — 不变 | +| cat-cafe 家内 thread(IM Hub + slack 私聊)| 不走 connector,无 channelIdentityOverride — 不变 | +| cat-cafe 家内 ↔ cat-cafe 家内 提到「砚砚」| prompt 里仍允许 body 引用「砚砚」(KD-6) | +| cat ↔ 外部 IM channel | override 强制外部 displayName(核心 fix)| +| cat ↔ cat (A2A / @句柄)| 完全不走 connector — 不变(句柄是 identity 常量 KD-21)| diff --git a/docs/references/im-channel-public-contract.md b/docs/references/im-channel-public-contract.md new file mode 100644 index 0000000000..e56717d4e3 --- /dev/null +++ b/docs/references/im-channel-public-contract.md @@ -0,0 +1,109 @@ +# IM Channel Public Contract — cat-cafe 公共频道协议 + +> 给集成 cat-cafe 但**不属于 cat-cafe roster** 的 agent / 真人一份对接文档。 +> 凡是 cat-cafe 的「猫猫」发到公共 IM channel(飞书群、Telegram 群、Discord 群、Slack 群…)的消息,遵循本 contract。 +> 这是**机制层执行契约**,不是建议或礼貌——连接器(adapter)层会强制过滤。 + +**关联**:[F267-channel-single-identity](../features/F267-channel-single-identity.md) 控制每个外部 channel 的对外显名(如飞书群 → 「咖啡猫」),与本 contract 配套生效。 + +## 一、什么是 cat-cafe + +cat-cafe 是一个 multi-agent 协作系统,每只「猫」是一个独立的 AI agent,跑在 Claude Code / Codex CLI / Gemini CLI 等不同模型上。猫之间有自己的内部协作协议(@句柄、球权、hold_ball 等),**但这些内部语言只在家内 thread 用,不会泄漏到 IM channel**。 + +如果你的 agent 在 IM 群里有 cat-cafe 成员但**你不是 cat-cafe** 的 agent / 猫,这文档告诉你: + +1. 你会在群里看到什么 +2. 你**应该忽略**什么 +3. 你**应该回应**什么 + +## 二、你应该忽略的「内部 shorthand」 + +下面这些词/符号是 cat-cafe 内部协作协议,**对外部 agent 不可读**。你的 bot filter / message handler 应该直接跳过,不要尝试解析、不要回应、不要 trigger 任何业务逻辑: + +| Pattern | 含义 | 外部 agent 视角 | +|---|---|---| +| `⏸️` | 内部 ball-hold marker | 不可读,跳过 | +| `OVER` / `静默` | 内部 ack 结束符 | 不可读,跳过 | +| `收。OVER` | 内部 ack("我接住球") | 不可读,跳过 | +| `hold_ball` | 内部持球动作 | 不可读,跳过 | +| `球权` | 内部动作所有权字段 | 不可读,跳过 | +| `球权在 X` | 内部状态描述 | 不可读,跳过 | +| `三轴自检` / `Linear / 干净断点 / warn` | 猫内部状态分类 | 不可读,跳过 | +| `user-channel 留档` | 猫内部存档声明 | 不可读,跳过 | +| 行首 `@` | 猫内部路由命令 | **结构上**你可能能解析为 @-mention,但语义不是给你的 | +| `球在我手上` / `球还在我手` / `球落地` | 内部状态描述 | 不可读,跳过 | + +**为什么要忽略**?这些 pattern 是猫内部协作的「短促状态更新」。在猫群里是有用的(球权流转、状态确认),但对外部 agent 是噪音——回复它们不会得到有意义的答案,且会把群里真实讨论顶掉。 + +## 三、你应该回应的「公共契约」 + +cat-cafe 猫发出的公共频道消息(feishu / telegram / slack / discord)一定遵守这些约束: + +| 条件 | 保证 | +|------|------| +| **语言** | 自然语言 + 必要时的代码块 / markdown,**不夹带内部 shorthand**(见 §二) | +| **@提及** | 飞书 `名字` / Telegram `@username` / Slack `<@Uxxxx>` 标准语法 | +| **rich block** | 飞书 interactive card / Slack block kit 等 platform-native 格式 | +| **格式** | 如果在群里回复一个真问题,输出**首句是对方问题/请求的确认或扩展**,不是 meta 自检、不是「球权声明」、不是「我在看」 | +| **receipt 动画** | 单字符 emoji reaction(飞书 ❤️ / Telegram 👍 / Slack `:eyes:`)是「接住球」的 placeholder,**不是你 @它的时机**(等正式回复) | +| **撤回** | cat-cafe **不主动撤回**任何消息(包括流式结束)。如果消息因平台限制被删,会提前发 `消息被撤回说明` | + +## 四、外部 agent 的「对接礼仪」 + +如果你的 agent 在含 cat-cafe 成员的群里,请遵循: + +1. **不解析 §二 列表**——当作 noise 处理 +2. **不主动 @ cat-cafe 猫**——除非有用户明确请求 +3. **如果你 @ 一只 cat**:使用**自然语言问题**,不要用 cat-cafe 内部 shorthand(即使你看到了也不要模仿,那是猫猫的语言) +4. **收到 cat-cafe 猫的回复**:当作 standard IM 消息处理,**不**寻找隐藏的内部 routing +5. **误解时 @co-creator**(即本系统的 operator)说明,而不是反过来试图和猫内部 shorthand 博弈 + +## 五、为什么这个 contract 存在 + +历史背景: + +- cat-cafe 内部用一套 shorthand 来做 ball ownership(球权流转)+ ack ping-pong 熔断协作 +- 这套机制**有效**——让多只猫在同一个 thread 里不打架、能熔断无效 ack +- 但这套机制**完全只在 cat-cafe 家内 thread 里有效** +- 当一只猫被拉到外部 IM 群,它**默认沿用**内部语言(包括回你 ⏸️) +- 这就是「猫把家里习惯带出去」的事故来源——毅之队飞书群 90 分钟 30+ 条 loopback 真实发生过 + +**这个 contract 的存在 = connection layer 已经把出口协议转换做了**。你不用适配猫,不需要懂猫 jargon,只需要按 §三 「公共契约」 理解猫给的消息。 + +## 六、对接示例 + +### ✅ 正确:openclaw 收到 cat-cafe 的回复 + +``` +User: 帮我看看这个 PR +@胖胖虾: (发给小猫咖啡猫) +小猫咖啡猫: @胖胖虾 收到,我来 review #134。先看代码 diff…(正文) +``` + +openclaw 看到的: +- `胖胖虾` → 标准飞书 @ 提及 +- 自然语言正文 → 可处理 +- 跳过中间任何 §二 shorthand(即使出现) + +### ❌ 错误:openclaw 试图解析 cat-cafe 内部 shorthand + +``` +小猫咖啡猫: 收。OVER。 +(三秒后) +小猫咖啡猫: 球权在 opus-47。 +``` + +openclaw 看到这些 → 应该**忽略**(§二 列表)。 +不应该:拼字符串识别「OVER」= ack 字段 → 跟踪「球权状态」。 +不应该:尝试和猫对接「球权协议」。 + +## 七、版本 & 反馈 + +- **contract version**:v1(2026-07-19) +- **owner**: cat-cafe 项目 / @砚砚 + @Ragdoll +- **变更通知**:cat-cafe 发到 doc repo 后会在群里贴一条 `[contract v{N} 更新]` +- **反馈**:对 contract 内容有疑问、提议 PR、报告真实使用场景 → 在 cat-cafe 项目仓库提 issue 或跟 operator 联系 + +--- + +🐾 别模仿我们的 shorthand——就像群聊礼仪一样,你有你自己的协作语言。跨系统协作的关键是**让对方读得懂**,不是让对方学你的 jargon。 diff --git a/packages/api/src/infrastructure/connectors/OutboundDeliveryHook.ts b/packages/api/src/infrastructure/connectors/OutboundDeliveryHook.ts index 7344cc228b..f090aa3bd5 100644 --- a/packages/api/src/infrastructure/connectors/OutboundDeliveryHook.ts +++ b/packages/api/src/infrastructure/connectors/OutboundDeliveryHook.ts @@ -4,7 +4,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type CatId, catRegistry, type RichBlock } from '@cat-cafe/shared'; import type { FastifyBaseLogger } from 'fastify'; - +import { resolveInternalRouteUrl } from '../../utils/upload-paths.js'; +import { type ChannelIdentity, type ChannelIdentityRegistry } from './channel-identity.js'; import { ConnectorMessageFormatter, type MessageEnvelope, type MessageOrigin } from './ConnectorMessageFormatter.js'; import type { IConnectorThreadBindingStore } from './ConnectorThreadBindingStore.js'; import { renderAllRichBlocksPlaintext } from './rich-block-plaintext.js'; @@ -87,6 +88,18 @@ export interface OutboundDeliveryHookOptions { | undefined; /** Resolve audio blocks with text but no url (voiceMode frontend-only blocks) by synthesizing TTS. */ readonly resolveVoiceBlocks?: ((blocks: RichBlock[], catId: string) => Promise) | undefined; + /** F267: Channel-level identity override registry. Optional — when missing, + * no override is applied (legacy behavior preserved for cat-cafe homes). */ + readonly channelIdentityRegistry?: ChannelIdentityRegistry | undefined; +} + +/** F267: Result of resolving a per-binding channel identity. */ +export interface ResolvedChannelIdentity { + readonly displayName: string; + readonly emoji: string; + readonly exposeInternalNames: boolean; + /** True when override was applied (vs. natural cat displayName). */ + readonly overridden: boolean; } export class OutboundDeliveryHook { @@ -94,6 +107,45 @@ export class OutboundDeliveryHook { constructor(private readonly opts: OutboundDeliveryHookOptions) {} + /** + * F267: Resolve the effective identity for a single outbound target. + * Pure function (no I/O) so it can be unit-tested without mocks. + * + * Returns the natural cat identity when no override is configured. + */ + resolveBindingIdentity( + binding: { connectorId: string; externalChatId: string }, + naturalCatDisplayName: string, + ): ResolvedChannelIdentity { + const natural = naturalCatDisplayName || 'Cat'; + if (!this.opts.channelIdentityRegistry) { + return { + displayName: natural, + emoji: '🐱', + exposeInternalNames: true, + overridden: false, + }; + } + const override = this.opts.channelIdentityRegistry.resolve( + binding.connectorId, + binding.externalChatId, + ); + if (!override) { + return { + displayName: natural, + emoji: '🐱', + exposeInternalNames: true, + overridden: false, + }; + } + return { + displayName: override.displayName, + emoji: override.emoji ?? '🐱', + exposeInternalNames: override.exposeInternalNames ?? false, + overridden: override.displayName !== natural, + }; + } + /** * Return the set of connectorIds bound to a thread. * Used by ConnectorInvokeTrigger to detect single-token adapters (e.g. weixin) @@ -154,10 +206,7 @@ export class OutboundDeliveryHook { } const entry = catId ? catRegistry.tryGet(catId) : undefined; - const catDisplayName = entry?.config.displayName ?? ''; - const catEmoji = '🐱'; - const textPrefix = catDisplayName ? `【${catDisplayName}🐱】\n` : ''; - const finalContent = `${textPrefix}${content}`; + const naturalCatDisplayName = entry?.config.displayName ?? ''; // Resolve audio blocks that have text but no url (voiceMode frontend-only blocks). // Without resolution, these would be silently dropped by Phase 6's url check. @@ -194,10 +243,29 @@ export class OutboundDeliveryHook { this.opts.log.warn({ connectorId: binding.connectorId }, 'No adapter registered for connector'); return; } + // F267: Per-binding identity resolution. Same thread may bind to multiple + // connectors with different identities (e.g. feishu → 咖啡猫; telegram → 砚砚). + const bindingIdentity = this.resolveBindingIdentity(binding, naturalCatDisplayName); + const catDisplayName = bindingIdentity.displayName; + const catEmoji = bindingIdentity.emoji; + if (bindingIdentity.overridden) { + this.opts.log.info( + { + connectorId: binding.connectorId, + externalChatId: binding.externalChatId, + naturalCat: naturalCatDisplayName, + effectiveIdentity: catDisplayName, + catId, + }, + '[OutboundDeliveryHook] F267: channel identity override applied', + ); + } + const textPrefix = catDisplayName ? `【${catDisplayName}${catEmoji}】\n` : ''; + const finalContent = `${textPrefix}${content}`; try { - // Phase E: Always prefer sendFormattedReply (interactive card) when adapter supports it. - // This ensures each cat's reply is a distinct card with identity header, - // preventing Feishu from merging multiple cats' plain-text into one bubble. + // Phase E / F230: Prefer sendFormattedReply when adapter supports it. + // For Feishu, this now sends plain text (msg_type: 'text') to support @mentions. + // Previously was interactive card — changed in F230 per multi-bot feedback. if (adapter.sendFormattedReply && !hasRichBlocks) { const envelope = threadMeta ? this.formatter.format({ @@ -324,21 +392,16 @@ export class OutboundDeliveryHook { const absPath = resolve?.(item.url); if (absPath) { await adapter.sendMedia(binding.externalChatId, { type: 'image', absPath }); - } else if (item.url.startsWith('https://')) { - // External HTTPS URL: adapter can download + upload to platform + } else if ( + item.url.startsWith('https://') || + item.url.startsWith('/uploads/') || + item.url.startsWith('/api/connector-media/') + ) { + const resolvedUrl = resolveInternalRouteUrl(item.url); await adapter.sendMedia(binding.externalChatId, { type: 'image', - url: item.url, + url: resolvedUrl, }); - } else if (item.url.startsWith('/uploads/') || item.url.startsWith('/api/connector-media/')) { - // Internal route URL — resolver failed (file not found or resolver not configured). - // Converting to http://localhost is NOT usable by external platforms (e.g. Feishu's - // SSRF protection blocks http:// + localhost). Skip with warning instead of sending - // an unusable localhost URL as text fallback. - this.opts.log.warn( - { blockKind: block.kind, url: item.url, hasResolver: !!resolve }, - '[OutboundDeliveryHook] media_gallery image skipped — local file not found (resolver returned undefined)', - ); } else { this.opts.log.warn( { blockKind: block.kind, url: item.url }, diff --git a/packages/api/src/infrastructure/connectors/channel-identity.ts b/packages/api/src/infrastructure/connectors/channel-identity.ts new file mode 100644 index 0000000000..46d7d1234e --- /dev/null +++ b/packages/api/src/infrastructure/connectors/channel-identity.ts @@ -0,0 +1,105 @@ +/** + * F267: Channel-level Single Identity Binding + * + * Per-channel identity override — when a channel (e.g. feishu group) has + * a configured display identity, all cat-cafe cats respond under that + * single identity instead of their individual roster names. + * + * External collaborators only see "咖啡猫", not 砚砚/小狸/宪宪/etc. + * + * Phase 1 — mechanism layer (OutboundDeliveryHook enforces). + * Phase 2 — prompt layer (SystemPromptBuilder injects identity pin). + * Phase 3 — observability (IM Hub audit dashboard). + */ + +export interface ChannelIdentity { + /** Display name shown to external recipients, e.g. "咖啡猫" */ + readonly displayName: string; + /** Optional emoji prefix, e.g. "🐱". Defaults to "🐱". */ + readonly emoji?: string; + /** Allow internal cat names (砚砚/小狸/etc.) in self-references. + * Default false (KD-5). */ + readonly exposeInternalNames?: boolean; +} + +/** + * Default identities applied when explicit registration is missing. + * Conservative: only feishu group gets a fallback override. + * DM keeps the cat's own displayName (KD-2). + */ +export const CHANNEL_IDENTITY_DEFAULTS: Readonly>>> = { + feishu: { + // Per-chat overrides go here. The 'default' key is the fallback for + // any feishu group that doesn't have a per-chat entry. Specific groups + // (like 'oc_xxxxx' = 毅之队) can override. + default: { + displayName: '咖啡猫', + emoji: '🐱', + exposeInternalNames: false, + }, + }, +}; + +export interface ChannelKey { + readonly connectorId: string; + readonly externalChatId: string; +} + +const channelKeyEq = (a: ChannelKey, b: ChannelKey): boolean => + a.connectorId === b.connectorId && a.externalChatId === b.externalChatId; + +/** In-memory registry; Phase 1 path. Phase 3 may swap to Redis-backed. */ +export class ChannelIdentityRegistry { + private readonly explicit = new Map(); + + /** + * Register an explicit identity for a (connector, chat) pair. + * Wins over the default fallback. + */ + set(connectorId: string, externalChatId: string, identity: ChannelIdentity): void { + this.explicit.set(`${connectorId}:${externalChatId}`, identity); + } + + unset(connectorId: string, externalChatId: string): boolean { + return this.explicit.delete(`${connectorId}:${externalChatId}`); + } + + /** + * Resolve the identity for an outbound (connector, chat) target. + * Order: explicit registration → connector default → undefined (no override). + */ + resolve(connectorId: string, externalChatId: string): ChannelIdentity | undefined { + const explicit = this.explicit.get(`${connectorId}:${externalChatId}`); + if (explicit) return explicit; + const fallback = CHANNEL_IDENTITY_DEFAULTS[connectorId]?.default; + return fallback; + } + + /** + * F267 KD-2: DM behavior preservation. + * The caller can hint whether this is a DM (chatType === 'p2p') — + * DMs do NOT inherit the connector default, only explicit registrations. + */ + resolveForChatType( + connectorId: string, + externalChatId: string, + chatType: 'p2p' | 'group' | undefined, + ): ChannelIdentity | undefined { + if (chatType === 'p2p') { + // DMs only honor explicit registrations — keep cat's natural displayName. + return this.explicit.get(`${connectorId}:${externalChatId}`); + } + return this.resolve(connectorId, externalChatId); + } + + list(): Array<{ key: ChannelKey; identity: ChannelIdentity }> { + const out: Array<{ key: ChannelKey; identity: ChannelIdentity }> = []; + for (const [k, identity] of this.explicit) { + const [connectorId, externalChatId] = k.split(':', 2); + out.push({ key: { connectorId, externalChatId }, identity }); + } + return out; + } +} + +export { channelKeyEq }; diff --git a/packages/api/test/channel-identity-override.test.js b/packages/api/test/channel-identity-override.test.js new file mode 100644 index 0000000000..66b46a6373 --- /dev/null +++ b/packages/api/test/channel-identity-override.test.js @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CHANNEL_IDENTITY_DEFAULTS, + ChannelIdentityRegistry, +} from '../dist/infrastructure/connectors/channel-identity.js'; + +// ----------------------------------------------------------------------------- +// ChannelIdentityRegistry unit tests (F267 AC-1G) +// ----------------------------------------------------------------------------- + +test('ChannelIdentityRegistry: explicit set wins over default', () => { + const reg = new ChannelIdentityRegistry(); + reg.set('feishu', 'oc_xxx', { displayName: '咖啡猫-毅之队', emoji: '🐱' }); + const id = reg.resolve('feishu', 'oc_xxx'); + assert.equal(id.displayName, '咖啡猫-毅之队'); +}); + +test('ChannelIdentityRegistry: unset falls back to connector default', () => { + const reg = new ChannelIdentityRegistry(); + const id = reg.resolve('feishu', 'oc_some_group'); + assert.equal(id.displayName, CHANNEL_IDENTITY_DEFAULTS.feishu.default.displayName); + assert.equal(id.emoji, '🐱'); + assert.equal(id.exposeInternalNames, false); +}); + +test('ChannelIdentityRegistry: unknown connector → undefined', () => { + const reg = new ChannelIdentityRegistry(); + const id = reg.resolve('unsupported-channel', 'oc_xxx'); + assert.equal(id, undefined); +}); + +test('ChannelIdentityRegistry: resolveForChatType p2p ignores connector default', () => { + const reg = new ChannelIdentityRegistry(); + // No explicit registration; DMs should not inherit 'feishu default' override. + const id = reg.resolveForChatType('feishu', 'oc_dm', 'p2p'); + assert.equal(id, undefined); +}); + +test('ChannelIdentityRegistry: resolveForChatType group uses connector default', () => { + const reg = new ChannelIdentityRegistry(); + const id = reg.resolveForChatType('feishu', 'oc_group', 'group'); + assert.equal(id.displayName, '咖啡猫'); +}); + +test('ChannelIdentityRegistry: resolveForChatType undefined falls back to resolver (legacy)', () => { + const reg = new ChannelIdentityRegistry(); + const id = reg.resolveForChatType('feishu', 'oc_group', undefined); + assert.equal(id.displayName, '咖啡猫'); +}); + +test('ChannelIdentityRegistry: explicit registration wins even for p2p', () => { + const reg = new ChannelIdentityRegistry(); + reg.set('feishu', 'oc_dm_specific', { displayName: '客服小咖啡' }); + const id = reg.resolveForChatType('feishu', 'oc_dm_specific', 'p2p'); + assert.equal(id.displayName, '客服小咖啡'); +}); + +test('ChannelIdentityRegistry: list returns all explicit registrations', () => { + const reg = new ChannelIdentityRegistry(); + reg.set('feishu', 'oc_a', { displayName: 'A' }); + reg.set('telegram', 'tg_b', { displayName: 'B' }); + const list = reg.list(); + assert.equal(list.length, 2); + const keys = list.map((e) => `${e.key.connectorId}:${e.key.externalChatId}`).sort(); + assert.deepEqual(keys, ['feishu:oc_a', 'telegram:tg_b']); +}); + +test('ChannelIdentityRegistry: unset removes registration', () => { + const reg = new ChannelIdentityRegistry(); + reg.set('feishu', 'oc_x', { displayName: 'X' }); + assert.equal(reg.unset('feishu', 'oc_x'), true); + // Falls back to default + const id = reg.resolve('feishu', 'oc_x'); + assert.equal(id.displayName, '咖啡猫'); +}); diff --git a/packages/api/test/outbound-identity-override.test.js b/packages/api/test/outbound-identity-override.test.js new file mode 100644 index 0000000000..7a9901d54d --- /dev/null +++ b/packages/api/test/outbound-identity-override.test.js @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ChannelIdentityRegistry } from '../dist/infrastructure/connectors/channel-identity.js'; +import { OutboundDeliveryHook } from '../dist/infrastructure/connectors/OutboundDeliveryHook.js'; + +// ----------------------------------------------------------------------------- +// OutboundDeliveryHook.resolveBindingIdentity (F267 AC-1A/1B/1C) +// Unit tests on the pure resolver — no I/O mocks needed. +// ----------------------------------------------------------------------------- + +const baseOpts = () => ({ + bindingStore: { + bind: () => null, + getByExternal: () => null, + getByThread: () => [], + remove: () => false, + listByUser: () => [], + setHubThread: () => null, + }, + adapters: new Map(), + log: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => baseOpts().log, + }, +}); + +test('OutboundDeliveryHook: no registry → natural cat identity, overridden=false', () => { + const hook = new OutboundDeliveryHook(baseOpts()); + const identity = hook.resolveBindingIdentity({ connectorId: 'feishu', externalChatId: 'oc_xxx' }, '砚砚'); + assert.equal(identity.displayName, '砚砚'); + assert.equal(identity.emoji, '🐱'); + assert.equal(identity.exposeInternalNames, true); + assert.equal(identity.overridden, false); +}); + +test('OutboundDeliveryHook: feishu group default → 咖啡猫, overridden=true', () => { + const registry = new ChannelIdentityRegistry(); + // No explicit set — rely on defaults via connector default fallback. + const hook = new OutboundDeliveryHook({ ...baseOpts(), channelIdentityRegistry: registry }); + const identity = hook.resolveBindingIdentity({ connectorId: 'feishu', externalChatId: 'oc_yizhidui' }, '砚砚'); + assert.equal(identity.displayName, '咖啡猫'); + assert.equal(identity.overridden, true); + assert.equal(identity.emoji, '🐱'); + assert.equal(identity.exposeInternalNames, false); +}); + +test('OutboundDeliveryHook: explicit registration wins even when natural name matches', () => { + const registry = new ChannelIdentityRegistry(); + registry.set('feishu', 'oc_z', { displayName: '咖啡猫-夜话组' }); + const hook = new OutboundDeliveryHook({ ...baseOpts(), channelIdentityRegistry: registry }); + const identity = hook.resolveBindingIdentity({ connectorId: 'feishu', externalChatId: 'oc_z' }, '小狸'); + assert.equal(identity.displayName, '咖啡猫-夜话组'); + assert.equal(identity.overridden, true); +}); + +test('OutboundDeliveryHook: telegram channel has no default → keep natural name', () => { + const registry = new ChannelIdentityRegistry(); + const hook = new OutboundDeliveryHook({ ...baseOpts(), channelIdentityRegistry: registry }); + const identity = hook.resolveBindingIdentity({ connectorId: 'telegram', externalChatId: '@group' }, '砚砚'); + assert.equal(identity.displayName, '砚砚'); + assert.equal(identity.overridden, false); +}); + +test('OutboundDeliveryHook: empty natural name → fallback "Cat"', () => { + const registry = new ChannelIdentityRegistry(); + registry.set('feishu', 'oc_empty', { displayName: '咖啡猫' }); + const hook = new OutboundDeliveryHook({ ...baseOpts(), channelIdentityRegistry: registry }); + const identity = hook.resolveBindingIdentity({ connectorId: 'feishu', externalChatId: 'oc_empty' }, ''); + assert.equal(identity.displayName, '咖啡猫'); + assert.equal(identity.overridden, true); +});