Skip to content

Commit 411fcab

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents ac60db7 + 1628217 commit 411fcab

61 files changed

Lines changed: 872 additions & 363 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Docs: https://docs.openclaw.ai
9595
- Agents/BTW: strip replayed tool blocks, hidden reasoning, and malformed image payloads from `/btw` side-question context so Bedrock no-tools side questions keep working after tool-use turns. (#64225) Thanks @ngutman.
9696
- Commands/btw: keep tool-less side questions from sending injected empty `tools` arrays on strict OpenAI-compatible providers, so `/btw` continues working after prior tool-call history. (#64219) Thanks @ngutman.
9797
- Agents/Bedrock: let `/btw` side questions use `auth: "aws-sdk"` without a static API key so Bedrock IAM and instance-role sessions stop failing before the side question runs. (#64218) Thanks @SnowSky1.
98+
- Feishu: route `/btw` side questions and `/stop` onto bounded out-of-band lanes so BTW no longer waits behind a busy normal chat turn while ordinary same-chat traffic stays FIFO. (#64324) Thanks @ngutman.
9899
- Agents/failover: detect llama.cpp slot context overflows as context-overflow errors so compaction can retry self-hosted OpenAI-compatible runs instead of surfacing the raw upstream 400. (#64196) Thanks @alexander-applyinnovations.
99100
- Claude CLI/skills: pass eligible OpenClaw skills into CLI runs, including native Claude Code skill resolution via a temporary plugin plus per-run skill env/API key injection. (#62686, #62723) Thanks @zomars.
100101
- Discord: keep generated auto-thread names working with reasoning models by giving title generation enough output budget for thinking plus visible title text. (#64172) Thanks @hanamizuki.
@@ -106,6 +107,7 @@ Docs: https://docs.openclaw.ai
106107
- Discord/TTS: route auto voice replies through the native voice-note path so Discord receives Opus voice messages instead of regular audio attachments. (#64096) Thanks @LiuHuaize.
107108
- Config/plugins: use plugin-owned command alias metadata when `plugins.allow` contains runtime command names like `dreaming`, and point users at the owning plugin instead of stale plugin-not-found guidance. (#64242) Thanks @feiskyer.
108109
- Agents/Gemini: strip orphaned `required` entries from Gemini tool schemas so provider validation no longer rejects tools after schema cleanup or union flattening. (#64284) Thanks @xxxxxmax.
110+
- Assistant text: strip Qwen-style XML tool call payloads from visible replies so web and channel messages no longer show raw `<tool_call><function=...>` output. (#64214) Thanks @MoerAI.
109111

110112
## 2026.4.9
111113

docs/reference/templates/AGENTS.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,6 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
136136

137137
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
138138

139-
Default heartbeat prompt:
140-
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
141-
142139
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
143140

144141
### Heartbeat vs Cron: When to Use Each

extensions/feishu/src/client.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
11
import type { Agent } from "node:https";
2+
import { createRequire } from "node:module";
23
import * as Lark from "@larksuiteoapi/node-sdk";
34
import { resolveAmbientNodeProxyAgent } from "openclaw/plugin-sdk/extension-shared";
45
import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
56

7+
const require = createRequire(import.meta.url);
8+
const { version: pluginVersion } = require("../package.json") as { version: string };
9+
10+
export { pluginVersion };
11+
12+
const FEISHU_USER_AGENT = `openclaw-feishu-builtin/${pluginVersion}/${process.platform}`;
13+
export { FEISHU_USER_AGENT };
14+
15+
/** User-Agent header value for all Feishu API requests. */
16+
export function getFeishuUserAgent(): string {
17+
return FEISHU_USER_AGENT;
18+
}
19+
620
type FeishuClientSdk = Pick<
721
typeof Lark,
822
| "AppType"
@@ -26,6 +40,35 @@ const defaultFeishuClientSdk: FeishuClientSdk = {
2640

2741
let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk;
2842

43+
// Override the SDK's default User-Agent interceptor.
44+
// The Lark SDK registers an axios request interceptor that sets
45+
// 'oapi-node-sdk/1.0.0'. Axios request interceptors execute in LIFO order
46+
// (last-registered runs first), so simply appending ours doesn't work — the
47+
// SDK's interceptor would run last and overwrite our UA. We must clear
48+
// handlers[] first, then register our own as the sole interceptor.
49+
//
50+
// Risk is low: the SDK only registers one interceptor (UA) at init time, and
51+
// we clear it at module load before any other code can register handlers.
52+
// If a future SDK version adds more interceptors, the upgrade will need
53+
// compatibility verification regardless.
54+
{
55+
const inst = Lark.defaultHttpInstance as {
56+
interceptors?: {
57+
request: { handlers: unknown[]; use: (fn: (req: unknown) => unknown) => void };
58+
};
59+
};
60+
if (inst.interceptors?.request) {
61+
inst.interceptors.request.handlers = [];
62+
inst.interceptors.request.use((req: unknown) => {
63+
const r = req as { headers?: Record<string, string> };
64+
if (r.headers) {
65+
r.headers["User-Agent"] = getFeishuUserAgent();
66+
}
67+
return req;
68+
});
69+
}
70+
}
71+
2972
/** Default HTTP timeout for Feishu API requests (30 seconds). */
3073
export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
3174
export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000;
@@ -36,7 +79,7 @@ type FeishuHttpInstanceLike = Pick<
3679
"request" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options"
3780
>;
3881

39-
async function getWsProxyAgent(): Promise<Agent | undefined> {
82+
async function getWsProxyAgent() {
4083
return resolveAmbientNodeProxyAgent<Agent>();
4184
}
4285

@@ -61,8 +104,8 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
61104

62105
/**
63106
* Create an HTTP instance that delegates to the Lark SDK's default instance
64-
* but injects a default request timeout to prevent indefinite hangs
65-
* (e.g. when the Feishu API is slow, causing per-chat queue deadlocks).
107+
* but injects a default request timeout and User-Agent header to prevent
108+
* indefinite hangs and set a standardized User-Agent per OAPI best practices.
66109
*/
67110
function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
68111
const base: FeishuHttpInstanceLike = feishuClientSdk.defaultHttpInstance;

extensions/feishu/src/monitor.account.ts

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { botNames, botOpenIds } from "./monitor.state.js";
2929
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
3030
import { getFeishuRuntime } from "./runtime.js";
3131
import { getMessageFeishu } from "./send.js";
32+
import { getFeishuSequentialKey } from "./sequential-key.js";
33+
import { createSequentialQueue } from "./sequential-queue.js";
3234
import { createFeishuThreadBindingManager } from "./thread-bindings.js";
3335
import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
3436

@@ -290,25 +292,6 @@ function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEven
290292
};
291293
}
292294

293-
/**
294-
* Per-chat serial queue that ensures messages from the same chat are processed
295-
* in arrival order while allowing different chats to run concurrently.
296-
*/
297-
function createChatQueue() {
298-
const queues = new Map<string, Promise<void>>();
299-
return (chatId: string, task: () => Promise<void>): Promise<void> => {
300-
const prev = queues.get(chatId) ?? Promise.resolve();
301-
const next = prev.then(task, task);
302-
queues.set(chatId, next);
303-
void next.finally(() => {
304-
if (queues.get(chatId) === next) {
305-
queues.delete(chatId);
306-
}
307-
});
308-
return next;
309-
};
310-
}
311-
312295
function mergeFeishuDebounceMentions(
313296
entries: FeishuMessageEvent[],
314297
): FeishuMessageEvent["message"]["mentions"] | undefined {
@@ -395,7 +378,9 @@ function registerEventHandlers(
395378
});
396379
const log = runtime?.log ?? console.log;
397380
const error = runtime?.error ?? console.error;
398-
const enqueue = createChatQueue();
381+
// Keep normal Feishu traffic FIFO per chat while allowing explicit out-of-band
382+
// commands like /btw and /stop to bypass the busy main-chat lane.
383+
const enqueue = createSequentialQueue();
399384
const runFeishuHandler = async (params: { task: () => Promise<void>; errorMessage: string }) => {
400385
if (fireAndForget) {
401386
void params.task().catch((err) => {
@@ -410,7 +395,12 @@ function registerEventHandlers(
410395
}
411396
};
412397
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
413-
const chatId = event.message.chat_id?.trim() || "unknown";
398+
const sequentialKey = getFeishuSequentialKey({
399+
accountId,
400+
event,
401+
botOpenId: botOpenIds.get(accountId),
402+
botName: botNames.get(accountId),
403+
});
414404
const task = () =>
415405
handleFeishuMessage({
416406
cfg,
@@ -422,7 +412,7 @@ function registerEventHandlers(
422412
accountId,
423413
processingClaimHeld: true,
424414
});
425-
await enqueue(chatId, task);
415+
await enqueue(sequentialKey, task);
426416
};
427417
const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
428418
const senderId =

extensions/feishu/src/probe.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ vi.mock("./client.js", () => ({
1010
const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret
1111
const DEFAULT_SUCCESS_RESPONSE = {
1212
code: 0,
13-
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
13+
data: { pingBotInfo: { botName: "TestBot", botID: "ou_abc123" } },
1414
} as const;
1515
const DEFAULT_SUCCESS_RESULT = {
1616
ok: true,
@@ -20,7 +20,7 @@ const DEFAULT_SUCCESS_RESULT = {
2020
} as const;
2121
const BOT1_RESPONSE = {
2222
code: 0,
23-
bot: { bot_name: "Bot1", open_id: "ou_1" },
23+
data: { pingBotInfo: { botName: "Bot1", botID: "ou_1" } },
2424
} as const;
2525

2626
function makeRequestFn(response: Record<string, unknown>) {
@@ -135,8 +135,9 @@ describe("probeFeishu", () => {
135135

136136
expect(requestFn).toHaveBeenCalledWith(
137137
expect.objectContaining({
138-
method: "GET",
139-
url: "/open-apis/bot/v3/info",
138+
method: "POST",
139+
url: "/open-apis/bot/v1/openclaw_bot/ping",
140+
data: { needBotInfo: true },
140141
timeout: FEISHU_PROBE_REQUEST_TIMEOUT_MS,
141142
}),
142143
);
@@ -259,10 +260,10 @@ describe("probeFeishu", () => {
259260
});
260261
});
261262

262-
it("handles response.data.bot fallback path", async () => {
263+
it("handles response with pingBotInfo in data", async () => {
263264
setupClient({
264265
code: 0,
265-
data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
266+
data: { pingBotInfo: { botName: "DataBot", botID: "ou_data" } },
266267
});
267268

268269
await expectDefaultSuccessResult(DEFAULT_CREDS, {

extensions/feishu/src/probe.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,19 @@ export type ProbeFeishuOptions = {
1818
abortSignal?: AbortSignal;
1919
};
2020

21-
type FeishuBotInfoResponse = {
21+
type FeishuPingResponse = {
2222
code: number;
2323
msg?: string;
24-
bot?: { bot_name?: string; open_id?: string };
25-
data?: { bot?: { bot_name?: string; open_id?: string } };
24+
data?: { pingBotInfo?: { botID?: string; botName?: string } };
2625
};
2726

2827
type FeishuRequestClient = ReturnType<typeof createFeishuClient> & {
2928
request(params: {
30-
method: "GET";
29+
method: "POST";
3130
url: string;
32-
data: Record<string, never>;
31+
data: Record<string, unknown>;
3332
timeout: number;
34-
}): Promise<FeishuBotInfoResponse>;
33+
}): Promise<FeishuPingResponse>;
3534
};
3635

3736
function setCachedProbeResult(
@@ -81,12 +80,14 @@ export async function probeFeishu(
8180

8281
try {
8382
const client = createFeishuClient(creds) as FeishuRequestClient;
84-
// Use bot/v3/info API to get bot information
85-
const responseResult = await raceWithTimeoutAndAbort<FeishuBotInfoResponse>(
83+
// Feishu-provided endpoint for OpenClaw, supported on both Feishu (CN)
84+
// and Lark (international). No OAuth scopes required. Validates
85+
// credentials and registers the app as an AI agent (智能体).
86+
const responseResult = await raceWithTimeoutAndAbort<FeishuPingResponse>(
8687
client.request({
87-
method: "GET",
88-
url: "/open-apis/bot/v3/info",
89-
data: {},
88+
method: "POST",
89+
url: "/open-apis/bot/v1/openclaw_bot/ping",
90+
data: { needBotInfo: true },
9091
timeout: timeoutMs,
9192
}),
9293
{
@@ -135,14 +136,14 @@ export async function probeFeishu(
135136
);
136137
}
137138

138-
const bot = response.bot || response.data?.bot;
139+
const botInfo = response.data?.pingBotInfo;
139140
return setCachedProbeResult(
140141
cacheKey,
141142
{
142143
ok: true,
143144
appId: creds.appId,
144-
botName: bot?.bot_name,
145-
botOpenId: bot?.open_id,
145+
botName: botInfo?.botName,
146+
botOpenId: botInfo?.botID,
146147
},
147148
PROBE_SUCCESS_TTL_MS,
148149
);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { FeishuMessageEvent } from "./bot.js";
3+
import { getFeishuSequentialKey } from "./sequential-key.js";
4+
5+
function createTextEvent(params: {
6+
text: string;
7+
messageId?: string;
8+
chatId?: string;
9+
}): FeishuMessageEvent {
10+
return {
11+
sender: {
12+
sender_id: {
13+
open_id: "ou_sender_1",
14+
user_id: "ou_user_1",
15+
},
16+
sender_type: "user",
17+
},
18+
message: {
19+
message_id: params.messageId ?? "om_message_1",
20+
chat_id: params.chatId ?? "oc_dm_chat",
21+
chat_type: "p2p",
22+
message_type: "text",
23+
content: JSON.stringify({ text: params.text }),
24+
},
25+
} as FeishuMessageEvent;
26+
}
27+
28+
describe("getFeishuSequentialKey", () => {
29+
it.each([
30+
[createTextEvent({ text: "hello" }), "feishu:default:oc_dm_chat"],
31+
[createTextEvent({ text: "/status" }), "feishu:default:oc_dm_chat"],
32+
[createTextEvent({ text: "/stop" }), "feishu:default:oc_dm_chat:control"],
33+
[createTextEvent({ text: "/btw what changed?" }), "feishu:default:oc_dm_chat:btw"],
34+
])("resolves sequential key %#", (event, expected) => {
35+
expect(
36+
getFeishuSequentialKey({
37+
accountId: "default",
38+
event,
39+
}),
40+
).toBe(expected);
41+
});
42+
43+
it("keeps /btw on a stable per-chat lane across different message ids", () => {
44+
const first = createTextEvent({ text: "/btw one", messageId: "om_message_1" });
45+
const second = createTextEvent({ text: "/btw two", messageId: "om_message_2" });
46+
47+
expect(
48+
getFeishuSequentialKey({
49+
accountId: "default",
50+
event: first,
51+
}),
52+
).toBe("feishu:default:oc_dm_chat:btw");
53+
expect(
54+
getFeishuSequentialKey({
55+
accountId: "default",
56+
event: second,
57+
}),
58+
).toBe("feishu:default:oc_dm_chat:btw");
59+
});
60+
61+
it("falls back to a stable btw lane when the message id is unavailable", () => {
62+
const event = createTextEvent({ text: "/btw what changed?" });
63+
delete (event.message as { message_id?: string }).message_id;
64+
65+
expect(
66+
getFeishuSequentialKey({
67+
accountId: "default",
68+
event,
69+
}),
70+
).toBe("feishu:default:oc_dm_chat:btw");
71+
});
72+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
2+
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
3+
4+
export function getFeishuSequentialKey(params: {
5+
accountId: string;
6+
event: FeishuMessageEvent;
7+
botOpenId?: string;
8+
botName?: string;
9+
}): string {
10+
const { accountId, event, botOpenId, botName } = params;
11+
const chatId = event.message.chat_id?.trim() || "unknown";
12+
const baseKey = `feishu:${accountId}:${chatId}`;
13+
const parsed = parseFeishuMessageEvent(event, botOpenId, botName);
14+
const text = parsed.content.trim();
15+
16+
if (isAbortRequestText(text)) {
17+
return `${baseKey}:control`;
18+
}
19+
20+
if (isBtwRequestText(text)) {
21+
return `${baseKey}:btw`;
22+
}
23+
24+
return baseKey;
25+
}

0 commit comments

Comments
 (0)