@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/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
- elements: [
291
- {
292
- tag: "markdown",
293
- content: text,
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 "open_id";
60
+ return "user_id";
61
61
  }
62
62
 
63
63
  export function looksLikeFeishuId(raw: string): boolean {
package/src/types.ts CHANGED
@@ -73,3 +73,10 @@ export type FeishuToolsConfig = {
73
73
  perm?: boolean;
74
74
  scopes?: boolean;
75
75
  };
76
+
77
+ export type DynamicAgentCreationConfig = {
78
+ enabled?: boolean;
79
+ workspaceTemplate?: string;
80
+ agentDirTemplate?: string;
81
+ maxAgents?: number;
82
+ };