@zhin.js/adapter-slack 4.0.1 → 4.1.0

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