fix(targeting): route single-chat conversationIds via oToMessages instead of group send - #564
Conversation
…tead of group send
## 背景
钉钉单聊 (1:1 robot direct chat) 的 conversationId 通常以 \`cidt\` 前缀(如
\`cidtUnZOR1ijwl8F5gk62MUtSRRWXYhCkI7bSBN7X8QcTM=\`),群聊以 \`cidp\` 等其它 cid 前缀。
当前 \`send-service.ts\` 与 \`card-service.ts\` 的 5 处使用同一判断:
\`\`\`ts
const isGroup = !isExplicitUser && resolvedTarget.startsWith("cid");
\`\`\`
把所有 cid 开头的 target 一律路由到 \`groupMessages/send\`(或 IM_GROUP openSpaceId)。
对于单聊 conversationId,钉钉服务端会拒绝(企业自建应用的 AppKey 并未注册为该群机器人):
\`\`\`
[ErrorPayload][send.proactiveMessage] code=resource.not.found
message=错误描述: robot 不存在;解决方案:请确认 robotCode 是否正确;
\`\`\`
实测:oToMessages/batchSend 可以接受 \`robotCode = AppKey\`(错误为 \`staffId.notExisted\`,
说明 robot 校验通过、只是 user 错),groupMessages/send 则严格要求 robotCode 是该群已绑定
的机器人 — 这是单聊与群两条 outbound 路径之间的鉴权差异。
issue 参考:soimy#493 \"让钉钉单聊机器人找人私聊时,创建会话使用了群聊的kind\"。
## 目标
让用户向单聊 conversationId 主动发消息(cron / 提醒 / 主动卡片等场景)能够送达,
同时保持已有群消息路径完全兼容、不引入回归。
## 实现
1. \`src/targeting/target-directory-store.ts\`:新增 \`findUserStaffIdByConversationId\`
反查函数,返回**唯一**匹配的 user 的 staffId(或 canonicalUserId fallback)。
- 当该 conversationId 已被 plugin 观察为群(存在于 \`state.groups\`),始终返回 null,
避免"刚加入群、只有一个成员发过言"被误判为单聊。
- 当 \`lastSeenInConversationIds\` 命中超过 1 个 user,也返回 null(真群)。
2. \`src/targeting/send-target-resolver.ts\`(新文件):跨 send 与 card 的统一 helper
\`resolveDingTalkSendTarget\`。封装 \`stripTargetPrefix\` + \`resolveOriginalPeerId\` +
反查逻辑,返回 \`{ resolvedTarget, isGroup, resolvedUserStaffId }\`。
3. \`src/send-service.ts\`:\`sendProactiveTextOrMarkdown\` 与 \`sendProactiveMedia\` 改为
调用 helper;\`payload.userIds\` 在反查命中时使用 \`resolvedUserStaffId\` 而非 conversationId。
4. \`src/card-service.ts\`:
- \`sendTemplateMismatchNotification\` 与 \`getCardRecallTarget\` 改为调用 helper;
- \`createAICard\` 同样切换到 helper:\`IM_ROBOT.\${conversationId}\` 改为
\`IM_ROBOT.\${resolvedUserStaffId ?? conversationId}\`,使单聊 cidt 也能命中私聊空间。
5. 兼容性保证:
- \`user:\` / \`group:\` 显式前缀逻辑保持不变;
- 没有 \`accountId\` 上下文(无法反查)时,维持原 isGroup 判断(不回归);
- 反查不到 / 命中多个时,维持原行为(即 \`startsWith("cid")\` 判群)。
## 实现 TODO
- [x] 新增 \`findUserStaffIdByConversationId\`,含 group-precedence 与 ambiguity 保护
- [x] 新增 \`resolveDingTalkSendTarget\` helper
- [x] \`send-service.ts\` 接入 helper(2 处)
- [x] \`card-service.ts\` 接入 helper(3 处),含 createAICard 的 IM_ROBOT openSpaceId 修正
- [x] 清理 \`card-service.ts\` 不再使用的 \`stripTargetPrefix\` / \`resolveOriginalPeerId\` 引用
## 验证 TODO
- [x] 新增 \`tests/unit/targeting/send-target-resolver.test.ts\`:12 个用例覆盖
- 空 / 未知 conversationId 返回 null
- 单聊 cidt 命中唯一 user → 返回 staffId
- 缺 staffId 时 fallback 到 canonicalUserId
- **群 + 单 user 时返回 null(防止 false positive)**
- 同 conversationId 跨 accountId 不串数据
- \`user:\` 前缀强制 user 路由
- 纯数字 staffId 走 user 路由
- 无 accountId 的 cid* 维持 group 兜底
- 已知 cid* 但无 directory 命中维持 group 兜底
- cidt 单聊 + 已知 user → user 路由 + staffId
- \`group:cid*\` 与无前缀语义一致(pin 当前行为)
- [x] \`pnpm test\`:1122/1122 通过(含全部 integration / 群卡片 lifecycle)
- [x] \`pnpm run type-check\`:通过
- [x] \`pnpm run lint\`:0 errors(warnings 数量与 main 一致)
- [x] \`pnpm run build:runtime\`:dist/index.js 526.1KB 正常生成
- [ ] 真机:单聊 cron 主动发送(已在用户环境通过本地 patch 验证;上游合并后再做 plugin 升级回归)
- [ ] 真机:群 AI 卡片 lifecycle(确认无回归)
Closes soimy#493
Greptile Summary本 PR 修复了钉钉单聊 conversationId(
Confidence Score: 3/5核心修复逻辑正确,但 group: 前缀被 directory 反查覆盖导致静默行为回归,与 PR 兼容性承诺不符 对于主要修复场景(无前缀的 cidt* conversationId),新路由逻辑完全正确,能解决 robot 不存在错误。但当调用方传入 group:cidt* 时,原来始终走 group 路由,新代码则会因 directory 反查成功而改走 user 路由,该行为变化未被文档明确标注为 breaking change,存在静默回归风险。 重点关注 src/targeting/send-target-resolver.ts 中对 group: 前缀的处理路径,以及 src/targeting/target-directory-store.ts 中群组键查找的大小写一致性
|
| Filename | Overview |
|---|---|
| src/targeting/send-target-resolver.ts | 新增 helper 封装路由决策逻辑;group: 前缀未能阻止 directory 反查,可能将显式群组目标误路由到 user 通道 |
| src/targeting/target-directory-store.ts | 新增 findUserStaffIdByConversationId 反查函数,逻辑清晰;群组键查找与 user cid 比较的大小写策略不一致存在潜在风险 |
| src/send-service.ts | 两处调用改为 resolveDingTalkSendTarget,userIds 改用 resolvedUserStaffId,逻辑正确;无新增问题 |
| src/card-service.ts | 三处调用统一切换到 helper;IM_ROBOT openSpaceId 改为使用 resolvedUserStaffId,修复单聊卡片投递;改动范围清晰 |
| tests/unit/targeting/send-target-resolver.test.ts | 12 个测试用例覆盖关键路径;group:cid 测试标题与断言相矛盾,注释虽有解释但仍具误导性 |
Sequence Diagram
sequenceDiagram
participant Caller as 调用方(cron/提醒)
participant Resolver as resolveDingTalkSendTarget
participant DirStore as findUserStaffIdByConversationId
participant DT_Group as groupMessages/send
participant DT_User as oToMessages/batchSend
Caller->>Resolver: "target=cidtXxx, accountId=acct"
Note over Resolver: looksLikeCid=true, accountId存在
Resolver->>DirStore: "conversationId=cidtXxx"
alt 群组已知
DirStore-->>Resolver: null
Resolver-->>Caller: "isGroup=true"
Caller->>DT_Group: "openConversationId=cidtXxx"
else 匹配唯一 user
DirStore-->>Resolver: staffId_123
Resolver-->>Caller: "isGroup=false, resolvedUserStaffId=staffId_123"
Caller->>DT_User: "userIds=[staffId_123]"
else 无匹配/无accountId
DirStore-->>Resolver: null
Resolver-->>Caller: "isGroup=true"
Caller->>DT_Group: 兜底原有行为
end
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
src/targeting/send-target-resolver.ts:44-61
**`group:` 前缀的路由覆盖被静默忽略**
当调用方传入 `group:cidtFoo` 时,`stripTargetPrefix` 返回 `{ targetId: "cidtFoo", isExplicitUser: false }`,导致 `looksLikeCid = true`,随后 helper 仍然会查询 directory。若 directory 恰好有唯一匹配的 user,则函数返回 `{ isGroup: false, resolvedUserStaffId: ... }`,把一个明确标注为群组目标的消息路由到了 `oToMessages/batchSend`。PR 描述中声称 "`user:/group:` 显式前缀逻辑保持不变",但这里存在行为回归:原始代码中 `group:cid*` 始终路由为 group,而新代码只要 directory 能查到 user 就会改为 user 路由。修复方法:在 `resolveDingTalkSendTarget` 内部检查是否有 `group:` 前缀,若有则跳过 directory 查询,直接返回 `{ isGroup: true, resolvedUserStaffId: null }`。
### Issue 2 of 3
src/targeting/target-directory-store.ts:428-429
**群组键的大小写校验与用户会话查找方式不一致**
`state.groups[conversationId]` 使用仅经 `trimValue` 处理的 key 进行直接查找,而 user 端查找使用 `normalizeLookup`(额外执行 `.toLowerCase()`)。若存储 key 与传入 conversationId 大小写不一致,群组保护逻辑会静默失效,导致真实群组被误判为单聊并向 user 路由。建议统一使用规范化 key 进行群组键查找。
### Issue 3 of 3
tests/unit/targeting/send-target-resolver.test.ts:230-254
**测试用例标题与断言内容相矛盾**
测试标题声称 `group:cid` 前缀 "is honored (forces group route)",但 `expect(r.isGroup).toBe(false)` 明确证明前缀并未被采纳——实际走了 user 路由。建议将标题改为描述实际行为(如 "group: prefix on cid* is NOT honored — known limitation"),以免维护者误读。
Reviews (1): Last reviewed commit: "fix(targeting): route single-chat conver..." | Re-trigger Greptile
| const { targetId, isExplicitUser } = stripTargetPrefix(params.target); | ||
| const resolvedTarget = resolveOriginalPeerId(targetId); | ||
| const looksLikeCid = !isExplicitUser && resolvedTarget.startsWith("cid"); | ||
| if (!looksLikeCid) { | ||
| return { resolvedTarget, isGroup: false, resolvedUserStaffId: null }; | ||
| } | ||
| if (params.accountId) { | ||
| const staffId = findUserStaffIdByConversationId({ | ||
| storePath: params.storePath, | ||
| accountId: params.accountId, | ||
| conversationId: resolvedTarget, | ||
| }); | ||
| if (staffId) { | ||
| return { resolvedTarget, isGroup: false, resolvedUserStaffId: staffId }; | ||
| } | ||
| } | ||
| return { resolvedTarget, isGroup: true, resolvedUserStaffId: null }; | ||
| } |
There was a problem hiding this comment.
当调用方传入 group:cidtFoo 时,stripTargetPrefix 返回 { targetId: "cidtFoo", isExplicitUser: false },导致 looksLikeCid = true,随后 helper 仍然会查询 directory。若 directory 恰好有唯一匹配的 user,则函数返回 { isGroup: false, resolvedUserStaffId: ... },把一个明确标注为群组目标的消息路由到了 oToMessages/batchSend。PR 描述中声称 "user:/group: 显式前缀逻辑保持不变",但这里存在行为回归:原始代码中 group:cid* 始终路由为 group,而新代码只要 directory 能查到 user 就会改为 user 路由。修复方法:在 resolveDingTalkSendTarget 内部检查是否有 group: 前缀,若有则跳过 directory 查询,直接返回 { isGroup: true, resolvedUserStaffId: null }。
Rule Used: What: All code review comments and feedback must b... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/targeting/send-target-resolver.ts
Line: 44-61
Comment:
**`group:` 前缀的路由覆盖被静默忽略**
当调用方传入 `group:cidtFoo` 时,`stripTargetPrefix` 返回 `{ targetId: "cidtFoo", isExplicitUser: false }`,导致 `looksLikeCid = true`,随后 helper 仍然会查询 directory。若 directory 恰好有唯一匹配的 user,则函数返回 `{ isGroup: false, resolvedUserStaffId: ... }`,把一个明确标注为群组目标的消息路由到了 `oToMessages/batchSend`。PR 描述中声称 "`user:/group:` 显式前缀逻辑保持不变",但这里存在行为回归:原始代码中 `group:cid*` 始终路由为 group,而新代码只要 directory 能查到 user 就会改为 user 路由。修复方法:在 `resolveDingTalkSendTarget` 内部检查是否有 `group:` 前缀,若有则跳过 directory 查询,直接返回 `{ isGroup: true, resolvedUserStaffId: null }`。
**Rule Used:** What: All code review comments and feedback must b... ([source](https://app.greptile.com/review/custom-context?memory=af4da0ce-8d7c-48c9-a88a-82361a98dddf))
How can I resolve this? If you propose a fix, please make it concise.| if (state.groups[conversationId]) { | ||
| return null; |
There was a problem hiding this comment.
state.groups[conversationId] 使用仅经 trimValue 处理的 key 进行直接查找,而 user 端查找使用 normalizeLookup(额外执行 .toLowerCase())。若存储 key 与传入 conversationId 大小写不一致,群组保护逻辑会静默失效,导致真实群组被误判为单聊并向 user 路由。建议统一使用规范化 key 进行群组键查找。
Rule Used: What: All code review comments and feedback must b... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/targeting/target-directory-store.ts
Line: 428-429
Comment:
**群组键的大小写校验与用户会话查找方式不一致**
`state.groups[conversationId]` 使用仅经 `trimValue` 处理的 key 进行直接查找,而 user 端查找使用 `normalizeLookup`(额外执行 `.toLowerCase()`)。若存储 key 与传入 conversationId 大小写不一致,群组保护逻辑会静默失效,导致真实群组被误判为单聊并向 user 路由。建议统一使用规范化 key 进行群组键查找。
**Rule Used:** What: All code review comments and feedback must b... ([source](https://app.greptile.com/review/custom-context?memory=af4da0ce-8d7c-48c9-a88a-82361a98dddf))
How can I resolve this? If you propose a fix, please make it concise.| it("group:cid prefix is honored (forces group route even when reverse-lookup would resolve)", () => { | ||
| // group: prefix sets isExplicitUser=false but the helper does not narrow to user. | ||
| // Although the directory has a single known user, an explicit group: hint should | ||
| // not trigger a user-route demotion. | ||
| const storePath = createStorePath(); | ||
| upsertObservedUserTarget({ | ||
| storePath, | ||
| accountId: "default", | ||
| senderId: "$:LWCP_v1:$U", | ||
| staffId: "U", | ||
| displayName: "U", | ||
| conversationId: "cidtAMBIG=", | ||
| }); | ||
| const r = resolveDingTalkSendTarget({ | ||
| target: "group:cidtAMBIG=", | ||
| storePath, | ||
| accountId: "default", | ||
| }); | ||
| // With the current helper semantics the group: prefix is treated identically to no | ||
| // prefix for cid* (still attempts directory lookup); pin behavior here so future | ||
| // refactors that change this semantic surface in the diff. | ||
| // If a stricter group-route-no-fallback is wanted later, add a dedicated flag. | ||
| expect(r.isGroup).toBe(false); | ||
| expect(r.resolvedUserStaffId).toBe("U"); | ||
| }); |
There was a problem hiding this comment.
测试标题声称 group:cid 前缀 "is honored (forces group route)",但 expect(r.isGroup).toBe(false) 明确证明前缀并未被采纳——实际走了 user 路由。建议将标题改为描述实际行为(如 "group: prefix on cid* is NOT honored — known limitation"),以免维护者误读。
Rule Used: What: All code review comments and feedback must b... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/targeting/send-target-resolver.test.ts
Line: 230-254
Comment:
**测试用例标题与断言内容相矛盾**
测试标题声称 `group:cid` 前缀 "is honored (forces group route)",但 `expect(r.isGroup).toBe(false)` 明确证明前缀并未被采纳——实际走了 user 路由。建议将标题改为描述实际行为(如 "group: prefix on cid* is NOT honored — known limitation"),以免维护者误读。
**Rule Used:** What: All code review comments and feedback must b... ([source](https://app.greptile.com/review/custom-context?memory=af4da0ce-8d7c-48c9-a88a-82361a98dddf))
How can I resolve this? If you propose a fix, please make it concise.
zhumin-zizhu
left a comment
There was a problem hiding this comment.
这两个问题建议合并前修掉。P1 会让本 PR 目标场景下的主动卡片发送仍然走旧的 IM_GROUP 路径,属于功能未完全修复;P2 会削弱 group: 显式前缀的语义,可能在目录状态不完整时把调用方明确指定的群发误降级成单聊。
| target, | ||
| storePath: options.storePath, | ||
| accountId: options.accountId, | ||
| }); |
There was a problem hiding this comment.
P1:这里虽然已经解析出了 resolvedUserStaffId,但下面的 proactive card 分支仍然只调用 sendProactiveCardText(config, resolvedTarget, ...),没有把 accountId / storePath 或这个反查出的 staffId 传下去。后续 createAICard 仍可能按 IM_GROUP.<cidt...> 创建卡片,继续触发 robot 不存在,然后才降级到 template API。也就是说,本 PR 要修的 learned single-chat conversationId 主动卡片发送场景仍然会失败。建议让 card 路径复用同一份 resolveDingTalkSendTarget 结果,至少把 accountId、storePath 和已解析出的 resolvedUserStaffId 传入 sendProactiveCardText / createAICard。
| const { targetId, isExplicitUser } = stripTargetPrefix(params.target); | ||
| const resolvedTarget = resolveOriginalPeerId(targetId); | ||
| const looksLikeCid = !isExplicitUser && resolvedTarget.startsWith("cid"); |
There was a problem hiding this comment.
P2:这里丢掉了调用方是否显式写了 group: 的信息。现在 group:cid... 和裸 cid... 一样会继续进入 learned directory 反查;如果目录里刚好只有一个匹配用户,就会返回 isGroup=false。这会改变 group: 作为显式群目标 escape hatch 的语义,在目录状态不完整或过期时反而无法阻止误判成单聊。建议 stripTargetPrefix 或 resolver 返回/保留 explicit group 标记,并在显式 group: 时直接保持 group route。
第一次遇到这个user case. 在我的知识中,userId一般都是 |
|
@davidfon888 我在本地核实了下conversationId的命名规则,结果和你提到的并不一致 |
本地日志核实:
|
| conversationId | conversationType | 含义 |
|---|---|---|
cidKzHlklWI***************S2iKHEU= |
1 | 单聊 |
cidhFUSdk****************fnZSCibbA= |
1 | 单聊 |
cidIpScRwN****************ZMQEiYA= |
1 | 单聊 |
cidl7+XiShNv*****************C3Maqo= |
1 | 单聊 |
cid//Vc7N***********0XAw== |
2 | 群聊 |
结论:没有观察到 cidt 或 cidp 前缀。 conversationId 的格式是 cid + base64 编码,第 4 个字符是 base64 内容的一部分,并非区分单聊/群聊的固定标识符。钉钉区分单聊/群聊的可靠字段是 conversationType(1=单聊,2=群聊)。
审核意见:实现意图合理,但有两处需修正
1. 核心意图合理 ✅
PR 要解决的问题确实存在:当前 !isExplicitUser && resolvedTarget.startsWith("cid") 把所有 cid 开头的目标一律路由到 groupMessages/send,导致单聊 conversationId 主动发送时返回 robot 不存在。
通过 findUserStaffIdByConversationId 反查 directory 来区分单聊/群聊的思路是正确的,且安全保护(群优先、多用户返回 null、无 accountId 兜底)设计得当。
2. group: 前缀处理有 bug ⚠️
当调用方传入 group:cidtFoo 时,stripTargetPrefix 返回 { targetId: "cidtFoo", isExplicitUser: false },导致 looksLikeCid = true,helper 仍会查询 directory。若 directory 恰好有唯一匹配的 user,则返回 { isGroup: false },把明确标注为群组目标的消息路由到了 oToMessages/batchSend。
这与 PR 描述中 "user:/group: 显式前缀逻辑保持不变" 的承诺矛盾。
测试用例也 pin 了这个错误行为(tests/unit/targeting/send-target-resolver.test.ts:247):
// 标题说 "group:cid prefix is honored (forces group route)"
// 但断言 isGroup=false,与标题矛盾
expect(r.isGroup).toBe(false);
expect(r.resolvedUserStaffId).toBe("U");建议:在 resolveDingTalkSendTarget 中检测原始 target 是否以 group: 开头,若是则跳过 directory 查询,直接返回 { isGroup: true, resolvedUserStaffId: null }。
3. PR 描述需更正 ⚠️
PR 描述中关于 cidt/cidp 前缀的说法应更正。建议改为更准确的描述,例如:
钉钉单聊和群聊的 conversationId 均以
cid开头(后接 base64 编码),无法仅凭前缀区分单聊/群聊。当前代码将所有cid开头的目标一律路由到群 API,导致单聊 conversationId 主动发送失败。
总结
| 项目 | 评估 |
|---|---|
| 核心问题(单聊 cid 被误路由到群 API) | 真实存在,需修复 |
| 反查 directory 区分单聊/群聊的方案 | 合理 |
group: 前缀处理 |
有 bug,需修复 |
cidt/cidp 前缀说法 |
不属实,需更正 |
背景
钉钉单聊 (1:1 robot direct chat) 的 conversationId 通常以 `cidt` 前缀(如
`cidtUnZOR1ijwl8F5gk62MUtSRRWXYhCkI7bSBN7X8QcTM=`),群聊以 `cidp` 等其它 cid 前缀。
当前 `send-service.ts` 与 `card-service.ts` 的 5 处使用同一判断:
```ts
const isGroup = !isExplicitUser && resolvedTarget.startsWith("cid");
```
把所有 cid 开头的 target 一律路由到 `groupMessages/send`(或 IM_GROUP openSpaceId)。
对于单聊 conversationId,钉钉服务端会拒绝(企业自建应用的 AppKey 并未注册为该群机器人):
```
[ErrorPayload][send.proactiveMessage] code=resource.not.found
message=错误描述: robot 不存在;解决方案:请确认 robotCode 是否正确;
```
实测:oToMessages/batchSend 可以接受 `robotCode = AppKey`(错误为 `staffId.notExisted`,
说明 robot 校验通过、只是 user 错),groupMessages/send 则严格要求 robotCode 是该群已绑定
的机器人 — 这是单聊与群两条 outbound 路径之间的鉴权差异。
issue 参考:#493 "让钉钉单聊机器人找人私聊时,创建会话使用了群聊的kind"。
目标
让用户向单聊 conversationId 主动发消息(cron / 提醒 / 主动卡片等场景)能够送达,
同时保持已有群消息路径完全兼容、不引入回归。
实现
`src/targeting/target-directory-store.ts`:新增 `findUserStaffIdByConversationId`
反查函数,返回唯一匹配的 user 的 staffId(或 canonicalUserId fallback)。
避免"刚加入群、只有一个成员发过言"被误判为单聊。
`src/targeting/send-target-resolver.ts`(新文件):跨 send 与 card 的统一 helper
`resolveDingTalkSendTarget`。封装 `stripTargetPrefix` + `resolveOriginalPeerId` +
反查逻辑,返回 `{ resolvedTarget, isGroup, resolvedUserStaffId }`。
`src/send-service.ts`:`sendProactiveTextOrMarkdown` 与 `sendProactiveMedia` 改为
调用 helper;`payload.userIds` 在反查命中时使用 `resolvedUserStaffId` 而非 conversationId。
`src/card-service.ts`:
`IM_ROBOT.${resolvedUserStaffId ?? conversationId}`,使单聊 cidt 也能命中私聊空间。
兼容性保证:
实现 TODO
验证 TODO
Closes #493