@zhin.js/adapter-slack 1.1.20 → 1.1.21
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 +43 -0
- package/README.md +12 -0
- package/lib/adapter.js +1 -0
- package/lib/console-spec.js +2 -0
- package/lib/endpoint.js +10 -0
- package/lib/native-stream.d.ts +12 -0
- package/lib/native-stream.js +86 -0
- package/lib/protocol.d.ts +7 -1
- package/lib/protocol.js +6 -0
- package/lib/reply-stream.d.ts +4 -0
- package/lib/reply-stream.js +105 -0
- package/lib/self-profile.d.ts +9 -0
- package/lib/self-profile.js +19 -0
- package/package.json +20 -20
- package/schema.json +126 -1
- package/src/adapter.ts +1 -0
- package/src/console-spec.ts +2 -0
- package/src/endpoint.ts +10 -0
- package/src/native-stream.ts +79 -0
- package/src/protocol.ts +11 -1
- package/src/reply-stream.ts +88 -0
- package/src/self-profile.ts +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.21
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- af8f803: Use the current Endpoint's platform profile for Agent account identity, with per-field configured fallbacks and scoped, identity-approved profile tools. Keep SOUL first-person and independent of account names. Profile ports preserve the source Client, cancellation, truthful update outcomes and private image authority.
|
|
8
|
+
|
|
9
|
+
Adapter Feature API 2.1 and Tool Feature API 1.1 describe the additive profile requirements; new Core, official adapters and generated plugins declare the corresponding floors. Existing compatible API-2.0/API-1.0 plugins remain accepted by the newer providers. npm releases remain patch.
|
|
10
|
+
|
|
11
|
+
Private IM assets now bind to the full canonical conversation and principal. Existing IM asset records that contain only the earlier lossy hash remain on disk until their original expiry, but cannot be safely rebound; recapture them. No automatic file deletion, platform profile mutation or workspace SOUL rewrite occurs during upgrade.
|
|
12
|
+
|
|
13
|
+
- 26fda97: Deliver validated final answers progressively through configured QQ C2C, Telegram drafts, Slack streams, Lark cards and Sandbox updates. Keep complete-message delivery for unsupported scenes, disabled adapters and outbound middleware that does not explicitly support revisions. Discord retains complete-message delivery.
|
|
14
|
+
|
|
15
|
+
Preserve source-account leases, media order and confirmed delivery facts across cancellation, capacity rollover and follow-up answer phases. Preview drafts and uncertain outcomes do not become sent history. Bound each reply's semantic snapshot to 1 MiB, retain confirmed prefixes on failure and avoid replaying published answers.
|
|
16
|
+
|
|
17
|
+
Project the latest confirmed message revision into paginated conversation history and synchronize Sandbox message updates across connections. Existing adapter implementations and complete-send callers remain compatible.
|
|
18
|
+
|
|
19
|
+
- 976886c: Preserve confirmed progressive reply content, routing and delivery evidence across cancellation, rollover and closing failures without replaying published text. Keep error dispositions and actual message references, and abort the owning provider request when its functional text consumer fails.
|
|
20
|
+
|
|
21
|
+
Enable native Sandbox Markdown through the real Core projection so Console updates retain formatting in one message. Preserve instance-level streaming opt-outs, recall tombstones and multi-page synchronization. The first safe content is immediate; later updates retain the configured pacing, including large valid intervals.
|
|
22
|
+
|
|
23
|
+
Keep Slack and Lark Markdown streaming within the current physical message. When continuation formatting cannot be verified, stop and retain confirmed content instead of replaying or silently changing it to plain text. Plain-text continuations retain their original reply route.
|
|
24
|
+
|
|
25
|
+
Use rebuildable revision metadata and indexes in the existing conversation event table for bounded SQL history reads. Original event JSON and retention remain unchanged; older writers and models keep their compatible read path. Supported adapter streaming remains enabled by default, with complete-message fallback for disabled settings or outbound middleware without incremental: true.
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [b88e988]
|
|
28
|
+
- Updated dependencies [f78eb84]
|
|
29
|
+
- Updated dependencies [c15cef9]
|
|
30
|
+
- Updated dependencies [b0c02a2]
|
|
31
|
+
- Updated dependencies [af8f803]
|
|
32
|
+
- Updated dependencies [26fda97]
|
|
33
|
+
- Updated dependencies [976886c]
|
|
34
|
+
- Updated dependencies [032dc96]
|
|
35
|
+
- @zhin.js/adapter@1.1.27
|
|
36
|
+
- @zhin.js/client@1.1.23
|
|
37
|
+
- @zhin.js/im-contract@1.1.10
|
|
38
|
+
- @zhin.js/core@1.1.55
|
|
39
|
+
- @zhin.js/tool@1.1.14
|
|
40
|
+
- @zhin.js/page@1.1.13
|
|
41
|
+
- @zhin.js/host-http@1.1.13
|
|
42
|
+
- @zhin.js/agent-feature@1.1.12
|
|
43
|
+
- @zhin.js/feature-kit@1.1.11
|
|
44
|
+
- @zhin.js/skill@1.1.12
|
|
45
|
+
|
|
3
46
|
## 1.1.20
|
|
4
47
|
|
|
5
48
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -10,6 +10,18 @@ Zhin.js Slack 适配器(Plugin Runtime),优先 Socket Mode,也可经 Run
|
|
|
10
10
|
- 约定式 `defineAdapter` / `definePlugin`(无需 `usePlugin`)
|
|
11
11
|
- Block Kit 按钮、斜杠命令、消息编辑、表情反应等(见 `tools/`)
|
|
12
12
|
|
|
13
|
+
## 渐进式回复
|
|
14
|
+
|
|
15
|
+
`streaming` 默认 `true`,可在插件或单个 endpoint 配置中设为 `false`,恢复普通完整发送。原生流使用同一条消息的 `chat.startStream`、`chat.appendStream` 与 `chat.stopStream`;全程固定为 `chunks` 模式,正文和 Markdown 按已确认的源码前缀追加。
|
|
16
|
+
|
|
17
|
+
私聊和普通频道都需要既有线程或明确的引用目标;普通频道还需要可信的发言者和 workspace。缺少这些信息时保留普通发送,不为获得流式能力创建新线程。引用和 @ 前缀只提交一次;未知结果不重发。媒体和按钮由统一发送链按原始顺序投递,不重复上传。
|
|
18
|
+
|
|
19
|
+
`streamingIntervalMs` 默认 `1000` 毫秒,用于后续回复更新间隔;首段立即发送,中间更新由统一回复控制器合并。
|
|
20
|
+
|
|
21
|
+
原生流的累计正文上限为 12,000 字符,总时长上限为 10 分钟,追加内容不会延长时限。平台明确返回已有流已结束时,纯文字回复保留已确认正文,在同一话题的新消息中继续未发送的部分;发送结果未知时不重发。详见 [Slack 原生流限制](https://docs.slack.dev/reference/methods/chat.startStream/#stream-timeout)。
|
|
22
|
+
|
|
23
|
+
纯文字可以在已确认的容量或时限边界续条。Markdown 保留单容器内的流式发送;需要续容器但无法保证独立格式时,会停止并保留已确认部分,不降级为纯文本。
|
|
24
|
+
|
|
13
25
|
## 安装
|
|
14
26
|
|
|
15
27
|
```bash
|
package/lib/adapter.js
CHANGED
|
@@ -17,6 +17,7 @@ export class SlackAdapter extends Adapter {
|
|
|
17
17
|
get configurationIdentityProbeFields() { return ['token', 'appToken', 'signingSecret']; }
|
|
18
18
|
get configurationUpdateFields() {
|
|
19
19
|
return ['token', 'appToken', 'signingSecret', 'socketMode',
|
|
20
|
+
'streaming', 'streamingIntervalMs',
|
|
20
21
|
'webhookPath', 'clientPingTimeout', 'webApiProxy', 'webApiProxy.port', 'streamProxy', 'streamProxy.port', 'streamProxy.serverName'];
|
|
21
22
|
}
|
|
22
23
|
validateConfiguration(config) { resolveSlackConfig(config); }
|
package/lib/console-spec.js
CHANGED
|
@@ -6,6 +6,8 @@ export const slackConsoleSpec = {
|
|
|
6
6
|
{ key: 'signingSecret', env: true, description: '签名密钥(HTTP Events,socketMode: false 必填)', descriptionI18n: { en: 'Signing secret (required for HTTP Events)' } },
|
|
7
7
|
{ key: 'appToken', env: true, description: 'App-Level Token(xapp-...,Socket Mode)', descriptionI18n: { en: 'App-level token (xapp-..., Socket Mode)' } },
|
|
8
8
|
{ key: 'socketMode', type: 'boolean', description: 'Socket Mode; false selects HTTP Events' },
|
|
9
|
+
{ key: 'streaming', type: 'boolean', description: '生成时逐步显示回复', descriptionI18n: { en: 'Show replies progressively while generating' } },
|
|
10
|
+
{ key: 'streamingIntervalMs', type: 'number', description: '回复更新间隔(毫秒)', descriptionI18n: { en: 'Reply update interval in milliseconds' } },
|
|
9
11
|
{ key: 'webhookPath', description: 'HTTP Events route' },
|
|
10
12
|
{ key: 'clientPingTimeout', type: 'number', description: 'SDK client ping timeout in milliseconds' },
|
|
11
13
|
{ key: 'webApiProxy.port', type: 'number', description: 'Configured Web API loopback proxy port' },
|
package/lib/endpoint.js
CHANGED
|
@@ -5,6 +5,7 @@ import { WebClient } from '@slack/web-api';
|
|
|
5
5
|
import { SocketModeClient } from '@slack/socket-mode';
|
|
6
6
|
import { createSlackStreamAgent } from './stream-proxy.js';
|
|
7
7
|
import { slackSocketConnectionError } from './socket-error.js';
|
|
8
|
+
import { createSlackSelfProfile } from './self-profile.js';
|
|
8
9
|
import { slackWebClientOptions, slackDeliveryError } from './web-client.js';
|
|
9
10
|
import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, createEndpointOperation, } from 'zhin.js/adapter';
|
|
10
11
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
@@ -16,6 +17,7 @@ import { postSlackEphemeral } from './slack-response-url.js';
|
|
|
16
17
|
import { editSlackContent, sendSlackContent } from './slack-outbound.js';
|
|
17
18
|
import { registerSlackWebhookRoutes } from './webhook.js';
|
|
18
19
|
import { receiveSlackSideEvent } from './side-event-dispatch.js';
|
|
20
|
+
import { openSlackReplyStream, supportsSlackReplyStream } from './reply-stream.js';
|
|
19
21
|
export class SlackEndpoint extends WebClient {
|
|
20
22
|
[endpointSpec];
|
|
21
23
|
#logger;
|
|
@@ -69,6 +71,12 @@ export class SlackEndpoint extends WebClient {
|
|
|
69
71
|
stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
|
|
70
72
|
name: options.config.id, get transportState() { return endpoint.#transportState; },
|
|
71
73
|
management: this.#management, control: this.#control,
|
|
74
|
+
...(options.config.streaming !== false ? { streaming: { mode: 'native',
|
|
75
|
+
minIntervalMs: options.config.streamingIntervalMs ?? 1000,
|
|
76
|
+
supports: supportsSlackReplyStream,
|
|
77
|
+
open: (request) => openSlackReplyStream(this, request, ts => this.trackMessageChannel(ts, request.conversation.id)),
|
|
78
|
+
} } : {}),
|
|
79
|
+
profile: createSlackSelfProfile({ client: this, accountId: () => this.#botUserId }),
|
|
72
80
|
});
|
|
73
81
|
}
|
|
74
82
|
/** Console 展示 / AdapterIndex live name(多 endpoint 时与 entry name 一致)。 */
|
|
@@ -107,6 +115,8 @@ export class SlackEndpoint extends WebClient {
|
|
|
107
115
|
const authTest = await this.auth.test();
|
|
108
116
|
if (authTest.user_id)
|
|
109
117
|
this.#botUserId = String(authTest.user_id);
|
|
118
|
+
if (this.#botUserId)
|
|
119
|
+
this[endpointSpec].reportSelfUserId(this.#botUserId);
|
|
110
120
|
this.#logger.info(`connected (${config.mode})`
|
|
111
121
|
+ (this.#botUserId ? ` | user: ${this.#botUserId}` : ''));
|
|
112
122
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { WebClient, ChatStartStreamArguments, ChatAppendStreamArguments } from '@slack/web-api';
|
|
2
|
+
type StreamContent = Pick<ChatAppendStreamArguments, 'markdown_text' | 'chunks'>;
|
|
3
|
+
type StreamTarget = Pick<ChatStartStreamArguments, 'channel' | 'thread_ts' | 'recipient_team_id' | 'recipient_user_id'>;
|
|
4
|
+
type StreamClient = Pick<WebClient['chat'], 'startStream' | 'appendStream' | 'stopStream'>;
|
|
5
|
+
/** Slack's native stream always uses one content mode and one confirmed message identity. */
|
|
6
|
+
export declare function createSlackNativeStream(chat: StreamClient, target: StreamTarget, signal?: AbortSignal): {
|
|
7
|
+
readonly messageTs: string | undefined;
|
|
8
|
+
readonly finalizedByPlatform: boolean;
|
|
9
|
+
append(content: StreamContent): Promise<string>;
|
|
10
|
+
stop(): Promise<string | undefined>;
|
|
11
|
+
};
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createEndpointOperation } from 'zhin.js/adapter';
|
|
2
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
3
|
+
import { slackDeliveryError } from './web-client.js';
|
|
4
|
+
import { ErrorCode } from '@slack/web-api';
|
|
5
|
+
/** Slack's native stream always uses one content mode and one confirmed message identity. */
|
|
6
|
+
export function createSlackNativeStream(chat, target, signal) {
|
|
7
|
+
const operation = createEndpointOperation({ signal });
|
|
8
|
+
let ts;
|
|
9
|
+
let mode;
|
|
10
|
+
let stopped = false;
|
|
11
|
+
let finalizedByPlatform = false;
|
|
12
|
+
let failure;
|
|
13
|
+
function assertOpen() {
|
|
14
|
+
if (failure)
|
|
15
|
+
throw failure;
|
|
16
|
+
if (stopped)
|
|
17
|
+
throw new EndpointDeliveryError('stream_closed', 'Slack stream is closed', 'not_sent');
|
|
18
|
+
}
|
|
19
|
+
function contentMode(content) {
|
|
20
|
+
const next = content.markdown_text !== undefined ? 'markdown_text' : 'chunks';
|
|
21
|
+
if (content.markdown_text !== undefined && content.chunks !== undefined
|
|
22
|
+
|| content.markdown_text === undefined && content.chunks === undefined
|
|
23
|
+
|| mode && mode !== next) {
|
|
24
|
+
throw new EndpointDeliveryError('invalid_payload', 'Slack stream content mode cannot change', 'not_sent');
|
|
25
|
+
}
|
|
26
|
+
mode = next;
|
|
27
|
+
}
|
|
28
|
+
function wasFinalized(error) {
|
|
29
|
+
const result = error;
|
|
30
|
+
// Slack documents that timed-out streams retain all previously accepted content.
|
|
31
|
+
// Only this explicit response with an existing identity proves the container is final.
|
|
32
|
+
if (!ts || result?.code !== ErrorCode.PlatformError || result.data?.error !== 'message_not_in_streaming_state')
|
|
33
|
+
return false;
|
|
34
|
+
stopped = true;
|
|
35
|
+
finalizedByPlatform = true;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
get messageTs() { return ts; },
|
|
40
|
+
get finalizedByPlatform() { return finalizedByPlatform; },
|
|
41
|
+
async append(content) {
|
|
42
|
+
assertOpen();
|
|
43
|
+
contentMode(content);
|
|
44
|
+
try {
|
|
45
|
+
const response = ts
|
|
46
|
+
? await operation.run(() => chat.appendStream({ channel: target.channel, ts: ts, ...content }))
|
|
47
|
+
: await operation.run(() => chat.startStream({ ...target, ...content }));
|
|
48
|
+
if (!ts) {
|
|
49
|
+
if (typeof response.ts !== 'string' || !response.ts.trim()) {
|
|
50
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Slack stream returned no message timestamp', 'unknown');
|
|
51
|
+
}
|
|
52
|
+
ts = response.ts;
|
|
53
|
+
}
|
|
54
|
+
return ts;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (wasFinalized(error))
|
|
58
|
+
throw new EndpointDeliveryError('stream_expired', 'Slack finalized the existing stream', 'not_sent');
|
|
59
|
+
failure = slackDeliveryError(error);
|
|
60
|
+
throw failure;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
async stop() {
|
|
64
|
+
if (failure)
|
|
65
|
+
throw failure;
|
|
66
|
+
if (stopped)
|
|
67
|
+
return ts;
|
|
68
|
+
assertOpen();
|
|
69
|
+
if (!ts) {
|
|
70
|
+
stopped = true;
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await operation.run(() => chat.stopStream({ channel: target.channel, ts: ts }));
|
|
75
|
+
stopped = true;
|
|
76
|
+
return ts;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (wasFinalized(error))
|
|
80
|
+
return ts;
|
|
81
|
+
failure = slackDeliveryError(error);
|
|
82
|
+
throw failure;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
|
|
1
2
|
import type { IncomingMessage } from 'node:http';
|
|
2
3
|
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
3
4
|
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
4
|
-
export interface SlackEndpointConfig {
|
|
5
|
+
export interface SlackEndpointConfig extends EndpointProfileConfiguration {
|
|
6
|
+
/** Native reply streaming is enabled unless explicitly disabled. */
|
|
7
|
+
readonly streaming?: boolean;
|
|
8
|
+
readonly streamingIntervalMs?: number;
|
|
5
9
|
readonly webApiProxy?: import('./web-api-proxy.js').SlackWebApiProxy;
|
|
6
10
|
readonly streamProxy?: import('./stream-proxy.js').SlackStreamProxy;
|
|
7
11
|
readonly id: string;
|
|
@@ -14,6 +18,8 @@ export interface SlackEndpointConfig {
|
|
|
14
18
|
readonly clientPingTimeout?: number;
|
|
15
19
|
}
|
|
16
20
|
export interface ResolvedSlackConfig {
|
|
21
|
+
readonly streaming?: boolean;
|
|
22
|
+
readonly streamingIntervalMs?: number;
|
|
17
23
|
readonly webApiProxy?: import('./web-api-proxy.js').SlackWebApiProxy;
|
|
18
24
|
readonly streamProxy?: import('./stream-proxy.js').SlackStreamProxy;
|
|
19
25
|
readonly context: 'slack';
|
package/lib/protocol.js
CHANGED
|
@@ -14,6 +14,10 @@ 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.streaming !== undefined && typeof config.streaming !== 'boolean')
|
|
18
|
+
throw new TypeError('Slack streaming must be a boolean');
|
|
19
|
+
if (config.streamingIntervalMs !== undefined && (!Number.isSafeInteger(config.streamingIntervalMs) || config.streamingIntervalMs < 0))
|
|
20
|
+
throw new TypeError('Slack streamingIntervalMs must be a non-negative integer');
|
|
17
21
|
if (config.webApiProxy && (!Number.isInteger(config.webApiProxy.port) || config.webApiProxy.port < 1 || config.webApiProxy.port > 65535))
|
|
18
22
|
throw new TypeError('Invalid Slack webApiProxy port');
|
|
19
23
|
if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
|
|
@@ -34,6 +38,8 @@ export function resolveSlackConfig(config) {
|
|
|
34
38
|
...(config.webApiProxy ? { webApiProxy: config.webApiProxy } : {}),
|
|
35
39
|
...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
|
|
36
40
|
context: 'slack',
|
|
41
|
+
streaming: config.streaming !== false,
|
|
42
|
+
streamingIntervalMs: config.streamingIntervalMs ?? 1000,
|
|
37
43
|
id,
|
|
38
44
|
token,
|
|
39
45
|
mode,
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { WebClient } from '@slack/web-api';
|
|
2
|
+
import type { EndpointMessageStream, EndpointStreamRequest } from 'zhin.js/adapter';
|
|
3
|
+
export declare function supportsSlackReplyStream(request: Pick<EndpointStreamRequest, 'conversation' | 'source'>): boolean;
|
|
4
|
+
export declare function openSlackReplyStream(client: WebClient, request: EndpointStreamRequest, onMessage: (ts: string) => void): EndpointMessageStream | undefined;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
|
+
import { createSlackNativeStream } from './native-stream.js';
|
|
3
|
+
import { formatOutboundWire } from './protocol.js';
|
|
4
|
+
import { formatSlackMessageRef, parseSlackMessageRef } from './slack-message-ref.js';
|
|
5
|
+
export function supportsSlackReplyStream(request) {
|
|
6
|
+
const event = request.source?.event;
|
|
7
|
+
// A future reply prefix may select a thread; recipient authority cannot be supplied by the model.
|
|
8
|
+
return request.conversation.kind === 'private'
|
|
9
|
+
|| Boolean(typeof event?.team_id === 'string' && event.team_id && request.source?.senderId);
|
|
10
|
+
}
|
|
11
|
+
export function openSlackReplyStream(client, request, onMessage) {
|
|
12
|
+
if (!supportsSlackReplyStream(request))
|
|
13
|
+
return undefined;
|
|
14
|
+
// A new physical message cannot inherit the previous native Markdown renderer state.
|
|
15
|
+
if (request.format === 'markdown' && request.continuation)
|
|
16
|
+
return undefined;
|
|
17
|
+
const prefix = request.prefix ?? [];
|
|
18
|
+
if (prefix.some(segment => segment.type !== 'reply' && segment.type !== 'mention'))
|
|
19
|
+
return undefined;
|
|
20
|
+
const wire = formatOutboundWire(prefix.length ? prefix : '');
|
|
21
|
+
const replyTo = wire.replyTo ?? request.continuation?.replyTo;
|
|
22
|
+
const quoted = replyTo ? parseSlackMessageRef(replyTo) : null;
|
|
23
|
+
if (quoted && quoted.channel !== request.conversation.id)
|
|
24
|
+
return undefined;
|
|
25
|
+
const threadTs = request.conversation.threadId ?? quoted?.ts ?? replyTo;
|
|
26
|
+
// Ordinary channel replies must not move into a new thread just to gain streaming.
|
|
27
|
+
if (!threadTs)
|
|
28
|
+
return undefined;
|
|
29
|
+
const event = request.source?.event;
|
|
30
|
+
const teamId = typeof event?.team_id === 'string' ? event.team_id : undefined;
|
|
31
|
+
const senderId = request.source?.senderId;
|
|
32
|
+
const native = createSlackNativeStream(client.chat, {
|
|
33
|
+
channel: request.conversation.id, thread_ts: threadTs,
|
|
34
|
+
...(request.conversation.kind !== 'private' ? { recipient_team_id: teamId, recipient_user_id: senderId } : {}),
|
|
35
|
+
}, request.signal);
|
|
36
|
+
let acceptedText = '';
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
let finished = false;
|
|
39
|
+
let exhausted = false;
|
|
40
|
+
let lastAcknowledgement;
|
|
41
|
+
const acknowledge = () => lastAcknowledgement = Object.freeze({
|
|
42
|
+
status: native.messageTs ? 'sent' : 'buffered',
|
|
43
|
+
...(native.messageTs ? { messageId: formatSlackMessageRef(request.conversation.id, native.messageTs) } : {}),
|
|
44
|
+
acceptedText,
|
|
45
|
+
...(exhausted ? { exhausted: true } : {}),
|
|
46
|
+
});
|
|
47
|
+
const write = async (text) => {
|
|
48
|
+
if (cancelled)
|
|
49
|
+
throw new EndpointDeliveryError('stream_cancelled', 'Slack stream was cancelled', 'not_sent');
|
|
50
|
+
if (finished)
|
|
51
|
+
return acknowledge();
|
|
52
|
+
if (!text.startsWith(acceptedText))
|
|
53
|
+
throw new EndpointDeliveryError('stream_rewrite', 'A confirmed reply prefix cannot change', 'not_sent');
|
|
54
|
+
if (native.finalizedByPlatform) {
|
|
55
|
+
exhausted = true;
|
|
56
|
+
return acknowledge();
|
|
57
|
+
}
|
|
58
|
+
let next = text;
|
|
59
|
+
if (wire.text.length + next.length > 12_000) {
|
|
60
|
+
exhausted = true;
|
|
61
|
+
if (request.format === 'markdown') {
|
|
62
|
+
if (!acceptedText)
|
|
63
|
+
throw new EndpointDeliveryError('stream_capacity', 'Slack Markdown has no confirmed boundary within its container capacity', 'not_sent');
|
|
64
|
+
return acknowledge();
|
|
65
|
+
}
|
|
66
|
+
next = next.slice(0, Math.max(0, 12_000 - wire.text.length));
|
|
67
|
+
if (/[\uD800-\uDBFF]$/.test(next))
|
|
68
|
+
next = next.slice(0, -1);
|
|
69
|
+
}
|
|
70
|
+
if (next === acceptedText)
|
|
71
|
+
return acknowledge();
|
|
72
|
+
const delta = (native.messageTs ? '' : wire.text) + next.slice(acceptedText.length);
|
|
73
|
+
// Fixed chunks mode allows complete rich blocks without switching Slack's wire mode.
|
|
74
|
+
let ts;
|
|
75
|
+
try {
|
|
76
|
+
ts = await native.append({ chunks: [{ type: 'markdown_text', text: delta }] });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (!native.finalizedByPlatform)
|
|
80
|
+
throw error;
|
|
81
|
+
exhausted = true;
|
|
82
|
+
return acknowledge();
|
|
83
|
+
}
|
|
84
|
+
acceptedText = next;
|
|
85
|
+
onMessage(ts);
|
|
86
|
+
return acknowledge();
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
get lastAcknowledgement() { return lastAcknowledgement; },
|
|
90
|
+
write,
|
|
91
|
+
async finish(text) {
|
|
92
|
+
if (finished)
|
|
93
|
+
return acknowledge();
|
|
94
|
+
await write(text);
|
|
95
|
+
if (cancelled)
|
|
96
|
+
throw new EndpointDeliveryError('stream_cancelled', 'Slack stream was cancelled', 'not_sent');
|
|
97
|
+
await native.stop();
|
|
98
|
+
finished = true;
|
|
99
|
+
if (native.finalizedByPlatform)
|
|
100
|
+
exhausted = true;
|
|
101
|
+
return acknowledge();
|
|
102
|
+
},
|
|
103
|
+
cancel() { cancelled = true; },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type EndpointProfile } from 'zhin.js/adapter';
|
|
2
|
+
import type { WebClient } from '@slack/web-api';
|
|
3
|
+
interface SlackSelfProfileOptions {
|
|
4
|
+
readonly client: Pick<WebClient, 'users'>;
|
|
5
|
+
readonly accountId: () => string | undefined;
|
|
6
|
+
}
|
|
7
|
+
/** Bot-token reads use users.info. User-token-only profile mutations are not advertised. */
|
|
8
|
+
export declare function createSlackSelfProfile({ client, accountId }: SlackSelfProfileOptions): EndpointProfile;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createEndpointOperation } from 'zhin.js/adapter';
|
|
2
|
+
/** Bot-token reads use users.info. User-token-only profile mutations are not advertised. */
|
|
3
|
+
export function createSlackSelfProfile({ client, accountId }) {
|
|
4
|
+
return Object.freeze({
|
|
5
|
+
writableFields: Object.freeze([]),
|
|
6
|
+
async read({ signal }) {
|
|
7
|
+
const id = accountId();
|
|
8
|
+
if (!id)
|
|
9
|
+
throw new Error('Slack self account is unavailable');
|
|
10
|
+
const response = await createEndpointOperation({ signal }).prepare(() => client.users.info({ user: id }));
|
|
11
|
+
if (!response.ok || response.user?.id !== id)
|
|
12
|
+
throw new Error('Slack returned no matching self profile');
|
|
13
|
+
const profile = response.user.profile;
|
|
14
|
+
const nickname = profile?.display_name || profile?.real_name || response.user.real_name || response.user.name;
|
|
15
|
+
return { ...(typeof nickname === 'string' ? { nickname } : {}),
|
|
16
|
+
...(typeof profile?.image_512 === 'string' ? { avatar: profile.image_512 } : {}) };
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
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.21",
|
|
4
4
|
"description": "Zhin.js Slack adapter for Plugin Runtime (Socket Mode / HTTP Events)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -33,27 +33,27 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@slack/socket-mode": "^2.0.7",
|
|
35
35
|
"@slack/web-api": "^7.19.0",
|
|
36
|
-
"@zhin.js/adapter": "1.1.
|
|
37
|
-
"@zhin.js/agent-feature": "1.1.
|
|
38
|
-
"@zhin.js/client": "1.1.
|
|
36
|
+
"@zhin.js/adapter": "1.1.27",
|
|
37
|
+
"@zhin.js/agent-feature": "1.1.12",
|
|
38
|
+
"@zhin.js/client": "1.1.23",
|
|
39
39
|
"@zhin.js/console-contract": "1.1.3",
|
|
40
|
-
"@zhin.js/core": "1.1.
|
|
41
|
-
"@zhin.js/feature-kit": "1.1.
|
|
42
|
-
"@zhin.js/host-http": "1.1.
|
|
43
|
-
"@zhin.js/im-contract": "1.1.
|
|
40
|
+
"@zhin.js/core": "1.1.55",
|
|
41
|
+
"@zhin.js/feature-kit": "1.1.11",
|
|
42
|
+
"@zhin.js/host-http": "1.1.13",
|
|
43
|
+
"@zhin.js/im-contract": "1.1.10",
|
|
44
44
|
"@zhin.js/logger": "1.1.2",
|
|
45
|
-
"@zhin.js/page": "1.1.
|
|
46
|
-
"@zhin.js/skill": "1.1.
|
|
47
|
-
"@zhin.js/tool": "1.1.
|
|
45
|
+
"@zhin.js/page": "1.1.13",
|
|
46
|
+
"@zhin.js/skill": "1.1.12",
|
|
47
|
+
"@zhin.js/tool": "1.1.14"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"zod": "^4.0.0",
|
|
51
|
-
"@zhin.js/adapter": "^1.1.
|
|
52
|
-
"@zhin.js/command": "^1.1.
|
|
53
|
-
"@zhin.js/core": "^1.1.
|
|
54
|
-
"@zhin.js/host-http": "^1.1.
|
|
55
|
-
"@zhin.js/permission": "^1.1.
|
|
56
|
-
"zhin.js": "^1.1.
|
|
51
|
+
"@zhin.js/adapter": "^1.1.27",
|
|
52
|
+
"@zhin.js/command": "^1.1.13",
|
|
53
|
+
"@zhin.js/core": "^1.1.55",
|
|
54
|
+
"@zhin.js/host-http": "^1.1.13",
|
|
55
|
+
"@zhin.js/permission": "^1.1.11",
|
|
56
|
+
"zhin.js": "^1.1.20"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
59
59
|
"@zhin.js/command": {
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"typescript": "^6.0.3",
|
|
72
72
|
"vitest": "^4.1.10",
|
|
73
73
|
"zod": "^4.4.3",
|
|
74
|
-
"zhin.js": "1.1.
|
|
74
|
+
"zhin.js": "1.1.20"
|
|
75
75
|
},
|
|
76
76
|
"files": [
|
|
77
77
|
"adapters",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"features": [
|
|
104
104
|
{
|
|
105
105
|
"package": "@zhin.js/adapter",
|
|
106
|
-
"api": "^2.
|
|
106
|
+
"api": "^2.1.0"
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"package": "@zhin.js/command",
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
"package": "@zhin.js/tool",
|
|
114
|
-
"api": "^1.
|
|
114
|
+
"api": "^1.1.0"
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
"package": "@zhin.js/skill",
|
package/schema.json
CHANGED
|
@@ -154,6 +154,36 @@
|
|
|
154
154
|
"description": "Fault proxy for outbound JSON Web APIs only: pin slack.com:443 TCP to loopback while preserving SNI and certificate validation. Independent of the Socket proxy; does not apply to upload hosts."
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
|
+
},
|
|
158
|
+
"streaming": {
|
|
159
|
+
"type": "boolean",
|
|
160
|
+
"description": "Use native streaming replies when the target supports them."
|
|
161
|
+
},
|
|
162
|
+
"streamingIntervalMs": {
|
|
163
|
+
"type": "integer",
|
|
164
|
+
"minimum": 0,
|
|
165
|
+
"description": "Reply stream update interval in milliseconds."
|
|
166
|
+
},
|
|
167
|
+
"nickname": {
|
|
168
|
+
"type": [
|
|
169
|
+
"string",
|
|
170
|
+
"null"
|
|
171
|
+
],
|
|
172
|
+
"description": "账号昵称兜底",
|
|
173
|
+
"x-i18n": {
|
|
174
|
+
"en": {
|
|
175
|
+
"description": "Fallback account nickname"
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"profile": {
|
|
180
|
+
"$ref": "#/$defs/selfProfileFallback",
|
|
181
|
+
"description": "账号资料兜底",
|
|
182
|
+
"x-i18n": {
|
|
183
|
+
"en": {
|
|
184
|
+
"description": "Fallback account profile"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
157
187
|
}
|
|
158
188
|
},
|
|
159
189
|
"required": [
|
|
@@ -176,9 +206,104 @@
|
|
|
176
206
|
"description": "Command prefix. The default empty string allows unprefixed commands; '/' requires a leading slash. Each endpoints[i] can override it."
|
|
177
207
|
}
|
|
178
208
|
}
|
|
209
|
+
},
|
|
210
|
+
"streaming": {
|
|
211
|
+
"type": "boolean",
|
|
212
|
+
"default": true,
|
|
213
|
+
"description": "Use native streaming replies when the target supports them."
|
|
214
|
+
},
|
|
215
|
+
"streamingIntervalMs": {
|
|
216
|
+
"type": "integer",
|
|
217
|
+
"minimum": 0,
|
|
218
|
+
"description": "Reply stream update interval in milliseconds.",
|
|
219
|
+
"default": 1000
|
|
220
|
+
},
|
|
221
|
+
"nickname": {
|
|
222
|
+
"type": [
|
|
223
|
+
"string",
|
|
224
|
+
"null"
|
|
225
|
+
],
|
|
226
|
+
"description": "账号昵称兜底",
|
|
227
|
+
"x-i18n": {
|
|
228
|
+
"en": {
|
|
229
|
+
"description": "Fallback account nickname"
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
"profile": {
|
|
234
|
+
"$ref": "#/$defs/selfProfileFallback",
|
|
235
|
+
"description": "账号资料兜底",
|
|
236
|
+
"x-i18n": {
|
|
237
|
+
"en": {
|
|
238
|
+
"description": "Fallback account profile"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
179
241
|
}
|
|
180
242
|
},
|
|
181
243
|
"required": [
|
|
182
244
|
"endpoints"
|
|
183
|
-
]
|
|
245
|
+
],
|
|
246
|
+
"$defs": {
|
|
247
|
+
"selfProfileFallback": {
|
|
248
|
+
"type": "object",
|
|
249
|
+
"additionalProperties": false,
|
|
250
|
+
"properties": {
|
|
251
|
+
"gender": {
|
|
252
|
+
"type": [
|
|
253
|
+
"string",
|
|
254
|
+
"null"
|
|
255
|
+
],
|
|
256
|
+
"enum": [
|
|
257
|
+
"male",
|
|
258
|
+
"female",
|
|
259
|
+
"other",
|
|
260
|
+
"unknown",
|
|
261
|
+
null
|
|
262
|
+
]
|
|
263
|
+
},
|
|
264
|
+
"age": {
|
|
265
|
+
"type": [
|
|
266
|
+
"integer",
|
|
267
|
+
"null"
|
|
268
|
+
],
|
|
269
|
+
"minimum": 0
|
|
270
|
+
},
|
|
271
|
+
"birthday": {
|
|
272
|
+
"type": [
|
|
273
|
+
"string",
|
|
274
|
+
"null"
|
|
275
|
+
],
|
|
276
|
+
"pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$"
|
|
277
|
+
},
|
|
278
|
+
"signature": {
|
|
279
|
+
"type": [
|
|
280
|
+
"string",
|
|
281
|
+
"null"
|
|
282
|
+
]
|
|
283
|
+
},
|
|
284
|
+
"avatar": {
|
|
285
|
+
"type": [
|
|
286
|
+
"string",
|
|
287
|
+
"null"
|
|
288
|
+
],
|
|
289
|
+
"pattern": "^(https?://[^\\s/?#]+(?:[/?#].*)?|)$"
|
|
290
|
+
},
|
|
291
|
+
"interests": {
|
|
292
|
+
"type": [
|
|
293
|
+
"array",
|
|
294
|
+
"null"
|
|
295
|
+
],
|
|
296
|
+
"items": {
|
|
297
|
+
"type": "string"
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
"bio": {
|
|
301
|
+
"type": [
|
|
302
|
+
"string",
|
|
303
|
+
"null"
|
|
304
|
+
]
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
184
309
|
}
|
package/src/adapter.ts
CHANGED
|
@@ -12,6 +12,7 @@ export class SlackAdapter extends Adapter<SlackEndpoint, SlackEndpointConfig, Sl
|
|
|
12
12
|
override get configurationIdentityFields(): readonly string[] { return ['id']; }
|
|
13
13
|
override get configurationIdentityProbeFields(): readonly string[] { return ['token', 'appToken', 'signingSecret']; }
|
|
14
14
|
override get configurationUpdateFields(): readonly string[] { return ['token', 'appToken', 'signingSecret', 'socketMode',
|
|
15
|
+
'streaming', 'streamingIntervalMs',
|
|
15
16
|
'webhookPath', 'clientPingTimeout', 'webApiProxy', 'webApiProxy.port', 'streamProxy', 'streamProxy.port', 'streamProxy.serverName']; }
|
|
16
17
|
protected override validateConfiguration(config: SlackEndpointConfig): void { resolveSlackConfig(config); }
|
|
17
18
|
protected override async identifyConfiguration(input: SlackEndpointConfig, signal: AbortSignal, changedFields: readonly string[]): Promise<string | undefined> {
|
package/src/console-spec.ts
CHANGED
|
@@ -11,6 +11,8 @@ export const slackConsoleSpec = {
|
|
|
11
11
|
{ key: 'signingSecret', env: true, description: '签名密钥(HTTP Events,socketMode: false 必填)', descriptionI18n: { en: 'Signing secret (required for HTTP Events)' } },
|
|
12
12
|
{ key: 'appToken', env: true, description: 'App-Level Token(xapp-...,Socket Mode)', descriptionI18n: { en: 'App-level token (xapp-..., Socket Mode)' } },
|
|
13
13
|
{ key: 'socketMode', type: 'boolean', description: 'Socket Mode; false selects HTTP Events' },
|
|
14
|
+
{ key: 'streaming', type: 'boolean', description: '生成时逐步显示回复', descriptionI18n: { en: 'Show replies progressively while generating' } },
|
|
15
|
+
{ key: 'streamingIntervalMs', type: 'number', description: '回复更新间隔(毫秒)', descriptionI18n: { en: 'Reply update interval in milliseconds' } },
|
|
14
16
|
{ key: 'webhookPath', description: 'HTTP Events route' },
|
|
15
17
|
{ key: 'clientPingTimeout', type: 'number', description: 'SDK client ping timeout in milliseconds' },
|
|
16
18
|
{ key: 'webApiProxy.port', type: 'number', description: 'Configured Web API loopback proxy port' },
|
package/src/endpoint.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SocketModeClient } from '@slack/socket-mode';
|
|
|
6
6
|
import { createSlackWebApiAgent } from './web-api-proxy.js';
|
|
7
7
|
import { createSlackStreamAgent } from './stream-proxy.js';
|
|
8
8
|
import { slackSocketConnectionError } from './socket-error.js';
|
|
9
|
+
import { createSlackSelfProfile } from './self-profile.js';
|
|
9
10
|
import { slackWebClientOptions, slackDeliveryError } from './web-client.js';
|
|
10
11
|
import {
|
|
11
12
|
createEndpointSpecification,
|
|
@@ -48,6 +49,7 @@ import { postSlackEphemeral } from './slack-response-url.js';
|
|
|
48
49
|
import { editSlackContent, sendSlackContent, type SlackChatClient, type SlackEditRequest } from './slack-outbound.js';
|
|
49
50
|
import { registerSlackWebhookRoutes, type SlackWebhookHandler } from './webhook.js';
|
|
50
51
|
import { receiveSlackSideEvent } from './side-event-dispatch.js';
|
|
52
|
+
import { openSlackReplyStream, supportsSlackReplyStream } from './reply-stream.js';
|
|
51
53
|
|
|
52
54
|
export interface SlackSocketLike {
|
|
53
55
|
on(
|
|
@@ -124,6 +126,13 @@ export class SlackEndpoint extends WebClient implements Endpoint, SlackWebhookHa
|
|
|
124
126
|
stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
|
|
125
127
|
name: options.config.id, get transportState() { return endpoint.#transportState; },
|
|
126
128
|
management: this.#management, control: this.#control,
|
|
129
|
+
...(options.config.streaming !== false ? { streaming: { mode: 'native' as const,
|
|
130
|
+
minIntervalMs: options.config.streamingIntervalMs ?? 1000,
|
|
131
|
+
supports: supportsSlackReplyStream,
|
|
132
|
+
open: (request: import('zhin.js/adapter').EndpointStreamRequest) => openSlackReplyStream(this, request,
|
|
133
|
+
ts => this.trackMessageChannel(ts, request.conversation.id)),
|
|
134
|
+
} } : {}),
|
|
135
|
+
profile: createSlackSelfProfile({ client: this, accountId: () => this.#botUserId }),
|
|
127
136
|
});
|
|
128
137
|
}
|
|
129
138
|
|
|
@@ -166,6 +175,7 @@ export class SlackEndpoint extends WebClient implements Endpoint, SlackWebhookHa
|
|
|
166
175
|
|
|
167
176
|
const authTest = await this.auth.test();
|
|
168
177
|
if (authTest.user_id) this.#botUserId = String(authTest.user_id);
|
|
178
|
+
if (this.#botUserId) this[endpointSpec].reportSelfUserId(this.#botUserId);
|
|
169
179
|
|
|
170
180
|
this.#logger.info(
|
|
171
181
|
`connected (${config.mode})`
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { WebClient, ChatStartStreamArguments, ChatAppendStreamArguments } from '@slack/web-api';
|
|
2
|
+
import { createEndpointOperation } from 'zhin.js/adapter';
|
|
3
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
4
|
+
import { slackDeliveryError } from './web-client.js';
|
|
5
|
+
import { ErrorCode } from '@slack/web-api';
|
|
6
|
+
|
|
7
|
+
type StreamContent = Pick<ChatAppendStreamArguments, 'markdown_text' | 'chunks'>;
|
|
8
|
+
type StreamTarget = Pick<ChatStartStreamArguments, 'channel' | 'thread_ts' | 'recipient_team_id' | 'recipient_user_id'>;
|
|
9
|
+
type StreamClient = Pick<WebClient['chat'], 'startStream' | 'appendStream' | 'stopStream'>;
|
|
10
|
+
|
|
11
|
+
/** Slack's native stream always uses one content mode and one confirmed message identity. */
|
|
12
|
+
export function createSlackNativeStream(chat: StreamClient, target: StreamTarget, signal?: AbortSignal) {
|
|
13
|
+
const operation = createEndpointOperation({ signal });
|
|
14
|
+
let ts: string | undefined;
|
|
15
|
+
let mode: 'markdown_text' | 'chunks' | undefined;
|
|
16
|
+
let stopped = false;
|
|
17
|
+
let finalizedByPlatform = false;
|
|
18
|
+
let failure: EndpointDeliveryError | undefined;
|
|
19
|
+
|
|
20
|
+
function assertOpen() {
|
|
21
|
+
if (failure) throw failure;
|
|
22
|
+
if (stopped) throw new EndpointDeliveryError('stream_closed', 'Slack stream is closed', 'not_sent');
|
|
23
|
+
}
|
|
24
|
+
function contentMode(content: StreamContent) {
|
|
25
|
+
const next = content.markdown_text !== undefined ? 'markdown_text' : 'chunks';
|
|
26
|
+
if (content.markdown_text !== undefined && content.chunks !== undefined
|
|
27
|
+
|| content.markdown_text === undefined && content.chunks === undefined
|
|
28
|
+
|| mode && mode !== next) {
|
|
29
|
+
throw new EndpointDeliveryError('invalid_payload', 'Slack stream content mode cannot change', 'not_sent');
|
|
30
|
+
}
|
|
31
|
+
mode = next;
|
|
32
|
+
}
|
|
33
|
+
function wasFinalized(error: unknown): boolean {
|
|
34
|
+
const result = error as { code?: unknown; data?: { error?: unknown } } | undefined;
|
|
35
|
+
// Slack documents that timed-out streams retain all previously accepted content.
|
|
36
|
+
// Only this explicit response with an existing identity proves the container is final.
|
|
37
|
+
if (!ts || result?.code !== ErrorCode.PlatformError || result.data?.error !== 'message_not_in_streaming_state') return false;
|
|
38
|
+
stopped = true; finalizedByPlatform = true;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
get messageTs() { return ts; },
|
|
44
|
+
get finalizedByPlatform() { return finalizedByPlatform; },
|
|
45
|
+
async append(content: StreamContent): Promise<string> {
|
|
46
|
+
assertOpen();
|
|
47
|
+
contentMode(content);
|
|
48
|
+
try {
|
|
49
|
+
const response = ts
|
|
50
|
+
? await operation.run(() => chat.appendStream({ channel: target.channel, ts: ts!, ...content }))
|
|
51
|
+
: await operation.run(() => chat.startStream({ ...target, ...content }));
|
|
52
|
+
if (!ts) {
|
|
53
|
+
if (typeof response.ts !== 'string' || !response.ts.trim()) {
|
|
54
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Slack stream returned no message timestamp', 'unknown');
|
|
55
|
+
}
|
|
56
|
+
ts = response.ts;
|
|
57
|
+
}
|
|
58
|
+
return ts!;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (wasFinalized(error)) throw new EndpointDeliveryError('stream_expired', 'Slack finalized the existing stream', 'not_sent');
|
|
61
|
+
failure = slackDeliveryError(error); throw failure;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
async stop(): Promise<string | undefined> {
|
|
65
|
+
if (failure) throw failure;
|
|
66
|
+
if (stopped) return ts;
|
|
67
|
+
assertOpen();
|
|
68
|
+
if (!ts) { stopped = true; return undefined; }
|
|
69
|
+
try {
|
|
70
|
+
await operation.run(() => chat.stopStream({ channel: target.channel, ts: ts! }));
|
|
71
|
+
stopped = true;
|
|
72
|
+
return ts;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (wasFinalized(error)) return ts;
|
|
75
|
+
failure = slackDeliveryError(error); throw failure;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
|
|
1
2
|
/**
|
|
2
3
|
* Slack protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
4
|
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
@@ -19,7 +20,10 @@ const SLACK_SIG_VERSION = 'v0';
|
|
|
19
20
|
const MAX_TIMESTAMP_DRIFT_SECONDS = 300;
|
|
20
21
|
|
|
21
22
|
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
22
|
-
export interface SlackEndpointConfig {
|
|
23
|
+
export interface SlackEndpointConfig extends EndpointProfileConfiguration {
|
|
24
|
+
/** Native reply streaming is enabled unless explicitly disabled. */
|
|
25
|
+
readonly streaming?: boolean;
|
|
26
|
+
readonly streamingIntervalMs?: number;
|
|
23
27
|
readonly webApiProxy?: import('./web-api-proxy.js').SlackWebApiProxy;
|
|
24
28
|
readonly streamProxy?: import('./stream-proxy.js').SlackStreamProxy;
|
|
25
29
|
readonly id: string;
|
|
@@ -33,6 +37,8 @@ export interface SlackEndpointConfig {
|
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export interface ResolvedSlackConfig {
|
|
40
|
+
readonly streaming?: boolean;
|
|
41
|
+
readonly streamingIntervalMs?: number;
|
|
36
42
|
readonly webApiProxy?: import('./web-api-proxy.js').SlackWebApiProxy;
|
|
37
43
|
readonly streamProxy?: import('./stream-proxy.js').SlackStreamProxy;
|
|
38
44
|
readonly context: 'slack';
|
|
@@ -125,6 +131,8 @@ export interface SlackWireSegment {
|
|
|
125
131
|
export interface SlackBlock { readonly type: string; readonly block_id?: string; readonly [key: string]: unknown }
|
|
126
132
|
|
|
127
133
|
export function resolveSlackConfig(config: SlackEndpointConfig): ResolvedSlackConfig {
|
|
134
|
+
if (config.streaming !== undefined && typeof config.streaming !== 'boolean') throw new TypeError('Slack streaming must be a boolean');
|
|
135
|
+
if (config.streamingIntervalMs !== undefined && (!Number.isSafeInteger(config.streamingIntervalMs) || config.streamingIntervalMs < 0)) throw new TypeError('Slack streamingIntervalMs must be a non-negative integer');
|
|
128
136
|
if (config.webApiProxy && (!Number.isInteger(config.webApiProxy.port) || config.webApiProxy.port < 1 || config.webApiProxy.port > 65535)) throw new TypeError('Invalid Slack webApiProxy port');
|
|
129
137
|
if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
|
|
130
138
|
|| 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');
|
|
@@ -145,6 +153,8 @@ export function resolveSlackConfig(config: SlackEndpointConfig): ResolvedSlackCo
|
|
|
145
153
|
...(config.webApiProxy ? { webApiProxy: config.webApiProxy } : {}),
|
|
146
154
|
...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
|
|
147
155
|
context: 'slack',
|
|
156
|
+
streaming: config.streaming !== false,
|
|
157
|
+
streamingIntervalMs: config.streamingIntervalMs ?? 1000,
|
|
148
158
|
id,
|
|
149
159
|
token,
|
|
150
160
|
mode,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { WebClient } from '@slack/web-api';
|
|
2
|
+
import type { EndpointMessageStream, EndpointStreamRequest, EndpointStreamAcknowledgement } from 'zhin.js/adapter';
|
|
3
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
4
|
+
import { createSlackNativeStream } from './native-stream.js';
|
|
5
|
+
import { formatOutboundWire } from './protocol.js';
|
|
6
|
+
import { formatSlackMessageRef, parseSlackMessageRef } from './slack-message-ref.js';
|
|
7
|
+
|
|
8
|
+
export function supportsSlackReplyStream(request: Pick<EndpointStreamRequest, 'conversation' | 'source'>): boolean {
|
|
9
|
+
const event = request.source?.event as { team_id?: unknown } | undefined;
|
|
10
|
+
// A future reply prefix may select a thread; recipient authority cannot be supplied by the model.
|
|
11
|
+
return request.conversation.kind === 'private'
|
|
12
|
+
|| Boolean(typeof event?.team_id === 'string' && event.team_id && request.source?.senderId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function openSlackReplyStream(client: WebClient, request: EndpointStreamRequest,
|
|
16
|
+
onMessage: (ts: string) => void): EndpointMessageStream | undefined {
|
|
17
|
+
if (!supportsSlackReplyStream(request)) return undefined;
|
|
18
|
+
// A new physical message cannot inherit the previous native Markdown renderer state.
|
|
19
|
+
if (request.format === 'markdown' && request.continuation) return undefined;
|
|
20
|
+
const prefix = request.prefix ?? [];
|
|
21
|
+
if (prefix.some(segment => segment.type !== 'reply' && segment.type !== 'mention')) return undefined;
|
|
22
|
+
const wire = formatOutboundWire(prefix.length ? prefix : '');
|
|
23
|
+
const replyTo = wire.replyTo ?? request.continuation?.replyTo;
|
|
24
|
+
const quoted = replyTo ? parseSlackMessageRef(replyTo) : null;
|
|
25
|
+
if (quoted && quoted.channel !== request.conversation.id) return undefined;
|
|
26
|
+
const threadTs = request.conversation.threadId ?? quoted?.ts ?? replyTo;
|
|
27
|
+
// Ordinary channel replies must not move into a new thread just to gain streaming.
|
|
28
|
+
if (!threadTs) return undefined;
|
|
29
|
+
const event = request.source?.event as { team_id?: unknown } | undefined;
|
|
30
|
+
const teamId = typeof event?.team_id === 'string' ? event.team_id : undefined;
|
|
31
|
+
const senderId = request.source?.senderId;
|
|
32
|
+
const native = createSlackNativeStream(client.chat, {
|
|
33
|
+
channel: request.conversation.id, thread_ts: threadTs,
|
|
34
|
+
...(request.conversation.kind !== 'private' ? { recipient_team_id: teamId, recipient_user_id: senderId } : {}),
|
|
35
|
+
}, request.signal);
|
|
36
|
+
let acceptedText = '';
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
let finished = false;
|
|
39
|
+
let exhausted = false;
|
|
40
|
+
let lastAcknowledgement: EndpointStreamAcknowledgement | undefined;
|
|
41
|
+
const acknowledge = (): EndpointStreamAcknowledgement => lastAcknowledgement = Object.freeze({
|
|
42
|
+
status: native.messageTs ? 'sent' : 'buffered',
|
|
43
|
+
...(native.messageTs ? { messageId: formatSlackMessageRef(request.conversation.id, native.messageTs) } : {}),
|
|
44
|
+
acceptedText,
|
|
45
|
+
...(exhausted ? { exhausted: true } : {}),
|
|
46
|
+
});
|
|
47
|
+
const write = async (text: string) => {
|
|
48
|
+
if (cancelled) throw new EndpointDeliveryError('stream_cancelled', 'Slack stream was cancelled', 'not_sent');
|
|
49
|
+
if (finished) return acknowledge();
|
|
50
|
+
if (!text.startsWith(acceptedText)) throw new EndpointDeliveryError('stream_rewrite', 'A confirmed reply prefix cannot change', 'not_sent');
|
|
51
|
+
if (native.finalizedByPlatform) { exhausted = true; return acknowledge(); }
|
|
52
|
+
let next = text;
|
|
53
|
+
if (wire.text.length + next.length > 12_000) {
|
|
54
|
+
exhausted = true;
|
|
55
|
+
if (request.format === 'markdown') {
|
|
56
|
+
if (!acceptedText) throw new EndpointDeliveryError('stream_capacity', 'Slack Markdown has no confirmed boundary within its container capacity', 'not_sent');
|
|
57
|
+
return acknowledge();
|
|
58
|
+
}
|
|
59
|
+
next = next.slice(0, Math.max(0, 12_000 - wire.text.length));
|
|
60
|
+
if (/[\uD800-\uDBFF]$/.test(next)) next = next.slice(0, -1);
|
|
61
|
+
}
|
|
62
|
+
if (next === acceptedText) return acknowledge();
|
|
63
|
+
const delta = (native.messageTs ? '' : wire.text) + next.slice(acceptedText.length);
|
|
64
|
+
// Fixed chunks mode allows complete rich blocks without switching Slack's wire mode.
|
|
65
|
+
let ts: string;
|
|
66
|
+
try { ts = await native.append({ chunks: [{ type: 'markdown_text', text: delta }] }); }
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (!native.finalizedByPlatform) throw error;
|
|
69
|
+
exhausted = true;
|
|
70
|
+
return acknowledge();
|
|
71
|
+
}
|
|
72
|
+
acceptedText = next; onMessage(ts);
|
|
73
|
+
return acknowledge();
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
get lastAcknowledgement() { return lastAcknowledgement; },
|
|
77
|
+
write,
|
|
78
|
+
async finish(text) {
|
|
79
|
+
if (finished) return acknowledge();
|
|
80
|
+
await write(text);
|
|
81
|
+
if (cancelled) throw new EndpointDeliveryError('stream_cancelled', 'Slack stream was cancelled', 'not_sent');
|
|
82
|
+
await native.stop(); finished = true;
|
|
83
|
+
if (native.finalizedByPlatform) exhausted = true;
|
|
84
|
+
return acknowledge();
|
|
85
|
+
},
|
|
86
|
+
cancel() { cancelled = true; },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createEndpointOperation, type EndpointProfile } from 'zhin.js/adapter';
|
|
2
|
+
import type { WebClient } from '@slack/web-api';
|
|
3
|
+
|
|
4
|
+
interface SlackSelfProfileOptions {
|
|
5
|
+
readonly client: Pick<WebClient, 'users'>;
|
|
6
|
+
readonly accountId: () => string | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Bot-token reads use users.info. User-token-only profile mutations are not advertised. */
|
|
10
|
+
export function createSlackSelfProfile({ client, accountId }: SlackSelfProfileOptions): EndpointProfile {
|
|
11
|
+
return Object.freeze<EndpointProfile>({
|
|
12
|
+
writableFields: Object.freeze([]),
|
|
13
|
+
async read({ signal }) {
|
|
14
|
+
const id = accountId();
|
|
15
|
+
if (!id) throw new Error('Slack self account is unavailable');
|
|
16
|
+
const response = await createEndpointOperation({ signal }).prepare(() => client.users.info({ user: id }));
|
|
17
|
+
if (!response.ok || response.user?.id !== id) throw new Error('Slack returned no matching self profile');
|
|
18
|
+
const profile = response.user.profile;
|
|
19
|
+
const nickname = profile?.display_name || profile?.real_name || response.user.real_name || response.user.name;
|
|
20
|
+
return { ...(typeof nickname === 'string' ? { nickname } : {}),
|
|
21
|
+
...(typeof profile?.image_512 === 'string' ? { avatar: profile.image_512 } : {}) };
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|