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/CHANGELOG.md +34 -0
- package/README.en.md +24 -186
- package/README.md +22 -188
- package/docs/CHANGELOG.en.md +21 -0
- package/docs/USAGE.en.md +189 -0
- package/docs/USAGE.md +193 -0
- package/docs/VALIDATION.md +24 -0
- package/docs/validation-0.3.5.md +19 -0
- package/lib/inbox.d.ts +1 -1
- package/lib/inbox.js +4 -8
- package/lib/index.d.ts +1 -1
- package/lib/index.js +5 -3
- package/lib/slack-client.js +7 -9
- package/lib/socket.d.ts +20 -10
- package/lib/socket.js +102 -36
- package/lib/tools.d.ts +2 -0
- package/lib/tools.js +13 -8
- package/lib/types.d.ts +1 -0
- package/package.json +5 -3
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
99
|
+
log('解析 Socket Mode 事件失败', error);
|
|
22
100
|
}
|
|
23
101
|
finally {
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
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
|
|
90
|
-
|
|
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 + '
|
|
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.
|
|
4
|
-
"description": "
|
|
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": {
|