Skip to content

Commit d93e6f6

Browse files
authored
fix(feishu): repair WebSocket reconnect and heartbeat config (#72411)
1 parent fdd2ff0 commit d93e6f6

7 files changed

Lines changed: 258 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,8 @@ Docs: https://docs.openclaw.ai
114114
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @vincentkoc.
115115
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
116116
- Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.
117-
118-
### Fixes
119-
120117
- TTS/BlueBubbles: pre-transcode synthesized MP3 audio to opus-in-CAF (mono, 24 kHz — validated against macOS 15.x Messages.app's native voice-memo CAF descriptor) on macOS hosts before handing the file to BlueBubbles, so iMessage renders the result as a native voice-memo bubble with proper duration and waveform UI instead of a plain file attachment. Adds an opt-in `tts.voice.preferAudioFileFormat` channel capability and a magic-byte sniff for the CAF container so the host-local-media validator (which uses `file-type` and didn't recognize CAF natively) can verify the pre-transcoded buffer. Channels that don't opt in are unaffected. (#72586) Fixes #72506. Thanks @omarshahine.
118+
- Feishu: retry WebSocket startup failures with monitor-owned backoff while preserving SDK-local heartbeat defaults, so persistent-connection startup failures no longer leave the monitor hung. Fixes #68766; related #42354 and #55532. Thanks @alex-xuweilong, @120106835, @sirfengyu, and @tianhaocui.
121119

122120
## 2026.4.26
123121

extensions/feishu/src/async.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ afterEach(() => {
66
});
77

88
describe("waitForAbortableDelay", () => {
9+
it("resolves false immediately when already aborted", async () => {
10+
vi.useFakeTimers();
11+
const abortController = new AbortController();
12+
abortController.abort();
13+
14+
await expect(waitForAbortableDelay(60_000, abortController.signal)).resolves.toBe(false);
15+
});
16+
917
it("resolves false immediately when aborted during backoff", async () => {
1018
vi.useFakeTimers();
1119
const abortController = new AbortController();

extensions/feishu/src/async.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,35 @@ export function waitForAbortableDelay(
7070
}
7171

7272
return new Promise((resolve) => {
73-
const handleAbort = () => {
74-
clearTimeout(timer);
75-
resolve(false);
73+
let settled = false;
74+
let timer: ReturnType<typeof setTimeout> | undefined;
75+
let handleAbort: (() => void) | undefined;
76+
77+
const finish = (value: boolean) => {
78+
if (settled) {
79+
return;
80+
}
81+
settled = true;
82+
if (timer) {
83+
clearTimeout(timer);
84+
}
85+
if (handleAbort) {
86+
abortSignal?.removeEventListener("abort", handleAbort);
87+
}
88+
resolve(value);
7689
};
7790

78-
const timer = setTimeout(() => {
79-
abortSignal?.removeEventListener("abort", handleAbort);
80-
resolve(true);
81-
}, delayMs);
82-
timer.unref?.();
91+
handleAbort = () => {
92+
finish(false);
93+
};
8394

8495
abortSignal?.addEventListener("abort", handleAbort, { once: true });
96+
if (abortSignal?.aborted) {
97+
finish(false);
98+
return;
99+
}
100+
101+
timer = setTimeout(() => finish(true), delayMs);
102+
timer.unref?.();
85103
});
86104
}

extensions/feishu/src/client.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,9 @@ function readCallOptions(
119119
return isRecord(call) ? call : {};
120120
}
121121

122-
function firstWsClientOptions(): { agent?: unknown } {
122+
function firstWsClientOptions(): { agent?: unknown; wsConfig?: unknown } {
123123
const options = readCallOptions(wsClientCtorMock, 0);
124-
return { agent: options.agent };
124+
return { agent: options.agent, wsConfig: options.wsConfig };
125125
}
126126

127127
beforeAll(async () => {
@@ -345,6 +345,16 @@ describe("createFeishuClient HTTP timeout", () => {
345345
});
346346

347347
describe("createFeishuWSClient proxy handling", () => {
348+
it("passes heartbeat wsConfig defaults to Lark.WSClient", async () => {
349+
await createFeishuWSClient(baseAccount);
350+
351+
const options = firstWsClientOptions();
352+
expect(options.wsConfig).toEqual({
353+
PingInterval: 30,
354+
PingTimeout: 3,
355+
});
356+
});
357+
348358
it("does not set a ws proxy agent when proxy env is absent", async () => {
349359
await createFeishuWSClient(baseAccount);
350360

extensions/feishu/src/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export { pluginVersion };
1515
const FEISHU_USER_AGENT = `openclaw-feishu-builtin/${pluginVersion}/${process.platform}`;
1616
export { FEISHU_USER_AGENT };
1717

18+
const FEISHU_WS_CONFIG = {
19+
PingInterval: 30,
20+
PingTimeout: 3,
21+
} as const;
22+
1823
/** User-Agent header value for all Feishu API requests. */
1924
export function getFeishuUserAgent(): string {
2025
return FEISHU_USER_AGENT;
@@ -232,7 +237,10 @@ export async function createFeishuWSClient(account: ResolvedFeishuAccount): Prom
232237
appSecret,
233238
domain: resolveDomain(domain),
234239
loggerLevel: feishuClientSdk.LoggerLevel.info,
240+
wsConfig: FEISHU_WS_CONFIG,
235241
...(agent ? { agent } : {}),
242+
} as ConstructorParameters<typeof feishuClientSdk.WSClient>[0] & {
243+
wsConfig: typeof FEISHU_WS_CONFIG;
236244
});
237245
}
238246

extensions/feishu/src/monitor.cleanup.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ function createWsClient(): MockWsClient {
3838
}
3939

4040
afterEach(() => {
41+
vi.useRealTimers();
4142
stopFeishuMonitorState();
4243
vi.clearAllMocks();
4344
});
@@ -79,6 +80,98 @@ describe("feishu websocket cleanup", () => {
7980
expect(botNames.has(accountId)).toBe(false);
8081
});
8182

83+
it("retries with backoff after websocket start rejects", async () => {
84+
vi.useFakeTimers();
85+
const failedClient = createWsClient();
86+
failedClient.start.mockRejectedValueOnce(
87+
new Error("connect failed\nAuthorization: Bearer token_abc appSecret=secret_abc"),
88+
);
89+
const recoveredClient = createWsClient();
90+
createFeishuWSClientMock
91+
.mockResolvedValueOnce(failedClient)
92+
.mockResolvedValueOnce(recoveredClient);
93+
94+
const abortController = new AbortController();
95+
const runtime = {
96+
log: vi.fn(),
97+
error: vi.fn(),
98+
exit: vi.fn(),
99+
};
100+
const accountId = "retry";
101+
102+
const monitorPromise = monitorWebSocket({
103+
account: createAccount(accountId),
104+
accountId,
105+
runtime,
106+
abortSignal: abortController.signal,
107+
eventDispatcher: {} as never,
108+
});
109+
110+
await vi.waitFor(() => {
111+
expect(failedClient.start).toHaveBeenCalledTimes(1);
112+
expect(failedClient.close).toHaveBeenCalledTimes(1);
113+
expect(wsClients.has(accountId)).toBe(false);
114+
});
115+
116+
await vi.advanceTimersByTimeAsync(1_000);
117+
118+
await vi.waitFor(() => {
119+
expect(recoveredClient.start).toHaveBeenCalledTimes(1);
120+
expect(wsClients.get(accountId)).toBe(recoveredClient);
121+
});
122+
123+
abortController.abort();
124+
await monitorPromise;
125+
126+
expect(createFeishuWSClientMock).toHaveBeenCalledTimes(2);
127+
expect(recoveredClient.close).toHaveBeenCalledTimes(1);
128+
expect(runtime.error).toHaveBeenCalledWith(
129+
expect.stringContaining("WebSocket start failed, retrying in 1000ms"),
130+
);
131+
const errorMessage = String(runtime.error.mock.calls[0]?.[0] ?? "");
132+
expect(errorMessage).not.toContain("\n");
133+
expect(errorMessage).not.toContain("token_abc");
134+
expect(errorMessage).not.toContain("secret_abc");
135+
expect(errorMessage).toContain("Authorization: Bearer [redacted]");
136+
expect(errorMessage).toContain("appSecret=[redacted]");
137+
});
138+
139+
it("redacts websocket close errors during abort cleanup", async () => {
140+
const wsClient = createWsClient();
141+
wsClient.close.mockImplementationOnce(() => {
142+
throw new Error("close failed\naccess_token=secret_token");
143+
});
144+
createFeishuWSClientMock.mockReturnValue(wsClient);
145+
146+
const abortController = new AbortController();
147+
const runtime = {
148+
log: vi.fn(),
149+
error: vi.fn(),
150+
exit: vi.fn(),
151+
};
152+
153+
const monitorPromise = monitorWebSocket({
154+
account: createAccount("close-error"),
155+
accountId: "close-error",
156+
runtime,
157+
abortSignal: abortController.signal,
158+
eventDispatcher: {} as never,
159+
});
160+
161+
await vi.waitFor(() => {
162+
expect(wsClient.start).toHaveBeenCalledTimes(1);
163+
});
164+
165+
abortController.abort();
166+
await monitorPromise;
167+
168+
const errorMessage = String(runtime.error.mock.calls[0]?.[0] ?? "");
169+
expect(errorMessage).toContain("error closing WebSocket client");
170+
expect(errorMessage).toContain("access_token=[redacted]");
171+
expect(errorMessage).not.toContain("\n");
172+
expect(errorMessage).not.toContain("secret_token");
173+
});
174+
82175
it("closes targeted websocket clients during stop cleanup", () => {
83176
const alphaClient = createWsClient();
84177
const betaClient = createWsClient();

0 commit comments

Comments
 (0)