@zhin.js/adapter-slack 4.0.1 → 4.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 (118) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +207 -98
  3. package/{skills/slack/SKILL.md → agent/skills/slack.md} +2 -0
  4. package/agent/tools/add_reaction.ts +26 -0
  5. package/agent/tools/archive_channel.ts +24 -0
  6. package/agent/tools/edit_message.ts +27 -0
  7. package/agent/tools/invite_to_channel.ts +26 -0
  8. package/agent/tools/pin_message.ts +26 -0
  9. package/agent/tools/remove_reaction.ts +26 -0
  10. package/agent/tools/set_purpose.ts +26 -0
  11. package/agent/tools/set_topic.ts +26 -0
  12. package/agent/tools/unarchive.ts +24 -0
  13. package/agent/tools/unpin_message.ts +26 -0
  14. package/agent/tools/user_info.ts +31 -0
  15. package/lib/agent/tools/add_reaction.js +21 -0
  16. package/lib/agent/tools/add_reaction.js.map +1 -0
  17. package/lib/agent/tools/archive_channel.js +21 -0
  18. package/lib/agent/tools/archive_channel.js.map +1 -0
  19. package/lib/agent/tools/edit_message.js +22 -0
  20. package/lib/agent/tools/edit_message.js.map +1 -0
  21. package/lib/agent/tools/invite_to_channel.js +22 -0
  22. package/lib/agent/tools/invite_to_channel.js.map +1 -0
  23. package/lib/agent/tools/pin_message.js +22 -0
  24. package/lib/agent/tools/pin_message.js.map +1 -0
  25. package/lib/agent/tools/remove_reaction.js +21 -0
  26. package/lib/agent/tools/remove_reaction.js.map +1 -0
  27. package/lib/agent/tools/set_purpose.js +22 -0
  28. package/lib/agent/tools/set_purpose.js.map +1 -0
  29. package/lib/agent/tools/set_topic.js +22 -0
  30. package/lib/agent/tools/set_topic.js.map +1 -0
  31. package/lib/agent/tools/unarchive.js +21 -0
  32. package/lib/agent/tools/unarchive.js.map +1 -0
  33. package/lib/agent/tools/unpin_message.js +22 -0
  34. package/lib/agent/tools/unpin_message.js.map +1 -0
  35. package/lib/agent/tools/user_info.js +28 -0
  36. package/lib/agent/tools/user_info.js.map +1 -0
  37. package/lib/{adapter.js → src/adapter.js} +19 -3
  38. package/lib/src/adapter.js.map +1 -0
  39. package/lib/src/endpoint.js +312 -0
  40. package/lib/src/endpoint.js.map +1 -0
  41. package/lib/src/event-dispatcher.js +233 -0
  42. package/lib/src/event-dispatcher.js.map +1 -0
  43. package/lib/src/index.js +51 -0
  44. package/lib/src/index.js.map +1 -0
  45. package/lib/src/markdown-to-mrkdwn.js +61 -0
  46. package/lib/src/markdown-to-mrkdwn.js.map +1 -0
  47. package/lib/src/mrkdwn-to-markdown.js +31 -0
  48. package/lib/src/mrkdwn-to-markdown.js.map +1 -0
  49. package/lib/{platform-permit.js → src/platform-permit.js} +3 -0
  50. package/lib/src/platform-permit.js.map +1 -0
  51. package/lib/src/segment-mapper.js.map +1 -0
  52. package/lib/src/signing.js +21 -0
  53. package/lib/src/signing.js.map +1 -0
  54. package/lib/src/slack-agent-deps.js +10 -0
  55. package/lib/src/slack-agent-deps.js.map +1 -0
  56. package/lib/src/slack-inbound-filter.js +47 -0
  57. package/lib/src/slack-inbound-filter.js.map +1 -0
  58. package/lib/src/slack-inbound.js +127 -0
  59. package/lib/src/slack-inbound.js.map +1 -0
  60. package/lib/src/slack-message-ref.js +18 -0
  61. package/lib/src/slack-message-ref.js.map +1 -0
  62. package/lib/src/slack-outbound.js +223 -0
  63. package/lib/src/slack-outbound.js.map +1 -0
  64. package/lib/src/slack-reaction.js +27 -0
  65. package/lib/src/slack-reaction.js.map +1 -0
  66. package/lib/src/slack-response-url.js +17 -0
  67. package/lib/src/slack-response-url.js.map +1 -0
  68. package/lib/src/slack-side-events.js +64 -0
  69. package/lib/src/slack-side-events.js.map +1 -0
  70. package/lib/src/transport-http.js +83 -0
  71. package/lib/src/transport-http.js.map +1 -0
  72. package/lib/src/transport-socket.js +68 -0
  73. package/lib/src/transport-socket.js.map +1 -0
  74. package/lib/src/types.js +5 -0
  75. package/lib/src/types.js.map +1 -0
  76. package/package.json +24 -6
  77. package/src/adapter.ts +28 -7
  78. package/src/endpoint.ts +206 -604
  79. package/src/event-dispatcher.ts +255 -0
  80. package/src/index.ts +31 -239
  81. package/src/markdown-to-mrkdwn.ts +58 -0
  82. package/src/mrkdwn-to-markdown.ts +29 -0
  83. package/src/platform-permit.ts +1 -2
  84. package/src/signing.ts +28 -0
  85. package/src/slack-agent-deps.ts +22 -0
  86. package/src/slack-inbound-filter.ts +60 -0
  87. package/src/slack-inbound.ts +140 -0
  88. package/src/slack-message-ref.ts +18 -0
  89. package/src/slack-outbound.ts +269 -0
  90. package/src/slack-reaction.ts +23 -0
  91. package/src/slack-response-url.ts +26 -0
  92. package/src/slack-side-events.ts +74 -0
  93. package/src/transport-http.ts +95 -0
  94. package/src/transport-socket.ts +77 -0
  95. package/src/types.ts +99 -4
  96. package/lib/adapter.d.ts +0 -19
  97. package/lib/adapter.d.ts.map +0 -1
  98. package/lib/adapter.js.map +0 -1
  99. package/lib/endpoint.d.ts +0 -108
  100. package/lib/endpoint.d.ts.map +0 -1
  101. package/lib/endpoint.js +0 -677
  102. package/lib/endpoint.js.map +0 -1
  103. package/lib/index.d.ts +0 -10
  104. package/lib/index.d.ts.map +0 -1
  105. package/lib/index.js +0 -252
  106. package/lib/index.js.map +0 -1
  107. package/lib/platform-permit.d.ts +0 -17
  108. package/lib/platform-permit.d.ts.map +0 -1
  109. package/lib/platform-permit.js.map +0 -1
  110. package/lib/segment-mapper.d.ts +0 -2
  111. package/lib/segment-mapper.d.ts.map +0 -1
  112. package/lib/segment-mapper.js.map +0 -1
  113. package/lib/types.d.ts +0 -17
  114. package/lib/types.d.ts.map +0 -1
  115. package/lib/types.js +0 -2
  116. package/lib/types.js.map +0 -1
  117. /package/{skills/slack → agent}/PERMITS.md +0 -0
  118. /package/lib/{segment-mapper.js → src/segment-mapper.js} +0 -0
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared runtime deps for slack agent/ authoring tools.
3
+ * Set once from plugin index during init — never call getPlugin() from tool execute.
4
+ */
5
+ import type { SlackAdapter } from './adapter.js';
6
+ import type { SlackEndpoint } from './endpoint.js';
7
+
8
+ export interface SlackAgentDeps {
9
+ getEndpoint: (endpointId: string) => SlackEndpoint;
10
+ getAdapter: () => SlackAdapter;
11
+ }
12
+
13
+ let _deps: SlackAgentDeps | null = null;
14
+
15
+ export function setSlackAgentDeps(deps: SlackAgentDeps): void {
16
+ _deps = deps;
17
+ }
18
+
19
+ export function getSlackAgentDeps(): SlackAgentDeps {
20
+ if (!_deps) throw new Error('slack agent deps not initialized');
21
+ return _deps;
22
+ }
@@ -0,0 +1,60 @@
1
+ import type { SlackMessageEvent } from './types.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,140 @@
1
+ /**
2
+ * Slack 入站消息解析 — 将 Slack mrkdwn 文本 + attachments + files 转为 wire segments
3
+ */
4
+ import type { MessageSegment } from 'zhin.js';
5
+ import type { SlackMessageEvent, SlackEvent } from './types.js';
6
+ import { mrkdwnToMarkdown } from './mrkdwn-to-markdown.js';
7
+
8
+ export function parseSlackMessageToSegments(msg: SlackMessageEvent): MessageSegment[] {
9
+ const segments: MessageSegment[] = [];
10
+
11
+ if (msg.text) {
12
+ segments.push(...parseSlackMrkdwn(msg.text));
13
+ }
14
+
15
+ if ('files' in msg && Array.isArray(msg.files)) {
16
+ for (const file of msg.files as Record<string, unknown>[]) {
17
+ segments.push(parseSlackFile(file));
18
+ }
19
+ }
20
+
21
+ if ('attachments' in msg && Array.isArray(msg.attachments)) {
22
+ for (const att of msg.attachments as Record<string, unknown>[]) {
23
+ if (att.image_url) {
24
+ segments.push({
25
+ type: 'image',
26
+ data: {
27
+ url: att.image_url,
28
+ title: att.title,
29
+ text: att.text,
30
+ },
31
+ });
32
+ }
33
+ }
34
+ }
35
+
36
+ return segments.length > 0 ? segments : [{ type: 'text', data: { text: '' } }];
37
+ }
38
+
39
+ function parseSlackFile(file: Record<string, unknown>): MessageSegment {
40
+ const mime = String(file.mimetype ?? '');
41
+ let type: string;
42
+ if (mime.startsWith('image/')) type = 'image';
43
+ else if (mime.startsWith('video/')) type = 'video';
44
+ else if (mime.startsWith('audio/')) type = 'audio';
45
+ else type = 'file';
46
+
47
+ return {
48
+ type,
49
+ data: {
50
+ id: file.id,
51
+ name: file.name,
52
+ url: file.url_private ?? file.permalink,
53
+ size: file.size,
54
+ mimetype: file.mimetype,
55
+ },
56
+ };
57
+ }
58
+
59
+ export function parseSlackMrkdwn(text: string): MessageSegment[] {
60
+ const segments: MessageSegment[] = [];
61
+ let lastIndex = 0;
62
+
63
+ for (const token of scanSlackTokens(text)) {
64
+ const start = token.start;
65
+ if (start > lastIndex) {
66
+ const before = text.slice(lastIndex, start);
67
+ if (before.trim()) {
68
+ segments.push({ type: 'text', data: { text: mrkdwnToMarkdown(before) } });
69
+ }
70
+ }
71
+
72
+ switch (token.kind) {
73
+ case 'user':
74
+ segments.push({ type: 'at', data: { id: token.id, name: token.label || token.id, text: token.raw } });
75
+ break;
76
+ case 'channel':
77
+ segments.push({ type: 'channel_mention', data: { id: token.id, name: token.label || token.id, text: token.raw } });
78
+ break;
79
+ case 'link':
80
+ segments.push({ type: 'link', data: { url: token.url, text: token.label || token.url } });
81
+ break;
82
+ }
83
+ lastIndex = start + token.raw.length;
84
+ }
85
+
86
+ if (lastIndex < text.length) {
87
+ const rest = text.slice(lastIndex);
88
+ if (rest.trim()) {
89
+ segments.push({ type: 'text', data: { text: mrkdwnToMarkdown(rest) } });
90
+ }
91
+ }
92
+
93
+ return segments.length > 0 ? segments : [{ type: 'text', data: { text: mrkdwnToMarkdown(text) } }];
94
+ }
95
+
96
+ type SlackToken =
97
+ | { kind: 'user' | 'channel'; start: number; raw: string; id: string; label?: string }
98
+ | { kind: 'link'; start: number; raw: string; url: string; label?: string };
99
+
100
+ function scanSlackTokens(text: string): SlackToken[] {
101
+ const tokens: SlackToken[] = [];
102
+ let cursor = 0;
103
+ while (cursor < text.length) {
104
+ const start = text.indexOf('<', cursor);
105
+ if (start < 0) break;
106
+ const end = text.indexOf('>', start + 1);
107
+ if (end < 0) break;
108
+ const raw = text.slice(start, end + 1);
109
+ const body = raw.slice(1, -1);
110
+ const sep = body.indexOf('|');
111
+ const head = sep >= 0 ? body.slice(0, sep) : body;
112
+ const label = sep >= 0 ? body.slice(sep + 1) : undefined;
113
+
114
+ if ((head.startsWith('@U') || head.startsWith('@W')) && isSlackId(head.slice(1))) {
115
+ tokens.push({ kind: 'user', start, raw, id: head.slice(1), label });
116
+ } else if (head.startsWith('#C') && isSlackId(head.slice(1))) {
117
+ tokens.push({ kind: 'channel', start, raw, id: head.slice(1), label });
118
+ } else if (head.startsWith('http://') || head.startsWith('https://')) {
119
+ tokens.push({ kind: 'link', start, raw, url: head, label });
120
+ }
121
+ cursor = end + 1;
122
+ }
123
+ return tokens;
124
+ }
125
+
126
+ function isSlackId(value: string): boolean {
127
+ if (!value) return false;
128
+ for (const ch of value) {
129
+ const code = ch.charCodeAt(0);
130
+ const isDigit = code >= 48 && code <= 57;
131
+ const isUpper = code >= 65 && code <= 90;
132
+ if (!isDigit && !isUpper) return false;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ export function resolveSlackChannelType(event: SlackEvent): 'private' | 'group' {
138
+ if (event.channel_type === 'im') return 'private';
139
+ return 'group';
140
+ }
@@ -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
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Slack 出站消息构造 — mrkdwn + Block Kit + files.uploadV2
3
+ */
4
+ import type { WebClient } from '@slack/web-api';
5
+ import type { MessageSegment, SendContent } from 'zhin.js';
6
+ import type { Logger } from '@zhin.js/logger';
7
+ import { markdownToMrkdwn, mrkdwnToPlainFallback, splitMrkdwnText } from './markdown-to-mrkdwn.js';
8
+
9
+ export interface SlackOutboundResult {
10
+ ts: string;
11
+ }
12
+
13
+ export interface SlackSendOptions {
14
+ channel: string;
15
+ threadTs?: string;
16
+ }
17
+
18
+ const SLACK_MAX_BLOCKS_PER_MESSAGE = 48;
19
+
20
+ export async function sendSlackContent(
21
+ client: WebClient,
22
+ content: SendContent,
23
+ opts: SlackSendOptions,
24
+ logger: Logger,
25
+ ): Promise<SlackOutboundResult> {
26
+ const segments = normalizeContent(content);
27
+ let textContent = '';
28
+ const blocks: Record<string, unknown>[] = [];
29
+ const attachments: Record<string, unknown>[] = [];
30
+ const pendingFiles: Array<{ buffer?: Buffer; url?: string; path?: string; name?: string }> = [];
31
+
32
+ for (const seg of segments) {
33
+ if (typeof seg === 'string') {
34
+ textContent += seg;
35
+ continue;
36
+ }
37
+ const { type, data } = seg as MessageSegment;
38
+ switch (type) {
39
+ case 'text':
40
+ textContent += data.text ?? '';
41
+ break;
42
+ case 'at':
43
+ case 'mention':
44
+ textContent += `<@${data.id ?? data.target}>`;
45
+ break;
46
+ case 'channel_mention':
47
+ textContent += `<#${data.id}>`;
48
+ break;
49
+ case 'link':
50
+ if (data.text && data.text !== data.url) {
51
+ textContent += `<${data.url}|${data.text}>`;
52
+ } else {
53
+ textContent += `<${data.url}>`;
54
+ }
55
+ break;
56
+ case 'image':
57
+ if (data.url) {
58
+ attachments.push({ image_url: data.url, title: data.name ?? data.title ?? '' });
59
+ } else if (data.media) {
60
+ pendingFiles.push(resolveMediaToFile(data.media, data.alt ?? 'image'));
61
+ }
62
+ break;
63
+ case 'audio':
64
+ case 'video':
65
+ case 'file':
66
+ if (data.media) {
67
+ pendingFiles.push(resolveMediaToFile(data.media, data.name ?? type));
68
+ } else if (data.file || data.url) {
69
+ pendingFiles.push({ path: data.file, url: data.url, name: data.name ?? type });
70
+ }
71
+ break;
72
+ case 'keyboard':
73
+ blocks.push(...keyboardToBlockKitBlocks(data));
74
+ break;
75
+ default:
76
+ textContent += data.text ?? `[${type}]`;
77
+ }
78
+ }
79
+
80
+ const textDelivery = applyTextMrkdwnBlocks(textContent, blocks);
81
+
82
+ for (const pf of pendingFiles) {
83
+ try {
84
+ await uploadFile(client, opts.channel, pf, opts.threadTs, logger);
85
+ } catch (e) {
86
+ logger.error('Failed to upload file:', e);
87
+ }
88
+ }
89
+
90
+ return postSlackMessage(client, {
91
+ channel: opts.channel,
92
+ threadTs: opts.threadTs,
93
+ blocks,
94
+ attachments,
95
+ fallbackText: textDelivery.fallbackText,
96
+ });
97
+ }
98
+
99
+ export async function editSlackContent(
100
+ client: WebClient,
101
+ channel: string,
102
+ ts: string,
103
+ content: SendContent,
104
+ ): Promise<void> {
105
+ const segments = normalizeContent(content);
106
+ let textContent = '';
107
+ const blocks: Record<string, unknown>[] = [];
108
+
109
+ for (const seg of segments) {
110
+ if (typeof seg === 'string') { textContent += seg; continue; }
111
+ const { type, data } = seg as MessageSegment;
112
+ switch (type) {
113
+ case 'text': textContent += data.text ?? ''; break;
114
+ case 'at': case 'mention': textContent += `<@${data.id ?? data.target}>`; break;
115
+ case 'channel_mention': textContent += `<#${data.id}>`; break;
116
+ case 'link':
117
+ textContent += data.text && data.text !== data.url ? `<${data.url}|${data.text}>` : `<${data.url}>`;
118
+ break;
119
+ case 'keyboard':
120
+ blocks.push(...keyboardToBlockKitBlocks(data));
121
+ break;
122
+ default: textContent += data.text ?? `[${type}]`;
123
+ }
124
+ }
125
+
126
+ const textDelivery = applyTextMrkdwnBlocks(textContent, blocks);
127
+ const payloadBlocks = blocks.slice(0, SLACK_MAX_BLOCKS_PER_MESSAGE);
128
+
129
+ const updateOpts: Record<string, unknown> = {
130
+ channel,
131
+ ts,
132
+ text: textDelivery.fallbackText || ' ',
133
+ };
134
+ if (payloadBlocks.length > 0) updateOpts.blocks = payloadBlocks;
135
+ await client.chat.update(updateOpts as any);
136
+ }
137
+
138
+ function normalizeContent(content: SendContent): Array<string | MessageSegment> {
139
+ if (typeof content === 'string') return [content];
140
+ if (!Array.isArray(content)) return [content as MessageSegment];
141
+ return content as Array<string | MessageSegment>;
142
+ }
143
+
144
+ function applyTextMrkdwnBlocks(
145
+ textContent: string,
146
+ blocks: Record<string, unknown>[],
147
+ ): { fallbackText: string } {
148
+ const trimmed = textContent.trim();
149
+ if (!trimmed) return { fallbackText: '' };
150
+
151
+ const mrkdwn = markdownToMrkdwn(trimmed);
152
+ const sections = splitMrkdwnText(mrkdwn).map((chunk) => ({
153
+ type: 'section',
154
+ text: { type: 'mrkdwn', text: chunk },
155
+ }));
156
+
157
+ if (blocks.length > 0) {
158
+ blocks.unshift(...sections);
159
+ return { fallbackText: '' };
160
+ }
161
+ blocks.push(...sections);
162
+ return { fallbackText: mrkdwnToPlainFallback(mrkdwn) };
163
+ }
164
+
165
+ async function postSlackMessage(
166
+ client: WebClient,
167
+ opts: {
168
+ channel: string;
169
+ threadTs?: string;
170
+ blocks: Record<string, unknown>[];
171
+ attachments: Record<string, unknown>[];
172
+ fallbackText: string;
173
+ },
174
+ ): Promise<SlackOutboundResult> {
175
+ const { channel, blocks, attachments, fallbackText } = opts;
176
+ let threadTs = opts.threadTs;
177
+ let firstTs = '';
178
+
179
+ if (blocks.length === 0) {
180
+ const result = await client.chat.postMessage({
181
+ channel,
182
+ text: fallbackText || 'Message',
183
+ ...(threadTs ? { thread_ts: threadTs } : {}),
184
+ ...(attachments.length > 0 ? { attachments } : {}),
185
+ } as any);
186
+ return { ts: (result as any).ts ?? '' };
187
+ }
188
+
189
+ for (let offset = 0; offset < blocks.length; offset += SLACK_MAX_BLOCKS_PER_MESSAGE) {
190
+ const chunk = blocks.slice(offset, offset + SLACK_MAX_BLOCKS_PER_MESSAGE);
191
+ const result = await client.chat.postMessage({
192
+ channel,
193
+ text: offset === 0 ? (fallbackText || ' ') : ' ',
194
+ blocks: chunk,
195
+ ...(threadTs ? { thread_ts: threadTs } : {}),
196
+ ...(offset === 0 && attachments.length > 0 ? { attachments } : {}),
197
+ } as any);
198
+ const ts = (result as any).ts ?? '';
199
+ if (!firstTs) firstTs = ts;
200
+ if (!threadTs) threadTs = ts;
201
+ }
202
+
203
+ return { ts: firstTs };
204
+ }
205
+
206
+ function resolveMediaToFile(
207
+ media: { kind: string; value: string; mime_type?: string },
208
+ name: string,
209
+ ): { buffer?: Buffer; url?: string; name: string } {
210
+ if (media.kind === 'base64') {
211
+ return { buffer: Buffer.from(media.value, 'base64'), name };
212
+ }
213
+ if (media.kind === 'url') {
214
+ return { url: media.value, name };
215
+ }
216
+ return { url: media.value, name };
217
+ }
218
+
219
+ /** 每行 keyboard → 独立 actions block(Slack 每 block 最多 5 个按钮) */
220
+ export function keyboardToBlockKitBlocks(data: Record<string, unknown>): Record<string, unknown>[] {
221
+ const rows = data.rows as Array<Array<Record<string, unknown>>> | undefined;
222
+ if (!rows?.length) return [];
223
+
224
+ const blocks: Record<string, unknown>[] = [];
225
+ for (const row of rows) {
226
+ const elements = row.slice(0, 5).map((btn, index) => ({
227
+ type: 'button',
228
+ text: { type: 'plain_text', text: String(btn.label ?? btn.text ?? 'button').slice(0, 75) },
229
+ action_id: String(btn.id ?? btn.action_id ?? `btn_${blocks.length}_${index}`),
230
+ ...(btn.value != null ? { value: String(btn.value) } : {}),
231
+ ...(btn.style === 'primary' ? { style: 'primary' } : {}),
232
+ ...(btn.style === 'danger' ? { style: 'danger' } : {}),
233
+ }));
234
+ if (elements.length > 0) {
235
+ blocks.push({ type: 'actions', elements });
236
+ }
237
+ }
238
+ return blocks;
239
+ }
240
+
241
+ async function uploadFile(
242
+ client: WebClient,
243
+ channel: string,
244
+ file: { buffer?: Buffer; url?: string; path?: string; name?: string },
245
+ threadTs?: string,
246
+ logger?: Logger,
247
+ ): Promise<void> {
248
+ try {
249
+ let buffer = file.buffer;
250
+ if (!buffer && file.path) {
251
+ const { readFile } = await import('node:fs/promises');
252
+ buffer = await readFile(file.path);
253
+ }
254
+ if (!buffer && file.url) {
255
+ const res = await fetch(file.url);
256
+ if (!res.ok) throw new Error(`fetch ${file.url}: ${res.status}`);
257
+ buffer = Buffer.from(await res.arrayBuffer());
258
+ }
259
+ if (!buffer) return;
260
+
261
+ await client.filesUploadV2(
262
+ threadTs
263
+ ? { channel_id: channel, file: buffer, filename: file.name ?? 'file', thread_ts: threadTs }
264
+ : { channel_id: channel, file: buffer, filename: file.name ?? 'file' },
265
+ );
266
+ } catch (e) {
267
+ logger?.error('File upload failed:', e);
268
+ }
269
+ }
@@ -0,0 +1,23 @@
1
+ /** 将 activity-feedback / 工具入参规范为 Slack reactions.add 的 name(不含冒号) */
2
+ const UNICODE_TO_SLACK: Record<string, string> = {
3
+ '⏳': 'hourglass_flowing_sand',
4
+ '✅': 'white_check_mark',
5
+ '❌': 'x',
6
+ '⏰': 'alarm_clock',
7
+ '🤔': 'thinking_face',
8
+ '👀': 'eyes',
9
+ };
10
+
11
+ export function normalizeSlackReactionName(emoji: string): string {
12
+ const trimmed = emoji.trim();
13
+ if (!trimmed) return 'hourglass_flowing_sand';
14
+ if (UNICODE_TO_SLACK[trimmed]) return UNICODE_TO_SLACK[trimmed]!;
15
+ if (trimmed.startsWith(':') && trimmed.endsWith(':') && trimmed.length > 2) {
16
+ return trimmed.slice(1, -1);
17
+ }
18
+ let start = 0;
19
+ let end = trimmed.length;
20
+ while (start < end && trimmed[start] === ':') start++;
21
+ while (end > start && trimmed[end - 1] === ':') end--;
22
+ return trimmed.slice(start, end);
23
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Slack response_url 即时 ephemeral 反馈(斜杠命令 / 按钮交互)
3
+ */
4
+ import type { Logger } from '@zhin.js/logger';
5
+
6
+ export function postSlackEphemeral(
7
+ responseUrl: string,
8
+ text: string,
9
+ logger?: Logger,
10
+ ): void {
11
+ if (!responseUrl?.trim()) return;
12
+
13
+ const body = JSON.stringify({
14
+ response_type: 'ephemeral',
15
+ text,
16
+ replace_original: false,
17
+ });
18
+
19
+ fetch(responseUrl, {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ body,
23
+ }).catch((err) => {
24
+ logger?.debug(`Slack response_url failed: ${err instanceof Error ? err.message : String(err)}`);
25
+ });
26
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Slack 事件 → zhin Notice 映射
3
+ */
4
+ import { mapNoticeParts, buildNotice, senderFromId } from 'zhin.js';
5
+ import type { SlackEvent } from './types.js';
6
+
7
+ export function isSlackNoticeEvent(eventType: string): boolean {
8
+ return SLACK_NOTICE_EVENT_TYPES.has(eventType);
9
+ }
10
+
11
+ const SLACK_NOTICE_EVENT_TYPES = new Set([
12
+ 'member_joined_channel',
13
+ 'member_left_channel',
14
+ 'reaction_added',
15
+ 'reaction_removed',
16
+ 'message_deleted',
17
+ 'channel_archive',
18
+ 'channel_unarchive',
19
+ 'channel_rename',
20
+ 'channel_created',
21
+ 'channel_deleted',
22
+ 'pin_added',
23
+ 'pin_removed',
24
+ 'app_home_opened',
25
+ 'team_join',
26
+ ]);
27
+
28
+ export function formatSlackNotice(
29
+ event: SlackEvent,
30
+ endpointName: string,
31
+ ) {
32
+ const eventType = event.type;
33
+ const { scene_type, sub_type } = mapNoticeParts('slack', eventType);
34
+
35
+ const channelId = resolveNoticeChannelId(event);
36
+ const ts = event.event_ts
37
+ ? parseFloat(event.event_ts) * 1000
38
+ : Date.now();
39
+
40
+ const actorId = event.user ?? (event as any).inviter;
41
+ const targetId =
42
+ (event as any).item_user ??
43
+ (event as any).user ??
44
+ undefined;
45
+
46
+ return buildNotice(event, {
47
+ $id: resolveSlackSideEventDedupeKey(event),
48
+ $adapter: 'slack',
49
+ $endpoint: endpointName,
50
+ $type: 'notice',
51
+ $scene_id: channelId,
52
+ $scene_type: scene_type,
53
+ $sub_type: sub_type,
54
+ $actor: senderFromId(actorId),
55
+ $target: actorId !== targetId ? senderFromId(targetId) : undefined,
56
+ $timestamp: ts,
57
+ });
58
+ }
59
+
60
+ export function resolveSlackSideEventDedupeKey(event: SlackEvent): string {
61
+ const ts = event.event_ts ?? event.ts ?? '';
62
+ const type = event.type;
63
+ const channel = event.channel ?? '';
64
+ const user = event.user ?? '';
65
+ return `slack:${ts}_${type}_${channel}_${user}`;
66
+ }
67
+
68
+ function resolveNoticeChannelId(event: SlackEvent): string {
69
+ if (event.channel) return String(event.channel);
70
+ const item = (event as any).item;
71
+ if (item?.channel) return String(item.channel);
72
+ const channel = (event as any).channel_id ?? (event as any).channel?.id;
73
+ return channel ? String(channel) : '';
74
+ }