@krischoichoi/channel-lark 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/lib/adapter.d.ts +128 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/adapter.js +347 -0
- package/lib/adapter.js.map +1 -0
- package/lib/auth/device-authorization.d.ts +12 -0
- package/lib/auth/device-authorization.d.ts.map +1 -0
- package/lib/auth/device-authorization.js +169 -0
- package/lib/auth/device-authorization.js.map +1 -0
- package/lib/card.d.ts +124 -0
- package/lib/card.d.ts.map +1 -0
- package/lib/card.js +347 -0
- package/lib/card.js.map +1 -0
- package/lib/config.d.ts +103 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +60 -0
- package/lib/config.js.map +1 -0
- package/lib/definition.d.ts +74 -0
- package/lib/definition.d.ts.map +1 -0
- package/lib/definition.js +190 -0
- package/lib/definition.js.map +1 -0
- package/lib/inbound.d.ts +41 -0
- package/lib/inbound.d.ts.map +1 -0
- package/lib/inbound.js +102 -0
- package/lib/inbound.js.map +1 -0
- package/lib/index.d.ts +58 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +104 -0
- package/lib/index.js.map +1 -0
- package/lib/lark-sdk-upstream.d.ts +128 -0
- package/lib/lark-sdk-upstream.d.ts.map +1 -0
- package/lib/lark-sdk-upstream.js +339 -0
- package/lib/lark-sdk-upstream.js.map +1 -0
- package/lib/manifest.d.ts +23 -0
- package/lib/manifest.d.ts.map +1 -0
- package/lib/manifest.js +36 -0
- package/lib/manifest.js.map +1 -0
- package/lib/mapper.d.ts +47 -0
- package/lib/mapper.d.ts.map +1 -0
- package/lib/mapper.js +223 -0
- package/lib/mapper.js.map +1 -0
- package/lib/media-hydrator.d.ts +55 -0
- package/lib/media-hydrator.d.ts.map +1 -0
- package/lib/media-hydrator.js +172 -0
- package/lib/media-hydrator.js.map +1 -0
- package/lib/openapi-outbound.d.ts +216 -0
- package/lib/openapi-outbound.d.ts.map +1 -0
- package/lib/openapi-outbound.js +384 -0
- package/lib/openapi-outbound.js.map +1 -0
- package/lib/outbound.d.ts +21 -0
- package/lib/outbound.d.ts.map +1 -0
- package/lib/outbound.js +93 -0
- package/lib/outbound.js.map +1 -0
- package/lib/upstream/media-port.d.ts +154 -0
- package/lib/upstream/media-port.d.ts.map +1 -0
- package/lib/upstream/media-port.js +168 -0
- package/lib/upstream/media-port.js.map +1 -0
- package/lib/upstream.d.ts +119 -0
- package/lib/upstream.d.ts.map +1 -0
- package/lib/upstream.js +2 -0
- package/lib/upstream.js.map +1 -0
- package/package.json +46 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { ControlError, } from '@krischoichoi/channel-control';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const deviceAuthorizationResponseSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
device_code: z.string().trim().min(1).optional(),
|
|
6
|
+
verification_uri_complete: z.string().trim().min(1).optional(),
|
|
7
|
+
verification_uri: z.string().trim().min(1).optional(),
|
|
8
|
+
expires_in: z.number().positive().optional(),
|
|
9
|
+
interval: z.number().positive().optional(),
|
|
10
|
+
error: z.string().trim().min(1).optional(),
|
|
11
|
+
error_description: z.string().trim().min(1).optional(),
|
|
12
|
+
})
|
|
13
|
+
.passthrough();
|
|
14
|
+
const tokenResponseSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
access_token: z.string().trim().min(1).optional(),
|
|
17
|
+
refresh_token: z.string().trim().min(1).optional(),
|
|
18
|
+
error: z.string().trim().min(1).optional(),
|
|
19
|
+
error_description: z.string().trim().min(1).optional(),
|
|
20
|
+
code: z.union([z.string(), z.number()]).optional(),
|
|
21
|
+
msg: z.string().trim().min(1).optional(),
|
|
22
|
+
})
|
|
23
|
+
.passthrough();
|
|
24
|
+
const providerStateSchema = z.object({
|
|
25
|
+
brand: z.enum(['feishu', 'lark']),
|
|
26
|
+
appId: z.string().trim().min(1),
|
|
27
|
+
appSecret: z.string().trim().min(1),
|
|
28
|
+
deviceCode: z.string().trim().min(1),
|
|
29
|
+
scope: z.string().trim().min(1),
|
|
30
|
+
});
|
|
31
|
+
function authError(message, cause) {
|
|
32
|
+
return new ControlError('CONTROL_ERROR', message, cause === undefined ? undefined : { cause });
|
|
33
|
+
}
|
|
34
|
+
function endpoint(domain, resource) {
|
|
35
|
+
return domain === 'lark'
|
|
36
|
+
? `https://accounts.larksuite.com/oauth/v1/${resource}`
|
|
37
|
+
: `https://accounts.feishu.cn/oauth/v1/${resource}`;
|
|
38
|
+
}
|
|
39
|
+
function tokenEndpoints(brand) {
|
|
40
|
+
return brand === 'lark'
|
|
41
|
+
? [
|
|
42
|
+
'https://accounts.larksuite.com/oauth/v1/token',
|
|
43
|
+
'https://open.larksuite.com/open-apis/authen/v2/oauth/token',
|
|
44
|
+
]
|
|
45
|
+
: [
|
|
46
|
+
'https://accounts.feishu.cn/oauth/v1/token',
|
|
47
|
+
'https://open.feishu.cn/open-apis/authen/v2/oauth/token',
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
async function readJson(response, provider) {
|
|
51
|
+
try {
|
|
52
|
+
return await response.json();
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw authError(`${provider} authorization returned invalid JSON`, error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function parseDeviceResponse(value) {
|
|
59
|
+
const parsed = deviceAuthorizationResponseSchema.safeParse(value);
|
|
60
|
+
if (!parsed.success)
|
|
61
|
+
throw authError('lark device authorization returned an invalid response', parsed.error);
|
|
62
|
+
return parsed.data;
|
|
63
|
+
}
|
|
64
|
+
function parseTokenResponse(value) {
|
|
65
|
+
const parsed = tokenResponseSchema.safeParse(value);
|
|
66
|
+
if (!parsed.success)
|
|
67
|
+
throw authError('lark authorization polling returned an invalid response', parsed.error);
|
|
68
|
+
return parsed.data;
|
|
69
|
+
}
|
|
70
|
+
function parseProviderState(value) {
|
|
71
|
+
const parsed = providerStateSchema.safeParse(value);
|
|
72
|
+
if (!parsed.success)
|
|
73
|
+
throw authError('lark auth session is missing provider state', parsed.error);
|
|
74
|
+
return parsed.data;
|
|
75
|
+
}
|
|
76
|
+
/** Begin Feishu/Lark device authorization. Secrets remain in opaque host state. */
|
|
77
|
+
export async function beginLarkDeviceAuthorization(options) {
|
|
78
|
+
const appId = options.appId.trim();
|
|
79
|
+
const appSecret = options.appSecret.trim();
|
|
80
|
+
if (!appId || !appSecret)
|
|
81
|
+
throw new ControlError('AUTH_NOT_READY', 'lark QR authorization requires configured appId and appSecret');
|
|
82
|
+
const fetcher = options.fetcher ?? fetch;
|
|
83
|
+
const brand = options.domain === 'lark' ? 'lark' : 'feishu';
|
|
84
|
+
const scope = 'offline_access';
|
|
85
|
+
let response;
|
|
86
|
+
try {
|
|
87
|
+
response = await fetcher(endpoint(options.domain, 'device_authorization'), {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {
|
|
90
|
+
authorization: `Basic ${Buffer.from(`${appId}:${appSecret}`).toString('base64')}`,
|
|
91
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
92
|
+
},
|
|
93
|
+
body: new URLSearchParams({ client_id: appId, scope }).toString(),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw authError('lark device authorization request failed', error);
|
|
98
|
+
}
|
|
99
|
+
const payload = parseDeviceResponse(await readJson(response, 'lark'));
|
|
100
|
+
if (!response.ok || payload.error)
|
|
101
|
+
throw authError('lark device authorization request failed');
|
|
102
|
+
const deviceCode = payload.device_code;
|
|
103
|
+
const completeUrl = payload.verification_uri_complete ?? payload.verification_uri;
|
|
104
|
+
if (!deviceCode || !completeUrl)
|
|
105
|
+
throw authError('lark did not return a device authorization challenge');
|
|
106
|
+
const expiresAt = Date.now() + (payload.expires_in ?? 240) * 1000;
|
|
107
|
+
const providerState = { brand, appId, appSecret, deviceCode, scope };
|
|
108
|
+
return {
|
|
109
|
+
provider: 'lark-device-authorization',
|
|
110
|
+
expiresAt,
|
|
111
|
+
pollingIntervalMs: Math.max(3000, (payload.interval ?? 5) * 1000),
|
|
112
|
+
qr: { kind: 'content', value: completeUrl, expiresAt },
|
|
113
|
+
prompt: { kind: 'confirm-on-phone', message: '请使用飞书/Lark 扫码并确认授权' },
|
|
114
|
+
deviceCode,
|
|
115
|
+
providerState,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Poll a device authorization session without exposing provider tokens. */
|
|
119
|
+
export async function pollLarkDeviceAuthorization(session, options = {}) {
|
|
120
|
+
const provider = parseProviderState(session.providerState);
|
|
121
|
+
const fetcher = options.fetcher ?? fetch;
|
|
122
|
+
const attempts = [
|
|
123
|
+
{ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: provider.deviceCode, client_id: provider.appId },
|
|
124
|
+
{ grant_type: 'device_code', device_code: provider.deviceCode, client_id: provider.appId },
|
|
125
|
+
{ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: provider.deviceCode },
|
|
126
|
+
{ grant_type: 'device_code', device_code: provider.deviceCode },
|
|
127
|
+
];
|
|
128
|
+
let lastError;
|
|
129
|
+
for (const tokenUrl of tokenEndpoints(provider.brand)) {
|
|
130
|
+
for (const attempt of attempts) {
|
|
131
|
+
let response;
|
|
132
|
+
try {
|
|
133
|
+
response = await fetcher(tokenUrl, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
authorization: `Basic ${Buffer.from(`${provider.appId}:${provider.appSecret}`).toString('base64')}`,
|
|
137
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
138
|
+
},
|
|
139
|
+
body: new URLSearchParams(attempt).toString(),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
lastError = error;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let payload;
|
|
147
|
+
try {
|
|
148
|
+
payload = parseTokenResponse(await readJson(response, 'lark'));
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
lastError = error;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (response.ok && (payload.access_token || payload.refresh_token)) {
|
|
155
|
+
return { state: 'authenticated', phase: 'authorized', expiresAt: session.expiresAt, detail: '飞书/Lark 扫码授权已完成' };
|
|
156
|
+
}
|
|
157
|
+
const code = payload.error ?? (payload.code === undefined ? '' : String(payload.code));
|
|
158
|
+
if (code === 'authorization_pending' || code === 'slow_down') {
|
|
159
|
+
return { state: 'pending', phase: 'waiting-scan', expiresAt: session.expiresAt, detail: '等待飞书/Lark 扫码授权中' };
|
|
160
|
+
}
|
|
161
|
+
if (code === 'expired_token' || code === 'invalid_grant' || response.status === 401) {
|
|
162
|
+
return { state: 'expired', phase: 'expired', expiresAt: session.expiresAt, detail: '二维码已失效,请重新生成' };
|
|
163
|
+
}
|
|
164
|
+
lastError = new Error(payload.error_description ?? payload.msg ?? `HTTP ${response.status}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
throw authError('lark authorization polling failed', lastError);
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=device-authorization.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-authorization.js","sourceRoot":"","sources":["../../src/auth/device-authorization.ts"],"names":[],"mappings":"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"}
|
package/lib/card.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lark CardKit 2.0 native streaming reply — the streaming reply
|
|
3
|
+
* (`ReplyHandle`).
|
|
4
|
+
*
|
|
5
|
+
* Lark streaming rides the OFFICIAL CardKit native typewriter flow — never the
|
|
6
|
+
* legacy `message.patch` pseudo-stream:
|
|
7
|
+
*
|
|
8
|
+
* 1. create a Card JSON 2.0 card entity with `streaming_mode: true`
|
|
9
|
+
* (`cardkit.v1.card.create`);
|
|
10
|
+
* 2. send it as an interactive card reference
|
|
11
|
+
* (`im.v1.message.create` with `{ type: 'card', data: { card_id } }`);
|
|
12
|
+
* 3. for every streamed delta, replace the markdown element's full content
|
|
13
|
+
* via `cardkit.v1.cardElement.content` with a monotonically increasing
|
|
14
|
+
* `sequence` and a stable `uuid` (idempotency);
|
|
15
|
+
* 4. `finish()`/`fail()` close the stream by setting `streaming_mode: false`
|
|
16
|
+
* through `cardkit.v1.card.settings`, writing the final summary.
|
|
17
|
+
*
|
|
18
|
+
* Rollover: when a single markdown element approaches the platform's
|
|
19
|
+
* ~30,000-char element cap (the official SDK's `Channel` guard), the handle
|
|
20
|
+
* finalizes the current card, creates + sends a fresh streaming card, and
|
|
21
|
+
* continues on it. `chunkIds` records every rollover card's message id.
|
|
22
|
+
*
|
|
23
|
+
* The handle does NOT throttle internally — ReplyRouter owns throttling via
|
|
24
|
+
* config `reply.updateIntervalMs`. It guards no-op updates (skip when the text
|
|
25
|
+
* is unchanged) and serializes card operations so concurrent router flushes
|
|
26
|
+
* cannot interleave. State is observable for tests: `status`, `cardId`,
|
|
27
|
+
* `text`, `error`, `sequence` and an `updates` record of applied operations.
|
|
28
|
+
*/
|
|
29
|
+
import type { ChannelLogger, ChannelTarget, OutboundMessage, ReplyHandle } from '@krischoichoi/channel-core';
|
|
30
|
+
import type { LarkUpstream } from './upstream.js';
|
|
31
|
+
export type LarkCardStatus = 'idle' | 'active' | 'finished' | 'failed';
|
|
32
|
+
/** One applied card operation (observable test surface). */
|
|
33
|
+
export interface LarkCardUpdate {
|
|
34
|
+
kind: 'created' | 'streamed' | 'rollover' | 'finished' | 'failed';
|
|
35
|
+
/** Card text at the time of the operation. */
|
|
36
|
+
text?: string;
|
|
37
|
+
/** Monotonic element sequence after the operation. */
|
|
38
|
+
sequence?: number;
|
|
39
|
+
/** Previous card entity id that a rollover finalized. */
|
|
40
|
+
cardId?: string;
|
|
41
|
+
/** Clock timestamp (injectable). */
|
|
42
|
+
at: number;
|
|
43
|
+
/** Error detail when the operation itself failed. */
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface LarkCardReplyOptions {
|
|
47
|
+
/** The upstream outbound driver (official OpenAPI or an injected fake). */
|
|
48
|
+
upstream: LarkUpstream;
|
|
49
|
+
/** Conversation the card is attached to. */
|
|
50
|
+
target: ChannelTarget;
|
|
51
|
+
logger: ChannelLogger;
|
|
52
|
+
/** Create the card on the first delta; otherwise only at `finish`. */
|
|
53
|
+
createOnFirstDelta: boolean;
|
|
54
|
+
/** Injectable clock (tests). */
|
|
55
|
+
now?: () => number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Hard cap for ONE markdown element before rolling over into a fresh card.
|
|
59
|
+
* Feishu rejects oversized elements (error 230099); the official SDK's
|
|
60
|
+
* high-level Channel uses the same 30,000-char default threshold.
|
|
61
|
+
*/
|
|
62
|
+
export declare const LARK_STREAM_ELEMENT_MAX_CHARS = 30000;
|
|
63
|
+
export declare class LarkCardReply implements ReplyHandle {
|
|
64
|
+
private readonly options;
|
|
65
|
+
status: LarkCardStatus;
|
|
66
|
+
/** Current (latest) CardKit card entity; undefined until first create. */
|
|
67
|
+
cardId?: string;
|
|
68
|
+
/** Message id of the current card reference (changes on rollover). */
|
|
69
|
+
messageId?: string;
|
|
70
|
+
text: string;
|
|
71
|
+
/** Monotonic per-element sequence (restarts on each rollover card). */
|
|
72
|
+
sequence: number;
|
|
73
|
+
/** Every card sent during this reply, in order (the first is the primary one). */
|
|
74
|
+
readonly chunkIds: string[];
|
|
75
|
+
/** Original failure value passed to `fail()`, if any. */
|
|
76
|
+
error?: unknown;
|
|
77
|
+
/** Ordered record of applied card operations. */
|
|
78
|
+
readonly updates: LarkCardUpdate[];
|
|
79
|
+
private readonly now;
|
|
80
|
+
private queue;
|
|
81
|
+
/** Text currently owned by the active card; finalized card heads are excluded. */
|
|
82
|
+
private content;
|
|
83
|
+
private hasRolledOver;
|
|
84
|
+
/** A previous failure was already written to the card — do not overwrite it. */
|
|
85
|
+
private failedShown;
|
|
86
|
+
constructor(options: LarkCardReplyOptions);
|
|
87
|
+
append(delta: string): Promise<void>;
|
|
88
|
+
replace(message: OutboundMessage): Promise<void>;
|
|
89
|
+
finish(message?: OutboundMessage): Promise<void>;
|
|
90
|
+
fail(error: unknown): Promise<void>;
|
|
91
|
+
private get finalized();
|
|
92
|
+
private push;
|
|
93
|
+
/** Create a CardKit entity and send its card reference (one-shot per card). */
|
|
94
|
+
private createCardAndSend;
|
|
95
|
+
/**
|
|
96
|
+
* Push the full element content through `cardElement.content`, rolling over
|
|
97
|
+
* into a fresh card when the element exceeds the platform cap. Sequence is
|
|
98
|
+
* monotonically increased and a stable uuid is derived from cardId +
|
|
99
|
+
* sequence (idempotent on retry).
|
|
100
|
+
*/
|
|
101
|
+
private pushContent;
|
|
102
|
+
/**
|
|
103
|
+
* Finalize the current card with the head content and start a fresh
|
|
104
|
+
* streaming card for the tail. Mirrors the official SDK Channel rollover:
|
|
105
|
+
* the current element is pinned to the head, streaming is closed on it, and
|
|
106
|
+
* a new streaming entity is created + sent seeded with the tail.
|
|
107
|
+
*/
|
|
108
|
+
private rollover;
|
|
109
|
+
/** Close the stream: `streaming_mode: false` + final summary. */
|
|
110
|
+
private closeStreaming;
|
|
111
|
+
private record;
|
|
112
|
+
private enqueue;
|
|
113
|
+
private replaceText;
|
|
114
|
+
}
|
|
115
|
+
/** Shorten markdown to a single-line preview for the card summary. */
|
|
116
|
+
export declare function truncateSummary(text: string, max?: number): string;
|
|
117
|
+
/**
|
|
118
|
+
* Split long content at ~`limit` chars while keeping fenced code blocks whole
|
|
119
|
+
* (mirrors the official SDK Channel splitter). A single line that alone
|
|
120
|
+
* exceeds `limit` (an unbreakable token) is hard-wrapped so the caller can
|
|
121
|
+
* always roll over instead of being stuck with an oversized element.
|
|
122
|
+
*/
|
|
123
|
+
export declare function splitForRollover(text: string, limit: number): string[];
|
|
124
|
+
//# sourceMappingURL=card.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"card.d.ts","sourceRoot":"","sources":["../src/card.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,EACV,aAAa,EACb,aAAa,EACb,eAAe,EACf,WAAW,EACZ,MAAM,4BAA4B,CAAC;AAMpC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,GAAG,QAAQ,CAAC;AAEvE,4DAA4D;AAC5D,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,UAAU,GAAG,QAAQ,CAAC;IAClE,8CAA8C;IAC9C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oCAAoC;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,oBAAoB;IACnC,2EAA2E;IAC3E,QAAQ,EAAE,YAAY,CAAC;IACvB,4CAA4C;IAC5C,MAAM,EAAE,aAAa,CAAC;IACtB,MAAM,EAAE,aAAa,CAAC;IACtB,sEAAsE;IACtE,kBAAkB,EAAE,OAAO,CAAC;IAC5B,gCAAgC;IAChC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,QAAS,CAAC;AAKpD,qBAAa,aAAc,YAAW,WAAW;IAwBnC,OAAO,CAAC,QAAQ,CAAC,OAAO;IAvBpC,MAAM,EAAE,cAAc,CAAU;IAChC,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,SAAM;IACV,uEAAuE;IACvE,QAAQ,SAAK;IACb,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAM;IACjC,yDAAyD;IACzD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iDAAiD;IACjD,QAAQ,CAAC,OAAO,EAAE,cAAc,EAAE,CAAM;IAExC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,KAAK,CAAoC;IACjD,kFAAkF;IAClF,OAAO,CAAC,OAAO,CAAM;IACrB,OAAO,CAAC,aAAa,CAAS;IAC9B,gFAAgF;IAChF,OAAO,CAAC,WAAW,CAAS;gBAEC,OAAO,EAAE,oBAAoB;IAI1D,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAapC,OAAO,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAWhD,MAAM,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IA8BhD,IAAI,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAgCnC,OAAO,KAAK,SAAS,GAEpB;YAEa,IAAI;IAUlB,+EAA+E;YACjE,iBAAiB;IAY/B;;;;;OAKG;YACW,WAAW;IAiBzB;;;;;OAKG;YACW,QAAQ;IA6CtB,iEAAiE;YACnD,cAAc;IAU5B,OAAO,CAAC,MAAM;IAkBd,OAAO,CAAC,OAAO;IASf,OAAO,CAAC,WAAW;CAepB;AAOD,sEAAsE;AACtE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,MAAM,CAI9D;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAgDtE"}
|
package/lib/card.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { ChannelError } from '@krischoichoi/channel-core';
|
|
2
|
+
import { STREAM_MARKDOWN_ELEMENT_ID, streamingCardJson, } from './openapi-outbound.js';
|
|
3
|
+
/**
|
|
4
|
+
* Hard cap for ONE markdown element before rolling over into a fresh card.
|
|
5
|
+
* Feishu rejects oversized elements (error 230099); the official SDK's
|
|
6
|
+
* high-level Channel uses the same 30,000-char default threshold.
|
|
7
|
+
*/
|
|
8
|
+
export const LARK_STREAM_ELEMENT_MAX_CHARS = 30_000;
|
|
9
|
+
/** Stable summary shown in chat previews while the card is still streaming. */
|
|
10
|
+
const GENERATING_SUMMARY = '[Generating...]';
|
|
11
|
+
export class LarkCardReply {
|
|
12
|
+
options;
|
|
13
|
+
status = 'idle';
|
|
14
|
+
/** Current (latest) CardKit card entity; undefined until first create. */
|
|
15
|
+
cardId;
|
|
16
|
+
/** Message id of the current card reference (changes on rollover). */
|
|
17
|
+
messageId;
|
|
18
|
+
text = '';
|
|
19
|
+
/** Monotonic per-element sequence (restarts on each rollover card). */
|
|
20
|
+
sequence = 0;
|
|
21
|
+
/** Every card sent during this reply, in order (the first is the primary one). */
|
|
22
|
+
chunkIds = [];
|
|
23
|
+
/** Original failure value passed to `fail()`, if any. */
|
|
24
|
+
error;
|
|
25
|
+
/** Ordered record of applied card operations. */
|
|
26
|
+
updates = [];
|
|
27
|
+
now;
|
|
28
|
+
queue = Promise.resolve();
|
|
29
|
+
/** Text currently owned by the active card; finalized card heads are excluded. */
|
|
30
|
+
content = '';
|
|
31
|
+
hasRolledOver = false;
|
|
32
|
+
/** A previous failure was already written to the card — do not overwrite it. */
|
|
33
|
+
failedShown = false;
|
|
34
|
+
constructor(options) {
|
|
35
|
+
this.options = options;
|
|
36
|
+
this.now = options.now ?? Date.now;
|
|
37
|
+
}
|
|
38
|
+
append(delta) {
|
|
39
|
+
return this.enqueue(async () => {
|
|
40
|
+
if (this.finalized)
|
|
41
|
+
return;
|
|
42
|
+
if (!delta)
|
|
43
|
+
return;
|
|
44
|
+
const merged = mergeStreamingText(this.text, delta);
|
|
45
|
+
const appended = merged.slice(this.text.length);
|
|
46
|
+
if (!appended)
|
|
47
|
+
return;
|
|
48
|
+
this.text = merged;
|
|
49
|
+
this.content += appended;
|
|
50
|
+
await this.push();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
replace(message) {
|
|
54
|
+
return this.enqueue(async () => {
|
|
55
|
+
if (this.finalized)
|
|
56
|
+
return;
|
|
57
|
+
if (message.text === undefined)
|
|
58
|
+
return;
|
|
59
|
+
// No-op guard: skip the network round-trip when nothing changed.
|
|
60
|
+
if (message.text === this.text)
|
|
61
|
+
return;
|
|
62
|
+
this.replaceText(message.text);
|
|
63
|
+
await this.push();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
finish(message) {
|
|
67
|
+
return this.enqueue(async () => {
|
|
68
|
+
if (this.finalized)
|
|
69
|
+
return;
|
|
70
|
+
const finalText = message?.text;
|
|
71
|
+
let needsSync = false;
|
|
72
|
+
if (finalText !== undefined && finalText !== this.text) {
|
|
73
|
+
this.replaceText(finalText);
|
|
74
|
+
needsSync = true;
|
|
75
|
+
}
|
|
76
|
+
if (!this.cardId) {
|
|
77
|
+
// Nothing was streamed eagerly: create the final card now and write
|
|
78
|
+
// the accumulated content into its markdown element (or do nothing
|
|
79
|
+
// when there is no content to show).
|
|
80
|
+
if (this.text) {
|
|
81
|
+
await this.createCardAndSend();
|
|
82
|
+
await this.pushContent(this.content);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if (needsSync) {
|
|
86
|
+
await this.pushContent(this.content);
|
|
87
|
+
}
|
|
88
|
+
if (this.cardId) {
|
|
89
|
+
await this.closeStreaming(this.content);
|
|
90
|
+
this.record('finished', this.text);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
this.record('finished', undefined);
|
|
94
|
+
}
|
|
95
|
+
this.status = 'finished';
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
fail(error) {
|
|
99
|
+
return this.enqueue(async () => {
|
|
100
|
+
if (this.finalized)
|
|
101
|
+
return;
|
|
102
|
+
this.error = error;
|
|
103
|
+
if (this.cardId && !this.failedShown) {
|
|
104
|
+
const failureContent = this.content ? `${this.content}\n\n— _(Generation interrupted)_` : '— _(Generation interrupted)_';
|
|
105
|
+
let updateError;
|
|
106
|
+
try {
|
|
107
|
+
// Write the failure into the card content first, then close the
|
|
108
|
+
// stream. A secondary failure must NOT overwrite the original error.
|
|
109
|
+
this.failedShown = true;
|
|
110
|
+
await this.pushContent(failureContent);
|
|
111
|
+
}
|
|
112
|
+
catch (secondary) {
|
|
113
|
+
updateError = secondary;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await this.closeStreaming(failureContent);
|
|
117
|
+
}
|
|
118
|
+
catch (secondary) {
|
|
119
|
+
updateError ??= secondary;
|
|
120
|
+
}
|
|
121
|
+
if (updateError) {
|
|
122
|
+
// Marking the card failed must not mask the original error.
|
|
123
|
+
this.options.logger.error('[channel-lark] failed to mark card failed', updateError);
|
|
124
|
+
this.record('failed', this.text, updateError);
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
this.record('failed', this.text);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
this.record('failed', this.text);
|
|
131
|
+
}
|
|
132
|
+
this.status = 'failed';
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
get finalized() {
|
|
136
|
+
return this.status === 'finished' || this.status === 'failed';
|
|
137
|
+
}
|
|
138
|
+
async push() {
|
|
139
|
+
if (this.cardId) {
|
|
140
|
+
await this.pushContent(this.content);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!this.options.createOnFirstDelta)
|
|
144
|
+
return; // buffer until finish
|
|
145
|
+
await this.createCardAndSend();
|
|
146
|
+
await this.pushContent(this.content);
|
|
147
|
+
}
|
|
148
|
+
/** Create a CardKit entity and send its card reference (one-shot per card). */
|
|
149
|
+
async createCardAndSend() {
|
|
150
|
+
const cardJson = streamingCardJson('...', GENERATING_SUMMARY);
|
|
151
|
+
const entity = await this.options.upstream.createCardEntity(cardJson);
|
|
152
|
+
const sent = await this.options.upstream.sendCardEntity(this.options.target.conversationId, entity.cardId);
|
|
153
|
+
this.cardId = entity.cardId;
|
|
154
|
+
this.messageId = sent.messageId;
|
|
155
|
+
this.sequence = 0;
|
|
156
|
+
this.chunkIds.push(sent.messageId);
|
|
157
|
+
this.status = 'active';
|
|
158
|
+
this.record('created', this.text);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Push the full element content through `cardElement.content`, rolling over
|
|
162
|
+
* into a fresh card when the element exceeds the platform cap. Sequence is
|
|
163
|
+
* monotonically increased and a stable uuid is derived from cardId +
|
|
164
|
+
* sequence (idempotent on retry).
|
|
165
|
+
*/
|
|
166
|
+
async pushContent(content) {
|
|
167
|
+
if (!this.cardId)
|
|
168
|
+
return;
|
|
169
|
+
if (content.length > LARK_STREAM_ELEMENT_MAX_CHARS) {
|
|
170
|
+
await this.rollover(content);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.sequence += 1;
|
|
174
|
+
await this.options.upstream.updateCardElementContent(this.cardId, STREAM_MARKDOWN_ELEMENT_ID, content || ' ', this.sequence, uuidFor(this.cardId, this.sequence));
|
|
175
|
+
this.record('streamed', content, undefined, this.sequence);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Finalize the current card with the head content and start a fresh
|
|
179
|
+
* streaming card for the tail. Mirrors the official SDK Channel rollover:
|
|
180
|
+
* the current element is pinned to the head, streaming is closed on it, and
|
|
181
|
+
* a new streaming entity is created + sent seeded with the tail.
|
|
182
|
+
*/
|
|
183
|
+
async rollover(content) {
|
|
184
|
+
if (!this.cardId)
|
|
185
|
+
return;
|
|
186
|
+
// A rollover cannot produce a single splittable piece below the cap.
|
|
187
|
+
const chunks = splitForRollover(content, LARK_STREAM_ELEMENT_MAX_CHARS);
|
|
188
|
+
if (chunks.length < 2)
|
|
189
|
+
throw new Error('rollover: content not splittable below limit');
|
|
190
|
+
const head = chunks[0];
|
|
191
|
+
const joinedWithNewlines = chunks.slice(1).join('\n');
|
|
192
|
+
const joinedWithoutSeparators = chunks.slice(1).join('');
|
|
193
|
+
// Keep the splitter's original character sequence intact. Line-oriented
|
|
194
|
+
// chunks need their delimiters; hard-wrapped tokens must not gain '\n'.
|
|
195
|
+
const tail = joinedWithNewlines.length + head.length === content.length
|
|
196
|
+
? joinedWithNewlines
|
|
197
|
+
: joinedWithoutSeparators;
|
|
198
|
+
// 1. Pin the current element to the head.
|
|
199
|
+
this.sequence += 1;
|
|
200
|
+
await this.options.upstream.updateCardElementContent(this.cardId, STREAM_MARKDOWN_ELEMENT_ID, head, this.sequence, uuidFor(this.cardId, this.sequence));
|
|
201
|
+
// 2. Close streaming on the old card (best-effort).
|
|
202
|
+
try {
|
|
203
|
+
await this.closeStreaming(head);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Feishu auto-closes an idle stream; a transient failure is recoverable.
|
|
207
|
+
}
|
|
208
|
+
this.hasRolledOver = true;
|
|
209
|
+
// 3. Create + send the fresh streaming card seeded with the tail.
|
|
210
|
+
const previousCardId = this.cardId;
|
|
211
|
+
const cardJson = streamingCardJson(tail || '...', GENERATING_SUMMARY);
|
|
212
|
+
const entity = await this.options.upstream.createCardEntity(cardJson);
|
|
213
|
+
const sent = await this.options.upstream.sendCardEntity(this.options.target.conversationId, entity.cardId);
|
|
214
|
+
this.cardId = entity.cardId;
|
|
215
|
+
this.messageId = sent.messageId;
|
|
216
|
+
this.sequence = 0;
|
|
217
|
+
this.content = tail;
|
|
218
|
+
this.chunkIds.push(sent.messageId);
|
|
219
|
+
this.record('rollover', tail, undefined, 0, previousCardId);
|
|
220
|
+
// Continue streaming the tail immediately.
|
|
221
|
+
await this.pushContent(tail);
|
|
222
|
+
}
|
|
223
|
+
/** Close the stream: `streaming_mode: false` + final summary. */
|
|
224
|
+
async closeStreaming(summaryText) {
|
|
225
|
+
if (!this.cardId)
|
|
226
|
+
return;
|
|
227
|
+
this.sequence += 1;
|
|
228
|
+
await this.options.upstream.finishStreamingCard(this.cardId, this.sequence, truncateSummary(summaryText));
|
|
229
|
+
}
|
|
230
|
+
record(kind, text, error, sequence, cardId) {
|
|
231
|
+
const update = {
|
|
232
|
+
kind,
|
|
233
|
+
text,
|
|
234
|
+
at: this.now(),
|
|
235
|
+
};
|
|
236
|
+
if (sequence !== undefined)
|
|
237
|
+
update.sequence = sequence;
|
|
238
|
+
if (cardId !== undefined)
|
|
239
|
+
update.cardId = cardId;
|
|
240
|
+
if (error !== undefined)
|
|
241
|
+
update.error = String(error);
|
|
242
|
+
this.updates.push(update);
|
|
243
|
+
}
|
|
244
|
+
enqueue(task) {
|
|
245
|
+
const next = this.queue.then(task, task);
|
|
246
|
+
this.queue = next.then(() => undefined, () => undefined);
|
|
247
|
+
return next;
|
|
248
|
+
}
|
|
249
|
+
replaceText(next) {
|
|
250
|
+
if (!this.hasRolledOver) {
|
|
251
|
+
this.text = next;
|
|
252
|
+
this.content = next;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!next.startsWith(this.text)) {
|
|
256
|
+
throw new ChannelError('CHANNEL_ERROR', 'lark streaming replacement cannot rewrite content already finalized by card rollover');
|
|
257
|
+
}
|
|
258
|
+
this.content += next.slice(this.text.length);
|
|
259
|
+
this.text = next;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Stable request uuid derived from the card entity id + sequence. */
|
|
263
|
+
function uuidFor(cardId, sequence) {
|
|
264
|
+
return `c_${cardId}_${sequence}`;
|
|
265
|
+
}
|
|
266
|
+
/** Shorten markdown to a single-line preview for the card summary. */
|
|
267
|
+
export function truncateSummary(text, max = 50) {
|
|
268
|
+
if (!text)
|
|
269
|
+
return '';
|
|
270
|
+
const cleaned = text.replace(/\s+/g, ' ').trim();
|
|
271
|
+
return cleaned.length <= max ? cleaned : `${cleaned.slice(0, max - 1)}…`;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Split long content at ~`limit` chars while keeping fenced code blocks whole
|
|
275
|
+
* (mirrors the official SDK Channel splitter). A single line that alone
|
|
276
|
+
* exceeds `limit` (an unbreakable token) is hard-wrapped so the caller can
|
|
277
|
+
* always roll over instead of being stuck with an oversized element.
|
|
278
|
+
*/
|
|
279
|
+
export function splitForRollover(text, limit) {
|
|
280
|
+
if (text.length <= limit)
|
|
281
|
+
return [text];
|
|
282
|
+
const lines = text.split('\n');
|
|
283
|
+
const out = [];
|
|
284
|
+
let buf = [];
|
|
285
|
+
let bufLen = 0;
|
|
286
|
+
let fenceLang = null;
|
|
287
|
+
const flush = () => {
|
|
288
|
+
if (buf.length === 0)
|
|
289
|
+
return;
|
|
290
|
+
let chunk = buf.join('\n');
|
|
291
|
+
if (fenceLang !== null)
|
|
292
|
+
chunk += '\n```';
|
|
293
|
+
out.push(chunk);
|
|
294
|
+
buf = [];
|
|
295
|
+
bufLen = 0;
|
|
296
|
+
if (fenceLang !== null) {
|
|
297
|
+
// Reopen the fence in the next chunk.
|
|
298
|
+
buf.push(`\`\`\`${fenceLang}`);
|
|
299
|
+
bufLen = buf[0].length;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
for (const line of lines) {
|
|
303
|
+
const match = line.match(/^```(\w*)$/);
|
|
304
|
+
const lineLen = line.length + (buf.length > 0 ? 1 : 0);
|
|
305
|
+
const isHeading = /^#{1,6}\s/.test(line);
|
|
306
|
+
const nearFull = bufLen > limit * 0.75;
|
|
307
|
+
if (bufLen + lineLen > limit || (isHeading && nearFull && buf.length > 0)) {
|
|
308
|
+
flush();
|
|
309
|
+
}
|
|
310
|
+
buf.push(line);
|
|
311
|
+
bufLen += lineLen;
|
|
312
|
+
if (match) {
|
|
313
|
+
fenceLang = fenceLang === null ? (match[1] ?? '') : null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
flush();
|
|
317
|
+
// Hard-wrap any remaining oversized chunk (a single unbreakable line/token
|
|
318
|
+
// cannot be split at a line boundary — never leave it above the cap).
|
|
319
|
+
const final = [];
|
|
320
|
+
for (const chunk of out) {
|
|
321
|
+
if (chunk.length <= limit) {
|
|
322
|
+
final.push(chunk);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
for (let i = 0; i < chunk.length; i += limit) {
|
|
326
|
+
final.push(chunk.slice(i, i + limit));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return final.length > 0 ? final : [text];
|
|
330
|
+
}
|
|
331
|
+
function mergeStreamingText(previous, next) {
|
|
332
|
+
if (!previous)
|
|
333
|
+
return next;
|
|
334
|
+
if (!next)
|
|
335
|
+
return previous;
|
|
336
|
+
if (next.startsWith(previous))
|
|
337
|
+
return next;
|
|
338
|
+
if (previous.startsWith(next))
|
|
339
|
+
return previous;
|
|
340
|
+
const maxOverlap = Math.min(previous.length, next.length);
|
|
341
|
+
for (let length = maxOverlap; length > 0; length -= 1) {
|
|
342
|
+
if (previous.endsWith(next.slice(0, length)))
|
|
343
|
+
return previous + next.slice(length);
|
|
344
|
+
}
|
|
345
|
+
return previous + next;
|
|
346
|
+
}
|
|
347
|
+
//# sourceMappingURL=card.js.map
|