@zhin.js/adapter-slack 1.1.4 → 1.1.5
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 +27 -0
- package/README.md +28 -0
- package/adapters/slack/index.js +1 -0
- package/adapters/slack/index.ts +1 -0
- package/lib/endpoint.d.ts +3 -2
- package/lib/endpoint.js +56 -25
- package/lib/protocol.d.ts +5 -0
- package/lib/protocol.js +28 -4
- package/lib/slack-outbound.js +3 -2
- package/lib/socket-error.d.ts +2 -0
- package/lib/socket-error.js +13 -0
- package/lib/stream-proxy.d.ts +6 -0
- package/lib/stream-proxy.js +23 -0
- package/lib/web-api-proxy.d.ts +6 -0
- package/lib/web-api-proxy.js +18 -0
- package/lib/web-client.d.ts +5 -0
- package/lib/web-client.js +15 -0
- package/package.json +14 -14
- package/schema.json +36 -0
- package/src/endpoint.ts +42 -30
- package/src/protocol.ts +31 -4
- package/src/slack-outbound.ts +3 -2
- package/src/socket-error.ts +12 -0
- package/src/stream-proxy.ts +22 -0
- package/src/web-api-proxy.ts +21 -0
- package/src/web-client.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b9b3953: Map canonical reply segments to Slack thread replies instead of displaying a literal reply placeholder. Existing conversation thread routing takes precedence.
|
|
8
|
+
|
|
9
|
+
Give unnamed uploads a MIME-derived filename extension and preserve explicit media filenames, rather than using image alt text as the filename.
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [b9b3953]
|
|
12
|
+
- Updated dependencies [b9b3953]
|
|
13
|
+
- Updated dependencies [b9b3953]
|
|
14
|
+
- Updated dependencies [b9b3953]
|
|
15
|
+
- Updated dependencies [b9b3953]
|
|
16
|
+
- Updated dependencies [b9b3953]
|
|
17
|
+
- Updated dependencies [b9b3953]
|
|
18
|
+
- Updated dependencies [b9b3953]
|
|
19
|
+
- Updated dependencies [b9b3953]
|
|
20
|
+
- Updated dependencies [b9b3953]
|
|
21
|
+
- Updated dependencies [b9b3953]
|
|
22
|
+
- @zhin.js/adapter@1.1.16
|
|
23
|
+
- @zhin.js/im-contract@1.1.3
|
|
24
|
+
- @zhin.js/core@1.1.40
|
|
25
|
+
- @zhin.js/host-http@1.1.3
|
|
26
|
+
- @zhin.js/tool@1.1.3
|
|
27
|
+
- @zhin.js/skill@1.1.3
|
|
28
|
+
- @zhin.js/agent-feature@1.1.3
|
|
29
|
+
|
|
3
30
|
## 1.1.4
|
|
4
31
|
|
|
5
32
|
### 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 后切断覆盖,实机未单独证明。
|
package/adapters/slack/index.js
CHANGED
package/adapters/slack/index.ts
CHANGED
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 {
|
|
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
|
-
??
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
appToken: config.appToken,
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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(
|
|
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(
|
|
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':
|
package/lib/slack-outbound.js
CHANGED
|
@@ -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,
|
|
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
|
|
20
|
+
threadTs,
|
|
20
21
|
blocks,
|
|
21
22
|
attachments: wire.attachments,
|
|
22
23
|
fallbackText: textDelivery.fallbackText,
|
|
@@ -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,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.
|
|
3
|
+
"version": "1.1.5",
|
|
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.
|
|
38
|
-
"@zhin.js/agent-feature": "1.1.
|
|
39
|
-
"@zhin.js/core": "1.1.
|
|
37
|
+
"@zhin.js/adapter": "1.1.16",
|
|
38
|
+
"@zhin.js/agent-feature": "1.1.3",
|
|
39
|
+
"@zhin.js/core": "1.1.40",
|
|
40
40
|
"@zhin.js/feature-kit": "1.1.2",
|
|
41
|
-
"@zhin.js/host-http": "1.1.
|
|
42
|
-
"@zhin.js/im-contract": "1.1.
|
|
41
|
+
"@zhin.js/host-http": "1.1.3",
|
|
42
|
+
"@zhin.js/im-contract": "1.1.3",
|
|
43
43
|
"@zhin.js/logger": "1.1.1",
|
|
44
|
-
"@zhin.js/skill": "1.1.
|
|
45
|
-
"@zhin.js/tool": "1.1.
|
|
44
|
+
"@zhin.js/skill": "1.1.3",
|
|
45
|
+
"@zhin.js/tool": "1.1.3"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"zod": "^4.0.0",
|
|
49
|
-
"@zhin.js/adapter": "^1.1.
|
|
50
|
-
"@zhin.js/command": "^1.1.
|
|
51
|
-
"@zhin.js/core": "^1.1.
|
|
52
|
-
"@zhin.js/host-http": "^1.1.
|
|
49
|
+
"@zhin.js/adapter": "^1.1.16",
|
|
50
|
+
"@zhin.js/command": "^1.1.4",
|
|
51
|
+
"@zhin.js/core": "^1.1.40",
|
|
52
|
+
"@zhin.js/host-http": "^1.1.3",
|
|
53
53
|
"@zhin.js/permission": "^1.1.3",
|
|
54
|
-
"zhin.js": "^1.1.
|
|
54
|
+
"zhin.js": "^1.1.5"
|
|
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.
|
|
72
|
+
"zhin.js": "1.1.5"
|
|
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 {
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
?? (
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
appToken: config.appToken,
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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(
|
|
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(
|
|
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,
|
package/src/slack-outbound.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
+
}
|