Skip to content

fix(targeting): route single-chat conversationIds via oToMessages instead of group send - #564

Open
davidfon888 wants to merge 1 commit into
soimy:mainfrom
davidfon888:fix/proactive-send-single-chat-routing
Open

fix(targeting): route single-chat conversationIds via oToMessages instead of group send#564
davidfon888 wants to merge 1 commit into
soimy:mainfrom
davidfon888:fix/proactive-send-single-chat-routing

Conversation

@davidfon888

Copy link
Copy Markdown

背景

钉钉单聊 (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 / 提醒 / 主动卡片等场景)能够送达,
同时保持已有群消息路径完全兼容、不引入回归。

实现

  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

  • 新增 `findUserStaffIdByConversationId`,含 group-precedence 与 ambiguity 保护
  • 新增 `resolveDingTalkSendTarget` helper
  • `send-service.ts` 接入 helper(2 处)
  • `card-service.ts` 接入 helper(3 处),含 createAICard 的 IM_ROBOT openSpaceId 修正
  • 清理 `card-service.ts` 不再使用的 `stripTargetPrefix` / `resolveOriginalPeerId` 引用

验证 TODO

  • 新增 `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 当前行为)
  • `pnpm test`:1122/1122 通过(含全部 integration / 群卡片 lifecycle)
  • `pnpm run type-check`:通过
  • `pnpm run lint`:0 errors(warnings 数量与 main 一致)
  • `pnpm run build:runtime`:dist/index.js 526.1KB 正常生成
  • 真机:单聊 cron 主动发送(已在用户环境通过本地 patch 验证;上游合并后再做 plugin 升级回归)
  • 真机:群 AI 卡片 lifecycle(确认无回归)

Closes #493

…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-apps

greptile-apps Bot commented May 14, 2026

Copy link
Copy Markdown

Greptile Summary

本 PR 修复了钉钉单聊 conversationId(cidt* 前缀)被误路由到群消息接口(groupMessages/send)的问题,通过引入目录反查机制,在 accountId 已知且 directory 中能精确匹配唯一 user 时,将消息改走 oToMessages/batchSend 并使用正确的 staffId

  • 新增 src/targeting/send-target-resolver.ts,将跨 send / card 服务重复的前缀解析 + 路由判断封装为统一 helper,并接入 findUserStaffIdByConversationId 反查逻辑。
  • src/targeting/target-directory-store.ts 新增 findUserStaffIdByConversationId,仅在精确匹配唯一 user 时返回 staffId,已知群组优先保护避免误判。
  • send-service.tscard-service.ts 共 5 处调用点切换到 helper,userIdsIM_ROBOT openSpaceId 改为使用反查到的 staffId,使单聊主动推送场景能正确送达。

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 中群组键查找的大小写一致性

Important Files Changed

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 两处调用改为 resolveDingTalkSendTargetuserIds 改用 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
Loading
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

Comment on lines +44 to +61
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 };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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)

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.

Comment on lines +428 to +429
if (state.groups[conversationId]) {
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 群组键的大小写校验与用户会话查找方式不一致

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.

Comment on lines +230 to +254
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");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 测试用例标题与断言内容相矛盾

测试标题声称 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 zhumin-zizhu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这两个问题建议合并前修掉。P1 会让本 PR 目标场景下的主动卡片发送仍然走旧的 IM_GROUP 路径,属于功能未完全修复;P2 会削弱 group: 显式前缀的语义,可能在目录状态不完整时把调用方明确指定的群发误降级成单聊。

Comment thread src/send-service.ts
target,
storePath: options.storePath,
accountId: options.accountId,
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1:这里虽然已经解析出了 resolvedUserStaffId,但下面的 proactive card 分支仍然只调用 sendProactiveCardText(config, resolvedTarget, ...),没有把 accountId / storePath 或这个反查出的 staffId 传下去。后续 createAICard 仍可能按 IM_GROUP.<cidt...> 创建卡片,继续触发 robot 不存在,然后才降级到 template API。也就是说,本 PR 要修的 learned single-chat conversationId 主动卡片发送场景仍然会失败。建议让 card 路径复用同一份 resolveDingTalkSendTarget 结果,至少把 accountIdstorePath 和已解析出的 resolvedUserStaffId 传入 sendProactiveCardText / createAICard

Comment on lines +44 to +46
const { targetId, isExplicitUser } = stripTargetPrefix(params.target);
const resolvedTarget = resolveOriginalPeerId(targetId);
const looksLikeCid = !isExplicitUser && resolvedTarget.startsWith("cid");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2:这里丢掉了调用方是否显式写了 group: 的信息。现在 group:cid... 和裸 cid... 一样会继续进入 learned directory 反查;如果目录里刚好只有一个匹配用户,就会返回 isGroup=false。这会改变 group: 作为显式群目标 escape hatch 的语义,在目录状态不完整或过期时反而无法阻止误判成单聊。建议 stripTargetPrefix 或 resolver 返回/保留 explicit group 标记,并在显式 group: 时直接保持 group route。

@soimy

soimy commented May 14, 2026

Copy link
Copy Markdown
Owner

钉钉单聊 (1:1 robot direct chat) 的 conversationId 通常以 cidt 前缀(如cidtUnZOR1ijwl8F5gk62MUtSRRWXYhCkI7bSBN7X8QcTM=),群聊以 cidp 等其它 cid 前缀。

第一次遇到这个user case. 在我的知识中,userId一般都是 managerXXX groupId一般都是cid 开头
需要再次确认下钉钉侧conversationId的特征规则

@soimy

soimy commented May 16, 2026

Copy link
Copy Markdown
Owner

@davidfon888 我在本地核实了下conversationId的命名规则,结果和你提到的并不一致
不过你提出的观点还是非常有借鉴意义,目前代码侧使用cid前缀作为群聊conversationId特征的判断过于草率,我们需要进一步收紧判断依据。

@soimy

soimy commented May 16, 2026

Copy link
Copy Markdown
Owner

本地日志核实:cidt/cidp 前缀说法不成立

从本地 gateway 日志 (~/.openclaw/logs/gateway.log) 提取了所有 conversationIdconversationType 的唯一配对:

conversationId conversationType 含义
cidKzHlklWI***************S2iKHEU= 1 单聊
cidhFUSdk****************fnZSCibbA= 1 单聊
cidIpScRwN****************ZMQEiYA= 1 单聊
cidl7+XiShNv*****************C3Maqo= 1 单聊
cid//Vc7N***********0XAw== 2 群聊

结论:没有观察到 cidtcidp 前缀。 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 前缀说法 不属实,需更正

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

让钉钉单聊机器人找人私聊时,创建会话使用了群聊的kind

3 participants