@zhin.js/adapter-slack 1.1.18 → 1.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/endpoint.ts CHANGED
@@ -1,19 +1,26 @@
1
1
  /**
2
2
  * SlackEndpoint — lifecycle, outbound, admit, Socket Mode, agent tool surface.
3
3
  */
4
+ import { WebClient, type WebClientOptions } from '@slack/web-api';
4
5
  import { SocketModeClient } from '@slack/socket-mode';
5
6
  import { createSlackWebApiAgent } from './web-api-proxy.js';
6
7
  import { createSlackStreamAgent } from './stream-proxy.js';
7
8
  import { slackSocketConnectionError } from './socket-error.js';
8
- import { createSlackWebClient, slackDeliveryError } from './web-client.js';
9
+ import { slackWebClientOptions, slackDeliveryError } from './web-client.js';
9
10
  import {
10
- Endpoint,
11
+ createEndpointSpecification,
12
+ endpointSpec,
13
+ type Endpoint,
14
+ type EndpointSpecification,
11
15
  createEndpointLifecycle,
12
16
  type EndpointFriend,
13
17
  type EndpointGroup,
14
18
  type EndpointControl,
15
19
  type EndpointManagement,
16
20
  type EndpointSendRequest,
21
+ type EndpointManagementRequest,
22
+ createEndpointOperation,
23
+ type EndpointGroupRequest,
17
24
  } from 'zhin.js/adapter';
18
25
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
19
26
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
@@ -38,7 +45,7 @@ import {
38
45
  import { formatSlackMessageRef, parseSlackMessageRef } from './slack-message-ref.js';
39
46
  import { normalizeSlackReactionName } from './slack-reaction.js';
40
47
  import { postSlackEphemeral } from './slack-response-url.js';
41
- import { editSlackContent, sendSlackContent, type SlackChatClient } from './slack-outbound.js';
48
+ import { editSlackContent, sendSlackContent, type SlackChatClient, type SlackEditRequest } from './slack-outbound.js';
42
49
  import { registerSlackWebhookRoutes, type SlackWebhookHandler } from './webhook.js';
43
50
  import { receiveSlackSideEvent } from './side-event-dispatch.js';
44
51
 
@@ -51,52 +58,14 @@ export interface SlackSocketLike {
51
58
  disconnect(): Promise<void>;
52
59
  }
53
60
 
54
- export interface SlackWebClientLike extends SlackChatClient {
55
- auth: {
56
- test(): Promise<{ user_id?: string; user?: string }>;
57
- };
58
- conversations: {
59
- invite(opts: { channel: string; users: string }): Promise<unknown>;
60
- kick(opts: { channel: string; user: string }): Promise<unknown>;
61
- setTopic(opts: { channel: string; topic: string }): Promise<unknown>;
62
- setPurpose(opts: { channel: string; purpose: string }): Promise<unknown>;
63
- archive(opts: { channel: string }): Promise<unknown>;
64
- unarchive(opts: { channel: string }): Promise<unknown>;
65
- rename(opts: { channel: string; name: string }): Promise<unknown>;
66
- members(opts: { channel: string }): Promise<{ members?: string[] }>;
67
- info(opts: { channel: string }): Promise<{ channel?: unknown }>;
68
- list(opts?: {
69
- types?: string;
70
- limit?: number;
71
- cursor?: string;
72
- exclude_archived?: boolean;
73
- }): Promise<{ channels?: unknown[]; response_metadata?: { next_cursor?: string } }>;
74
- };
75
- users: {
76
- info(opts: { user: string }): Promise<{ user?: unknown }>;
77
- list(opts?: {
78
- limit?: number;
79
- cursor?: string;
80
- }): Promise<{ members?: unknown[]; response_metadata?: { next_cursor?: string } }>;
81
- };
82
- reactions: {
83
- add(opts: { channel: string; timestamp: string; name: string }): Promise<unknown>;
84
- remove(opts: { channel: string; timestamp: string; name: string }): Promise<unknown>;
85
- };
86
- pins: {
87
- add(opts: { channel: string; timestamp: string }): Promise<unknown>;
88
- remove(opts: { channel: string; timestamp: string }): Promise<unknown>;
89
- };
90
- chat: SlackChatClient['chat'] & {
91
- delete(opts: { channel: string; ts: string }): Promise<unknown>;
92
- };
93
- }
61
+ export type SlackWebClientLike = WebClient;
94
62
 
95
63
  export interface SlackEndpointOptions {
96
64
  readonly id: CapabilityId;
97
65
  readonly config: ResolvedSlackConfig;
98
66
  readonly http?: HttpHost;
99
- readonly createClient?: (token: string) => SlackWebClientLike;
67
+ readonly webApiAgent?: ReturnType<typeof createSlackWebApiAgent>;
68
+ readonly webOptions?: WebClientOptions;
100
69
  readonly createSocket?: (opts: {
101
70
  readonly appToken: string;
102
71
  readonly clientPingTimeout: number;
@@ -104,52 +73,58 @@ export interface SlackEndpointOptions {
104
73
  }) => SlackSocketLike;
105
74
  }
106
75
 
107
- export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements SlackWebhookHandler {
76
+ export class SlackEndpoint extends WebClient implements Endpoint, SlackWebhookHandler {
77
+ readonly [endpointSpec]: EndpointSpecification;
108
78
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
109
79
 
110
80
  readonly #options: SlackEndpointOptions;
111
81
  readonly #inboundFilter = createSlackInboundFilterState();
112
82
  readonly #messageChannelMap = new Map<string, string>();
113
- #client?: SlackWebClientLike;
114
- #webApiAgent?: ReturnType<typeof createSlackWebApiAgent>;
115
83
  #socket?: SlackSocketLike;
116
84
  readonly #lifecycle: ReturnType<typeof createEndpointLifecycle>;
117
85
  #routeReleases: HttpRouteRegistration[] = [];
118
86
  #botUserId?: string;
119
- #open = false;
87
+ #executionSignal?: AbortSignal;
120
88
  #started = false;
121
- readonly management: EndpointManagement = createSlackEndpointManagement(() => this.client);
122
- readonly control: EndpointControl = Object.freeze<EndpointControl>({
123
- recall: async (message) => {
89
+ readonly #management: EndpointManagement = createSlackEndpointManagement(() => this);
90
+ readonly #control: EndpointControl = Object.freeze<EndpointControl>({
91
+ recall: async ({ message, signal }) => {
124
92
  const ref = this.resolveMessageRef(message.id, message.conversation.id);
125
- if (!ref || !this.#client) return;
126
- await this.#client.chat.delete(ref);
93
+ if (!ref || !this.#started) return;
94
+ await createEndpointOperation({ signal }).run(() => this.chat.delete(ref));
127
95
  },
128
- edit: async (message, content) => {
96
+ edit: async ({ message, content, signal }) => {
129
97
  const ref = this.resolveMessageRef(message.id, message.conversation.id);
130
98
  if (!ref) return null;
131
- await this.#editMessage(ref.channel, ref.ts, content);
99
+ await this.#editMessage({ channel: ref.channel, ts: ref.ts, content, signal });
132
100
  return message.id;
133
101
  },
134
- addReaction: async (message, emoji) => {
102
+ addReaction: async ({ message, emoji, signal }) => {
135
103
  const ref = this.resolveMessageRef(message.id, message.conversation.id);
136
104
  if (!ref) return null;
137
105
  const reaction = normalizeSlackReactionName(emoji);
138
- await this.#addReaction(ref.channel, ref.ts, reaction);
106
+ await this.#addReaction({ channel: ref.channel, timestamp: ref.ts, name: reaction, signal });
139
107
  return reaction;
140
108
  },
141
- removeReaction: async (message, reactionId) => {
109
+ removeReaction: async ({ message, reactionId, signal }) => {
142
110
  const ref = this.resolveMessageRef(message.id, message.conversation.id);
143
111
  if (!ref) return;
144
- await this.#removeReaction(ref.channel, ref.ts, reactionId);
112
+ await this.#removeReaction({ channel: ref.channel, timestamp: ref.ts, name: reactionId, signal });
145
113
  },
146
114
  });
147
115
 
148
116
  constructor(options: SlackEndpointOptions) {
149
- super();
117
+ super(options.config.token, slackWebClientOptions({ ...options.webOptions, ...(options.webApiAgent ? { agent: options.webApiAgent } : {}) }));
150
118
  this.#logger = getAdapterLogger('slack', options.config.id);
151
119
  this.#options = options;
152
120
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
121
+ const endpoint = this;
122
+ this[endpointSpec] = createEndpointSpecification(this, {
123
+ start: signal => this.#startFramework(signal), open: () => undefined, close: () => undefined,
124
+ stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
125
+ name: options.config.id, get transportState() { return endpoint.#transportState; },
126
+ management: this.#management, control: this.#control,
127
+ });
153
128
  }
154
129
 
155
130
  /** Console 展示 / AdapterIndex live name(多 endpoint 时与 entry name 一致)。 */
@@ -157,12 +132,7 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
157
132
  return this.#options.config.id;
158
133
  }
159
134
 
160
- get client(): SlackWebClientLike {
161
- if (!this.#client) throw new Error('Slack client not connected');
162
- return this.#client;
163
- }
164
-
165
- get transportState() { return this.config.mode === 'socket' ? this.#lifecycle.state : this.#started ? 'open' : 'stopped'; }
135
+ get #transportState() { return this.config.mode === 'socket' ? this.#lifecycle.state : this.#started ? 'open' : 'stopped'; }
166
136
 
167
137
  get platformUserId(): string | undefined {
168
138
  return this.#botUserId;
@@ -172,14 +142,12 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
172
142
  return this.#options.config;
173
143
  }
174
144
 
175
- async start(): Promise<void> {
145
+ async #startFramework(signal: AbortSignal): Promise<void> {
146
+ this.#executionSignal = signal;
176
147
  if (this.#started) return;
177
148
  this.#started = true;
178
149
  try {
179
150
  const { config } = this.#options;
180
- if (config.webApiProxy && !this.#options.createClient) this.#webApiAgent = createSlackWebApiAgent(config.webApiProxy);
181
- this.#client = this.#options.createClient?.(config.token)
182
- ?? (createSlackWebClient(config.token, this.#webApiAgent ? { agent: this.#webApiAgent } : {}) as unknown as SlackWebClientLike);
183
151
 
184
152
  if (config.mode === 'socket') {
185
153
  await this.#startSocket();
@@ -196,7 +164,7 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
196
164
  }));
197
165
  }
198
166
 
199
- const authTest = await this.#client.auth.test();
167
+ const authTest = await this.auth.test();
200
168
  if (authTest.user_id) this.#botUserId = String(authTest.user_id);
201
169
 
202
170
  this.#logger.info(
@@ -204,24 +172,14 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
204
172
  + (this.#botUserId ? ` | user: ${this.#botUserId}` : ''),
205
173
  );
206
174
  } catch (error) {
207
- await this.stop();
175
+ await this.#stopFramework();
208
176
  this.#logger.error('Failed to connect Slack endpoint:', error);
209
177
  throw error;
210
178
  }
211
179
  }
212
180
 
213
- open(): void {
214
- this.#open = true;
215
- }
216
-
217
- close(): void {
218
- this.#open = false;
219
- }
220
-
221
- async stop(): Promise<void> {
222
- this.#open = false;
223
- this.#webApiAgent?.destroy();
224
- this.#webApiAgent = undefined;
181
+ async #stopFramework(): Promise<void> {
182
+ this.#options.webApiAgent?.destroy();
225
183
  await this.#lifecycle.stop();
226
184
  if (this.#socket) {
227
185
  try {
@@ -232,148 +190,153 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
232
190
  this.#socket = undefined;
233
191
  }
234
192
  for (const release of this.#routeReleases.splice(0)) release();
235
- this.#client = undefined;
236
193
  this.#started = false;
237
194
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
238
195
  }
239
196
 
240
- async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
241
- if (!this.#client) throw new Error('Slack client not connected');
197
+ async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
198
+ if (!this.#started) throw new Error('Slack client not connected');
242
199
  const channel = conversation.id;
243
200
  const threadTs = conversation.threadId;
244
201
  try {
245
- const result = await sendSlackContent(
246
- this.#client,
247
- payload,
248
- { channel, threadTs },
249
- this.#logger,
250
- );
251
- this.trackMessageChannel(result.ts, channel);
252
- return formatSlackMessageRef(channel, result.ts);
202
+ const result = await sendSlackContent(this, { content: payload, channel, threadTs, logger: this.#logger, signal });
203
+ this.trackMessageChannel(result.ts, channel);
204
+ return formatSlackMessageRef(channel, result.ts);
253
205
  } catch (error) { throw slackDeliveryError(error); }
254
206
  }
255
207
 
256
208
  /** Test / internal: admit a message event when open. */
257
- admit(event: SlackMessageEvent | SlackEvent): void {
258
- if (!this.#open) return;
259
- void this.#emitPlatformEvent(event.type || 'event', event);
260
- if (event.type !== 'message' && event.type !== 'app_mention') return;
261
- const msg = event as SlackMessageEvent;
262
- if (shouldDropSlackInboundMessage(msg, this.#inboundFilter, this.#botUserId)) return;
263
- if (!msg.channel || !msg.ts) return;
264
-
265
- this.trackMessageChannel(msg.ts, msg.channel);
266
- const threadTs = msg.thread_ts && msg.thread_ts !== msg.ts ? msg.thread_ts : undefined;
267
- const conversation = slackInboundConversation(String(this.#options.id), {
268
- channelId: msg.channel,
269
- channelType: msg.channel_type,
270
- threadId: threadTs,
271
- });
272
- void this.emit('message.receive', {
273
- conversation,
274
- message: { conversation, id: msg.ts },
275
- content: formatInboundContent(msg),
276
- sender: { id: msg.user ?? msg.channel ?? '' },
277
- endpointId: this.#options.config.id,
278
- ...(msg.type === 'app_mention' ? { mentioned: true } : {}),
279
- metadata: Object.freeze({
280
- channelType: resolveSlackChannelType(msg),
281
- userId: msg.user,
282
- threadTs,
283
- ts: msg.ts,
284
- }),
285
- }).catch((err) => {
286
- this.#logger.warn(formatCompact({
287
- op: 'slack_gateway_receive_failed',
288
- target: `${conversation.kind}:${conversation.id}`,
289
- error: err instanceof Error ? err.message : String(err),
290
- }));
291
- });
209
+ admit(event: SlackMessageEvent | SlackEvent, original: unknown = event): void {
210
+ void this[endpointSpec].events.run(() => {
211
+ void this.#emitPlatformEvent(event.type || 'event', event);
212
+ if (event.type !== 'message' && event.type !== 'app_mention') return;
213
+ const msg = event as SlackMessageEvent;
214
+ if (shouldDropSlackInboundMessage(msg, this.#inboundFilter, this.#botUserId)) return;
215
+ if (!msg.channel || !msg.ts) return;
216
+
217
+ this.trackMessageChannel(msg.ts, msg.channel);
218
+ const threadTs = msg.thread_ts && msg.thread_ts !== msg.ts ? msg.thread_ts : undefined;
219
+ const conversation = slackInboundConversation(String(this.#options.id), {
220
+ channelId: msg.channel,
221
+ channelType: msg.channel_type,
222
+ threadId: threadTs,
223
+ });
224
+ void this[endpointSpec].events.emit('message.receive', {
225
+ conversation,
226
+ event: original,
227
+ ...(typeof msg.ts === 'string' && Number.isFinite(Number(msg.ts)) ? { platformSentAt: Number(msg.ts) * 1_000 } : {}),
228
+ message: { conversation, id: msg.ts },
229
+ content: formatInboundContent(msg),
230
+ sender: { id: msg.user ?? msg.channel ?? '' },
231
+ endpointId: this.#options.config.id,
232
+ ...(msg.type === 'app_mention' ? { mentioned: true } : {}),
233
+ metadata: Object.freeze({
234
+ channelType: resolveSlackChannelType(msg),
235
+ userId: msg.user,
236
+ threadTs,
237
+ ts: msg.ts,
238
+ }),
239
+ }).catch((err) => {
240
+ this.#logger.warn(formatCompact({
241
+ op: 'slack_gateway_receive_failed',
242
+ target: `${conversation.kind}:${conversation.id}`,
243
+ error: err instanceof Error ? err.message : String(err),
244
+ }));
245
+ });
246
+ }).catch(() => this.#logger.debug('Inbound callback did not complete'));
292
247
  }
293
248
 
294
249
  admitInteraction(payload: SlackInteractionPayload): void {
295
- if (!this.#open) return;
296
- void this.#emitPlatformEvent(`interaction.${payload.type}`, payload);
297
- if (payload.type !== 'block_actions' || !payload.actions?.length) return;
298
- const channelId = payload.channel?.id ?? '';
299
- const userId = payload.user.id;
300
- const messageTs = payload.message?.ts ?? '';
301
- const sourceThread = payload.message?.thread_ts;
302
- const threadTs = typeof sourceThread === 'string' && sourceThread && sourceThread !== messageTs ? sourceThread : undefined;
303
- if (payload.response_url) {
304
- postSlackEphemeral(payload.response_url, '已收到', this.#logger);
305
- }
306
- const actionTs = payload.actions[0]?.action_ts ?? messageTs ?? `action-${Date.now()}`;
307
- const conversation = slackInboundConversation(String(this.#options.id), {
308
- channelId: channelId || userId,
309
- // block_actions 无 channel_type;无 channel 时按与发起用户的 DM 处理
310
- channelType: channelId ? undefined : 'im',
311
- threadId: threadTs,
312
- });
313
- void this.emit('message.receive', {
314
- conversation,
315
- message: { conversation, id: actionTs },
316
- content: formatInteractionContent(payload),
317
- segments: [{ type: 'action', data: { id: actionTs, payload: payload.actions[0].value ?? payload.actions[0].action_id, ...(messageTs ? { sourceMessageId: messageTs } : {}) } }],
318
- sender: { id: userId },
319
- endpointId: this.#options.config.id,
320
- metadata: Object.freeze({
321
- eventType: 'block_actions',
322
- actionId: payload.actions[0]?.action_id,
323
- threadTs,
324
- }),
325
- }).catch((err) => {
326
- this.#logger.warn(formatCompact({
327
- op: 'slack_gateway_receive_failed',
328
- target: `${conversation.kind}:${conversation.id}`,
329
- error: err instanceof Error ? err.message : String(err),
330
- }));
331
- });
250
+ void this[endpointSpec].events.run(() => {
251
+ void this.#emitPlatformEvent(`interaction.${payload.type}`, payload);
252
+ if (payload.type !== 'block_actions' || !payload.actions?.length) return;
253
+ const channelId = payload.channel?.id ?? '';
254
+ const userId = payload.user.id;
255
+ const messageTs = payload.message?.ts ?? '';
256
+ const sourceThread = payload.message?.thread_ts;
257
+ const threadTs = typeof sourceThread === 'string' && sourceThread && sourceThread !== messageTs ? sourceThread : undefined;
258
+ if (payload.response_url) {
259
+ postSlackEphemeral(payload.response_url, '已收到', this.#logger);
260
+ }
261
+ const actionTs = payload.actions[0]?.action_ts ?? messageTs ?? `action-${Date.now()}`;
262
+ const platformSentAt = Number(payload.actions[0]?.action_ts) * 1_000;
263
+ const conversation = slackInboundConversation(String(this.#options.id), {
264
+ channelId: channelId || userId,
265
+ // block_actions 无 channel_type;无 channel 时按与发起用户的 DM 处理
266
+ channelType: channelId ? undefined : 'im',
267
+ threadId: threadTs,
268
+ });
269
+ void this[endpointSpec].events.emit('message.receive', {
270
+ conversation,
271
+ event: payload,
272
+ ...(Number.isFinite(platformSentAt) ? { platformSentAt } : {}),
273
+ message: { conversation, id: actionTs },
274
+ content: formatInteractionContent(payload),
275
+ segments: [{ type: 'action', data: { id: actionTs, payload: payload.actions[0].value ?? payload.actions[0].action_id, ...(messageTs ? { sourceMessageId: messageTs } : {}) } }],
276
+ sender: { id: userId },
277
+ endpointId: this.#options.config.id,
278
+ metadata: Object.freeze({
279
+ eventType: 'block_actions',
280
+ actionId: payload.actions[0]?.action_id,
281
+ threadTs,
282
+ }),
283
+ }).catch((err) => {
284
+ this.#logger.warn(formatCompact({
285
+ op: 'slack_gateway_receive_failed',
286
+ target: `${conversation.kind}:${conversation.id}`,
287
+ error: err instanceof Error ? err.message : String(err),
288
+ }));
289
+ });
290
+ }).catch(() => this.#logger.debug('Inbound callback did not complete'));
332
291
  }
333
292
 
334
293
  admitSlashCommand(cmd: SlackSlashCommand): void {
335
- if (!this.#open) return;
336
- void this.#emitPlatformEvent(`slash.${cmd.command}`, cmd);
337
- postSlackEphemeral(cmd.response_url, '处理中…', this.#logger);
338
- const conversation = slackInboundConversation(String(this.#options.id), {
339
- channelId: cmd.channel_id,
340
- });
341
- void this.emit('message.receive', {
342
- conversation,
343
- message: { conversation, id: cmd.trigger_id },
344
- content: formatSlashContent(cmd),
345
- sender: { id: cmd.user_id, name: cmd.user_name },
346
- endpointId: this.#options.config.id,
347
- metadata: Object.freeze({
348
- eventType: 'slash_command',
349
- command: cmd.command,
350
- }),
351
- }).catch((err) => {
352
- this.#logger.warn(formatCompact({
353
- op: 'slack_gateway_receive_failed',
354
- target: `${conversation.kind}:${conversation.id}`,
355
- error: err instanceof Error ? err.message : String(err),
356
- }));
357
- });
294
+ void this[endpointSpec].events.run(() => {
295
+ void this.#emitPlatformEvent(`slash.${cmd.command}`, cmd);
296
+ postSlackEphemeral(cmd.response_url, '处理中…', this.#logger);
297
+ const conversation = slackInboundConversation(String(this.#options.id), {
298
+ channelId: cmd.channel_id,
299
+ });
300
+ void this[endpointSpec].events.emit('message.receive', {
301
+ conversation,
302
+ message: { conversation, id: cmd.trigger_id },
303
+ event: cmd,
304
+ content: formatSlashContent(cmd),
305
+ sender: { id: cmd.user_id, name: cmd.user_name },
306
+ endpointId: this.#options.config.id,
307
+ metadata: Object.freeze({
308
+ eventType: 'slash_command',
309
+ command: cmd.command,
310
+ }),
311
+ }).catch((err) => {
312
+ this.#logger.warn(formatCompact({
313
+ op: 'slack_gateway_receive_failed',
314
+ target: `${conversation.kind}:${conversation.id}`,
315
+ error: err instanceof Error ? err.message : String(err),
316
+ }));
317
+ });
318
+ }).catch(() => this.#logger.debug('Inbound callback did not complete'));
358
319
  }
359
320
 
360
321
  handleEnvelope(body: unknown): void {
361
- const envelope = body as SlackEventEnvelope;
362
- if (envelope?.type === 'event_callback' && envelope.event) {
363
- const event = envelope.event;
364
- if (event.type !== 'message' && event.type !== 'app_mention') {
365
- void this.#emitPlatformEvent(event.type || 'event', event);
366
- receiveSlackSideEvent(
367
- (name, payload) => this.emit(name, payload),
368
- String(this.#options.id),
369
- this.#options.config.id,
370
- event,
371
- this.#logger,
372
- );
373
- return;
322
+ void this[endpointSpec].events.run(() => {
323
+ const envelope = body as SlackEventEnvelope;
324
+ if (envelope?.type === 'event_callback' && envelope.event) {
325
+ const event = envelope.event;
326
+ if (event.type !== 'message' && event.type !== 'app_mention') {
327
+ void this.#emitPlatformEvent(event.type || 'event', event);
328
+ receiveSlackSideEvent(
329
+ (name, payload) => this[endpointSpec].events.emit(name, payload),
330
+ String(this.#options.id),
331
+ this.#options.config.id,
332
+ event,
333
+ this.#logger,
334
+ );
335
+ return;
336
+ }
337
+ this.admit(event, body);
374
338
  }
375
- this.admit(event);
376
- }
339
+ }).catch(() => this.#logger.debug('Inbound callback did not complete'));
377
340
  }
378
341
 
379
342
  trackMessageChannel(ts: string, channel: string): void {
@@ -399,21 +362,21 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
399
362
  }
400
363
 
401
364
  async recallMessage(messageId: string): Promise<void> {
402
- if (!this.#client) return;
365
+ if (!this.#started) return;
403
366
  const ref = this.resolveMessageRef(messageId);
404
367
  if (!ref) return;
405
- await this.#client.chat.delete({ channel: ref.channel, ts: ref.ts });
368
+ await this.chat.delete({ channel: ref.channel, ts: ref.ts });
406
369
  }
407
370
 
408
- async #editMessage(channel: string, messageTs: string, content: unknown): Promise<void> {
409
- if (!this.#client) throw new Error('Slack client not connected');
410
- await editSlackContent(this.#client, channel, messageTs, content);
371
+ async #editMessage(request: SlackEditRequest): Promise<void> {
372
+ if (!this.#started) throw new Error('Slack client not connected');
373
+ await editSlackContent(this, request);
411
374
  }
412
375
 
413
- async #addReaction(channel: string, timestamp: string, name: string): Promise<void> {
376
+ async #addReaction({ channel, timestamp, name, signal }: SlackReactionRequest): Promise<void> {
414
377
  const reaction = normalizeSlackReactionName(name);
415
378
  try {
416
- await this.#client!.reactions.add({ channel, timestamp, name: reaction });
379
+ await createEndpointOperation({ signal }).run(() => this.reactions.add({ channel, timestamp, name: reaction }));
417
380
  } catch (error) {
418
381
  const code = (error as { data?: { error?: string } })?.data?.error;
419
382
  if (code === 'already_reacted') return;
@@ -421,10 +384,10 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
421
384
  }
422
385
  }
423
386
 
424
- async #removeReaction(channel: string, timestamp: string, name: string): Promise<void> {
387
+ async #removeReaction({ channel, timestamp, name, signal }: SlackReactionRequest): Promise<void> {
425
388
  const reaction = normalizeSlackReactionName(name);
426
389
  try {
427
- await this.#client!.reactions.remove({ channel, timestamp, name: reaction });
390
+ await createEndpointOperation({ signal }).run(() => this.reactions.remove({ channel, timestamp, name: reaction }));
428
391
  } catch (error) {
429
392
  const code = (error as { data?: { error?: string } })?.data?.error;
430
393
  if (code === 'no_reaction') return;
@@ -433,7 +396,7 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
433
396
  }
434
397
 
435
398
  async #emitPlatformEvent(name: string, event: unknown): Promise<void> {
436
- await this.emitPlatform(name, event).catch((error) => {
399
+ await this[endpointSpec].events.platform(name, event).catch((error) => {
437
400
  this.#logger.warn(formatCompact({
438
401
  op: 'slack_platform_event_failed',
439
402
  event: name,
@@ -442,6 +405,17 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
442
405
  });
443
406
  }
444
407
 
408
+ #awaitSocketAck(ack: Promise<unknown>): Promise<unknown> {
409
+ const signal = this.#executionSignal;
410
+ if (!signal) return ack;
411
+ return new Promise((resolve, reject) => {
412
+ const abort = () => reject(signal.reason);
413
+ signal.addEventListener('abort', abort, { once: true });
414
+ void ack.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
415
+ if (signal.aborted) abort();
416
+ });
417
+ }
418
+
445
419
  async #startSocket(): Promise<void> {
446
420
  const { config } = this.#options;
447
421
  if (!config.appToken) {
@@ -462,9 +436,39 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
462
436
  handle.onForceClose(release);
463
437
  socket.on('disconnected', () => { if (!active) return; release(); handle.notifyClosed('Slack socket disconnected'); });
464
438
  socket.on('error', error => { connectionError = slackSocketConnectionError(error); if (!active) return; release(); handle.notifyClosed('Slack socket error'); });
465
- socket.on('slack_event', async ({ ack, body }) => { if (!active || this.#socket !== socket) return; await ack(); if (active) this.handleEnvelope(body); });
466
- socket.on('interactive', async ({ ack, body }) => { if (!active || this.#socket !== socket) return; await ack(); if (active) this.admitInteraction(body as SlackInteractionPayload); });
467
- socket.on('slash_commands', async ({ ack, body }) => { if (!active || this.#socket !== socket) return; await ack(); if (active) this.admitSlashCommand(body as SlackSlashCommand); });
439
+ socket.on('slack_event', ({ ack, body }) => {
440
+ if (!active || this.#socket !== socket) return;
441
+ void this[endpointSpec].events.run(async () => {
442
+ try { await this.#awaitSocketAck(ack()); } catch (error) {
443
+ if (!this.#executionSignal?.aborted) this.#logger.warn(formatCompact({ op: 'slack_socket_ack_failed' }));
444
+ throw error;
445
+ }
446
+ this.#executionSignal?.throwIfAborted();
447
+ if (active) this.handleEnvelope(body);
448
+ }).catch(() => this.#logger.debug('Socket callback did not complete'));
449
+ });
450
+ socket.on('interactive', ({ ack, body }) => {
451
+ if (!active || this.#socket !== socket) return;
452
+ void this[endpointSpec].events.run(async () => {
453
+ try { await this.#awaitSocketAck(ack()); } catch (error) {
454
+ if (!this.#executionSignal?.aborted) this.#logger.warn(formatCompact({ op: 'slack_socket_ack_failed' }));
455
+ throw error;
456
+ }
457
+ this.#executionSignal?.throwIfAborted();
458
+ if (active) this.admitInteraction(body as SlackInteractionPayload);
459
+ }).catch(() => this.#logger.debug('Socket callback did not complete'));
460
+ });
461
+ socket.on('slash_commands', ({ ack, body }) => {
462
+ if (!active || this.#socket !== socket) return;
463
+ void this[endpointSpec].events.run(async () => {
464
+ try { await this.#awaitSocketAck(ack()); } catch (error) {
465
+ if (!this.#executionSignal?.aborted) this.#logger.warn(formatCompact({ op: 'slack_socket_ack_failed' }));
466
+ throw error;
467
+ }
468
+ this.#executionSignal?.throwIfAborted();
469
+ if (active) this.admitSlashCommand(body as SlackSlashCommand);
470
+ }).catch(() => this.#logger.debug('Socket callback did not complete'));
471
+ });
468
472
  try { await socket.start(); } catch (error) { release(); throw connectionError ?? slackSocketConnectionError(error); }
469
473
  });
470
474
  }
@@ -479,17 +483,18 @@ function createSlackEndpointManagement(
479
483
  return Object.freeze<EndpointManagement>({
480
484
  // Slack 无"群"概念:public channel 归一为 group(channel 语义由 listChannels 之外省略,
481
485
  // Slack channel 本身就是会话载体,避免同一批数据在两个列表里重复)。
482
- async listGroups(): Promise<readonly EndpointGroup[]> {
486
+ async listGroups(request: EndpointManagementRequest = {}): Promise<readonly EndpointGroup[]> {
487
+ const operation = createEndpointOperation(request);
483
488
  const client = requireClient();
484
489
  const groups: EndpointGroup[] = [];
485
490
  let cursor: string | undefined;
486
491
  do {
487
- const page = await client.conversations.list({
492
+ const page = await operation.run(() => client.conversations.list({
488
493
  types: 'public_channel',
489
494
  exclude_archived: true,
490
495
  limit: SLACK_LIST_PAGE_SIZE,
491
496
  ...(cursor ? { cursor } : {}),
492
- });
497
+ }));
493
498
  for (const value of page.channels ?? []) {
494
499
  const channel = asRecord(value);
495
500
  const id = typeof channel.id === 'string' ? channel.id : '';
@@ -501,15 +506,16 @@ function createSlackEndpointManagement(
501
506
  return groups;
502
507
  },
503
508
  // Slack 无"好友"概念:workspace 成员归一为 friend;nickname 取 real_name 回退 name。
504
- async listFriends(): Promise<readonly EndpointFriend[]> {
509
+ async listFriends(request: EndpointManagementRequest = {}): Promise<readonly EndpointFriend[]> {
510
+ const operation = createEndpointOperation(request);
505
511
  const client = requireClient();
506
512
  const friends: EndpointFriend[] = [];
507
513
  let cursor: string | undefined;
508
514
  do {
509
- const page = await client.users.list({
515
+ const page = await operation.run(() => client.users.list({
510
516
  limit: SLACK_LIST_PAGE_SIZE,
511
517
  ...(cursor ? { cursor } : {}),
512
- });
518
+ }));
513
519
  for (const value of page.members ?? []) {
514
520
  const user = asRecord(value);
515
521
  const id = typeof user.id === 'string' ? user.id : '';
@@ -526,9 +532,10 @@ function createSlackEndpointManagement(
526
532
  return friends;
527
533
  },
528
534
  // conversations.members 只回 user id 列表(平台形状),逐 user 拉 profile 成本由调用方决定。
529
- async listGroupMembers(groupId: string): Promise<readonly string[]> {
535
+ async listGroupMembers({ groupId, signal }: EndpointGroupRequest): Promise<readonly string[]> {
536
+ const operation = createEndpointOperation({ signal });
530
537
  const client = requireClient();
531
- const result = await client.conversations.members({ channel: groupId });
538
+ const result = await operation.run(() => client.conversations.members({ channel: groupId }));
532
539
  return result.members ?? [];
533
540
  },
534
541
  });
@@ -539,3 +546,9 @@ function asRecord(value: unknown): Record<string, unknown> {
539
546
  ? value as Record<string, unknown>
540
547
  : {};
541
548
  }
549
+
550
+ interface SlackReactionRequest extends EndpointManagementRequest {
551
+ readonly channel: string;
552
+ readonly timestamp: string;
553
+ readonly name: string;
554
+ }