@zhin.js/adapter-slack 1.1.4 → 1.1.6

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 CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.6
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [badcc1f]
8
+ - @zhin.js/core@1.1.41
9
+ - @zhin.js/feature-kit@1.1.3
10
+ - @zhin.js/adapter@1.1.17
11
+ - @zhin.js/agent-feature@1.1.4
12
+ - @zhin.js/skill@1.1.4
13
+ - @zhin.js/tool@1.1.4
14
+ - @zhin.js/host-http@1.1.4
15
+
16
+ ## 1.1.5
17
+
18
+ ### Patch Changes
19
+
20
+ - b9b3953: Map canonical reply segments to Slack thread replies instead of displaying a literal reply placeholder. Existing conversation thread routing takes precedence.
21
+
22
+ Give unnamed uploads a MIME-derived filename extension and preserve explicit media filenames, rather than using image alt text as the filename.
23
+
24
+ - Updated dependencies [b9b3953]
25
+ - Updated dependencies [b9b3953]
26
+ - Updated dependencies [b9b3953]
27
+ - Updated dependencies [b9b3953]
28
+ - Updated dependencies [b9b3953]
29
+ - Updated dependencies [b9b3953]
30
+ - Updated dependencies [b9b3953]
31
+ - Updated dependencies [b9b3953]
32
+ - Updated dependencies [b9b3953]
33
+ - Updated dependencies [b9b3953]
34
+ - Updated dependencies [b9b3953]
35
+ - @zhin.js/adapter@1.1.16
36
+ - @zhin.js/im-contract@1.1.3
37
+ - @zhin.js/core@1.1.40
38
+ - @zhin.js/host-http@1.1.3
39
+ - @zhin.js/tool@1.1.3
40
+ - @zhin.js/skill@1.1.3
41
+ - @zhin.js/agent-feature@1.1.3
42
+
3
43
  ## 1.1.4
4
44
 
5
45
  ### Patch Changes
package/README.md CHANGED
@@ -143,3 +143,31 @@ MIT
143
143
  成功发送返回平台 `ts` 组成的消息引用,不以本地时间替代缺失回执。
144
144
  文件上传失败会让本次发送失败;分段消息中某段缺少有效回执时,停止后续分段。
145
145
  前面已成功的文件或分段不会自动回滚;发送失败可能表示部分已送达,请先核对目标会话再重试。
146
+ # Socket Mode 可控断线验收
147
+
148
+ `streamProxy: { port: 18443, serverName: "实际网关域名" }` 使用 SDK 合法 `clientOptions.agent`。配置网关的 WSS 仅改 TCP 路由为 `127.0.0.1:18443`,保留真实 URL、Host、TLS SNI、证书链和域名校验。SDK 的 discovery API 仍用标准 HTTPS Agent 直连 `slack.com`;其他网关及 WebSocket Upgrade 到 API 域名均明确拒绝,不绕代理。日志仅包含安全 hostname,不打印完整 URL/query/token。
149
+
150
+ 验收项目通过 `SLACK_STREAM_PROXY_PORT` / `SLACK_STREAM_PROXY_SERVER_NAME` 进程变量临时覆盖,需同时填写,留空直连。未知域名可先设占位值,从拒绝日志获取真实 hostname,再启动 `scripts/platform-acceptance/tcp-fault-proxy.mjs --upstream-host 实际网关域名 --upstream-port 443 --port 18443 --control-port 18444`;将 serverName 同步为此域名。不要覆盖已有 `.env`。平台更换动态网关时停止验收并更新固定上游,不能直连继续取恢复证据。
151
+
152
+ Socket SDK 的自动重连已关闭;start/stop/reconnect 由统一 Endpoint lifecycle 管理,SDK 保留协议心跳。断线销毁当前 Agent/Socket,重连新建实例;旧实例回调不进入消息链,stop 取消待重连。可观察 Endpoint `transportState` 的 open/重连/stopped。
153
+
154
+ 先验证正常消息,再 POST 代理 `/cut`,观察 Socket 关闭和生命周期退出 open;POST `/recover`,观察新连接 open,再发送唯一新 probe 验证真实入站及可见回复。切断只覆盖 Socket 入站,普通 Web API 出站仍直连,不代表所有网络恢复或离线消息无损。完成后停止代理、移除临时覆盖。实际 SDK 的本地 TLS/hello 握手、断线恢复与防绕过回归通过,真实平台结果另记。
155
+
156
+ ### 出站失败与重试边界
157
+
158
+ 默认 WebClient 禁用 SDK 网络/5xx 自动重试,并以 `rejectRateLimitedCalls: true` 返回 429,不在后台排队重发。平台明确返回拒绝记为 rejected;连接中断、408、5xx、缺少真实时间戳记为 unknown,调用者不得把 unknown 当成“未发送”盲目重试。实机 WSS 故障代理只控制入站长连接,不能作为 Web API 故障证据。本地真实 SDK HTTP fixture 已验证每种故障只出现一个 POST;不代表真实租户已通过。
159
+
160
+ SDK 官方选项依据:[Slack Web API 文档](https://docs.slack.dev/tools/node-slack-sdk/web-api/)。若需 API 专用故障入口,应在专用测试实例 WebClient 的正式 `agent` 选项注入固定 loopback TCP 路由,保留真实 slack.com URL、SNI 和证书校验;验收项目现提供独立 `webApiProxy` / `SLACK_WEB_API_PROXY_PORT`,不能用 WSS proxy 冒充 API proxy。
161
+
162
+ ### 专用 Web API 故障入口
163
+
164
+ 可选 endpoint `webApiProxy: { port: 18560 }` 仅作用于该实例的 JSON Web API 客户端。目的地固定 `slack.com:443`,真实 HTTPS URL/SNI/证书校验保留,TCP 只路由到 `127.0.0.1:18560`;错误域名拒绝、不回退直连。默认关闭,与 Socket `streamProxy` 独立;SocketModeClient 的连接发现和 WSS 不走此代理。停止 endpoint 会销毁专用 agent。上传使用其他域名,此专用代理明确拒绝,故只用于 JSON消息/引用/按钮/链接探针,不据此验收文件上传。
165
+
166
+ 使用现有透明TCP故障代理:
167
+
168
+ ```bash
169
+ node scripts/platform-acceptance/tcp-fault-proxy.mjs --upstream-host slack.com --upstream-port 443 --port 18560 --control-port 18561
170
+ SLACK_WEB_API_PROXY_PORT=18560 pnpm --filter platform-acceptance-bot dev:slack
171
+ ```
172
+
173
+ 启动代理 forwarding 后先发新的 `/acceptance probe:slack-api-baseline`,确认真实回执;control `POST /cut` 只切 API TCP,WSS保持连接,再发独立新样本并确认报告 unknown且无后台重发。`POST /recover` 后先确认旧样本没有补发,再发新样本验证恢复。每个未知样本都保留并检查平台可见消息;禁止因为 unknown 重发同一业务。TCP cut 发生在请求前不能证明“平台已接收但回执丢失”,这一情况已有本地 TLS fixture 在服务收到 POST 后切断覆盖,实机未单独证明。
@@ -17,6 +17,7 @@ export default defineAdapter({
17
17
  segments: {
18
18
  outboundMedia: ['url', 'upload', 'path'],
19
19
  interactive: 'native',
20
+ markdown: 'native',
20
21
  },
21
22
  create(context) {
22
23
  const config = resolveSlackConfig(context.config);
@@ -22,6 +22,7 @@ export default defineAdapter<SlackEndpointConfig>({
22
22
  segments: {
23
23
  outboundMedia: ['url', 'upload', 'path'],
24
24
  interactive: 'native',
25
+ markdown: 'native',
25
26
  },
26
27
  create(context) {
27
28
  const config = resolveSlackConfig(context.config);
package/lib/endpoint.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { Endpoint } from 'zhin.js/adapter';
2
- import type { EndpointControl, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
1
+ import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
3
2
  import type { HttpHost } from '@zhin.js/host-http';
4
3
  import type { CapabilityId } from 'zhin.js';
5
4
  import { type ResolvedSlackConfig, type SlackEvent, type SlackInteractionPayload, type SlackMessageEvent, type SlackSlashCommand } from './protocol.js';
@@ -122,6 +121,7 @@ export interface SlackEndpointOptions {
122
121
  readonly createSocket?: (opts: {
123
122
  readonly appToken: string;
124
123
  readonly clientPingTimeout: number;
124
+ readonly autoReconnectEnabled: false;
125
125
  }) => SlackSocketLike;
126
126
  }
127
127
  export declare class SlackEndpoint extends Endpoint<SlackWebClientLike> implements SlackWebhookHandler {
@@ -132,6 +132,7 @@ export declare class SlackEndpoint extends Endpoint<SlackWebClientLike> implemen
132
132
  /** Console 展示 / AdapterIndex live name(多 endpoint 时与 entry name 一致)。 */
133
133
  get name(): string;
134
134
  get client(): SlackWebClientLike;
135
+ get transportState(): import("@zhin.js/adapter").EndpointLifecycleState;
135
136
  get platformUserId(): string | undefined;
136
137
  get config(): ResolvedSlackConfig;
137
138
  start(): Promise<void>;
package/lib/endpoint.js CHANGED
@@ -1,9 +1,12 @@
1
- import { Endpoint } from 'zhin.js/adapter';
2
1
  /**
3
2
  * SlackEndpoint — lifecycle, outbound, admit, Socket Mode, agent tool surface.
4
3
  */
5
4
  import { SocketModeClient } from '@slack/socket-mode';
6
- import { WebClient } from '@slack/web-api';
5
+ import { createSlackWebApiAgent } from './web-api-proxy.js';
6
+ import { createSlackStreamAgent } from './stream-proxy.js';
7
+ import { slackSocketConnectionError } from './socket-error.js';
8
+ import { createSlackWebClient, slackDeliveryError } from './web-client.js';
9
+ import { Endpoint, createEndpointLifecycle, } from 'zhin.js/adapter';
7
10
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
8
11
  import { formatInboundContent, formatInteractionContent, formatSlashContent, resolveSlackChannelType, slackInboundConversation, } from './protocol.js';
9
12
  import { createSlackInboundFilterState, shouldDropSlackInboundMessage, } from './slack-inbound-filter.js';
@@ -19,7 +22,9 @@ export class SlackEndpoint extends Endpoint {
19
22
  #inboundFilter = createSlackInboundFilterState();
20
23
  #messageChannelMap = new Map();
21
24
  #client;
25
+ #webApiAgent;
22
26
  #socket;
27
+ #lifecycle;
23
28
  #routeReleases = [];
24
29
  #botUserId;
25
30
  #open = false;
@@ -58,6 +63,7 @@ export class SlackEndpoint extends Endpoint {
58
63
  super();
59
64
  this.#logger = getAdapterLogger('slack', options.config.id);
60
65
  this.#options = options;
66
+ this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
61
67
  }
62
68
  /** Console 展示 / AdapterIndex live name(多 endpoint 时与 entry name 一致)。 */
63
69
  get name() {
@@ -68,6 +74,7 @@ export class SlackEndpoint extends Endpoint {
68
74
  throw new Error('Slack client not connected');
69
75
  return this.#client;
70
76
  }
77
+ get transportState() { return this.config.mode === 'socket' ? this.#lifecycle.state : this.#started ? 'open' : 'stopped'; }
71
78
  get platformUserId() {
72
79
  return this.#botUserId;
73
80
  }
@@ -80,8 +87,10 @@ export class SlackEndpoint extends Endpoint {
80
87
  this.#started = true;
81
88
  try {
82
89
  const { config } = this.#options;
90
+ if (config.webApiProxy && !this.#options.createClient)
91
+ this.#webApiAgent = createSlackWebApiAgent(config.webApiProxy);
83
92
  this.#client = this.#options.createClient?.(config.token)
84
- ?? new WebClient(config.token);
93
+ ?? createSlackWebClient(config.token, this.#webApiAgent ? { agent: this.#webApiAgent } : {});
85
94
  if (config.mode === 'socket') {
86
95
  await this.#startSocket();
87
96
  }
@@ -117,6 +126,9 @@ export class SlackEndpoint extends Endpoint {
117
126
  }
118
127
  async stop() {
119
128
  this.#open = false;
129
+ this.#webApiAgent?.destroy();
130
+ this.#webApiAgent = undefined;
131
+ await this.#lifecycle.stop();
120
132
  if (this.#socket) {
121
133
  try {
122
134
  await this.#socket.disconnect();
@@ -137,9 +149,14 @@ export class SlackEndpoint extends Endpoint {
137
149
  throw new Error('Slack client not connected');
138
150
  const channel = conversation.id;
139
151
  const threadTs = conversation.threadId;
140
- const result = await sendSlackContent(this.#client, payload, { channel, threadTs }, this.#logger);
141
- this.trackMessageChannel(result.ts, channel);
142
- return formatSlackMessageRef(channel, result.ts);
152
+ try {
153
+ const result = await sendSlackContent(this.#client, payload, { channel, threadTs }, this.#logger);
154
+ this.trackMessageChannel(result.ts, channel);
155
+ return formatSlackMessageRef(channel, result.ts);
156
+ }
157
+ catch (error) {
158
+ throw slackDeliveryError(error);
159
+ }
143
160
  }
144
161
  /** Test / internal: admit a message event when open. */
145
162
  admit(event) {
@@ -203,6 +220,7 @@ export class SlackEndpoint extends Endpoint {
203
220
  conversation,
204
221
  message: { conversation, id: actionTs },
205
222
  content: formatInteractionContent(payload),
223
+ segments: [{ type: 'action', data: { id: actionTs, payload: payload.actions[0].value ?? payload.actions[0].action_id, ...(messageTs ? { sourceMessageId: messageTs } : {}) } }],
206
224
  sender: { id: userId },
207
225
  endpointId: this.#options.config.id,
208
226
  metadata: Object.freeze({
@@ -334,26 +352,39 @@ export class SlackEndpoint extends Endpoint {
334
352
  if (!config.appToken.startsWith('xapp-')) {
335
353
  throw new Error(`Slack appToken 格式不正确:Socket Mode 需要 xapp- 前缀的 App-Level Token(当前看起来是 ${config.appToken.slice(0, 5)}…,xoxb- 是 bot token,请填到 token 字段)`);
336
354
  }
337
- this.#socket = this.#options.createSocket?.({
338
- appToken: config.appToken,
339
- clientPingTimeout: config.clientPingTimeout,
340
- }) ?? new SocketModeClient({
341
- appToken: config.appToken,
342
- clientPingTimeout: config.clientPingTimeout,
343
- });
344
- this.#socket.on('slack_event', async ({ ack, body }) => {
345
- await ack();
346
- this.handleEnvelope(body);
347
- });
348
- this.#socket.on('interactive', async ({ ack, body }) => {
349
- await ack();
350
- this.admitInteraction(body);
351
- });
352
- this.#socket.on('slash_commands', async ({ ack, body }) => {
353
- await ack();
354
- this.admitSlashCommand(body);
355
+ await this.#lifecycle.start(async (handle) => {
356
+ let active = true;
357
+ let connectionError;
358
+ const agent = config.streamProxy ? createSlackStreamAgent(config.streamProxy) : undefined;
359
+ const socket = this.#options.createSocket?.({ appToken: config.appToken, clientPingTimeout: config.clientPingTimeout, autoReconnectEnabled: false })
360
+ ?? new SocketModeClient({ appToken: config.appToken, clientPingTimeout: config.clientPingTimeout,
361
+ autoReconnectEnabled: false, ...(agent ? { clientOptions: { agent } } : {}) });
362
+ this.#socket = socket;
363
+ const release = () => { if (!active)
364
+ return; active = false; if (this.#socket === socket)
365
+ this.#socket = undefined; agent?.destroy(); void socket.disconnect().catch(() => { }); };
366
+ handle.onForceClose(release);
367
+ socket.on('disconnected', () => { if (!active)
368
+ return; release(); handle.notifyClosed('Slack socket disconnected'); });
369
+ socket.on('error', error => { connectionError = slackSocketConnectionError(error); if (!active)
370
+ return; release(); handle.notifyClosed('Slack socket error'); });
371
+ socket.on('slack_event', async ({ ack, body }) => { if (!active || this.#socket !== socket)
372
+ return; await ack(); if (active)
373
+ this.handleEnvelope(body); });
374
+ socket.on('interactive', async ({ ack, body }) => { if (!active || this.#socket !== socket)
375
+ return; await ack(); if (active)
376
+ this.admitInteraction(body); });
377
+ socket.on('slash_commands', async ({ ack, body }) => { if (!active || this.#socket !== socket)
378
+ return; await ack(); if (active)
379
+ this.admitSlashCommand(body); });
380
+ try {
381
+ await socket.start();
382
+ }
383
+ catch (error) {
384
+ release();
385
+ throw connectionError ?? slackSocketConnectionError(error);
386
+ }
355
387
  });
356
- await this.#socket.start();
357
388
  }
358
389
  }
359
390
  /** Slack Web API 分页上限(每页 1000,cursor 翻页直到 next_cursor 为空)。 */
package/lib/protocol.d.ts CHANGED
@@ -2,6 +2,8 @@ import type { IncomingMessage } from 'node:http';
2
2
  import { type ConversationRef } from '@zhin.js/im-contract';
3
3
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
4
4
  export interface SlackEndpointConfig {
5
+ readonly webApiProxy?: import('./web-api-proxy.js').SlackWebApiProxy;
6
+ readonly streamProxy?: import('./stream-proxy.js').SlackStreamProxy;
5
7
  readonly id: string;
6
8
  readonly token: string;
7
9
  readonly signingSecret?: string;
@@ -12,6 +14,8 @@ export interface SlackEndpointConfig {
12
14
  readonly clientPingTimeout?: number;
13
15
  }
14
16
  export interface ResolvedSlackConfig {
17
+ readonly webApiProxy?: import('./web-api-proxy.js').SlackWebApiProxy;
18
+ readonly streamProxy?: import('./stream-proxy.js').SlackStreamProxy;
15
19
  readonly context: 'slack';
16
20
  readonly id: string;
17
21
  readonly token: string;
@@ -126,6 +130,7 @@ export declare function formatSlashContent(cmd: SlackSlashCommand): string;
126
130
  * Segment canonicalization is intentionally not done here.
127
131
  */
128
132
  export declare function formatOutboundWire(payload: unknown): {
133
+ replyTo?: string;
129
134
  text: string;
130
135
  blocks: Record<string, unknown>[];
131
136
  attachments: Record<string, unknown>[];
package/lib/protocol.js CHANGED
@@ -14,6 +14,11 @@ function dropOutboundMedia(type, reason) {
14
14
  const SLACK_SIG_VERSION = 'v0';
15
15
  const MAX_TIMESTAMP_DRIFT_SECONDS = 300;
16
16
  export function resolveSlackConfig(config) {
17
+ if (config.webApiProxy && (!Number.isInteger(config.webApiProxy.port) || config.webApiProxy.port < 1 || config.webApiProxy.port > 65535))
18
+ throw new TypeError('Invalid Slack webApiProxy port');
19
+ if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
20
+ || typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName) || config.streamProxy.serverName === 'slack.com'))
21
+ throw new TypeError('Invalid Slack streamProxy port/serverName');
17
22
  const id = requiredEndpointField(config.id, 'id');
18
23
  const token = requiredEndpointField(config.token, 'token');
19
24
  const socketMode = config.socketMode;
@@ -26,6 +31,8 @@ export function resolveSlackConfig(config) {
26
31
  ? requiredEndpointField(config.appToken, 'appToken')
27
32
  : config.appToken?.trim();
28
33
  return {
34
+ ...(config.webApiProxy ? { webApiProxy: config.webApiProxy } : {}),
35
+ ...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
29
36
  context: 'slack',
30
37
  id,
31
38
  token,
@@ -110,6 +117,7 @@ export function formatOutboundWire(payload) {
110
117
  const blocks = [];
111
118
  const attachments = [];
112
119
  const files = [];
120
+ let replyTo;
113
121
  for (const item of items) {
114
122
  if (typeof item === 'string') {
115
123
  text += item;
@@ -117,6 +125,11 @@ export function formatOutboundWire(payload) {
117
125
  }
118
126
  const data = item.data ?? {};
119
127
  switch (item.type) {
128
+ case 'reply':
129
+ if (typeof data.message_id === 'string' && data.message_id)
130
+ replyTo = data.message_id;
131
+ break;
132
+ case 'markdown':
120
133
  case 'text':
121
134
  text += String(data.text ?? data.content ?? '');
122
135
  break;
@@ -149,7 +162,7 @@ export function formatOutboundWire(payload) {
149
162
  });
150
163
  break;
151
164
  }
152
- const imageFile = resolveMediaToFile(media, String(data.alt ?? media.file_name ?? 'image'));
165
+ const imageFile = resolveMediaToFile(media, String(media.file_name ?? mediaDefaultFilename('image', media)));
153
166
  if (imageFile)
154
167
  files.push(imageFile);
155
168
  else
@@ -164,13 +177,16 @@ export function formatOutboundWire(payload) {
164
177
  dropOutboundMedia(item.type, 'missing_media_ref');
165
178
  break;
166
179
  }
167
- const mediaFile = resolveMediaToFile(media, String(data.name ?? media.file_name ?? item.type));
180
+ const mediaFile = resolveMediaToFile(media, String(media.file_name ?? data.name ?? mediaDefaultFilename(item.type, media)));
168
181
  if (mediaFile)
169
182
  files.push(mediaFile);
170
183
  else
171
184
  dropOutboundMedia(item.type, 'unsupported_media_kind');
172
185
  break;
173
186
  }
187
+ case 'share':
188
+ attachments.push({ title: String(data.title ?? ''), title_link: String(data.url ?? ''), text: String(data.description ?? data.content ?? ''), ...(data.image ? { image_url: String(data.image) } : {}) });
189
+ break;
174
190
  case 'keyboard':
175
191
  blocks.push(...keyboardToBlockKitBlocks(data));
176
192
  break;
@@ -178,7 +194,7 @@ export function formatOutboundWire(payload) {
178
194
  text += String(data.text ?? `[${item.type}]`);
179
195
  }
180
196
  }
181
- return { text, blocks, attachments, files };
197
+ return { text, blocks, attachments, files, ...(replyTo ? { replyTo } : {}) };
182
198
  }
183
199
  export function keyboardToBlockKitBlocks(data) {
184
200
  const rows = data.rows;
@@ -190,7 +206,7 @@ export function keyboardToBlockKitBlocks(data) {
190
206
  type: 'button',
191
207
  text: { type: 'plain_text', text: String(btn.label ?? btn.text ?? 'button').slice(0, 75) },
192
208
  action_id: String(btn.id ?? btn.action_id ?? `btn_${blocks.length}_${index}`),
193
- ...(btn.value != null ? { value: String(btn.value) } : {}),
209
+ ...(btn.payload != null || btn.value != null ? { value: String(btn.payload ?? btn.value) } : {}),
194
210
  ...(btn.style === 'primary' ? { style: 'primary' } : {}),
195
211
  ...(btn.style === 'danger' ? { style: 'danger' } : {}),
196
212
  }));
@@ -207,6 +223,14 @@ export function keyboardToBlockKitBlocks(data) {
207
223
  * - kind=path → 记录本地路径,上传前读盘;
208
224
  * - kind=file → Slack 无平台不透明文件引用可直发,返回 undefined 由调用方丢弃留痕。
209
225
  */
226
+ function mediaDefaultFilename(kind, media) {
227
+ const extension = {
228
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp',
229
+ 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/wav': 'wav',
230
+ 'video/mp4': 'mp4', 'video/webm': 'webm', 'application/pdf': 'pdf',
231
+ }[media.mime_type?.split(';')[0].trim().toLowerCase() ?? ''];
232
+ return `${kind}${extension ? `.${extension}` : ''}`;
233
+ }
210
234
  function resolveMediaToFile(media, name) {
211
235
  switch (media.kind) {
212
236
  case 'base64':
@@ -3,11 +3,12 @@ import { formatOutboundWire, keyboardToBlockKitBlocks } from './protocol.js';
3
3
  const SLACK_MAX_BLOCKS_PER_MESSAGE = 48;
4
4
  export async function sendSlackContent(client, content, opts, logger) {
5
5
  const wire = formatOutboundWire(content);
6
+ const threadTs = opts.threadTs ?? wire.replyTo;
6
7
  const blocks = [...wire.blocks];
7
8
  const textDelivery = applyTextMrkdwnBlocks(wire.text, blocks);
8
9
  for (const pf of wire.files) {
9
10
  try {
10
- await uploadFile(client, opts.channel, pf, opts.threadTs);
11
+ await uploadFile(client, opts.channel, pf, threadTs);
11
12
  }
12
13
  catch (e) {
13
14
  logger.error('Failed to upload file:', e);
@@ -16,7 +17,7 @@ export async function sendSlackContent(client, content, opts, logger) {
16
17
  }
17
18
  return postSlackMessage(client, {
18
19
  channel: opts.channel,
19
- threadTs: opts.threadTs,
20
+ threadTs,
20
21
  blocks,
21
22
  attachments: wire.attachments,
22
23
  fallbackText: textDelivery.fallbackText,
@@ -0,0 +1,2 @@
1
+ /** SDK can reject start() with undefined; never pass raw URL-bearing SDK errors to logs. */
2
+ export declare function slackSocketConnectionError(value: unknown): Error;
@@ -0,0 +1,13 @@
1
+ /** SDK can reject start() with undefined; never pass raw URL-bearing SDK errors to logs. */
2
+ export function slackSocketConnectionError(value) {
3
+ const proxyReason = 'Slack Stream proxy gateway identity changed; update fixed upstream before retrying';
4
+ const source = value && typeof value === 'object' ? value : undefined;
5
+ if (source?.message === proxyReason || source?.original?.message === proxyReason) {
6
+ return new Error('Slack Socket Mode gateway identity changed; fault proxy refused the connection');
7
+ }
8
+ const code = source?.original?.code ?? source?.code;
9
+ const safeCodes = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_HAS_EXPIRED', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'];
10
+ if (typeof code === 'string' && safeCodes.includes(code))
11
+ return new Error(`Slack Socket Mode connection failed (${code})`);
12
+ return new Error('Slack Socket Mode connection failed; SDK did not confirm a connection');
13
+ }
@@ -0,0 +1,6 @@
1
+ import { Agent } from 'node:https';
2
+ export interface SlackStreamProxy {
3
+ readonly port: number;
4
+ readonly serverName: string;
5
+ }
6
+ export declare function createSlackStreamAgent(proxy: SlackStreamProxy): Agent;
@@ -0,0 +1,23 @@
1
+ import { Agent } from 'node:https';
2
+ import tls from 'node:tls';
3
+ import { Socket } from 'node:net';
4
+ import { getLogger } from '@zhin.js/logger';
5
+ export function createSlackStreamAgent(proxy) {
6
+ const agent = new Agent({ keepAlive: false });
7
+ const direct = agent.createConnection.bind(agent);
8
+ agent.createConnection = (options, callback) => {
9
+ const hostname = String(options.hostname ?? options.host ?? '');
10
+ const headers = options.headers;
11
+ const upgrade = Object.entries(headers ?? {}).some(([name, value]) => name.toLowerCase() === 'upgrade' && String(value).toLowerCase() === 'websocket');
12
+ if (hostname === proxy.serverName && Number(options.port ?? 443) === 443)
13
+ return tls.connect({ ...options, path: undefined,
14
+ host: '127.0.0.1', port: proxy.port, servername: hostname, rejectUnauthorized: true });
15
+ if (!upgrade && hostname === 'slack.com' && Number(options.port ?? 443) === 443)
16
+ return direct(options, callback);
17
+ getLogger('slack').warn(`Stream proxy rejected gateway hostname: ${hostname}`);
18
+ const rejected = new Socket();
19
+ queueMicrotask(() => rejected.destroy(new Error('Slack Stream proxy gateway identity changed; update fixed upstream before retrying')));
20
+ return rejected;
21
+ };
22
+ return agent;
23
+ }
@@ -0,0 +1,6 @@
1
+ import { Agent } from 'node:https';
2
+ export interface SlackWebApiProxy {
3
+ readonly port: number;
4
+ }
5
+ /** JSON Web API only: keep slack.com identity, route TCP to a fixed loopback port. */
6
+ export declare function createSlackWebApiAgent(proxy: SlackWebApiProxy): Agent;
@@ -0,0 +1,18 @@
1
+ import { Agent } from 'node:https';
2
+ import tls from 'node:tls';
3
+ import { Socket } from 'node:net';
4
+ /** JSON Web API only: keep slack.com identity, route TCP to a fixed loopback port. */
5
+ export function createSlackWebApiAgent(proxy) {
6
+ const agent = new Agent({ keepAlive: false });
7
+ agent.createConnection = options => {
8
+ const hostname = String(options.hostname ?? options.host ?? '');
9
+ if (hostname !== 'slack.com' || Number(options.port ?? 443) !== 443) {
10
+ const rejected = new Socket();
11
+ queueMicrotask(() => rejected.destroy(new Error('Slack Web API proxy rejected destination identity')));
12
+ return rejected;
13
+ }
14
+ return tls.connect({ ...options, path: undefined,
15
+ host: '127.0.0.1', port: proxy.port, servername: 'slack.com', rejectUnauthorized: true });
16
+ };
17
+ return agent;
18
+ }
@@ -0,0 +1,5 @@
1
+ import { WebClient, type WebClientOptions } from '@slack/web-api';
2
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
3
+ /** A non-idempotent outbound API call gets exactly one transport attempt. */
4
+ export declare function createSlackWebClient(token: string, options?: WebClientOptions): WebClient;
5
+ export declare function slackDeliveryError(error: unknown): EndpointDeliveryError;
@@ -0,0 +1,15 @@
1
+ import { WebClient, ErrorCode } from '@slack/web-api';
2
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
3
+ /** A non-idempotent outbound API call gets exactly one transport attempt. */
4
+ export function createSlackWebClient(token, options = {}) {
5
+ return new WebClient(token, { ...options, retryConfig: { retries: 0 }, rejectRateLimitedCalls: true });
6
+ }
7
+ export function slackDeliveryError(error) {
8
+ if (error instanceof EndpointDeliveryError)
9
+ return error;
10
+ const value = error;
11
+ const status = typeof value?.statusCode === 'number' ? value.statusCode : undefined;
12
+ const rejected = value?.code === ErrorCode.PlatformError || value?.code === ErrorCode.RateLimitedError
13
+ || value?.code === ErrorCode.HTTPError && status !== undefined && status >= 400 && status < 500 && status !== 408;
14
+ return new EndpointDeliveryError(rejected ? 'platform_rejected' : 'delivery_unconfirmed', rejected ? 'Slack rejected outbound request' : 'Slack outbound outcome is unknown', rejected ? 'rejected' : 'unknown');
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-slack",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Zhin.js Slack adapter for Plugin Runtime (Socket Mode / HTTP Events)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -34,24 +34,24 @@
34
34
  "dependencies": {
35
35
  "@slack/socket-mode": "^2.0.7",
36
36
  "@slack/web-api": "^7.19.0",
37
- "@zhin.js/adapter": "1.1.15",
38
- "@zhin.js/agent-feature": "1.1.2",
39
- "@zhin.js/core": "1.1.39",
40
- "@zhin.js/feature-kit": "1.1.2",
41
- "@zhin.js/host-http": "1.1.2",
42
- "@zhin.js/im-contract": "1.1.2",
37
+ "@zhin.js/adapter": "1.1.17",
38
+ "@zhin.js/agent-feature": "1.1.4",
39
+ "@zhin.js/core": "1.1.41",
40
+ "@zhin.js/feature-kit": "1.1.3",
41
+ "@zhin.js/host-http": "1.1.4",
42
+ "@zhin.js/im-contract": "1.1.3",
43
43
  "@zhin.js/logger": "1.1.1",
44
- "@zhin.js/skill": "1.1.2",
45
- "@zhin.js/tool": "1.1.2"
44
+ "@zhin.js/skill": "1.1.4",
45
+ "@zhin.js/tool": "1.1.4"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "zod": "^4.0.0",
49
- "@zhin.js/adapter": "^1.1.15",
50
- "@zhin.js/command": "^1.1.3",
51
- "@zhin.js/core": "^1.1.39",
52
- "@zhin.js/host-http": "^1.1.2",
53
- "@zhin.js/permission": "^1.1.3",
54
- "zhin.js": "^1.1.4"
49
+ "@zhin.js/adapter": "^1.1.17",
50
+ "@zhin.js/command": "^1.1.5",
51
+ "@zhin.js/core": "^1.1.41",
52
+ "@zhin.js/host-http": "^1.1.4",
53
+ "@zhin.js/permission": "^1.1.4",
54
+ "zhin.js": "^1.1.6"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@zhin.js/command": {
@@ -69,7 +69,7 @@
69
69
  "typescript": "^6.0.3",
70
70
  "vitest": "^4.1.10",
71
71
  "zod": "^4.4.3",
72
- "zhin.js": "1.1.4"
72
+ "zhin.js": "1.1.6"
73
73
  },
74
74
  "files": [
75
75
  "adapters",
package/schema.json CHANGED
@@ -88,6 +88,42 @@
88
88
  "id": {
89
89
  "type": "string",
90
90
  "description": "Slack bot name"
91
+ },
92
+ "streamProxy": {
93
+ "type": "object",
94
+ "additionalProperties": false,
95
+ "properties": {
96
+ "port": {
97
+ "type": "integer",
98
+ "minimum": 1,
99
+ "maximum": 65535
100
+ },
101
+ "serverName": {
102
+ "type": "string",
103
+ "minLength": 1,
104
+ "pattern": "^[^\\s/:@?#]+$"
105
+ }
106
+ },
107
+ "required": [
108
+ "port",
109
+ "serverName"
110
+ ],
111
+ "description": "Socket故障验收:WSS固定loopback TCP,API仍标准TLS直连"
112
+ },
113
+ "webApiProxy": {
114
+ "type": "object",
115
+ "additionalProperties": false,
116
+ "properties": {
117
+ "port": {
118
+ "type": "integer",
119
+ "minimum": 1,
120
+ "maximum": 65535
121
+ }
122
+ },
123
+ "required": [
124
+ "port"
125
+ ],
126
+ "description": "出站JSON Web API专用故障代理:仅slack.com:443,固定loopbackTCP,保留SNI和证书校验,与Socket代理独立;上传域名不适用"
91
127
  }
92
128
  },
93
129
  "required": [
package/src/endpoint.ts CHANGED
@@ -1,15 +1,19 @@
1
- import { Endpoint } from 'zhin.js/adapter';
2
1
  /**
3
2
  * SlackEndpoint — lifecycle, outbound, admit, Socket Mode, agent tool surface.
4
3
  */
5
4
  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,
5
+ import { createSlackWebApiAgent } from './web-api-proxy.js';
6
+ import { createSlackStreamAgent } from './stream-proxy.js';
7
+ import { slackSocketConnectionError } from './socket-error.js';
8
+ import { createSlackWebClient, slackDeliveryError } from './web-client.js';
9
+ import {
10
+ Endpoint,
11
+ createEndpointLifecycle,
12
+ type EndpointFriend,
13
+ type EndpointGroup,
14
+ type EndpointControl,
15
+ type EndpointManagement,
16
+ type EndpointSendRequest,
13
17
  } from 'zhin.js/adapter';
14
18
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
15
19
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
@@ -96,6 +100,7 @@ export interface SlackEndpointOptions {
96
100
  readonly createSocket?: (opts: {
97
101
  readonly appToken: string;
98
102
  readonly clientPingTimeout: number;
103
+ readonly autoReconnectEnabled: false;
99
104
  }) => SlackSocketLike;
100
105
  }
101
106
 
@@ -106,7 +111,9 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
106
111
  readonly #inboundFilter = createSlackInboundFilterState();
107
112
  readonly #messageChannelMap = new Map<string, string>();
108
113
  #client?: SlackWebClientLike;
114
+ #webApiAgent?: ReturnType<typeof createSlackWebApiAgent>;
109
115
  #socket?: SlackSocketLike;
116
+ readonly #lifecycle: ReturnType<typeof createEndpointLifecycle>;
110
117
  #routeReleases: HttpRouteRegistration[] = [];
111
118
  #botUserId?: string;
112
119
  #open = false;
@@ -142,6 +149,7 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
142
149
  super();
143
150
  this.#logger = getAdapterLogger('slack', options.config.id);
144
151
  this.#options = options;
152
+ this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
145
153
  }
146
154
 
147
155
  /** Console 展示 / AdapterIndex live name(多 endpoint 时与 entry name 一致)。 */
@@ -154,6 +162,8 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
154
162
  return this.#client;
155
163
  }
156
164
 
165
+ get transportState() { return this.config.mode === 'socket' ? this.#lifecycle.state : this.#started ? 'open' : 'stopped'; }
166
+
157
167
  get platformUserId(): string | undefined {
158
168
  return this.#botUserId;
159
169
  }
@@ -167,8 +177,9 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
167
177
  this.#started = true;
168
178
  try {
169
179
  const { config } = this.#options;
180
+ if (config.webApiProxy && !this.#options.createClient) this.#webApiAgent = createSlackWebApiAgent(config.webApiProxy);
170
181
  this.#client = this.#options.createClient?.(config.token)
171
- ?? (new WebClient(config.token) as unknown as SlackWebClientLike);
182
+ ?? (createSlackWebClient(config.token, this.#webApiAgent ? { agent: this.#webApiAgent } : {}) as unknown as SlackWebClientLike);
172
183
 
173
184
  if (config.mode === 'socket') {
174
185
  await this.#startSocket();
@@ -209,6 +220,9 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
209
220
 
210
221
  async stop(): Promise<void> {
211
222
  this.#open = false;
223
+ this.#webApiAgent?.destroy();
224
+ this.#webApiAgent = undefined;
225
+ await this.#lifecycle.stop();
212
226
  if (this.#socket) {
213
227
  try {
214
228
  await this.#socket.disconnect();
@@ -227,6 +241,7 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
227
241
  if (!this.#client) throw new Error('Slack client not connected');
228
242
  const channel = conversation.id;
229
243
  const threadTs = conversation.threadId;
244
+ try {
230
245
  const result = await sendSlackContent(
231
246
  this.#client,
232
247
  payload,
@@ -235,6 +250,7 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
235
250
  );
236
251
  this.trackMessageChannel(result.ts, channel);
237
252
  return formatSlackMessageRef(channel, result.ts);
253
+ } catch (error) { throw slackDeliveryError(error); }
238
254
  }
239
255
 
240
256
  /** Test / internal: admit a message event when open. */
@@ -295,6 +311,7 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
295
311
  conversation,
296
312
  message: { conversation, id: actionTs },
297
313
  content: formatInteractionContent(payload),
314
+ segments: [{ type: 'action', data: { id: actionTs, payload: payload.actions[0].value ?? payload.actions[0].action_id, ...(messageTs ? { sourceMessageId: messageTs } : {}) } }],
298
315
  sender: { id: userId },
299
316
  endpointId: this.#options.config.id,
300
317
  metadata: Object.freeze({
@@ -430,28 +447,23 @@ export class SlackEndpoint extends Endpoint<SlackWebClientLike> implements Slack
430
447
  if (!config.appToken.startsWith('xapp-')) {
431
448
  throw new Error(`Slack appToken 格式不正确:Socket Mode 需要 xapp- 前缀的 App-Level Token(当前看起来是 ${config.appToken.slice(0, 5)}…,xoxb- 是 bot token,请填到 token 字段)`);
432
449
  }
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);
450
+ await this.#lifecycle.start(async handle => {
451
+ let active = true;
452
+ let connectionError: Error | undefined;
453
+ const agent = config.streamProxy ? createSlackStreamAgent(config.streamProxy) : undefined;
454
+ const socket = this.#options.createSocket?.({ appToken: config.appToken!, clientPingTimeout: config.clientPingTimeout, autoReconnectEnabled: false })
455
+ ?? new SocketModeClient({ appToken: config.appToken!, clientPingTimeout: config.clientPingTimeout,
456
+ autoReconnectEnabled: false, ...(agent ? { clientOptions: { agent } } : {}) }) as unknown as SlackSocketLike;
457
+ this.#socket = socket;
458
+ const release = () => { if (!active) return; active = false; if (this.#socket === socket) this.#socket = undefined; agent?.destroy(); void socket.disconnect().catch(() => {}); };
459
+ handle.onForceClose(release);
460
+ socket.on('disconnected', () => { if (!active) return; release(); handle.notifyClosed('Slack socket disconnected'); });
461
+ socket.on('error', error => { connectionError = slackSocketConnectionError(error); if (!active) return; release(); handle.notifyClosed('Slack socket error'); });
462
+ socket.on('slack_event', async ({ ack, body }) => { if (!active || this.#socket !== socket) return; await ack(); if (active) this.handleEnvelope(body); });
463
+ socket.on('interactive', async ({ ack, body }) => { if (!active || this.#socket !== socket) return; await ack(); if (active) this.admitInteraction(body as SlackInteractionPayload); });
464
+ socket.on('slash_commands', async ({ ack, body }) => { if (!active || this.#socket !== socket) return; await ack(); if (active) this.admitSlashCommand(body as SlackSlashCommand); });
465
+ try { await socket.start(); } catch (error) { release(); throw connectionError ?? slackSocketConnectionError(error); }
444
466
  });
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
467
  }
456
468
  }
457
469
 
package/src/protocol.ts CHANGED
@@ -20,6 +20,8 @@ const MAX_TIMESTAMP_DRIFT_SECONDS = 300;
20
20
 
21
21
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
22
22
  export interface SlackEndpointConfig {
23
+ readonly webApiProxy?: import('./web-api-proxy.js').SlackWebApiProxy;
24
+ readonly streamProxy?: import('./stream-proxy.js').SlackStreamProxy;
23
25
  readonly id: string;
24
26
  readonly token: string;
25
27
  readonly signingSecret?: string;
@@ -31,6 +33,8 @@ export interface SlackEndpointConfig {
31
33
  }
32
34
 
33
35
  export interface ResolvedSlackConfig {
36
+ readonly webApiProxy?: import('./web-api-proxy.js').SlackWebApiProxy;
37
+ readonly streamProxy?: import('./stream-proxy.js').SlackStreamProxy;
34
38
  readonly context: 'slack';
35
39
  readonly id: string;
36
40
  readonly token: string;
@@ -118,6 +122,9 @@ export interface SlackWireSegment {
118
122
  }
119
123
 
120
124
  export function resolveSlackConfig(config: SlackEndpointConfig): ResolvedSlackConfig {
125
+ if (config.webApiProxy && (!Number.isInteger(config.webApiProxy.port) || config.webApiProxy.port < 1 || config.webApiProxy.port > 65535)) throw new TypeError('Invalid Slack webApiProxy port');
126
+ if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
127
+ || typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName) || config.streamProxy.serverName === 'slack.com')) throw new TypeError('Invalid Slack streamProxy port/serverName');
121
128
  const id = requiredEndpointField(config.id, 'id');
122
129
  const token = requiredEndpointField(config.token, 'token');
123
130
  const socketMode = config.socketMode;
@@ -132,6 +139,8 @@ export function resolveSlackConfig(config: SlackEndpointConfig): ResolvedSlackCo
132
139
  : config.appToken?.trim();
133
140
 
134
141
  return {
142
+ ...(config.webApiProxy ? { webApiProxy: config.webApiProxy } : {}),
143
+ ...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
135
144
  context: 'slack',
136
145
  id,
137
146
  token,
@@ -214,6 +223,7 @@ export function formatSlashContent(cmd: SlackSlashCommand): string {
214
223
  * Segment canonicalization is intentionally not done here.
215
224
  */
216
225
  export function formatOutboundWire(payload: unknown): {
226
+ replyTo?: string;
217
227
  text: string;
218
228
  blocks: Record<string, unknown>[];
219
229
  attachments: Record<string, unknown>[];
@@ -242,6 +252,7 @@ export function formatOutboundWire(payload: unknown): {
242
252
  const blocks: Record<string, unknown>[] = [];
243
253
  const attachments: Record<string, unknown>[] = [];
244
254
  const files: Array<{ buffer?: Buffer; url?: string; path?: string; name?: string }> = [];
255
+ let replyTo: string | undefined;
245
256
 
246
257
  for (const item of items) {
247
258
  if (typeof item === 'string') {
@@ -250,6 +261,10 @@ export function formatOutboundWire(payload: unknown): {
250
261
  }
251
262
  const data = item.data ?? {};
252
263
  switch (item.type) {
264
+ case 'reply':
265
+ if (typeof data.message_id === 'string' && data.message_id) replyTo = data.message_id;
266
+ break;
267
+ case 'markdown':
253
268
  case 'text':
254
269
  text += String(data.text ?? data.content ?? '');
255
270
  break;
@@ -281,7 +296,7 @@ export function formatOutboundWire(payload: unknown): {
281
296
  });
282
297
  break;
283
298
  }
284
- const imageFile = resolveMediaToFile(media, String(data.alt ?? media.file_name ?? 'image'));
299
+ const imageFile = resolveMediaToFile(media, String(media.file_name ?? mediaDefaultFilename('image', media)));
285
300
  if (imageFile) files.push(imageFile);
286
301
  else dropOutboundMedia(item.type, 'unsupported_media_kind');
287
302
  break;
@@ -294,11 +309,14 @@ export function formatOutboundWire(payload: unknown): {
294
309
  dropOutboundMedia(item.type, 'missing_media_ref');
295
310
  break;
296
311
  }
297
- const mediaFile = resolveMediaToFile(media, String(data.name ?? media.file_name ?? item.type));
312
+ const mediaFile = resolveMediaToFile(media, String(media.file_name ?? data.name ?? mediaDefaultFilename(item.type, media)));
298
313
  if (mediaFile) files.push(mediaFile);
299
314
  else dropOutboundMedia(item.type, 'unsupported_media_kind');
300
315
  break;
301
316
  }
317
+ case 'share':
318
+ attachments.push({ title: String(data.title ?? ''), title_link: String(data.url ?? ''), text: String(data.description ?? data.content ?? ''), ...(data.image ? { image_url: String(data.image) } : {}) });
319
+ break;
302
320
  case 'keyboard':
303
321
  blocks.push(...keyboardToBlockKitBlocks(data));
304
322
  break;
@@ -307,7 +325,7 @@ export function formatOutboundWire(payload: unknown): {
307
325
  }
308
326
  }
309
327
 
310
- return { text, blocks, attachments, files };
328
+ return { text, blocks, attachments, files, ...(replyTo ? { replyTo } : {}) };
311
329
  }
312
330
 
313
331
  export function keyboardToBlockKitBlocks(data: Record<string, unknown>): Record<string, unknown>[] {
@@ -320,7 +338,7 @@ export function keyboardToBlockKitBlocks(data: Record<string, unknown>): Record<
320
338
  type: 'button',
321
339
  text: { type: 'plain_text', text: String(btn.label ?? btn.text ?? 'button').slice(0, 75) },
322
340
  action_id: String(btn.id ?? btn.action_id ?? `btn_${blocks.length}_${index}`),
323
- ...(btn.value != null ? { value: String(btn.value) } : {}),
341
+ ...(btn.payload != null || btn.value != null ? { value: String(btn.payload ?? btn.value) } : {}),
324
342
  ...(btn.style === 'primary' ? { style: 'primary' } : {}),
325
343
  ...(btn.style === 'danger' ? { style: 'danger' } : {}),
326
344
  }));
@@ -338,6 +356,15 @@ export function keyboardToBlockKitBlocks(data: Record<string, unknown>): Record<
338
356
  * - kind=path → 记录本地路径,上传前读盘;
339
357
  * - kind=file → Slack 无平台不透明文件引用可直发,返回 undefined 由调用方丢弃留痕。
340
358
  */
359
+ function mediaDefaultFilename(kind: string, media: MediaRef): string {
360
+ const extension = ({
361
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp',
362
+ 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/wav': 'wav',
363
+ 'video/mp4': 'mp4', 'video/webm': 'webm', 'application/pdf': 'pdf',
364
+ } as Record<string, string>)[media.mime_type?.split(';')[0].trim().toLowerCase() ?? ''];
365
+ return `${kind}${extension ? `.${extension}` : ''}`;
366
+ }
367
+
341
368
  function resolveMediaToFile(
342
369
  media: MediaRef,
343
370
  name: string,
@@ -31,12 +31,13 @@ export async function sendSlackContent(
31
31
  logger: Logger,
32
32
  ): Promise<SlackOutboundResult> {
33
33
  const wire = formatOutboundWire(content);
34
+ const threadTs = opts.threadTs ?? wire.replyTo;
34
35
  const blocks = [...wire.blocks];
35
36
  const textDelivery = applyTextMrkdwnBlocks(wire.text, blocks);
36
37
 
37
38
  for (const pf of wire.files) {
38
39
  try {
39
- await uploadFile(client, opts.channel, pf, opts.threadTs);
40
+ await uploadFile(client, opts.channel, pf, threadTs);
40
41
  } catch (e) {
41
42
  logger.error('Failed to upload file:', e);
42
43
  throw e;
@@ -45,7 +46,7 @@ export async function sendSlackContent(
45
46
 
46
47
  return postSlackMessage(client, {
47
48
  channel: opts.channel,
48
- threadTs: opts.threadTs,
49
+ threadTs,
49
50
  blocks,
50
51
  attachments: wire.attachments,
51
52
  fallbackText: textDelivery.fallbackText,
@@ -0,0 +1,12 @@
1
+ /** SDK can reject start() with undefined; never pass raw URL-bearing SDK errors to logs. */
2
+ export function slackSocketConnectionError(value: unknown): Error {
3
+ const proxyReason = 'Slack Stream proxy gateway identity changed; update fixed upstream before retrying';
4
+ const source = value && typeof value === 'object' ? value as { message?: unknown; code?: unknown; original?: { message?: unknown; code?: unknown } } : undefined;
5
+ if (source?.message === proxyReason || source?.original?.message === proxyReason) {
6
+ return new Error('Slack Socket Mode gateway identity changed; fault proxy refused the connection');
7
+ }
8
+ const code = source?.original?.code ?? source?.code;
9
+ const safeCodes = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_HAS_EXPIRED', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'];
10
+ if (typeof code === 'string' && safeCodes.includes(code)) return new Error(`Slack Socket Mode connection failed (${code})`);
11
+ return new Error('Slack Socket Mode connection failed; SDK did not confirm a connection');
12
+ }
@@ -0,0 +1,22 @@
1
+ import { Agent } from 'node:https';
2
+ import tls from 'node:tls';
3
+ import { Socket } from 'node:net';
4
+ import { getLogger } from '@zhin.js/logger';
5
+ export interface SlackStreamProxy { readonly port: number; readonly serverName: string }
6
+ export function createSlackStreamAgent(proxy: SlackStreamProxy): Agent {
7
+ const agent = new Agent({ keepAlive: false });
8
+ const direct = agent.createConnection.bind(agent);
9
+ agent.createConnection = (options, callback) => {
10
+ const hostname = String(options.hostname ?? options.host ?? '');
11
+ const headers = options.headers as Record<string, unknown> | undefined;
12
+ const upgrade = Object.entries(headers ?? {}).some(([name, value]) => name.toLowerCase() === 'upgrade' && String(value).toLowerCase() === 'websocket');
13
+ if (hostname === proxy.serverName && Number(options.port ?? 443) === 443) return tls.connect({ ...options as tls.ConnectionOptions, path: undefined,
14
+ host: '127.0.0.1', port: proxy.port, servername: hostname, rejectUnauthorized: true });
15
+ if (!upgrade && hostname === 'slack.com' && Number(options.port ?? 443) === 443) return direct(options, callback);
16
+ getLogger('slack').warn(`Stream proxy rejected gateway hostname: ${hostname}`);
17
+ const rejected = new Socket();
18
+ queueMicrotask(() => rejected.destroy(new Error('Slack Stream proxy gateway identity changed; update fixed upstream before retrying')));
19
+ return rejected;
20
+ };
21
+ return agent;
22
+ }
@@ -0,0 +1,21 @@
1
+ import { Agent } from 'node:https';
2
+ import tls from 'node:tls';
3
+ import { Socket } from 'node:net';
4
+
5
+ export interface SlackWebApiProxy { readonly port: number }
6
+
7
+ /** JSON Web API only: keep slack.com identity, route TCP to a fixed loopback port. */
8
+ export function createSlackWebApiAgent(proxy: SlackWebApiProxy): Agent {
9
+ const agent = new Agent({ keepAlive: false });
10
+ agent.createConnection = options => {
11
+ const hostname = String(options.hostname ?? options.host ?? '');
12
+ if (hostname !== 'slack.com' || Number(options.port ?? 443) !== 443) {
13
+ const rejected = new Socket();
14
+ queueMicrotask(() => rejected.destroy(new Error('Slack Web API proxy rejected destination identity')));
15
+ return rejected;
16
+ }
17
+ return tls.connect({ ...options as tls.ConnectionOptions, path: undefined,
18
+ host: '127.0.0.1', port: proxy.port, servername: 'slack.com', rejectUnauthorized: true });
19
+ };
20
+ return agent;
21
+ }
@@ -0,0 +1,17 @@
1
+ import { WebClient, ErrorCode, type WebClientOptions } from '@slack/web-api';
2
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
3
+
4
+ /** A non-idempotent outbound API call gets exactly one transport attempt. */
5
+ export function createSlackWebClient(token: string, options: WebClientOptions = {}): WebClient {
6
+ return new WebClient(token, { ...options, retryConfig: { retries: 0 }, rejectRateLimitedCalls: true });
7
+ }
8
+
9
+ export function slackDeliveryError(error: unknown): EndpointDeliveryError {
10
+ if (error instanceof EndpointDeliveryError) return error;
11
+ const value = error as { code?: unknown; statusCode?: unknown } | undefined;
12
+ const status = typeof value?.statusCode === 'number' ? value.statusCode : undefined;
13
+ const rejected = value?.code === ErrorCode.PlatformError || value?.code === ErrorCode.RateLimitedError
14
+ || value?.code === ErrorCode.HTTPError && status !== undefined && status >= 400 && status < 500 && status !== 408;
15
+ return new EndpointDeliveryError(rejected ? 'platform_rejected' : 'delivery_unconfirmed',
16
+ rejected ? 'Slack rejected outbound request' : 'Slack outbound outcome is unknown', rejected ? 'rejected' : 'unknown');
17
+ }