@openclaw/feishu 2026.5.2-beta.2 → 2026.5.3-beta.1
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/dist/accounts-Ba3-WP1z.js +423 -0
- package/dist/api.js +2280 -0
- package/dist/app-registration-B8qc1MCM.js +184 -0
- package/dist/audio-preflight.runtime-BPlzkO3l.js +7 -0
- package/dist/card-interaction-BfRLgvw_.js +96 -0
- package/dist/channel-CSD_Jt8I.js +1668 -0
- package/dist/channel-entry.js +22 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.runtime-DYsXcD36.js +700 -0
- package/dist/client-DBVoQL5w.js +157 -0
- package/dist/contract-api.js +9 -0
- package/dist/conversation-id-DWS3Ep2A.js +139 -0
- package/dist/directory.static-f3EeoRJd.js +44 -0
- package/dist/drive-C5eJLJr7.js +883 -0
- package/dist/index.js +68 -0
- package/dist/monitor-CT189QfR.js +60 -0
- package/dist/monitor.account-dJV2jO8C.js +4990 -0
- package/dist/monitor.state-DYM02ipp.js +100 -0
- package/dist/policy-D6c-wMPl.js +118 -0
- package/dist/probe-BNzzU_uR.js +149 -0
- package/dist/rolldown-runtime-DUslC3ob.js +14 -0
- package/dist/runtime-CG0DuRCy.js +8 -0
- package/dist/runtime-api.js +14 -0
- package/dist/secret-contract-Dm4Z_zQN.js +119 -0
- package/dist/secret-contract-api.js +2 -0
- package/dist/security-audit-DqJdocrN.js +11 -0
- package/dist/security-audit-shared-ByuMx9cJ.js +38 -0
- package/dist/security-contract-api.js +2 -0
- package/dist/send-DowxxbpH.js +1218 -0
- package/dist/session-conversation-B4nrW-vo.js +27 -0
- package/dist/session-key-api.js +2 -0
- package/dist/setup-api.js +2 -0
- package/dist/setup-entry.js +15 -0
- package/dist/subagent-hooks-C3UhPVLV.js +227 -0
- package/dist/subagent-hooks-api.js +23 -0
- package/dist/targets-JMFJRKSe.js +48 -0
- package/dist/thread-bindings-BmS6TLes.js +222 -0
- package/package.json +15 -6
- package/api.ts +0 -31
- package/channel-entry.ts +0 -20
- package/channel-plugin-api.ts +0 -1
- package/contract-api.ts +0 -16
- package/index.ts +0 -82
- package/runtime-api.ts +0 -55
- package/secret-contract-api.ts +0 -5
- package/security-contract-api.ts +0 -1
- package/session-key-api.ts +0 -1
- package/setup-api.ts +0 -3
- package/setup-entry.test.ts +0 -14
- package/setup-entry.ts +0 -13
- package/src/accounts.test.ts +0 -459
- package/src/accounts.ts +0 -326
- package/src/app-registration.ts +0 -331
- package/src/approval-auth.test.ts +0 -24
- package/src/approval-auth.ts +0 -25
- package/src/async.test.ts +0 -35
- package/src/async.ts +0 -104
- package/src/audio-preflight.runtime.ts +0 -9
- package/src/bitable.test.ts +0 -131
- package/src/bitable.ts +0 -762
- package/src/bot-content.ts +0 -474
- package/src/bot-group-name.test.ts +0 -108
- package/src/bot-runtime-api.ts +0 -12
- package/src/bot-sender-name.ts +0 -125
- package/src/bot.broadcast.test.ts +0 -463
- package/src/bot.card-action.test.ts +0 -577
- package/src/bot.checkBotMentioned.test.ts +0 -265
- package/src/bot.helpers.test.ts +0 -118
- package/src/bot.stripBotMention.test.ts +0 -126
- package/src/bot.test.ts +0 -3040
- package/src/bot.ts +0 -1559
- package/src/card-action.ts +0 -447
- package/src/card-interaction.test.ts +0 -129
- package/src/card-interaction.ts +0 -159
- package/src/card-test-helpers.ts +0 -47
- package/src/card-ux-approval.ts +0 -65
- package/src/card-ux-launcher.test.ts +0 -99
- package/src/card-ux-launcher.ts +0 -121
- package/src/card-ux-shared.ts +0 -33
- package/src/channel-runtime-api.ts +0 -16
- package/src/channel.runtime.ts +0 -47
- package/src/channel.test.ts +0 -959
- package/src/channel.ts +0 -1313
- package/src/chat-schema.ts +0 -25
- package/src/chat.test.ts +0 -196
- package/src/chat.ts +0 -188
- package/src/client.test.ts +0 -433
- package/src/client.ts +0 -290
- package/src/comment-dispatcher-runtime-api.ts +0 -6
- package/src/comment-dispatcher.test.ts +0 -169
- package/src/comment-dispatcher.ts +0 -107
- package/src/comment-handler-runtime-api.ts +0 -3
- package/src/comment-handler.test.ts +0 -486
- package/src/comment-handler.ts +0 -309
- package/src/comment-reaction.test.ts +0 -166
- package/src/comment-reaction.ts +0 -259
- package/src/comment-shared.test.ts +0 -182
- package/src/comment-shared.ts +0 -406
- package/src/comment-target.ts +0 -44
- package/src/config-schema.test.ts +0 -309
- package/src/config-schema.ts +0 -333
- package/src/conversation-id.test.ts +0 -18
- package/src/conversation-id.ts +0 -199
- package/src/dedup-runtime-api.ts +0 -1
- package/src/dedup.ts +0 -141
- package/src/directory.static.ts +0 -61
- package/src/directory.test.ts +0 -136
- package/src/directory.ts +0 -124
- package/src/doc-schema.ts +0 -182
- package/src/docx-batch-insert.test.ts +0 -91
- package/src/docx-batch-insert.ts +0 -223
- package/src/docx-color-text.ts +0 -154
- package/src/docx-table-ops.test.ts +0 -53
- package/src/docx-table-ops.ts +0 -316
- package/src/docx-types.ts +0 -38
- package/src/docx.account-selection.test.ts +0 -79
- package/src/docx.test.ts +0 -685
- package/src/docx.ts +0 -1616
- package/src/drive-schema.ts +0 -92
- package/src/drive.test.ts +0 -1219
- package/src/drive.ts +0 -829
- package/src/dynamic-agent.ts +0 -137
- package/src/event-types.ts +0 -45
- package/src/external-keys.test.ts +0 -20
- package/src/external-keys.ts +0 -19
- package/src/lifecycle.test-support.ts +0 -220
- package/src/media.test.ts +0 -900
- package/src/media.ts +0 -861
- package/src/mention-target.types.ts +0 -5
- package/src/mention.ts +0 -114
- package/src/message-action-contract.ts +0 -13
- package/src/monitor-state-runtime-api.ts +0 -7
- package/src/monitor-transport-runtime-api.ts +0 -7
- package/src/monitor.account.ts +0 -468
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +0 -219
- package/src/monitor.bot-identity.ts +0 -86
- package/src/monitor.bot-menu-handler.ts +0 -165
- package/src/monitor.bot-menu.lifecycle.test-support.ts +0 -224
- package/src/monitor.bot-menu.test.ts +0 -178
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +0 -264
- package/src/monitor.card-action.lifecycle.test-support.ts +0 -373
- package/src/monitor.cleanup.test.ts +0 -376
- package/src/monitor.comment-notice-handler.ts +0 -105
- package/src/monitor.comment.test.ts +0 -937
- package/src/monitor.comment.ts +0 -1386
- package/src/monitor.lifecycle.test.ts +0 -4
- package/src/monitor.message-handler.ts +0 -339
- package/src/monitor.reaction.lifecycle.test-support.ts +0 -68
- package/src/monitor.reaction.test.ts +0 -713
- package/src/monitor.startup.test.ts +0 -192
- package/src/monitor.startup.ts +0 -74
- package/src/monitor.state.defaults.test.ts +0 -46
- package/src/monitor.state.ts +0 -170
- package/src/monitor.synthetic-error.ts +0 -18
- package/src/monitor.test-mocks.ts +0 -45
- package/src/monitor.transport.ts +0 -424
- package/src/monitor.ts +0 -100
- package/src/monitor.webhook-e2e.test.ts +0 -272
- package/src/monitor.webhook-security.test.ts +0 -264
- package/src/monitor.webhook.test-helpers.ts +0 -116
- package/src/outbound-runtime-api.ts +0 -1
- package/src/outbound.test.ts +0 -935
- package/src/outbound.ts +0 -718
- package/src/perm-schema.ts +0 -52
- package/src/perm.ts +0 -170
- package/src/pins.ts +0 -108
- package/src/policy.test.ts +0 -334
- package/src/policy.ts +0 -236
- package/src/post.test.ts +0 -105
- package/src/post.ts +0 -275
- package/src/probe.test.ts +0 -275
- package/src/probe.ts +0 -166
- package/src/processing-claims.ts +0 -59
- package/src/qr-terminal.ts +0 -1
- package/src/reactions.ts +0 -123
- package/src/reasoning-preview.test.ts +0 -59
- package/src/reasoning-preview.ts +0 -20
- package/src/reply-dispatcher-runtime-api.ts +0 -7
- package/src/reply-dispatcher.test.ts +0 -1144
- package/src/reply-dispatcher.ts +0 -650
- package/src/runtime.ts +0 -9
- package/src/secret-contract.ts +0 -145
- package/src/secret-input.ts +0 -1
- package/src/security-audit-shared.ts +0 -69
- package/src/security-audit.test.ts +0 -61
- package/src/security-audit.ts +0 -1
- package/src/send-result.ts +0 -29
- package/src/send-target.test.ts +0 -80
- package/src/send-target.ts +0 -35
- package/src/send.reply-fallback.test.ts +0 -292
- package/src/send.test.ts +0 -550
- package/src/send.ts +0 -800
- package/src/sequential-key.test.ts +0 -72
- package/src/sequential-key.ts +0 -28
- package/src/sequential-queue.test.ts +0 -92
- package/src/sequential-queue.ts +0 -16
- package/src/session-conversation.ts +0 -42
- package/src/session-route.ts +0 -48
- package/src/setup-core.ts +0 -51
- package/src/setup-surface.test.ts +0 -174
- package/src/setup-surface.ts +0 -581
- package/src/streaming-card.test.ts +0 -190
- package/src/streaming-card.ts +0 -490
- package/src/subagent-hooks.test.ts +0 -603
- package/src/subagent-hooks.ts +0 -397
- package/src/targets.ts +0 -97
- package/src/test-support/lifecycle-test-support.ts +0 -453
- package/src/thread-bindings.test.ts +0 -143
- package/src/thread-bindings.ts +0 -330
- package/src/tool-account-routing.test.ts +0 -187
- package/src/tool-account.test.ts +0 -44
- package/src/tool-account.ts +0 -93
- package/src/tool-factory-test-harness.ts +0 -79
- package/src/tool-result.test.ts +0 -32
- package/src/tool-result.ts +0 -16
- package/src/tools-config.test.ts +0 -21
- package/src/tools-config.ts +0 -22
- package/src/types.ts +0 -104
- package/src/typing.test.ts +0 -144
- package/src/typing.ts +0 -214
- package/src/wiki-schema.ts +0 -55
- package/src/wiki.ts +0 -227
- package/subagent-hooks-api.ts +0 -31
- package/tsconfig.json +0 -16
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
|
4
|
-
|
|
5
|
-
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
|
6
|
-
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
|
7
|
-
}));
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
FeishuStreamingSession,
|
|
11
|
-
mergeStreamingText,
|
|
12
|
-
resolveStreamingCardSendMode,
|
|
13
|
-
} from "./streaming-card.js";
|
|
14
|
-
|
|
15
|
-
type StreamingSessionState = {
|
|
16
|
-
cardId: string;
|
|
17
|
-
messageId: string;
|
|
18
|
-
sequence: number;
|
|
19
|
-
currentText: string;
|
|
20
|
-
hasNote: boolean;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
function setStreamingSessionInternals(
|
|
24
|
-
session: FeishuStreamingSession,
|
|
25
|
-
values: {
|
|
26
|
-
state: StreamingSessionState;
|
|
27
|
-
lastUpdateTime?: number;
|
|
28
|
-
},
|
|
29
|
-
) {
|
|
30
|
-
const internals = session as unknown as {
|
|
31
|
-
state: StreamingSessionState;
|
|
32
|
-
lastUpdateTime: number;
|
|
33
|
-
};
|
|
34
|
-
internals.state = values.state;
|
|
35
|
-
if (values.lastUpdateTime !== undefined) {
|
|
36
|
-
internals.lastUpdateTime = values.lastUpdateTime;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe("FeishuStreamingSession", () => {
|
|
41
|
-
beforeEach(() => {
|
|
42
|
-
vi.useRealTimers();
|
|
43
|
-
fetchWithSsrFGuardMock.mockReset();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
function mockFetches(updateBodies: string[]) {
|
|
47
|
-
fetchWithSsrFGuardMock.mockImplementation(
|
|
48
|
-
async ({ url, init }: { url: string; init?: { body?: string } }) => {
|
|
49
|
-
const release = vi.fn(async () => {});
|
|
50
|
-
if (url.includes("/auth/")) {
|
|
51
|
-
return {
|
|
52
|
-
response: {
|
|
53
|
-
ok: true,
|
|
54
|
-
json: async () => ({
|
|
55
|
-
code: 0,
|
|
56
|
-
msg: "ok",
|
|
57
|
-
tenant_access_token: "token",
|
|
58
|
-
expire: 7200,
|
|
59
|
-
}),
|
|
60
|
-
},
|
|
61
|
-
release,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
if (url.includes("/elements/content/content")) {
|
|
65
|
-
updateBodies.push(init?.body ?? "");
|
|
66
|
-
}
|
|
67
|
-
return {
|
|
68
|
-
response: {
|
|
69
|
-
ok: true,
|
|
70
|
-
json: async () => ({ code: 0, msg: "ok" }),
|
|
71
|
-
},
|
|
72
|
-
release,
|
|
73
|
-
};
|
|
74
|
-
},
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
it("flushes throttled pending text after the throttle window", async () => {
|
|
79
|
-
vi.useFakeTimers();
|
|
80
|
-
vi.setSystemTime(1_000);
|
|
81
|
-
const updateBodies: string[] = [];
|
|
82
|
-
mockFetches(updateBodies);
|
|
83
|
-
|
|
84
|
-
const session = new FeishuStreamingSession({} as never, {
|
|
85
|
-
appId: "app_pending_flush",
|
|
86
|
-
appSecret: "secret",
|
|
87
|
-
});
|
|
88
|
-
setStreamingSessionInternals(session, {
|
|
89
|
-
state: {
|
|
90
|
-
cardId: "card_1",
|
|
91
|
-
messageId: "om_1",
|
|
92
|
-
sequence: 1,
|
|
93
|
-
currentText: "hello",
|
|
94
|
-
hasNote: false,
|
|
95
|
-
},
|
|
96
|
-
lastUpdateTime: 1_000,
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
await session.update("hello small");
|
|
100
|
-
expect(updateBodies).toHaveLength(0);
|
|
101
|
-
|
|
102
|
-
await vi.advanceTimersByTimeAsync(160);
|
|
103
|
-
|
|
104
|
-
expect(updateBodies).toHaveLength(1);
|
|
105
|
-
expect(JSON.parse(updateBodies[0] ?? "{}")).toMatchObject({
|
|
106
|
-
content: "hello small",
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("pushes natural-boundary updates immediately inside the throttle window", async () => {
|
|
111
|
-
vi.useFakeTimers();
|
|
112
|
-
vi.setSystemTime(2_000);
|
|
113
|
-
const updateBodies: string[] = [];
|
|
114
|
-
mockFetches(updateBodies);
|
|
115
|
-
|
|
116
|
-
const session = new FeishuStreamingSession({} as never, {
|
|
117
|
-
appId: "app_boundary_flush",
|
|
118
|
-
appSecret: "secret",
|
|
119
|
-
});
|
|
120
|
-
setStreamingSessionInternals(session, {
|
|
121
|
-
state: {
|
|
122
|
-
cardId: "card_2",
|
|
123
|
-
messageId: "om_2",
|
|
124
|
-
sequence: 1,
|
|
125
|
-
currentText: "hello",
|
|
126
|
-
hasNote: false,
|
|
127
|
-
},
|
|
128
|
-
lastUpdateTime: 2_000,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
await session.update("hello!");
|
|
132
|
-
|
|
133
|
-
expect(updateBodies).toHaveLength(1);
|
|
134
|
-
expect(JSON.parse(updateBodies[0] ?? "{}")).toMatchObject({
|
|
135
|
-
content: "hello!",
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
describe("mergeStreamingText", () => {
|
|
141
|
-
it("prefers the latest full text when it already includes prior text", () => {
|
|
142
|
-
expect(mergeStreamingText("hello", "hello world")).toBe("hello world");
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("keeps previous text when the next partial is empty or redundant", () => {
|
|
146
|
-
expect(mergeStreamingText("hello", "")).toBe("hello");
|
|
147
|
-
expect(mergeStreamingText("hello world", "hello")).toBe("hello world");
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("appends fragmented chunks without injecting newlines", () => {
|
|
151
|
-
expect(mergeStreamingText("hello wor", "ld")).toBe("hello world");
|
|
152
|
-
expect(mergeStreamingText("line1", "line2")).toBe("line1line2");
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("merges overlap between adjacent partial snapshots", () => {
|
|
156
|
-
expect(mergeStreamingText("好的,让我", "让我再读取一遍")).toBe("好的,让我再读取一遍");
|
|
157
|
-
expect(mergeStreamingText("revision_id: 552", "2,一点变化都没有")).toBe(
|
|
158
|
-
"revision_id: 552,一点变化都没有",
|
|
159
|
-
);
|
|
160
|
-
expect(mergeStreamingText("abc", "cabc")).toBe("cabc");
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
describe("resolveStreamingCardSendMode", () => {
|
|
165
|
-
it("prefers message.reply when reply target and root id both exist", () => {
|
|
166
|
-
expect(
|
|
167
|
-
resolveStreamingCardSendMode({
|
|
168
|
-
replyToMessageId: "om_parent",
|
|
169
|
-
rootId: "om_topic_root",
|
|
170
|
-
}),
|
|
171
|
-
).toBe("reply");
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("falls back to root create when reply target is absent", () => {
|
|
175
|
-
expect(
|
|
176
|
-
resolveStreamingCardSendMode({
|
|
177
|
-
rootId: "om_topic_root",
|
|
178
|
-
}),
|
|
179
|
-
).toBe("root_create");
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("uses create mode when no reply routing fields are provided", () => {
|
|
183
|
-
expect(resolveStreamingCardSendMode()).toBe("create");
|
|
184
|
-
expect(
|
|
185
|
-
resolveStreamingCardSendMode({
|
|
186
|
-
replyInThread: true,
|
|
187
|
-
}),
|
|
188
|
-
).toBe("create");
|
|
189
|
-
});
|
|
190
|
-
});
|
package/src/streaming-card.ts
DELETED
|
@@ -1,490 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Feishu Streaming Card - Card Kit streaming API for real-time text output
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { Client } from "@larksuiteoapi/node-sdk";
|
|
6
|
-
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
7
|
-
import { getFeishuUserAgent } from "./client.js";
|
|
8
|
-
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
|
|
9
|
-
import type { FeishuDomain } from "./types.js";
|
|
10
|
-
|
|
11
|
-
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
|
|
12
|
-
type CardState = {
|
|
13
|
-
cardId: string;
|
|
14
|
-
messageId: string;
|
|
15
|
-
sequence: number;
|
|
16
|
-
currentText: string;
|
|
17
|
-
hasNote: boolean;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
/** Options for customising the initial streaming card appearance. */
|
|
21
|
-
type StreamingCardOptions = {
|
|
22
|
-
/** Optional header with title and color template. */
|
|
23
|
-
header?: CardHeaderConfig;
|
|
24
|
-
/** Optional grey note footer text. */
|
|
25
|
-
note?: string;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/** Optional header for streaming cards (title bar with color template) */
|
|
29
|
-
type StreamingCardHeader = {
|
|
30
|
-
title: string;
|
|
31
|
-
/** Color template: blue, green, red, orange, purple, indigo, wathet, turquoise, yellow, grey, carmine, violet, lime */
|
|
32
|
-
template?: string;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
type StreamingStartOptions = {
|
|
36
|
-
replyToMessageId?: string;
|
|
37
|
-
replyInThread?: boolean;
|
|
38
|
-
rootId?: string;
|
|
39
|
-
header?: StreamingCardHeader;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const STREAMING_UPDATE_THROTTLE_MS = 160;
|
|
43
|
-
const STREAMING_SIGNIFICANT_DELTA_CHARS = 18;
|
|
44
|
-
|
|
45
|
-
// Token cache (keyed by domain + appId)
|
|
46
|
-
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
|
47
|
-
|
|
48
|
-
function resolveApiBase(domain?: FeishuDomain): string {
|
|
49
|
-
if (domain === "lark") {
|
|
50
|
-
return "https://open.larksuite.com/open-apis";
|
|
51
|
-
}
|
|
52
|
-
if (domain && domain !== "feishu" && domain.startsWith("http")) {
|
|
53
|
-
return `${domain.replace(/\/+$/, "")}/open-apis`;
|
|
54
|
-
}
|
|
55
|
-
return "https://open.feishu.cn/open-apis";
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
|
|
59
|
-
if (domain === "lark") {
|
|
60
|
-
return ["open.larksuite.com"];
|
|
61
|
-
}
|
|
62
|
-
if (domain && domain !== "feishu" && domain.startsWith("http")) {
|
|
63
|
-
try {
|
|
64
|
-
return [new URL(domain).hostname];
|
|
65
|
-
} catch {
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return ["open.feishu.cn"];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function getToken(creds: Credentials): Promise<string> {
|
|
73
|
-
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
|
74
|
-
const cached = tokenCache.get(key);
|
|
75
|
-
if (cached && cached.expiresAt > Date.now() + 60000) {
|
|
76
|
-
return cached.token;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const { response, release } = await fetchWithSsrFGuard({
|
|
80
|
-
url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`,
|
|
81
|
-
init: {
|
|
82
|
-
method: "POST",
|
|
83
|
-
headers: { "Content-Type": "application/json", "User-Agent": getFeishuUserAgent() },
|
|
84
|
-
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
|
|
85
|
-
},
|
|
86
|
-
policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
|
|
87
|
-
auditContext: "feishu.streaming-card.token",
|
|
88
|
-
});
|
|
89
|
-
if (!response.ok) {
|
|
90
|
-
await release();
|
|
91
|
-
throw new Error(`Token request failed with HTTP ${response.status}`);
|
|
92
|
-
}
|
|
93
|
-
const data = (await response.json()) as {
|
|
94
|
-
code: number;
|
|
95
|
-
msg: string;
|
|
96
|
-
tenant_access_token?: string;
|
|
97
|
-
expire?: number;
|
|
98
|
-
};
|
|
99
|
-
await release();
|
|
100
|
-
if (data.code !== 0 || !data.tenant_access_token) {
|
|
101
|
-
throw new Error(`Token error: ${data.msg}`);
|
|
102
|
-
}
|
|
103
|
-
tokenCache.set(key, {
|
|
104
|
-
token: data.tenant_access_token,
|
|
105
|
-
expiresAt: Date.now() + (data.expire ?? 7200) * 1000,
|
|
106
|
-
});
|
|
107
|
-
return data.tenant_access_token;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function truncateSummary(text: string, max = 50): string {
|
|
111
|
-
if (!text) {
|
|
112
|
-
return "";
|
|
113
|
-
}
|
|
114
|
-
const clean = text.replace(/\n/g, " ").trim();
|
|
115
|
-
return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function hasNaturalStreamingBoundary(text: string): boolean {
|
|
119
|
-
return /[\n。!?!?;;::]$/.test(text);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function shouldPushStreamingUpdate(previousText: string, nextText: string): boolean {
|
|
123
|
-
if (!previousText) {
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
if (hasNaturalStreamingBoundary(nextText)) {
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
return nextText.length - previousText.length >= STREAMING_SIGNIFICANT_DELTA_CHARS;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function mergeStreamingText(
|
|
133
|
-
previousText: string | undefined,
|
|
134
|
-
nextText: string | undefined,
|
|
135
|
-
): string {
|
|
136
|
-
const previous = typeof previousText === "string" ? previousText : "";
|
|
137
|
-
const next = typeof nextText === "string" ? nextText : "";
|
|
138
|
-
if (!next) {
|
|
139
|
-
return previous;
|
|
140
|
-
}
|
|
141
|
-
if (!previous || next === previous) {
|
|
142
|
-
return next;
|
|
143
|
-
}
|
|
144
|
-
if (next.startsWith(previous)) {
|
|
145
|
-
return next;
|
|
146
|
-
}
|
|
147
|
-
if (previous.startsWith(next)) {
|
|
148
|
-
return previous;
|
|
149
|
-
}
|
|
150
|
-
if (next.includes(previous)) {
|
|
151
|
-
return next;
|
|
152
|
-
}
|
|
153
|
-
if (previous.includes(next)) {
|
|
154
|
-
return previous;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Merge partial overlaps, e.g. "这" + "这是" => "这是".
|
|
158
|
-
const maxOverlap = Math.min(previous.length, next.length);
|
|
159
|
-
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
|
160
|
-
if (previous.slice(-overlap) === next.slice(0, overlap)) {
|
|
161
|
-
return `${previous}${next.slice(overlap)}`;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
// Fallback for fragmented partial chunks: append as-is to avoid losing tokens.
|
|
165
|
-
return `${previous}${next}`;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export function resolveStreamingCardSendMode(options?: StreamingStartOptions) {
|
|
169
|
-
if (options?.replyToMessageId) {
|
|
170
|
-
return "reply";
|
|
171
|
-
}
|
|
172
|
-
if (options?.rootId) {
|
|
173
|
-
return "root_create";
|
|
174
|
-
}
|
|
175
|
-
return "create";
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Streaming card session manager */
|
|
179
|
-
export class FeishuStreamingSession {
|
|
180
|
-
private client: Client;
|
|
181
|
-
private creds: Credentials;
|
|
182
|
-
private state: CardState | null = null;
|
|
183
|
-
private queue: Promise<void> = Promise.resolve();
|
|
184
|
-
private closed = false;
|
|
185
|
-
private log?: (msg: string) => void;
|
|
186
|
-
private lastUpdateTime = 0;
|
|
187
|
-
private pendingText: string | null = null;
|
|
188
|
-
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
189
|
-
private updateThrottleMs = STREAMING_UPDATE_THROTTLE_MS;
|
|
190
|
-
|
|
191
|
-
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
|
|
192
|
-
this.client = client;
|
|
193
|
-
this.creds = creds;
|
|
194
|
-
this.log = log;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
async start(
|
|
198
|
-
receiveId: string,
|
|
199
|
-
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
|
|
200
|
-
options?: StreamingCardOptions & StreamingStartOptions,
|
|
201
|
-
): Promise<void> {
|
|
202
|
-
if (this.state) {
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const apiBase = resolveApiBase(this.creds.domain);
|
|
207
|
-
const elements: Record<string, unknown>[] = [
|
|
208
|
-
{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" },
|
|
209
|
-
];
|
|
210
|
-
if (options?.note) {
|
|
211
|
-
elements.push({ tag: "hr" });
|
|
212
|
-
elements.push({
|
|
213
|
-
tag: "markdown",
|
|
214
|
-
content: `<font color='grey'>${options.note}</font>`,
|
|
215
|
-
element_id: "note",
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
const cardJson: Record<string, unknown> = {
|
|
219
|
-
schema: "2.0",
|
|
220
|
-
config: {
|
|
221
|
-
streaming_mode: true,
|
|
222
|
-
summary: { content: "[Generating...]" },
|
|
223
|
-
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } },
|
|
224
|
-
},
|
|
225
|
-
body: { elements },
|
|
226
|
-
};
|
|
227
|
-
if (options?.header) {
|
|
228
|
-
cardJson.header = {
|
|
229
|
-
title: { tag: "plain_text", content: options.header.title },
|
|
230
|
-
template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Create card entity
|
|
235
|
-
const { response: createRes, release: releaseCreate } = await fetchWithSsrFGuard({
|
|
236
|
-
url: `${apiBase}/cardkit/v1/cards`,
|
|
237
|
-
init: {
|
|
238
|
-
method: "POST",
|
|
239
|
-
headers: {
|
|
240
|
-
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
241
|
-
"Content-Type": "application/json",
|
|
242
|
-
"User-Agent": getFeishuUserAgent(),
|
|
243
|
-
},
|
|
244
|
-
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
|
|
245
|
-
},
|
|
246
|
-
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
247
|
-
auditContext: "feishu.streaming-card.create",
|
|
248
|
-
});
|
|
249
|
-
if (!createRes.ok) {
|
|
250
|
-
await releaseCreate();
|
|
251
|
-
throw new Error(`Create card request failed with HTTP ${createRes.status}`);
|
|
252
|
-
}
|
|
253
|
-
const createData = (await createRes.json()) as {
|
|
254
|
-
code: number;
|
|
255
|
-
msg: string;
|
|
256
|
-
data?: { card_id: string };
|
|
257
|
-
};
|
|
258
|
-
await releaseCreate();
|
|
259
|
-
if (createData.code !== 0 || !createData.data?.card_id) {
|
|
260
|
-
throw new Error(`Create card failed: ${createData.msg}`);
|
|
261
|
-
}
|
|
262
|
-
const cardId = createData.data.card_id;
|
|
263
|
-
const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } });
|
|
264
|
-
|
|
265
|
-
// Prefer message.reply when we have a reply target — reply_in_thread
|
|
266
|
-
// reliably routes streaming cards into Feishu topics, whereas
|
|
267
|
-
// message.create with root_id may silently ignore root_id for card
|
|
268
|
-
// references (card_id format).
|
|
269
|
-
let sendRes;
|
|
270
|
-
const sendOptions = options ?? {};
|
|
271
|
-
const sendMode = resolveStreamingCardSendMode(sendOptions);
|
|
272
|
-
if (sendMode === "reply") {
|
|
273
|
-
sendRes = await this.client.im.message.reply({
|
|
274
|
-
path: { message_id: sendOptions.replyToMessageId! },
|
|
275
|
-
data: {
|
|
276
|
-
msg_type: "interactive",
|
|
277
|
-
content: cardContent,
|
|
278
|
-
...(sendOptions.replyInThread ? { reply_in_thread: true } : {}),
|
|
279
|
-
},
|
|
280
|
-
});
|
|
281
|
-
} else if (sendMode === "root_create") {
|
|
282
|
-
// root_id is undeclared in the SDK types but accepted at runtime
|
|
283
|
-
sendRes = await this.client.im.message.create({
|
|
284
|
-
params: { receive_id_type: receiveIdType },
|
|
285
|
-
data: Object.assign(
|
|
286
|
-
{ receive_id: receiveId, msg_type: "interactive", content: cardContent },
|
|
287
|
-
{ root_id: sendOptions.rootId },
|
|
288
|
-
),
|
|
289
|
-
});
|
|
290
|
-
} else {
|
|
291
|
-
sendRes = await this.client.im.message.create({
|
|
292
|
-
params: { receive_id_type: receiveIdType },
|
|
293
|
-
data: {
|
|
294
|
-
receive_id: receiveId,
|
|
295
|
-
msg_type: "interactive",
|
|
296
|
-
content: cardContent,
|
|
297
|
-
},
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
|
|
301
|
-
throw new Error(`Send card failed: ${sendRes.msg}`);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
this.state = {
|
|
305
|
-
cardId,
|
|
306
|
-
messageId: sendRes.data.message_id,
|
|
307
|
-
sequence: 1,
|
|
308
|
-
currentText: "",
|
|
309
|
-
hasNote: !!options?.note,
|
|
310
|
-
};
|
|
311
|
-
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private async updateCardContent(text: string, onError?: (error: unknown) => void): Promise<void> {
|
|
315
|
-
if (!this.state) {
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
const apiBase = resolveApiBase(this.creds.domain);
|
|
319
|
-
this.state.sequence += 1;
|
|
320
|
-
await fetchWithSsrFGuard({
|
|
321
|
-
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`,
|
|
322
|
-
init: {
|
|
323
|
-
method: "PUT",
|
|
324
|
-
headers: {
|
|
325
|
-
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
326
|
-
"Content-Type": "application/json",
|
|
327
|
-
"User-Agent": getFeishuUserAgent(),
|
|
328
|
-
},
|
|
329
|
-
body: JSON.stringify({
|
|
330
|
-
content: text,
|
|
331
|
-
sequence: this.state.sequence,
|
|
332
|
-
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
|
333
|
-
}),
|
|
334
|
-
},
|
|
335
|
-
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
336
|
-
auditContext: "feishu.streaming-card.update",
|
|
337
|
-
})
|
|
338
|
-
.then(async ({ release }) => {
|
|
339
|
-
await release();
|
|
340
|
-
})
|
|
341
|
-
.catch((error) => onError?.(error));
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
private clearFlushTimer(): void {
|
|
345
|
-
if (this.flushTimer) {
|
|
346
|
-
clearTimeout(this.flushTimer);
|
|
347
|
-
this.flushTimer = null;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
private schedulePendingFlush(): void {
|
|
352
|
-
if (this.flushTimer || !this.pendingText || this.closed) {
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
const delayMs = Math.max(0, this.updateThrottleMs - (Date.now() - this.lastUpdateTime));
|
|
356
|
-
this.flushTimer = setTimeout(() => {
|
|
357
|
-
this.flushTimer = null;
|
|
358
|
-
const pending = this.pendingText;
|
|
359
|
-
if (!pending || this.closed) {
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
void this.update(pending);
|
|
363
|
-
}, delayMs);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
async update(text: string): Promise<void> {
|
|
367
|
-
if (!this.state || this.closed) {
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
const mergedInput = mergeStreamingText(this.pendingText ?? this.state.currentText, text);
|
|
371
|
-
if (!mergedInput || mergedInput === this.state.currentText) {
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
this.pendingText = mergedInput;
|
|
375
|
-
this.clearFlushTimer();
|
|
376
|
-
|
|
377
|
-
const shouldForceUpdate = shouldPushStreamingUpdate(this.state.currentText, mergedInput);
|
|
378
|
-
const now = Date.now();
|
|
379
|
-
if (!shouldForceUpdate && now - this.lastUpdateTime < this.updateThrottleMs) {
|
|
380
|
-
this.schedulePendingFlush();
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
this.lastUpdateTime = now;
|
|
384
|
-
|
|
385
|
-
this.queue = this.queue.then(async () => {
|
|
386
|
-
if (!this.state || this.closed) {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
const nextText = this.pendingText ?? mergedInput;
|
|
390
|
-
const mergedText = mergeStreamingText(this.state.currentText, nextText);
|
|
391
|
-
if (!mergedText || mergedText === this.state.currentText) {
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
this.pendingText = null;
|
|
395
|
-
this.state.currentText = mergedText;
|
|
396
|
-
await this.updateCardContent(mergedText, (e) => this.log?.(`Update failed: ${String(e)}`));
|
|
397
|
-
});
|
|
398
|
-
await this.queue;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
private async updateNoteContent(note: string): Promise<void> {
|
|
402
|
-
if (!this.state || !this.state.hasNote) {
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
const apiBase = resolveApiBase(this.creds.domain);
|
|
406
|
-
this.state.sequence += 1;
|
|
407
|
-
await fetchWithSsrFGuard({
|
|
408
|
-
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/note/content`,
|
|
409
|
-
init: {
|
|
410
|
-
method: "PUT",
|
|
411
|
-
headers: {
|
|
412
|
-
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
413
|
-
"Content-Type": "application/json",
|
|
414
|
-
"User-Agent": getFeishuUserAgent(),
|
|
415
|
-
},
|
|
416
|
-
body: JSON.stringify({
|
|
417
|
-
content: `<font color='grey'>${note}</font>`,
|
|
418
|
-
sequence: this.state.sequence,
|
|
419
|
-
uuid: `n_${this.state.cardId}_${this.state.sequence}`,
|
|
420
|
-
}),
|
|
421
|
-
},
|
|
422
|
-
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
423
|
-
auditContext: "feishu.streaming-card.note-update",
|
|
424
|
-
})
|
|
425
|
-
.then(async ({ release }) => {
|
|
426
|
-
await release();
|
|
427
|
-
})
|
|
428
|
-
.catch((e) => this.log?.(`Note update failed: ${String(e)}`));
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
async close(finalText?: string, options?: { note?: string }): Promise<void> {
|
|
432
|
-
if (!this.state || this.closed) {
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
this.closed = true;
|
|
436
|
-
this.clearFlushTimer();
|
|
437
|
-
await this.queue;
|
|
438
|
-
|
|
439
|
-
const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
|
|
440
|
-
const text = finalText ? mergeStreamingText(pendingMerged, finalText) : pendingMerged;
|
|
441
|
-
const apiBase = resolveApiBase(this.creds.domain);
|
|
442
|
-
|
|
443
|
-
// Only send final update if content differs from what's already displayed
|
|
444
|
-
if (text && text !== this.state.currentText) {
|
|
445
|
-
await this.updateCardContent(text);
|
|
446
|
-
this.state.currentText = text;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// Update note with final model/provider info
|
|
450
|
-
if (options?.note) {
|
|
451
|
-
await this.updateNoteContent(options.note);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// Close streaming mode
|
|
455
|
-
this.state.sequence += 1;
|
|
456
|
-
await fetchWithSsrFGuard({
|
|
457
|
-
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`,
|
|
458
|
-
init: {
|
|
459
|
-
method: "PATCH",
|
|
460
|
-
headers: {
|
|
461
|
-
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
462
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
463
|
-
"User-Agent": getFeishuUserAgent(),
|
|
464
|
-
},
|
|
465
|
-
body: JSON.stringify({
|
|
466
|
-
settings: JSON.stringify({
|
|
467
|
-
config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
|
|
468
|
-
}),
|
|
469
|
-
sequence: this.state.sequence,
|
|
470
|
-
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
|
|
471
|
-
}),
|
|
472
|
-
},
|
|
473
|
-
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
474
|
-
auditContext: "feishu.streaming-card.close",
|
|
475
|
-
})
|
|
476
|
-
.then(async ({ release }) => {
|
|
477
|
-
await release();
|
|
478
|
-
})
|
|
479
|
-
.catch((e) => this.log?.(`Close failed: ${String(e)}`));
|
|
480
|
-
const finalState = this.state;
|
|
481
|
-
this.state = null;
|
|
482
|
-
this.pendingText = null;
|
|
483
|
-
|
|
484
|
-
this.log?.(`Closed streaming: cardId=${finalState.cardId}`);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
isActive(): boolean {
|
|
488
|
-
return this.state !== null && !this.closed;
|
|
489
|
-
}
|
|
490
|
-
}
|