@openclaw/feishu 2026.2.9 → 2026.2.13
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/package.json +2 -5
- package/src/bot.checkBotMentioned.test.ts +64 -0
- package/src/bot.test.ts +265 -0
- package/src/bot.ts +153 -52
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +11 -13
- package/src/config-schema.ts +34 -0
- package/src/dedup.ts +33 -0
- package/src/docx.ts +14 -4
- package/src/dynamic-agent.ts +131 -0
- package/src/media.test.ts +151 -0
- package/src/media.ts +27 -13
- package/src/monitor.ts +173 -33
- package/src/reply-dispatcher.test.ts +116 -0
- package/src/reply-dispatcher.ts +124 -67
- package/src/send.ts +11 -7
- package/src/streaming-card.ts +223 -0
- package/src/targets.test.ts +16 -0
- package/src/targets.ts +1 -1
- package/src/types.ts +7 -0
package/src/send.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
2
|
import type { MentionTarget } from "./mention.js";
|
|
3
|
-
import type { FeishuSendResult } from "./types.js";
|
|
3
|
+
import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js";
|
|
4
4
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
5
5
|
import { createFeishuClient } from "./client.js";
|
|
6
6
|
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
|
@@ -281,18 +281,22 @@ export async function updateCardFeishu(params: {
|
|
|
281
281
|
/**
|
|
282
282
|
* Build a Feishu interactive card with markdown content.
|
|
283
283
|
* Cards render markdown properly (code blocks, tables, links, etc.)
|
|
284
|
+
* Uses schema 2.0 format for proper markdown rendering.
|
|
284
285
|
*/
|
|
285
286
|
export function buildMarkdownCard(text: string): Record<string, unknown> {
|
|
286
287
|
return {
|
|
288
|
+
schema: "2.0",
|
|
287
289
|
config: {
|
|
288
290
|
wide_screen_mode: true,
|
|
289
291
|
},
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
292
|
+
body: {
|
|
293
|
+
elements: [
|
|
294
|
+
{
|
|
295
|
+
tag: "markdown",
|
|
296
|
+
content: text,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
296
300
|
};
|
|
297
301
|
}
|
|
298
302
|
|
|
@@ -0,0 +1,223 @@
|
|
|
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 type { FeishuDomain } from "./types.js";
|
|
7
|
+
|
|
8
|
+
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
|
|
9
|
+
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
|
|
10
|
+
|
|
11
|
+
// Token cache (keyed by domain + appId)
|
|
12
|
+
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
|
13
|
+
|
|
14
|
+
function resolveApiBase(domain?: FeishuDomain): string {
|
|
15
|
+
if (domain === "lark") {
|
|
16
|
+
return "https://open.larksuite.com/open-apis";
|
|
17
|
+
}
|
|
18
|
+
if (domain && domain !== "feishu" && domain.startsWith("http")) {
|
|
19
|
+
return `${domain.replace(/\/+$/, "")}/open-apis`;
|
|
20
|
+
}
|
|
21
|
+
return "https://open.feishu.cn/open-apis";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function getToken(creds: Credentials): Promise<string> {
|
|
25
|
+
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
|
26
|
+
const cached = tokenCache.get(key);
|
|
27
|
+
if (cached && cached.expiresAt > Date.now() + 60000) {
|
|
28
|
+
return cached.token;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const res = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
|
|
35
|
+
});
|
|
36
|
+
const data = (await res.json()) as {
|
|
37
|
+
code: number;
|
|
38
|
+
msg: string;
|
|
39
|
+
tenant_access_token?: string;
|
|
40
|
+
expire?: number;
|
|
41
|
+
};
|
|
42
|
+
if (data.code !== 0 || !data.tenant_access_token) {
|
|
43
|
+
throw new Error(`Token error: ${data.msg}`);
|
|
44
|
+
}
|
|
45
|
+
tokenCache.set(key, {
|
|
46
|
+
token: data.tenant_access_token,
|
|
47
|
+
expiresAt: Date.now() + (data.expire ?? 7200) * 1000,
|
|
48
|
+
});
|
|
49
|
+
return data.tenant_access_token;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function truncateSummary(text: string, max = 50): string {
|
|
53
|
+
if (!text) {
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
const clean = text.replace(/\n/g, " ").trim();
|
|
57
|
+
return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Streaming card session manager */
|
|
61
|
+
export class FeishuStreamingSession {
|
|
62
|
+
private client: Client;
|
|
63
|
+
private creds: Credentials;
|
|
64
|
+
private state: CardState | null = null;
|
|
65
|
+
private queue: Promise<void> = Promise.resolve();
|
|
66
|
+
private closed = false;
|
|
67
|
+
private log?: (msg: string) => void;
|
|
68
|
+
private lastUpdateTime = 0;
|
|
69
|
+
private pendingText: string | null = null;
|
|
70
|
+
private updateThrottleMs = 100; // Throttle updates to max 10/sec
|
|
71
|
+
|
|
72
|
+
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
|
|
73
|
+
this.client = client;
|
|
74
|
+
this.creds = creds;
|
|
75
|
+
this.log = log;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async start(
|
|
79
|
+
receiveId: string,
|
|
80
|
+
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
if (this.state) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
87
|
+
const cardJson = {
|
|
88
|
+
schema: "2.0",
|
|
89
|
+
config: {
|
|
90
|
+
streaming_mode: true,
|
|
91
|
+
summary: { content: "[Generating...]" },
|
|
92
|
+
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 2 } },
|
|
93
|
+
},
|
|
94
|
+
body: {
|
|
95
|
+
elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Create card entity
|
|
100
|
+
const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
|
|
107
|
+
});
|
|
108
|
+
const createData = (await createRes.json()) as {
|
|
109
|
+
code: number;
|
|
110
|
+
msg: string;
|
|
111
|
+
data?: { card_id: string };
|
|
112
|
+
};
|
|
113
|
+
if (createData.code !== 0 || !createData.data?.card_id) {
|
|
114
|
+
throw new Error(`Create card failed: ${createData.msg}`);
|
|
115
|
+
}
|
|
116
|
+
const cardId = createData.data.card_id;
|
|
117
|
+
|
|
118
|
+
// Send card message
|
|
119
|
+
const sendRes = await this.client.im.message.create({
|
|
120
|
+
params: { receive_id_type: receiveIdType },
|
|
121
|
+
data: {
|
|
122
|
+
receive_id: receiveId,
|
|
123
|
+
msg_type: "interactive",
|
|
124
|
+
content: JSON.stringify({ type: "card", data: { card_id: cardId } }),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
|
|
128
|
+
throw new Error(`Send card failed: ${sendRes.msg}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
|
|
132
|
+
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async update(text: string): Promise<void> {
|
|
136
|
+
if (!this.state || this.closed) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Throttle: skip if updated recently, but remember pending text
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
if (now - this.lastUpdateTime < this.updateThrottleMs) {
|
|
142
|
+
this.pendingText = text;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.pendingText = null;
|
|
146
|
+
this.lastUpdateTime = now;
|
|
147
|
+
|
|
148
|
+
this.queue = this.queue.then(async () => {
|
|
149
|
+
if (!this.state || this.closed) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.state.currentText = text;
|
|
153
|
+
this.state.sequence += 1;
|
|
154
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
155
|
+
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
|
|
156
|
+
method: "PUT",
|
|
157
|
+
headers: {
|
|
158
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
159
|
+
"Content-Type": "application/json",
|
|
160
|
+
},
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
content: text,
|
|
163
|
+
sequence: this.state.sequence,
|
|
164
|
+
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
|
165
|
+
}),
|
|
166
|
+
}).catch((e) => this.log?.(`Update failed: ${String(e)}`));
|
|
167
|
+
});
|
|
168
|
+
await this.queue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async close(finalText?: string): Promise<void> {
|
|
172
|
+
if (!this.state || this.closed) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
this.closed = true;
|
|
176
|
+
await this.queue;
|
|
177
|
+
|
|
178
|
+
// Use finalText, or pending throttled text, or current text
|
|
179
|
+
const text = finalText ?? this.pendingText ?? this.state.currentText;
|
|
180
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
181
|
+
|
|
182
|
+
// Only send final update if content differs from what's already displayed
|
|
183
|
+
if (text && text !== this.state.currentText) {
|
|
184
|
+
this.state.sequence += 1;
|
|
185
|
+
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
|
|
186
|
+
method: "PUT",
|
|
187
|
+
headers: {
|
|
188
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
},
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
content: text,
|
|
193
|
+
sequence: this.state.sequence,
|
|
194
|
+
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
|
195
|
+
}),
|
|
196
|
+
}).catch(() => {});
|
|
197
|
+
this.state.currentText = text;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Close streaming mode
|
|
201
|
+
this.state.sequence += 1;
|
|
202
|
+
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, {
|
|
203
|
+
method: "PATCH",
|
|
204
|
+
headers: {
|
|
205
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
206
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
207
|
+
},
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
settings: JSON.stringify({
|
|
210
|
+
config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
|
|
211
|
+
}),
|
|
212
|
+
sequence: this.state.sequence,
|
|
213
|
+
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
|
|
214
|
+
}),
|
|
215
|
+
}).catch((e) => this.log?.(`Close failed: ${String(e)}`));
|
|
216
|
+
|
|
217
|
+
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
isActive(): boolean {
|
|
221
|
+
return this.state !== null && !this.closed;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveReceiveIdType } from "./targets.js";
|
|
3
|
+
|
|
4
|
+
describe("resolveReceiveIdType", () => {
|
|
5
|
+
it("resolves chat IDs by oc_ prefix", () => {
|
|
6
|
+
expect(resolveReceiveIdType("oc_123")).toBe("chat_id");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("resolves open IDs by ou_ prefix", () => {
|
|
10
|
+
expect(resolveReceiveIdType("ou_123")).toBe("open_id");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("defaults unprefixed IDs to user_id", () => {
|
|
14
|
+
expect(resolveReceiveIdType("u_123")).toBe("user_id");
|
|
15
|
+
});
|
|
16
|
+
});
|
package/src/targets.ts
CHANGED
|
@@ -57,7 +57,7 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_
|
|
|
57
57
|
if (trimmed.startsWith(OPEN_ID_PREFIX)) {
|
|
58
58
|
return "open_id";
|
|
59
59
|
}
|
|
60
|
-
return "
|
|
60
|
+
return "user_id";
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function looksLikeFeishuId(raw: string): boolean {
|
package/src/types.ts
CHANGED