@zhin.js/adapter-slack 4.1.0 → 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 (118) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +58 -250
  3. package/adapters/slack.ts +34 -0
  4. package/agent/tools/add_reaction.ts +2 -2
  5. package/agent/tools/archive_channel.ts +2 -2
  6. package/agent/tools/edit_message.ts +5 -4
  7. package/agent/tools/invite_to_channel.ts +2 -2
  8. package/agent/tools/pin_message.ts +2 -2
  9. package/agent/tools/remove_reaction.ts +2 -2
  10. package/agent/tools/set_purpose.ts +2 -2
  11. package/agent/tools/set_topic.ts +2 -2
  12. package/agent/tools/unarchive.ts +2 -2
  13. package/agent/tools/unpin_message.ts +2 -2
  14. package/agent/tools/user_info.ts +2 -2
  15. package/lib/endpoint.d.ts +147 -0
  16. package/lib/endpoint.js +335 -0
  17. package/lib/index.d.ts +11 -0
  18. package/lib/index.js +11 -0
  19. package/lib/markdown-to-mrkdwn.d.ts +9 -0
  20. package/lib/markdown-to-mrkdwn.js +121 -0
  21. package/lib/mrkdwn-to-markdown.d.ts +4 -0
  22. package/lib/{src/mrkdwn-to-markdown.js → mrkdwn-to-markdown.js} +0 -1
  23. package/lib/platform-permit.d.ts +16 -0
  24. package/lib/{src/platform-permit.js → platform-permit.js} +1 -2
  25. package/lib/protocol.d.ts +143 -0
  26. package/lib/protocol.js +225 -0
  27. package/lib/slack-agent-deps.d.ts +28 -0
  28. package/lib/slack-agent-deps.js +30 -0
  29. package/lib/slack-inbound-filter.d.ts +12 -0
  30. package/lib/{src/slack-inbound-filter.js → slack-inbound-filter.js} +0 -1
  31. package/lib/slack-message-ref.d.ts +7 -0
  32. package/lib/{src/slack-message-ref.js → slack-message-ref.js} +0 -1
  33. package/lib/slack-outbound.d.ts +24 -0
  34. package/lib/slack-outbound.js +109 -0
  35. package/lib/slack-reaction.d.ts +1 -0
  36. package/lib/{src/slack-reaction.js → slack-reaction.js} +0 -1
  37. package/lib/slack-response-url.d.ts +5 -0
  38. package/lib/{src/slack-response-url.js → slack-response-url.js} +0 -1
  39. package/lib/webhook.d.ts +14 -0
  40. package/lib/webhook.js +69 -0
  41. package/package.json +41 -20
  42. package/plugin.ts +13 -0
  43. package/schema.json +38 -0
  44. package/src/endpoint.ts +317 -227
  45. package/src/index.ts +62 -66
  46. package/src/markdown-to-mrkdwn.ts +63 -4
  47. package/src/platform-permit.ts +1 -1
  48. package/src/protocol.ts +380 -0
  49. package/src/slack-agent-deps.ts +46 -11
  50. package/src/slack-inbound-filter.ts +1 -1
  51. package/src/slack-outbound.ts +32 -134
  52. package/src/webhook.ts +103 -0
  53. package/lib/agent/tools/add_reaction.js +0 -21
  54. package/lib/agent/tools/add_reaction.js.map +0 -1
  55. package/lib/agent/tools/archive_channel.js +0 -21
  56. package/lib/agent/tools/archive_channel.js.map +0 -1
  57. package/lib/agent/tools/edit_message.js +0 -22
  58. package/lib/agent/tools/edit_message.js.map +0 -1
  59. package/lib/agent/tools/invite_to_channel.js +0 -22
  60. package/lib/agent/tools/invite_to_channel.js.map +0 -1
  61. package/lib/agent/tools/pin_message.js +0 -22
  62. package/lib/agent/tools/pin_message.js.map +0 -1
  63. package/lib/agent/tools/remove_reaction.js +0 -21
  64. package/lib/agent/tools/remove_reaction.js.map +0 -1
  65. package/lib/agent/tools/set_purpose.js +0 -22
  66. package/lib/agent/tools/set_purpose.js.map +0 -1
  67. package/lib/agent/tools/set_topic.js +0 -22
  68. package/lib/agent/tools/set_topic.js.map +0 -1
  69. package/lib/agent/tools/unarchive.js +0 -21
  70. package/lib/agent/tools/unarchive.js.map +0 -1
  71. package/lib/agent/tools/unpin_message.js +0 -22
  72. package/lib/agent/tools/unpin_message.js.map +0 -1
  73. package/lib/agent/tools/user_info.js +0 -28
  74. package/lib/agent/tools/user_info.js.map +0 -1
  75. package/lib/src/adapter.js +0 -62
  76. package/lib/src/adapter.js.map +0 -1
  77. package/lib/src/endpoint.js +0 -312
  78. package/lib/src/endpoint.js.map +0 -1
  79. package/lib/src/event-dispatcher.js +0 -233
  80. package/lib/src/event-dispatcher.js.map +0 -1
  81. package/lib/src/index.js +0 -51
  82. package/lib/src/index.js.map +0 -1
  83. package/lib/src/markdown-to-mrkdwn.js +0 -61
  84. package/lib/src/markdown-to-mrkdwn.js.map +0 -1
  85. package/lib/src/mrkdwn-to-markdown.js.map +0 -1
  86. package/lib/src/platform-permit.js.map +0 -1
  87. package/lib/src/segment-mapper.js +0 -2
  88. package/lib/src/segment-mapper.js.map +0 -1
  89. package/lib/src/signing.js +0 -21
  90. package/lib/src/signing.js.map +0 -1
  91. package/lib/src/slack-agent-deps.js +0 -10
  92. package/lib/src/slack-agent-deps.js.map +0 -1
  93. package/lib/src/slack-inbound-filter.js.map +0 -1
  94. package/lib/src/slack-inbound.js +0 -127
  95. package/lib/src/slack-inbound.js.map +0 -1
  96. package/lib/src/slack-message-ref.js.map +0 -1
  97. package/lib/src/slack-outbound.js +0 -223
  98. package/lib/src/slack-outbound.js.map +0 -1
  99. package/lib/src/slack-reaction.js.map +0 -1
  100. package/lib/src/slack-response-url.js.map +0 -1
  101. package/lib/src/slack-side-events.js +0 -64
  102. package/lib/src/slack-side-events.js.map +0 -1
  103. package/lib/src/transport-http.js +0 -83
  104. package/lib/src/transport-http.js.map +0 -1
  105. package/lib/src/transport-socket.js +0 -68
  106. package/lib/src/transport-socket.js.map +0 -1
  107. package/lib/src/types.js +0 -5
  108. package/lib/src/types.js.map +0 -1
  109. package/plugin.yml +0 -3
  110. package/src/adapter.ts +0 -75
  111. package/src/event-dispatcher.ts +0 -255
  112. package/src/segment-mapper.ts +0 -1
  113. package/src/signing.ts +0 -28
  114. package/src/slack-inbound.ts +0 -140
  115. package/src/slack-side-events.ts +0 -74
  116. package/src/transport-http.ts +0 -95
  117. package/src/transport-socket.ts +0 -77
  118. package/src/types.ts +0 -113
package/src/endpoint.ts CHANGED
@@ -1,183 +1,294 @@
1
1
  /**
2
- * Slack Endpoint 双传输(Socket Mode / HTTP)
2
+ * SlackEndpointlifecycle, outbound, admit, Socket Mode, agent tool surface.
3
3
  */
4
+ import { SocketModeClient } from '@slack/socket-mode';
4
5
  import { WebClient } from '@slack/web-api';
5
- import { formatCompact, Endpoint, Message, segment, type SendOptions, type EditMessageOptions, expandInteractiveSegmentsInContent } from 'zhin.js';
6
- import type { SlackEndpointConfig, SlackMessageEvent } from './types.js';
7
- import type { SlackAdapter } from './adapter.js';
8
- import { toCanonicalSegments, fromCanonicalSegments } from './segment-mapper.js';
9
- import { parseSlackMessageToSegments, resolveSlackChannelType } from './slack-inbound.js';
10
- import { sendSlackContent, editSlackContent } from './slack-outbound.js';
11
- import { normalizeSlackReactionName } from './slack-reaction.js';
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';
12
29
  import { formatSlackMessageRef, parseSlackMessageRef } from './slack-message-ref.js';
13
- import { SlackEventDispatcher } from './event-dispatcher.js';
14
- import { SlackSocketTransport } from './transport-socket.js';
15
- import { SlackHttpTransport, type RouterLike } from './transport-http.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
+ }
16
76
 
17
- export class SlackEndpoint implements Endpoint<SlackEndpointConfig, SlackMessageEvent> {
18
- $connected: boolean;
19
- /** Slack Bot User ID(auth.test.user_id),用于 @ 触发 AI */
20
- $platformUserId?: string;
21
- client?: WebClient;
22
- /** message ts channel id(Activity Feedback reaction / recall 定位频道) */
23
- readonly messageChannelMap = new Map<string, string>();
24
- senderPermitCache = new Map<string, { at: number; role?: string; permissions: string[] }>();
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
+ }
25
88
 
26
- private dispatcher?: SlackEventDispatcher;
27
- private socketTransport?: SlackSocketTransport;
28
- private httpTransport?: SlackHttpTransport;
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;
103
+ }
29
104
 
30
- get logger() { return this.adapter.plugin.logger; }
31
- get $id() { return this.$config.name; }
105
+ get client(): SlackWebClientLike | undefined {
106
+ return this.#client;
107
+ }
32
108
 
33
- constructor(public adapter: SlackAdapter, public $config: SlackEndpointConfig) {
34
- this.$connected = false;
109
+ get platformUserId(): string | undefined {
110
+ return this.#botUserId;
35
111
  }
36
112
 
37
- async $connect(router?: RouterLike): Promise<void> {
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;
38
120
  try {
39
- this.client = new WebClient(this.$config.token);
40
- this.dispatcher = new SlackEventDispatcher(this);
41
-
42
- if (this.$config.socketMode) {
43
- this.socketTransport = new SlackSocketTransport(this.$config, this.dispatcher, this.logger);
44
- await this.socketTransport.connect();
45
- } else if (router) {
46
- this.httpTransport = new SlackHttpTransport(this.$config, this.dispatcher, this.logger);
47
- this.httpTransport.registerRoutes(router);
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();
129
+ } else {
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
+ }));
48
140
  }
49
141
 
50
- this.$connected = true;
142
+ const authTest = await this.#client.auth.test();
143
+ if (authTest.user_id) this.#botUserId = String(authTest.user_id);
51
144
 
52
- const authTest = await this.client.auth.test();
53
- if (authTest.user_id) {
54
- this.$platformUserId = String(authTest.user_id);
55
- }
56
- this.logger.debug(formatCompact({
57
- endpoint: this.$config.name,
58
- user: authTest.user,
59
- platform_user_id: this.$platformUserId,
145
+ logger.info(formatCompact({
146
+ op: 'connect',
147
+ endpoint: config.name,
148
+ mode: config.mode,
149
+ platform_user_id: this.#botUserId,
60
150
  }));
61
-
62
- if (!this.$config.socketMode && router) {
63
- this.logger.debug(formatCompact({ op: 'listen', mode: 'http', path: '/slack/events' }));
64
- }
65
151
  } catch (error) {
66
- this.logger.error('Failed to connect Slack bot:', error);
67
- this.$connected = false;
152
+ await this.stop();
153
+ logger.error('Failed to connect Slack endpoint:', error);
68
154
  throw error;
69
155
  }
70
156
  }
71
157
 
72
- async $disconnect(): Promise<void> {
73
- try {
74
- if (this.socketTransport) {
75
- await this.socketTransport.disconnect();
76
- this.socketTransport = undefined;
158
+ open(): void {
159
+ this.#open = true;
160
+ }
161
+
162
+ close(): void {
163
+ this.#open = false;
164
+ }
165
+
166
+ async stop(): Promise<void> {
167
+ this.#open = false;
168
+ if (this.#socket) {
169
+ try {
170
+ await this.#socket.disconnect();
171
+ } catch {
172
+ /* ignore */
77
173
  }
78
- this.httpTransport = undefined;
79
- this.$connected = false;
80
- this.logger.debug(formatCompact({ op: 'disconnect', endpoint: this.$config.name }));
81
- } catch (error) {
82
- this.logger.error('Error disconnecting Slack bot:', error);
83
- throw error;
174
+ this.#socket = undefined;
84
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 }));
85
182
  }
86
183
 
87
- $formatMessage(msg: SlackMessageEvent): Message<SlackMessageEvent> {
88
- const channelType = resolveSlackChannelType(msg);
89
- const channelId = msg.channel;
90
- this.trackMessageChannel(msg.ts, channelId);
91
- const wire = parseSlackMessageToSegments(msg);
92
- const content = toCanonicalSegments(wire);
93
- const userId = msg.user ?? '';
94
- const userName = (msg as any).username ?? (userId || 'Unknown');
95
- const messageText = msg.text ?? '';
96
-
97
- this.trackMessageChannel(msg.ts, channelId);
98
-
99
- const result = Message.from(msg, {
100
- $id: formatSlackMessageRef(channelId, msg.ts),
101
- $adapter: 'slack',
102
- $endpoint: this.$config.name,
103
- $sender: { id: userId, name: userName },
104
- $channel: { id: channelId, type: channelType },
105
- $content: content,
106
- $raw: messageText,
107
- $timestamp: parseFloat(msg.ts) * 1000,
108
- $quote_id: msg.thread_ts && msg.thread_ts !== msg.ts ? msg.thread_ts : undefined,
109
- $recall: async () => {
110
- await this.client!.chat.delete({ channel: channelId, ts: msg.ts });
111
- },
112
- $reply: async (replyContent, quote?) => {
113
- if (!Array.isArray(replyContent)) replyContent = [replyContent];
114
- const threadTs = quote
115
- ? (typeof quote === 'boolean' ? msg.ts : quote)
116
- : (msg.thread_ts ?? undefined);
117
- return await this.adapter.sendMessage({
118
- context: 'slack',
119
- endpoint: this.$config.name,
120
- id: channelId,
121
- type: channelType,
122
- content: replyContent,
123
- ...(threadTs ? { threadId: threadTs } : {}),
124
- });
125
- },
126
- });
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,
192
+ );
193
+ if (result.ts) this.trackMessageChannel(result.ts, channel);
194
+ return formatSlackMessageRef(channel, result.ts || String(Date.now()));
195
+ }
127
196
 
128
- return result;
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
+ });
129
228
  }
130
229
 
131
- async $sendMessage(options: SendOptions): Promise<string> {
132
- try {
133
- const canonical = expandInteractiveSegmentsInContent(options.content);
134
- const wire = fromCanonicalSegments(canonical);
135
- const result = await sendSlackContent(this.client!, wire, {
136
- channel: options.id,
137
- threadTs: options.threadId,
138
- }, this.logger);
139
- this.logger.debug(
140
- `${this.$config.name} send ${options.type}(${options.id}): ${segment.raw(options.content)}`,
141
- );
142
- this.trackMessageChannel(result.ts, options.id);
143
- return formatSlackMessageRef(options.id, result.ts);
144
- } catch (error) {
145
- this.logger.error('Failed to send Slack message:', error);
146
- throw error;
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);
147
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
+ });
148
258
  }
149
259
 
150
- async $editMessage(options: EditMessageOptions): Promise<void> {
151
- const ref = parseSlackMessageRef(options.messageId);
152
- const channel = ref?.channel ?? options.id;
153
- const ts = ref?.ts ?? options.messageId;
154
- const canonical = expandInteractiveSegmentsInContent(options.content);
155
- const wire = fromCanonicalSegments(canonical);
156
- await editSlackContent(this.client!, channel, ts, wire);
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
+ });
157
281
  }
158
282
 
159
- async $recallMessage(id: string): Promise<void> {
160
- const ref = this.resolveMessageRef(id);
161
- if (!ref) {
162
- this.logger.warn(formatCompact({ op: 'recall_skip', reason: 'channel_unknown', message_id: id }));
163
- return;
164
- }
165
- try {
166
- await this.client!.chat.delete({ channel: ref.channel, ts: ref.ts });
167
- } catch (error: unknown) {
168
- const slackError = (error as { data?: { error?: string } })?.data?.error;
169
- if (slackError === 'message_not_found') {
170
- this.logger.debug(formatCompact({ op: 'recall_skip', reason: 'message_not_found', message_id: id }));
171
- return;
172
- }
173
- 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);
174
287
  }
175
288
  }
176
289
 
177
290
  trackMessageChannel(ts: string, channel: string): void {
178
- if (ts && channel) {
179
- this.messageChannelMap.set(ts, channel);
180
- }
291
+ if (ts && channel) this.#messageChannelMap.set(ts, channel);
181
292
  }
182
293
 
183
294
  resolveMessageRef(messageId: string, channelHint?: string): { channel: string; ts: string } | null {
@@ -186,155 +297,134 @@ export class SlackEndpoint implements Endpoint<SlackEndpointConfig, SlackMessage
186
297
  this.trackMessageChannel(parsed.ts, parsed.channel);
187
298
  return parsed;
188
299
  }
189
- if (channelHint) {
190
- return { channel: channelHint, ts: messageId };
191
- }
192
- const channel = this.messageChannelMap.get(messageId);
193
- if (channel) {
194
- return { channel, ts: messageId };
195
- }
196
- return null;
197
- }
198
-
199
- /**
200
- * Activity Feedback:在用户消息上添加表情回应
201
- * @returns reaction name,供 $removeReaction 使用
202
- */
203
- async $addReaction(
204
- messageId: string,
205
- emoji: string,
206
- hint?: { sceneType?: 'private' | 'group' | 'channel'; channelId?: string },
207
- ): Promise<string | null> {
208
- const ref = this.resolveMessageRef(messageId, hint?.channelId);
209
- if (!ref) {
210
- this.logger.warn(formatCompact({ op: 'reaction_add_skip', reason: 'channel_unknown', message_id: messageId }));
211
- return null;
212
- }
213
- const name = normalizeSlackReactionName(emoji);
214
- try {
215
- await this.addReaction(ref.channel, ref.ts, name);
216
- return name;
217
- } catch (error) {
218
- this.logger.error('Failed to add Slack reaction:', error);
219
- return null;
220
- }
300
+ if (channelHint) return { channel: channelHint, ts: messageId };
301
+ const channel = this.#messageChannelMap.get(messageId);
302
+ return channel ? { channel, ts: messageId } : null;
221
303
  }
222
304
 
223
- /** Activity Feedback:移除本 Bot 在消息上的表情回应 */
224
- async $removeReaction(messageId: string, reactionId: string, channelHint?: string): Promise<void> {
225
- const ref = this.resolveMessageRef(messageId, channelHint);
226
- if (!ref) {
227
- this.logger.warn(formatCompact({ op: 'reaction_remove_skip', reason: 'channel_unknown', message_id: messageId }));
228
- return;
229
- }
230
- const name = normalizeSlackReactionName(reactionId);
231
- try {
232
- await this.removeReaction(ref.channel, ref.ts, name);
233
- } catch (error) {
234
- const code = (error as { data?: { error?: string } })?.data?.error;
235
- if (code === 'no_reaction') return;
236
- this.logger.error('Failed to remove Slack reaction:', error);
237
- }
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);
238
308
  }
239
309
 
240
- // ==================== 工作区管理 API ====================
310
+ // ── Agent tool surface ──────────────────────────────────────────────
241
311
 
242
312
  async inviteToChannel(channel: string, users: string[]): Promise<boolean> {
243
- await this.client!.conversations.invite({ channel, users: users.join(',') });
244
- this.logger.debug(formatCompact({ op: 'invite', endpoint: this.$id, channel, users: users.join(',') }));
313
+ await this.#client!.conversations.invite({ channel, users: users.join(',') });
245
314
  return true;
246
315
  }
247
316
 
248
317
  async kickFromChannel(channel: string, user: string): Promise<boolean> {
249
- await this.client!.conversations.kick({ channel, user });
250
- this.logger.debug(formatCompact({ op: 'kick', endpoint: this.$id, channel, user }));
318
+ await this.#client!.conversations.kick({ channel, user });
251
319
  return true;
252
320
  }
253
321
 
254
322
  async setChannelTopic(channel: string, topic: string): Promise<boolean> {
255
- await this.client!.conversations.setTopic({ channel, topic });
256
- this.logger.debug(formatCompact({ op: 'set_topic', endpoint: this.$id, channel }));
323
+ await this.#client!.conversations.setTopic({ channel, topic });
257
324
  return true;
258
325
  }
259
326
 
260
327
  async setChannelPurpose(channel: string, purpose: string): Promise<boolean> {
261
- await this.client!.conversations.setPurpose({ channel, purpose });
262
- this.logger.debug(formatCompact({ op: 'set_purpose', endpoint: this.$id, channel }));
328
+ await this.#client!.conversations.setPurpose({ channel, purpose });
263
329
  return true;
264
330
  }
265
331
 
266
332
  async archiveChannel(channel: string): Promise<boolean> {
267
- await this.client!.conversations.archive({ channel });
268
- this.logger.debug(formatCompact({ op: 'archive', endpoint: this.$id, channel }));
333
+ await this.#client!.conversations.archive({ channel });
269
334
  return true;
270
335
  }
271
336
 
272
337
  async unarchiveChannel(channel: string): Promise<boolean> {
273
- await this.client!.conversations.unarchive({ channel });
274
- this.logger.debug(formatCompact({ op: 'unarchive', endpoint: this.$id, channel }));
338
+ await this.#client!.conversations.unarchive({ channel });
275
339
  return true;
276
340
  }
277
341
 
278
342
  async renameChannel(channel: string, name: string): Promise<boolean> {
279
- await this.client!.conversations.rename({ channel, name });
280
- this.logger.debug(formatCompact({ op: 'rename', endpoint: this.$id, channel, name }));
343
+ await this.#client!.conversations.rename({ channel, name });
281
344
  return true;
282
345
  }
283
346
 
284
347
  async getChannelMembers(channel: string): Promise<string[]> {
285
- const result = await this.client!.conversations.members({ channel });
348
+ const result = await this.#client!.conversations.members({ channel });
286
349
  return result.members || [];
287
350
  }
288
351
 
289
- async getChannelInfo(channel: string): Promise<any> {
290
- const result = await this.client!.conversations.info({ channel });
352
+ async getChannelInfo(channel: string): Promise<unknown> {
353
+ const result = await this.#client!.conversations.info({ channel });
291
354
  return result.channel;
292
355
  }
293
356
 
294
- async getUserInfo(user: string): Promise<any> {
295
- const result = await this.client!.users.info({ user });
357
+ async getUserInfo(user: string): Promise<unknown> {
358
+ const result = await this.#client!.users.info({ user });
296
359
  return result.user;
297
360
  }
298
361
 
299
362
  async addReaction(channel: string, timestamp: string, name: string): Promise<boolean> {
363
+ const reaction = normalizeSlackReactionName(name);
300
364
  try {
301
- await this.client!.reactions.add({ channel, timestamp, name });
302
- this.logger.debug(formatCompact({ op: 'reaction_add', endpoint: this.$id, name }));
365
+ await this.#client!.reactions.add({ channel, timestamp, name: reaction });
303
366
  return true;
304
367
  } catch (error) {
305
368
  const code = (error as { data?: { error?: string } })?.data?.error;
306
- if (code === 'already_reacted') {
307
- this.logger.debug(formatCompact({ op: 'reaction_add', endpoint: this.$id, name, status: 'exists' }));
308
- return true;
309
- }
369
+ if (code === 'already_reacted') return true;
310
370
  throw error;
311
371
  }
312
372
  }
313
373
 
314
374
  async removeReaction(channel: string, timestamp: string, name: string): Promise<boolean> {
375
+ const reaction = normalizeSlackReactionName(name);
315
376
  try {
316
- await this.client!.reactions.remove({ channel, timestamp, name });
317
- this.logger.debug(formatCompact({ op: 'reaction_remove', endpoint: this.$id, name }));
377
+ await this.#client!.reactions.remove({ channel, timestamp, name: reaction });
318
378
  return true;
319
379
  } catch (error) {
320
380
  const code = (error as { data?: { error?: string } })?.data?.error;
321
- if (code === 'no_reaction') {
322
- this.logger.debug(formatCompact({ op: 'reaction_remove', endpoint: this.$id, name, status: 'gone' }));
323
- return true;
324
- }
381
+ if (code === 'no_reaction') return true;
325
382
  throw error;
326
383
  }
327
384
  }
328
385
 
329
386
  async pinMessage(channel: string, timestamp: string): Promise<boolean> {
330
- await this.client!.pins.add({ channel, timestamp });
331
- this.logger.debug(formatCompact({ op: 'pin', endpoint: this.$id, channel }));
387
+ await this.#client!.pins.add({ channel, timestamp });
332
388
  return true;
333
389
  }
334
390
 
335
391
  async unpinMessage(channel: string, timestamp: string): Promise<boolean> {
336
- await this.client!.pins.remove({ channel, timestamp });
337
- this.logger.debug(formatCompact({ op: 'unpin', endpoint: this.$id, channel }));
392
+ await this.#client!.pins.remove({ channel, timestamp });
338
393
  return true;
339
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 };
428
+ }
429
+ return { channel: target };
340
430
  }