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