@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.
- package/README.md +19 -11
- package/lib/client.js +1796 -1094
- package/lib/index.js +116 -115
- package/package.json +2 -2
- package/plugin-src/client/channel-logos.js +13 -0
- package/plugin-src/client/channels/dingtalk/index.js +1 -1
- package/plugin-src/client/channels/feishu/index.js +1 -2
- package/plugin-src/client/channels/qq/index.js +1 -1
- package/plugin-src/client/channels/shared/token-channel.js +1 -2
- package/plugin-src/client/channels/slack/api.js +11 -0
- package/plugin-src/client/channels/slack/index.js +130 -0
- package/plugin-src/client/channels/slack/styles.js +34 -0
- package/plugin-src/client/channels/wecom/index.js +1 -1
- package/plugin-src/client/channels/weixin/index.js +1 -2
- package/plugin-src/client/channels/whatsapp/index.js +1 -1
- package/plugin-src/client/credential-binding.js +1 -2
- package/plugin-src/client/i18n.js +443 -0
- package/plugin-src/client/index.js +29 -4
- package/plugin-src/client/styles.js +16 -8
- package/plugin-src/host/channels/shared/production.mjs +2 -2
- package/plugin-src/host/channels/slack/index.mjs +28 -0
- package/plugin-src/host/channels/slack/production.mjs +91 -0
- package/plugin-src/host/channels/slack/rpc.mjs +127 -0
- package/plugin-src/host/index.mjs +3 -0
- package/scripts/verify-package.mjs +10 -3
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/slack/config-store.mjs +173 -0
- package/src/channels/slack/harness-client.mjs +3 -0
- package/src/channels/slack/manifest.mjs +31 -0
- package/src/channels/slack/slack-api.mjs +259 -0
- package/src/channels/slack/slack-bridge.mjs +15 -0
- package/src/channels/slack/slack-controller.mjs +318 -0
- package/src/channels/slack/slack-runtime.mjs +462 -0
- package/src/channels/slack/state-store.mjs +3 -0
- package/src/channels/weixin/weixin-api.mjs +1 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
const DEFAULT_BASE_URL = 'https://slack.com/api/';
|
|
2
|
+
|
|
3
|
+
function cleanString(value) {
|
|
4
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function requestSignal(signal, timeoutMs) {
|
|
8
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
9
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function delay(ms, signal) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
if (signal?.aborted) {
|
|
15
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const timer = setTimeout(resolve, ms);
|
|
19
|
+
timer?.unref?.();
|
|
20
|
+
signal?.addEventListener('abort', () => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
23
|
+
}, { once: true });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function slackId(value, name) {
|
|
28
|
+
const result = cleanString(value);
|
|
29
|
+
if (!result || !/^[A-Z][A-Z0-9]{4,30}$/i.test(result)) {
|
|
30
|
+
throw new TypeError(`Invalid Slack ${name}`);
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requiredString(value, name) {
|
|
36
|
+
const result = cleanString(value);
|
|
37
|
+
if (!result) throw new TypeError(`Slack ${name} is required`);
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeOutgoingText(value, { trim = true } = {}) {
|
|
42
|
+
const raw = typeof value === 'string' ? value : '';
|
|
43
|
+
const text = trim ? raw.trim() : raw;
|
|
44
|
+
if (!text) throw new TypeError('Slack message text is required');
|
|
45
|
+
return text
|
|
46
|
+
.replace(/<@([A-Z0-9]+)>/gi, '@$1')
|
|
47
|
+
.replace(/<!(channel|here|everyone)(?:\^[^>]*)?>/gi, '@$1');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function apiFailure(method, payload, tokenKind) {
|
|
51
|
+
const reason = cleanString(payload?.error) ?? 'unknown_error';
|
|
52
|
+
const error = new Error(`Slack ${method} failed: ${reason.replaceAll('_', ' ')}`);
|
|
53
|
+
if (['invalid_auth', 'not_authed', 'token_revoked', 'account_inactive'].includes(reason)) {
|
|
54
|
+
error.code = tokenKind === 'app' ? 'slack-invalid-app-token' : 'slack-invalid-bot-token';
|
|
55
|
+
} else if (reason === 'missing_scope') {
|
|
56
|
+
error.code = 'slack-missing-scope';
|
|
57
|
+
} else if (reason === 'method_not_supported_for_channel_type'
|
|
58
|
+
|| reason === 'channel_type_not_supported'
|
|
59
|
+
|| reason === 'deprecated_endpoint') {
|
|
60
|
+
error.code = 'slack-stream-unavailable';
|
|
61
|
+
} else {
|
|
62
|
+
error.code = `slack-${reason}`;
|
|
63
|
+
}
|
|
64
|
+
return error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validSlackBotToken(value) {
|
|
68
|
+
return typeof value === 'string'
|
|
69
|
+
&& /^xoxb-[A-Za-z0-9-]{16,}$/.test(value.trim());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validSlackAppToken(value) {
|
|
73
|
+
return typeof value === 'string'
|
|
74
|
+
&& /^xapp-[A-Za-z0-9-]{16,}$/.test(value.trim());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class SlackApi {
|
|
78
|
+
#botToken;
|
|
79
|
+
#appToken;
|
|
80
|
+
#fetch;
|
|
81
|
+
#baseUrl;
|
|
82
|
+
|
|
83
|
+
constructor({ botToken, appToken, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
|
|
84
|
+
if (botToken !== undefined && !validSlackBotToken(botToken)) {
|
|
85
|
+
throw new TypeError('Slack Bot Token is invalid');
|
|
86
|
+
}
|
|
87
|
+
if (appToken !== undefined && !validSlackAppToken(appToken)) {
|
|
88
|
+
throw new TypeError('Slack App Token is invalid');
|
|
89
|
+
}
|
|
90
|
+
if (!botToken && !appToken) throw new TypeError('SlackApi requires a token');
|
|
91
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('SlackApi requires fetch');
|
|
92
|
+
this.#botToken = botToken?.trim();
|
|
93
|
+
this.#appToken = appToken?.trim();
|
|
94
|
+
this.#fetch = fetchImpl;
|
|
95
|
+
this.#baseUrl = new URL(baseUrl);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
authTest(options = {}) {
|
|
99
|
+
return this.#request('auth.test', { ...options, tokenKind: 'bot' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
openConnection(options = {}) {
|
|
103
|
+
return this.#request('apps.connections.open', {
|
|
104
|
+
...options,
|
|
105
|
+
tokenKind: 'app',
|
|
106
|
+
body: undefined,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
postMessage({ channelId, text, threadTs, signal }) {
|
|
111
|
+
return this.#request('chat.postMessage', {
|
|
112
|
+
tokenKind: 'bot',
|
|
113
|
+
signal,
|
|
114
|
+
body: {
|
|
115
|
+
channel: slackId(channelId, 'channel id'),
|
|
116
|
+
text: safeOutgoingText(text),
|
|
117
|
+
...(threadTs ? { thread_ts: cleanString(threadTs) } : {}),
|
|
118
|
+
mrkdwn: true,
|
|
119
|
+
link_names: false,
|
|
120
|
+
unfurl_links: false,
|
|
121
|
+
unfurl_media: false,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
updateMessage({ channelId, ts, text, signal }) {
|
|
127
|
+
return this.#request('chat.update', {
|
|
128
|
+
tokenKind: 'bot',
|
|
129
|
+
signal,
|
|
130
|
+
body: {
|
|
131
|
+
channel: slackId(channelId, 'channel id'),
|
|
132
|
+
ts: requiredString(ts, 'message timestamp'),
|
|
133
|
+
text: safeOutgoingText(text),
|
|
134
|
+
parse: 'none',
|
|
135
|
+
link_names: false,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
startStream({ channelId, threadTs, recipientTeamId, recipientUserId, markdownText, signal }) {
|
|
141
|
+
return this.#request('chat.startStream', {
|
|
142
|
+
tokenKind: 'bot',
|
|
143
|
+
signal,
|
|
144
|
+
body: {
|
|
145
|
+
channel: slackId(channelId, 'channel id'),
|
|
146
|
+
thread_ts: requiredString(threadTs, 'thread timestamp'),
|
|
147
|
+
...(recipientTeamId ? { recipient_team_id: slackId(recipientTeamId, 'team id') } : {}),
|
|
148
|
+
...(recipientUserId ? { recipient_user_id: slackId(recipientUserId, 'user id') } : {}),
|
|
149
|
+
...(cleanString(markdownText) ? { markdown_text: safeOutgoingText(markdownText) } : {}),
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
appendStream({ channelId, ts, markdownText, signal }) {
|
|
155
|
+
return this.#request('chat.appendStream', {
|
|
156
|
+
tokenKind: 'bot',
|
|
157
|
+
signal,
|
|
158
|
+
body: {
|
|
159
|
+
channel: slackId(channelId, 'channel id'),
|
|
160
|
+
ts: requiredString(ts, 'stream timestamp'),
|
|
161
|
+
markdown_text: safeOutgoingText(markdownText, { trim: false }),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
stopStream({ channelId, ts, markdownText, signal }) {
|
|
167
|
+
return this.#request('chat.stopStream', {
|
|
168
|
+
tokenKind: 'bot',
|
|
169
|
+
signal,
|
|
170
|
+
body: {
|
|
171
|
+
channel: slackId(channelId, 'channel id'),
|
|
172
|
+
ts: requiredString(ts, 'stream timestamp'),
|
|
173
|
+
...(cleanString(markdownText) ? {
|
|
174
|
+
markdown_text: safeOutgoingText(markdownText, { trim: false }),
|
|
175
|
+
} : {}),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async #request(method, {
|
|
181
|
+
tokenKind,
|
|
182
|
+
body,
|
|
183
|
+
signal,
|
|
184
|
+
timeoutMs = 15_000,
|
|
185
|
+
retry = true,
|
|
186
|
+
}) {
|
|
187
|
+
const token = tokenKind === 'app' ? this.#appToken : this.#botToken;
|
|
188
|
+
if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`);
|
|
189
|
+
let response;
|
|
190
|
+
try {
|
|
191
|
+
response = await this.#fetch(new URL(method, this.#baseUrl), {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: {
|
|
194
|
+
authorization: `Bearer ${token}`,
|
|
195
|
+
'content-type': body === undefined
|
|
196
|
+
? 'application/x-www-form-urlencoded;charset=utf-8'
|
|
197
|
+
: 'application/json;charset=utf-8',
|
|
198
|
+
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.2.2)',
|
|
199
|
+
},
|
|
200
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
201
|
+
signal: requestSignal(signal, timeoutMs),
|
|
202
|
+
redirect: 'error',
|
|
203
|
+
});
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
|
206
|
+
throw new Error(`Slack ${method} transport failed`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let payload;
|
|
210
|
+
try {
|
|
211
|
+
payload = await response.json();
|
|
212
|
+
} catch {
|
|
213
|
+
throw new Error(`Slack ${method} returned invalid JSON`);
|
|
214
|
+
}
|
|
215
|
+
if (response.status === 429 && retry) {
|
|
216
|
+
const seconds = Number(response.headers.get('retry-after')) || 1;
|
|
217
|
+
await delay(Math.min(10_000, Math.max(100, seconds * 1_000)), signal);
|
|
218
|
+
return this.#request(method, { tokenKind, body, signal, timeoutMs, retry: false });
|
|
219
|
+
}
|
|
220
|
+
if (!response.ok || payload?.ok !== true) throw apiFailure(method, payload, tokenKind);
|
|
221
|
+
return payload;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function inspectSlackCredentials({ botToken, appToken }, options = {}) {
|
|
226
|
+
if (!validSlackBotToken(botToken)) {
|
|
227
|
+
const error = new TypeError('Slack Bot Token 必须以 xoxb- 开头。');
|
|
228
|
+
error.code = 'slack-invalid-bot-token';
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
if (!validSlackAppToken(appToken)) {
|
|
232
|
+
const error = new TypeError('Slack App Token 必须以 xapp- 开头。');
|
|
233
|
+
error.code = 'slack-invalid-app-token';
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
const api = new SlackApi({ botToken, appToken, ...options });
|
|
237
|
+
const [identity, connection] = await Promise.all([api.authTest(), api.openConnection()]);
|
|
238
|
+
if (!identity?.team_id || !identity?.user_id || !identity?.bot_id) {
|
|
239
|
+
throw new Error('Slack Bot Token 没有返回完整的机器人身份。');
|
|
240
|
+
}
|
|
241
|
+
let socketUrl;
|
|
242
|
+
try {
|
|
243
|
+
socketUrl = new URL(connection?.url);
|
|
244
|
+
} catch {
|
|
245
|
+
socketUrl = null;
|
|
246
|
+
}
|
|
247
|
+
if (!socketUrl || socketUrl.protocol !== 'wss:') {
|
|
248
|
+
const error = new Error('Slack App Token 无法创建 Socket Mode 连接,请确认已启用 Socket Mode 和 connections:write。');
|
|
249
|
+
error.code = 'slack-socket-mode';
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
platformId: `${identity.team_id}:${identity.user_id}`,
|
|
254
|
+
name: cleanString(identity.user) ?? 'DeepSeek Harness',
|
|
255
|
+
username: cleanString(identity.user),
|
|
256
|
+
teamId: String(identity.team_id),
|
|
257
|
+
teamName: cleanString(identity.team),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs';
|
|
2
|
+
|
|
3
|
+
export const SLACK_DESCRIPTOR = Object.freeze({
|
|
4
|
+
key: 'slack',
|
|
5
|
+
label: 'Slack',
|
|
6
|
+
connectionLabel: ' Socket Mode 长连接',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export class SlackHarnessBridge extends TextHarnessBridge {
|
|
10
|
+
constructor(options) {
|
|
11
|
+
super({ descriptor: SLACK_DESCRIPTOR, ...options });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { createTextBridgeStatus as createSlackBridgeStatus };
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
|
|
2
|
+
import { inspectSlackCredentials } from './slack-api.mjs';
|
|
3
|
+
import { SLACK_DESCRIPTOR } from './slack-bridge.mjs';
|
|
4
|
+
|
|
5
|
+
function cleanString(value) {
|
|
6
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function safeError(code, message) {
|
|
10
|
+
return Object.freeze({ code, message });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SlackController {
|
|
14
|
+
#credentials;
|
|
15
|
+
#configStore;
|
|
16
|
+
#inspectCredentials;
|
|
17
|
+
#createRuntime;
|
|
18
|
+
#deleteState;
|
|
19
|
+
#logger;
|
|
20
|
+
#runtimes = new Map();
|
|
21
|
+
#errors = new Map();
|
|
22
|
+
#transitions = new Map();
|
|
23
|
+
#revision = 0;
|
|
24
|
+
#closed = false;
|
|
25
|
+
|
|
26
|
+
constructor({
|
|
27
|
+
credentials,
|
|
28
|
+
configStore,
|
|
29
|
+
inspectCredentials = inspectSlackCredentials,
|
|
30
|
+
createRuntime,
|
|
31
|
+
deleteState = async () => {},
|
|
32
|
+
logger = console,
|
|
33
|
+
}) {
|
|
34
|
+
if (!credentials || typeof credentials.resolve !== 'function'
|
|
35
|
+
|| typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
|
|
36
|
+
throw new TypeError('Slack requires the DSH credential provider');
|
|
37
|
+
}
|
|
38
|
+
if (!configStore || typeof configStore.list !== 'function'
|
|
39
|
+
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
|
|
40
|
+
throw new TypeError('Slack requires a config store');
|
|
41
|
+
}
|
|
42
|
+
if (typeof inspectCredentials !== 'function' || typeof createRuntime !== 'function') {
|
|
43
|
+
throw new TypeError('Slack controller dependencies are incomplete');
|
|
44
|
+
}
|
|
45
|
+
this.#credentials = credentials;
|
|
46
|
+
this.#configStore = configStore;
|
|
47
|
+
this.#inspectCredentials = inspectCredentials;
|
|
48
|
+
this.#createRuntime = createRuntime;
|
|
49
|
+
this.#deleteState = deleteState;
|
|
50
|
+
this.#logger = logger;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async initialize() {
|
|
54
|
+
if (this.#closed) return this.status();
|
|
55
|
+
for (const config of this.#configStore.list()) {
|
|
56
|
+
await this.#withBotTransition(config.botId, async () => {
|
|
57
|
+
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
|
|
58
|
+
const resolved = await this.#resolveCredentials(config);
|
|
59
|
+
if (!resolved) {
|
|
60
|
+
this.#errors.set(config.botId, safeError(
|
|
61
|
+
'missing-token',
|
|
62
|
+
'Slack机器人凭据缺失,请移除后重新接入。',
|
|
63
|
+
));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
await this.#startRuntime(config, resolved);
|
|
68
|
+
this.#errors.delete(config.botId);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
this.#errors.set(config.botId, safeError(
|
|
71
|
+
'connection-failed',
|
|
72
|
+
'Slack Socket Mode 连接未就绪,插件会自动重试。',
|
|
73
|
+
));
|
|
74
|
+
this.#logger.warn?.(
|
|
75
|
+
`[dsh-im:slack] bot ${config.botId} failed to initialize:`,
|
|
76
|
+
error,
|
|
77
|
+
);
|
|
78
|
+
} finally {
|
|
79
|
+
this.#touch();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return this.status();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async bindCredentials({ botToken, appToken } = {}) {
|
|
87
|
+
if (this.#closed) throw new Error('Slack controller is closed');
|
|
88
|
+
const normalizedBotToken = cleanString(botToken);
|
|
89
|
+
const normalizedAppToken = cleanString(appToken);
|
|
90
|
+
if (!normalizedBotToken || !normalizedAppToken) {
|
|
91
|
+
throw new TypeError('Slack Bot Token and App Token are required');
|
|
92
|
+
}
|
|
93
|
+
const inspected = await this.#inspectCredentials({
|
|
94
|
+
botToken: normalizedBotToken,
|
|
95
|
+
appToken: normalizedAppToken,
|
|
96
|
+
});
|
|
97
|
+
const platformId = cleanString(inspected?.platformId);
|
|
98
|
+
const name = cleanString(inspected?.name);
|
|
99
|
+
if (!platformId || !name) throw new Error('Slack returned an invalid bot identity');
|
|
100
|
+
const identity = deriveSlackBotIdentity(platformId);
|
|
101
|
+
|
|
102
|
+
await this.#withBotTransition(identity.botId, async () => {
|
|
103
|
+
if (this.#closed) throw new Error('Slack controller is closed');
|
|
104
|
+
const previousConfig = this.#configStore.getByPlatformId(platformId);
|
|
105
|
+
const previousBotToken = await this.#credentials.resolve(identity.botTokenRef).catch(() => undefined);
|
|
106
|
+
const previousAppToken = await this.#credentials.resolve(identity.appTokenRef).catch(() => undefined);
|
|
107
|
+
const config = {
|
|
108
|
+
...identity,
|
|
109
|
+
platformId,
|
|
110
|
+
name,
|
|
111
|
+
username: cleanString(inspected.username),
|
|
112
|
+
teamId: cleanString(inspected.teamId),
|
|
113
|
+
teamName: cleanString(inspected.teamName),
|
|
114
|
+
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
|
115
|
+
connectedAt: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
await this.#credentials.set(identity.botTokenRef, normalizedBotToken);
|
|
119
|
+
await this.#credentials.set(identity.appTokenRef, normalizedAppToken);
|
|
120
|
+
await this.#configStore.save(config);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
await Promise.all([
|
|
123
|
+
this.#restoreCredential(identity.botTokenRef, previousBotToken),
|
|
124
|
+
this.#restoreCredential(identity.appTokenRef, previousAppToken),
|
|
125
|
+
]);
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await this.#startRuntime(config, {
|
|
130
|
+
botToken: normalizedBotToken,
|
|
131
|
+
appToken: normalizedAppToken,
|
|
132
|
+
});
|
|
133
|
+
this.#errors.delete(identity.botId);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
this.#errors.set(identity.botId, safeError(
|
|
136
|
+
'connection-failed',
|
|
137
|
+
'Slack机器人已接入,Socket Mode 连接暂未就绪。',
|
|
138
|
+
));
|
|
139
|
+
this.#logger.warn?.(
|
|
140
|
+
`[dsh-im:slack] bot ${identity.botId} credential connection failed:`,
|
|
141
|
+
error,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
this.#touch();
|
|
145
|
+
});
|
|
146
|
+
return this.status();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async reconnectBot(botId) {
|
|
150
|
+
const config = this.#configStore.get(botId);
|
|
151
|
+
if (!config) throw new Error('Unknown Slack bot');
|
|
152
|
+
await this.#withBotTransition(botId, async () => {
|
|
153
|
+
const resolved = await this.#resolveCredentials(config);
|
|
154
|
+
if (!resolved) throw new Error('Slack bot credentials are missing');
|
|
155
|
+
try {
|
|
156
|
+
await this.#startRuntime(config, resolved);
|
|
157
|
+
this.#errors.delete(botId);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
this.#errors.set(botId, safeError(
|
|
160
|
+
'connection-failed',
|
|
161
|
+
'Slack Socket Mode 连接仍未就绪,请检查两个 Token。',
|
|
162
|
+
));
|
|
163
|
+
throw error;
|
|
164
|
+
} finally {
|
|
165
|
+
this.#touch();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return this.status();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async deleteBot(botId) {
|
|
172
|
+
const config = this.#configStore.get(botId);
|
|
173
|
+
if (!config) throw new Error('Unknown Slack bot');
|
|
174
|
+
await this.#withBotTransition(botId, async () => {
|
|
175
|
+
const previousBotToken = await this.#credentials.resolve(config.botTokenRef).catch(() => undefined);
|
|
176
|
+
const previousAppToken = await this.#credentials.resolve(config.appTokenRef).catch(() => undefined);
|
|
177
|
+
await this.#stopRuntime(botId);
|
|
178
|
+
try {
|
|
179
|
+
await this.#credentials.unset(config.botTokenRef);
|
|
180
|
+
await this.#credentials.unset(config.appTokenRef);
|
|
181
|
+
await this.#configStore.remove(botId);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
await Promise.all([
|
|
184
|
+
this.#restoreCredential(config.botTokenRef, previousBotToken),
|
|
185
|
+
this.#restoreCredential(config.appTokenRef, previousAppToken),
|
|
186
|
+
]);
|
|
187
|
+
if (previousBotToken?.value && previousAppToken?.value) {
|
|
188
|
+
await this.#startRuntime(config, {
|
|
189
|
+
botToken: previousBotToken.value,
|
|
190
|
+
appToken: previousAppToken.value,
|
|
191
|
+
}).catch(() => undefined);
|
|
192
|
+
}
|
|
193
|
+
throw new Error('Unable to remove the Slack bot safely.', { cause: error });
|
|
194
|
+
}
|
|
195
|
+
await this.#deleteState({ botId, config }).catch((error) => {
|
|
196
|
+
this.#logger.warn?.(`[dsh-im:slack] bot ${botId} state cleanup failed:`, error);
|
|
197
|
+
});
|
|
198
|
+
this.#errors.delete(botId);
|
|
199
|
+
this.#touch();
|
|
200
|
+
});
|
|
201
|
+
return this.status();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
status() {
|
|
205
|
+
const bots = this.#configStore.list().map((config) => {
|
|
206
|
+
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
|
207
|
+
const connected = runtimeStatus?.ready === true
|
|
208
|
+
&& runtimeStatus.connectionState === 'connected'
|
|
209
|
+
&& runtimeStatus.harnessReachable === true;
|
|
210
|
+
const state = connected ? 'connected'
|
|
211
|
+
: runtimeStatus?.connectionState === 'connecting' ? 'connecting'
|
|
212
|
+
: this.#errors.has(config.botId) || runtimeStatus?.connectionState === 'failed'
|
|
213
|
+
? 'error' : 'offline';
|
|
214
|
+
return {
|
|
215
|
+
botId: config.botId,
|
|
216
|
+
state,
|
|
217
|
+
connected,
|
|
218
|
+
configured: true,
|
|
219
|
+
bot: {
|
|
220
|
+
name: config.name,
|
|
221
|
+
username: config.username,
|
|
222
|
+
teamName: config.teamName,
|
|
223
|
+
idMasked: maskSlackBotId(config.platformId),
|
|
224
|
+
},
|
|
225
|
+
health: {
|
|
226
|
+
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
|
227
|
+
summary: connected ? `Slack${SLACK_DESCRIPTOR.connectionLabel}运行正常`
|
|
228
|
+
: state === 'error' ? 'Slack连接未就绪,插件会自动重试'
|
|
229
|
+
: 'Slack连接当前离线',
|
|
230
|
+
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
|
231
|
+
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
|
232
|
+
},
|
|
233
|
+
stats: {
|
|
234
|
+
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
235
|
+
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
236
|
+
},
|
|
237
|
+
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
const connected = bots.filter((bot) => bot.connected).length;
|
|
241
|
+
return {
|
|
242
|
+
schemaVersion: 1,
|
|
243
|
+
revision: this.#revision,
|
|
244
|
+
state: bots.length === 0 ? 'disconnected'
|
|
245
|
+
: connected === bots.length ? 'connected'
|
|
246
|
+
: connected > 0 ? 'degraded' : 'offline',
|
|
247
|
+
bots,
|
|
248
|
+
totals: { configured: bots.length, connected },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async close() {
|
|
253
|
+
if (this.#closed) return;
|
|
254
|
+
this.#closed = true;
|
|
255
|
+
await Promise.allSettled([...this.#transitions.values()]);
|
|
256
|
+
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async #startRuntime(config, { botToken, appToken }) {
|
|
260
|
+
if (this.#closed) throw new Error('Slack controller is closed');
|
|
261
|
+
await this.#stopRuntime(config.botId);
|
|
262
|
+
if (this.#closed) throw new Error('Slack controller is closed');
|
|
263
|
+
const runtime = await this.#createRuntime({
|
|
264
|
+
botId: config.botId,
|
|
265
|
+
config,
|
|
266
|
+
botToken,
|
|
267
|
+
appToken,
|
|
268
|
+
});
|
|
269
|
+
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
270
|
+
throw new TypeError('createRuntime returned an invalid Slack runtime');
|
|
271
|
+
}
|
|
272
|
+
this.#runtimes.set(config.botId, runtime);
|
|
273
|
+
try {
|
|
274
|
+
await runtime.start();
|
|
275
|
+
} catch (error) {
|
|
276
|
+
await runtime.stop().catch(() => undefined);
|
|
277
|
+
this.#runtimes.delete(config.botId);
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async #stopRuntime(botId) {
|
|
283
|
+
const runtime = this.#runtimes.get(botId);
|
|
284
|
+
this.#runtimes.delete(botId);
|
|
285
|
+
await runtime?.stop().catch((error) => {
|
|
286
|
+
this.#logger.warn?.(`[dsh-im:slack] bot ${botId} failed to stop cleanly:`, error);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async #resolveCredentials(config) {
|
|
291
|
+
const [bot, app] = await Promise.all([
|
|
292
|
+
this.#credentials.resolve(config.botTokenRef).catch(() => undefined),
|
|
293
|
+
this.#credentials.resolve(config.appTokenRef).catch(() => undefined),
|
|
294
|
+
]);
|
|
295
|
+
const botToken = cleanString(bot?.value);
|
|
296
|
+
const appToken = cleanString(app?.value);
|
|
297
|
+
return botToken && appToken ? { botToken, appToken } : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async #restoreCredential(ref, previous) {
|
|
301
|
+
if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
|
|
302
|
+
else await this.#credentials.unset(ref).catch(() => undefined);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#withBotTransition(botId, operation) {
|
|
306
|
+
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
|
307
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
308
|
+
const settled = current.finally(() => {
|
|
309
|
+
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
|
310
|
+
});
|
|
311
|
+
this.#transitions.set(botId, settled);
|
|
312
|
+
return settled;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#touch() {
|
|
316
|
+
this.#revision += 1;
|
|
317
|
+
}
|
|
318
|
+
}
|