Skip to content

Commit 71e9633

Browse files
author
Joshua Chittick
committed
feat: add Lark long-connection runtime mode
Start a Lark WS client from configured app credentials so local testing works without public webhooks while preserving the existing callback path and event handling behavior.
1 parent 3edc01c commit 71e9633

File tree

6 files changed

+161
-25
lines changed

6 files changed

+161
-25
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
# Optional default Lark app credentials for action API
1111
# LARK_APP_ID=
1212
# LARK_APP_SECRET=
13+
# LARK_LONG_CONNECTION=true

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Settings UI can be accessible via http://127.0.0.1:9293 or use `/setting` comman
5151

5252
For Lark event subscriptions (Open Platform app), use callback endpoint: `POST /api/lark/event`.
5353
In Lark chats, send `/setting` to get a settings card and open local settings quickly.
54+
By default Ode also starts Lark long-connection mode (WS) when Lark app credentials are configured, so local testing can work without a public callback URL. Set `LARK_LONG_CONNECTION=false` to disable it.
5455

5556
![Channel](static/channel-setting.png)
5657
*Run `@bot /setting` to trigger setting dialog.*

README.zh-CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ ode
4949

5050
Lark 开放平台事件订阅回调地址可使用:`POST /api/lark/event`
5151
在 Lark 聊天里发送 `/setting` 可收到设置卡片并快速打开本地设置页面。
52+
默认情况下,只要配置了 Lark 凭据,Ode 也会启动 Lark 长连接(WS)模式,因此本地测试不需要公网回调地址。可通过 `LARK_LONG_CONNECTION=false` 关闭。
5253

5354
![Channel](static/channel-setting.png)
5455
*Run `@bot /setting` to trigger setting dialog.*

bun.lock

Lines changed: 43 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"typescript": "^5.9.3"
3030
},
3131
"dependencies": {
32+
"@larksuiteoapi/node-sdk": "^1.55.0",
3233
"@opencode-ai/sdk": "^1.1.25",
3334
"@slack/bolt": "^4.6.0",
3435
"@slack/socket-mode": "^2.0.5",

packages/ims/lark/client.ts

Lines changed: 114 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createAgentAdapter } from "@/agents/adapter";
22
import type { OpenCodeMessageContext } from "@/agents/types";
3+
import * as Lark from "@larksuiteoapi/node-sdk";
34
import {
45
getChannelSystemMessage,
56
getGitHubInfoForUser,
@@ -52,6 +53,7 @@ type LarkBotInfoResponse = {
5253
const tenantTokenCache = new Map<string, { token: string; expiresAt: number }>();
5354
const botOpenIdCache = new Map<string, string>();
5455
const sentMessageThreadMap = new Map<string, { channelId: string; threadId: string }>();
56+
const wsClientRegistry = new Map<string, unknown>();
5557

5658
function getLarkCredentialsForChannel(channelId: string): LarkCredentials | null {
5759
const channel = channelId.trim();
@@ -444,42 +446,37 @@ type LarkIncomingEnvelope = {
444446
};
445447
};
446448

447-
export async function handleLarkEventPayload(payload: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
448-
if (!payload || typeof payload !== "object") {
449-
return { status: 400, body: { ok: false, error: "Invalid payload" } };
450-
}
451-
452-
const envelope = payload as LarkIncomingEnvelope;
453-
if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
454-
return { status: 200, body: { challenge: envelope.challenge } };
455-
}
449+
type LarkIncomingEvent = NonNullable<LarkIncomingEnvelope["event"]>;
456450

457-
if (envelope.header?.event_type !== "im.message.receive_v1") {
458-
return { status: 200, body: { code: 0 } };
459-
}
451+
function isLarkLongConnectionEnabled(): boolean {
452+
const raw = process.env.LARK_LONG_CONNECTION?.trim().toLowerCase();
453+
if (!raw) return true;
454+
return !["0", "false", "off", "no"].includes(raw);
455+
}
460456

461-
const message = envelope.event?.message;
462-
const senderOpenId = envelope.event?.sender?.sender_id?.open_id?.trim() || "";
457+
async function processLarkIncomingEvent(event: LarkIncomingEvent): Promise<void> {
458+
const message = event.message;
459+
const senderOpenId = event.sender?.sender_id?.open_id?.trim() || "";
463460
const channelId = message?.chat_id?.trim() || "";
464461
const messageId = message?.message_id?.trim() || "";
465462
const threadId = message?.root_id?.trim() || message?.parent_id?.trim() || messageId;
466463
const isThreadReply = Boolean(message?.root_id || message?.parent_id);
467464

468465
if (!channelId || !messageId || !threadId || !senderOpenId) {
469-
return { status: 200, body: { code: 0 } };
466+
return;
470467
}
471468

472469
if (!isAuthorizedLarkChannel(channelId)) {
473-
return { status: 200, body: { code: 0 } };
470+
return;
474471
}
475472

476473
const botOpenId = await getBotOpenIdForChannel(channelId);
477474
if (botOpenId && senderOpenId === botOpenId) {
478-
return { status: 200, body: { code: 0 } };
475+
return;
479476
}
480477

481478
if (message?.message_type !== "text") {
482-
return { status: 200, body: { code: 0 } };
479+
return;
483480
}
484481

485482
const mentions = parseMentionedOpenIds(message?.mentions);
@@ -490,27 +487,27 @@ export async function handleLarkEventPayload(payload: unknown): Promise<{ status
490487

491488
if (isSettingsCommand(text)) {
492489
await sendSettingsCard(channelId, threadId);
493-
return { status: 200, body: { code: 0 } };
490+
return;
494491
}
495492

496493
if (isThreadReply) {
497494
if (!isMentioned && !active) {
498-
return { status: 200, body: { code: 0 } };
495+
return;
499496
}
500497
} else if (!isMentioned) {
501-
return { status: 200, body: { code: 0 } };
498+
return;
502499
}
503500

504501
if (!text) {
505-
return { status: 200, body: { code: 0 } };
502+
return;
506503
}
507504

508505
if (isStopCommand(text)) {
509506
const stopped = await coreRuntime.handleStopCommand(channelId, threadId);
510507
if (stopped) {
511508
await sendMessage(channelId, threadId, "Request stopped.", true);
512509
}
513-
return { status: 200, body: { code: 0 } };
510+
return;
514511
}
515512

516513
markThreadActive(channelId, threadId);
@@ -524,6 +521,98 @@ export async function handleLarkEventPayload(payload: unknown): Promise<{ status
524521
},
525522
text
526523
);
524+
}
525+
526+
async function startLarkLongConnections(reason: string): Promise<void> {
527+
if (!isLarkLongConnectionEnabled()) {
528+
log.debug("Lark long connection disabled", { reason });
529+
return;
530+
}
531+
532+
const workspaces = getLarkAppCredentials();
533+
const uniqueCredentials = new Map<string, { appId: string; appSecret: string }>();
534+
for (const workspace of workspaces) {
535+
if (!uniqueCredentials.has(workspace.appId)) {
536+
uniqueCredentials.set(workspace.appId, {
537+
appId: workspace.appId,
538+
appSecret: workspace.appSecret,
539+
});
540+
}
541+
}
542+
543+
for (const [appId, creds] of uniqueCredentials.entries()) {
544+
if (wsClientRegistry.has(appId)) {
545+
continue;
546+
}
547+
548+
const eventDispatcher = new Lark.EventDispatcher({}).register({
549+
"im.message.receive_v1": async (data: unknown) => {
550+
try {
551+
await processLarkIncomingEvent(data as LarkIncomingEvent);
552+
} catch (error) {
553+
log.warn("Failed to handle Lark long-connection message event", {
554+
appId,
555+
error: String(error),
556+
});
557+
}
558+
},
559+
});
560+
561+
const wsClient = new Lark.WSClient({
562+
appId: creds.appId,
563+
appSecret: creds.appSecret,
564+
domain: Lark.Domain.Feishu,
565+
loggerLevel: Lark.LoggerLevel.warn,
566+
});
567+
568+
await Promise.resolve(
569+
wsClient.start({
570+
eventDispatcher,
571+
})
572+
);
573+
574+
wsClientRegistry.set(appId, wsClient);
575+
log.info("Lark long connection started", { appId });
576+
}
577+
}
578+
579+
async function stopLarkLongConnections(reason: string): Promise<void> {
580+
const entries = Array.from(wsClientRegistry.entries());
581+
wsClientRegistry.clear();
582+
for (const [appId, client] of entries) {
583+
try {
584+
const wsClient = client as { stop?: () => unknown | Promise<unknown> };
585+
if (typeof wsClient.stop === "function") {
586+
await Promise.resolve(wsClient.stop());
587+
}
588+
log.info("Lark long connection stopped", { appId, reason });
589+
} catch (error) {
590+
log.warn("Failed to stop Lark long connection", {
591+
appId,
592+
reason,
593+
error: String(error),
594+
});
595+
}
596+
}
597+
}
598+
599+
export async function handleLarkEventPayload(payload: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
600+
if (!payload || typeof payload !== "object") {
601+
return { status: 400, body: { ok: false, error: "Invalid payload" } };
602+
}
603+
604+
const envelope = payload as LarkIncomingEnvelope;
605+
if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
606+
return { status: 200, body: { challenge: envelope.challenge } };
607+
}
608+
609+
if (envelope.header?.event_type !== "im.message.receive_v1") {
610+
return { status: 200, body: { code: 0 } };
611+
}
612+
613+
if (envelope.event) {
614+
await processLarkIncomingEvent(envelope.event);
615+
}
527616

528617
return { status: 200, body: { code: 0 } };
529618
}
@@ -543,12 +632,14 @@ export async function startLarkRuntime(reason: string): Promise<boolean> {
543632
reason,
544633
workspaceCount: workspaces.length,
545634
});
635+
await startLarkLongConnections(reason);
546636
return true;
547637
}
548638

549639
export async function stopLarkRuntime(reason: string): Promise<void> {
550640
if (!larkRuntimeStarted) return;
551641
larkRuntimeStarted = false;
642+
await stopLarkLongConnections(reason);
552643
tenantTokenCache.clear();
553644
botOpenIdCache.clear();
554645
sentMessageThreadMap.clear();

0 commit comments

Comments
 (0)