@xmanrui/dsh-im 4.0.0 → 4.1.0

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 (74) hide show
  1. package/PROACTIVE_DELIVERY.en.md +321 -0
  2. package/PROACTIVE_DELIVERY.md +321 -0
  3. package/README.en.md +11 -5
  4. package/README.md +11 -5
  5. package/lib/client.js +1200 -110
  6. package/lib/index.js +229 -229
  7. package/package.json +4 -1
  8. package/plugin-src/client/channel-card-meta.js +44 -0
  9. package/plugin-src/client/channels/dingtalk/index.js +16 -8
  10. package/plugin-src/client/channels/feishu/index.js +17 -9
  11. package/plugin-src/client/channels/qq/index.js +16 -8
  12. package/plugin-src/client/channels/shared/token-channel.js +16 -8
  13. package/plugin-src/client/channels/wecom/index.js +16 -8
  14. package/plugin-src/client/channels/weixin/index.js +16 -8
  15. package/plugin-src/client/channels/whatsapp/index.js +16 -8
  16. package/plugin-src/client/delivery-settings.js +749 -0
  17. package/plugin-src/client/i18n.js +82 -0
  18. package/plugin-src/client/index.js +59 -22
  19. package/plugin-src/client/styles.js +72 -0
  20. package/plugin-src/host/channels/dingtalk/index.mjs +3 -0
  21. package/plugin-src/host/channels/dingtalk/production.mjs +4 -0
  22. package/plugin-src/host/channels/discord/index.mjs +6 -1
  23. package/plugin-src/host/channels/feishu/index.mjs +3 -0
  24. package/plugin-src/host/channels/feishu/production.mjs +4 -0
  25. package/plugin-src/host/channels/qq/index.mjs +6 -1
  26. package/plugin-src/host/channels/qq/production.mjs +2 -0
  27. package/plugin-src/host/channels/shared/production.mjs +7 -0
  28. package/plugin-src/host/channels/slack/index.mjs +6 -1
  29. package/plugin-src/host/channels/slack/production.mjs +4 -0
  30. package/plugin-src/host/channels/telegram/index.mjs +6 -1
  31. package/plugin-src/host/channels/wecom/index.mjs +6 -1
  32. package/plugin-src/host/channels/wecom/production.mjs +4 -0
  33. package/plugin-src/host/channels/weixin/index.mjs +3 -0
  34. package/plugin-src/host/channels/weixin/production.mjs +4 -0
  35. package/plugin-src/host/channels/whatsapp/index.mjs +6 -1
  36. package/plugin-src/host/channels/whatsapp/production.mjs +4 -0
  37. package/plugin-src/host/delivery-adapter.mjs +179 -0
  38. package/plugin-src/host/delivery-http.mjs +132 -0
  39. package/plugin-src/host/delivery-rpc.mjs +158 -0
  40. package/plugin-src/host/delivery-service.mjs +224 -0
  41. package/plugin-src/host/delivery-suggestions.mjs +135 -0
  42. package/plugin-src/host/index.mjs +32 -5
  43. package/scripts/verify-package.mjs +3 -0
  44. package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
  45. package/src/channels/dingtalk/dingtalk-bridge.mjs +2 -2
  46. package/src/channels/dingtalk/dingtalk-controller.mjs +14 -0
  47. package/src/channels/dingtalk/dingtalk-runtime.mjs +39 -0
  48. package/src/channels/discord/discord-runtime.mjs +16 -0
  49. package/src/channels/feishu/bridge.mjs +4 -4
  50. package/src/channels/feishu/feishu-cards.mjs +4 -4
  51. package/src/channels/feishu/feishu-runtime.mjs +35 -0
  52. package/src/channels/feishu/multi-bot-controller.mjs +15 -0
  53. package/src/channels/qq/qq-bridge.mjs +2 -2
  54. package/src/channels/qq/qq-controller.mjs +14 -0
  55. package/src/channels/qq/qq-runtime.mjs +23 -0
  56. package/src/channels/shared/bot-workspace-store.mjs +192 -6
  57. package/src/channels/shared/i18n-en/feishu.mjs +6 -2
  58. package/src/channels/shared/i18n-en/shared-a.mjs +4 -0
  59. package/src/channels/shared/preset-command.mjs +2 -2
  60. package/src/channels/shared/text-harness-bridge.mjs +7 -2
  61. package/src/channels/shared/token-bot-controller.mjs +16 -0
  62. package/src/channels/shared/workspace-command.mjs +1 -1
  63. package/src/channels/slack/slack-controller.mjs +14 -0
  64. package/src/channels/slack/slack-runtime.mjs +24 -0
  65. package/src/channels/telegram/telegram-runtime.mjs +26 -0
  66. package/src/channels/wecom/state-store.mjs +4 -0
  67. package/src/channels/wecom/wecom-bridge.mjs +2 -2
  68. package/src/channels/wecom/wecom-controller.mjs +14 -0
  69. package/src/channels/wecom/wecom-runtime.mjs +21 -0
  70. package/src/channels/weixin/weixin-bridge.mjs +2 -2
  71. package/src/channels/weixin/weixin-controller.mjs +14 -0
  72. package/src/channels/weixin/weixin-runtime.mjs +24 -0
  73. package/src/channels/whatsapp/whatsapp-controller.mjs +14 -0
  74. package/src/channels/whatsapp/whatsapp-runtime.mjs +19 -0
@@ -0,0 +1,224 @@
1
+ import { normalizeDeliveryTarget } from './delivery-adapter.mjs';
2
+
3
+ const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
4
+ const TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/;
5
+ const CHANNEL_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
6
+ const DRAFT_TARGET_ID = '__test__';
7
+
8
+ const ADAPTER_METHODS = Object.freeze([
9
+ 'ownsBot',
10
+ 'listTargets',
11
+ 'listSuggestions',
12
+ 'createTarget',
13
+ 'updateTarget',
14
+ 'deleteTarget',
15
+ 'sendText',
16
+ ]);
17
+
18
+ const DELIVERY_ERROR_CODES = new Set([
19
+ 'bad-request',
20
+ 'unknown-bot',
21
+ 'unknown-target',
22
+ 'target-conflict',
23
+ 'invalid-target',
24
+ 'bot-not-connected',
25
+ 'target-rejected',
26
+ 'delivery-failed',
27
+ 'cancelled',
28
+ ]);
29
+
30
+ function deliveryError(code, message = code, options) {
31
+ const error = new Error(message, options);
32
+ error.code = code;
33
+ return error;
34
+ }
35
+
36
+ function botIdOf(value) {
37
+ if (typeof value !== 'string' || !BOT_ID_PATTERN.test(value)) {
38
+ throw deliveryError('bad-request', 'Invalid bot id');
39
+ }
40
+ return value;
41
+ }
42
+
43
+ function targetIdOf(value) {
44
+ if (typeof value !== 'string' || !TARGET_ID_PATTERN.test(value)) {
45
+ throw deliveryError('bad-request', 'Invalid target id');
46
+ }
47
+ return value;
48
+ }
49
+
50
+ function targetObject(value, { includesTargetId } = {}) {
51
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
52
+ throw deliveryError('bad-request', 'Invalid target');
53
+ }
54
+ if (includesTargetId) targetIdOf(value.targetId);
55
+ else if (Object.hasOwn(value, 'targetId')) {
56
+ throw deliveryError('bad-request', 'A target id cannot be changed');
57
+ }
58
+ return value;
59
+ }
60
+
61
+ function draftTargetObject(value) {
62
+ targetObject(value);
63
+ const keys = Object.keys(value);
64
+ if (keys.length !== 2 || !keys.includes('kind') || !keys.includes('route')) {
65
+ throw deliveryError('bad-request', 'Invalid draft target');
66
+ }
67
+ return value;
68
+ }
69
+
70
+ function cancellation(signal) {
71
+ if (signal?.aborted) throw deliveryError('cancelled', 'Request cancelled');
72
+ }
73
+
74
+ function publicOperationError(error, fallback = 'delivery-failed') {
75
+ if (error?.code === 'workspace-bot-not-found') {
76
+ return deliveryError('unknown-bot', 'Unknown bot', { cause: error });
77
+ }
78
+ if (DELIVERY_ERROR_CODES.has(error?.code)) return error;
79
+ return deliveryError(fallback, fallback, { cause: error });
80
+ }
81
+
82
+ function validateAdapter(adapter) {
83
+ if (!adapter || typeof adapter !== 'object'
84
+ || typeof adapter.channel !== 'string' || !CHANNEL_PATTERN.test(adapter.channel)) {
85
+ throw new TypeError('A delivery adapter with a valid channel is required');
86
+ }
87
+ for (const method of ADAPTER_METHODS) {
88
+ if (typeof adapter[method] !== 'function') {
89
+ throw new TypeError(`A complete delivery adapter is required (${method})`);
90
+ }
91
+ }
92
+ return adapter;
93
+ }
94
+
95
+ export class DeliveryService {
96
+ #adapters = new Map();
97
+
98
+ registerAdapter(value) {
99
+ const adapter = validateAdapter(value);
100
+ const registration = Object.freeze({ adapter });
101
+ this.#adapters.set(adapter.channel, registration);
102
+ return () => {
103
+ if (this.#adapters.get(adapter.channel) !== registration) return false;
104
+ this.#adapters.delete(adapter.channel);
105
+ return true;
106
+ };
107
+ }
108
+
109
+ async listTargets(botId) {
110
+ const id = botIdOf(botId);
111
+ const adapter = await this.#adapterFor(id);
112
+ try {
113
+ const targets = await adapter.listTargets(id);
114
+ if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
115
+ return { botId: id, channel: adapter.channel, targets };
116
+ } catch (error) {
117
+ throw publicOperationError(error);
118
+ }
119
+ }
120
+
121
+ async listSuggestions(botId) {
122
+ const id = botIdOf(botId);
123
+ const adapter = await this.#adapterFor(id);
124
+ try {
125
+ const suggestions = await adapter.listSuggestions(id);
126
+ if (!Array.isArray(suggestions)) throw new TypeError('Adapter returned invalid suggestions');
127
+ return {
128
+ botId: id,
129
+ channel: adapter.channel,
130
+ suggestions: suggestions.map((suggestion) => normalizeDeliveryTarget(
131
+ adapter.channel,
132
+ suggestion,
133
+ { targetIdRequired: false },
134
+ )),
135
+ };
136
+ } catch (error) {
137
+ throw publicOperationError(error);
138
+ }
139
+ }
140
+
141
+ async createTarget(botId, target) {
142
+ const id = botIdOf(botId);
143
+ targetObject(target, { includesTargetId: true });
144
+ const adapter = await this.#adapterFor(id);
145
+ try {
146
+ return await adapter.createTarget(id, target);
147
+ } catch (error) {
148
+ throw publicOperationError(error);
149
+ }
150
+ }
151
+
152
+ async updateTarget(botId, targetId, replacement) {
153
+ const id = botIdOf(botId);
154
+ const targetKey = targetIdOf(targetId);
155
+ targetObject(replacement);
156
+ const adapter = await this.#adapterFor(id);
157
+ try {
158
+ return await adapter.updateTarget(id, targetKey, replacement);
159
+ } catch (error) {
160
+ throw publicOperationError(error);
161
+ }
162
+ }
163
+
164
+ async deleteTarget(botId, targetId) {
165
+ const id = botIdOf(botId);
166
+ const targetKey = targetIdOf(targetId);
167
+ const adapter = await this.#adapterFor(id);
168
+ try {
169
+ await adapter.deleteTarget(id, targetKey);
170
+ return { deleted: true };
171
+ } catch (error) {
172
+ throw publicOperationError(error);
173
+ }
174
+ }
175
+
176
+ async send(botId, targetIdOrDraft, text, { signal } = {}) {
177
+ const id = botIdOf(botId);
178
+ const targetKey = typeof targetIdOrDraft === 'string'
179
+ ? targetIdOf(targetIdOrDraft)
180
+ : null;
181
+ const draft = targetKey === null ? draftTargetObject(targetIdOrDraft) : null;
182
+ if (typeof text !== 'string' || !text.trim()) {
183
+ throw deliveryError('bad-request', 'Message text is required');
184
+ }
185
+ cancellation(signal);
186
+ const adapter = await this.#adapterFor(id);
187
+ try {
188
+ let target;
189
+ if (draft) {
190
+ target = { targetId: DRAFT_TARGET_ID, ...draft };
191
+ } else {
192
+ const targets = await adapter.listTargets(id);
193
+ if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
194
+ target = targets.find((candidate) => candidate?.targetId === targetKey);
195
+ if (!target) throw deliveryError('unknown-target', 'Unknown target');
196
+ }
197
+ cancellation(signal);
198
+ await adapter.sendText(id, target, text, { signal });
199
+ return { sent: true };
200
+ } catch (error) {
201
+ if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
202
+ throw deliveryError('cancelled', 'Request cancelled', { cause: error });
203
+ }
204
+ throw publicOperationError(error);
205
+ }
206
+ }
207
+
208
+ async #adapterFor(botId) {
209
+ for (const { adapter } of this.#adapters.values()) {
210
+ let ownsBot;
211
+ try {
212
+ ownsBot = await adapter.ownsBot(botId);
213
+ } catch (error) {
214
+ throw publicOperationError(error);
215
+ }
216
+ if (ownsBot) return adapter;
217
+ }
218
+ throw deliveryError('unknown-bot', 'Unknown bot');
219
+ }
220
+ }
221
+
222
+ export function createDeliveryService() {
223
+ return new DeliveryService();
224
+ }
@@ -0,0 +1,135 @@
1
+ const CHANNELS = new Set([
2
+ 'weixin',
3
+ 'feishu',
4
+ 'dingtalk',
5
+ 'wecom',
6
+ 'qq',
7
+ 'slack',
8
+ 'telegram',
9
+ 'discord',
10
+ 'whatsapp',
11
+ ]);
12
+
13
+ function isRecord(value) {
14
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
15
+ }
16
+
17
+ function opaqueId(value) {
18
+ return typeof value === 'string'
19
+ && value.length > 0
20
+ && value.length <= 512
21
+ && value.trim() === value
22
+ && !/[\s:\u0000-\u001f\u007f]/u.test(value);
23
+ }
24
+
25
+ function afterPrefix(key, prefix) {
26
+ const marker = `${prefix}:`;
27
+ if (!key.startsWith(marker)) return null;
28
+ const value = key.slice(marker.length);
29
+ return opaqueId(value) ? value : null;
30
+ }
31
+
32
+ function simpleSuggestion(key, definitions) {
33
+ for (const [prefix, kind, field] of definitions) {
34
+ const value = afterPrefix(key, prefix);
35
+ if (value) return { kind, route: { [field]: value } };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function feishuSuggestion(key) {
41
+ const openId = afterPrefix(key, 'p2p');
42
+ if (openId?.startsWith('ou_')) {
43
+ return { kind: 'user', route: { openId } };
44
+ }
45
+ return simpleSuggestion(key, [['group', 'group', 'chatId']]);
46
+ }
47
+
48
+ function slackSuggestion(key) {
49
+ const directChannel = afterPrefix(key, 'direct');
50
+ if (directChannel && /^[A-Za-z0-9_-]{1,128}$/.test(directChannel)) {
51
+ return { kind: 'conversation', route: { channelId: directChannel } };
52
+ }
53
+ const match = /^group:([^:]+):(\d{1,20}(?:\.\d{1,20})?)$/.exec(key);
54
+ if (!match || !/^[A-Za-z0-9_-]{1,128}$/.test(match[1])) return null;
55
+ return { kind: 'thread', route: { channelId: match[1], threadTs: match[2] } };
56
+ }
57
+
58
+ function telegramSuggestion(key) {
59
+ const match = /^(direct|group):(-?\d+)(?::([1-9]\d*))?$/.exec(key);
60
+ if (!match) return null;
61
+ const chatId = Number(match[2]);
62
+ if (!Number.isSafeInteger(chatId)) return null;
63
+ if (match[1] === 'direct' && match[3] !== undefined) return null;
64
+ if (match[3] === undefined) return { kind: 'chat', route: { chatId: match[2] } };
65
+ const messageThreadId = Number(match[3]);
66
+ if (!Number.isSafeInteger(messageThreadId) || messageThreadId <= 0) return null;
67
+ return { kind: 'topic', route: { chatId: match[2], messageThreadId } };
68
+ }
69
+
70
+ function discordSuggestion(key) {
71
+ const match = /^(?:direct|group):(\d{1,32})$/.exec(key);
72
+ return match ? { kind: 'channel', route: { channelId: match[1] } } : null;
73
+ }
74
+
75
+ function whatsappSuggestion(key) {
76
+ const direct = /^direct:(\d{5,32}@(s\.whatsapp\.net|lid))$/.exec(key);
77
+ if (direct) return { kind: 'user', route: { jid: direct[1] } };
78
+ const group = /^group:(\d{5,32}(?:-\d{1,32})?@g\.us)$/.exec(key);
79
+ return group ? { kind: 'group', route: { jid: group[1] } } : null;
80
+ }
81
+
82
+ /** Convert one persisted conversation key into a stable proactive-delivery route. */
83
+ export function deliverySuggestionFromConversationKey(channel, key) {
84
+ if (!CHANNELS.has(channel) || typeof key !== 'string') return null;
85
+ switch (channel) {
86
+ case 'weixin':
87
+ return simpleSuggestion(key, [['p2p', 'user', 'toUserId']]);
88
+ case 'feishu':
89
+ return feishuSuggestion(key);
90
+ case 'dingtalk':
91
+ return simpleSuggestion(key, [
92
+ ['p2p', 'user', 'userId'],
93
+ ['group', 'group', 'openConversationId'],
94
+ ]);
95
+ case 'wecom':
96
+ return simpleSuggestion(key, [
97
+ ['direct', 'user', 'chatId'],
98
+ ['group', 'group', 'chatId'],
99
+ ]);
100
+ case 'qq':
101
+ return simpleSuggestion(key, [
102
+ ['c2c', 'user', 'userOpenId'],
103
+ ['group', 'group', 'groupOpenId'],
104
+ ]);
105
+ case 'slack':
106
+ return slackSuggestion(key);
107
+ case 'telegram':
108
+ return telegramSuggestion(key);
109
+ case 'discord':
110
+ return discordSuggestion(key);
111
+ case 'whatsapp':
112
+ return whatsappSuggestion(key);
113
+ default:
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Extract and de-duplicate stable delivery routes from a persisted sessions map.
120
+ * Session ids and every other state field are intentionally ignored.
121
+ */
122
+ export function deliverySuggestionsFromSessions(channel, sessions) {
123
+ if (!CHANNELS.has(channel) || !isRecord(sessions)) return [];
124
+ const suggestions = [];
125
+ const seen = new Set();
126
+ for (const key of Object.keys(sessions)) {
127
+ const suggestion = deliverySuggestionFromConversationKey(channel, key);
128
+ if (!suggestion) continue;
129
+ const identity = JSON.stringify(suggestion);
130
+ if (seen.has(identity)) continue;
131
+ seen.add(identity);
132
+ suggestions.push(suggestion);
133
+ }
134
+ return suggestions;
135
+ }
@@ -10,6 +10,9 @@ import { apply as applyWeixin } from './channels/weixin/index.mjs';
10
10
  import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
11
11
  import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
12
12
  import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
13
+ import { installDeliveryRpc } from './delivery-rpc.mjs';
14
+ import { installDeliveryHttp } from './delivery-http.mjs';
15
+ import { createDeliveryService } from './delivery-service.mjs';
13
16
  import { installUpdateRpc } from './update-rpc.mjs';
14
17
 
15
18
  export const name = 'dsh-im-host';
@@ -19,15 +22,19 @@ export const inject = [
19
22
  'typertGateway',
20
23
  ];
21
24
 
22
- function channelConfig(config, name) {
25
+ function channelConfig(config, name, deliveryService) {
23
26
  const channel = config[name] ?? {};
24
- return config.rpcAuthority === undefined
27
+ const withAuthority = config.rpcAuthority === undefined
25
28
  ? channel
26
29
  : { ...channel, rpcAuthority: config.rpcAuthority };
30
+ return name === 'office' ? withAuthority : { ...withAuthority, deliveryService };
27
31
  }
28
32
 
29
33
  export function createImHostPlugin(internals = {}) {
30
34
  const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
35
+ const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
36
+ const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
37
+ const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
31
38
  const startFeishu = internals.applyFeishu ?? applyFeishu;
32
39
  const startWeixin = internals.applyWeixin ?? applyWeixin;
33
40
  const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
@@ -54,8 +61,17 @@ export function createImHostPlugin(internals = {}) {
54
61
  name,
55
62
  inject,
56
63
  async apply(ctx, config = {}) {
64
+ const deliveryService = makeDeliveryService();
65
+ if (typeof ctx?.provide === 'function') {
66
+ ctx.provide('dshIm', Object.freeze({
67
+ send: (botId, targetId, text, options) => (
68
+ deliveryService.send(botId, targetId, text, options)
69
+ ),
70
+ listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
71
+ }));
72
+ }
57
73
  const activate = async (readyCtx) => {
58
- await activateChannels(readyCtx, config);
74
+ await activateChannels(readyCtx, config, deliveryService);
59
75
  };
60
76
  if (typeof ctx?.inject === 'function') {
61
77
  const modern = typeof ctx?.typertGateway?.stream === 'function';
@@ -63,13 +79,19 @@ export function createImHostPlugin(internals = {}) {
63
79
  modern ? ['sessionController', 'workspaceController'] : ['apiProxy'],
64
80
  activate,
65
81
  );
82
+ ctx.inject(['webServer'], (httpCtx) => {
83
+ startDeliveryHttp(httpCtx, deliveryService);
84
+ });
66
85
  return;
67
86
  }
68
87
  await activate(ctx);
88
+ if (ctx?.webServer?.register && typeof ctx?.effect === 'function') {
89
+ startDeliveryHttp(ctx, deliveryService);
90
+ }
69
91
  },
70
92
  });
71
93
 
72
- async function activateChannels(ctx, config) {
94
+ async function activateChannels(ctx, config, deliveryService) {
73
95
  setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
74
96
  if (typeof ctx?.inject === 'function') {
75
97
  ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
@@ -87,11 +109,16 @@ export function createImHostPlugin(internals = {}) {
87
109
  } catch (error) {
88
110
  logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
89
111
  }
112
+ try {
113
+ startDelivery(ctx, deliveryService, { authority: config.rpcAuthority });
114
+ } catch (error) {
115
+ logger.error?.('[dsh-im] failed to activate delivery management; continuing with channels', error);
116
+ }
90
117
  }
91
118
  const failures = [];
92
119
  for (const [channel, start] of channels) {
93
120
  try {
94
- await start(ctx, channelConfig(config, channel));
121
+ await start(ctx, channelConfig(config, channel, deliveryService));
95
122
  } catch (error) {
96
123
  failures.push(error);
97
124
  logger.error?.(`[dsh-im] failed to activate ${channel}; continuing with the remaining channels`, error);
@@ -24,6 +24,9 @@ const required = [
24
24
  'bin/dsh-im.mjs',
25
25
  'cordis.patch.yml',
26
26
  'README.md',
27
+ 'README.en.md',
28
+ 'PROACTIVE_DELIVERY.md',
29
+ 'PROACTIVE_DELIVERY.en.md',
27
30
  'THIRD_PARTY_NOTICES.md',
28
31
  'plugin-src/client/channels/dingtalk/index.js',
29
32
  'plugin-src/client/channels/slack/index.js',
@@ -1049,6 +1049,39 @@ export function createDingtalkApi({
1049
1049
  return true;
1050
1050
  },
1051
1051
 
1052
+ async sendRobotText({ clientId, clientSecret, target, text, signal }) {
1053
+ if (typeof text !== 'string' || !text.trim()) throw new TypeError('text is required');
1054
+ const content = text;
1055
+ const normalizedTarget = normalizeFileTarget(target);
1056
+ const token = await accessToken({ clientId, clientSecret, signal });
1057
+ const body = {
1058
+ robotCode: normalizedTarget.robotCode,
1059
+ msgKey: 'sampleText',
1060
+ msgParam: JSON.stringify({ content }),
1061
+ ...(normalizedTarget.type === 'group'
1062
+ ? { openConversationId: normalizedTarget.openConversationId }
1063
+ : { userIds: [normalizedTarget.userId] }),
1064
+ };
1065
+ const pathname = normalizedTarget.type === 'group'
1066
+ ? 'v1.0/robot/groupMessages/send'
1067
+ : 'v1.0/robot/oToMessages/batchSend';
1068
+ const response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
1069
+ body,
1070
+ headers: { 'x-acs-dingtalk-access-token': token },
1071
+ signal,
1072
+ action: '主动文字消息发送',
1073
+ });
1074
+ const rejection = rejectedProviderResponse(response);
1075
+ if (rejection) {
1076
+ throw new DingtalkApiError(
1077
+ 'send-rejected',
1078
+ '钉钉服务拒绝了主动文字消息。',
1079
+ { providerCode: rejection },
1080
+ );
1081
+ }
1082
+ return response;
1083
+ },
1084
+
1052
1085
  async sendFile(request) {
1053
1086
  return sendArtifact(request, {
1054
1087
  uploadType: 'file',
@@ -72,14 +72,14 @@ const HELP_TEXT_LINES = [
72
72
  '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
73
73
  '/workspace 工作区绝对路径 切换工作区',
74
74
  '/workspacelist 列出工作区绝对路径',
75
- '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
75
+ '/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
76
76
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
77
77
  '/models 按序号列出所有可用模型',
78
78
  '/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
79
79
  '/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
80
80
  '/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
81
81
  '示例:先发 /models,再发 /model 2 [推理等级ID]',
82
- '/presetlist 按序号列出可用 Agent Preset',
82
+ '/presetlist 或 /presets 按序号列出可用 Agent Preset',
83
83
  '/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
84
84
  '纯数字 ID:/preset id:<ID>',
85
85
  '/preset --default 跟随 Host 默认',
@@ -406,6 +406,20 @@ export class DingtalkController {
406
406
  });
407
407
  }
408
408
 
409
+ async sendProactiveText(botId, target, text, options = {}) {
410
+ const config = this.#configStore.get(botId);
411
+ if (!config) throw new Error('Unknown DingTalk bot');
412
+ return this.#withBotTransition(botId, async () => {
413
+ const runtime = this.#runtimes.get(botId);
414
+ if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
415
+ const error = new Error(t('钉钉消息连接当前离线'));
416
+ error.code = 'bot-not-connected';
417
+ throw error;
418
+ }
419
+ return runtime.sendProactiveText(target, text, options);
420
+ });
421
+ }
422
+
409
423
  /** Removes one bot, its secret, runtime, and local conversation state. */
410
424
  async deleteBot(botId) {
411
425
  const config = this.#configStore.get(botId);
@@ -388,6 +388,45 @@ export class DingtalkRuntime {
388
388
  });
389
389
  }
390
390
 
391
+ async sendProactiveText(target, text, { signal } = {}) {
392
+ const userId = typeof target?.route?.userId === 'string'
393
+ ? target.route.userId.trim() : '';
394
+ const openConversationId = typeof target?.route?.openConversationId === 'string'
395
+ ? target.route.openConversationId.trim() : '';
396
+ if ((target?.kind === 'user' && (!userId || openConversationId))
397
+ || (target?.kind === 'group' && (!openConversationId || userId))
398
+ || (target?.kind !== 'user' && target?.kind !== 'group')) {
399
+ const error = new TypeError('Invalid DingTalk proactive delivery target');
400
+ error.code = 'invalid-target';
401
+ throw error;
402
+ }
403
+ if (!this.#status.ready || !this.#abortController) {
404
+ const error = new Error('DingTalk runtime is not connected');
405
+ error.code = 'bot-not-connected';
406
+ throw error;
407
+ }
408
+ signal?.throwIfAborted();
409
+ try {
410
+ await this.#api.sendRobotText({
411
+ clientId: this.#config.clientId,
412
+ clientSecret: this.#clientSecret,
413
+ target: {
414
+ type: target.kind,
415
+ robotCode: this.#config.clientId,
416
+ ...(target.kind === 'user' ? { userId } : { openConversationId }),
417
+ },
418
+ text,
419
+ signal: signal ?? this.#abortController.signal,
420
+ });
421
+ } catch (cause) {
422
+ if (cause?.code !== 'send-rejected') throw cause;
423
+ const error = new Error('DingTalk rejected the proactive delivery target', { cause });
424
+ error.code = 'target-rejected';
425
+ throw error;
426
+ }
427
+ return { sent: true };
428
+ }
429
+
391
430
  #pendingSenders() {
392
431
  return typeof this.#state.pendingSenders === 'function'
393
432
  ? this.#state.pendingSenders()
@@ -501,6 +501,22 @@ export class DiscordRuntime {
501
501
  return this.#bridge.sendConnectionTest(text);
502
502
  }
503
503
 
504
+ async sendProactiveText(target, text, options = {}) {
505
+ if (!this.#status.ready || !this.#bridge) {
506
+ const error = new Error('Discord bot is not connected');
507
+ error.code = 'bot-not-connected';
508
+ throw error;
509
+ }
510
+ const channelId = typeof target?.route?.channelId === 'string'
511
+ ? target.route.channelId.trim() : '';
512
+ if (target?.kind !== 'channel' || !channelId) {
513
+ const error = new TypeError('Invalid Discord proactive delivery target');
514
+ error.code = 'invalid-target';
515
+ throw error;
516
+ }
517
+ return this.#bridge.sendProactiveText({ channelId }, text, options);
518
+ }
519
+
504
520
  async start() {
505
521
  if (this.#status.ready && this.#socket) return this.status;
506
522
  if (this.#starting) return this.#starting;
@@ -93,7 +93,7 @@ const REPAIR_COMMAND = /^\/repair(?:\s+(qr|status|cancel|verify))?\s*$/i;
93
93
  const WATCH_COMMAND = /^\/watch(?:\s+([^\s]+))?$/i;
94
94
  const UNWATCH_COMMAND = /^\/unwatch(?:\s+([^\s]+))?$/i;
95
95
  const WATCHLIST_COMMAND = /^\/watchlist$/i;
96
- const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
96
+ const SESSION_LIST_PREFIX = /^\/(?:sessionlist|sessions)(?:\s|$)/i;
97
97
  const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
98
98
  const NUMBER_REPLY = /^\d{1,2}$/;
99
99
  /** A displayed menu stays number-tappable for this long. */
@@ -133,13 +133,13 @@ const REPAIR_URL_HOSTS = new Set([
133
133
 
134
134
  const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
135
135
  /** Matches fast card commands that should not be queued behind a running task. */
136
- const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|sessionlist(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
136
+ const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
137
137
 
138
138
  /** Canonical workspace/session help advertised by every bridge family. */
139
139
  const WORKSPACE_HELP_LINES = [
140
140
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
141
141
  '/workspacelist 列出工作区绝对路径',
142
- '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
142
+ '/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
143
143
  ];
144
144
 
145
145
  /** Safe user-facing text for bind/workspace failures (no raw messages). */
@@ -1005,7 +1005,7 @@ export class FeishuHarnessBridge {
1005
1005
  return;
1006
1006
  }
1007
1007
  if (SESSION_LIST_PREFIX.test(commandText)) {
1008
- const selector = commandText.slice('/sessionlist'.length).trim() || null;
1008
+ const selector = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim() || null;
1009
1009
  await this.#showSessions({ chatId: event.message.chat_id, key }, selector, 0);
1010
1010
  return;
1011
1011
  }
@@ -487,7 +487,7 @@ export function menuHelpText() {
487
487
  '🤖 助手菜单(回复数字即可,无需记命令)',
488
488
  '',
489
489
  '📋 会话 / 工作区',
490
- '/sessionlist 列出工作区会话',
490
+ '/sessionlist 或 /sessions 列出工作区会话',
491
491
  '/session ID 绑定已有会话',
492
492
  '/workspacelist 列出工作区',
493
493
  '/workspace 路径 切换工作区',
@@ -506,7 +506,7 @@ export function menuHelpText() {
506
506
  '/unwatch ID 取消关注',
507
507
  '',
508
508
  '🤖 预设 / 模型',
509
- '/presetlist 列出可用 Agent Preset',
509
+ '/presetlist 或 /presets 列出可用 Agent Preset',
510
510
  '/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
511
511
  '纯数字 ID:/preset id:<ID>',
512
512
  '/preset --default 跟随 Host 默认',
@@ -553,7 +553,7 @@ const HELP_TEXT_COMMANDS = [
553
553
  '`/m` — 打开菜单卡片',
554
554
  '`/new` — 开启全新会话',
555
555
  '`/session ID` — 绑定已有会话',
556
- '`/sessionlist [工作区]` — 列出会话',
556
+ '`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话',
557
557
  '`/workspace 路径` — 切换工作区',
558
558
  '`/workspacelist` — 列出工作区',
559
559
  '`/status` — 查看连接状态',
@@ -564,7 +564,7 @@ const HELP_TEXT_COMMANDS = [
564
564
  '`/watchlist` — 关注列表',
565
565
  '`/unwatch ID` — 取消关注',
566
566
  '`/archived on/off` — 归档显隐',
567
- '`/presetlist` — 列出预设',
567
+ '`/presetlist` 或 `/presets` — 列出预设',
568
568
  '`/preset [序号/ID]` — 切换预设',
569
569
  '`/preset --default` — 跟随默认',
570
570
  '`/models` — 列出模型',