@zhin.js/adapter-slack 1.0.77 → 1.1.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/CHANGELOG.md +642 -0
- package/README.md +112 -158
- package/adapters/slack.js +40 -0
- package/adapters/slack.ts +45 -0
- package/agent/PERMITS.md +24 -0
- package/{skills/slack/SKILL.md → agent/skills/slack.md} +2 -0
- package/agent/tools/add_reaction.ts +29 -0
- package/agent/tools/archive_channel.ts +21 -0
- package/agent/tools/edit_message.ts +25 -0
- package/agent/tools/invite_to_channel.ts +23 -0
- package/agent/tools/pin_message.ts +23 -0
- package/agent/tools/remove_reaction.ts +32 -0
- package/agent/tools/set_purpose.ts +23 -0
- package/agent/tools/set_topic.ts +23 -0
- package/agent/tools/unarchive.ts +21 -0
- package/agent/tools/unpin_message.ts +23 -0
- package/agent/tools/user_info.ts +29 -0
- package/commands/endpoint/add/[id].js +3 -0
- package/commands/endpoint/add/[id].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].ts +3 -0
- package/lib/client.d.ts +25 -0
- package/lib/client.js +2 -0
- package/lib/endpoint.d.ts +153 -0
- package/lib/endpoint.js +425 -0
- package/lib/index.d.ts +10 -10
- package/lib/index.js +10 -242
- package/lib/markdown-to-mrkdwn.d.ts +9 -0
- package/lib/markdown-to-mrkdwn.js +121 -0
- package/lib/mrkdwn-to-markdown.d.ts +4 -0
- package/lib/mrkdwn-to-markdown.js +30 -0
- package/lib/platform-permit.d.ts +15 -0
- package/lib/platform-permit.js +43 -0
- package/lib/protocol.d.ts +153 -0
- package/lib/protocol.js +272 -0
- package/lib/side-event-dispatch.d.ts +4 -0
- package/lib/side-event-dispatch.js +45 -0
- package/lib/slack-endpoint-commands.d.ts +1 -0
- package/lib/slack-endpoint-commands.js +18 -0
- package/lib/slack-inbound-filter.d.ts +12 -0
- package/lib/slack-inbound-filter.js +46 -0
- package/lib/slack-message-ref.d.ts +7 -0
- package/lib/slack-message-ref.js +17 -0
- package/lib/slack-outbound.d.ts +24 -0
- package/lib/slack-outbound.js +111 -0
- package/lib/slack-reaction.d.ts +1 -0
- package/lib/slack-reaction.js +26 -0
- package/lib/slack-response-url.d.ts +5 -0
- package/lib/slack-response-url.js +16 -0
- package/lib/slack-runtime-state.d.ts +1 -0
- package/lib/slack-runtime-state.js +6 -0
- package/lib/webhook.d.ts +14 -0
- package/lib/webhook.js +72 -0
- package/package.json +67 -15
- package/plugin.js +19 -0
- package/schema.json +95 -0
- package/src/client.ts +30 -0
- package/src/endpoint.ts +526 -0
- package/src/index.ts +59 -265
- package/src/markdown-to-mrkdwn.ts +117 -0
- package/src/mrkdwn-to-markdown.ts +29 -0
- package/src/platform-permit.ts +59 -0
- package/src/protocol.ts +435 -0
- package/src/side-event-dispatch.ts +54 -0
- package/src/slack-endpoint-commands.ts +19 -0
- package/src/slack-inbound-filter.ts +60 -0
- package/src/slack-message-ref.ts +18 -0
- package/src/slack-outbound.ts +170 -0
- package/src/slack-reaction.ts +23 -0
- package/src/slack-response-url.ts +26 -0
- package/src/slack-runtime-state.ts +7 -0
- package/src/webhook.ts +106 -0
- package/lib/adapter.d.ts +0 -16
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -43
- package/lib/adapter.js.map +0 -1
- package/lib/bot.d.ts +0 -106
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -628
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts +0 -17
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -52
- package/src/bot.ts +0 -688
- package/src/types.ts +0 -18
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
*/
|
|
5
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import type { IncomingMessage } from 'node:http';
|
|
7
|
+
import { isMediaRef, type MediaRef } from '@zhin.js/core';
|
|
8
|
+
import type { ConversationKind, ConversationRef } from '@zhin.js/im-contract';
|
|
9
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
10
|
+
import { mrkdwnToMarkdown } from './mrkdwn-to-markdown.js';
|
|
11
|
+
|
|
12
|
+
const logger = getLogger('slack');
|
|
13
|
+
|
|
14
|
+
/** 无 canonical MediaRef 或不可投递时留痕(对齐 QQ `*_outbound_media_dropped` 风格)。 */
|
|
15
|
+
function dropOutboundMedia(type: string, reason: string): void {
|
|
16
|
+
logger.warn(formatCompact({ op: 'slack_outbound_media_dropped', type, reason }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SLACK_SIG_VERSION = 'v0';
|
|
20
|
+
const MAX_TIMESTAMP_DRIFT_SECONDS = 300;
|
|
21
|
+
|
|
22
|
+
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
23
|
+
export interface SlackAdapterConfig {
|
|
24
|
+
readonly id?: string;
|
|
25
|
+
readonly token?: string;
|
|
26
|
+
readonly signingSecret?: string;
|
|
27
|
+
readonly appToken?: string;
|
|
28
|
+
/** Default true (Socket Mode). Set false for HTTP Events API via httpHostToken. */
|
|
29
|
+
readonly socketMode?: boolean;
|
|
30
|
+
readonly webhookPath?: string;
|
|
31
|
+
readonly clientPingTimeout?: number;
|
|
32
|
+
/** Transitional: legacy root `endpoints[]` with `context: slack`. */
|
|
33
|
+
readonly endpoints?: ReadonlyArray<Partial<ResolvedSlackConfig> & {
|
|
34
|
+
readonly context?: string;
|
|
35
|
+
readonly socketMode?: boolean;
|
|
36
|
+
readonly signingSecret?: string;
|
|
37
|
+
readonly appToken?: string;
|
|
38
|
+
readonly webhookPath?: string;
|
|
39
|
+
readonly clientPingTimeout?: number;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ResolvedSlackConfig {
|
|
44
|
+
readonly context: 'slack';
|
|
45
|
+
readonly id: string;
|
|
46
|
+
readonly token: string;
|
|
47
|
+
readonly mode: 'socket' | 'http';
|
|
48
|
+
readonly signingSecret: string;
|
|
49
|
+
readonly appToken?: string;
|
|
50
|
+
readonly webhookPath: string;
|
|
51
|
+
readonly clientPingTimeout: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SlackEventEnvelope {
|
|
55
|
+
readonly token?: string;
|
|
56
|
+
readonly team_id?: string;
|
|
57
|
+
readonly api_app_id?: string;
|
|
58
|
+
readonly event: SlackEvent;
|
|
59
|
+
readonly type: 'event_callback';
|
|
60
|
+
readonly event_id?: string;
|
|
61
|
+
readonly event_time?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SlackUrlVerification {
|
|
65
|
+
readonly type: 'url_verification';
|
|
66
|
+
readonly token: string;
|
|
67
|
+
readonly challenge: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SlackInteractionPayload {
|
|
71
|
+
readonly type: 'block_actions' | 'message_action' | 'shortcut' | 'view_submission' | 'view_closed';
|
|
72
|
+
readonly trigger_id?: string;
|
|
73
|
+
readonly user: { id: string; username?: string; name?: string; team_id?: string };
|
|
74
|
+
readonly channel?: { id: string; name?: string };
|
|
75
|
+
readonly message?: { ts: string; text?: string; [key: string]: unknown };
|
|
76
|
+
readonly actions?: SlackBlockAction[];
|
|
77
|
+
readonly response_url?: string;
|
|
78
|
+
readonly [key: string]: unknown;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SlackBlockAction {
|
|
82
|
+
readonly type: string;
|
|
83
|
+
readonly action_id: string;
|
|
84
|
+
readonly block_id: string;
|
|
85
|
+
readonly value?: string;
|
|
86
|
+
readonly text?: { type: string; text: string };
|
|
87
|
+
readonly action_ts?: string;
|
|
88
|
+
readonly [key: string]: unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SlackSlashCommand {
|
|
92
|
+
readonly token: string;
|
|
93
|
+
readonly team_id: string;
|
|
94
|
+
readonly channel_id: string;
|
|
95
|
+
readonly channel_name: string;
|
|
96
|
+
readonly user_id: string;
|
|
97
|
+
readonly user_name: string;
|
|
98
|
+
readonly command: string;
|
|
99
|
+
readonly text: string;
|
|
100
|
+
readonly response_url: string;
|
|
101
|
+
readonly trigger_id: string;
|
|
102
|
+
readonly api_app_id?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SlackEvent {
|
|
106
|
+
readonly type: string;
|
|
107
|
+
readonly ts?: string;
|
|
108
|
+
readonly event_ts?: string;
|
|
109
|
+
readonly user?: string;
|
|
110
|
+
readonly channel?: string;
|
|
111
|
+
readonly channel_type?: string;
|
|
112
|
+
readonly text?: string;
|
|
113
|
+
readonly thread_ts?: string;
|
|
114
|
+
readonly subtype?: string;
|
|
115
|
+
readonly bot_id?: string;
|
|
116
|
+
readonly [key: string]: unknown;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type SlackMessageEvent = SlackEvent & {
|
|
120
|
+
readonly type: 'message' | 'app_mention';
|
|
121
|
+
readonly ts: string;
|
|
122
|
+
readonly channel: string;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export interface SlackWireSegment {
|
|
126
|
+
readonly type: string;
|
|
127
|
+
readonly data?: Record<string, unknown>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function resolveSlackConfig(config: SlackAdapterConfig = {}): ResolvedSlackConfig {
|
|
131
|
+
const entry = config.endpoints?.find((item) => item.context === 'slack');
|
|
132
|
+
const token = config.token
|
|
133
|
+
?? entry?.token
|
|
134
|
+
?? process.env.SLACK_BOT_TOKEN
|
|
135
|
+
?? process.env.SLACK_TOKEN;
|
|
136
|
+
if (!token) {
|
|
137
|
+
throw new TypeError(
|
|
138
|
+
'Slack adapter requires token (plugins.<key>.token or endpoints with context: slack)',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const id = (typeof config.id === 'string' && config.id)
|
|
143
|
+
|| (typeof entry?.id === 'string' && entry.id)
|
|
144
|
+
|| process.env.SLACK_BOT_NAME
|
|
145
|
+
|| 'slack-bot';
|
|
146
|
+
|
|
147
|
+
const socketMode = config.socketMode ?? entry?.socketMode;
|
|
148
|
+
// Prefer Socket Mode (default true) — no public URL required.
|
|
149
|
+
const mode: 'socket' | 'http' = socketMode === false ? 'http' : 'socket';
|
|
150
|
+
|
|
151
|
+
const signingSecret = config.signingSecret
|
|
152
|
+
?? entry?.signingSecret
|
|
153
|
+
?? process.env.SLACK_SIGNING_SECRET
|
|
154
|
+
?? '';
|
|
155
|
+
const appToken = config.appToken
|
|
156
|
+
?? entry?.appToken
|
|
157
|
+
?? process.env.SLACK_APP_TOKEN
|
|
158
|
+
?? undefined;
|
|
159
|
+
|
|
160
|
+
if (mode === 'socket' && !appToken) {
|
|
161
|
+
throw new TypeError(
|
|
162
|
+
'Slack Socket Mode requires appToken (xapp-...); set socketMode: false for HTTP Events API',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (mode === 'http' && !signingSecret) {
|
|
166
|
+
throw new TypeError(
|
|
167
|
+
'Slack HTTP Events API requires signingSecret',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
context: 'slack',
|
|
173
|
+
id,
|
|
174
|
+
token,
|
|
175
|
+
mode,
|
|
176
|
+
signingSecret,
|
|
177
|
+
appToken,
|
|
178
|
+
webhookPath: normalizeWebhookPath(
|
|
179
|
+
config.webhookPath ?? entry?.webhookPath ?? '/slack/events',
|
|
180
|
+
),
|
|
181
|
+
clientPingTimeout: config.clientPingTimeout
|
|
182
|
+
?? entry?.clientPingTimeout
|
|
183
|
+
?? 15_000,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function normalizeWebhookPath(path: string): string {
|
|
188
|
+
const trimmed = path.trim() || '/slack/events';
|
|
189
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function resolveSlackChannelType(event: Pick<SlackEvent, 'channel_type'>): 'private' | 'group' {
|
|
193
|
+
return event.channel_type === 'im' ? 'private' : 'group';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 入站归一化 → ConversationRef:channel_type `im` → private,其余(channel/group/mpim)
|
|
198
|
+
* → group;交互/斜杠命令无 channel_type 时按平台 id 前缀推断(D* 为 DM)。Slack 无
|
|
199
|
+
* guild 容器概念(team 为 workspace 级,不进 parent);线程根 ts 进 `threadId`。
|
|
200
|
+
*/
|
|
201
|
+
export function slackInboundConversation(
|
|
202
|
+
endpointKey: string,
|
|
203
|
+
opts: {
|
|
204
|
+
readonly channelId: string;
|
|
205
|
+
readonly channelType?: string;
|
|
206
|
+
readonly threadId?: string;
|
|
207
|
+
},
|
|
208
|
+
): ConversationRef {
|
|
209
|
+
const kind: ConversationKind = opts.channelType != null
|
|
210
|
+
? (opts.channelType === 'im' ? 'private' : 'group')
|
|
211
|
+
: (opts.channelId.startsWith('D') ? 'private' : 'group');
|
|
212
|
+
return {
|
|
213
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
214
|
+
kind,
|
|
215
|
+
id: opts.channelId,
|
|
216
|
+
...(opts.threadId ? { threadId: opts.threadId } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Build inbound text for OutboundMessageService.receive. */
|
|
221
|
+
export function formatInboundContent(event: SlackMessageEvent | SlackEvent): string {
|
|
222
|
+
const text = typeof event.text === 'string' ? event.text : '';
|
|
223
|
+
if (text) return mrkdwnToMarkdown(text);
|
|
224
|
+
if ('files' in event && Array.isArray(event.files) && event.files.length > 0) {
|
|
225
|
+
return '[file]';
|
|
226
|
+
}
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function formatInteractionContent(payload: SlackInteractionPayload): string {
|
|
231
|
+
const action = payload.actions?.[0];
|
|
232
|
+
if (!action) return '[action]';
|
|
233
|
+
const label = action.text?.text ?? action.value ?? action.action_id;
|
|
234
|
+
return `[action: ${action.action_id}${label ? ` ${label}` : ''}]`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function formatSlashContent(cmd: SlackSlashCommand): string {
|
|
238
|
+
return `${cmd.command} ${cmd.text}`.trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Wire-encode an already-rendered outbound payload into Slack text + Block Kit blocks.
|
|
243
|
+
* Segment canonicalization is intentionally not done here.
|
|
244
|
+
*/
|
|
245
|
+
export function formatOutboundWire(payload: unknown): {
|
|
246
|
+
text: string;
|
|
247
|
+
blocks: Record<string, unknown>[];
|
|
248
|
+
attachments: Record<string, unknown>[];
|
|
249
|
+
files: Array<{ buffer?: Buffer; url?: string; path?: string; name?: string }>;
|
|
250
|
+
} {
|
|
251
|
+
if (typeof payload === 'string') {
|
|
252
|
+
return { text: payload, blocks: [], attachments: [], files: [] };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const items: Array<string | SlackWireSegment> = Array.isArray(payload)
|
|
256
|
+
? payload as Array<string | SlackWireSegment>
|
|
257
|
+
: payload && typeof payload === 'object' && 'type' in (payload as object)
|
|
258
|
+
? [payload as SlackWireSegment]
|
|
259
|
+
: [];
|
|
260
|
+
|
|
261
|
+
if (items.length === 0) {
|
|
262
|
+
const text = payload == null
|
|
263
|
+
? ''
|
|
264
|
+
: typeof payload === 'object'
|
|
265
|
+
? JSON.stringify(payload)
|
|
266
|
+
: String(payload);
|
|
267
|
+
return { text, blocks: [], attachments: [], files: [] };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let text = '';
|
|
271
|
+
const blocks: Record<string, unknown>[] = [];
|
|
272
|
+
const attachments: Record<string, unknown>[] = [];
|
|
273
|
+
const files: Array<{ buffer?: Buffer; url?: string; path?: string; name?: string }> = [];
|
|
274
|
+
|
|
275
|
+
for (const item of items) {
|
|
276
|
+
if (typeof item === 'string') {
|
|
277
|
+
text += item;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const data = item.data ?? {};
|
|
281
|
+
switch (item.type) {
|
|
282
|
+
case 'text':
|
|
283
|
+
text += String(data.text ?? data.content ?? '');
|
|
284
|
+
break;
|
|
285
|
+
case 'at':
|
|
286
|
+
case 'mention':
|
|
287
|
+
text += `<@${String(data.id ?? data.target ?? '')}>`;
|
|
288
|
+
break;
|
|
289
|
+
case 'channel_mention':
|
|
290
|
+
text += `<#${String(data.id ?? '')}>`;
|
|
291
|
+
break;
|
|
292
|
+
case 'link':
|
|
293
|
+
if (data.text && data.text !== data.url) {
|
|
294
|
+
text += `<${String(data.url)}|${String(data.text)}>`;
|
|
295
|
+
} else {
|
|
296
|
+
text += `<${String(data.url ?? '')}>`;
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
case 'image': {
|
|
300
|
+
// canonical MediaRef 唯一来源;kind=url 走 attachment 直发,其余物化后 files.uploadV2 上传
|
|
301
|
+
const media = data.media;
|
|
302
|
+
if (!isMediaRef(media)) {
|
|
303
|
+
dropOutboundMedia(item.type, 'missing_media_ref');
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
if (media.kind === 'url') {
|
|
307
|
+
attachments.push({
|
|
308
|
+
image_url: media.value,
|
|
309
|
+
title: String(data.alt ?? data.title ?? media.file_name ?? ''),
|
|
310
|
+
});
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
const imageFile = resolveMediaToFile(media, String(data.alt ?? media.file_name ?? 'image'));
|
|
314
|
+
if (imageFile) files.push(imageFile);
|
|
315
|
+
else dropOutboundMedia(item.type, 'unsupported_media_kind');
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
case 'audio':
|
|
319
|
+
case 'video':
|
|
320
|
+
case 'file': {
|
|
321
|
+
const media = data.media;
|
|
322
|
+
if (!isMediaRef(media)) {
|
|
323
|
+
dropOutboundMedia(item.type, 'missing_media_ref');
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
const mediaFile = resolveMediaToFile(media, String(data.name ?? media.file_name ?? item.type));
|
|
327
|
+
if (mediaFile) files.push(mediaFile);
|
|
328
|
+
else dropOutboundMedia(item.type, 'unsupported_media_kind');
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
case 'keyboard':
|
|
332
|
+
blocks.push(...keyboardToBlockKitBlocks(data));
|
|
333
|
+
break;
|
|
334
|
+
default:
|
|
335
|
+
text += String(data.text ?? `[${item.type}]`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { text, blocks, attachments, files };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function keyboardToBlockKitBlocks(data: Record<string, unknown>): Record<string, unknown>[] {
|
|
343
|
+
const rows = data.rows as Array<Array<Record<string, unknown>>> | undefined;
|
|
344
|
+
if (!rows?.length) return [];
|
|
345
|
+
|
|
346
|
+
const blocks: Record<string, unknown>[] = [];
|
|
347
|
+
for (const row of rows) {
|
|
348
|
+
const elements = row.slice(0, 5).map((btn, index) => ({
|
|
349
|
+
type: 'button',
|
|
350
|
+
text: { type: 'plain_text', text: String(btn.label ?? btn.text ?? 'button').slice(0, 75) },
|
|
351
|
+
action_id: String(btn.id ?? btn.action_id ?? `btn_${blocks.length}_${index}`),
|
|
352
|
+
...(btn.value != null ? { value: String(btn.value) } : {}),
|
|
353
|
+
...(btn.style === 'primary' ? { style: 'primary' } : {}),
|
|
354
|
+
...(btn.style === 'danger' ? { style: 'danger' } : {}),
|
|
355
|
+
}));
|
|
356
|
+
if (elements.length > 0) {
|
|
357
|
+
blocks.push({ type: 'actions', elements });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return blocks;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* MediaRef → 待上传文件描述:
|
|
365
|
+
* - kind=url → 记录 URL,由 files.uploadV2 前拉取物化;
|
|
366
|
+
* - kind=base64 → 解码为 buffer 直传;
|
|
367
|
+
* - kind=path → 记录本地路径,上传前读盘;
|
|
368
|
+
* - kind=file → Slack 无平台不透明文件引用可直发,返回 undefined 由调用方丢弃留痕。
|
|
369
|
+
*/
|
|
370
|
+
function resolveMediaToFile(
|
|
371
|
+
media: MediaRef,
|
|
372
|
+
name: string,
|
|
373
|
+
): { buffer?: Buffer; url?: string; path?: string; name: string } | undefined {
|
|
374
|
+
switch (media.kind) {
|
|
375
|
+
case 'base64':
|
|
376
|
+
return {
|
|
377
|
+
buffer: Buffer.from(media.value.replace(/^base64:\/\//, ''), 'base64'),
|
|
378
|
+
name,
|
|
379
|
+
};
|
|
380
|
+
case 'url':
|
|
381
|
+
return { url: media.value, name };
|
|
382
|
+
case 'path':
|
|
383
|
+
return { path: media.value, name };
|
|
384
|
+
default:
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function verifySlackSignature(
|
|
390
|
+
signingSecret: string,
|
|
391
|
+
rawBody: string,
|
|
392
|
+
timestamp: string,
|
|
393
|
+
signature: string,
|
|
394
|
+
): boolean {
|
|
395
|
+
const now = Math.floor(Date.now() / 1000);
|
|
396
|
+
const ts = parseInt(timestamp, 10);
|
|
397
|
+
if (Number.isNaN(ts) || Math.abs(now - ts) > MAX_TIMESTAMP_DRIFT_SECONDS) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const baseString = `${SLACK_SIG_VERSION}:${timestamp}:${rawBody}`;
|
|
402
|
+
const hmac = createHmac('sha256', signingSecret).update(baseString).digest('hex');
|
|
403
|
+
const expected = `${SLACK_SIG_VERSION}=${hmac}`;
|
|
404
|
+
|
|
405
|
+
if (expected.length !== signature.length) return false;
|
|
406
|
+
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function readTextBody(
|
|
410
|
+
request: IncomingMessage,
|
|
411
|
+
options: { readonly limit?: number } = {},
|
|
412
|
+
): Promise<string> {
|
|
413
|
+
const limit = options.limit ?? 1_048_576;
|
|
414
|
+
const chunks: Buffer[] = [];
|
|
415
|
+
let size = 0;
|
|
416
|
+
for await (const chunk of request) {
|
|
417
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
418
|
+
size += buffer.length;
|
|
419
|
+
if (size > limit) {
|
|
420
|
+
request.destroy();
|
|
421
|
+
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
422
|
+
}
|
|
423
|
+
chunks.push(buffer);
|
|
424
|
+
}
|
|
425
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function headerValue(
|
|
429
|
+
headers: IncomingMessage['headers'],
|
|
430
|
+
name: string,
|
|
431
|
+
): string {
|
|
432
|
+
const raw = headers[name.toLowerCase()];
|
|
433
|
+
if (Array.isArray(raw)) return raw[0] ?? '';
|
|
434
|
+
return raw ?? '';
|
|
435
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { buildNotice, mapNoticeParts, senderFromId, SLACK_NOTICE_PARTS_MAP } from '@zhin.js/core';
|
|
2
|
+
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
3
|
+
import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
|
|
4
|
+
import type { SlackEvent } from './protocol.js';
|
|
5
|
+
|
|
6
|
+
export function receiveSlackSideEvent(
|
|
7
|
+
emit: EndpointEventEmitter,
|
|
8
|
+
endpointKey: string,
|
|
9
|
+
configId: string,
|
|
10
|
+
event: SlackEvent,
|
|
11
|
+
logger: ReturnType<typeof getAdapterLogger>,
|
|
12
|
+
): void {
|
|
13
|
+
if (!emit) return;
|
|
14
|
+
const eventType = String(event.type ?? '');
|
|
15
|
+
if (!Object.prototype.hasOwnProperty.call(SLACK_NOTICE_PARTS_MAP, eventType)) return;
|
|
16
|
+
const record = event as Record<string, unknown>;
|
|
17
|
+
const parts = mapNoticeParts('slack', eventType);
|
|
18
|
+
const channel = typeof record.channel === 'string' ? record.channel : undefined;
|
|
19
|
+
const user = typeof record.user === 'string' ? record.user : undefined;
|
|
20
|
+
const sceneId = channel ?? user ?? configId;
|
|
21
|
+
const dedupeKey = [
|
|
22
|
+
eventType,
|
|
23
|
+
channel ?? '',
|
|
24
|
+
user ?? '',
|
|
25
|
+
String(record.ts ?? record.event_ts ?? ''),
|
|
26
|
+
].join(':');
|
|
27
|
+
void emit('notice.receive', buildNotice(record, {
|
|
28
|
+
$id: `slack:${dedupeKey}`,
|
|
29
|
+
$adapter: 'slack' as never,
|
|
30
|
+
$endpoint: configId,
|
|
31
|
+
$type: 'notice',
|
|
32
|
+
$scene_id: sceneId,
|
|
33
|
+
$scene_type: parts.scene_type,
|
|
34
|
+
$sub_type: parts.sub_type,
|
|
35
|
+
$actor: senderFromId(user),
|
|
36
|
+
$target: senderFromId(
|
|
37
|
+
typeof record.item_user === 'string' ? record.item_user : undefined,
|
|
38
|
+
),
|
|
39
|
+
$timestamp: toMillis(record.event_ts ?? record.ts),
|
|
40
|
+
})).catch((err) => {
|
|
41
|
+
logger.warn(formatCompact({
|
|
42
|
+
op: 'slack_side_event_failed',
|
|
43
|
+
endpoint: configId,
|
|
44
|
+
event: eventType,
|
|
45
|
+
error: err instanceof Error ? err.message : String(err),
|
|
46
|
+
}));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toMillis(time: unknown): number {
|
|
51
|
+
const value = Number(time);
|
|
52
|
+
if (!Number.isFinite(value) || value <= 0) return Date.now();
|
|
53
|
+
return value < 1e12 ? Math.round(value * 1000) : Math.round(value);
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `slack.endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
|
+
*/
|
|
5
|
+
import { createEndpointCommands } from 'zhin.js/adapter';
|
|
6
|
+
import { defineCommand } from 'zhin.js/command';
|
|
7
|
+
import { slackRuntimeStateToken } from './slack-runtime-state.js';
|
|
8
|
+
|
|
9
|
+
export const slackEndpointCommands = createEndpointCommands({
|
|
10
|
+
adapterKey: 'slack',
|
|
11
|
+
adapterDisplayName: 'Slack',
|
|
12
|
+
fields: [
|
|
13
|
+
{ key: 'token', required: true, env: true, description: 'Bot User OAuth Token(xoxb-...)' },
|
|
14
|
+
{ key: 'signingSecret', env: true, description: '签名密钥(HTTP Events,socketMode: false 必填)' },
|
|
15
|
+
{ key: 'appToken', env: true, description: 'App-Level Token(xapp-...,Socket Mode)' },
|
|
16
|
+
],
|
|
17
|
+
running: (use) => use(slackRuntimeStateToken).endpoints.values(),
|
|
18
|
+
describeEntry: (entry) => `token: ${String(entry.token)}`,
|
|
19
|
+
}, defineCommand);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { SlackMessageEvent } from './protocol.js';
|
|
2
|
+
|
|
3
|
+
const DEDUPE_TTL_MS = 60_000;
|
|
4
|
+
|
|
5
|
+
export interface SlackInboundFilterState {
|
|
6
|
+
seenInbound: Map<string, number>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createSlackInboundFilterState(): SlackInboundFilterState {
|
|
10
|
+
return { seenInbound: new Map() };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function pruneSeen(state: SlackInboundFilterState, now: number): void {
|
|
14
|
+
if (state.seenInbound.size < 512) return;
|
|
15
|
+
for (const [key, at] of state.seenInbound) {
|
|
16
|
+
if (now - at > DEDUPE_TTL_MS) {
|
|
17
|
+
state.seenInbound.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 过滤不应进入 IM 管道的 Slack 入站消息。
|
|
24
|
+
* - 跳过 Bot 自身消息
|
|
25
|
+
* - 频道内 @Bot:只保留 app_mention,丢弃重复的 message 事件
|
|
26
|
+
* - channel:ts 去重(message + app_mention 等同一条)
|
|
27
|
+
*/
|
|
28
|
+
export function shouldDropSlackInboundMessage(
|
|
29
|
+
event: SlackMessageEvent,
|
|
30
|
+
state: SlackInboundFilterState,
|
|
31
|
+
botUserId?: string,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (event.subtype === 'bot_message' || event.subtype === 'message_changed') {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (event.bot_id) return true;
|
|
38
|
+
if (botUserId && event.user === botUserId) return true;
|
|
39
|
+
|
|
40
|
+
const channelType = event.channel_type ?? 'channel';
|
|
41
|
+
const text = event.text ?? '';
|
|
42
|
+
|
|
43
|
+
if (event.type === 'message' && channelType !== 'im' && botUserId) {
|
|
44
|
+
if (text.includes(`<@${botUserId}>`)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!event.channel || !event.ts) return true;
|
|
50
|
+
|
|
51
|
+
const key = `${event.channel}:${event.ts}`;
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const seenAt = state.seenInbound.get(key);
|
|
54
|
+
if (seenAt != null && now - seenAt < DEDUPE_TTL_MS) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
state.seenInbound.set(key, now);
|
|
58
|
+
pruneSeen(state, now);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Slack 消息定位:channel + ts(Activity Feedback / recall / reaction 共用) */
|
|
2
|
+
|
|
3
|
+
export function formatSlackMessageRef(channel: string, ts: string): string {
|
|
4
|
+
return `${channel}:${ts}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function parseSlackMessageRef(ref: string): { channel: string; ts: string } | null {
|
|
8
|
+
const sep = ref.indexOf(':');
|
|
9
|
+
if (sep <= 0) return null;
|
|
10
|
+
const channel = ref.slice(0, sep);
|
|
11
|
+
const ts = ref.slice(sep + 1);
|
|
12
|
+
if (!channel || !ts) return null;
|
|
13
|
+
return { channel, ts };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function slackMessageTs(ref: string): string {
|
|
17
|
+
return parseSlackMessageRef(ref)?.ts ?? ref;
|
|
18
|
+
}
|