@zhin.js/adapter-slack 1.0.78 → 1.1.2
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/CHANGELOG.md +731 -2
- package/PERMITS.md +24 -0
- package/README.md +111 -158
- package/adapters/slack/index.js +40 -0
- package/adapters/slack/index.ts +45 -0
- package/agents/slack/agent.json +20 -0
- package/agents/slack/boundaries.md +3 -0
- package/agents/slack/conventions.md +3 -0
- package/agents/slack/skills/slack-channels/SKILL.md +24 -0
- package/agents/slack/skills/slack-channels/tools/archive_channel/index.js +19 -0
- package/agents/slack/skills/slack-channels/tools/archive_channel/index.ts +21 -0
- package/agents/slack/skills/slack-channels/tools/invite_to_channel/index.js +20 -0
- package/agents/slack/skills/slack-channels/tools/invite_to_channel/index.ts +23 -0
- package/agents/slack/skills/slack-channels/tools/set_purpose/index.js +20 -0
- package/agents/slack/skills/slack-channels/tools/set_purpose/index.ts +23 -0
- package/agents/slack/skills/slack-channels/tools/set_topic/index.js +20 -0
- package/agents/slack/skills/slack-channels/tools/set_topic/index.ts +23 -0
- package/agents/slack/skills/slack-channels/tools/unarchive/index.js +19 -0
- package/agents/slack/skills/slack-channels/tools/unarchive/index.ts +21 -0
- package/agents/slack/skills/slack-channels/tools/user_info/index.js +27 -0
- package/agents/slack/skills/slack-channels/tools/user_info/index.ts +29 -0
- package/agents/slack/skills/slack-messages/SKILL.md +22 -0
- package/agents/slack/skills/slack-messages/tools/add_reaction/index.js +27 -0
- package/agents/slack/skills/slack-messages/tools/add_reaction/index.ts +29 -0
- package/agents/slack/skills/slack-messages/tools/edit_message/index.js +21 -0
- package/agents/slack/skills/slack-messages/tools/edit_message/index.ts +25 -0
- package/agents/slack/skills/slack-messages/tools/pin_message/index.js +20 -0
- package/agents/slack/skills/slack-messages/tools/pin_message/index.ts +23 -0
- package/agents/slack/skills/slack-messages/tools/remove_reaction/index.js +30 -0
- package/agents/slack/skills/slack-messages/tools/remove_reaction/index.ts +32 -0
- package/agents/slack/skills/slack-messages/tools/unpin_message/index.js +20 -0
- package/agents/slack/skills/slack-messages/tools/unpin_message/index.ts +23 -0
- package/agents/slack/system.md +3 -0
- package/commands/slack/endpoint/add/[id]/index.js +3 -0
- package/commands/slack/endpoint/add/[id]/index.ts +3 -0
- package/commands/slack/endpoint/definition.js +19 -0
- package/commands/slack/endpoint/definition.ts +19 -0
- package/commands/slack/endpoint/list/index.js +3 -0
- package/commands/slack/endpoint/list/index.ts +3 -0
- package/commands/slack/endpoint/remove/[id]/index.js +3 -0
- package/commands/slack/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +25 -0
- package/lib/client.js +2 -0
- package/lib/endpoint.d.ts +153 -0
- package/lib/endpoint.js +425 -0
- package/lib/index.d.ts +10 -10
- package/lib/index.js +10 -242
- package/lib/markdown-to-mrkdwn.d.ts +9 -0
- package/lib/markdown-to-mrkdwn.js +121 -0
- package/lib/mrkdwn-to-markdown.d.ts +4 -0
- package/lib/mrkdwn-to-markdown.js +30 -0
- package/lib/platform-permit.d.ts +15 -0
- package/lib/platform-permit.js +43 -0
- package/lib/protocol.d.ts +144 -0
- package/lib/protocol.js +258 -0
- package/lib/side-event-dispatch.d.ts +4 -0
- package/lib/side-event-dispatch.js +45 -0
- package/lib/slack-inbound-filter.d.ts +12 -0
- package/lib/slack-inbound-filter.js +46 -0
- package/lib/slack-message-ref.d.ts +7 -0
- package/lib/slack-message-ref.js +17 -0
- package/lib/slack-outbound.d.ts +24 -0
- package/lib/slack-outbound.js +111 -0
- package/lib/slack-reaction.d.ts +1 -0
- package/lib/slack-reaction.js +26 -0
- package/lib/slack-response-url.d.ts +5 -0
- package/lib/slack-response-url.js +16 -0
- package/lib/slack-runtime-state.d.ts +1 -0
- package/lib/slack-runtime-state.js +6 -0
- package/lib/webhook.d.ts +14 -0
- package/lib/webhook.js +72 -0
- package/package.json +80 -16
- package/plugin.js +19 -0
- package/schema.json +108 -0
- package/src/client.ts +30 -0
- package/src/endpoint.ts +526 -0
- package/src/index.ts +59 -265
- package/src/markdown-to-mrkdwn.ts +117 -0
- package/src/mrkdwn-to-markdown.ts +29 -0
- package/src/platform-permit.ts +59 -0
- package/src/protocol.ts +406 -0
- package/src/side-event-dispatch.ts +54 -0
- package/src/slack-inbound-filter.ts +60 -0
- package/src/slack-message-ref.ts +18 -0
- package/src/slack-outbound.ts +170 -0
- package/src/slack-reaction.ts +23 -0
- package/src/slack-response-url.ts +26 -0
- package/src/slack-runtime-state.ts +7 -0
- package/src/webhook.ts +106 -0
- package/lib/adapter.d.ts +0 -16
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -43
- package/lib/adapter.js.map +0 -1
- package/lib/bot.d.ts +0 -106
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -628
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts +0 -17
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/skills/slack/SKILL.md +0 -91
- package/src/adapter.ts +0 -52
- package/src/bot.ts +0 -688
- package/src/types.ts +0 -18
package/src/endpoint.ts
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
+
/**
|
|
3
|
+
* SlackEndpoint — lifecycle, outbound, admit, Socket Mode, agent tool surface.
|
|
4
|
+
*/
|
|
5
|
+
import { SocketModeClient } from '@slack/socket-mode';
|
|
6
|
+
import { WebClient } from '@slack/web-api';
|
|
7
|
+
import type {
|
|
8
|
+
EndpointFriend,
|
|
9
|
+
EndpointGroup,
|
|
10
|
+
EndpointControl,
|
|
11
|
+
EndpointManagement,
|
|
12
|
+
EndpointSendRequest,
|
|
13
|
+
} from 'zhin.js/adapter';
|
|
14
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
15
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
16
|
+
import type { CapabilityId } from 'zhin.js';
|
|
17
|
+
import {
|
|
18
|
+
formatInboundContent,
|
|
19
|
+
formatInteractionContent,
|
|
20
|
+
formatSlashContent,
|
|
21
|
+
resolveSlackChannelType,
|
|
22
|
+
slackInboundConversation,
|
|
23
|
+
type ResolvedSlackConfig,
|
|
24
|
+
type SlackEvent,
|
|
25
|
+
type SlackEventEnvelope,
|
|
26
|
+
type SlackInteractionPayload,
|
|
27
|
+
type SlackMessageEvent,
|
|
28
|
+
type SlackSlashCommand,
|
|
29
|
+
} from './protocol.js';
|
|
30
|
+
import {
|
|
31
|
+
createSlackInboundFilterState,
|
|
32
|
+
shouldDropSlackInboundMessage,
|
|
33
|
+
} from './slack-inbound-filter.js';
|
|
34
|
+
import { formatSlackMessageRef, parseSlackMessageRef } from './slack-message-ref.js';
|
|
35
|
+
import { normalizeSlackReactionName } from './slack-reaction.js';
|
|
36
|
+
import { postSlackEphemeral } from './slack-response-url.js';
|
|
37
|
+
import { editSlackContent, sendSlackContent, type SlackChatClient } from './slack-outbound.js';
|
|
38
|
+
import { registerSlackWebhookRoutes, type SlackWebhookHandler } from './webhook.js';
|
|
39
|
+
import { receiveSlackSideEvent } from './side-event-dispatch.js';
|
|
40
|
+
|
|
41
|
+
export interface SlackSocketLike {
|
|
42
|
+
on(
|
|
43
|
+
event: string,
|
|
44
|
+
handler: (args: { ack: () => Promise<void>; body: unknown }) => void | Promise<void>,
|
|
45
|
+
): void;
|
|
46
|
+
start(): Promise<void>;
|
|
47
|
+
disconnect(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SlackWebClientLike extends SlackChatClient {
|
|
51
|
+
auth: {
|
|
52
|
+
test(): Promise<{ user_id?: string; user?: string }>;
|
|
53
|
+
};
|
|
54
|
+
conversations: {
|
|
55
|
+
invite(opts: { channel: string; users: string }): Promise<unknown>;
|
|
56
|
+
kick(opts: { channel: string; user: string }): Promise<unknown>;
|
|
57
|
+
setTopic(opts: { channel: string; topic: string }): Promise<unknown>;
|
|
58
|
+
setPurpose(opts: { channel: string; purpose: string }): Promise<unknown>;
|
|
59
|
+
archive(opts: { channel: string }): Promise<unknown>;
|
|
60
|
+
unarchive(opts: { channel: string }): Promise<unknown>;
|
|
61
|
+
rename(opts: { channel: string; name: string }): Promise<unknown>;
|
|
62
|
+
members(opts: { channel: string }): Promise<{ members?: string[] }>;
|
|
63
|
+
info(opts: { channel: string }): Promise<{ channel?: unknown }>;
|
|
64
|
+
list(opts?: {
|
|
65
|
+
types?: string;
|
|
66
|
+
limit?: number;
|
|
67
|
+
cursor?: string;
|
|
68
|
+
exclude_archived?: boolean;
|
|
69
|
+
}): Promise<{ channels?: unknown[]; response_metadata?: { next_cursor?: string } }>;
|
|
70
|
+
};
|
|
71
|
+
users: {
|
|
72
|
+
info(opts: { user: string }): Promise<{ user?: unknown }>;
|
|
73
|
+
list(opts?: {
|
|
74
|
+
limit?: number;
|
|
75
|
+
cursor?: string;
|
|
76
|
+
}): Promise<{ members?: unknown[]; response_metadata?: { next_cursor?: string } }>;
|
|
77
|
+
};
|
|
78
|
+
reactions: {
|
|
79
|
+
add(opts: { channel: string; timestamp: string; name: string }): Promise<unknown>;
|
|
80
|
+
remove(opts: { channel: string; timestamp: string; name: string }): Promise<unknown>;
|
|
81
|
+
};
|
|
82
|
+
pins: {
|
|
83
|
+
add(opts: { channel: string; timestamp: string }): Promise<unknown>;
|
|
84
|
+
remove(opts: { channel: string; timestamp: string }): Promise<unknown>;
|
|
85
|
+
};
|
|
86
|
+
chat: SlackChatClient['chat'] & {
|
|
87
|
+
delete(opts: { channel: string; ts: string }): Promise<unknown>;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SlackEndpointOptions {
|
|
92
|
+
readonly id: CapabilityId;
|
|
93
|
+
readonly config: ResolvedSlackConfig;
|
|
94
|
+
readonly http?: HttpHost;
|
|
95
|
+
readonly createClient?: (token: string) => SlackWebClientLike;
|
|
96
|
+
readonly createSocket?: (opts: {
|
|
97
|
+
readonly appToken: string;
|
|
98
|
+
readonly clientPingTimeout: number;
|
|
99
|
+
}) => SlackSocketLike;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements SlackWebhookHandler {
|
|
103
|
+
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
104
|
+
|
|
105
|
+
readonly #options: SlackEndpointOptions;
|
|
106
|
+
readonly #inboundFilter = createSlackInboundFilterState();
|
|
107
|
+
readonly #messageChannelMap = new Map<string, string>();
|
|
108
|
+
#client?: SlackWebClientLike;
|
|
109
|
+
#socket?: SlackSocketLike;
|
|
110
|
+
#routeReleases: HttpRouteRegistration[] = [];
|
|
111
|
+
#botUserId?: string;
|
|
112
|
+
#open = false;
|
|
113
|
+
#started = false;
|
|
114
|
+
readonly management: EndpointManagement = createSlackEndpointManagement(() => this.client);
|
|
115
|
+
readonly control: EndpointControl = Object.freeze<EndpointControl>({
|
|
116
|
+
recall: async (message) => {
|
|
117
|
+
const ref = this.resolveMessageRef(message.id, message.conversation.id);
|
|
118
|
+
if (!ref || !this.#client) return;
|
|
119
|
+
await this.#client.chat.delete(ref);
|
|
120
|
+
},
|
|
121
|
+
edit: async (message, content) => {
|
|
122
|
+
const ref = this.resolveMessageRef(message.id, message.conversation.id);
|
|
123
|
+
if (!ref) return null;
|
|
124
|
+
await this.#editMessage(ref.channel, ref.ts, content);
|
|
125
|
+
return message.id;
|
|
126
|
+
},
|
|
127
|
+
addReaction: async (message, emoji) => {
|
|
128
|
+
const ref = this.resolveMessageRef(message.id, message.conversation.id);
|
|
129
|
+
if (!ref) return null;
|
|
130
|
+
const reaction = normalizeSlackReactionName(emoji);
|
|
131
|
+
await this.#addReaction(ref.channel, ref.ts, reaction);
|
|
132
|
+
return reaction;
|
|
133
|
+
},
|
|
134
|
+
removeReaction: async (message, reactionId) => {
|
|
135
|
+
const ref = this.resolveMessageRef(message.id, message.conversation.id);
|
|
136
|
+
if (!ref) return;
|
|
137
|
+
await this.#removeReaction(ref.channel, ref.ts, reactionId);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
constructor(options: SlackEndpointOptions) {
|
|
142
|
+
super();
|
|
143
|
+
this.#logger = getAdapterLogger('slack', options.config.id);
|
|
144
|
+
this.#options = options;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Console 展示 / AdapterIndex live name(多 endpoint 时与 entry name 一致)。 */
|
|
148
|
+
get name(): string {
|
|
149
|
+
return this.#options.config.id;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
get client(): SlackWebClientLike {
|
|
153
|
+
if (!this.#client) throw new Error('Slack client not connected');
|
|
154
|
+
return this.#client;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get platformUserId(): string | undefined {
|
|
158
|
+
return this.#botUserId;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get config(): ResolvedSlackConfig {
|
|
162
|
+
return this.#options.config;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async start(): Promise<void> {
|
|
166
|
+
if (this.#started) return;
|
|
167
|
+
this.#started = true;
|
|
168
|
+
try {
|
|
169
|
+
const { config } = this.#options;
|
|
170
|
+
this.#client = this.#options.createClient?.(config.token)
|
|
171
|
+
?? (new WebClient(config.token) as unknown as SlackWebClientLike);
|
|
172
|
+
|
|
173
|
+
if (config.mode === 'socket') {
|
|
174
|
+
await this.#startSocket();
|
|
175
|
+
} else {
|
|
176
|
+
const http = this.#options.http;
|
|
177
|
+
if (!http) {
|
|
178
|
+
throw new Error('Slack HTTP Events API requires httpHostToken (Runtime Host)');
|
|
179
|
+
}
|
|
180
|
+
this.#routeReleases.push(...registerSlackWebhookRoutes(http, this));
|
|
181
|
+
this.#logger.debug(formatCompact({
|
|
182
|
+
endpoint: config.id,
|
|
183
|
+
op: 'webhook',
|
|
184
|
+
path: config.webhookPath,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const authTest = await this.#client.auth.test();
|
|
189
|
+
if (authTest.user_id) this.#botUserId = String(authTest.user_id);
|
|
190
|
+
|
|
191
|
+
this.#logger.info(
|
|
192
|
+
`connected (${config.mode})`
|
|
193
|
+
+ (this.#botUserId ? ` | user: ${this.#botUserId}` : ''),
|
|
194
|
+
);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
await this.stop();
|
|
197
|
+
this.#logger.error('Failed to connect Slack endpoint:', error);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
open(): void {
|
|
203
|
+
this.#open = true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
close(): void {
|
|
207
|
+
this.#open = false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async stop(): Promise<void> {
|
|
211
|
+
this.#open = false;
|
|
212
|
+
if (this.#socket) {
|
|
213
|
+
try {
|
|
214
|
+
await this.#socket.disconnect();
|
|
215
|
+
} catch {
|
|
216
|
+
/* ignore */
|
|
217
|
+
}
|
|
218
|
+
this.#socket = undefined;
|
|
219
|
+
}
|
|
220
|
+
for (const release of this.#routeReleases.splice(0)) release();
|
|
221
|
+
this.#client = undefined;
|
|
222
|
+
this.#started = false;
|
|
223
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
227
|
+
if (!this.#client) throw new Error('Slack client not connected');
|
|
228
|
+
const channel = conversation.id;
|
|
229
|
+
const threadTs = conversation.threadId;
|
|
230
|
+
const result = await sendSlackContent(
|
|
231
|
+
this.#client,
|
|
232
|
+
payload,
|
|
233
|
+
{ channel, threadTs },
|
|
234
|
+
this.#logger,
|
|
235
|
+
);
|
|
236
|
+
this.trackMessageChannel(result.ts, channel);
|
|
237
|
+
return formatSlackMessageRef(channel, result.ts);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Test / internal: admit a message event when open. */
|
|
241
|
+
admit(event: SlackMessageEvent | SlackEvent): void {
|
|
242
|
+
if (!this.#open) return;
|
|
243
|
+
void this.#emitPlatformEvent(event.type || 'event', event);
|
|
244
|
+
if (event.type !== 'message' && event.type !== 'app_mention') return;
|
|
245
|
+
const msg = event as SlackMessageEvent;
|
|
246
|
+
if (shouldDropSlackInboundMessage(msg, this.#inboundFilter, this.#botUserId)) return;
|
|
247
|
+
if (!msg.channel || !msg.ts) return;
|
|
248
|
+
|
|
249
|
+
this.trackMessageChannel(msg.ts, msg.channel);
|
|
250
|
+
const threadTs = msg.thread_ts && msg.thread_ts !== msg.ts ? msg.thread_ts : undefined;
|
|
251
|
+
const conversation = slackInboundConversation(String(this.#options.id), {
|
|
252
|
+
channelId: msg.channel,
|
|
253
|
+
channelType: msg.channel_type,
|
|
254
|
+
threadId: threadTs,
|
|
255
|
+
});
|
|
256
|
+
void this.emit('message.receive', {
|
|
257
|
+
conversation,
|
|
258
|
+
message: { conversation, id: msg.ts },
|
|
259
|
+
content: formatInboundContent(msg),
|
|
260
|
+
sender: { id: msg.user ?? msg.channel ?? '' },
|
|
261
|
+
endpointId: this.#options.config.id,
|
|
262
|
+
...(msg.type === 'app_mention' ? { mentioned: true } : {}),
|
|
263
|
+
metadata: Object.freeze({
|
|
264
|
+
channelType: resolveSlackChannelType(msg),
|
|
265
|
+
userId: msg.user,
|
|
266
|
+
threadTs,
|
|
267
|
+
ts: msg.ts,
|
|
268
|
+
}),
|
|
269
|
+
}).catch((err) => {
|
|
270
|
+
this.#logger.warn(formatCompact({
|
|
271
|
+
op: 'slack_gateway_receive_failed',
|
|
272
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
273
|
+
error: err instanceof Error ? err.message : String(err),
|
|
274
|
+
}));
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
admitInteraction(payload: SlackInteractionPayload): void {
|
|
279
|
+
if (!this.#open) return;
|
|
280
|
+
void this.#emitPlatformEvent(`interaction.${payload.type}`, payload);
|
|
281
|
+
if (payload.type !== 'block_actions' || !payload.actions?.length) return;
|
|
282
|
+
const channelId = payload.channel?.id ?? '';
|
|
283
|
+
const userId = payload.user.id;
|
|
284
|
+
const messageTs = payload.message?.ts ?? '';
|
|
285
|
+
if (payload.response_url) {
|
|
286
|
+
postSlackEphemeral(payload.response_url, '已收到', this.#logger);
|
|
287
|
+
}
|
|
288
|
+
const actionTs = payload.actions[0]?.action_ts ?? messageTs ?? `action-${Date.now()}`;
|
|
289
|
+
const conversation = slackInboundConversation(String(this.#options.id), {
|
|
290
|
+
channelId: channelId || userId,
|
|
291
|
+
// block_actions 无 channel_type;无 channel 时按与发起用户的 DM 处理
|
|
292
|
+
channelType: channelId ? undefined : 'im',
|
|
293
|
+
});
|
|
294
|
+
void this.emit('message.receive', {
|
|
295
|
+
conversation,
|
|
296
|
+
message: { conversation, id: actionTs },
|
|
297
|
+
content: formatInteractionContent(payload),
|
|
298
|
+
sender: { id: userId },
|
|
299
|
+
endpointId: this.#options.config.id,
|
|
300
|
+
metadata: Object.freeze({
|
|
301
|
+
eventType: 'block_actions',
|
|
302
|
+
actionId: payload.actions[0]?.action_id,
|
|
303
|
+
threadTs: messageTs || undefined,
|
|
304
|
+
}),
|
|
305
|
+
}).catch((err) => {
|
|
306
|
+
this.#logger.warn(formatCompact({
|
|
307
|
+
op: 'slack_gateway_receive_failed',
|
|
308
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
309
|
+
error: err instanceof Error ? err.message : String(err),
|
|
310
|
+
}));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
admitSlashCommand(cmd: SlackSlashCommand): void {
|
|
315
|
+
if (!this.#open) return;
|
|
316
|
+
void this.#emitPlatformEvent(`slash.${cmd.command}`, cmd);
|
|
317
|
+
postSlackEphemeral(cmd.response_url, '处理中…', this.#logger);
|
|
318
|
+
const conversation = slackInboundConversation(String(this.#options.id), {
|
|
319
|
+
channelId: cmd.channel_id,
|
|
320
|
+
});
|
|
321
|
+
void this.emit('message.receive', {
|
|
322
|
+
conversation,
|
|
323
|
+
message: { conversation, id: cmd.trigger_id },
|
|
324
|
+
content: formatSlashContent(cmd),
|
|
325
|
+
sender: { id: cmd.user_id, name: cmd.user_name },
|
|
326
|
+
endpointId: this.#options.config.id,
|
|
327
|
+
metadata: Object.freeze({
|
|
328
|
+
eventType: 'slash_command',
|
|
329
|
+
command: cmd.command,
|
|
330
|
+
}),
|
|
331
|
+
}).catch((err) => {
|
|
332
|
+
this.#logger.warn(formatCompact({
|
|
333
|
+
op: 'slack_gateway_receive_failed',
|
|
334
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
335
|
+
error: err instanceof Error ? err.message : String(err),
|
|
336
|
+
}));
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
handleEnvelope(body: unknown): void {
|
|
341
|
+
const envelope = body as SlackEventEnvelope;
|
|
342
|
+
if (envelope?.type === 'event_callback' && envelope.event) {
|
|
343
|
+
const event = envelope.event;
|
|
344
|
+
if (event.type !== 'message' && event.type !== 'app_mention') {
|
|
345
|
+
void this.#emitPlatformEvent(event.type || 'event', event);
|
|
346
|
+
receiveSlackSideEvent(
|
|
347
|
+
(name, payload) => this.emit(name, payload),
|
|
348
|
+
String(this.#options.id),
|
|
349
|
+
this.#options.config.id,
|
|
350
|
+
event,
|
|
351
|
+
this.#logger,
|
|
352
|
+
);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this.admit(event);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
trackMessageChannel(ts: string, channel: string): void {
|
|
360
|
+
if (!ts || !channel) return;
|
|
361
|
+
// LRU:超 1024 条淘汰最久未更新的记录,避免无界增长。
|
|
362
|
+
if (this.#messageChannelMap.has(ts)) this.#messageChannelMap.delete(ts);
|
|
363
|
+
this.#messageChannelMap.set(ts, channel);
|
|
364
|
+
if (this.#messageChannelMap.size > 1024) {
|
|
365
|
+
const oldest = this.#messageChannelMap.keys().next().value;
|
|
366
|
+
if (oldest != null) this.#messageChannelMap.delete(oldest);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
resolveMessageRef(messageId: string, channelHint?: string): { channel: string; ts: string } | null {
|
|
371
|
+
const parsed = parseSlackMessageRef(messageId);
|
|
372
|
+
if (parsed) {
|
|
373
|
+
this.trackMessageChannel(parsed.ts, parsed.channel);
|
|
374
|
+
return parsed;
|
|
375
|
+
}
|
|
376
|
+
if (channelHint) return { channel: channelHint, ts: messageId };
|
|
377
|
+
const channel = this.#messageChannelMap.get(messageId);
|
|
378
|
+
return channel ? { channel, ts: messageId } : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async recallMessage(messageId: string): Promise<void> {
|
|
382
|
+
if (!this.#client) return;
|
|
383
|
+
const ref = this.resolveMessageRef(messageId);
|
|
384
|
+
if (!ref) return;
|
|
385
|
+
await this.#client.chat.delete({ channel: ref.channel, ts: ref.ts });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async #editMessage(channel: string, messageTs: string, content: unknown): Promise<void> {
|
|
389
|
+
if (!this.#client) throw new Error('Slack client not connected');
|
|
390
|
+
await editSlackContent(this.#client, channel, messageTs, content);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async #addReaction(channel: string, timestamp: string, name: string): Promise<void> {
|
|
394
|
+
const reaction = normalizeSlackReactionName(name);
|
|
395
|
+
try {
|
|
396
|
+
await this.#client!.reactions.add({ channel, timestamp, name: reaction });
|
|
397
|
+
} catch (error) {
|
|
398
|
+
const code = (error as { data?: { error?: string } })?.data?.error;
|
|
399
|
+
if (code === 'already_reacted') return;
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async #removeReaction(channel: string, timestamp: string, name: string): Promise<void> {
|
|
405
|
+
const reaction = normalizeSlackReactionName(name);
|
|
406
|
+
try {
|
|
407
|
+
await this.#client!.reactions.remove({ channel, timestamp, name: reaction });
|
|
408
|
+
} catch (error) {
|
|
409
|
+
const code = (error as { data?: { error?: string } })?.data?.error;
|
|
410
|
+
if (code === 'no_reaction') return;
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async #emitPlatformEvent(name: string, event: unknown): Promise<void> {
|
|
416
|
+
await this.emitPlatform(name, event).catch((error) => {
|
|
417
|
+
this.#logger.warn(formatCompact({
|
|
418
|
+
op: 'slack_platform_event_failed',
|
|
419
|
+
event: name,
|
|
420
|
+
error: error instanceof Error ? error.message : String(error),
|
|
421
|
+
}));
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async #startSocket(): Promise<void> {
|
|
426
|
+
const { config } = this.#options;
|
|
427
|
+
if (!config.appToken) {
|
|
428
|
+
throw new Error('Slack Socket Mode 需要 appToken(xapp- 前缀的 App-Level Token);注意 bot token 填 token 字段(xoxb- 前缀),两者不可混用');
|
|
429
|
+
}
|
|
430
|
+
if (!config.appToken.startsWith('xapp-')) {
|
|
431
|
+
throw new Error(`Slack appToken 格式不正确:Socket Mode 需要 xapp- 前缀的 App-Level Token(当前看起来是 ${config.appToken.slice(0, 5)}…,xoxb- 是 bot token,请填到 token 字段)`);
|
|
432
|
+
}
|
|
433
|
+
this.#socket = this.#options.createSocket?.({
|
|
434
|
+
appToken: config.appToken,
|
|
435
|
+
clientPingTimeout: config.clientPingTimeout,
|
|
436
|
+
}) ?? new SocketModeClient({
|
|
437
|
+
appToken: config.appToken,
|
|
438
|
+
clientPingTimeout: config.clientPingTimeout,
|
|
439
|
+
}) as unknown as SlackSocketLike;
|
|
440
|
+
|
|
441
|
+
this.#socket.on('slack_event', async ({ ack, body }) => {
|
|
442
|
+
await ack();
|
|
443
|
+
this.handleEnvelope(body);
|
|
444
|
+
});
|
|
445
|
+
this.#socket.on('interactive', async ({ ack, body }) => {
|
|
446
|
+
await ack();
|
|
447
|
+
this.admitInteraction(body as SlackInteractionPayload);
|
|
448
|
+
});
|
|
449
|
+
this.#socket.on('slash_commands', async ({ ack, body }) => {
|
|
450
|
+
await ack();
|
|
451
|
+
this.admitSlashCommand(body as SlackSlashCommand);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await this.#socket.start();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Slack Web API 分页上限(每页 1000,cursor 翻页直到 next_cursor 为空)。 */
|
|
459
|
+
const SLACK_LIST_PAGE_SIZE = 1000;
|
|
460
|
+
|
|
461
|
+
function createSlackEndpointManagement(
|
|
462
|
+
requireClient: () => SlackWebClientLike,
|
|
463
|
+
): EndpointManagement {
|
|
464
|
+
return Object.freeze<EndpointManagement>({
|
|
465
|
+
// Slack 无"群"概念:public channel 归一为 group(channel 语义由 listChannels 之外省略,
|
|
466
|
+
// Slack channel 本身就是会话载体,避免同一批数据在两个列表里重复)。
|
|
467
|
+
async listGroups(): Promise<readonly EndpointGroup[]> {
|
|
468
|
+
const client = requireClient();
|
|
469
|
+
const groups: EndpointGroup[] = [];
|
|
470
|
+
let cursor: string | undefined;
|
|
471
|
+
do {
|
|
472
|
+
const page = await client.conversations.list({
|
|
473
|
+
types: 'public_channel',
|
|
474
|
+
exclude_archived: true,
|
|
475
|
+
limit: SLACK_LIST_PAGE_SIZE,
|
|
476
|
+
...(cursor ? { cursor } : {}),
|
|
477
|
+
});
|
|
478
|
+
for (const value of page.channels ?? []) {
|
|
479
|
+
const channel = asRecord(value);
|
|
480
|
+
const id = typeof channel.id === 'string' ? channel.id : '';
|
|
481
|
+
if (!id) continue;
|
|
482
|
+
groups.push({ group_id: id, name: String(channel.name ?? id) });
|
|
483
|
+
}
|
|
484
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
485
|
+
} while (cursor);
|
|
486
|
+
return groups;
|
|
487
|
+
},
|
|
488
|
+
// Slack 无"好友"概念:workspace 成员归一为 friend;nickname 取 real_name 回退 name。
|
|
489
|
+
async listFriends(): Promise<readonly EndpointFriend[]> {
|
|
490
|
+
const client = requireClient();
|
|
491
|
+
const friends: EndpointFriend[] = [];
|
|
492
|
+
let cursor: string | undefined;
|
|
493
|
+
do {
|
|
494
|
+
const page = await client.users.list({
|
|
495
|
+
limit: SLACK_LIST_PAGE_SIZE,
|
|
496
|
+
...(cursor ? { cursor } : {}),
|
|
497
|
+
});
|
|
498
|
+
for (const value of page.members ?? []) {
|
|
499
|
+
const user = asRecord(value);
|
|
500
|
+
const id = typeof user.id === 'string' ? user.id : '';
|
|
501
|
+
if (!id || user.deleted === true) continue;
|
|
502
|
+
const profile = asRecord(user.profile);
|
|
503
|
+
friends.push({
|
|
504
|
+
user_id: id,
|
|
505
|
+
nickname: String(profile.real_name ?? user.real_name ?? user.name ?? id),
|
|
506
|
+
remark: '',
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
cursor = page.response_metadata?.next_cursor || undefined;
|
|
510
|
+
} while (cursor);
|
|
511
|
+
return friends;
|
|
512
|
+
},
|
|
513
|
+
// conversations.members 只回 user id 列表(平台形状),逐 user 拉 profile 成本由调用方决定。
|
|
514
|
+
async listGroupMembers(groupId: string): Promise<readonly string[]> {
|
|
515
|
+
const client = requireClient();
|
|
516
|
+
const result = await client.conversations.members({ channel: groupId });
|
|
517
|
+
return result.members ?? [];
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
523
|
+
return value !== null && typeof value === 'object'
|
|
524
|
+
? value as Record<string, unknown>
|
|
525
|
+
: {};
|
|
526
|
+
}
|