dsh-slack 0.3.3 → 0.3.5

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.
package/lib/socket.js CHANGED
@@ -1,16 +1,94 @@
1
- /**
2
- * Socket Mode 启动/生命周期:用 @slack/socket-mode 建立 WebSocket,
3
- * 收到 message 事件写入收件箱;网络错误只 warn 不抛崩(SDK 自带自动重连)。
4
- */
1
+ /** Socket Mode lifecycle: SDK auto-reconnect is disabled; every attempt is owned and caught here. */
5
2
  import { SocketModeClient } from '@slack/socket-mode';
6
3
  import { parseMessageEvent } from './inbox.js';
7
- /**
8
- * 启动 Socket Mode 客户端,返回 dispose 函数(断开连接)。
9
- * 网络错误 / 解析错误均 try/catch + warn,绝不抛出。
10
- */
11
- export function startSocketModeClient(appToken, handler, warn) {
12
- const client = new SocketModeClient({ appToken });
4
+ const AUTH_ERRORS = new Set(['invalid_auth', 'not_authed', 'account_inactive', 'user_removed_from_team', 'team_disabled', 'token_revoked', 'token_expired', 'missing_scope', 'not_allowed_token_type']);
5
+ function errorCode(error) {
6
+ const value = error;
7
+ const code = value?.data?.error ?? value?.code;
8
+ if (typeof code === 'string' && (AUTH_ERRORS.has(code) || /^(?:E[A-Z0-9_]+|slack_webapi_[a-z_]+)$/.test(code)))
9
+ return code;
10
+ return 'connection_error';
11
+ }
12
+ export function startSocketModeClient(appToken, handler, warn, options = {}) {
13
+ const client = (options.createClient ?? (config => new SocketModeClient(config)))({
14
+ appToken, autoReconnectEnabled: false,
15
+ clientOptions: { timeout: 15000, retryConfig: { retries: 0 }, rejectRateLimitedCalls: true },
16
+ });
17
+ const retryBase = Number.isFinite(options.retryBaseMs) ? Math.max(1, Math.min(30000, options.retryBaseMs)) : 1000;
18
+ let status = { state: 'connecting', attempts: 0 };
19
+ let stopped = false, fatal = false, connecting = false, needsRetry = false, failures = 0;
20
+ let retry;
21
+ const log = (message, error) => {
22
+ try {
23
+ warn('[dsh-slack] ' + message + (error === undefined ? '' : '(' + errorCode(error) + ')'));
24
+ }
25
+ catch { /* Logging must not create an unhandled rejection. */ }
26
+ };
27
+ const disconnect = () => { void Promise.resolve().then(() => client.disconnect()).catch(error => log('Socket Mode 断开失败', error)); };
28
+ const schedule = () => {
29
+ if (stopped || fatal || connecting || retry !== undefined)
30
+ return;
31
+ status = { ...status, state: 'reconnecting' };
32
+ const delay = Math.min(30000, retryBase * 2 ** Math.min(failures, 5));
33
+ retry = setTimeout(() => { retry = undefined; connect(); }, delay);
34
+ retry.unref?.();
35
+ };
36
+ const failure = (error) => {
37
+ const code = errorCode(error);
38
+ fatal = AUTH_ERRORS.has(code);
39
+ status = { ...status, state: fatal ? 'auth_failed' : 'reconnecting', error: code };
40
+ failures++;
41
+ needsRetry = !fatal;
42
+ log(fatal ? 'Socket Mode 令牌或权限无效,已停止重连;更新 App-Level Token 后重启插件' : 'Socket Mode 连接失败,将退避重试', error);
43
+ if (fatal)
44
+ disconnect();
45
+ };
46
+ const connect = () => {
47
+ if (stopped || fatal || connecting)
48
+ return;
49
+ connecting = true;
50
+ needsRetry = false;
51
+ status = { ...status, state: status.attempts === 0 ? 'connecting' : 'reconnecting', attempts: status.attempts + 1 };
52
+ void Promise.resolve().then(() => {
53
+ if (!stopped)
54
+ return client.start();
55
+ }).then(() => {
56
+ if (stopped)
57
+ disconnect();
58
+ }).catch(error => { if (!stopped)
59
+ failure(error); }).finally(() => {
60
+ connecting = false;
61
+ if (needsRetry)
62
+ schedule();
63
+ });
64
+ };
65
+ client.on('connected', () => {
66
+ if (stopped || fatal) {
67
+ disconnect();
68
+ return;
69
+ }
70
+ failures = 0;
71
+ needsRetry = false;
72
+ status = { state: 'connected', attempts: status.attempts };
73
+ });
74
+ client.on('disconnected', () => {
75
+ if (stopped || fatal)
76
+ return;
77
+ needsRetry = true;
78
+ status = { ...status, state: 'reconnecting' };
79
+ schedule();
80
+ });
81
+ client.on('error', error => {
82
+ if (stopped || fatal)
83
+ return;
84
+ if (AUTH_ERRORS.has(errorCode(error)))
85
+ failure(error);
86
+ else
87
+ log('Socket Mode 连接错误', error);
88
+ });
13
89
  client.on('slack_event', (...args) => {
90
+ if (stopped || fatal)
91
+ return;
14
92
  const raw = args[0];
15
93
  try {
16
94
  const message = parseMessageEvent(raw);
@@ -18,34 +96,22 @@ export function startSocketModeClient(appToken, handler, warn) {
18
96
  handler.onMessage(message);
19
97
  }
20
98
  catch (error) {
21
- warn('[dsh-slack] 解析 Socket Mode 事件失败:', error);
99
+ log('解析 Socket Mode 事件失败', error);
22
100
  }
23
101
  finally {
24
- // events_api 事件必须 ack,否则 Slack 会重投;重复投递由收件箱 seen 去重兜底。
25
- if (typeof raw.ack === 'function') {
26
- void raw.ack().catch(() => { });
27
- }
102
+ if (typeof raw?.ack === 'function')
103
+ void Promise.resolve().then(() => raw.ack()).catch(() => { });
28
104
  }
29
105
  });
30
- client.on('error', (...args) => {
31
- warn('[dsh-slack] Socket Mode 连接错误(SDK 会自动重连):', args[0]);
32
- });
33
- try {
34
- client.start().catch((error) => {
35
- warn('[dsh-slack] Socket Mode 启动失败:', error);
36
- });
37
- }
38
- catch (error) {
39
- warn('[dsh-slack] Socket Mode 启动异常:', error);
40
- }
41
- return () => {
42
- try {
43
- client.disconnect().catch((error) => {
44
- warn('[dsh-slack] Socket Mode 断开失败:', error);
45
- });
46
- }
47
- catch (error) {
48
- warn('[dsh-slack] Socket Mode 断开异常:', error);
49
- }
50
- };
106
+ connect();
107
+ return Object.assign(() => {
108
+ if (stopped)
109
+ return;
110
+ stopped = true;
111
+ if (retry !== undefined)
112
+ clearTimeout(retry);
113
+ retry = undefined;
114
+ status = { ...status, state: 'stopped' };
115
+ disconnect();
116
+ }, { status: () => ({ ...status }) });
51
117
  }
package/lib/tools.d.ts CHANGED
@@ -2,11 +2,13 @@ import type { SlackConfig } from './config.js';
2
2
  import type { ToolDefinition } from './types.js';
3
3
  import type { SlackClient } from './slack-client.js';
4
4
  import type { InboxQueue } from './inbox.js';
5
+ import type { SocketStatus } from './socket.js';
5
6
  /** 工具构建的依赖注入点。 */
6
7
  export interface ToolDeps {
7
8
  clientProvider: () => SlackClient;
8
9
  configProvider: () => SlackConfig | undefined;
9
10
  inboxProvider: () => InboxQueue;
11
+ socketStatusProvider?: () => SocketStatus | undefined;
10
12
  }
11
13
  /** 自检工具:检查 token / appToken / 默认频道配置,不发起网络请求。 */
12
14
  export declare function buildHealthTool(deps: ToolDeps): ToolDefinition;
package/lib/tools.js CHANGED
@@ -32,6 +32,7 @@ const CHANNELS_OUTPUT_SCHEMA = {
32
32
  const INBOX_OUTPUT_SCHEMA = {
33
33
  type: 'object',
34
34
  properties: {
35
+ remaining: { type: 'integer' },
35
36
  messages: {
36
37
  type: 'array',
37
38
  items: {
@@ -47,7 +48,7 @@ const INBOX_OUTPUT_SCHEMA = {
47
48
  },
48
49
  },
49
50
  },
50
- required: ['messages'],
51
+ required: ['messages', 'remaining'],
51
52
  additionalProperties: true,
52
53
  };
53
54
  function textBlock(text) {
@@ -62,7 +63,7 @@ function clampLimit(value) {
62
63
  export function buildHealthTool(deps) {
63
64
  return {
64
65
  name: 'slack_health',
65
- description: 'dsh-slack 自检:检查 botToken / appToken / 默认频道配置是否就绪(不发起网络请求)。遇到问题时先运行本工具定位。',
66
+ description: 'dsh-slack 自检:检查 botToken / appToken / 默认频道配置和当前 Socket Mode 连接状态(不额外发起网络请求)。遇到问题时先运行本工具定位。',
66
67
  parameters: compileParameters({}),
67
68
  output: {
68
69
  schema: { type: 'object', additionalProperties: true },
@@ -83,11 +84,15 @@ export function buildHealthTool(deps) {
83
84
  const token = resolveToken(config);
84
85
  checks.push({ name: 'botToken', ok: token !== '', detail: token !== '' ? '已配置' : '未配置:请填 token 或环境变量(发送/频道工具需要)' });
85
86
  const appToken = resolveAppToken(config);
86
- checks.push({ name: 'appToken', ok: true, detail: appToken !== '' ? '已配置(Socket Mode 收件箱可用)' : '未配置(收件箱功能不可用,通知不受影响)' });
87
+ checks.push({ name: 'appToken', ok: appToken === '' || appToken.startsWith('xapp-'), detail: appToken !== '' ? '已配置(Socket Mode 连接状态见下项)' : '未配置(收件箱功能不可用,通知不受影响)' });
87
88
  const defaultChannel = resolveDefaultChannel(config);
88
89
  checks.push({ name: '默认频道', ok: true, detail: defaultChannel !== '' ? defaultChannel : '未配置(发送时必须显式给 channel)' });
89
- const ok = token !== '';
90
- return { ok, plugin: 'dsh-slack', checks };
90
+ const socket = deps.socketStatusProvider?.();
91
+ const states = { connecting: '正在连接', connected: '已连接', reconnecting: '已断开,退避重连中', auth_failed: '令牌或权限无效,已停止重连;更新 App-Level Token 后重启插件', stopped: '已停止' };
92
+ if (appToken !== '')
93
+ checks.push({ name: 'Socket Mode', ok: socket?.state === 'connected', detail: socket ? states[socket.state] + (socket.error ? '(' + socket.error + ')' : '') : '仅检查配置,尚未确认连接' });
94
+ const ok = token !== '' && checks.every(check => check.ok === true);
95
+ return { ok, plugin: 'dsh-slack', checks, ...(socket ? { socket } : {}) };
91
96
  },
92
97
  };
93
98
  }
@@ -170,7 +175,7 @@ export function buildChannelsTool(deps) {
170
175
  export function buildInboxTool(deps) {
171
176
  const parameters = compileParameters({
172
177
  limit: { type: 'integer', description: '最多返回的消息数(默认 10,范围 1-50)。' },
173
- markRead: { type: 'boolean', description: '为 true 时,返回后清空收件箱队列(标记已读)。' },
178
+ markRead: { type: 'boolean', description: '为 true 时,只将本次返回的消息标记已读;未返回的消息继续保留。' },
174
179
  });
175
180
  const output = {
176
181
  schema: INBOX_OUTPUT_SCHEMA,
@@ -180,7 +185,7 @@ export function buildInboxTool(deps) {
180
185
  return [textBlock('收件箱为空:尚未开启 Socket Mode(缺少 appToken),或暂未收到新的 Slack 消息。请确认已配置 appToken 并订阅 message.channels / message.im 事件。')];
181
186
  }
182
187
  const lines = v.messages.map((m) => '[' + m.channel + '] ' + m.user + '(ts: ' + m.ts + '):' + m.text);
183
- return [textBlock('收件箱(' + v.messages.length + ' 条,新的在前):\n' + lines.join('\n'))];
188
+ return [textBlock('收件箱(' + v.messages.length + ' 条,新的在前;剩余未读 ' + (v.remaining ?? 0) + ' 条):\n' + lines.join('\n'))];
184
189
  },
185
190
  };
186
191
  return {
@@ -194,7 +199,7 @@ export function buildInboxTool(deps) {
194
199
  const markRead = raw.markRead === true;
195
200
  const queue = deps.inboxProvider();
196
201
  const messages = markRead ? queue.drain(limit) : queue.list(limit);
197
- return { messages: messages.map((m) => ({ ts: m.ts, channel: m.channel, user: m.user, text: m.text })) };
202
+ return { messages: messages.map((m) => ({ ts: m.ts, channel: m.channel, user: m.user, text: m.text })), remaining: queue.size };
198
203
  },
199
204
  };
200
205
  }
package/lib/types.d.ts CHANGED
@@ -28,6 +28,7 @@ export interface SlackContext {
28
28
  };
29
29
  /** Cordis 生命周期:传入启动函数,其返回的清理函数在 dispose 时执行。 */
30
30
  effect?: (start: () => void | (() => void)) => void;
31
+ on?: (event: 'dispose', listener: () => void) => unknown;
31
32
  /** 可选日志器;缺省回退 console.warn。 */
32
33
  logger?: {
33
34
  warn(message: string, ...args: unknown[]): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-slack",
3
- "version": "0.3.3",
4
- "description": "DSH Slack 插件:通知与桥接,Socket Mode 双向消息(收件箱 + 线程回复)。",
3
+ "version": "0.3.5",
4
+ "description": "连接 Slack,发送通知并处理 Socket Mode 收件箱与线程回复。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -17,7 +17,9 @@
17
17
  "lib",
18
18
  "cordis.patch.yml",
19
19
  "README.md",
20
- "README.en.md"
20
+ "README.en.md",
21
+ "docs",
22
+ "CHANGELOG.md"
21
23
  ],
22
24
  "license": "MIT",
23
25
  "dsh": {