@zhin.js/adapter-lark 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 -1
- package/lib/client.d.ts +1 -0
- package/lib/client.js +8 -0
- package/lib/console-spec.js +2 -0
- package/lib/endpoint.js +3 -0
- package/lib/i18n.d.ts +1 -1
- package/lib/protocol.d.ts +7 -1
- package/lib/protocol.js +6 -0
- package/lib/reply-stream.d.ts +12 -0
- package/lib/reply-stream.js +142 -0
- package/lib/self-profile.d.ts +9 -0
- package/lib/self-profile.js +15 -0
- package/package.json +20 -20
- package/schema.json +126 -1
- package/src/adapter.ts +1 -1
- package/src/client.ts +9 -1
- package/src/console-spec.ts +2 -0
- package/src/endpoint.ts +3 -0
- package/src/protocol.ts +11 -1
- package/src/reply-stream.ts +130 -0
- package/src/self-profile.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# @zhin.js/adapter-lark
|
|
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
|
@@ -11,6 +11,18 @@ Zhin.js 飞书 / Lark 适配器(Plugin Runtime),通过 Runtime Host HTTP W
|
|
|
11
11
|
- canonical `markdown` 段编码为 `interactive` JSON 2.0 卡片中的 `markdown`
|
|
12
12
|
- 约定式 `defineAdapter` / `definePlugin`(无需 `usePlugin`)
|
|
13
13
|
|
|
14
|
+
## 渐进式回复
|
|
15
|
+
|
|
16
|
+
`streaming` 默认 `true`,可在插件或单个 endpoint 配置中设为 `false`,恢复普通完整发送。正文和 Markdown 使用 CardKit JSON 2.0 流式卡片:先创建实体并发送卡片引用,随后以累计正文更新同一组件,结束时关闭 `streaming_mode`。内容更新与收尾共用严格递增的 `sequence`。
|
|
17
|
+
|
|
18
|
+
需要应用具有 CardKit 创建与更新卡片及当前会话发送权限。引用目标和 @ 前缀保持不变;资源创建成功不等于聊天消息已经送达,消息发送或更新结果未知时不重新创建或发布。媒体和按钮继续经统一发送链按原始顺序处理。
|
|
19
|
+
|
|
20
|
+
`streamingIntervalMs` 默认 `1000` 毫秒,用于后续回复更新间隔;首段立即发送,中间更新由统一回复控制器合并。
|
|
21
|
+
|
|
22
|
+
平台明确拒绝已有卡片的更新并报告流式超时或容量超限时,纯文字回复保留已确认正文,再用新卡片继续未发送部分。首条内容自身超限时明确报错,不重复创建或截断原文。卡片流式模式在开启 10 分钟后自动关闭,详见[流式更新卡片](https://open.feishu.cn/document/cardkit-v1/streaming-updates-openapi-overview)。
|
|
23
|
+
|
|
24
|
+
纯文字可以在已确认的容量或时限边界续条。Markdown 保留单容器内的流式发送;已确认的 Markdown 卡片需要续容器时,无法恢复其渲染状态,会停止并保留已确认部分,不降级为纯文本。关闭流式模式失败时,正文的确认记录会保留,关闭结果仍明确记录为失败或未知。
|
|
25
|
+
|
|
14
26
|
## 安装
|
|
15
27
|
|
|
16
28
|
```bash
|
package/lib/adapter.js
CHANGED
|
@@ -7,7 +7,7 @@ export class LarkAdapter extends Adapter {
|
|
|
7
7
|
#context;
|
|
8
8
|
constructor(context) { super(); this.#context = context; }
|
|
9
9
|
get configurationIdentityFields() { return ['id', 'appId', 'isFeishu']; }
|
|
10
|
-
get configurationUpdateFields() { return ['appSecret', 'encryptKey', 'verificationToken', 'mode', 'webhookPath', 'apiBaseUrl', 'webApiProxy', 'streamProxy']; }
|
|
10
|
+
get configurationUpdateFields() { return ['appSecret', 'encryptKey', 'verificationToken', 'mode', 'webhookPath', 'apiBaseUrl', 'webApiProxy', 'streamProxy', 'streaming', 'streamingIntervalMs']; }
|
|
11
11
|
get configurationIdentityProbeFields() { return ['appSecret', 'apiBaseUrl']; }
|
|
12
12
|
async identifyConfiguration(input, signal, _changed) {
|
|
13
13
|
const config = resolveLarkConfig(input), client = new LarkClient({ id: this.descriptor.id, config, signal });
|
package/lib/client.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export declare class LarkClient extends SdkClient {
|
|
|
53
53
|
*/
|
|
54
54
|
protected readonly endpointManagement: EndpointManagement;
|
|
55
55
|
protected readonly endpointControl: EndpointControl;
|
|
56
|
+
protected get endpointStreaming(): import('zhin.js/adapter').EndpointStreaming | undefined;
|
|
56
57
|
protected configName(): string;
|
|
57
58
|
/** Native protocol work runs here; Endpoint attaches the framework receipt without replacing SDK APIs. */
|
|
58
59
|
protected receiveInboundCallback<T>(receive: () => T | Promise<T>): Promise<T>;
|
package/lib/client.js
CHANGED
|
@@ -14,6 +14,7 @@ import { formatInboundContent, formatOutboundBody, generateMessageId, larkInboun
|
|
|
14
14
|
import { normalizeLarkSenderForPermit } from './platform-permit.js';
|
|
15
15
|
import { registerLarkWebhookRoutes } from './webhook.js';
|
|
16
16
|
import { createLarkLongConnection } from './long-connection.js';
|
|
17
|
+
import { openLarkReplyStream, supportsLarkReplyStream } from './reply-stream.js';
|
|
17
18
|
/** 出站 HTTP 调用统一 30s 超时。 */
|
|
18
19
|
const OUTBOUND_TIMEOUT_MS = 30_000;
|
|
19
20
|
/** Feishu/Lark OpenAPI client surface carried by every Endpoint event. */
|
|
@@ -48,6 +49,13 @@ export class LarkClient extends SdkClient {
|
|
|
48
49
|
listGroupMembers: ({ groupId, signal }) => this.#listAllPages(`/im/v1/chats/${encodeURIComponent(groupId)}/members`, { params: { member_id_type: 'open_id' }, signal }),
|
|
49
50
|
});
|
|
50
51
|
endpointControl = createRecallEndpointControl((id, signal) => this.recallMessage(id, { signal }));
|
|
52
|
+
get endpointStreaming() {
|
|
53
|
+
return this.config.streaming !== false ? { mode: 'card',
|
|
54
|
+
minIntervalMs: this.config.streamingIntervalMs ?? 1000,
|
|
55
|
+
supports: supportsLarkReplyStream,
|
|
56
|
+
open: request => openLarkReplyStream(request, (path, options) => this.#request(path, options)),
|
|
57
|
+
} : undefined;
|
|
58
|
+
}
|
|
51
59
|
#routeReleases = [];
|
|
52
60
|
#open = false;
|
|
53
61
|
#started = false;
|
package/lib/console-spec.js
CHANGED
|
@@ -6,6 +6,8 @@ export const larkConsoleSpec = {
|
|
|
6
6
|
{ key: 'appSecret', required: true, env: true, description: '飞书 App Secret', descriptionI18n: { en: 'Lark app secret' } },
|
|
7
7
|
{ key: 'encryptKey', env: true, description: '事件加密 key', descriptionI18n: { en: 'Event encryption key' } },
|
|
8
8
|
{ key: 'verificationToken', env: true, description: '事件验证 token', descriptionI18n: { en: 'Event verification token' } },
|
|
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
|
],
|
|
10
12
|
docsUrl: 'https://zhin.js.org/adapters/lark',
|
|
11
13
|
readPermission: 'lark:read',
|
package/lib/endpoint.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createEndpointSpecification, endpointSpec } from 'zhin.js/adapter';
|
|
2
2
|
import { LarkClient } from './client.js';
|
|
3
|
+
import { createLarkSelfProfile } from './self-profile.js';
|
|
3
4
|
/** Actual protocol Client; framework lifecycle and admission stay in their symbol namespace. */
|
|
4
5
|
export class LarkEndpoint extends LarkClient {
|
|
5
6
|
[endpointSpec] = this.#createSpecification();
|
|
@@ -12,6 +13,8 @@ export class LarkEndpoint extends LarkClient {
|
|
|
12
13
|
name: this.configName(),
|
|
13
14
|
control: this.endpointControl,
|
|
14
15
|
management: this.endpointManagement,
|
|
16
|
+
streaming: this.endpointStreaming,
|
|
17
|
+
profile: createLarkSelfProfile({ client: this, reportAccount: id => this[endpointSpec].reportSelfUserId(id) }),
|
|
15
18
|
};
|
|
16
19
|
Object.defineProperty(hooks, 'transportState', { get: () => this.transportState });
|
|
17
20
|
return createEndpointSpecification(this, hooks);
|
package/lib/i18n.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const messages: (locale: import("zhin.js").Locale | undefined, key: "
|
|
1
|
+
export declare const messages: (locale: import("zhin.js").Locale | undefined, key: "link" | "openLink" | "message", values?: import("zhin.js").MessageValues) => string;
|
package/lib/protocol.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
|
|
1
2
|
import type { Locale } from 'zhin.js/i18n';
|
|
2
3
|
import type { IncomingMessage } from 'node:http';
|
|
3
4
|
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
4
5
|
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
5
|
-
export interface LarkEndpointConfig {
|
|
6
|
+
export interface LarkEndpointConfig extends EndpointProfileConfiguration {
|
|
7
|
+
/** Native streaming cards are enabled unless explicitly disabled. */
|
|
8
|
+
readonly streaming?: boolean;
|
|
9
|
+
readonly streamingIntervalMs?: number;
|
|
6
10
|
readonly webApiProxy?: import('./web-api-proxy.js').LarkWebApiProxy;
|
|
7
11
|
readonly streamProxy?: import('./stream-proxy.js').LarkStreamProxy;
|
|
8
12
|
readonly id: string;
|
|
@@ -16,6 +20,8 @@ export interface LarkEndpointConfig {
|
|
|
16
20
|
readonly isFeishu?: boolean;
|
|
17
21
|
}
|
|
18
22
|
export interface ResolvedLarkConfig {
|
|
23
|
+
readonly streaming?: boolean;
|
|
24
|
+
readonly streamingIntervalMs?: number;
|
|
19
25
|
readonly webApiProxy?: import('./web-api-proxy.js').LarkWebApiProxy;
|
|
20
26
|
readonly streamProxy?: import('./stream-proxy.js').LarkStreamProxy;
|
|
21
27
|
readonly context: 'lark';
|
package/lib/protocol.js
CHANGED
|
@@ -9,6 +9,10 @@ import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
|
9
9
|
import { formatLarkCard } from './cards.js';
|
|
10
10
|
const logger = getLogger('lark');
|
|
11
11
|
export function resolveLarkConfig(config) {
|
|
12
|
+
if (config.streaming !== undefined && typeof config.streaming !== 'boolean')
|
|
13
|
+
throw new TypeError('Lark streaming must be a boolean');
|
|
14
|
+
if (config.streamingIntervalMs !== undefined && (!Number.isSafeInteger(config.streamingIntervalMs) || config.streamingIntervalMs < 0))
|
|
15
|
+
throw new TypeError('Lark streamingIntervalMs must be a non-negative integer');
|
|
12
16
|
if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
|
|
13
17
|
|| typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName)))
|
|
14
18
|
throw new TypeError('Invalid Lark streamProxy port/serverName');
|
|
@@ -34,6 +38,8 @@ export function resolveLarkConfig(config) {
|
|
|
34
38
|
...(config.webApiProxy ? { webApiProxy: config.webApiProxy } : {}),
|
|
35
39
|
...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
|
|
36
40
|
context: 'lark',
|
|
41
|
+
streaming: config.streaming !== false,
|
|
42
|
+
streamingIntervalMs: config.streamingIntervalMs ?? 1000,
|
|
37
43
|
mode,
|
|
38
44
|
id,
|
|
39
45
|
appId,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EndpointMessageStream, EndpointStreamRequest } from 'zhin.js/adapter';
|
|
2
|
+
import type { LarkApiResponse } from './protocol.js';
|
|
3
|
+
export interface LarkStreamRequestOptions {
|
|
4
|
+
readonly method: 'POST' | 'PUT' | 'PATCH';
|
|
5
|
+
readonly body: Record<string, unknown>;
|
|
6
|
+
readonly signal?: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
type Request = (path: string, options: LarkStreamRequestOptions) => Promise<LarkApiResponse>;
|
|
9
|
+
export declare function supportsLarkReplyStream(request: Pick<EndpointStreamRequest, 'conversation' | 'source'>): boolean;
|
|
10
|
+
/** CardKit text updates use cumulative content and one sequence across content/settings. */
|
|
11
|
+
export declare function openLarkReplyStream(request: EndpointStreamRequest, send: Request): EndpointMessageStream | undefined;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
|
+
export function supportsLarkReplyStream(request) {
|
|
3
|
+
return !request.conversation.threadId && !request.conversation.parent;
|
|
4
|
+
}
|
|
5
|
+
/** CardKit text updates use cumulative content and one sequence across content/settings. */
|
|
6
|
+
export function openLarkReplyStream(request, send) {
|
|
7
|
+
// CardKit has no verified way to restore a previous card's Markdown renderer state.
|
|
8
|
+
if (request.format === 'markdown' && request.continuation)
|
|
9
|
+
return undefined;
|
|
10
|
+
const prefix = request.prefix ?? [];
|
|
11
|
+
if (prefix.some(segment => segment.type !== 'reply' && segment.type !== 'mention')
|
|
12
|
+
|| !supportsLarkReplyStream(request))
|
|
13
|
+
return undefined;
|
|
14
|
+
const reply = prefix.find(segment => segment.type === 'reply');
|
|
15
|
+
const replyTo = (reply && 'message_id' in reply.data ? reply.data.message_id : undefined) ?? request.continuation?.replyTo;
|
|
16
|
+
const mentions = prefix.filter(segment => segment.type === 'mention').map(segment => {
|
|
17
|
+
const target = String('target' in segment.data ? segment.data.target : '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
18
|
+
return `<at id="${target}"></at>`;
|
|
19
|
+
}).join('');
|
|
20
|
+
let cardId;
|
|
21
|
+
let messageId;
|
|
22
|
+
let sequence = 0;
|
|
23
|
+
let acceptedText = '';
|
|
24
|
+
let cancelled = false;
|
|
25
|
+
let finished = false;
|
|
26
|
+
let exhausted = false;
|
|
27
|
+
let platformClosed = false;
|
|
28
|
+
let failure;
|
|
29
|
+
let lastAcknowledgement;
|
|
30
|
+
const acknowledge = () => lastAcknowledgement = Object.freeze({ status: messageId ? 'sent' : 'buffered',
|
|
31
|
+
...(messageId ? { messageId } : {}), acceptedText, ...(exhausted ? { exhausted: true } : {}) });
|
|
32
|
+
const bodyText = (text) => request.format === 'markdown' ? text : text.replace(/[\\`*_{}[\]()#+.!|>~-]/g, '\\$&');
|
|
33
|
+
const assertActive = () => {
|
|
34
|
+
if (cancelled || request.signal?.aborted)
|
|
35
|
+
throw new EndpointDeliveryError('stream_cancelled', 'Lark stream was cancelled before submission', 'not_sent');
|
|
36
|
+
};
|
|
37
|
+
const call = (path, method, body) => {
|
|
38
|
+
assertActive();
|
|
39
|
+
return send(path, { method, body, signal: request.signal });
|
|
40
|
+
};
|
|
41
|
+
const write = async (text) => {
|
|
42
|
+
if (failure)
|
|
43
|
+
throw failure;
|
|
44
|
+
assertActive();
|
|
45
|
+
if (finished)
|
|
46
|
+
return acknowledge();
|
|
47
|
+
if (!text.startsWith(acceptedText))
|
|
48
|
+
throw new EndpointDeliveryError('stream_rewrite', 'A confirmed reply prefix cannot change', 'not_sent');
|
|
49
|
+
if (exhausted)
|
|
50
|
+
return acknowledge();
|
|
51
|
+
if (text === acceptedText)
|
|
52
|
+
return acknowledge();
|
|
53
|
+
if (!cardId) {
|
|
54
|
+
try {
|
|
55
|
+
const card = await call('/cardkit/v1/cards', 'POST', { type: 'card_json', data: JSON.stringify({
|
|
56
|
+
schema: '2.0', config: { streaming_mode: true, update_multi: true },
|
|
57
|
+
body: { elements: [
|
|
58
|
+
...(mentions ? [{ tag: 'markdown', element_id: 'zhin_prefix', content: mentions }] : []),
|
|
59
|
+
{ tag: 'markdown', element_id: 'zhin_reply', content: bodyText(text) },
|
|
60
|
+
] },
|
|
61
|
+
}) });
|
|
62
|
+
cardId = typeof card.data?.card_id === 'string' && card.data.card_id ? card.data.card_id : undefined;
|
|
63
|
+
if (!cardId)
|
|
64
|
+
throw new Error('CardKit returned no card ID');
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
// A card resource may exist, but no chat-message request has been attempted.
|
|
68
|
+
failure = new EndpointDeliveryError('stream_resource_unavailable', 'Lark streaming card could not be prepared', 'not_sent', { cause: error });
|
|
69
|
+
throw failure;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
if (!messageId) {
|
|
74
|
+
const message = await call(typeof replyTo === 'string' && replyTo
|
|
75
|
+
? `/im/v1/messages/${encodeURIComponent(replyTo)}/reply` : '/im/v1/messages?receive_id_type=chat_id', 'POST', {
|
|
76
|
+
...(replyTo ? {} : { receive_id: request.conversation.id }),
|
|
77
|
+
msg_type: 'interactive', content: JSON.stringify({ type: 'card', data: { card_id: cardId } }),
|
|
78
|
+
});
|
|
79
|
+
if (typeof message.data?.message_id !== 'string' || !message.data.message_id.trim()) {
|
|
80
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Lark stream returned no message ID', 'unknown');
|
|
81
|
+
}
|
|
82
|
+
messageId = message.data.message_id;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
await call(`/cardkit/v1/cards/${encodeURIComponent(cardId)}/elements/zhin_reply/content`, 'PUT', {
|
|
86
|
+
content: bodyText(text), sequence: ++sequence,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
acceptedText = text;
|
|
90
|
+
return acknowledge();
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
const code = rejectedPlatformCode(error);
|
|
94
|
+
if (messageId && acceptedText && (code === 200850 || code === 200860)) {
|
|
95
|
+
// The rejected update contributed no source. Timeout explicitly closes the
|
|
96
|
+
// prior card; capacity requires closing only its last confirmed contents.
|
|
97
|
+
exhausted = true;
|
|
98
|
+
platformClosed = code === 200850;
|
|
99
|
+
return acknowledge();
|
|
100
|
+
}
|
|
101
|
+
failure = streamFailure(error);
|
|
102
|
+
throw failure;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
get lastAcknowledgement() { return lastAcknowledgement; },
|
|
107
|
+
write,
|
|
108
|
+
async finish(text) {
|
|
109
|
+
if (finished)
|
|
110
|
+
return acknowledge();
|
|
111
|
+
await write(text);
|
|
112
|
+
if (cardId && messageId && !platformClosed) {
|
|
113
|
+
try {
|
|
114
|
+
await call(`/cardkit/v1/cards/${encodeURIComponent(cardId)}/settings`, 'PATCH', {
|
|
115
|
+
settings: JSON.stringify({ config: { streaming_mode: false } }), sequence: ++sequence,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (rejectedPlatformCode(error) === 200850) {
|
|
120
|
+
platformClosed = true;
|
|
121
|
+
exhausted = true;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
failure = streamFailure(error);
|
|
125
|
+
throw failure;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
finished = true;
|
|
130
|
+
return acknowledge();
|
|
131
|
+
},
|
|
132
|
+
cancel() { cancelled = true; },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function rejectedPlatformCode(error) {
|
|
136
|
+
return error instanceof EndpointDeliveryError && error.disposition === 'rejected' && 'platformCode' in error
|
|
137
|
+
&& typeof error.platformCode === 'number' ? error.platformCode : undefined;
|
|
138
|
+
}
|
|
139
|
+
function streamFailure(error) {
|
|
140
|
+
return error instanceof EndpointDeliveryError ? error
|
|
141
|
+
: new EndpointDeliveryError('delivery_unconfirmed', 'Lark stream update outcome is unknown', 'unknown', { cause: error });
|
|
142
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type EndpointProfile } from 'zhin.js/adapter';
|
|
2
|
+
import type { LarkClient } from './client.js';
|
|
3
|
+
interface LarkSelfProfileOptions {
|
|
4
|
+
readonly client: Pick<LarkClient, 'request' | 'config'>;
|
|
5
|
+
readonly reportAccount: (id: string) => void;
|
|
6
|
+
}
|
|
7
|
+
/** Same bot-info endpoint used for read-only credential identity verification. */
|
|
8
|
+
export declare function createLarkSelfProfile({ client, reportAccount }: LarkSelfProfileOptions): EndpointProfile;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createEndpointOperation } from 'zhin.js/adapter';
|
|
2
|
+
/** Same bot-info endpoint used for read-only credential identity verification. */
|
|
3
|
+
export function createLarkSelfProfile({ client, reportAccount }) {
|
|
4
|
+
return Object.freeze({
|
|
5
|
+
writableFields: Object.freeze([]),
|
|
6
|
+
async read({ signal }) {
|
|
7
|
+
const response = await createEndpointOperation({ signal }).prepare(() => client.request({ url: `${client.config.apiBaseUrl}/bot/v3/info`, method: 'GET' }));
|
|
8
|
+
if (response.code !== 0 || !response.bot?.open_id)
|
|
9
|
+
throw new Error('Lark returned no bot profile');
|
|
10
|
+
reportAccount(response.bot.open_id);
|
|
11
|
+
return { ...(typeof response.bot.app_name === 'string' ? { nickname: response.bot.app_name } : {}),
|
|
12
|
+
...(typeof response.bot.avatar_url === 'string' ? { avatar: response.bot.avatar_url } : {}) };
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-lark",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.21",
|
|
4
4
|
"description": "Zhin.js Lark/Feishu adapter (HTTP webhook and WebSocket long connection)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -35,27 +35,27 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@larksuiteoapi/node-sdk": "^1.67.0",
|
|
37
37
|
"undici": "6.29.1",
|
|
38
|
-
"@zhin.js/adapter": "1.1.
|
|
39
|
-
"@zhin.js/agent-feature": "1.1.
|
|
40
|
-
"@zhin.js/client": "1.1.
|
|
38
|
+
"@zhin.js/adapter": "1.1.27",
|
|
39
|
+
"@zhin.js/agent-feature": "1.1.12",
|
|
40
|
+
"@zhin.js/client": "1.1.23",
|
|
41
41
|
"@zhin.js/console-contract": "1.1.3",
|
|
42
|
-
"@zhin.js/core": "1.1.
|
|
43
|
-
"@zhin.js/feature-kit": "1.1.
|
|
44
|
-
"@zhin.js/host-http": "1.1.
|
|
45
|
-
"@zhin.js/im-contract": "1.1.
|
|
42
|
+
"@zhin.js/core": "1.1.55",
|
|
43
|
+
"@zhin.js/feature-kit": "1.1.11",
|
|
44
|
+
"@zhin.js/host-http": "1.1.13",
|
|
45
|
+
"@zhin.js/im-contract": "1.1.10",
|
|
46
46
|
"@zhin.js/logger": "1.1.2",
|
|
47
|
-
"@zhin.js/page": "1.1.
|
|
48
|
-
"@zhin.js/skill": "1.1.
|
|
49
|
-
"@zhin.js/tool": "1.1.
|
|
47
|
+
"@zhin.js/page": "1.1.13",
|
|
48
|
+
"@zhin.js/skill": "1.1.12",
|
|
49
|
+
"@zhin.js/tool": "1.1.14"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"zod": "^4.0.0",
|
|
53
|
-
"@zhin.js/adapter": "^1.1.
|
|
54
|
-
"@zhin.js/command": "^1.1.
|
|
55
|
-
"@zhin.js/core": "^1.1.
|
|
56
|
-
"@zhin.js/host-http": "^1.1.
|
|
57
|
-
"@zhin.js/permission": "^1.1.
|
|
58
|
-
"zhin.js": "^1.1.
|
|
53
|
+
"@zhin.js/adapter": "^1.1.27",
|
|
54
|
+
"@zhin.js/command": "^1.1.13",
|
|
55
|
+
"@zhin.js/core": "^1.1.55",
|
|
56
|
+
"@zhin.js/host-http": "^1.1.13",
|
|
57
|
+
"@zhin.js/permission": "^1.1.11",
|
|
58
|
+
"zhin.js": "^1.1.20"
|
|
59
59
|
},
|
|
60
60
|
"peerDependenciesMeta": {
|
|
61
61
|
"@zhin.js/command": {
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"typescript": "^6.0.3",
|
|
74
74
|
"vitest": "^4.1.10",
|
|
75
75
|
"zod": "^4.4.3",
|
|
76
|
-
"zhin.js": "1.1.
|
|
76
|
+
"zhin.js": "1.1.20"
|
|
77
77
|
},
|
|
78
78
|
"files": [
|
|
79
79
|
"adapters",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"features": [
|
|
106
106
|
{
|
|
107
107
|
"package": "@zhin.js/adapter",
|
|
108
|
-
"api": "^2.
|
|
108
|
+
"api": "^2.1.0"
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
"package": "@zhin.js/command",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"package": "@zhin.js/tool",
|
|
116
|
-
"api": "^1.
|
|
116
|
+
"api": "^1.1.0"
|
|
117
117
|
},
|
|
118
118
|
{
|
|
119
119
|
"package": "@zhin.js/skill",
|
package/schema.json
CHANGED
|
@@ -163,6 +163,36 @@
|
|
|
163
163
|
"description": "Disabled by default. Loopback TLS fault proxy for Feishu outbound APIs only; preserves the official Host, SNI and certificate validation. Independent of streamProxy."
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
|
+
},
|
|
167
|
+
"streaming": {
|
|
168
|
+
"type": "boolean",
|
|
169
|
+
"description": "Use native streaming cards for progressive replies."
|
|
170
|
+
},
|
|
171
|
+
"streamingIntervalMs": {
|
|
172
|
+
"type": "integer",
|
|
173
|
+
"minimum": 0,
|
|
174
|
+
"description": "Reply stream update interval in milliseconds."
|
|
175
|
+
},
|
|
176
|
+
"nickname": {
|
|
177
|
+
"type": [
|
|
178
|
+
"string",
|
|
179
|
+
"null"
|
|
180
|
+
],
|
|
181
|
+
"description": "账号昵称兜底",
|
|
182
|
+
"x-i18n": {
|
|
183
|
+
"en": {
|
|
184
|
+
"description": "Fallback account nickname"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"profile": {
|
|
189
|
+
"$ref": "#/$defs/selfProfileFallback",
|
|
190
|
+
"description": "账号资料兜底",
|
|
191
|
+
"x-i18n": {
|
|
192
|
+
"en": {
|
|
193
|
+
"description": "Fallback account profile"
|
|
194
|
+
}
|
|
195
|
+
}
|
|
166
196
|
}
|
|
167
197
|
},
|
|
168
198
|
"required": [
|
|
@@ -214,9 +244,104 @@
|
|
|
214
244
|
"description": "Disabled by default. Loopback TLS fault proxy for Feishu outbound APIs only; preserves the official Host, SNI and certificate validation. Independent of streamProxy."
|
|
215
245
|
}
|
|
216
246
|
}
|
|
247
|
+
},
|
|
248
|
+
"streaming": {
|
|
249
|
+
"type": "boolean",
|
|
250
|
+
"default": true,
|
|
251
|
+
"description": "Use native streaming cards for progressive replies."
|
|
252
|
+
},
|
|
253
|
+
"streamingIntervalMs": {
|
|
254
|
+
"type": "integer",
|
|
255
|
+
"minimum": 0,
|
|
256
|
+
"description": "Reply stream update interval in milliseconds.",
|
|
257
|
+
"default": 1000
|
|
258
|
+
},
|
|
259
|
+
"nickname": {
|
|
260
|
+
"type": [
|
|
261
|
+
"string",
|
|
262
|
+
"null"
|
|
263
|
+
],
|
|
264
|
+
"description": "账号昵称兜底",
|
|
265
|
+
"x-i18n": {
|
|
266
|
+
"en": {
|
|
267
|
+
"description": "Fallback account nickname"
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
"profile": {
|
|
272
|
+
"$ref": "#/$defs/selfProfileFallback",
|
|
273
|
+
"description": "账号资料兜底",
|
|
274
|
+
"x-i18n": {
|
|
275
|
+
"en": {
|
|
276
|
+
"description": "Fallback account profile"
|
|
277
|
+
}
|
|
278
|
+
}
|
|
217
279
|
}
|
|
218
280
|
},
|
|
219
281
|
"required": [
|
|
220
282
|
"endpoints"
|
|
221
|
-
]
|
|
283
|
+
],
|
|
284
|
+
"$defs": {
|
|
285
|
+
"selfProfileFallback": {
|
|
286
|
+
"type": "object",
|
|
287
|
+
"additionalProperties": false,
|
|
288
|
+
"properties": {
|
|
289
|
+
"gender": {
|
|
290
|
+
"type": [
|
|
291
|
+
"string",
|
|
292
|
+
"null"
|
|
293
|
+
],
|
|
294
|
+
"enum": [
|
|
295
|
+
"male",
|
|
296
|
+
"female",
|
|
297
|
+
"other",
|
|
298
|
+
"unknown",
|
|
299
|
+
null
|
|
300
|
+
]
|
|
301
|
+
},
|
|
302
|
+
"age": {
|
|
303
|
+
"type": [
|
|
304
|
+
"integer",
|
|
305
|
+
"null"
|
|
306
|
+
],
|
|
307
|
+
"minimum": 0
|
|
308
|
+
},
|
|
309
|
+
"birthday": {
|
|
310
|
+
"type": [
|
|
311
|
+
"string",
|
|
312
|
+
"null"
|
|
313
|
+
],
|
|
314
|
+
"pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$"
|
|
315
|
+
},
|
|
316
|
+
"signature": {
|
|
317
|
+
"type": [
|
|
318
|
+
"string",
|
|
319
|
+
"null"
|
|
320
|
+
]
|
|
321
|
+
},
|
|
322
|
+
"avatar": {
|
|
323
|
+
"type": [
|
|
324
|
+
"string",
|
|
325
|
+
"null"
|
|
326
|
+
],
|
|
327
|
+
"pattern": "^(https?://[^\\s/?#]+(?:[/?#].*)?|)$"
|
|
328
|
+
},
|
|
329
|
+
"interests": {
|
|
330
|
+
"type": [
|
|
331
|
+
"array",
|
|
332
|
+
"null"
|
|
333
|
+
],
|
|
334
|
+
"items": {
|
|
335
|
+
"type": "string"
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
"bio": {
|
|
339
|
+
"type": [
|
|
340
|
+
"string",
|
|
341
|
+
"null"
|
|
342
|
+
]
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
222
347
|
}
|
package/src/adapter.ts
CHANGED
|
@@ -8,7 +8,7 @@ export class LarkAdapter extends Adapter<LarkEndpoint, LarkEndpointConfig, LarkC
|
|
|
8
8
|
readonly #context: AdapterContext<LarkEndpointConfig>;
|
|
9
9
|
constructor(context: AdapterContext<LarkEndpointConfig>) { super(); this.#context = context; }
|
|
10
10
|
override get configurationIdentityFields(): readonly string[] { return ['id', 'appId', 'isFeishu']; }
|
|
11
|
-
override get configurationUpdateFields(): readonly string[] { return ['appSecret', 'encryptKey', 'verificationToken', 'mode', 'webhookPath', 'apiBaseUrl', 'webApiProxy', 'streamProxy']; }
|
|
11
|
+
override get configurationUpdateFields(): readonly string[] { return ['appSecret', 'encryptKey', 'verificationToken', 'mode', 'webhookPath', 'apiBaseUrl', 'webApiProxy', 'streamProxy', 'streaming', 'streamingIntervalMs']; }
|
|
12
12
|
override get configurationIdentityProbeFields(): readonly string[] { return ['appSecret', 'apiBaseUrl']; }
|
|
13
13
|
protected override async identifyConfiguration(input: LarkEndpointConfig, signal: AbortSignal, _changed: readonly string[]): Promise<string | undefined> {
|
|
14
14
|
const config = resolveLarkConfig(input), client = new LarkClient({ id: this.descriptor.id, config, signal });
|
package/src/client.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
import { normalizeLarkSenderForPermit } from './platform-permit.js';
|
|
44
44
|
import { registerLarkWebhookRoutes } from './webhook.js';
|
|
45
45
|
import { createLarkLongConnection, type LarkLongConnectionFactory } from './long-connection.js';
|
|
46
|
+
import { openLarkReplyStream, supportsLarkReplyStream } from './reply-stream.js';
|
|
46
47
|
|
|
47
48
|
/** 出站 HTTP 调用统一 30s 超时。 */
|
|
48
49
|
const OUTBOUND_TIMEOUT_MS = 30_000;
|
|
@@ -123,6 +124,13 @@ export class LarkClient extends SdkClient {
|
|
|
123
124
|
),
|
|
124
125
|
});
|
|
125
126
|
protected readonly endpointControl: EndpointControl = createRecallEndpointControl((id, signal) => this.recallMessage(id, { signal }));
|
|
127
|
+
protected get endpointStreaming(): import('zhin.js/adapter').EndpointStreaming | undefined {
|
|
128
|
+
return this.config.streaming !== false ? { mode: 'card',
|
|
129
|
+
minIntervalMs: this.config.streamingIntervalMs ?? 1000,
|
|
130
|
+
supports: supportsLarkReplyStream,
|
|
131
|
+
open: request => openLarkReplyStream(request, (path, options) => this.#request(path, options)),
|
|
132
|
+
} : undefined;
|
|
133
|
+
}
|
|
126
134
|
#routeReleases: HttpRouteRegistration[] = [];
|
|
127
135
|
|
|
128
136
|
#open = false;
|
|
@@ -665,7 +673,7 @@ export class LarkClient extends SdkClient {
|
|
|
665
673
|
async #request(
|
|
666
674
|
path: string,
|
|
667
675
|
options: {
|
|
668
|
-
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
676
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
669
677
|
params?: Record<string, string | number>;
|
|
670
678
|
body?: Record<string, unknown>;
|
|
671
679
|
signal?: AbortSignal;
|
package/src/console-spec.ts
CHANGED
|
@@ -11,6 +11,8 @@ export const larkConsoleSpec = {
|
|
|
11
11
|
{ key: 'appSecret', required: true, env: true, description: '飞书 App Secret', descriptionI18n: { en: 'Lark app secret' } },
|
|
12
12
|
{ key: 'encryptKey', env: true, description: '事件加密 key', descriptionI18n: { en: 'Event encryption key' } },
|
|
13
13
|
{ key: 'verificationToken', env: true, description: '事件验证 token', descriptionI18n: { en: 'Event verification token' } },
|
|
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
|
] as readonly AdapterConsoleField[],
|
|
15
17
|
docsUrl: 'https://zhin.js.org/adapters/lark',
|
|
16
18
|
readPermission: 'lark:read',
|
package/src/endpoint.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createEndpointSpecification, endpointSpec, type Endpoint } from 'zhin.js/adapter';
|
|
2
2
|
import { LarkClient, type LarkEndpointOptions } from './client.js';
|
|
3
|
+
import { createLarkSelfProfile } from './self-profile.js';
|
|
3
4
|
export type { LarkEndpointOptions } from './client.js';
|
|
4
5
|
export type { LarkFetch } from './client.js';
|
|
5
6
|
|
|
@@ -15,6 +16,8 @@ export class LarkEndpoint extends LarkClient implements Endpoint {
|
|
|
15
16
|
name: this.configName(),
|
|
16
17
|
control: this.endpointControl,
|
|
17
18
|
management: this.endpointManagement,
|
|
19
|
+
streaming: this.endpointStreaming,
|
|
20
|
+
profile: createLarkSelfProfile({ client: this, reportAccount: id => this[endpointSpec].reportSelfUserId(id) }),
|
|
18
21
|
};
|
|
19
22
|
Object.defineProperty(hooks, 'transportState', { get: () => this.transportState });
|
|
20
23
|
return createEndpointSpecification(this, hooks);
|
package/src/protocol.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
|
|
1
2
|
import type { Locale } from 'zhin.js/i18n';
|
|
2
3
|
import { validateLarkWebApiProxy } from './web-api-proxy.js';
|
|
3
4
|
/**
|
|
@@ -15,7 +16,10 @@ import { formatLarkCard } from './cards.js';
|
|
|
15
16
|
const logger = getLogger('lark');
|
|
16
17
|
|
|
17
18
|
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
18
|
-
export interface LarkEndpointConfig {
|
|
19
|
+
export interface LarkEndpointConfig extends EndpointProfileConfiguration {
|
|
20
|
+
/** Native streaming cards are enabled unless explicitly disabled. */
|
|
21
|
+
readonly streaming?: boolean;
|
|
22
|
+
readonly streamingIntervalMs?: number;
|
|
19
23
|
readonly webApiProxy?: import('./web-api-proxy.js').LarkWebApiProxy;
|
|
20
24
|
readonly streamProxy?: import('./stream-proxy.js').LarkStreamProxy;
|
|
21
25
|
readonly id: string;
|
|
@@ -30,6 +34,8 @@ export interface LarkEndpointConfig {
|
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export interface ResolvedLarkConfig {
|
|
37
|
+
readonly streaming?: boolean;
|
|
38
|
+
readonly streamingIntervalMs?: number;
|
|
33
39
|
readonly webApiProxy?: import('./web-api-proxy.js').LarkWebApiProxy;
|
|
34
40
|
readonly streamProxy?: import('./stream-proxy.js').LarkStreamProxy;
|
|
35
41
|
readonly context: 'lark';
|
|
@@ -116,6 +122,8 @@ export interface LarkSendBody {
|
|
|
116
122
|
}
|
|
117
123
|
|
|
118
124
|
export function resolveLarkConfig(config: LarkEndpointConfig): ResolvedLarkConfig {
|
|
125
|
+
if (config.streaming !== undefined && typeof config.streaming !== 'boolean') throw new TypeError('Lark streaming must be a boolean');
|
|
126
|
+
if (config.streamingIntervalMs !== undefined && (!Number.isSafeInteger(config.streamingIntervalMs) || config.streamingIntervalMs < 0)) throw new TypeError('Lark streamingIntervalMs must be a non-negative integer');
|
|
119
127
|
if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
|
|
120
128
|
|| typeof config.streamProxy.serverName !== 'string' || !config.streamProxy.serverName || /[\s/:@?#]/.test(config.streamProxy.serverName))) throw new TypeError('Invalid Lark streamProxy port/serverName');
|
|
121
129
|
if (config.webApiProxy) validateLarkWebApiProxy(config.webApiProxy);
|
|
@@ -139,6 +147,8 @@ export function resolveLarkConfig(config: LarkEndpointConfig): ResolvedLarkConfi
|
|
|
139
147
|
...(config.webApiProxy ? { webApiProxy: config.webApiProxy } : {}),
|
|
140
148
|
...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
|
|
141
149
|
context: 'lark',
|
|
150
|
+
streaming: config.streaming !== false,
|
|
151
|
+
streamingIntervalMs: config.streamingIntervalMs ?? 1000,
|
|
142
152
|
mode,
|
|
143
153
|
id,
|
|
144
154
|
appId,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
|
+
import type { EndpointMessageStream, EndpointStreamRequest, EndpointStreamAcknowledgement } from 'zhin.js/adapter';
|
|
3
|
+
import type { LarkApiResponse } from './protocol.js';
|
|
4
|
+
|
|
5
|
+
export interface LarkStreamRequestOptions {
|
|
6
|
+
readonly method: 'POST' | 'PUT' | 'PATCH';
|
|
7
|
+
readonly body: Record<string, unknown>;
|
|
8
|
+
readonly signal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
type Request = (path: string, options: LarkStreamRequestOptions) => Promise<LarkApiResponse>;
|
|
11
|
+
|
|
12
|
+
export function supportsLarkReplyStream(request: Pick<EndpointStreamRequest, 'conversation' | 'source'>): boolean {
|
|
13
|
+
return !request.conversation.threadId && !request.conversation.parent;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** CardKit text updates use cumulative content and one sequence across content/settings. */
|
|
17
|
+
export function openLarkReplyStream(request: EndpointStreamRequest, send: Request): EndpointMessageStream | undefined {
|
|
18
|
+
// CardKit has no verified way to restore a previous card's 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')
|
|
22
|
+
|| !supportsLarkReplyStream(request)) return undefined;
|
|
23
|
+
const reply = prefix.find(segment => segment.type === 'reply');
|
|
24
|
+
const replyTo = (reply && 'message_id' in reply.data ? reply.data.message_id : undefined) ?? request.continuation?.replyTo;
|
|
25
|
+
const mentions = prefix.filter(segment => segment.type === 'mention').map(segment => {
|
|
26
|
+
const target = String('target' in segment.data ? segment.data.target : '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
27
|
+
return `<at id="${target}"></at>`;
|
|
28
|
+
}).join('');
|
|
29
|
+
let cardId: string | undefined;
|
|
30
|
+
let messageId: string | undefined;
|
|
31
|
+
let sequence = 0;
|
|
32
|
+
let acceptedText = '';
|
|
33
|
+
let cancelled = false;
|
|
34
|
+
let finished = false;
|
|
35
|
+
let exhausted = false;
|
|
36
|
+
let platformClosed = false;
|
|
37
|
+
let failure: unknown;
|
|
38
|
+
let lastAcknowledgement: EndpointStreamAcknowledgement | undefined;
|
|
39
|
+
const acknowledge = (): EndpointStreamAcknowledgement => lastAcknowledgement = Object.freeze({ status: messageId ? 'sent' : 'buffered',
|
|
40
|
+
...(messageId ? { messageId } : {}), acceptedText, ...(exhausted ? { exhausted: true } : {}) });
|
|
41
|
+
const bodyText = (text: string) => request.format === 'markdown' ? text : text.replace(/[\\`*_{}[\]()#+.!|>~-]/g, '\\$&');
|
|
42
|
+
const assertActive = () => {
|
|
43
|
+
if (cancelled || request.signal?.aborted) throw new EndpointDeliveryError('stream_cancelled', 'Lark stream was cancelled before submission', 'not_sent');
|
|
44
|
+
};
|
|
45
|
+
const call = (path: string, method: LarkStreamRequestOptions['method'], body: Record<string, unknown>) => {
|
|
46
|
+
assertActive();
|
|
47
|
+
return send(path, { method, body, signal: request.signal });
|
|
48
|
+
};
|
|
49
|
+
const write = async (text: string) => {
|
|
50
|
+
if (failure) throw failure;
|
|
51
|
+
assertActive();
|
|
52
|
+
if (finished) return acknowledge();
|
|
53
|
+
if (!text.startsWith(acceptedText)) throw new EndpointDeliveryError('stream_rewrite', 'A confirmed reply prefix cannot change', 'not_sent');
|
|
54
|
+
if (exhausted) return acknowledge();
|
|
55
|
+
if (text === acceptedText) return acknowledge();
|
|
56
|
+
if (!cardId) {
|
|
57
|
+
try {
|
|
58
|
+
const card = await call('/cardkit/v1/cards', 'POST', { type: 'card_json', data: JSON.stringify({
|
|
59
|
+
schema: '2.0', config: { streaming_mode: true, update_multi: true },
|
|
60
|
+
body: { elements: [
|
|
61
|
+
...(mentions ? [{ tag: 'markdown', element_id: 'zhin_prefix', content: mentions }] : []),
|
|
62
|
+
{ tag: 'markdown', element_id: 'zhin_reply', content: bodyText(text) },
|
|
63
|
+
] },
|
|
64
|
+
}) });
|
|
65
|
+
cardId = typeof card.data?.card_id === 'string' && card.data.card_id ? card.data.card_id : undefined;
|
|
66
|
+
if (!cardId) throw new Error('CardKit returned no card ID');
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// A card resource may exist, but no chat-message request has been attempted.
|
|
69
|
+
failure = new EndpointDeliveryError('stream_resource_unavailable', 'Lark streaming card could not be prepared', 'not_sent', { cause: error });
|
|
70
|
+
throw failure;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
if (!messageId) {
|
|
75
|
+
const message = await call(typeof replyTo === 'string' && replyTo
|
|
76
|
+
? `/im/v1/messages/${encodeURIComponent(replyTo)}/reply` : '/im/v1/messages?receive_id_type=chat_id', 'POST', {
|
|
77
|
+
...(replyTo ? {} : { receive_id: request.conversation.id }),
|
|
78
|
+
msg_type: 'interactive', content: JSON.stringify({ type: 'card', data: { card_id: cardId } }),
|
|
79
|
+
});
|
|
80
|
+
if (typeof message.data?.message_id !== 'string' || !message.data.message_id.trim()) {
|
|
81
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Lark stream returned no message ID', 'unknown');
|
|
82
|
+
}
|
|
83
|
+
messageId = message.data.message_id;
|
|
84
|
+
} else {
|
|
85
|
+
await call(`/cardkit/v1/cards/${encodeURIComponent(cardId)}/elements/zhin_reply/content`, 'PUT', {
|
|
86
|
+
content: bodyText(text), sequence: ++sequence,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
acceptedText = text;
|
|
90
|
+
return acknowledge();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const code = rejectedPlatformCode(error);
|
|
93
|
+
if (messageId && acceptedText && (code === 200850 || code === 200860)) {
|
|
94
|
+
// The rejected update contributed no source. Timeout explicitly closes the
|
|
95
|
+
// prior card; capacity requires closing only its last confirmed contents.
|
|
96
|
+
exhausted = true; platformClosed = code === 200850;
|
|
97
|
+
return acknowledge();
|
|
98
|
+
}
|
|
99
|
+
failure = streamFailure(error); throw failure;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
get lastAcknowledgement() { return lastAcknowledgement; },
|
|
104
|
+
write,
|
|
105
|
+
async finish(text) {
|
|
106
|
+
if (finished) return acknowledge();
|
|
107
|
+
await write(text);
|
|
108
|
+
if (cardId && messageId && !platformClosed) {
|
|
109
|
+
try { await call(`/cardkit/v1/cards/${encodeURIComponent(cardId)}/settings`, 'PATCH', {
|
|
110
|
+
settings: JSON.stringify({ config: { streaming_mode: false } }), sequence: ++sequence,
|
|
111
|
+
}); } catch (error) {
|
|
112
|
+
if (rejectedPlatformCode(error) === 200850) { platformClosed = true; exhausted = true; }
|
|
113
|
+
else { failure = streamFailure(error); throw failure; }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
finished = true; return acknowledge();
|
|
117
|
+
},
|
|
118
|
+
cancel() { cancelled = true; },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function rejectedPlatformCode(error: unknown): number | undefined {
|
|
123
|
+
return error instanceof EndpointDeliveryError && error.disposition === 'rejected' && 'platformCode' in error
|
|
124
|
+
&& typeof error.platformCode === 'number' ? error.platformCode : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function streamFailure(error: unknown): EndpointDeliveryError {
|
|
128
|
+
return error instanceof EndpointDeliveryError ? error
|
|
129
|
+
: new EndpointDeliveryError('delivery_unconfirmed', 'Lark stream update outcome is unknown', 'unknown', { cause: error });
|
|
130
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createEndpointOperation, type EndpointProfile } from 'zhin.js/adapter';
|
|
2
|
+
import type { LarkClient } from './client.js';
|
|
3
|
+
|
|
4
|
+
interface LarkSelfProfileOptions {
|
|
5
|
+
readonly client: Pick<LarkClient, 'request' | 'config'>;
|
|
6
|
+
readonly reportAccount: (id: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Same bot-info endpoint used for read-only credential identity verification. */
|
|
10
|
+
export function createLarkSelfProfile({ client, reportAccount }: LarkSelfProfileOptions): EndpointProfile {
|
|
11
|
+
return Object.freeze<EndpointProfile>({
|
|
12
|
+
writableFields: Object.freeze([]),
|
|
13
|
+
async read({ signal }) {
|
|
14
|
+
const response = await createEndpointOperation({ signal }).prepare(() => client.request<{
|
|
15
|
+
code?: number; bot?: { open_id?: string; app_name?: string; avatar_url?: string };
|
|
16
|
+
}>({ url: `${client.config.apiBaseUrl}/bot/v3/info`, method: 'GET' }));
|
|
17
|
+
if (response.code !== 0 || !response.bot?.open_id) throw new Error('Lark returned no bot profile');
|
|
18
|
+
reportAccount(response.bot.open_id);
|
|
19
|
+
return { ...(typeof response.bot.app_name === 'string' ? { nickname: response.bot.app_name } : {}),
|
|
20
|
+
...(typeof response.bot.avatar_url === 'string' ? { avatar: response.bot.avatar_url } : {}) };
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|