@xmanrui/dsh-im 0.2.2 → 0.3.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 (35) hide show
  1. package/README.md +19 -11
  2. package/lib/client.js +1796 -1094
  3. package/lib/index.js +116 -115
  4. package/package.json +2 -2
  5. package/plugin-src/client/channel-logos.js +13 -0
  6. package/plugin-src/client/channels/dingtalk/index.js +1 -1
  7. package/plugin-src/client/channels/feishu/index.js +1 -2
  8. package/plugin-src/client/channels/qq/index.js +1 -1
  9. package/plugin-src/client/channels/shared/token-channel.js +1 -2
  10. package/plugin-src/client/channels/slack/api.js +11 -0
  11. package/plugin-src/client/channels/slack/index.js +130 -0
  12. package/plugin-src/client/channels/slack/styles.js +34 -0
  13. package/plugin-src/client/channels/wecom/index.js +1 -1
  14. package/plugin-src/client/channels/weixin/index.js +1 -2
  15. package/plugin-src/client/channels/whatsapp/index.js +1 -1
  16. package/plugin-src/client/credential-binding.js +1 -2
  17. package/plugin-src/client/i18n.js +443 -0
  18. package/plugin-src/client/index.js +29 -4
  19. package/plugin-src/client/styles.js +16 -8
  20. package/plugin-src/host/channels/shared/production.mjs +2 -2
  21. package/plugin-src/host/channels/slack/index.mjs +28 -0
  22. package/plugin-src/host/channels/slack/production.mjs +91 -0
  23. package/plugin-src/host/channels/slack/rpc.mjs +127 -0
  24. package/plugin-src/host/index.mjs +3 -0
  25. package/scripts/verify-package.mjs +10 -3
  26. package/src/channels/discord/discord-api.mjs +1 -1
  27. package/src/channels/slack/config-store.mjs +173 -0
  28. package/src/channels/slack/harness-client.mjs +3 -0
  29. package/src/channels/slack/manifest.mjs +31 -0
  30. package/src/channels/slack/slack-api.mjs +259 -0
  31. package/src/channels/slack/slack-bridge.mjs +15 -0
  32. package/src/channels/slack/slack-controller.mjs +318 -0
  33. package/src/channels/slack/slack-runtime.mjs +462 -0
  34. package/src/channels/slack/state-store.mjs +3 -0
  35. package/src/channels/weixin/weixin-api.mjs +1 -1
@@ -0,0 +1,462 @@
1
+ import { splitMessageText } from '../shared/editable-message-stream.mjs';
2
+ import { SlackApi } from './slack-api.mjs';
3
+ import { createSlackBridgeStatus, SlackHarnessBridge } from './slack-bridge.mjs';
4
+
5
+ const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
6
+ const SLACK_MESSAGE_LIMIT = 38_000;
7
+ const SLACK_STREAM_CHUNK_LIMIT = 11_000;
8
+
9
+ function addSocketListener(socket, event, listener) {
10
+ if (typeof socket.addEventListener === 'function') socket.addEventListener(event, listener);
11
+ else if (typeof socket.on === 'function') socket.on(event, listener);
12
+ else throw new TypeError('Slack WebSocket does not support events');
13
+ }
14
+
15
+ function eventData(event) {
16
+ const value = event?.data ?? event;
17
+ if (typeof value === 'string') return value;
18
+ if (Buffer.isBuffer(value)) return value.toString('utf8');
19
+ if (value instanceof ArrayBuffer) return Buffer.from(value).toString('utf8');
20
+ if (ArrayBuffer.isView(value)) {
21
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('utf8');
22
+ }
23
+ return null;
24
+ }
25
+
26
+ function socketUrl(value) {
27
+ const url = new URL(value);
28
+ if (url.protocol !== 'wss:') throw new Error('Slack returned an insecure Socket Mode URL');
29
+ return url.href;
30
+ }
31
+
32
+ function decodeSlackText(value) {
33
+ return typeof value === 'string' ? value
34
+ .replaceAll('&', '&')
35
+ .replaceAll('&lt;', '<')
36
+ .replaceAll('&gt;', '>') : '';
37
+ }
38
+
39
+ function stripBotMention(value, botUserId) {
40
+ return decodeSlackText(value)
41
+ .replace(new RegExp(`<@${botUserId}>`, 'gi'), '')
42
+ .trim();
43
+ }
44
+
45
+ export function normalizeSlackEvent(payload, botUserId) {
46
+ const event = payload?.event;
47
+ if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
48
+ const direct = event.type === 'message' && event.channel_type === 'im';
49
+ const mentioned = event.type === 'app_mention';
50
+ if (!direct && !mentioned) return null;
51
+ if (event.subtype || event.bot_id || event.app_id) return null;
52
+ const threadTs = String(event.thread_ts ?? event.ts);
53
+ return {
54
+ messageId: String(payload.event_id),
55
+ senderId: String(event.user),
56
+ senderIsBot: String(event.user) === String(botUserId),
57
+ kind: direct ? 'direct' : 'group',
58
+ conversationId: direct ? String(event.channel) : `${event.channel}:${threadTs}`,
59
+ content: stripBotMention(event.text ?? '', botUserId),
60
+ addressed: direct || mentioned,
61
+ replyTarget: {
62
+ channelId: String(event.channel),
63
+ threadTs,
64
+ recipientUserId: String(event.user),
65
+ recipientTeamId: String(event.user_team ?? payload.team_id ?? ''),
66
+ },
67
+ };
68
+ }
69
+
70
+ function isToolProgress(text) {
71
+ return typeof text === 'string' && /^正在使用.+…$/.test(text.trim());
72
+ }
73
+
74
+ async function appendInChunks(api, target, ts, text, signal) {
75
+ if (!text) return;
76
+ for (let offset = 0; offset < text.length; offset += SLACK_STREAM_CHUNK_LIMIT) {
77
+ await api.appendStream({
78
+ channelId: target.channelId,
79
+ ts,
80
+ markdownText: text.slice(offset, offset + SLACK_STREAM_CHUNK_LIMIT),
81
+ signal,
82
+ });
83
+ }
84
+ }
85
+
86
+ async function createSlackMessageStream({ api, target, signal, logger }) {
87
+ const started = await api.startStream({
88
+ channelId: target.channelId,
89
+ threadTs: target.threadTs,
90
+ recipientTeamId: target.recipientTeamId || undefined,
91
+ recipientUserId: target.recipientUserId || undefined,
92
+ signal,
93
+ });
94
+ const ts = typeof started?.ts === 'string' ? started.ts : null;
95
+ if (!ts) throw new Error('Slack did not return a streaming message timestamp');
96
+
97
+ let appended = '';
98
+ let pending = '';
99
+ let timer = null;
100
+ let inFlight = null;
101
+ let broken = false;
102
+ let closed = false;
103
+
104
+ const appendLatest = async (text) => {
105
+ const next = splitMessageText(text, SLACK_MESSAGE_LIMIT)[0] ?? '';
106
+ if (!next || !next.startsWith(appended)) return;
107
+ const delta = next.slice(appended.length);
108
+ if (!delta) return;
109
+ await appendInChunks(api, target, ts, delta, signal);
110
+ appended = next;
111
+ };
112
+
113
+ const schedule = () => {
114
+ if (closed || broken || timer !== null || inFlight || !pending) return;
115
+ timer = setTimeout(() => {
116
+ timer = null;
117
+ const text = pending;
118
+ pending = '';
119
+ inFlight = appendLatest(text)
120
+ .catch((error) => {
121
+ broken = true;
122
+ logger.warn?.('[dsh-im:slack] streaming append failed:', error);
123
+ })
124
+ .finally(() => {
125
+ inFlight = null;
126
+ schedule();
127
+ });
128
+ }, 350);
129
+ timer?.unref?.();
130
+ };
131
+
132
+ return {
133
+ update(text) {
134
+ if (closed || broken || typeof text !== 'string' || !text.trim() || isToolProgress(text)) return;
135
+ pending = text;
136
+ schedule();
137
+ },
138
+ async finish(text) {
139
+ if (closed) throw new Error('Slack message stream is already closed');
140
+ closed = true;
141
+ if (timer !== null) clearTimeout(timer);
142
+ timer = null;
143
+ pending = '';
144
+ await inFlight?.catch(() => undefined);
145
+
146
+ const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT);
147
+ const first = chunks[0] ?? '处理完成。';
148
+ if (!broken && first.startsWith(appended)) {
149
+ await appendInChunks(api, target, ts, first.slice(appended.length), signal);
150
+ await api.stopStream({ channelId: target.channelId, ts, signal });
151
+ } else {
152
+ await api.stopStream({ channelId: target.channelId, ts, signal }).catch(() => undefined);
153
+ await api.updateMessage({ channelId: target.channelId, ts, text: first, signal });
154
+ }
155
+ for (const chunk of chunks.slice(1)) {
156
+ await api.postMessage({
157
+ channelId: target.channelId,
158
+ threadTs: target.threadTs,
159
+ text: chunk,
160
+ signal,
161
+ });
162
+ }
163
+ },
164
+ cancel() {
165
+ closed = true;
166
+ pending = '';
167
+ if (timer !== null) clearTimeout(timer);
168
+ timer = null;
169
+ void api.stopStream({ channelId: target.channelId, ts, signal }).catch(() => undefined);
170
+ },
171
+ };
172
+ }
173
+
174
+ class SlackBotClient {
175
+ #api;
176
+ #signal;
177
+ #logger;
178
+
179
+ constructor({ api, signal, logger }) {
180
+ this.#api = api;
181
+ this.#signal = signal;
182
+ this.#logger = logger;
183
+ }
184
+
185
+ async sendText(target, text) {
186
+ const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT);
187
+ let result = null;
188
+ for (const chunk of chunks) {
189
+ result = await this.#api.postMessage({
190
+ channelId: target.channelId,
191
+ threadTs: target.threadTs,
192
+ text: chunk,
193
+ signal: this.#signal,
194
+ });
195
+ }
196
+ return result;
197
+ }
198
+
199
+ openStream(target) {
200
+ return createSlackMessageStream({
201
+ api: this.#api,
202
+ target,
203
+ signal: this.#signal,
204
+ logger: this.#logger,
205
+ });
206
+ }
207
+ }
208
+
209
+ export function createSlackRuntimeStatus() {
210
+ return {
211
+ startedAt: null,
212
+ ready: false,
213
+ connectionState: 'idle',
214
+ harnessReachable: false,
215
+ lastCheckedAt: null,
216
+ lastConnectedAt: null,
217
+ lastError: null,
218
+ ...createSlackBridgeStatus(),
219
+ };
220
+ }
221
+
222
+ export class SlackRuntime {
223
+ #config;
224
+ #botToken;
225
+ #appToken;
226
+ #harness;
227
+ #state;
228
+ #logger;
229
+ #replyTimeoutMs;
230
+ #connectTimeoutMs;
231
+ #createApi;
232
+ #createWebSocket;
233
+ #status = createSlackRuntimeStatus();
234
+ #api = null;
235
+ #bridge = null;
236
+ #abortController = null;
237
+ #socket = null;
238
+ #appId = null;
239
+ #reconnectTimer = null;
240
+ #reconnectAttempt = 0;
241
+ #generation = 0;
242
+ #stopped = true;
243
+ #starting = null;
244
+
245
+ constructor({
246
+ config,
247
+ botToken,
248
+ appToken,
249
+ harness,
250
+ state,
251
+ logger = console,
252
+ replyTimeoutMs = 600_000,
253
+ connectTimeoutMs = 20_000,
254
+ createApi = (options) => new SlackApi(options),
255
+ createWebSocket = (url) => new WebSocket(url),
256
+ }) {
257
+ if (!config || !botToken || !appToken || !harness || !state) {
258
+ throw new TypeError('SlackRuntime requires config, both tokens, Harness, and state');
259
+ }
260
+ if (typeof createWebSocket !== 'function') throw new TypeError('SlackRuntime requires WebSocket');
261
+ this.#config = config;
262
+ this.#botToken = botToken;
263
+ this.#appToken = appToken;
264
+ this.#harness = harness;
265
+ this.#state = state;
266
+ this.#logger = logger;
267
+ this.#replyTimeoutMs = replyTimeoutMs;
268
+ this.#connectTimeoutMs = connectTimeoutMs;
269
+ this.#createApi = createApi;
270
+ this.#createWebSocket = createWebSocket;
271
+ }
272
+
273
+ get status() {
274
+ return structuredClone(this.#status);
275
+ }
276
+
277
+ async start() {
278
+ if (this.#status.ready && this.#socket) return this.status;
279
+ if (this.#starting) return this.#starting;
280
+ this.#starting = this.#start().finally(() => {
281
+ this.#starting = null;
282
+ });
283
+ return this.#starting;
284
+ }
285
+
286
+ async #start() {
287
+ await this.stop();
288
+ this.#stopped = false;
289
+ this.#reconnectAttempt = 0;
290
+ this.#status.startedAt = new Date().toISOString();
291
+ this.#status.connectionState = 'connecting';
292
+ this.#status.lastError = null;
293
+ await this.#harness.ensureRunning();
294
+ this.#status.harnessReachable = true;
295
+ const controller = new AbortController();
296
+ this.#abortController = controller;
297
+ const api = this.#createApi({ botToken: this.#botToken, appToken: this.#appToken });
298
+ this.#api = api;
299
+ try {
300
+ const identity = await api.authTest({ signal: controller.signal });
301
+ if (`${identity?.team_id}:${identity?.user_id}` !== this.#config.platformId) {
302
+ throw new Error('Slack Bot Token identity does not match the saved bot');
303
+ }
304
+ const client = new SlackBotClient({ api, signal: controller.signal, logger: this.#logger });
305
+ this.#bridge = new SlackHarnessBridge({
306
+ bot: client,
307
+ harness: this.#harness,
308
+ state: this.#state,
309
+ status: this.#status,
310
+ logger: this.#logger,
311
+ replyTimeoutMs: this.#replyTimeoutMs,
312
+ });
313
+ let timer;
314
+ try {
315
+ await Promise.race([
316
+ this.#connect(),
317
+ new Promise((_, reject) => {
318
+ timer = setTimeout(
319
+ () => reject(new Error('Slack Socket Mode did not become ready in time')),
320
+ this.#connectTimeoutMs,
321
+ );
322
+ timer?.unref?.();
323
+ }),
324
+ ]);
325
+ } finally {
326
+ clearTimeout(timer);
327
+ }
328
+ return this.status;
329
+ } catch (error) {
330
+ this.#status.ready = false;
331
+ this.#status.connectionState = 'failed';
332
+ this.#status.lastError = error?.message ?? String(error);
333
+ await this.stop();
334
+ throw error;
335
+ }
336
+ }
337
+
338
+ async #connect() {
339
+ if (this.#stopped) throw new Error('Slack runtime is stopped');
340
+ const connection = await this.#api.openConnection({ signal: this.#abortController?.signal });
341
+ return this.#openSocket(connection?.url);
342
+ }
343
+
344
+ #openSocket(value) {
345
+ if (this.#stopped) return Promise.reject(new Error('Slack runtime is stopped'));
346
+ const generation = ++this.#generation;
347
+ const socket = this.#createWebSocket(socketUrl(value));
348
+ this.#socket = socket;
349
+ let settled = false;
350
+ return new Promise((resolve, reject) => {
351
+ const markReady = (packet) => {
352
+ if (settled || generation !== this.#generation) return;
353
+ settled = true;
354
+ this.#appId = packet?.connection_info?.app_id ?? null;
355
+ this.#reconnectAttempt = 0;
356
+ const now = Date.now();
357
+ this.#status.ready = true;
358
+ this.#status.connectionState = 'connected';
359
+ this.#status.lastCheckedAt = now;
360
+ this.#status.lastConnectedAt = now;
361
+ this.#status.lastError = null;
362
+ resolve();
363
+ };
364
+
365
+ addSocketListener(socket, 'message', (event) => {
366
+ if (generation !== this.#generation || this.#stopped) return;
367
+ const raw = eventData(event);
368
+ if (!raw) return;
369
+ let packet;
370
+ try {
371
+ packet = JSON.parse(raw);
372
+ } catch {
373
+ this.#logger.warn?.('[dsh-im:slack] ignored malformed Socket Mode JSON');
374
+ return;
375
+ }
376
+ if (packet.type === 'hello') {
377
+ markReady(packet);
378
+ return;
379
+ }
380
+ if (packet.envelope_id && socket.readyState === 1) {
381
+ socket.send(JSON.stringify({ envelope_id: packet.envelope_id }));
382
+ this.#status.lastCheckedAt = Date.now();
383
+ }
384
+ if (packet.type === 'disconnect') {
385
+ socket.close(4000, 'Slack requested reconnect');
386
+ return;
387
+ }
388
+ if (packet.type !== 'events_api' || packet.payload?.type !== 'event_callback') return;
389
+ if (this.#appId && packet.payload.api_app_id
390
+ && packet.payload.api_app_id !== this.#appId) return;
391
+ const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1]);
392
+ if (message) void this.#bridge?.accept(message);
393
+ });
394
+
395
+ addSocketListener(socket, 'close', (event = {}) => {
396
+ if (generation !== this.#generation) return;
397
+ if (this.#socket === socket) this.#socket = null;
398
+ if (this.#stopped) {
399
+ if (!settled) reject(new DOMException('Stopped', 'AbortError'));
400
+ return;
401
+ }
402
+ const code = Number(event.code) || 0;
403
+ const error = new Error(`Slack Socket Mode closed (${code || 'unknown'})`);
404
+ this.#status.ready = false;
405
+ this.#status.connectionState = 'connecting';
406
+ this.#status.lastError = error.message;
407
+ if (!settled) {
408
+ settled = true;
409
+ reject(error);
410
+ }
411
+ this.#scheduleReconnect();
412
+ });
413
+
414
+ addSocketListener(socket, 'error', () => {
415
+ if (generation !== this.#generation || this.#stopped) return;
416
+ this.#status.lastError = 'Slack Socket Mode WebSocket error';
417
+ });
418
+ });
419
+ }
420
+
421
+ #scheduleReconnect() {
422
+ if (this.#stopped || this.#reconnectTimer !== null) return;
423
+ const delay = RECONNECT_DELAYS_MS[Math.min(this.#reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
424
+ this.#reconnectAttempt += 1;
425
+ this.#reconnectTimer = setTimeout(() => {
426
+ this.#reconnectTimer = null;
427
+ void this.#connect().catch((error) => {
428
+ if (this.#stopped) return;
429
+ this.#logger.warn?.('[dsh-im:slack] Socket Mode reconnect failed:', error);
430
+ this.#scheduleReconnect();
431
+ });
432
+ }, delay);
433
+ this.#reconnectTimer?.unref?.();
434
+ }
435
+
436
+ async stop() {
437
+ this.#stopped = true;
438
+ this.#generation += 1;
439
+ this.#abortController?.abort();
440
+ this.#abortController = null;
441
+ if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer);
442
+ this.#reconnectTimer = null;
443
+ const socket = this.#socket;
444
+ const bridge = this.#bridge;
445
+ this.#socket = null;
446
+ this.#bridge = null;
447
+ this.#api = null;
448
+ this.#appId = null;
449
+ try {
450
+ if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
451
+ } catch (error) {
452
+ this.#logger.warn?.(`[dsh-im:slack] bot ${this.#config.botId} failed to close Socket Mode:`, error);
453
+ }
454
+ await Promise.race([
455
+ bridge?.waitForIdle() ?? Promise.resolve(),
456
+ new Promise((resolve) => setTimeout(resolve, 2_000)),
457
+ ]);
458
+ this.#status.ready = false;
459
+ this.#status.connectionState = 'idle';
460
+ return this.status;
461
+ }
462
+ }
@@ -0,0 +1,3 @@
1
+ import { ConversationStateStore } from '../shared/conversation-state-store.mjs';
2
+
3
+ export class SlackStateStore extends ConversationStateStore {}
@@ -92,7 +92,7 @@ function authenticatedHeaders(token) {
92
92
  function baseInfo() {
93
93
  return {
94
94
  channel_version: WEIXIN_PROTOCOL_VERSION,
95
- bot_agent: 'DeepSeekHarness/0.2.2',
95
+ bot_agent: 'DeepSeekHarness/0.3.0',
96
96
  };
97
97
  }
98
98