@zhin.js/adapter-slack 4.0.1 → 4.1.1

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 (80) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +77 -160
  3. package/adapters/slack.ts +34 -0
  4. package/{skills/slack/SKILL.md → agent/skills/slack.md} +2 -0
  5. package/agent/tools/add_reaction.ts +26 -0
  6. package/agent/tools/archive_channel.ts +24 -0
  7. package/agent/tools/edit_message.ts +28 -0
  8. package/agent/tools/invite_to_channel.ts +26 -0
  9. package/agent/tools/pin_message.ts +26 -0
  10. package/agent/tools/remove_reaction.ts +26 -0
  11. package/agent/tools/set_purpose.ts +26 -0
  12. package/agent/tools/set_topic.ts +26 -0
  13. package/agent/tools/unarchive.ts +24 -0
  14. package/agent/tools/unpin_message.ts +26 -0
  15. package/agent/tools/user_info.ts +31 -0
  16. package/lib/endpoint.d.ts +133 -94
  17. package/lib/endpoint.js +270 -612
  18. package/lib/index.d.ts +11 -10
  19. package/lib/index.js +11 -252
  20. package/lib/markdown-to-mrkdwn.d.ts +9 -0
  21. package/lib/markdown-to-mrkdwn.js +121 -0
  22. package/lib/mrkdwn-to-markdown.d.ts +4 -0
  23. package/lib/mrkdwn-to-markdown.js +30 -0
  24. package/lib/platform-permit.d.ts +1 -2
  25. package/lib/platform-permit.js +4 -2
  26. package/lib/protocol.d.ts +143 -0
  27. package/lib/protocol.js +225 -0
  28. package/lib/slack-agent-deps.d.ts +28 -0
  29. package/lib/slack-agent-deps.js +30 -0
  30. package/lib/slack-inbound-filter.d.ts +12 -0
  31. package/lib/slack-inbound-filter.js +46 -0
  32. package/lib/slack-message-ref.d.ts +7 -0
  33. package/lib/slack-message-ref.js +17 -0
  34. package/lib/slack-outbound.d.ts +24 -0
  35. package/lib/slack-outbound.js +109 -0
  36. package/lib/slack-reaction.d.ts +1 -0
  37. package/lib/slack-reaction.js +26 -0
  38. package/lib/slack-response-url.d.ts +5 -0
  39. package/lib/slack-response-url.js +16 -0
  40. package/lib/webhook.d.ts +14 -0
  41. package/lib/webhook.js +69 -0
  42. package/package.json +53 -14
  43. package/plugin.ts +13 -0
  44. package/schema.json +38 -0
  45. package/src/endpoint.ts +339 -647
  46. package/src/index.ts +65 -277
  47. package/src/markdown-to-mrkdwn.ts +117 -0
  48. package/src/mrkdwn-to-markdown.ts +29 -0
  49. package/src/platform-permit.ts +1 -2
  50. package/src/protocol.ts +380 -0
  51. package/src/slack-agent-deps.ts +57 -0
  52. package/src/slack-inbound-filter.ts +60 -0
  53. package/src/slack-message-ref.ts +18 -0
  54. package/src/slack-outbound.ts +167 -0
  55. package/src/slack-reaction.ts +23 -0
  56. package/src/slack-response-url.ts +26 -0
  57. package/src/webhook.ts +103 -0
  58. package/lib/adapter.d.ts +0 -19
  59. package/lib/adapter.d.ts.map +0 -1
  60. package/lib/adapter.js +0 -46
  61. package/lib/adapter.js.map +0 -1
  62. package/lib/endpoint.d.ts.map +0 -1
  63. package/lib/endpoint.js.map +0 -1
  64. package/lib/index.d.ts.map +0 -1
  65. package/lib/index.js.map +0 -1
  66. package/lib/platform-permit.d.ts.map +0 -1
  67. package/lib/platform-permit.js.map +0 -1
  68. package/lib/segment-mapper.d.ts +0 -2
  69. package/lib/segment-mapper.d.ts.map +0 -1
  70. package/lib/segment-mapper.js +0 -2
  71. package/lib/segment-mapper.js.map +0 -1
  72. package/lib/types.d.ts +0 -17
  73. package/lib/types.d.ts.map +0 -1
  74. package/lib/types.js +0 -2
  75. package/lib/types.js.map +0 -1
  76. package/plugin.yml +0 -3
  77. package/src/adapter.ts +0 -54
  78. package/src/segment-mapper.ts +0 -1
  79. package/src/types.ts +0 -18
  80. /package/{skills/slack → agent}/PERMITS.md +0 -0
package/src/endpoint.ts CHANGED
@@ -1,738 +1,430 @@
1
1
  /**
2
- * Slack Endpoint 实现
2
+ * SlackEndpoint lifecycle, outbound, admit, Socket Mode, agent tool surface.
3
3
  */
4
- import { App as SlackApp, LogLevel } from "@slack/bolt";
5
- import { WebClient, type ChatPostMessageArguments } from "@slack/web-api";
6
- import { formatCompact, Endpoint, Message, MessageSegment, segment, SendContent, SendOptions, expandInteractiveSegmentsInContent,} from 'zhin.js';
7
- import type { SlackEndpointConfig, SlackMessageEvent } from "./types.js";
8
- import type { SlackAdapter } from "./adapter.js";
9
- import { normalizeSlackSenderForPermit } from "./platform-permit.js";
10
- import { fromCanonicalSegments, toCanonicalSegments } from './segment-mapper.js';
11
-
12
-
13
- export class SlackEndpoint implements Endpoint<SlackEndpointConfig, SlackMessageEvent> {
14
- $connected: boolean;
15
- /** 延迟到 `$connect`,避免子类 mock `$connect` 时仍在构造函数里创建 Bolt/WebClient(会在后台触发 Slack API) */
16
- private app?: SlackApp;
17
- private client?: WebClient;
18
-
19
- get logger() {
20
- return this.adapter.plugin.logger;
21
- }
22
-
23
- get $id() {
24
- return this.$config.name;
25
- }
26
-
27
- constructor(public adapter: SlackAdapter, public $config: SlackEndpointConfig) {
28
- this.$connected = false;
29
- }
30
-
31
- #ensureSlackRuntime(): void {
32
- if (this.app && this.client) return;
33
- const $config = this.$config;
34
- if ($config.socketMode && $config.appToken) {
35
- this.app = new SlackApp({
36
- token: $config.token,
37
- signingSecret: $config.signingSecret,
38
- appToken: $config.appToken,
39
- socketMode: true,
40
- logLevel: $config.logLevel || LogLevel.INFO,
41
- });
42
- } else {
43
- this.app = new SlackApp({
44
- token: $config.token,
45
- signingSecret: $config.signingSecret,
46
- socketMode: false,
47
- logLevel: $config.logLevel || LogLevel.INFO,
48
- });
49
- }
50
- this.client = new WebClient($config.token);
4
+ import { SocketModeClient } from '@slack/socket-mode';
5
+ import { WebClient } from '@slack/web-api';
6
+ import type { EndpointInstance } from '@zhin.js/adapter';
7
+ import type { MessageGateway } from '@zhin.js/core/runtime';
8
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
9
+ import { formatCompact, getLogger } from '@zhin.js/logger';
10
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
11
+ import {
12
+ formatInboundContent,
13
+ formatInteractionContent,
14
+ formatSlashContent,
15
+ inboundMessageId,
16
+ resolveSlackChannelType,
17
+ type ResolvedSlackConfig,
18
+ type SlackEvent,
19
+ type SlackEventEnvelope,
20
+ type SlackInteractionPayload,
21
+ type SlackMessageEvent,
22
+ type SlackSlashCommand,
23
+ } from './protocol.js';
24
+ import { registerSlackAgentEndpoint } from './slack-agent-deps.js';
25
+ import {
26
+ createSlackInboundFilterState,
27
+ shouldDropSlackInboundMessage,
28
+ } from './slack-inbound-filter.js';
29
+ import { formatSlackMessageRef, parseSlackMessageRef } from './slack-message-ref.js';
30
+ import { normalizeSlackReactionName } from './slack-reaction.js';
31
+ import { postSlackEphemeral } from './slack-response-url.js';
32
+ import { editSlackContent, sendSlackContent, type SlackChatClient } from './slack-outbound.js';
33
+ import { registerSlackWebhookRoutes, type SlackWebhookHandler } from './webhook.js';
34
+
35
+ const logger = getLogger('slack');
36
+
37
+ export interface SlackSocketLike {
38
+ on(
39
+ event: string,
40
+ handler: (args: { ack: () => Promise<void>; body: unknown }) => void | Promise<void>,
41
+ ): void;
42
+ start(): Promise<void>;
43
+ disconnect(): Promise<void>;
44
+ }
45
+
46
+ export interface SlackWebClientLike extends SlackChatClient {
47
+ auth: {
48
+ test(): Promise<{ user_id?: string; user?: string }>;
49
+ };
50
+ conversations: {
51
+ invite(opts: { channel: string; users: string }): Promise<unknown>;
52
+ kick(opts: { channel: string; user: string }): Promise<unknown>;
53
+ setTopic(opts: { channel: string; topic: string }): Promise<unknown>;
54
+ setPurpose(opts: { channel: string; purpose: string }): Promise<unknown>;
55
+ archive(opts: { channel: string }): Promise<unknown>;
56
+ unarchive(opts: { channel: string }): Promise<unknown>;
57
+ rename(opts: { channel: string; name: string }): Promise<unknown>;
58
+ members(opts: { channel: string }): Promise<{ members?: string[] }>;
59
+ info(opts: { channel: string }): Promise<{ channel?: unknown }>;
60
+ };
61
+ users: {
62
+ info(opts: { user: string }): Promise<{ user?: unknown }>;
63
+ };
64
+ reactions: {
65
+ add(opts: { channel: string; timestamp: string; name: string }): Promise<unknown>;
66
+ remove(opts: { channel: string; timestamp: string; name: string }): Promise<unknown>;
67
+ };
68
+ pins: {
69
+ add(opts: { channel: string; timestamp: string }): Promise<unknown>;
70
+ remove(opts: { channel: string; timestamp: string }): Promise<unknown>;
71
+ };
72
+ chat: SlackChatClient['chat'] & {
73
+ delete(opts: { channel: string; ts: string }): Promise<unknown>;
74
+ };
75
+ }
76
+
77
+ export interface SlackEndpointOptions {
78
+ readonly id: CapabilityId;
79
+ readonly gateway: MessageGateway;
80
+ readonly config: ResolvedSlackConfig;
81
+ readonly http?: HttpHost;
82
+ readonly createClient?: (token: string) => SlackWebClientLike;
83
+ readonly createSocket?: (opts: {
84
+ readonly appToken: string;
85
+ readonly clientPingTimeout: number;
86
+ }) => SlackSocketLike;
87
+ }
88
+
89
+ export class SlackEndpoint implements EndpointInstance, SlackWebhookHandler {
90
+ readonly #options: SlackEndpointOptions;
91
+ readonly #inboundFilter = createSlackInboundFilterState();
92
+ readonly #messageChannelMap = new Map<string, string>();
93
+ #client?: SlackWebClientLike;
94
+ #socket?: SlackSocketLike;
95
+ #routeReleases: HttpRouteRegistration[] = [];
96
+ #botUserId?: string;
97
+ #open = false;
98
+ #started = false;
99
+ #unregisterAgent?: () => void;
100
+
101
+ constructor(options: SlackEndpointOptions) {
102
+ this.#options = options;
51
103
  }
52
104
 
53
- async $connect(): Promise<void> {
54
- this.#ensureSlackRuntime();
105
+ get client(): SlackWebClientLike | undefined {
106
+ return this.#client;
107
+ }
108
+
109
+ get platformUserId(): string | undefined {
110
+ return this.#botUserId;
111
+ }
112
+
113
+ get config(): ResolvedSlackConfig {
114
+ return this.#options.config;
115
+ }
116
+
117
+ async start(): Promise<void> {
118
+ if (this.#started) return;
119
+ this.#started = true;
55
120
  try {
56
- // Set up message event handler
57
- this.app!.message(async ({ message, say }) => {
58
- await this.handleSlackMessage(message as SlackMessageEvent);
59
- });
60
-
61
- // Set up app mention handler
62
- this.app!.event("app_mention", async ({ event, say }) => {
63
- await this.handleSlackMessage(event as any);
64
- });
65
-
66
- // Start the app
67
- const port = this.$config.port || 3000;
68
- if (this.$config.socketMode) {
69
- await this.app!.start();
121
+ const { config } = this.#options;
122
+ this.#client = this.#options.createClient?.(config.token)
123
+ ?? (new WebClient(config.token) as unknown as SlackWebClientLike);
124
+
125
+ this.#unregisterAgent = registerSlackAgentEndpoint(config.name, this);
126
+
127
+ if (config.mode === 'socket') {
128
+ await this.#startSocket();
70
129
  } else {
71
- await this.app!.start(port);
130
+ const http = this.#options.http;
131
+ if (!http) {
132
+ throw new Error('Slack HTTP Events API requires httpHostToken (Runtime Host)');
133
+ }
134
+ this.#routeReleases.push(...registerSlackWebhookRoutes(http, this));
135
+ logger.debug(formatCompact({
136
+ endpoint: config.name,
137
+ op: 'webhook',
138
+ path: config.webhookPath,
139
+ }));
72
140
  }
73
141
 
74
- this.$connected = true;
75
-
76
- // Get bot info
77
- const authTest = await this.client!.auth.test();
78
- this.logger.info(formatCompact({ endpoint: this.$config.name, user: authTest.user }));
142
+ const authTest = await this.#client.auth.test();
143
+ if (authTest.user_id) this.#botUserId = String(authTest.user_id);
79
144
 
80
- if (!this.$config.socketMode) {
81
- this.logger.info(formatCompact( { op: "listen", port }));
82
- }
145
+ logger.info(formatCompact({
146
+ op: 'connect',
147
+ endpoint: config.name,
148
+ mode: config.mode,
149
+ platform_user_id: this.#botUserId,
150
+ }));
83
151
  } catch (error) {
84
- this.logger.error("Failed to connect Slack bot:", error);
85
- this.$connected = false;
152
+ await this.stop();
153
+ logger.error('Failed to connect Slack endpoint:', error);
86
154
  throw error;
87
155
  }
88
156
  }
89
157
 
90
- async $disconnect(): Promise<void> {
91
- if (!this.app) {
92
- this.$connected = false;
93
- return;
94
- }
95
- try {
96
- await this.app!.stop();
97
- this.$connected = false;
98
- this.logger.info(formatCompact( { op: "disconnect", endpoint: this.$config.name }));
99
- } catch (error) {
100
- this.logger.error("Error disconnecting Slack bot:", error);
101
- throw error;
102
- }
158
+ open(): void {
159
+ this.#open = true;
103
160
  }
104
161
 
105
- private senderPermitCache = new Map<string, { at: number; role?: string; permissions: string[] }>();
106
-
107
- private async enrichChannelSender(
108
- message: Message<SlackMessageEvent>,
109
- msg: SlackMessageEvent,
110
- ): Promise<void> {
111
- if (message.$channel.type !== "group" || !("user" in msg) || !msg.user) return;
112
- const channelId = msg.channel;
113
- const userId = msg.user;
114
- const key = `${channelId}:${userId}`;
115
- const now = Date.now();
116
- const cached = this.senderPermitCache.get(key);
117
- if (cached && now - cached.at < 60_000) {
118
- message.$sender.role = cached.role;
119
- message.$sender.permissions = cached.permissions;
120
- return;
121
- }
122
- try {
123
- const user = await this.getUserInfo(userId);
124
- let isChannelManager = false;
162
+ close(): void {
163
+ this.#open = false;
164
+ }
165
+
166
+ async stop(): Promise<void> {
167
+ this.#open = false;
168
+ if (this.#socket) {
125
169
  try {
126
- const channel = await this.getChannelInfo(channelId);
127
- if (channel?.creator === userId) isChannelManager = true;
170
+ await this.#socket.disconnect();
128
171
  } catch {
129
- // ignore
172
+ /* ignore */
130
173
  }
131
- const normalized = normalizeSlackSenderForPermit({
132
- isWorkspaceOwner: user.is_owner === true,
133
- isWorkspaceAdmin: user.is_admin === true,
134
- isChannelManager,
135
- });
136
- const entry = {
137
- at: now,
138
- role: normalized.role,
139
- permissions: normalized.permissions ?? [],
140
- };
141
- this.senderPermitCache.set(key, entry);
142
- message.$sender.role = entry.role;
143
- message.$sender.permissions = entry.permissions;
144
- } catch {
145
- // 保守拒绝
146
- }
174
+ this.#socket = undefined;
175
+ }
176
+ for (const release of this.#routeReleases.splice(0)) release();
177
+ this.#unregisterAgent?.();
178
+ this.#unregisterAgent = undefined;
179
+ this.#client = undefined;
180
+ this.#started = false;
181
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
147
182
  }
148
183
 
149
- private async handleSlackMessage(msg: SlackMessageEvent): Promise<void> {
150
- // Ignore bot messages and message changes
151
- if ("subtype" in msg && (msg.subtype === "bot_message" || msg.subtype === "message_changed")) {
152
- return;
153
- }
154
-
155
- const message = this.$formatMessage(msg);
156
- await this.enrichChannelSender(message, msg);
157
- this.adapter.emit("message.receive", message);
158
- this.logger.debug(
159
- `${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}): ${segment.raw(message.$content)}`
184
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
185
+ if (!this.#client) throw new Error('Slack client not connected');
186
+ const { channel, threadTs } = parseSendTarget(target);
187
+ const result = await sendSlackContent(
188
+ this.#client,
189
+ payload,
190
+ { channel, threadTs },
191
+ logger,
160
192
  );
193
+ if (result.ts) this.trackMessageChannel(result.ts, channel);
194
+ return formatSlackMessageRef(channel, result.ts || String(Date.now()));
161
195
  }
162
196
 
163
- $formatMessage(msg: SlackMessageEvent): Message<SlackMessageEvent> {
164
- // Determine channel type based on channel ID
165
- const channelType = "channel_type" in msg && msg.channel_type === "im" ? "private" : "group";
166
- const channelId = msg.channel;
167
-
168
- // Parse message content
169
- const wire = this.parseMessageContent(msg);
170
- const content = toCanonicalSegments(wire);
171
-
172
- // Extract user info safely
173
- const userId = ("user" in msg ? msg.user : "") || "";
174
- const userName = ("username" in msg ? msg.username : null) || userId || "Unknown";
175
- const messageText = ("text" in msg ? msg.text : "") || "";
176
-
177
- const result = Message.from(msg, {
178
- $id: msg.ts,
179
- $adapter: "slack",
180
- $endpoint: this.$config.name,
181
- $sender: {
182
- id: userId,
183
- name: userName,
184
- },
185
- $channel: {
186
- id: channelId,
187
- type: channelType,
188
- },
189
- $content: content,
190
- $raw: messageText,
191
- $timestamp: parseFloat(msg.ts) * 1000,
192
- $recall: async () => {
193
- try {
194
- await this.client!.chat.delete({
195
- channel: channelId,
196
- ts: result.$id,
197
- });
198
- } catch (error) {
199
- this.logger.error("Error recalling Slack message:", error);
200
- throw error;
201
- }
202
- },
203
- $reply: async (
204
- content: SendContent,
205
- quote?: boolean | string
206
- ): Promise<string> => {
207
- if (!Array.isArray(content)) content = [content];
208
-
209
- const sendOptions: Partial<ChatPostMessageArguments> = {
210
- channel: channelId,
211
- };
212
-
213
- // Handle thread reply
214
- if (quote) {
215
- const threadTs = typeof quote === "boolean" ? result.$id : quote;
216
- sendOptions.thread_ts = threadTs;
217
- }
218
-
219
- return await this.adapter.sendMessage({
220
- context: "slack",
221
- endpoint: this.$config.name,
222
- id: channelId,
223
- type: "channel",
224
- content: content,
225
- });
226
- },
197
+ /** Test / internal: admit a message event when open. */
198
+ admit(event: SlackMessageEvent | SlackEvent): void {
199
+ if (!this.#open) return;
200
+ if (event.type !== 'message' && event.type !== 'app_mention') return;
201
+ const msg = event as SlackMessageEvent;
202
+ if (shouldDropSlackInboundMessage(msg, this.#inboundFilter, this.#botUserId)) return;
203
+ if (!msg.channel || !msg.ts) return;
204
+
205
+ this.trackMessageChannel(msg.ts, msg.channel);
206
+ void this.#options.gateway.receive({
207
+ adapter: this.#options.id,
208
+ target: msg.channel,
209
+ content: formatInboundContent(msg),
210
+ sender: msg.user ?? msg.channel,
211
+ id: inboundMessageId(msg),
212
+ metadata: Object.freeze({
213
+ endpoint: this.#options.config.name,
214
+ channelType: resolveSlackChannelType(msg),
215
+ userId: msg.user,
216
+ threadTs: msg.thread_ts && msg.thread_ts !== msg.ts ? msg.thread_ts : undefined,
217
+ ts: msg.ts,
218
+ // app_mention 事件本身即 @ 机器人;新 Runtime 纯文本 content 需经 metadata 传递
219
+ ...(msg.type === 'app_mention' ? { mentioned: true } : {}),
220
+ }),
221
+ }).catch((err) => {
222
+ logger.warn(formatCompact({
223
+ op: 'slack_gateway_receive_failed',
224
+ target: msg.channel,
225
+ error: err instanceof Error ? err.message : String(err),
226
+ }));
227
227
  });
228
-
229
- return result;
230
228
  }
231
229
 
232
- private parseMessageContent(msg: SlackMessageEvent): MessageSegment[] {
233
- const segments: MessageSegment[] = [];
234
-
235
- // Handle text
236
- if ("text" in msg && msg.text) {
237
- // Parse Slack formatting
238
- segments.push(...this.parseSlackText(msg.text));
239
- }
240
-
241
- // Handle files
242
- if ("files" in msg && msg.files) {
243
- for (const file of msg.files) {
244
- if (file.mimetype?.startsWith("image/")) {
245
- segments.push({
246
- type: "image",
247
- data: {
248
- id: file.id,
249
- name: file.name,
250
- url: file.url_private || file.permalink,
251
- size: file.size,
252
- mimetype: file.mimetype,
253
- },
254
- });
255
- } else if (file.mimetype?.startsWith("video/")) {
256
- segments.push({
257
- type: "video",
258
- data: {
259
- id: file.id,
260
- name: file.name,
261
- url: file.url_private || file.permalink,
262
- size: file.size,
263
- mimetype: file.mimetype,
264
- },
265
- });
266
- } else if (file.mimetype?.startsWith("audio/")) {
267
- segments.push({
268
- type: "audio",
269
- data: {
270
- id: file.id,
271
- name: file.name,
272
- url: file.url_private || file.permalink,
273
- size: file.size,
274
- mimetype: file.mimetype,
275
- },
276
- });
277
- } else {
278
- segments.push({
279
- type: "file",
280
- data: {
281
- id: file.id,
282
- name: file.name,
283
- url: file.url_private || file.permalink,
284
- size: file.size,
285
- mimetype: file.mimetype,
286
- },
287
- });
288
- }
289
- }
290
- }
291
-
292
- // Handle attachments
293
- if ("attachments" in msg && msg.attachments) {
294
- for (const attachment of msg.attachments) {
295
- if (attachment.image_url) {
296
- segments.push({
297
- type: "image",
298
- data: {
299
- url: attachment.image_url,
300
- title: attachment.title,
301
- text: attachment.text,
302
- },
303
- });
304
- }
305
- }
306
- }
307
-
308
- return segments.length > 0
309
- ? segments
310
- : [{ type: "text", data: { text: "" } }];
230
+ admitInteraction(payload: SlackInteractionPayload): void {
231
+ if (!this.#open) return;
232
+ if (payload.type !== 'block_actions' || !payload.actions?.length) return;
233
+ const channelId = payload.channel?.id ?? '';
234
+ const userId = payload.user.id;
235
+ const messageTs = payload.message?.ts ?? '';
236
+ if (payload.response_url) {
237
+ postSlackEphemeral(payload.response_url, '已收到', logger);
238
+ }
239
+ void this.#options.gateway.receive({
240
+ adapter: this.#options.id,
241
+ target: channelId || userId,
242
+ content: formatInteractionContent(payload),
243
+ sender: userId,
244
+ id: payload.actions[0]?.action_ts ?? messageTs ?? `action-${Date.now()}`,
245
+ metadata: Object.freeze({
246
+ endpoint: this.#options.config.name,
247
+ eventType: 'block_actions',
248
+ actionId: payload.actions[0]?.action_id,
249
+ threadTs: messageTs || undefined,
250
+ }),
251
+ }).catch((err) => {
252
+ logger.warn(formatCompact({
253
+ op: 'slack_gateway_receive_failed',
254
+ target: channelId,
255
+ error: err instanceof Error ? err.message : String(err),
256
+ }));
257
+ });
311
258
  }
312
259
 
313
- private parseSlackText(text: string): MessageSegment[] {
314
- const segments: MessageSegment[] = [];
315
- let lastIndex = 0;
316
-
317
- // Match user mentions <@U12345678>
318
- const userMentionRegex = /<@([UW][A-Z0-9]+)(?:\|([^>]+))?>/g;
319
- // Match channel mentions <#C12345678|general>
320
- const channelMentionRegex = /<#([C][A-Z0-9]+)(?:\|([^>]+))?>/g;
321
- // Match links <http://example.com|Example>
322
- const linkRegex = /<(https?:\/\/[^|>]+)(?:\|([^>]+))?>/g;
323
-
324
- const allMatches: Array<{
325
- match: RegExpExecArray;
326
- type: "user" | "channel" | "link";
327
- }> = [];
328
-
329
- // Collect all matches
330
- let match;
331
- while ((match = userMentionRegex.exec(text)) !== null) {
332
- allMatches.push({ match, type: "user" });
333
- }
334
- while ((match = channelMentionRegex.exec(text)) !== null) {
335
- allMatches.push({ match, type: "channel" });
336
- }
337
- while ((match = linkRegex.exec(text)) !== null) {
338
- allMatches.push({ match, type: "link" });
339
- }
340
-
341
- // Sort by position
342
- allMatches.sort((a, b) => a.match.index! - b.match.index!);
343
-
344
- // Process matches
345
- for (const { match, type } of allMatches) {
346
- const matchStart = match.index!;
347
- const matchEnd = matchStart + match[0].length;
348
-
349
- // Add text before match
350
- if (matchStart > lastIndex) {
351
- const beforeText = text.slice(lastIndex, matchStart);
352
- if (beforeText.trim()) {
353
- segments.push({ type: "text", data: { text: beforeText } });
354
- }
355
- }
356
-
357
- // Add special segment
358
- switch (type) {
359
- case "user":
360
- segments.push({
361
- type: "at",
362
- data: {
363
- id: match[1],
364
- name: match[2] || match[1],
365
- text: match[0],
366
- },
367
- });
368
- break;
369
-
370
- case "channel":
371
- segments.push({
372
- type: "channel_mention",
373
- data: {
374
- id: match[1],
375
- name: match[2] || match[1],
376
- text: match[0],
377
- },
378
- });
379
- break;
380
-
381
- case "link":
382
- segments.push({
383
- type: "link",
384
- data: {
385
- url: match[1],
386
- text: match[2] || match[1],
387
- },
388
- });
389
- break;
390
- }
391
-
392
- lastIndex = matchEnd;
393
- }
394
-
395
- // Add remaining text
396
- if (lastIndex < text.length) {
397
- const remainingText = text.slice(lastIndex);
398
- if (remainingText.trim()) {
399
- segments.push({ type: "text", data: { text: remainingText } });
400
- }
401
- }
402
-
403
- return segments.length > 0
404
- ? segments
405
- : [{ type: "text", data: { text } }];
260
+ admitSlashCommand(cmd: SlackSlashCommand): void {
261
+ if (!this.#open) return;
262
+ postSlackEphemeral(cmd.response_url, '处理中…', logger);
263
+ void this.#options.gateway.receive({
264
+ adapter: this.#options.id,
265
+ target: cmd.channel_id,
266
+ content: formatSlashContent(cmd),
267
+ sender: cmd.user_id,
268
+ id: cmd.trigger_id,
269
+ metadata: Object.freeze({
270
+ endpoint: this.#options.config.name,
271
+ eventType: 'slash_command',
272
+ command: cmd.command,
273
+ }),
274
+ }).catch((err) => {
275
+ logger.warn(formatCompact({
276
+ op: 'slack_gateway_receive_failed',
277
+ target: cmd.channel_id,
278
+ error: err instanceof Error ? err.message : String(err),
279
+ }));
280
+ });
406
281
  }
407
282
 
408
- async $sendMessage(options: SendOptions): Promise<string> {
409
- try {
410
- const canonical = expandInteractiveSegmentsInContent(options.content);
411
- const wire = fromCanonicalSegments(canonical);
412
- const result = await this.sendContentToChannel(
413
- options.id,
414
- wire
415
- );
416
- this.logger.debug(
417
- `${this.$config.name} send ${options.type}(${options.id}): ${segment.raw(options.content)}`
418
- );
419
- return result.ts || "";
420
- } catch (error) {
421
- this.logger.error("Failed to send Slack message:", error);
422
- throw error;
283
+ handleEnvelope(body: unknown): void {
284
+ const envelope = body as SlackEventEnvelope;
285
+ if (envelope?.type === 'event_callback' && envelope.event) {
286
+ this.admit(envelope.event);
423
287
  }
424
288
  }
425
289
 
426
- private async sendContentToChannel(
427
- channel: string,
428
- content: SendContent,
429
- extraOptions: Partial<ChatPostMessageArguments> = {}
430
- ): Promise<any> {
431
- if (!Array.isArray(content)) content = [content];
432
-
433
- let textContent = "";
434
- const attachments: any[] = [];
435
-
436
- for (const segment of content) {
437
- if (typeof segment === "string") {
438
- textContent += segment;
439
- continue;
440
- }
441
-
442
- const { type, data } = segment;
443
-
444
- switch (type) {
445
- case "text":
446
- textContent += data.text || "";
447
- break;
448
-
449
- case "at":
450
- textContent += `<@${data.id}>`;
451
- break;
452
-
453
- case "channel_mention":
454
- textContent += `<#${data.id}>`;
455
- break;
456
-
457
- case "link":
458
- if (data.text && data.text !== data.url) {
459
- textContent += `<${data.url}|${data.text}>`;
460
- } else {
461
- textContent += `<${data.url}>`;
462
- }
463
- break;
464
-
465
- case "image":
466
- if (data.url) {
467
- attachments.push({
468
- image_url: data.url,
469
- title: data.name || data.title,
470
- });
471
- }
472
- break;
473
-
474
- case "file":
475
- // Files need to be uploaded separately
476
- if (data.file) {
477
- try {
478
- await this.client!.files.upload({
479
- channels: channel,
480
- file: data.file,
481
- filename: data.name,
482
- });
483
- } catch (error) {
484
- this.logger.error("Failed to upload file:", error);
485
- }
486
- }
487
- break;
488
-
489
- default:
490
- textContent += data.text || `[${type}]`;
491
- }
492
- }
493
-
494
- // Send message
495
- const messageOptions: any = {
496
- channel,
497
- text: textContent.trim() || "Message",
498
- ...extraOptions,
499
- };
290
+ trackMessageChannel(ts: string, channel: string): void {
291
+ if (ts && channel) this.#messageChannelMap.set(ts, channel);
292
+ }
500
293
 
501
- if (attachments.length > 0) {
502
- messageOptions.attachments = attachments;
294
+ resolveMessageRef(messageId: string, channelHint?: string): { channel: string; ts: string } | null {
295
+ const parsed = parseSlackMessageRef(messageId);
296
+ if (parsed) {
297
+ this.trackMessageChannel(parsed.ts, parsed.channel);
298
+ return parsed;
503
299
  }
504
-
505
- const result = await this.client!.chat.postMessage(messageOptions as ChatPostMessageArguments);
506
- return result.message || {};
300
+ if (channelHint) return { channel: channelHint, ts: messageId };
301
+ const channel = this.#messageChannelMap.get(messageId);
302
+ return channel ? { channel, ts: messageId } : null;
507
303
  }
508
304
 
509
- async $recallMessage(id: string): Promise<void> {
510
- // Slack requires both channel and ts (timestamp) to delete a message
511
- // The Endpoint interface only provides message ID (ts), making recall impossible
512
- // Users should use message.$recall() instead, which has the full context
513
- throw new Error(
514
- "SlackEndpoint.$recallMessage: Message recall not supported without channel information. " +
515
- "Use message.$recall() method instead, which contains the required context."
516
- );
305
+ async editMessage(channel: string, messageTs: string, content: unknown): Promise<void> {
306
+ if (!this.#client) throw new Error('Slack client not connected');
307
+ await editSlackContent(this.#client, channel, messageTs, content);
517
308
  }
518
309
 
519
- // ==================== 工作区管理 API ====================
310
+ // ── Agent tool surface ──────────────────────────────────────────────
520
311
 
521
- /**
522
- * 邀请用户到频道
523
- * @param channel 频道 ID
524
- * @param users 用户 ID 列表
525
- */
526
312
  async inviteToChannel(channel: string, users: string[]): Promise<boolean> {
527
- try {
528
- await this.client!.conversations.invite({ channel, users: users.join(',') });
529
- this.logger.debug(formatCompact( { op: "invite", endpoint: this.$id, channel, users: users.join(",") }));
530
- return true;
531
- } catch (error) {
532
- this.logger.error(`Slack Endpoint ${this.$id} 邀请用户失败:`, error);
533
- throw error;
534
- }
313
+ await this.#client!.conversations.invite({ channel, users: users.join(',') });
314
+ return true;
535
315
  }
536
316
 
537
- /**
538
- * 从频道踢出用户
539
- * @param channel 频道 ID
540
- * @param user 用户 ID
541
- */
542
317
  async kickFromChannel(channel: string, user: string): Promise<boolean> {
543
- try {
544
- await this.client!.conversations.kick({ channel, user });
545
- this.logger.debug(formatCompact( { op: "kick", endpoint: this.$id, channel, user }));
546
- return true;
547
- } catch (error) {
548
- this.logger.error(`Slack Endpoint ${this.$id} 踢出用户失败:`, error);
549
- throw error;
550
- }
318
+ await this.#client!.conversations.kick({ channel, user });
319
+ return true;
551
320
  }
552
321
 
553
- /**
554
- * 设置频道话题
555
- * @param channel 频道 ID
556
- * @param topic 话题
557
- */
558
322
  async setChannelTopic(channel: string, topic: string): Promise<boolean> {
559
- try {
560
- await this.client!.conversations.setTopic({ channel, topic });
561
- this.logger.debug(formatCompact( { op: "set_topic", endpoint: this.$id, channel }));
562
- return true;
563
- } catch (error) {
564
- this.logger.error(`Slack Endpoint ${this.$id} 设置话题失败:`, error);
565
- throw error;
566
- }
323
+ await this.#client!.conversations.setTopic({ channel, topic });
324
+ return true;
567
325
  }
568
326
 
569
- /**
570
- * 设置频道目的
571
- * @param channel 频道 ID
572
- * @param purpose 目的
573
- */
574
327
  async setChannelPurpose(channel: string, purpose: string): Promise<boolean> {
575
- try {
576
- await this.client!.conversations.setPurpose({ channel, purpose });
577
- this.logger.debug(formatCompact( { op: "set_purpose", endpoint: this.$id, channel }));
578
- return true;
579
- } catch (error) {
580
- this.logger.error(`Slack Endpoint ${this.$id} 设置目的失败:`, error);
581
- throw error;
582
- }
328
+ await this.#client!.conversations.setPurpose({ channel, purpose });
329
+ return true;
583
330
  }
584
331
 
585
- /**
586
- * 归档频道
587
- * @param channel 频道 ID
588
- */
589
332
  async archiveChannel(channel: string): Promise<boolean> {
590
- try {
591
- await this.client!.conversations.archive({ channel });
592
- this.logger.debug(formatCompact( { op: "archive", endpoint: this.$id, channel }));
593
- return true;
594
- } catch (error) {
595
- this.logger.error(`Slack Endpoint ${this.$id} 归档频道失败:`, error);
596
- throw error;
597
- }
333
+ await this.#client!.conversations.archive({ channel });
334
+ return true;
598
335
  }
599
336
 
600
- /**
601
- * 取消归档频道
602
- * @param channel 频道 ID
603
- */
604
337
  async unarchiveChannel(channel: string): Promise<boolean> {
605
- try {
606
- await this.client!.conversations.unarchive({ channel });
607
- this.logger.debug(formatCompact( { op: "unarchive", endpoint: this.$id, channel }));
608
- return true;
609
- } catch (error) {
610
- this.logger.error(`Slack Endpoint ${this.$id} 取消归档失败:`, error);
611
- throw error;
612
- }
338
+ await this.#client!.conversations.unarchive({ channel });
339
+ return true;
613
340
  }
614
341
 
615
- /**
616
- * 重命名频道
617
- * @param channel 频道 ID
618
- * @param name 新名称
619
- */
620
342
  async renameChannel(channel: string, name: string): Promise<boolean> {
621
- try {
622
- await this.client!.conversations.rename({ channel, name });
623
- this.logger.debug(formatCompact( { op: "rename", endpoint: this.$id, channel, name }));
624
- return true;
625
- } catch (error) {
626
- this.logger.error(`Slack Endpoint ${this.$id} 重命名频道失败:`, error);
627
- throw error;
628
- }
343
+ await this.#client!.conversations.rename({ channel, name });
344
+ return true;
629
345
  }
630
346
 
631
- /**
632
- * 获取频道成员列表
633
- * @param channel 频道 ID
634
- */
635
347
  async getChannelMembers(channel: string): Promise<string[]> {
636
- try {
637
- const result = await this.client!.conversations.members({ channel });
638
- return result.members || [];
639
- } catch (error) {
640
- this.logger.error(`Slack Endpoint ${this.$id} 获取成员列表失败:`, error);
641
- throw error;
642
- }
348
+ const result = await this.#client!.conversations.members({ channel });
349
+ return result.members || [];
643
350
  }
644
351
 
645
- /**
646
- * 获取频道信息
647
- * @param channel 频道 ID
648
- */
649
- async getChannelInfo(channel: string): Promise<any> {
650
- try {
651
- const result = await this.client!.conversations.info({ channel });
652
- return result.channel;
653
- } catch (error) {
654
- this.logger.error(`Slack Endpoint ${this.$id} 获取频道信息失败:`, error);
655
- throw error;
656
- }
352
+ async getChannelInfo(channel: string): Promise<unknown> {
353
+ const result = await this.#client!.conversations.info({ channel });
354
+ return result.channel;
657
355
  }
658
356
 
659
- /**
660
- * 获取用户信息
661
- * @param user 用户 ID
662
- */
663
- async getUserInfo(user: string): Promise<any> {
664
- try {
665
- const result = await this.client!.users.info({ user });
666
- return result.user;
667
- } catch (error) {
668
- this.logger.error(`Slack Endpoint ${this.$id} 获取用户信息失败:`, error);
669
- throw error;
670
- }
357
+ async getUserInfo(user: string): Promise<unknown> {
358
+ const result = await this.#client!.users.info({ user });
359
+ return result.user;
671
360
  }
672
361
 
673
- /**
674
- * 添加消息反应
675
- * @param channel 频道 ID
676
- * @param timestamp 消息时间戳
677
- * @param name 表情名称
678
- */
679
362
  async addReaction(channel: string, timestamp: string, name: string): Promise<boolean> {
363
+ const reaction = normalizeSlackReactionName(name);
680
364
  try {
681
- await this.client!.reactions.add({ channel, timestamp, name });
682
- this.logger.debug(formatCompact( { op: "reaction_add", endpoint: this.$id, name }));
365
+ await this.#client!.reactions.add({ channel, timestamp, name: reaction });
683
366
  return true;
684
367
  } catch (error) {
685
- this.logger.error(`Slack Endpoint ${this.$id} 添加反应失败:`, error);
368
+ const code = (error as { data?: { error?: string } })?.data?.error;
369
+ if (code === 'already_reacted') return true;
686
370
  throw error;
687
371
  }
688
372
  }
689
373
 
690
- /**
691
- * 移除消息反应
692
- * @param channel 频道 ID
693
- * @param timestamp 消息时间戳
694
- * @param name 表情名称
695
- */
696
374
  async removeReaction(channel: string, timestamp: string, name: string): Promise<boolean> {
375
+ const reaction = normalizeSlackReactionName(name);
697
376
  try {
698
- await this.client!.reactions.remove({ channel, timestamp, name });
699
- this.logger.debug(formatCompact( { op: "reaction_remove", endpoint: this.$id, name }));
377
+ await this.#client!.reactions.remove({ channel, timestamp, name: reaction });
700
378
  return true;
701
379
  } catch (error) {
702
- this.logger.error(`Slack Endpoint ${this.$id} 移除反应失败:`, error);
380
+ const code = (error as { data?: { error?: string } })?.data?.error;
381
+ if (code === 'no_reaction') return true;
703
382
  throw error;
704
383
  }
705
384
  }
706
385
 
707
- /**
708
- * 置顶消息
709
- * @param channel 频道 ID
710
- * @param timestamp 消息时间戳
711
- */
712
386
  async pinMessage(channel: string, timestamp: string): Promise<boolean> {
713
- try {
714
- await this.client!.pins.add({ channel, timestamp });
715
- this.logger.debug(formatCompact( { op: "pin", endpoint: this.$id, channel }));
716
- return true;
717
- } catch (error) {
718
- this.logger.error(`Slack Endpoint ${this.$id} 置顶消息失败:`, error);
719
- throw error;
720
- }
387
+ await this.#client!.pins.add({ channel, timestamp });
388
+ return true;
721
389
  }
722
390
 
723
- /**
724
- * 取消置顶消息
725
- * @param channel 频道 ID
726
- * @param timestamp 消息时间戳
727
- */
728
391
  async unpinMessage(channel: string, timestamp: string): Promise<boolean> {
729
- try {
730
- await this.client!.pins.remove({ channel, timestamp });
731
- this.logger.debug(formatCompact( { op: "unpin", endpoint: this.$id, channel }));
732
- return true;
733
- } catch (error) {
734
- this.logger.error(`Slack Endpoint ${this.$id} 取消置顶失败:`, error);
735
- throw error;
736
- }
392
+ await this.#client!.pins.remove({ channel, timestamp });
393
+ return true;
394
+ }
395
+
396
+ async #startSocket(): Promise<void> {
397
+ const { config } = this.#options;
398
+ if (!config.appToken) throw new Error('Socket Mode requires appToken');
399
+ this.#socket = this.#options.createSocket?.({
400
+ appToken: config.appToken,
401
+ clientPingTimeout: config.clientPingTimeout,
402
+ }) ?? new SocketModeClient({
403
+ appToken: config.appToken,
404
+ clientPingTimeout: config.clientPingTimeout,
405
+ }) as unknown as SlackSocketLike;
406
+
407
+ this.#socket.on('slack_event', async ({ ack, body }) => {
408
+ await ack();
409
+ this.handleEnvelope(body);
410
+ });
411
+ this.#socket.on('interactive', async ({ ack, body }) => {
412
+ await ack();
413
+ this.admitInteraction(body as SlackInteractionPayload);
414
+ });
415
+ this.#socket.on('slash_commands', async ({ ack, body }) => {
416
+ await ack();
417
+ this.admitSlashCommand(body as SlackSlashCommand);
418
+ });
419
+
420
+ await this.#socket.start();
421
+ }
422
+ }
423
+
424
+ function parseSendTarget(target: string): { channel: string; threadTs?: string } {
425
+ const parsed = parseSlackMessageRef(target);
426
+ if (parsed && /^\d+\.\d+$/.test(parsed.ts)) {
427
+ return { channel: parsed.channel, threadTs: parsed.ts };
737
428
  }
429
+ return { channel: target };
738
430
  }