@soimy/dingtalk 3.3.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +141 -12
  2. package/index.ts +71 -66
  3. package/package.json +6 -5
  4. package/src/access-control.ts +65 -0
  5. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  8. package/src/ack-reaction-classifier.ts +17 -4
  9. package/src/ack-reaction-service.ts +66 -19
  10. package/src/attachment-text-extractor.ts +2 -1
  11. package/src/card-service.ts +145 -257
  12. package/src/channel.ts +106 -47
  13. package/src/config-schema.ts +28 -6
  14. package/src/config.ts +30 -6
  15. package/src/connection-manager.ts +16 -5
  16. package/src/inbound-handler.ts +694 -520
  17. package/src/media-utils.ts +99 -36
  18. package/src/message-context-store.ts +787 -0
  19. package/src/message-utils.ts +221 -42
  20. package/src/messaging/quoted-context.ts +269 -0
  21. package/src/messaging/quoted-ref.ts +97 -0
  22. package/src/onboarding.ts +381 -269
  23. package/src/reply-strategy-card.ts +225 -0
  24. package/src/reply-strategy-markdown.ts +55 -0
  25. package/src/reply-strategy-with-reaction.ts +190 -0
  26. package/src/reply-strategy.ts +72 -0
  27. package/src/runtime.ts +5 -7
  28. package/src/send-service.ts +164 -62
  29. package/src/targeting/agent-name-matcher.ts +148 -0
  30. package/src/targeting/agent-routing.ts +181 -0
  31. package/src/targeting/target-directory-adapter.ts +152 -0
  32. package/src/targeting/target-directory-store.ts +396 -0
  33. package/src/targeting/target-input.ts +62 -0
  34. package/src/types.ts +124 -21
  35. package/src/quote-journal.ts +0 -242
  36. package/src/quoted-msg-cache.ts +0 -226
package/README.md CHANGED
@@ -39,6 +39,7 @@
39
39
  - [使用示例](#使用示例)
40
40
  - [故障排除](#故障排除)
41
41
  - [开发指南](#开发指南)
42
+ - [架构与职责边界](#架构与职责边界)
42
43
  - [测试](#测试)
43
44
  - [许可](#许可)
44
45
 
@@ -54,6 +55,54 @@
54
55
  - ✅ **Markdown 表格兼容** — 自动把 Markdown 表格转换为钉钉更稳定的可读文本
55
56
  - ✅ **互动卡片** — 支持流式更新,适用于 AI 实时输出
56
57
  - ✅ **完整 AI 对话** — 接入 Clawdbot 消息处理管道
58
+ - ✅ **@多助手路由** — 在群聊中通过 `@助手名` 路由到不同的 agent(实验性功能)
59
+
60
+ ### @多助手路由(实验性)
61
+
62
+ > ⚠️ **实验性功能**:此功能使用框架层 `agents.list` 配置实现,与框架的 `bindings` 机制独立运作。
63
+
64
+ 在群聊中,用户可以通过 `@助手名` 来指定要对话的 agent。每个 agent 拥有独立的 session。
65
+
66
+ ```
67
+ 用户: @frontend 帮我看看这个组件的问题
68
+ [frontend] 好的,请贴出代码...
69
+
70
+ 用户: @dba 数据库慢查询怎么处理?
71
+ [dba] 从数据库角度分析...
72
+ ```
73
+
74
+ #### 配置方式
75
+
76
+ 在 OpenClaw 配置文件中配置 `agents.list`:
77
+
78
+ ```json
79
+ {
80
+ "agents": {
81
+ "list": [
82
+ { "id": "main", "name": "助手", "default": true },
83
+ { "id": "frontend", "name": "前端专家" },
84
+ { "id": "dba", "name": "DBA" }
85
+ ]
86
+ }
87
+ }
88
+ ```
89
+
90
+ #### 当前功能范围
91
+
92
+ - @mention 解析 → agent 名匹配(支持 `name` 和 `id`)
93
+ - 路由到独立 agent session
94
+ - 回复自动添加 `[助手名]` 前缀
95
+
96
+ #### 后续迭代计划
97
+
98
+ - 群聊历史上下文注入(被 @ 的 agent 能看到近期对话)
99
+ - 多专家协作讨论(专家间链式 @mention、讨论记录共享)
100
+ - `/agents` 命令列出可用专家
101
+
102
+ #### 已知限制
103
+
104
+ - sub-agent 路由使用框架的 `buildAgentSessionKey` API,不通过 `bindings` 配置匹配
105
+ - 与框架顶层的 `bindings` 配置**独立运作**,同时配置两者可能导致混淆
57
106
 
58
107
  ### 进程级(memory-only)运行态说明
59
108
 
@@ -374,6 +423,7 @@ openclaw configure --section channels
374
423
  "agentId": "123456789",
375
424
  "dmPolicy": "open",
376
425
  "groupPolicy": "open",
426
+ "displayNameResolution": "disabled", // 或 "all";启用后可能因重名/旧名称/权限边界限制导致误解析
377
427
  "journalTTLDays": 7,
378
428
  "ackReaction": "🤔思考中", // 给原消息贴处理中的表情反馈;设为 "" 可关闭
379
429
  "debug": false,
@@ -409,8 +459,11 @@ openclaw gateway restart
409
459
  | `corpId` | string | - | 企业 ID |
410
460
  | `agentId` | string | - | 应用 ID |
411
461
  | `dmPolicy` | string | `"open"` | 私聊策略:open/pairing/allowlist |
412
- | `groupPolicy` | string | `"open"` | 群聊策略:open/allowlist |
413
- | `allowFrom` | string[] | `[]` | 允许的发送者 ID 列表 |
462
+ | `groupPolicy` | string | `"open"` | 群聊策略:open/allowlist/disabled |
463
+ | `allowFrom` | string[] | `[]` | 允许的发送者 ID 列表(仅私聊) |
464
+ | `groupAllowFrom` | string[] | - | 群聊发送者白名单(全局) |
465
+ | `groups` | object | - | 按 conversationId 的群级配置,详见[群聊策略](#群聊策略-grouppolicy) |
466
+ | `displayNameResolution` | string | `"disabled"` | 基于本地通讯录存储的显示名/群名解析开关:disabled/all |
414
467
  | `bypassProxyForSend` | boolean | `false` | 发送链路直连,不走全局代理 |
415
468
  | `learningEnabled` | boolean | `false` | 开启学习信号采集与学习提示注入 |
416
469
  | `learningAutoApply` | boolean | `false` | 自动将学习笔记注入当前会话 |
@@ -430,6 +483,16 @@ openclaw gateway restart
430
483
  | `maxReconnectDelay` | number | `60000` | 最大重连延迟(毫秒) |
431
484
  | `reconnectJitter` | number | `0.3` | 重连延迟抖动因子(0-1) |
432
485
 
486
+ 关于 `displayNameResolution`:
487
+
488
+ - `disabled`:默认值。发送目标必须使用显式 ID,例如 `conversationId`、`staffId`、`user:manager8031`
489
+ - `all`:允许插件使用本地通讯录存储做群显示名/用户显示名解析
490
+ - learned directory 数据来自入站消息观测,并按 `accountId` 落盘到 `targets.directory`
491
+ - 当前上游 target resolver 还没有把 requester owner/authz 上下文传到插件,因此暂不提供 owner-only 模式
492
+ - 开启后存在误投风险:显示名可能重名、后来改名,或本地目录还停留在旧观测值
493
+ - 开启后存在权限扩散风险:当前 `all` 会对所有能进入发送链路的调用方生效,不是 owner-only
494
+ - 对敏感通知、不可撤回消息或高风险自动化,建议继续使用显式 ID 而不是显示名
495
+
433
496
  ### 钉钉原生“思考中”表情反馈
434
497
 
435
498
  当 `ackReaction` 为非空字符串时,插件会在处理开始时给用户原消息添加一条钉钉原生文本表情反馈,并在处理结束后自动撤回。该增强不会阻断主流程:贴表情或撤表情失败时只记录日志,仍继续正常回复。
@@ -443,9 +506,12 @@ openclaw gateway restart
443
506
  - 该反馈作用于用户原消息,不会额外发送一条“思考中”消息
444
507
  - 解析顺序与官方一致:`channels.dingtalk.accounts.<accountId>.ackReaction` -> `channels.dingtalk.ackReaction` -> `messages.ackReaction` -> `agents.list[].identity.emoji`
445
508
  - 若上述路径都未配置,则不发送 ack reaction
446
- - 当最终解析值为 `emoji` 时,钉钉插件会按当前输入语气自动选择一条颜文字 reaction
509
+ - 当最终解析值为 `emoji` 时,钉钉插件会贴固定的 `🤔思考中` 原生 reaction,并允许后续 tool-progress 流程动态切换
510
+ - 当最终解析值为 `kaomoji` 时,钉钉插件会先按输入语气选一条颜文字作为初始 reaction;若后续进入 `read/search/bash/write` 等工具阶段,仍会临时切换到对应的 tool-progress reaction
511
+ - 为兼容历史配置,`ackReaction: ""` 仍然表示关闭该能力;若你此前把 `ackReaction: "emoji"` 当作“按语气选颜文字”,升级后请改成 `ackReaction: "kaomoji"`
447
512
  - 当前钉钉实现底层走 `emotion/reply` / `emotion/recall`,会把解析出的 `ackReaction` 文本原样写入 `emotionName` / `textEmotion.emotionName`
448
513
  - 若配置值为 `🤔思考中`,效果与钉钉原生“思考中”反馈一致;配置为其他文本时,会按该文本发送对应的 ack reaction
514
+ - 钉钉 `emotion/reply` 对部分 kaomoji 存在兼容性限制。当前候选集已按真实 API 多轮复测做过筛选,并移除了会稳定触发 `500 system.err` 的字符串,例如 `٩(๑>◡<๑)۶`、`(•̀へ •́ ╮ )`、`(´• ω •`)`、`(づ。◕‿‿◕。)づ`、`(⁄ ⁄•⁄ω⁄•⁄ ⁄)`、`┌(┌ *`д´)┐`
449
515
 
450
516
  示例:
451
517
 
@@ -453,7 +519,7 @@ openclaw gateway restart
453
519
  {
454
520
  "channels": {
455
521
  "dingtalk": {
456
- "ackReaction": "emoji"
522
+ "ackReaction": "kaomoji"
457
523
  }
458
524
  }
459
525
  }
@@ -725,7 +791,53 @@ node scripts/feedback-learning-debug.mjs --storePath /path/to/session-store.json
725
791
  ### 群聊策略 (groupPolicy)
726
792
 
727
793
  - `open` — 任何群都可以 @机器人
728
- - `allowlist` — 只有配置的群可以使用
794
+ - `allowlist` — 只有配置的群可以使用(见下方详细说明)
795
+ - `disabled` — 完全禁用群聊消息(消息静默丢弃,不会发送拒绝提示)
796
+
797
+ #### allowlist 模式详解
798
+
799
+ 当 `groupPolicy` 设为 `"allowlist"` 时,群聊准入按以下顺序判定:
800
+
801
+ 1. **群 ID 检查**:`groups` 中存在该 `conversationId` 的配置 → 放行
802
+ 2. **通配符**:`groups` 中存在 `"*"` 配置 → 放行
803
+ 3. **旧版兜底**:`allowFrom` 中包含该群 ID → 放行(已弃用,会输出迁移提示)
804
+ 4. 以上均不匹配 → 拒绝
805
+
806
+ ##### 发送者白名单 (groupAllowFrom)
807
+
808
+ 群准入通过后,还可以限制允许发言的用户。优先级:
809
+
810
+ 1. `groups[conversationId].groupAllowFrom` — 群级白名单(最高优先)
811
+ 2. `groups["*"].groupAllowFrom` — 通配符级白名单
812
+ 3. 顶层 `groupAllowFrom` — 全局白名单
813
+ 4. 以上均未配置 → 不限制发送者
814
+
815
+ ##### 每群 requireMention
816
+
817
+ 可在 `groups` 中为每个群单独配置是否需要 @机器人才响应:
818
+
819
+ ```jsonc
820
+ {
821
+ "groupPolicy": "allowlist",
822
+ "groupAllowFrom": ["user-001", "user-002"], // 全局群聊发送者白名单
823
+ "groups": {
824
+ "cidXXX": {
825
+ "systemPrompt": "你是客服机器人", // 群级 system prompt
826
+ "requireMention": false, // 不需要 @,直接响应
827
+ "groupAllowFrom": ["user-003"] // 群级发送者白名单(覆盖全局)
828
+ },
829
+ "*": {
830
+ "requireMention": true // 其他群默认需要 @
831
+ }
832
+ }
833
+ }
834
+ ```
835
+
836
+ > 注意:钉钉群聊中不 @机器人时不会推送消息,因此 `requireMention: false` 在钉钉群聊中实际不生效。
837
+
838
+ > **迁移提示**:如果你目前使用 `allowFrom` 同时控制私聊用户和群聊群 ID,
839
+ > 建议将群 ID 迁移到 `groups` 配置中,将群聊发送者 ID 迁移到 `groupAllowFrom`。
840
+ > 旧的 `allowFrom` 群 ID 兜底机制仍可工作,但会在日志中输出弃用警告。
729
841
 
730
842
  ## 消息类型支持
731
843
 
@@ -928,6 +1040,10 @@ node scripts/feedback-learning-debug.mjs --storePath /path/to/session-store.json
928
1040
  2. 使用 `/v1.0/card/streaming` 实现流式更新
929
1041
  3. 自动状态管理(PROCESSING → INPUTING → FINISHED)
930
1042
  4. 内置 300ms 节流 + 单航班(single-flight)保护,避免 API 过载
1043
+ 5. 默认为 AI Card 投递开启 `dynamicSummary`,改善钉钉会话列表 `lastmsg` 预览随卡片正文更新的表现
1044
+
1045
+ > `dynamicSummary` 这项接入思路参考自 `DingTalk-Real-AI/dingtalk-openclaw-connector` 的 MIT 许可实现:
1046
+ > <https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector/commit/7aff25b1f77f4dbd5dd64673d4e726ca938f5498>
931
1047
 
932
1048
  **卡片流式模式 (`cardRealTimeStream`):**
933
1049
 
@@ -1343,15 +1459,28 @@ await sendProactiveMedia(config, 'cidxxxxxxxx', '/absolute/path/to/photo.png', '
1343
1459
  });
1344
1460
  ```
1345
1461
 
1346
- ### 架构
1462
+ ## 架构与职责边界
1463
+
1464
+ 仓库整体架构、模块职责边界、增量迁移策略和新功能落位建议,以 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 为准。
1465
+ 中文版本见 [`docs/ARCHITECTURE.zh-CN.md`](docs/ARCHITECTURE.zh-CN.md)。
1466
+
1467
+ 协作时建议优先把握这些总原则:
1468
+
1469
+ - 先遵守逻辑功能分区,再做物理目录迁移
1470
+ - `src/channel.ts` 保持为装配层,避免继续堆积业务逻辑
1471
+ - 新功能应优先落到清晰的业务域,而不是继续平铺到 `src/` 根目录
1472
+ - 结构重排尽量与行为改动拆分,降低进行中 PR 的冲突面
1473
+ - 对存量代码采用渐进迁移策略,不要求一次性整体搬迁
1347
1474
 
1348
- 插件遵循 Telegram 参考实现的架构模式:
1475
+ 计划中的目录分区摘要:
1349
1476
 
1350
- - **index.ts**: 最小化插件注册入口
1351
- - **src/channel.ts**: 所有 DingTalk 特定的逻辑(API、消息处理、配置等)
1352
- - **src/runtime.ts**: 运行时管理(getter/setter)
1353
- - **src/types.ts**: 类型定义
1354
- - **utils.ts**: 通用工具函数
1477
+ - `gateway/`: Stream 连接、回调注册、入站事件入口与启停时序
1478
+ - `targeting/`: `conversationId`、peer、session alias、目标解析与群目录能力
1479
+ - `messaging/`: 入站内容提取、reply strategy、文本/markdown/media 发送与消息上下文
1480
+ - `card/`: AI Card 创建、流式更新、结束态、恢复与缓存
1481
+ - `command/`: slash 命令、feedback learning、target rule 与后续扩展命令能力
1482
+ - `platform/`: config、auth、runtime、logger、types 等底层平台能力
1483
+ - `shared/`: 跨领域复用的持久化原语、dedup 与通用工具
1355
1484
 
1356
1485
  ## 测试
1357
1486
 
package/index.ts CHANGED
@@ -1,80 +1,85 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import * as pluginSdk from "openclaw/plugin-sdk";
3
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
1
+ import { defineChannelPluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import { readStringParam } from "openclaw/plugin-sdk/param-readers";
4
3
  import { dingtalkPlugin } from "./src/channel";
5
4
  import { getConfig } from "./src/config";
6
5
  import { appendToDoc, createDoc, DocCreateAppendError, listDocs, searchDocs } from "./src/docs-service";
7
6
  import { setDingTalkRuntime } from "./src/runtime";
8
- import type { DingtalkPluginModule } from "./src/types";
9
7
 
10
8
  type GatewayMethodContext = Pick<
11
9
  Parameters<Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1]>[0],
12
10
  "params" | "respond"
13
11
  >;
14
12
 
15
- const plugin: DingtalkPluginModule = {
13
+ function registerDingTalkDocsGatewayMethods(api: OpenClawPluginApi): void {
14
+ api.registerGatewayMethod("dingtalk.docs.create", async ({ respond, params }: GatewayMethodContext) => {
15
+ const accountId = readStringParam(params, "accountId");
16
+ const spaceId = readStringParam(params, "spaceId", { required: true });
17
+ const title = readStringParam(params, "title", { required: true });
18
+ const content = readStringParam(params, "content", { allowEmpty: true });
19
+ const parentId = readStringParam(params, "parentId");
20
+ const config = getConfig(api.config, accountId ?? undefined);
21
+ try {
22
+ const doc = await createDoc(
23
+ config,
24
+ spaceId,
25
+ title,
26
+ content ?? undefined,
27
+ api.logger,
28
+ parentId ?? undefined,
29
+ );
30
+ return respond(true, doc);
31
+ } catch (error) {
32
+ if (error instanceof DocCreateAppendError) {
33
+ return respond(true, {
34
+ partialSuccess: true,
35
+ initContentAppended: false,
36
+ docId: error.doc.docId,
37
+ doc: error.doc,
38
+ appendError: error.message,
39
+ });
40
+ }
41
+ throw error;
42
+ }
43
+ });
44
+
45
+ api.registerGatewayMethod("dingtalk.docs.append", async ({ respond, params }: GatewayMethodContext) => {
46
+ const accountId = readStringParam(params, "accountId");
47
+ const docId = readStringParam(params, "docId", { required: true });
48
+ const content = readStringParam(params, "content", { required: true, allowEmpty: false });
49
+ const config = getConfig(api.config, accountId ?? undefined);
50
+ const result = await appendToDoc(config, docId, content, api.logger);
51
+ return respond(true, result);
52
+ });
53
+
54
+ api.registerGatewayMethod("dingtalk.docs.search", async ({ respond, params }: GatewayMethodContext) => {
55
+ const accountId = readStringParam(params, "accountId");
56
+ const keyword = readStringParam(params, "keyword", { required: true });
57
+ const spaceId = readStringParam(params, "spaceId");
58
+ const config = getConfig(api.config, accountId ?? undefined);
59
+ const docs = await searchDocs(config, keyword, spaceId, api.logger);
60
+ return respond(true, { docs });
61
+ });
62
+
63
+ api.registerGatewayMethod("dingtalk.docs.list", async ({ respond, params }: GatewayMethodContext) => {
64
+ const accountId = readStringParam(params, "accountId");
65
+ const spaceId = readStringParam(params, "spaceId", { required: true });
66
+ const parentId = readStringParam(params, "parentId");
67
+ const config = getConfig(api.config, accountId ?? undefined);
68
+ const docs = await listDocs(config, spaceId, parentId, api.logger);
69
+ return respond(true, { docs });
70
+ });
71
+ }
72
+
73
+ export { dingtalkPlugin } from "./src/channel";
74
+ export { setDingTalkRuntime } from "./src/runtime";
75
+
76
+ export default defineChannelPluginEntry({
16
77
  id: "dingtalk",
17
78
  name: "DingTalk Channel",
18
79
  description: "DingTalk (钉钉) messaging channel via Stream mode",
19
- configSchema: emptyPluginConfigSchema(),
20
- register(api: OpenClawPluginApi): void {
21
- setDingTalkRuntime(api.runtime);
22
- api.registerChannel({ plugin: dingtalkPlugin });
23
- api.registerGatewayMethod("dingtalk.docs.create", async ({ respond, params }: GatewayMethodContext) => {
24
- const accountId = pluginSdk.readStringParam(params, "accountId");
25
- const spaceId = pluginSdk.readStringParam(params, "spaceId", { required: true });
26
- const title = pluginSdk.readStringParam(params, "title", { required: true });
27
- const content = pluginSdk.readStringParam(params, "content", { allowEmpty: true });
28
- const parentId = pluginSdk.readStringParam(params, "parentId");
29
- const config = getConfig(api.config, accountId ?? undefined);
30
- try {
31
- const doc = await createDoc(
32
- config,
33
- spaceId,
34
- title,
35
- content ?? undefined,
36
- api.logger,
37
- parentId ?? undefined,
38
- );
39
- return respond(true, doc);
40
- } catch (error) {
41
- if (error instanceof DocCreateAppendError) {
42
- return respond(true, {
43
- partialSuccess: true,
44
- initContentAppended: false,
45
- docId: error.doc.docId,
46
- doc: error.doc,
47
- appendError: error.message,
48
- });
49
- }
50
- throw error;
51
- }
52
- });
53
- api.registerGatewayMethod("dingtalk.docs.append", async ({ respond, params }: GatewayMethodContext) => {
54
- const accountId = pluginSdk.readStringParam(params, "accountId");
55
- const docId = pluginSdk.readStringParam(params, "docId", { required: true });
56
- const content = pluginSdk.readStringParam(params, "content", { required: true, allowEmpty: false });
57
- const config = getConfig(api.config, accountId ?? undefined);
58
- const result = await appendToDoc(config, docId, content, api.logger);
59
- return respond(true, result);
60
- });
61
- api.registerGatewayMethod("dingtalk.docs.search", async ({ respond, params }: GatewayMethodContext) => {
62
- const accountId = pluginSdk.readStringParam(params, "accountId");
63
- const keyword = pluginSdk.readStringParam(params, "keyword", { required: true });
64
- const spaceId = pluginSdk.readStringParam(params, "spaceId");
65
- const config = getConfig(api.config, accountId ?? undefined);
66
- const docs = await searchDocs(config, keyword, spaceId, api.logger);
67
- return respond(true, { docs });
68
- });
69
- api.registerGatewayMethod("dingtalk.docs.list", async ({ respond, params }: GatewayMethodContext) => {
70
- const accountId = pluginSdk.readStringParam(params, "accountId");
71
- const spaceId = pluginSdk.readStringParam(params, "spaceId", { required: true });
72
- const parentId = pluginSdk.readStringParam(params, "parentId");
73
- const config = getConfig(api.config, accountId ?? undefined);
74
- const docs = await listDocs(config, spaceId, parentId, api.logger);
75
- return respond(true, { docs });
76
- });
80
+ plugin: dingtalkPlugin,
81
+ setRuntime: setDingTalkRuntime,
82
+ registerFull(api) {
83
+ registerDingTalkDocsGatewayMethods(api);
77
84
  },
78
- };
79
-
80
- export default plugin;
85
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "files": [
22
22
  "index.ts",
23
- "src/*.ts",
23
+ "src/**/*.ts",
24
24
  "openclaw.plugin.json",
25
25
  "clawbot.plugin.json"
26
26
  ],
@@ -51,6 +51,7 @@
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.2.0",
53
53
  "@vitest/coverage-v8": "^3.2.4",
54
+ "openclaw": "2026.3.22",
54
55
  "oxfmt": "0.34.0",
55
56
  "oxlint": "^1.49.0",
56
57
  "oxlint-tsgolint": "^0.15.0",
@@ -58,7 +59,7 @@
58
59
  "vitest": "^3.2.4"
59
60
  },
60
61
  "peerDependencies": {
61
- "openclaw": ">=2026.2.13"
62
+ "openclaw": ">=2026.3.14"
62
63
  },
63
64
  "openclaw": {
64
65
  "extensions": [
@@ -72,8 +73,8 @@
72
73
  "id": "dingtalk",
73
74
  "label": "DingTalk",
74
75
  "selectionLabel": "DingTalk (钉钉)",
75
- "docsPath": "/channels/dingtalk",
76
- "docsLabel": "dingtalk",
76
+ "docsPath": "https://github.com/soimy/openclaw-channel-dingtalk",
77
+ "docsLabel": "plugin docs",
77
78
  "blurb": "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
78
79
  "order": 70,
79
80
  "aliases": [
@@ -54,6 +54,71 @@ export function isSenderGroupAllowed(params: {
54
54
  return false;
55
55
  }
56
56
 
57
+ export type GroupAccessResult = {
58
+ allowed: boolean;
59
+ reason?: "disabled" | "group_not_allowed" | "sender_not_allowed";
60
+ legacyFallback?: boolean;
61
+ };
62
+
63
+ export type GroupAccessParams = {
64
+ groupPolicy: "open" | "allowlist" | "disabled";
65
+ groupId: string;
66
+ senderId: string;
67
+ groups?: Record<string, { groupAllowFrom?: string[] }>;
68
+ groupAllowFrom?: string[];
69
+ allowFrom?: string[]; // legacy fallback
70
+ };
71
+
72
+ export function resolveGroupAccess(params: GroupAccessParams): GroupAccessResult {
73
+ const { groupPolicy, groupId, senderId, groups, groupAllowFrom, allowFrom } = params;
74
+
75
+ // Step 1: disabled → block all
76
+ if (groupPolicy === "disabled") {
77
+ return { allowed: false, reason: "disabled" };
78
+ }
79
+
80
+ // Step 2: allowlist → group ID check
81
+ let legacyFallback = false;
82
+ if (groupPolicy === "allowlist") {
83
+ const groupConfig = groups?.[groupId];
84
+ const wildcardConfig = groups?.["*"];
85
+ const groupInConfig = groupConfig !== undefined || wildcardConfig !== undefined;
86
+
87
+ if (!groupInConfig) {
88
+ // Legacy fallback: check allowFrom for group ID
89
+ const legacyAllow = normalizeAllowFrom(allowFrom);
90
+ if (isSenderGroupAllowed({ allow: legacyAllow, groupId })) {
91
+ legacyFallback = true;
92
+ // Continue to sender check below
93
+ } else {
94
+ return { allowed: false, reason: "group_not_allowed" };
95
+ }
96
+ }
97
+ }
98
+
99
+ // Step 3: Sender check
100
+ // Priority: per-group > wildcard > top-level
101
+ const perGroupAllowFrom = groups?.[groupId]?.groupAllowFrom;
102
+ const wildcardAllowFrom = groups?.["*"]?.groupAllowFrom;
103
+ const effectiveAllowFrom = perGroupAllowFrom ?? wildcardAllowFrom ?? groupAllowFrom;
104
+
105
+ if (effectiveAllowFrom == null) {
106
+ return { allowed: true, legacyFallback: legacyFallback || undefined };
107
+ }
108
+
109
+ // Empty array = block all senders (fail-closed per official design)
110
+ if (effectiveAllowFrom.length === 0) {
111
+ return { allowed: false, reason: "sender_not_allowed" };
112
+ }
113
+
114
+ const normalized = normalizeAllowFrom(effectiveAllowFrom);
115
+ if (isSenderAllowed({ allow: normalized, senderId })) {
116
+ return { allowed: true, legacyFallback: legacyFallback || undefined };
117
+ }
118
+
119
+ return { allowed: false, reason: "sender_not_allowed" };
120
+ }
121
+
57
122
  export function isSenderOwner(params: {
58
123
  allow: NormalizedAllowFrom;
59
124
  senderId?: string;