@zhin.js/adapter-slack 1.0.78 → 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.
Files changed (92) hide show
  1. package/CHANGELOG.md +638 -2
  2. package/README.md +112 -158
  3. package/adapters/slack.js +40 -0
  4. package/adapters/slack.ts +45 -0
  5. package/agent/PERMITS.md +24 -0
  6. package/{skills/slack/SKILL.md → agent/skills/slack.md} +2 -0
  7. package/agent/tools/add_reaction.ts +29 -0
  8. package/agent/tools/archive_channel.ts +21 -0
  9. package/agent/tools/edit_message.ts +25 -0
  10. package/agent/tools/invite_to_channel.ts +23 -0
  11. package/agent/tools/pin_message.ts +23 -0
  12. package/agent/tools/remove_reaction.ts +32 -0
  13. package/agent/tools/set_purpose.ts +23 -0
  14. package/agent/tools/set_topic.ts +23 -0
  15. package/agent/tools/unarchive.ts +21 -0
  16. package/agent/tools/unpin_message.ts +23 -0
  17. package/agent/tools/user_info.ts +29 -0
  18. package/commands/endpoint/add/[id].js +3 -0
  19. package/commands/endpoint/add/[id].ts +3 -0
  20. package/commands/endpoint/list.js +3 -0
  21. package/commands/endpoint/list.ts +3 -0
  22. package/commands/endpoint/remove/[id].js +3 -0
  23. package/commands/endpoint/remove/[id].ts +3 -0
  24. package/lib/client.d.ts +25 -0
  25. package/lib/client.js +2 -0
  26. package/lib/endpoint.d.ts +153 -0
  27. package/lib/endpoint.js +425 -0
  28. package/lib/index.d.ts +10 -10
  29. package/lib/index.js +10 -242
  30. package/lib/markdown-to-mrkdwn.d.ts +9 -0
  31. package/lib/markdown-to-mrkdwn.js +121 -0
  32. package/lib/mrkdwn-to-markdown.d.ts +4 -0
  33. package/lib/mrkdwn-to-markdown.js +30 -0
  34. package/lib/platform-permit.d.ts +15 -0
  35. package/lib/platform-permit.js +43 -0
  36. package/lib/protocol.d.ts +153 -0
  37. package/lib/protocol.js +272 -0
  38. package/lib/side-event-dispatch.d.ts +4 -0
  39. package/lib/side-event-dispatch.js +45 -0
  40. package/lib/slack-endpoint-commands.d.ts +1 -0
  41. package/lib/slack-endpoint-commands.js +18 -0
  42. package/lib/slack-inbound-filter.d.ts +12 -0
  43. package/lib/slack-inbound-filter.js +46 -0
  44. package/lib/slack-message-ref.d.ts +7 -0
  45. package/lib/slack-message-ref.js +17 -0
  46. package/lib/slack-outbound.d.ts +24 -0
  47. package/lib/slack-outbound.js +111 -0
  48. package/lib/slack-reaction.d.ts +1 -0
  49. package/lib/slack-reaction.js +26 -0
  50. package/lib/slack-response-url.d.ts +5 -0
  51. package/lib/slack-response-url.js +16 -0
  52. package/lib/slack-runtime-state.d.ts +1 -0
  53. package/lib/slack-runtime-state.js +6 -0
  54. package/lib/webhook.d.ts +14 -0
  55. package/lib/webhook.js +72 -0
  56. package/package.json +67 -15
  57. package/plugin.js +19 -0
  58. package/schema.json +95 -0
  59. package/src/client.ts +30 -0
  60. package/src/endpoint.ts +526 -0
  61. package/src/index.ts +59 -265
  62. package/src/markdown-to-mrkdwn.ts +117 -0
  63. package/src/mrkdwn-to-markdown.ts +29 -0
  64. package/src/platform-permit.ts +59 -0
  65. package/src/protocol.ts +435 -0
  66. package/src/side-event-dispatch.ts +54 -0
  67. package/src/slack-endpoint-commands.ts +19 -0
  68. package/src/slack-inbound-filter.ts +60 -0
  69. package/src/slack-message-ref.ts +18 -0
  70. package/src/slack-outbound.ts +170 -0
  71. package/src/slack-reaction.ts +23 -0
  72. package/src/slack-response-url.ts +26 -0
  73. package/src/slack-runtime-state.ts +7 -0
  74. package/src/webhook.ts +106 -0
  75. package/lib/adapter.d.ts +0 -16
  76. package/lib/adapter.d.ts.map +0 -1
  77. package/lib/adapter.js +0 -43
  78. package/lib/adapter.js.map +0 -1
  79. package/lib/bot.d.ts +0 -106
  80. package/lib/bot.d.ts.map +0 -1
  81. package/lib/bot.js +0 -628
  82. package/lib/bot.js.map +0 -1
  83. package/lib/index.d.ts.map +0 -1
  84. package/lib/index.js.map +0 -1
  85. package/lib/types.d.ts +0 -17
  86. package/lib/types.d.ts.map +0 -1
  87. package/lib/types.js +0 -2
  88. package/lib/types.js.map +0 -1
  89. package/plugin.yml +0 -3
  90. package/src/adapter.ts +0 -52
  91. package/src/bot.ts +0 -688
  92. package/src/types.ts +0 -18
@@ -0,0 +1,272 @@
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 { isMediaRef } from '@zhin.js/core';
7
+ import { formatCompact, getLogger } from '@zhin.js/logger';
8
+ import { mrkdwnToMarkdown } from './mrkdwn-to-markdown.js';
9
+ const logger = getLogger('slack');
10
+ /** 无 canonical MediaRef 或不可投递时留痕(对齐 QQ `*_outbound_media_dropped` 风格)。 */
11
+ function dropOutboundMedia(type, reason) {
12
+ logger.warn(formatCompact({ op: 'slack_outbound_media_dropped', type, reason }));
13
+ }
14
+ const SLACK_SIG_VERSION = 'v0';
15
+ const MAX_TIMESTAMP_DRIFT_SECONDS = 300;
16
+ export function resolveSlackConfig(config = {}) {
17
+ const entry = config.endpoints?.find((item) => item.context === 'slack');
18
+ const token = config.token
19
+ ?? entry?.token
20
+ ?? process.env.SLACK_BOT_TOKEN
21
+ ?? process.env.SLACK_TOKEN;
22
+ if (!token) {
23
+ throw new TypeError('Slack adapter requires token (plugins.<key>.token or endpoints with context: slack)');
24
+ }
25
+ const id = (typeof config.id === 'string' && config.id)
26
+ || (typeof entry?.id === 'string' && entry.id)
27
+ || process.env.SLACK_BOT_NAME
28
+ || 'slack-bot';
29
+ const socketMode = config.socketMode ?? entry?.socketMode;
30
+ // Prefer Socket Mode (default true) — no public URL required.
31
+ const mode = socketMode === false ? 'http' : 'socket';
32
+ const signingSecret = config.signingSecret
33
+ ?? entry?.signingSecret
34
+ ?? process.env.SLACK_SIGNING_SECRET
35
+ ?? '';
36
+ const appToken = config.appToken
37
+ ?? entry?.appToken
38
+ ?? process.env.SLACK_APP_TOKEN
39
+ ?? undefined;
40
+ if (mode === 'socket' && !appToken) {
41
+ throw new TypeError('Slack Socket Mode requires appToken (xapp-...); set socketMode: false for HTTP Events API');
42
+ }
43
+ if (mode === 'http' && !signingSecret) {
44
+ throw new TypeError('Slack HTTP Events API requires signingSecret');
45
+ }
46
+ return {
47
+ context: 'slack',
48
+ id,
49
+ token,
50
+ mode,
51
+ signingSecret,
52
+ appToken,
53
+ webhookPath: normalizeWebhookPath(config.webhookPath ?? entry?.webhookPath ?? '/slack/events'),
54
+ clientPingTimeout: config.clientPingTimeout
55
+ ?? entry?.clientPingTimeout
56
+ ?? 15_000,
57
+ };
58
+ }
59
+ export function normalizeWebhookPath(path) {
60
+ const trimmed = path.trim() || '/slack/events';
61
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
62
+ }
63
+ export function resolveSlackChannelType(event) {
64
+ return event.channel_type === 'im' ? 'private' : 'group';
65
+ }
66
+ /**
67
+ * 入站归一化 → ConversationRef:channel_type `im` → private,其余(channel/group/mpim)
68
+ * → group;交互/斜杠命令无 channel_type 时按平台 id 前缀推断(D* 为 DM)。Slack 无
69
+ * guild 容器概念(team 为 workspace 级,不进 parent);线程根 ts 进 `threadId`。
70
+ */
71
+ export function slackInboundConversation(endpointKey, opts) {
72
+ const kind = opts.channelType != null
73
+ ? (opts.channelType === 'im' ? 'private' : 'group')
74
+ : (opts.channelId.startsWith('D') ? 'private' : 'group');
75
+ return {
76
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
77
+ kind,
78
+ id: opts.channelId,
79
+ ...(opts.threadId ? { threadId: opts.threadId } : {}),
80
+ };
81
+ }
82
+ /** Build inbound text for OutboundMessageService.receive. */
83
+ export function formatInboundContent(event) {
84
+ const text = typeof event.text === 'string' ? event.text : '';
85
+ if (text)
86
+ return mrkdwnToMarkdown(text);
87
+ if ('files' in event && Array.isArray(event.files) && event.files.length > 0) {
88
+ return '[file]';
89
+ }
90
+ return '';
91
+ }
92
+ export function formatInteractionContent(payload) {
93
+ const action = payload.actions?.[0];
94
+ if (!action)
95
+ return '[action]';
96
+ const label = action.text?.text ?? action.value ?? action.action_id;
97
+ return `[action: ${action.action_id}${label ? ` ${label}` : ''}]`;
98
+ }
99
+ export function formatSlashContent(cmd) {
100
+ return `${cmd.command} ${cmd.text}`.trim();
101
+ }
102
+ /**
103
+ * Wire-encode an already-rendered outbound payload into Slack text + Block Kit blocks.
104
+ * Segment canonicalization is intentionally not done here.
105
+ */
106
+ export function formatOutboundWire(payload) {
107
+ if (typeof payload === 'string') {
108
+ return { text: payload, blocks: [], attachments: [], files: [] };
109
+ }
110
+ const items = Array.isArray(payload)
111
+ ? payload
112
+ : payload && typeof payload === 'object' && 'type' in payload
113
+ ? [payload]
114
+ : [];
115
+ if (items.length === 0) {
116
+ const text = payload == null
117
+ ? ''
118
+ : typeof payload === 'object'
119
+ ? JSON.stringify(payload)
120
+ : String(payload);
121
+ return { text, blocks: [], attachments: [], files: [] };
122
+ }
123
+ let text = '';
124
+ const blocks = [];
125
+ const attachments = [];
126
+ const files = [];
127
+ for (const item of items) {
128
+ if (typeof item === 'string') {
129
+ text += item;
130
+ continue;
131
+ }
132
+ const data = item.data ?? {};
133
+ switch (item.type) {
134
+ case 'text':
135
+ text += String(data.text ?? data.content ?? '');
136
+ break;
137
+ case 'at':
138
+ case 'mention':
139
+ text += `<@${String(data.id ?? data.target ?? '')}>`;
140
+ break;
141
+ case 'channel_mention':
142
+ text += `<#${String(data.id ?? '')}>`;
143
+ break;
144
+ case 'link':
145
+ if (data.text && data.text !== data.url) {
146
+ text += `<${String(data.url)}|${String(data.text)}>`;
147
+ }
148
+ else {
149
+ text += `<${String(data.url ?? '')}>`;
150
+ }
151
+ break;
152
+ case 'image': {
153
+ // canonical MediaRef 唯一来源;kind=url 走 attachment 直发,其余物化后 files.uploadV2 上传
154
+ const media = data.media;
155
+ if (!isMediaRef(media)) {
156
+ dropOutboundMedia(item.type, 'missing_media_ref');
157
+ break;
158
+ }
159
+ if (media.kind === 'url') {
160
+ attachments.push({
161
+ image_url: media.value,
162
+ title: String(data.alt ?? data.title ?? media.file_name ?? ''),
163
+ });
164
+ break;
165
+ }
166
+ const imageFile = resolveMediaToFile(media, String(data.alt ?? media.file_name ?? 'image'));
167
+ if (imageFile)
168
+ files.push(imageFile);
169
+ else
170
+ dropOutboundMedia(item.type, 'unsupported_media_kind');
171
+ break;
172
+ }
173
+ case 'audio':
174
+ case 'video':
175
+ case 'file': {
176
+ const media = data.media;
177
+ if (!isMediaRef(media)) {
178
+ dropOutboundMedia(item.type, 'missing_media_ref');
179
+ break;
180
+ }
181
+ const mediaFile = resolveMediaToFile(media, String(data.name ?? media.file_name ?? item.type));
182
+ if (mediaFile)
183
+ files.push(mediaFile);
184
+ else
185
+ dropOutboundMedia(item.type, 'unsupported_media_kind');
186
+ break;
187
+ }
188
+ case 'keyboard':
189
+ blocks.push(...keyboardToBlockKitBlocks(data));
190
+ break;
191
+ default:
192
+ text += String(data.text ?? `[${item.type}]`);
193
+ }
194
+ }
195
+ return { text, blocks, attachments, files };
196
+ }
197
+ export function keyboardToBlockKitBlocks(data) {
198
+ const rows = data.rows;
199
+ if (!rows?.length)
200
+ return [];
201
+ const blocks = [];
202
+ for (const row of rows) {
203
+ const elements = row.slice(0, 5).map((btn, index) => ({
204
+ type: 'button',
205
+ text: { type: 'plain_text', text: String(btn.label ?? btn.text ?? 'button').slice(0, 75) },
206
+ action_id: String(btn.id ?? btn.action_id ?? `btn_${blocks.length}_${index}`),
207
+ ...(btn.value != null ? { value: String(btn.value) } : {}),
208
+ ...(btn.style === 'primary' ? { style: 'primary' } : {}),
209
+ ...(btn.style === 'danger' ? { style: 'danger' } : {}),
210
+ }));
211
+ if (elements.length > 0) {
212
+ blocks.push({ type: 'actions', elements });
213
+ }
214
+ }
215
+ return blocks;
216
+ }
217
+ /**
218
+ * MediaRef → 待上传文件描述:
219
+ * - kind=url → 记录 URL,由 files.uploadV2 前拉取物化;
220
+ * - kind=base64 → 解码为 buffer 直传;
221
+ * - kind=path → 记录本地路径,上传前读盘;
222
+ * - kind=file → Slack 无平台不透明文件引用可直发,返回 undefined 由调用方丢弃留痕。
223
+ */
224
+ function resolveMediaToFile(media, name) {
225
+ switch (media.kind) {
226
+ case 'base64':
227
+ return {
228
+ buffer: Buffer.from(media.value.replace(/^base64:\/\//, ''), 'base64'),
229
+ name,
230
+ };
231
+ case 'url':
232
+ return { url: media.value, name };
233
+ case 'path':
234
+ return { path: media.value, name };
235
+ default:
236
+ return undefined;
237
+ }
238
+ }
239
+ export function verifySlackSignature(signingSecret, rawBody, timestamp, signature) {
240
+ const now = Math.floor(Date.now() / 1000);
241
+ const ts = parseInt(timestamp, 10);
242
+ if (Number.isNaN(ts) || Math.abs(now - ts) > MAX_TIMESTAMP_DRIFT_SECONDS) {
243
+ return false;
244
+ }
245
+ const baseString = `${SLACK_SIG_VERSION}:${timestamp}:${rawBody}`;
246
+ const hmac = createHmac('sha256', signingSecret).update(baseString).digest('hex');
247
+ const expected = `${SLACK_SIG_VERSION}=${hmac}`;
248
+ if (expected.length !== signature.length)
249
+ return false;
250
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
251
+ }
252
+ export async function readTextBody(request, options = {}) {
253
+ const limit = options.limit ?? 1_048_576;
254
+ const chunks = [];
255
+ let size = 0;
256
+ for await (const chunk of request) {
257
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
258
+ size += buffer.length;
259
+ if (size > limit) {
260
+ request.destroy();
261
+ throw new Error(`Request body exceeds ${limit} bytes`);
262
+ }
263
+ chunks.push(buffer);
264
+ }
265
+ return Buffer.concat(chunks).toString('utf8');
266
+ }
267
+ export function headerValue(headers, name) {
268
+ const raw = headers[name.toLowerCase()];
269
+ if (Array.isArray(raw))
270
+ return raw[0] ?? '';
271
+ return raw ?? '';
272
+ }
@@ -0,0 +1,4 @@
1
+ import type { EndpointEventEmitter } from 'zhin.js/adapter';
2
+ import { type getAdapterLogger } from '@zhin.js/logger';
3
+ import type { SlackEvent } from './protocol.js';
4
+ export declare function receiveSlackSideEvent(emit: EndpointEventEmitter, endpointKey: string, configId: string, event: SlackEvent, logger: ReturnType<typeof getAdapterLogger>): void;
@@ -0,0 +1,45 @@
1
+ import { buildNotice, mapNoticeParts, senderFromId, SLACK_NOTICE_PARTS_MAP } from '@zhin.js/core';
2
+ import { formatCompact } from '@zhin.js/logger';
3
+ export function receiveSlackSideEvent(emit, endpointKey, configId, event, logger) {
4
+ if (!emit)
5
+ return;
6
+ const eventType = String(event.type ?? '');
7
+ if (!Object.prototype.hasOwnProperty.call(SLACK_NOTICE_PARTS_MAP, eventType))
8
+ return;
9
+ const record = event;
10
+ const parts = mapNoticeParts('slack', eventType);
11
+ const channel = typeof record.channel === 'string' ? record.channel : undefined;
12
+ const user = typeof record.user === 'string' ? record.user : undefined;
13
+ const sceneId = channel ?? user ?? configId;
14
+ const dedupeKey = [
15
+ eventType,
16
+ channel ?? '',
17
+ user ?? '',
18
+ String(record.ts ?? record.event_ts ?? ''),
19
+ ].join(':');
20
+ void emit('notice.receive', buildNotice(record, {
21
+ $id: `slack:${dedupeKey}`,
22
+ $adapter: 'slack',
23
+ $endpoint: configId,
24
+ $type: 'notice',
25
+ $scene_id: sceneId,
26
+ $scene_type: parts.scene_type,
27
+ $sub_type: parts.sub_type,
28
+ $actor: senderFromId(user),
29
+ $target: senderFromId(typeof record.item_user === 'string' ? record.item_user : undefined),
30
+ $timestamp: toMillis(record.event_ts ?? record.ts),
31
+ })).catch((err) => {
32
+ logger.warn(formatCompact({
33
+ op: 'slack_side_event_failed',
34
+ endpoint: configId,
35
+ event: eventType,
36
+ error: err instanceof Error ? err.message : String(err),
37
+ }));
38
+ });
39
+ }
40
+ function toMillis(time) {
41
+ const value = Number(time);
42
+ if (!Number.isFinite(value) || value <= 0)
43
+ return Date.now();
44
+ return value < 1e12 ? Math.round(value * 1000) : Math.round(value);
45
+ }
@@ -0,0 +1 @@
1
+ export declare const slackEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage, string | undefined>>>;
@@ -0,0 +1,18 @@
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
+ export const slackEndpointCommands = createEndpointCommands({
9
+ adapterKey: 'slack',
10
+ adapterDisplayName: 'Slack',
11
+ fields: [
12
+ { key: 'token', required: true, env: true, description: 'Bot User OAuth Token(xoxb-...)' },
13
+ { key: 'signingSecret', env: true, description: '签名密钥(HTTP Events,socketMode: false 必填)' },
14
+ { key: 'appToken', env: true, description: 'App-Level Token(xapp-...,Socket Mode)' },
15
+ ],
16
+ running: (use) => use(slackRuntimeStateToken).endpoints.values(),
17
+ describeEntry: (entry) => `token: ${String(entry.token)}`,
18
+ }, defineCommand);
@@ -0,0 +1,12 @@
1
+ import type { SlackMessageEvent } from './protocol.js';
2
+ export interface SlackInboundFilterState {
3
+ seenInbound: Map<string, number>;
4
+ }
5
+ export declare function createSlackInboundFilterState(): SlackInboundFilterState;
6
+ /**
7
+ * 过滤不应进入 IM 管道的 Slack 入站消息。
8
+ * - 跳过 Bot 自身消息
9
+ * - 频道内 @Bot:只保留 app_mention,丢弃重复的 message 事件
10
+ * - channel:ts 去重(message + app_mention 等同一条)
11
+ */
12
+ export declare function shouldDropSlackInboundMessage(event: SlackMessageEvent, state: SlackInboundFilterState, botUserId?: string): boolean;
@@ -0,0 +1,46 @@
1
+ const DEDUPE_TTL_MS = 60_000;
2
+ export function createSlackInboundFilterState() {
3
+ return { seenInbound: new Map() };
4
+ }
5
+ function pruneSeen(state, now) {
6
+ if (state.seenInbound.size < 512)
7
+ return;
8
+ for (const [key, at] of state.seenInbound) {
9
+ if (now - at > DEDUPE_TTL_MS) {
10
+ state.seenInbound.delete(key);
11
+ }
12
+ }
13
+ }
14
+ /**
15
+ * 过滤不应进入 IM 管道的 Slack 入站消息。
16
+ * - 跳过 Bot 自身消息
17
+ * - 频道内 @Bot:只保留 app_mention,丢弃重复的 message 事件
18
+ * - channel:ts 去重(message + app_mention 等同一条)
19
+ */
20
+ export function shouldDropSlackInboundMessage(event, state, botUserId) {
21
+ if (event.subtype === 'bot_message' || event.subtype === 'message_changed') {
22
+ return true;
23
+ }
24
+ if (event.bot_id)
25
+ return true;
26
+ if (botUserId && event.user === botUserId)
27
+ return true;
28
+ const channelType = event.channel_type ?? 'channel';
29
+ const text = event.text ?? '';
30
+ if (event.type === 'message' && channelType !== 'im' && botUserId) {
31
+ if (text.includes(`<@${botUserId}>`)) {
32
+ return true;
33
+ }
34
+ }
35
+ if (!event.channel || !event.ts)
36
+ return true;
37
+ const key = `${event.channel}:${event.ts}`;
38
+ const now = Date.now();
39
+ const seenAt = state.seenInbound.get(key);
40
+ if (seenAt != null && now - seenAt < DEDUPE_TTL_MS) {
41
+ return true;
42
+ }
43
+ state.seenInbound.set(key, now);
44
+ pruneSeen(state, now);
45
+ return false;
46
+ }
@@ -0,0 +1,7 @@
1
+ /** Slack 消息定位:channel + ts(Activity Feedback / recall / reaction 共用) */
2
+ export declare function formatSlackMessageRef(channel: string, ts: string): string;
3
+ export declare function parseSlackMessageRef(ref: string): {
4
+ channel: string;
5
+ ts: string;
6
+ } | null;
7
+ export declare function slackMessageTs(ref: string): string;
@@ -0,0 +1,17 @@
1
+ /** Slack 消息定位:channel + ts(Activity Feedback / recall / reaction 共用) */
2
+ export function formatSlackMessageRef(channel, ts) {
3
+ return `${channel}:${ts}`;
4
+ }
5
+ export function parseSlackMessageRef(ref) {
6
+ const sep = ref.indexOf(':');
7
+ if (sep <= 0)
8
+ return null;
9
+ const channel = ref.slice(0, sep);
10
+ const ts = ref.slice(sep + 1);
11
+ if (!channel || !ts)
12
+ return null;
13
+ return { channel, ts };
14
+ }
15
+ export function slackMessageTs(ref) {
16
+ return parseSlackMessageRef(ref)?.ts ?? ref;
17
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Slack outbound — chat.postMessage + Block Kit + files.uploadV2
3
+ */
4
+ import type { Logger } from '@zhin.js/logger';
5
+ import { keyboardToBlockKitBlocks } from './protocol.js';
6
+ export interface SlackOutboundResult {
7
+ ts: string;
8
+ }
9
+ export interface SlackSendOptions {
10
+ channel: string;
11
+ threadTs?: string;
12
+ }
13
+ export interface SlackChatClient {
14
+ chat: {
15
+ postMessage(opts: Record<string, unknown>): Promise<{
16
+ ts?: string;
17
+ }>;
18
+ update(opts: Record<string, unknown>): Promise<unknown>;
19
+ };
20
+ filesUploadV2?(opts: Record<string, unknown>): Promise<unknown>;
21
+ }
22
+ export declare function sendSlackContent(client: SlackChatClient, content: unknown, opts: SlackSendOptions, logger: Logger): Promise<SlackOutboundResult>;
23
+ export declare function editSlackContent(client: SlackChatClient, channel: string, ts: string, content: unknown): Promise<void>;
24
+ export { keyboardToBlockKitBlocks };
@@ -0,0 +1,111 @@
1
+ import { markdownToMrkdwn, mrkdwnToPlainFallback, splitMrkdwnText } from './markdown-to-mrkdwn.js';
2
+ import { formatOutboundWire, keyboardToBlockKitBlocks } from './protocol.js';
3
+ const SLACK_MAX_BLOCKS_PER_MESSAGE = 48;
4
+ export async function sendSlackContent(client, content, opts, logger) {
5
+ const wire = formatOutboundWire(content);
6
+ const blocks = [...wire.blocks];
7
+ const textDelivery = applyTextMrkdwnBlocks(wire.text, blocks);
8
+ for (const pf of wire.files) {
9
+ try {
10
+ await uploadFile(client, opts.channel, pf, opts.threadTs);
11
+ }
12
+ catch (e) {
13
+ logger.error('Failed to upload file:', e);
14
+ throw e;
15
+ }
16
+ }
17
+ return postSlackMessage(client, {
18
+ channel: opts.channel,
19
+ threadTs: opts.threadTs,
20
+ blocks,
21
+ attachments: wire.attachments,
22
+ fallbackText: textDelivery.fallbackText,
23
+ });
24
+ }
25
+ export async function editSlackContent(client, channel, ts, content) {
26
+ const wire = formatOutboundWire(content);
27
+ const blocks = [...wire.blocks];
28
+ const textDelivery = applyTextMrkdwnBlocks(wire.text, blocks);
29
+ const payloadBlocks = blocks.slice(0, SLACK_MAX_BLOCKS_PER_MESSAGE);
30
+ const updateOpts = {
31
+ channel,
32
+ ts,
33
+ text: textDelivery.fallbackText || ' ',
34
+ };
35
+ if (payloadBlocks.length > 0)
36
+ updateOpts.blocks = payloadBlocks;
37
+ await client.chat.update(updateOpts);
38
+ }
39
+ function applyTextMrkdwnBlocks(textContent, blocks) {
40
+ const trimmed = textContent.trim();
41
+ if (!trimmed)
42
+ return { fallbackText: '' };
43
+ const mrkdwn = markdownToMrkdwn(trimmed);
44
+ const sections = splitMrkdwnText(mrkdwn).map((chunk) => ({
45
+ type: 'section',
46
+ text: { type: 'mrkdwn', text: chunk },
47
+ }));
48
+ if (blocks.length > 0) {
49
+ blocks.unshift(...sections);
50
+ return { fallbackText: '' };
51
+ }
52
+ blocks.push(...sections);
53
+ return { fallbackText: mrkdwnToPlainFallback(mrkdwn) };
54
+ }
55
+ async function postSlackMessage(client, opts) {
56
+ const { channel, blocks, attachments, fallbackText } = opts;
57
+ let threadTs = opts.threadTs;
58
+ let firstTs = '';
59
+ if (blocks.length === 0) {
60
+ const result = await client.chat.postMessage({
61
+ channel,
62
+ text: fallbackText || 'Message',
63
+ ...(threadTs ? { thread_ts: threadTs } : {}),
64
+ ...(attachments.length > 0 ? { attachments } : {}),
65
+ });
66
+ return { ts: requireMessageTimestamp(result) };
67
+ }
68
+ for (let offset = 0; offset < blocks.length; offset += SLACK_MAX_BLOCKS_PER_MESSAGE) {
69
+ const chunk = blocks.slice(offset, offset + SLACK_MAX_BLOCKS_PER_MESSAGE);
70
+ const result = await client.chat.postMessage({
71
+ channel,
72
+ text: offset === 0 ? (fallbackText || ' ') : ' ',
73
+ blocks: chunk,
74
+ ...(threadTs ? { thread_ts: threadTs } : {}),
75
+ ...(offset === 0 && attachments.length > 0 ? { attachments } : {}),
76
+ });
77
+ const ts = requireMessageTimestamp(result);
78
+ if (!firstTs)
79
+ firstTs = ts;
80
+ if (!threadTs)
81
+ threadTs = ts;
82
+ }
83
+ return { ts: firstTs };
84
+ }
85
+ function requireMessageTimestamp(result) {
86
+ if (typeof result?.ts !== 'string' || !result.ts.trim()) {
87
+ throw new Error('Slack chat.postMessage returned no valid ts; delivery is unconfirmed');
88
+ }
89
+ return result.ts;
90
+ }
91
+ async function uploadFile(client, channel, file, threadTs) {
92
+ if (!client.filesUploadV2)
93
+ throw new Error('Slack client does not support file uploads');
94
+ let buffer = file.buffer;
95
+ if (!buffer && file.path) {
96
+ const { readFile } = await import('node:fs/promises');
97
+ buffer = await readFile(file.path);
98
+ }
99
+ if (!buffer && file.url) {
100
+ const res = await fetch(file.url);
101
+ if (!res.ok)
102
+ throw new Error(`fetch ${file.url}: ${res.status}`);
103
+ buffer = Buffer.from(await res.arrayBuffer());
104
+ }
105
+ if (!buffer)
106
+ throw new Error('Slack file contains no uploadable content');
107
+ await client.filesUploadV2(threadTs
108
+ ? { channel_id: channel, file: buffer, filename: file.name ?? 'file', thread_ts: threadTs }
109
+ : { channel_id: channel, file: buffer, filename: file.name ?? 'file' });
110
+ }
111
+ export { keyboardToBlockKitBlocks };
@@ -0,0 +1 @@
1
+ export declare function normalizeSlackReactionName(emoji: string): string;
@@ -0,0 +1,26 @@
1
+ /** 将 activity-feedback / 工具入参规范为 Slack reactions.add 的 name(不含冒号) */
2
+ const UNICODE_TO_SLACK = {
3
+ '⏳': 'hourglass_flowing_sand',
4
+ '✅': 'white_check_mark',
5
+ '❌': 'x',
6
+ '⏰': 'alarm_clock',
7
+ '🤔': 'thinking_face',
8
+ '👀': 'eyes',
9
+ };
10
+ export function normalizeSlackReactionName(emoji) {
11
+ const trimmed = emoji.trim();
12
+ if (!trimmed)
13
+ return 'hourglass_flowing_sand';
14
+ if (UNICODE_TO_SLACK[trimmed])
15
+ return UNICODE_TO_SLACK[trimmed];
16
+ if (trimmed.startsWith(':') && trimmed.endsWith(':') && trimmed.length > 2) {
17
+ return trimmed.slice(1, -1);
18
+ }
19
+ let start = 0;
20
+ let end = trimmed.length;
21
+ while (start < end && trimmed[start] === ':')
22
+ start++;
23
+ while (end > start && trimmed[end - 1] === ':')
24
+ end--;
25
+ return trimmed.slice(start, end);
26
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Slack response_url 即时 ephemeral 反馈(斜杠命令 / 按钮交互)
3
+ */
4
+ import type { Logger } from '@zhin.js/logger';
5
+ export declare function postSlackEphemeral(responseUrl: string, text: string, logger?: Logger): void;
@@ -0,0 +1,16 @@
1
+ export function postSlackEphemeral(responseUrl, text, logger) {
2
+ if (!responseUrl?.trim())
3
+ return;
4
+ const body = JSON.stringify({
5
+ response_type: 'ephemeral',
6
+ text,
7
+ replace_original: false,
8
+ });
9
+ fetch(responseUrl, {
10
+ method: 'POST',
11
+ headers: { 'Content-Type': 'application/json' },
12
+ body,
13
+ }).catch((err) => {
14
+ logger?.debug(`Slack response_url failed: ${err instanceof Error ? err.message : String(err)}`);
15
+ });
16
+ }
@@ -0,0 +1 @@
1
+ export declare const slackRuntimeStateToken: import("zhin.js").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Slack 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `slack.endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from 'zhin.js/adapter';
6
+ export const slackRuntimeStateToken = defineEndpointRuntimeStateToken('slack');
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Slack webhook HTTP: signature → parse → admit.
3
+ */
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
+ import { type ResolvedSlackConfig, type SlackInteractionPayload, type SlackSlashCommand } from './protocol.js';
7
+ export interface SlackWebhookHandler {
8
+ readonly config: ResolvedSlackConfig;
9
+ handleEnvelope(body: unknown): void;
10
+ admitInteraction(payload: SlackInteractionPayload): void;
11
+ admitSlashCommand(cmd: SlackSlashCommand): void;
12
+ }
13
+ export declare function registerSlackWebhookRoutes(http: HttpHost, handler: SlackWebhookHandler): HttpRouteRegistration[];
14
+ export declare function handleSlackWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: SlackWebhookHandler): Promise<void>;