@xmanrui/dsh-im 0.2.1 → 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 (39) hide show
  1. package/README.md +19 -11
  2. package/lib/client.js +1847 -1145
  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/dingtalk/styles.js +8 -8
  8. package/plugin-src/client/channels/feishu/index.js +1 -2
  9. package/plugin-src/client/channels/feishu/styles.js +3 -3
  10. package/plugin-src/client/channels/qq/index.js +1 -1
  11. package/plugin-src/client/channels/shared/token-channel.js +1 -2
  12. package/plugin-src/client/channels/slack/api.js +11 -0
  13. package/plugin-src/client/channels/slack/index.js +130 -0
  14. package/plugin-src/client/channels/slack/styles.js +34 -0
  15. package/plugin-src/client/channels/wecom/index.js +1 -1
  16. package/plugin-src/client/channels/wecom/styles.js +1 -1
  17. package/plugin-src/client/channels/weixin/index.js +1 -2
  18. package/plugin-src/client/channels/weixin/styles.js +9 -9
  19. package/plugin-src/client/channels/whatsapp/index.js +1 -1
  20. package/plugin-src/client/credential-binding.js +1 -2
  21. package/plugin-src/client/i18n.js +443 -0
  22. package/plugin-src/client/index.js +29 -4
  23. package/plugin-src/client/styles.js +46 -38
  24. package/plugin-src/host/channels/shared/production.mjs +2 -2
  25. package/plugin-src/host/channels/slack/index.mjs +28 -0
  26. package/plugin-src/host/channels/slack/production.mjs +91 -0
  27. package/plugin-src/host/channels/slack/rpc.mjs +127 -0
  28. package/plugin-src/host/index.mjs +3 -0
  29. package/scripts/verify-package.mjs +10 -3
  30. package/src/channels/discord/discord-api.mjs +1 -1
  31. package/src/channels/slack/config-store.mjs +173 -0
  32. package/src/channels/slack/harness-client.mjs +3 -0
  33. package/src/channels/slack/manifest.mjs +31 -0
  34. package/src/channels/slack/slack-api.mjs +259 -0
  35. package/src/channels/slack/slack-bridge.mjs +15 -0
  36. package/src/channels/slack/slack-controller.mjs +318 -0
  37. package/src/channels/slack/slack-runtime.mjs +462 -0
  38. package/src/channels/slack/state-store.mjs +3 -0
  39. package/src/channels/weixin/weixin-api.mjs +1 -1
@@ -0,0 +1,173 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+
5
+ const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
6
+ const BOT_ID_PATTERN = /^slack_[a-f0-9]{24}$/;
7
+ const BOT_TOKEN_REF_PATTERN = /^DSH_SLACK_BOT_TOKEN_[A-F0-9]{24}$/;
8
+ const APP_TOKEN_REF_PATTERN = /^DSH_SLACK_APP_TOKEN_[A-F0-9]{24}$/;
9
+
10
+ function cleanString(value) {
11
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
12
+ }
13
+
14
+ export function deriveSlackBotIdentity(platformId) {
15
+ const raw = cleanString(platformId);
16
+ if (!raw) throw new TypeError('platformId is required');
17
+ const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
18
+ const suffix = digest.toUpperCase();
19
+ return {
20
+ botId: `slack_${digest}`,
21
+ botTokenRef: `DSH_SLACK_BOT_TOKEN_${suffix}`,
22
+ appTokenRef: `DSH_SLACK_APP_TOKEN_${suffix}`,
23
+ };
24
+ }
25
+
26
+ export function maskSlackBotId(platformId) {
27
+ const value = cleanString(platformId) ?? '';
28
+ const [teamId, userId] = value.split(':');
29
+ if (teamId && userId) return `${teamId.slice(0, 5)}••• · ${userId.slice(0, 5)}•••`;
30
+ return value ? `${value.slice(0, 6)}••••` : 'Slack机器人';
31
+ }
32
+
33
+ export class SlackConfigStore {
34
+ #path;
35
+ #value = EMPTY_DOCUMENT;
36
+ #writeQueue = Promise.resolve();
37
+
38
+ constructor(path) {
39
+ this.#path = path;
40
+ }
41
+
42
+ async load() {
43
+ try {
44
+ const normalized = this.#normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
45
+ if (!normalized) throw new Error('dsh-im Slack config contains invalid bot data');
46
+ this.#value = normalized;
47
+ } catch (error) {
48
+ if (error?.code !== 'ENOENT') throw error;
49
+ this.#value = EMPTY_DOCUMENT;
50
+ }
51
+ return this;
52
+ }
53
+
54
+ list() {
55
+ return structuredClone(this.#value.bots);
56
+ }
57
+
58
+ get(botId) {
59
+ const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
60
+ return bot ? structuredClone(bot) : null;
61
+ }
62
+
63
+ getByPlatformId(platformId) {
64
+ const bot = this.#value.bots.find((candidate) => candidate.platformId === platformId);
65
+ return bot ? structuredClone(bot) : null;
66
+ }
67
+
68
+ async save(value) {
69
+ const normalized = this.#normalizeBot(value);
70
+ if (!normalized) throw new Error('Refusing to persist incomplete Slack bot data');
71
+ return this.#mutate((bots) => {
72
+ const collision = bots.find((bot) => (
73
+ bot.botId !== normalized.botId
74
+ && (bot.platformId === normalized.platformId
75
+ || bot.botTokenRef === normalized.botTokenRef
76
+ || bot.appTokenRef === normalized.appTokenRef)
77
+ ));
78
+ if (collision) throw new Error('Duplicate Slack bot identity');
79
+ const index = bots.findIndex((bot) => bot.botId === normalized.botId);
80
+ if (index === -1) bots.push(normalized);
81
+ else bots[index] = normalized;
82
+ return structuredClone(normalized);
83
+ });
84
+ }
85
+
86
+ async remove(botId) {
87
+ if (!BOT_ID_PATTERN.test(botId)) throw new TypeError('Invalid Slack bot id');
88
+ return this.#mutate((bots) => {
89
+ const index = bots.findIndex((bot) => bot.botId === botId);
90
+ if (index === -1) return null;
91
+ return structuredClone(bots.splice(index, 1)[0]);
92
+ });
93
+ }
94
+
95
+ async clear() {
96
+ const operation = this.#writeQueue.then(async () => {
97
+ try {
98
+ await unlink(this.#path);
99
+ } catch (error) {
100
+ if (error?.code !== 'ENOENT') throw error;
101
+ }
102
+ this.#value = EMPTY_DOCUMENT;
103
+ });
104
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
105
+ await operation;
106
+ }
107
+
108
+ #normalizeBot(value) {
109
+ if (!value || typeof value !== 'object') return null;
110
+ const botId = cleanString(value.botId);
111
+ const platformId = cleanString(value.platformId);
112
+ const botTokenRef = cleanString(value.botTokenRef);
113
+ const appTokenRef = cleanString(value.appTokenRef);
114
+ const name = cleanString(value.name);
115
+ if (!botId || !platformId || !botTokenRef || !appTokenRef || !name
116
+ || !BOT_ID_PATTERN.test(botId)
117
+ || !BOT_TOKEN_REF_PATTERN.test(botTokenRef)
118
+ || !APP_TOKEN_REF_PATTERN.test(appTokenRef)) return null;
119
+ const derived = deriveSlackBotIdentity(platformId);
120
+ if (derived.botId !== botId
121
+ || derived.botTokenRef !== botTokenRef
122
+ || derived.appTokenRef !== appTokenRef) return null;
123
+ return Object.freeze({
124
+ botId,
125
+ platformId,
126
+ botTokenRef,
127
+ appTokenRef,
128
+ name,
129
+ username: cleanString(value.username),
130
+ teamId: cleanString(value.teamId),
131
+ teamName: cleanString(value.teamName),
132
+ createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
133
+ connectedAt: cleanString(value.connectedAt),
134
+ });
135
+ }
136
+
137
+ #normalizeDocument(value) {
138
+ if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null;
139
+ const bots = value.bots.map((bot) => this.#normalizeBot(bot));
140
+ if (bots.some((bot) => bot === null)) return null;
141
+ const ids = new Set();
142
+ const platformIds = new Set();
143
+ const refs = new Set();
144
+ for (const bot of bots) {
145
+ if (ids.has(bot.botId) || platformIds.has(bot.platformId)
146
+ || refs.has(bot.botTokenRef) || refs.has(bot.appTokenRef)) return null;
147
+ ids.add(bot.botId);
148
+ platformIds.add(bot.platformId);
149
+ refs.add(bot.botTokenRef);
150
+ refs.add(bot.appTokenRef);
151
+ }
152
+ return Object.freeze({ version: 1, bots: Object.freeze(bots) });
153
+ }
154
+
155
+ async #mutate(mutator) {
156
+ let result;
157
+ const operation = this.#writeQueue.then(async () => {
158
+ const bots = [...this.#value.bots];
159
+ result = mutator(bots);
160
+ const document = Object.freeze({ version: 1, bots: Object.freeze(bots) });
161
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
162
+ const temporary = `${this.#path}.tmp`;
163
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
164
+ encoding: 'utf8', mode: 0o600,
165
+ });
166
+ await rename(temporary, this.#path);
167
+ this.#value = document;
168
+ });
169
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
170
+ await operation;
171
+ return result;
172
+ }
173
+ }
@@ -0,0 +1,3 @@
1
+ import { HarnessClient } from '../weixin/harness-client.mjs';
2
+
3
+ export class SlackHarnessClient extends HarnessClient {}
@@ -0,0 +1,31 @@
1
+ export const SLACK_APP_MANIFEST_YAML = `_metadata:
2
+ major_version: 1
3
+ display_information:
4
+ name: DeepSeek Harness
5
+ description: Connect Slack conversations to a local DeepSeek Harness agent.
6
+ background_color: "#4A154B"
7
+ features:
8
+ app_home:
9
+ home_tab_enabled: false
10
+ messages_tab_enabled: true
11
+ messages_tab_read_only_enabled: false
12
+ bot_user:
13
+ display_name: DeepSeek Harness
14
+ always_online: false
15
+ oauth_config:
16
+ scopes:
17
+ bot:
18
+ - app_mentions:read
19
+ - chat:write
20
+ - im:history
21
+ settings:
22
+ event_subscriptions:
23
+ bot_events:
24
+ - app_mention
25
+ - message.im
26
+ org_deploy_enabled: false
27
+ socket_mode_enabled: true
28
+ token_rotation_enabled: false
29
+ `;
30
+
31
+ export const SLACK_CREATE_APP_URL = 'https://api.slack.com/apps?new_app=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 };