adapt to feishu and add proxy

Signed-off-by: Jiaxin Shui <shuijx@xiaopeng.com>
This commit is contained in:
Jiaxin Shui 2026-01-28 09:19:48 +08:00
parent 6859e1e6a6
commit 50512b97e8
27 changed files with 1698 additions and 3223 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,8 +6,8 @@
<title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
<script type="module" crossorigin src="./assets/index-DpnB3DIH.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CjW_qQ45.css">
</head>
<body>
<clawdbot-app></clawdbot-app>

View File

@ -5,9 +5,14 @@ services:
HOME: /home/node
TERM: xterm-256color
CLAWDBOT_GATEWAY_TOKEN: ${CLAWDBOT_GATEWAY_TOKEN}
CLAWDBOT_GATEWAY_PASSWORD: ${CLAWDBOT_GATEWAY_PASSWORD}
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
HTTP_PROXY: ${CLAWDBOT_PROXY_URL}
HTTPS_PROXY: ${CLAWDBOT_PROXY_URL}
ALL_PROXY: ${CLAWDBOT_PROXY_URL}
NO_PROXY: ${CLAWDBOT_NO_PROXY}
volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
@ -24,7 +29,9 @@ services:
"--bind",
"${CLAWDBOT_GATEWAY_BIND:-lan}",
"--port",
"${CLAWDBOT_GATEWAY_PORT:-18789}"
"${CLAWDBOT_GATEWAY_PORT:-18789}",
"--auth",
"password"
]
clawdbot-cli:
@ -35,7 +42,12 @@ services:
BROWSER: echo
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
CLAWDBOT_GATEWAY_PASSWORD: ${CLAWDBOT_GATEWAY_PASSWORD}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
HTTP_PROXY: ${CLAWDBOT_PROXY_URL}
HTTPS_PROXY: ${CLAWDBOT_PROXY_URL}
ALL_PROXY: ${CLAWDBOT_PROXY_URL}
NO_PROXY: ${CLAWDBOT_NO_PROXY}
volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd

View File

@ -0,0 +1,11 @@
{
"id": "feishu",
"channels": [
"feishu"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,20 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { feishuPlugin } from "./src/channel.js";
import { handleFeishuWebhookRequest } from "./src/monitor.js";
import { setFeishuRuntime } from "./src/runtime.js";
const plugin = {
id: "feishu",
name: "Feishu",
description: "Clawdbot Feishu (Lark) channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setFeishuRuntime(api.runtime);
api.registerChannel({ plugin: feishuPlugin });
api.registerHttpHandler(handleFeishuWebhookRequest);
},
};
export default plugin;

View File

@ -0,0 +1,14 @@
{
"name": "@clawdbot/feishu",
"version": "2026.1.25",
"type": "module",
"description": "Clawdbot Feishu channel plugin",
"dependencies": {
"@larksuiteoapi/node-sdk": "1.40.1"
},
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -0,0 +1,78 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
export type FeishuChannelConfig = {
name?: string;
enabled?: boolean;
appId?: string;
appSecret?: string;
app_id?: string;
app_secret?: string;
verificationToken?: string;
verification_token?: string;
encryptKey?: string;
encrypt_key?: string;
eventMode?: "webhook" | "long-connection";
webhookPath?: string;
webhookUrl?: string;
allowBots?: boolean;
requireMention?: boolean;
dmPolicy?: "open" | "pairing" | "disabled";
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groupAllowFrom?: Array<string | number>;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
blockStreamingCoalesce?: { minChars?: number; idleMs?: number };
markdown?: Record<string, unknown>;
};
export type ResolvedFeishuAccount = {
accountId: string;
enabled: boolean;
configured: boolean;
name?: string;
appId?: string;
appSecret?: string;
verificationToken?: string;
encryptKey?: string;
config: FeishuChannelConfig;
credentialSource: "config" | "none";
};
function normalizeConfig(raw: FeishuChannelConfig | undefined): FeishuChannelConfig {
return raw ?? {};
}
export function resolveFeishuAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedFeishuAccount {
const cfg = params.cfg;
const raw = (cfg.channels?.["feishu"] ?? {}) as FeishuChannelConfig;
const config = normalizeConfig(raw);
const appId = config.appId?.trim() || config.app_id?.trim() || undefined;
const appSecret = config.appSecret?.trim() || config.app_secret?.trim() || undefined;
const verificationToken =
config.verificationToken?.trim() || config.verification_token?.trim() || undefined;
const encryptKey = config.encryptKey?.trim() || config.encrypt_key?.trim() || undefined;
const enabled = config.enabled !== false;
const configured = Boolean(appId && appSecret);
return {
accountId: params.accountId?.trim() || DEFAULT_ACCOUNT_ID,
enabled,
configured,
name: config.name?.trim() || undefined,
appId,
appSecret,
verificationToken,
encryptKey,
config,
credentialSource: configured ? "config" : "none",
};
}
export function resolveDefaultFeishuAccountId(): string {
return DEFAULT_ACCOUNT_ID;
}

View File

@ -0,0 +1,118 @@
import type { FeishuChannelConfig, ResolvedFeishuAccount } from "./accounts.js";
const FEISHU_API_BASE = "https://open.feishu.cn/open-apis";
type TokenCacheEntry = {
token: string;
expiresAt: number;
};
const tokenCache = new Map<string, TokenCacheEntry>();
function cacheKey(appId: string, appSecret: string): string {
return `${appId}:${appSecret}`;
}
function shouldRefreshToken(entry: TokenCacheEntry): boolean {
return Date.now() + 30_000 >= entry.expiresAt;
}
async function fetchTenantToken(params: {
appId: string;
appSecret: string;
}): Promise<{ token: string; expiresAt: number }> {
const res = await fetch(`${FEISHU_API_BASE}/auth/v3/tenant_access_token/internal`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app_id: params.appId,
app_secret: params.appSecret,
}),
});
if (!res.ok) {
throw new Error(`Feishu token request failed: HTTP ${res.status}`);
}
const payload = (await res.json()) as {
code?: number;
msg?: string;
tenant_access_token?: string;
expire?: number;
};
if (payload.code !== 0 || !payload.tenant_access_token) {
throw new Error(`Feishu token error: ${payload.msg ?? "unknown error"}`);
}
const expiresIn = Math.max(60, Number(payload.expire ?? 3600));
return {
token: payload.tenant_access_token,
expiresAt: Date.now() + expiresIn * 1000,
};
}
export async function resolveFeishuTenantToken(params: {
config: FeishuChannelConfig;
}): Promise<string> {
const appId = params.config.appId?.trim() || params.config.app_id?.trim();
const appSecret = params.config.appSecret?.trim() || params.config.app_secret?.trim();
if (!appId || !appSecret) {
throw new Error("Feishu appId/appSecret not configured");
}
const key = cacheKey(appId, appSecret);
const cached = tokenCache.get(key);
if (cached && !shouldRefreshToken(cached)) {
return cached.token;
}
const fetched = await fetchTenantToken({ appId, appSecret });
tokenCache.set(key, fetched);
return fetched.token;
}
export async function sendFeishuMessage(params: {
account: ResolvedFeishuAccount;
receiveIdType: "chat_id" | "user_id" | "open_id";
receiveId: string;
text: string;
}): Promise<{ messageId?: string }> {
const token = await resolveFeishuTenantToken({ config: params.account.config });
const res = await fetch(
`${FEISHU_API_BASE}/im/v1/messages?receive_id_type=${params.receiveIdType}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
receive_id: params.receiveId,
msg_type: "text",
content: JSON.stringify({ text: params.text }),
}),
},
);
if (!res.ok) {
throw new Error(`Feishu send failed: HTTP ${res.status}`);
}
const payload = (await res.json()) as {
code?: number;
msg?: string;
data?: { message_id?: string };
};
if (payload.code !== 0) {
throw new Error(`Feishu send error: ${payload.msg ?? "unknown error"}`);
}
return { messageId: payload.data?.message_id };
}
export async function probeFeishu(account: ResolvedFeishuAccount): Promise<{
ok: boolean;
reason?: string;
}> {
try {
if (!account.appId || !account.appSecret) {
return { ok: false, reason: "missing appId/appSecret" };
}
await resolveFeishuTenantToken({ config: account.config });
return { ok: true };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
}

View File

@ -0,0 +1,312 @@
import type { ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
formatPairingApproveHint,
missingTargetError,
PAIRING_APPROVED_MESSAGE,
} from "clawdbot/plugin-sdk";
import { FeishuConfigSchema } from "./config-schema.js";
import {
resolveFeishuAccount,
resolveDefaultFeishuAccountId,
type ResolvedFeishuAccount,
} from "./accounts.js";
import { probeFeishu, sendFeishuMessage } from "./api.js";
import { getFeishuRuntime } from "./runtime.js";
import { resolveFeishuWebhookPath, startFeishuLongConnection, startFeishuMonitor } from "./monitor.js";
const meta = {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu (App Bot)",
detailLabel: "Feishu Bot",
docsPath: "/channels/feishu",
docsLabel: "feishu",
blurb: "Feishu/Lark app bot via event subscription.",
aliases: ["lark"],
order: 66,
systemImage: "bubble.left.and.bubble.right",
quickstartAllowFrom: true,
} as const;
function normalizeAllowEntry(entry: string): string {
return entry
.trim()
.replace(/^(feishu|lark):/i, "")
.replace(/^(user_id|open_id|union_id):/i, "")
.replace(/^user:/i, "")
.toLowerCase();
}
function resolveRecipientFromId(raw: string): { type: "user_id" | "open_id"; id: string } | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const normalized = trimmed.replace(/^(feishu|lark):/i, "");
if (/^open_id:/i.test(normalized)) {
const id = normalized.replace(/^open_id:/i, "").trim();
return id ? { type: "open_id", id } : null;
}
if (/^user_id:/i.test(normalized)) {
const id = normalized.replace(/^user_id:/i, "").trim();
return id ? { type: "user_id", id } : null;
}
if (/^user:/i.test(normalized)) {
const id = normalized.replace(/^user:/i, "").trim();
return id ? { type: "user_id", id } : null;
}
return { type: "user_id", id: normalized };
}
function resolveOutboundTarget(raw?: string | null): { type: "chat_id" | "user_id" | "open_id"; id: string } | null {
const trimmed = raw?.trim() ?? "";
if (!trimmed) return null;
const normalized = trimmed.replace(/^(feishu|lark):/i, "");
if (/^(chat_id|chat):/i.test(normalized)) {
const id = normalized.replace(/^(chat_id|chat):/i, "").trim();
return id ? { type: "chat_id", id } : null;
}
if (/^open_id:/i.test(normalized)) {
const id = normalized.replace(/^open_id:/i, "").trim();
return id ? { type: "open_id", id } : null;
}
if (/^(user_id|user):/i.test(normalized)) {
const id = normalized.replace(/^(user_id|user):/i, "").trim();
return id ? { type: "user_id", id } : null;
}
return { type: "chat_id", id: normalized };
}
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: {
...meta,
},
pairing: {
idLabel: "feishuUserId",
normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
notifyApproval: async ({ cfg, id }) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig });
if (!account.configured) return;
const recipient = resolveRecipientFromId(id);
if (!recipient) return;
await sendFeishuMessage({
account,
receiveIdType: recipient.type,
receiveId: recipient.id,
text: PAIRING_APPROVED_MESSAGE,
});
},
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
media: false,
threads: false,
nativeCommands: false,
blockStreaming: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.feishu"] },
configSchema: {
...buildChannelConfigSchema(FeishuConfigSchema),
uiHints: {
appId: { label: "App ID" },
appSecret: { label: "App Secret", sensitive: true },
verificationToken: { label: "Verification Token", sensitive: true },
encryptKey: { label: "Encrypt Key", sensitive: true, advanced: true },
eventMode: { label: "Event Delivery", help: 'Use "long-connection" for app bot DMs.' },
webhookPath: { label: "Webhook Path", placeholder: "/feishu" },
webhookUrl: { label: "Webhook URL", advanced: true },
dmPolicy: { label: "DM Policy" },
allowFrom: { label: "DM Allowlist", itemTemplate: "user_id:..." },
groupAllowFrom: { label: "Group Allowlist", itemTemplate: "chat_id:..." },
},
},
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }),
defaultAccountId: () => resolveDefaultFeishuAccountId(),
setAccountEnabled: ({ cfg, enabled }) => ({
...cfg,
channels: {
...cfg.channels,
feishu: {
...(cfg.channels?.feishu ?? {}),
enabled,
},
},
}),
deleteAccount: ({ cfg }) => {
const next = { ...cfg } as ClawdbotConfig;
const nextChannels = { ...cfg.channels };
delete nextChannels.feishu;
if (Object.keys(nextChannels).length > 0) {
next.channels = nextChannels;
} else {
delete next.channels;
}
return next;
},
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
appId: account.appId,
eventMode: account.config.eventMode ?? "webhook",
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
}),
resolveAllowFrom: ({ cfg }) => (cfg.channels?.feishu?.allowFrom ?? []).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => normalizeAllowEntry(String(entry)))
.filter(Boolean),
},
security: {
resolveDmPolicy: ({ cfg, account }) => {
const basePath = "channels.feishu.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: `${basePath}allowFrom`,
approveHint: formatPairingApproveHint("feishu"),
normalizeEntry: (raw) => normalizeAllowEntry(raw),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- Feishu groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
];
},
},
groups: {
resolveRequireMention: ({ cfg }) => cfg.channels?.feishu?.requireMention ?? false,
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000,
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
if (trimmed) {
const target = resolveOutboundTarget(trimmed);
if (target) return { ok: true, to: `${target.type}:${target.id}` };
}
const allowListRaw = (allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter((entry) => entry && entry !== "*");
const allowTarget = allowListRaw[0];
if (allowTarget) {
const target =
resolveOutboundTarget(allowTarget) ?? resolveOutboundTarget(`user_id:${allowTarget}`);
if (target) return { ok: true, to: `${target.type}:${target.id}` };
}
if (mode === "implicit" || mode === "heartbeat") {
return {
ok: false,
error: missingTargetError(
"Feishu",
"<chat_id:ID|user_id:ID|open_id:ID> or channels.feishu.allowFrom[0]",
),
};
}
return {
ok: false,
error: missingTargetError(
"Feishu",
"<chat_id:ID|user_id:ID|open_id:ID> or channels.feishu.allowFrom[0]",
),
};
},
sendText: async ({ cfg, to, text }) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig });
const target = resolveOutboundTarget(to);
if (!target) {
throw new Error("Feishu target missing");
}
const result = await sendFeishuMessage({
account,
receiveIdType: target.type,
receiveId: target.id,
text,
});
return {
channel: "feishu",
messageId: result?.messageId ?? "",
chatId: target.id,
};
},
},
status: {
probeAccount: async ({ account }) => probeFeishu(account),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
appId: account.appId,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
probe,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const eventMode = account.config.eventMode ?? "webhook";
ctx.log?.info(`[${account.accountId}] starting Feishu ${eventMode}`);
ctx.setStatus({
accountId: account.accountId,
running: true,
lastStartAt: Date.now(),
eventMode,
webhookPath: eventMode === "webhook" ? resolveFeishuWebhookPath({ account }) : undefined,
});
const unregister =
eventMode === "long-connection"
? await startFeishuLongConnection({
account,
config: ctx.cfg as ClawdbotConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
})
: await startFeishuMonitor({
account,
config: ctx.cfg as ClawdbotConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
});
return () => {
unregister?.();
ctx.setStatus({
accountId: account.accountId,
running: false,
lastStopAt: Date.now(),
});
};
},
},
};

View File

@ -0,0 +1,44 @@
import { z } from "zod";
import {
BlockStreamingCoalesceSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
const FeishuConfigSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
appId: z.string().optional(),
appSecret: z.string().optional(),
verificationToken: z.string().optional(),
encryptKey: z.string().optional(),
eventMode: z.enum(["webhook", "long-connection"]).optional(),
webhookPath: z.string().optional(),
webhookUrl: z.string().optional(),
allowBots: z.boolean().optional(),
requireMention: z.boolean().optional(),
markdown: MarkdownConfigSchema,
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
})
.strict();
export const FeishuConfigSchema = FeishuConfigSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'channels.feishu.dmPolicy="open" requires channels.feishu.allowFrom to include "*"',
});
});

View File

@ -0,0 +1,700 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { createDecipheriv, createHash } from "node:crypto";
import * as Lark from "@larksuiteoapi/node-sdk";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveMentionGatingWithBypass } from "clawdbot/plugin-sdk";
import type { ResolvedFeishuAccount } from "./accounts.js";
import { sendFeishuMessage } from "./api.js";
import { getFeishuRuntime } from "./runtime.js";
import type { FeishuWebhookPayload, FeishuMessage, FeishuSender } from "./types.js";
export type FeishuRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
export type FeishuMonitorOptions = {
account: ResolvedFeishuAccount;
config: ClawdbotConfig;
runtime: FeishuRuntimeEnv;
abortSignal: AbortSignal;
webhookPath?: string;
webhookUrl?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
type FeishuCoreRuntime = ReturnType<typeof getFeishuRuntime>;
type WebhookTarget = {
account: ResolvedFeishuAccount;
config: ClawdbotConfig;
runtime: FeishuRuntimeEnv;
core: FeishuCoreRuntime;
path: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
const webhookTargets = new Map<string, WebhookTarget[]>();
type FeishuLongConnectionEvent = {
message?: FeishuMessage;
sender?: FeishuSender;
};
function logVerbose(core: FeishuCoreRuntime, runtime: FeishuRuntimeEnv, message: string) {
if (core.logging.shouldLogVerbose()) {
runtime.log?.(`[feishu] ${message}`);
}
}
function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "/";
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withSlash.length > 1 && withSlash.endsWith("/")) {
return withSlash.slice(0, -1);
}
return withSlash;
}
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
const trimmedPath = webhookPath?.trim();
if (trimmedPath) return normalizeWebhookPath(trimmedPath);
if (webhookUrl?.trim()) {
try {
const parsed = new URL(webhookUrl);
return normalizeWebhookPath(parsed.pathname || "/");
} catch {
return null;
}
}
return "/feishu";
}
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
const chunks: Buffer[] = [];
let total = 0;
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
let resolved = false;
const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => {
if (resolved) return;
resolved = true;
req.removeAllListeners();
resolve(value);
};
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
doResolve({ ok: false, error: "payload too large" });
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw.trim()) {
doResolve({ ok: false, error: "empty payload" });
return;
}
doResolve({ ok: true, value: JSON.parse(raw) as unknown });
} catch (err) {
doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
}
});
req.on("error", (err) => {
doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
});
});
}
function deriveEncryptKey(raw: string): Buffer {
const buf = Buffer.from(raw, "utf8");
if (buf.length === 32) return buf;
return createHash("sha256").update(buf).digest();
}
function decryptFeishuPayload(encrypt: string, encryptKey: string): string {
const key = deriveEncryptKey(encryptKey);
const iv = key.subarray(0, 16);
const payload = Buffer.from(encrypt, "base64");
const decipher = createDecipheriv("aes-256-cbc", key, iv);
const decrypted = Buffer.concat([decipher.update(payload), decipher.final()]);
return decrypted.toString("utf8");
}
function resolveTokenFromPayload(payload: FeishuWebhookPayload): string | undefined {
const token = payload.header?.token ?? payload.token;
return token?.trim() || undefined;
}
function resolveAppIdFromPayload(payload: FeishuWebhookPayload): string | undefined {
return payload.header?.app_id?.trim() || payload.app_id?.trim() || undefined;
}
export function registerFeishuWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
};
}
function matchesTarget(payload: FeishuWebhookPayload, target: WebhookTarget): boolean {
const account = target.account;
const appId = resolveAppIdFromPayload(payload);
if (account.appId && appId && account.appId !== appId) return false;
const token = resolveTokenFromPayload(payload);
if (account.verificationToken && token && account.verificationToken !== token) return false;
return true;
}
function normalizeAllowEntry(entry: string): string {
return entry
.trim()
.replace(/^(feishu|lark):/i, "")
.replace(/^(user_id|open_id|union_id):/i, "")
.replace(/^user:/i, "")
.toLowerCase();
}
function resolveSenderIds(sender?: FeishuSender): {
primary?: string;
ids: string[];
rawUserId?: string;
rawOpenId?: string;
} {
const userId = sender?.sender_id?.user_id?.trim() || "";
const openId = sender?.sender_id?.open_id?.trim() || "";
const unionId = sender?.sender_id?.union_id?.trim() || "";
const ids = [userId, openId, unionId].filter(Boolean);
return {
primary: ids[0],
ids,
rawUserId: userId || undefined,
rawOpenId: openId || undefined,
};
}
function isSenderAllowed(allowFrom: Array<string | number>, senderIds: string[]): boolean {
if (allowFrom.includes("*")) return true;
const normalizedAllow = allowFrom
.map((entry) => normalizeAllowEntry(String(entry)))
.filter(Boolean);
const normalizedSender = senderIds.map((id) => normalizeAllowEntry(id)).filter(Boolean);
return normalizedAllow.some((entry) => normalizedSender.includes(entry));
}
function normalizeGroupAllowEntry(entry: string): string {
return entry
.trim()
.replace(/^(feishu|lark):/i, "")
.replace(/^(chat_id|chat):/i, "")
.toLowerCase();
}
function isGroupAllowed(allowFrom: Array<string | number>, chatId: string): boolean {
if (allowFrom.includes("*")) return true;
const normalized = allowFrom
.map((entry) => normalizeGroupAllowEntry(String(entry)))
.filter(Boolean);
return normalized.includes(normalizeGroupAllowEntry(chatId));
}
function buildPairingId(params: { userId?: string; openId?: string; fallback?: string }): string {
if (params.userId) return `user_id:${params.userId}`;
if (params.openId) return `open_id:${params.openId}`;
return params.fallback ?? "";
}
function parseFeishuText(message: FeishuMessage): string {
const content = message.content?.trim();
if (!content) return "";
try {
const payload = JSON.parse(content) as {
text?: string;
title?: string;
content?: Array<Array<{ tag?: string; text?: string }>>;
};
if (payload.text?.trim()) return payload.text.trim();
if (message.message_type === "post" && Array.isArray(payload.content)) {
const parts: string[] = [];
for (const row of payload.content) {
if (!Array.isArray(row)) continue;
for (const cell of row) {
if (cell?.tag === "text" && cell.text?.trim()) {
parts.push(cell.text.trim());
}
}
}
const joined = parts.join(" ").trim();
if (joined) return joined;
if (payload.title?.trim()) return payload.title.trim();
}
return "";
} catch {
return "";
}
}
export async function handleFeishuWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) return false;
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
return true;
}
const body = await readJsonBody(req, 1024 * 1024);
if (!body.ok) {
res.statusCode = body.error === "payload too large" ? 413 : 400;
res.end(body.error ?? "invalid payload");
return true;
}
let rawPayload = body.value;
if (!rawPayload || typeof rawPayload !== "object" || Array.isArray(rawPayload)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
const candidate = rawPayload as FeishuWebhookPayload;
let selected: WebhookTarget | undefined;
let payload: FeishuWebhookPayload | undefined;
if (candidate.encrypt) {
for (const target of targets) {
const encryptKey = target.account.encryptKey;
if (!encryptKey) continue;
try {
const decrypted = decryptFeishuPayload(candidate.encrypt, encryptKey);
const decoded = JSON.parse(decrypted) as FeishuWebhookPayload;
if (!matchesTarget(decoded, target)) continue;
selected = target;
payload = decoded;
break;
} catch {
continue;
}
}
if (!selected || !payload) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
} else {
for (const target of targets) {
if (!matchesTarget(candidate, target)) continue;
selected = target;
payload = candidate;
break;
}
if (!selected || !payload) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
}
if (payload.type === "url_verification") {
const token = resolveTokenFromPayload(payload);
if (selected.account.verificationToken && token !== selected.account.verificationToken) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ challenge: payload.challenge ?? "" }));
return true;
}
selected.statusSink?.({ lastInboundAt: Date.now() });
processFeishuEvent(payload, selected).catch((err) => {
selected?.runtime.error?.(
`[${selected.account.accountId}] Feishu webhook failed: ${String(err)}`,
);
});
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end("{}");
return true;
}
async function processFeishuEvent(payload: FeishuWebhookPayload, target: WebhookTarget) {
const eventType = payload.header?.event_type;
if (eventType !== "im.message.receive_v1") return;
const event = payload.event;
if (!event?.message || !event.sender) return;
await processMessageWithPipeline({
message: event.message,
sender: event.sender,
account: target.account,
config: target.config,
runtime: target.runtime,
core: target.core,
statusSink: target.statusSink,
});
}
async function processMessageWithPipeline(params: {
message: FeishuMessage;
sender: FeishuSender;
account: ResolvedFeishuAccount;
config: ClawdbotConfig;
runtime: FeishuRuntimeEnv;
core: FeishuCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}) {
const { message, sender, account, config, runtime, core, statusSink } = params;
const chatId = message.chat_id?.trim() ?? "";
if (!chatId) return;
const chatType = (message.chat_type ?? "").toLowerCase();
const isGroup = chatType !== "p2p";
const senderIds = resolveSenderIds(sender);
const senderId = senderIds.primary ?? "";
if (!senderId) return;
const allowBots = account.config.allowBots === true;
if (!allowBots && sender.sender_type?.toLowerCase() === "bot") {
logVerbose(core, runtime, `skip bot-authored message (${senderId})`);
return;
}
const rawBody = parseFeishuText(message);
if (!rawBody) return;
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const groupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(core, runtime, `drop group message (groupPolicy=disabled, chat=${chatId})`);
return;
}
if (groupPolicy === "allowlist") {
if (groupAllowFrom.length === 0) {
logVerbose(core, runtime, `drop group message (no group allowlist, chat=${chatId})`);
return;
}
if (!isGroupAllowed(groupAllowFrom, chatId)) {
logVerbose(core, runtime, `drop group message (not allowlisted, chat=${chatId})`);
return;
}
}
}
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const senderAllowedForCommands = isSenderAllowed(effectiveAllowFrom, senderIds.ids);
const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: config.commands?.useAccessGroups !== false,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
let effectiveWasMentioned = false;
if (isGroup) {
const mentions = message.mentions ?? [];
const hasAnyMention = mentions.length > 0;
const wasMentioned = hasAnyMention;
const requireMention = account.config.requireMention ?? false;
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config,
surface: "feishu",
});
const mentionGate = resolveMentionGatingWithBypass({
isGroup: true,
requireMention,
canDetectMention: true,
wasMentioned,
implicitMention: false,
hasAnyMention,
allowTextCommands,
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
commandAuthorized: commandAuthorized === true,
});
effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (mentionGate.shouldSkip) {
logVerbose(core, runtime, `drop group message (mention required, chat=${chatId})`);
return;
}
}
if (!isGroup) {
if (dmPolicy === "disabled") {
logVerbose(core, runtime, `blocked DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open" && !senderAllowedForCommands) {
if (dmPolicy === "pairing") {
const pairingId = buildPairingId({
userId: senderIds.rawUserId,
openId: senderIds.rawOpenId,
fallback: senderId,
});
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "feishu",
id: pairingId,
meta: {},
});
if (created) {
logVerbose(core, runtime, `feishu pairing request sender=${pairingId}`);
try {
await sendFeishuMessage({
account,
receiveIdType: "chat_id",
receiveId: chatId,
text: core.channel.pairing.buildPairingReply({
channel: "feishu",
idLine: `Your Feishu user id: ${pairingId}`,
code,
}),
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(core, runtime, `pairing reply failed for ${pairingId}: ${String(err)}`);
}
}
}
return;
}
}
if (
isGroup &&
core.channel.commands.isControlCommandMessage(rawBody, config) &&
commandAuthorized !== true
) {
logVerbose(core, runtime, `feishu: drop control command from ${senderId}`);
return;
}
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: chatId,
},
});
const fromLabel = isGroup ? `chat:${chatId}` : `user:${senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: fromLabel,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: `feishu:${senderId}`,
To: `feishu:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "channel" : "direct",
ConversationLabel: fromLabel,
SenderId: senderId,
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
CommandAuthorized: commandAuthorized,
Provider: "feishu",
Surface: "feishu",
MessageSid: message.message_id,
MessageSidFull: message.message_id,
ReplyToId: message.message_id,
ReplyToIdFull: message.message_id,
GroupSpace: isGroup ? chatId : undefined,
OriginatingChannel: "feishu",
OriginatingTo: `feishu:${chatId}`,
});
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
runtime.error?.(`feishu: failed updating session meta: ${String(err)}`);
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
await deliverFeishuReply({
payload,
account,
chatId,
runtime,
statusSink,
});
},
onError: (err, info) => {
runtime.error?.(`[${account.accountId}] Feishu ${info.kind} reply failed: ${String(err)}`);
},
},
});
}
async function deliverFeishuReply(params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
account: ResolvedFeishuAccount;
chatId: string;
runtime: FeishuRuntimeEnv;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}) {
const { payload, account, chatId, runtime, statusSink } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
let text = payload.text?.trim() ?? "";
if (!text && mediaList.length > 0) {
text = mediaList.length === 1 ? `Attachment: ${mediaList[0]}` : mediaList.join("\n");
}
if (!text) return;
await sendFeishuMessage({
account,
receiveIdType: "chat_id",
receiveId: chatId,
text,
});
statusSink?.({ lastOutboundAt: Date.now() });
logVerbose(getFeishuRuntime(), runtime, `sent reply to chat ${chatId}`);
}
async function monitorFeishuProvider(options: FeishuMonitorOptions): Promise<() => void> {
const core = getFeishuRuntime();
const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl);
if (!webhookPath) {
options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`);
return () => {};
}
const unregister = registerFeishuWebhookTarget({
account: options.account,
config: options.config,
runtime: options.runtime,
core,
path: webhookPath,
statusSink: options.statusSink,
});
return unregister;
}
export async function startFeishuLongConnection(options: FeishuMonitorOptions): Promise<() => void> {
const { account, config, runtime, statusSink, abortSignal } = options;
const appId = account.appId;
const appSecret = account.appSecret;
if (!appId || !appSecret) {
runtime.error?.(`[${account.accountId}] missing appId/appSecret for long connection`);
return () => {};
}
const core = getFeishuRuntime();
const dispatcher = new Lark.EventDispatcher({}).register({
"im.message.receive_v1": async (data: FeishuLongConnectionEvent) => {
if (!data?.message || !data?.sender) return;
statusSink?.({ lastInboundAt: Date.now() });
try {
await processMessageWithPipeline({
message: data.message,
sender: data.sender,
account,
config,
runtime,
core,
statusSink,
});
} catch (err) {
runtime.error?.(`[${account.accountId}] Feishu long connection failed: ${String(err)}`);
}
},
});
const wsClient = new Lark.WSClient({
appId,
appSecret,
});
wsClient.start({ eventDispatcher: dispatcher });
const stopClient = () => {
const stop = (wsClient as { stop?: () => void }).stop;
if (typeof stop === "function") stop();
const close = (wsClient as { close?: () => void }).close;
if (typeof close === "function") close();
};
const onAbort = () => stopClient();
abortSignal.addEventListener("abort", onAbort, { once: true });
return () => {
abortSignal.removeEventListener("abort", onAbort);
stopClient();
};
}
export async function startFeishuMonitor(params: FeishuMonitorOptions): Promise<() => void> {
return monitorFeishuProvider(params);
}
export function resolveFeishuWebhookPath(params: { account: ResolvedFeishuAccount }): string {
return resolveWebhookPath(
params.account.config.webhookPath,
params.account.config.webhookUrl,
) ?? "/feishu";
}

View File

@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setFeishuRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getFeishuRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Feishu runtime not initialized");
}
return runtime;
}

View File

@ -0,0 +1,49 @@
export type FeishuEventHeader = {
event_type?: string;
token?: string;
app_id?: string;
tenant_key?: string;
create_time?: string;
};
export type FeishuSenderId = {
user_id?: string;
open_id?: string;
union_id?: string;
};
export type FeishuSender = {
sender_id?: FeishuSenderId;
sender_type?: string;
};
export type FeishuMention = {
key?: string;
name?: string;
id?: FeishuSenderId & { user_id?: string; open_id?: string };
};
export type FeishuMessage = {
message_id?: string;
chat_id?: string;
chat_type?: string;
message_type?: string;
content?: string;
mentions?: FeishuMention[];
};
export type FeishuEvent = {
sender?: FeishuSender;
message?: FeishuMessage;
};
export type FeishuWebhookPayload = {
schema?: string;
header?: FeishuEventHeader;
event?: FeishuEvent;
type?: string;
challenge?: string;
token?: string;
encrypt?: string;
app_id?: string;
};

55
pnpm-lock.yaml generated
View File

@ -304,6 +304,12 @@ importers:
extensions/discord: {}
extensions/feishu:
dependencies:
'@larksuiteoapi/node-sdk':
specifier: 1.40.1
version: 1.40.1
extensions/google-antigravity-auth: {}
extensions/google-gemini-cli-auth: {}
@ -1269,6 +1275,9 @@ packages:
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
'@larksuiteoapi/node-sdk@1.40.1':
resolution: {integrity: sha512-rY0xkwc2xDCtxTUKkor1cz86QX8MAQQlKLsL9Ipao7U0PCywhhn/NiqRbbW8kDin0YcBH8RBohDjMaVB2zcJqw==}
'@line/bot-sdk@10.6.0':
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
engines: {node: '>=20'}
@ -2981,6 +2990,9 @@ packages:
aws4@1.13.2:
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
axios@0.27.2:
resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
@ -4059,6 +4071,13 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.get@4.4.2:
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
lodash.identity@3.0.0:
resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
@ -4077,9 +4096,15 @@ packages:
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash.pickby@4.6.0:
resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
@ -6732,6 +6757,21 @@ snapshots:
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
'@larksuiteoapi/node-sdk@1.40.1':
dependencies:
axios: 0.27.2
lodash.get: 4.4.2
lodash.identity: 3.0.0
lodash.merge: 4.6.2
lodash.pickby: 4.6.0
protobufjs: 7.5.4
qs: 6.14.1
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- debug
- utf-8-validate
'@line/bot-sdk@10.6.0':
dependencies:
'@types/node': 24.10.9
@ -8693,6 +8733,13 @@ snapshots:
aws4@1.13.2: {}
axios@0.27.2:
dependencies:
follow-redirects: 1.15.11(debug@4.4.3)
form-data: 4.0.5
transitivePeerDependencies:
- debug
axios@1.13.2(debug@4.4.3):
dependencies:
follow-redirects: 1.15.11(debug@4.4.3)
@ -9913,6 +9960,10 @@ snapshots:
lodash.debounce@4.0.8:
optional: true
lodash.get@4.4.2: {}
lodash.identity@3.0.0: {}
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
@ -9925,8 +9976,12 @@ snapshots:
lodash.isstring@4.0.1: {}
lodash.merge@4.6.2: {}
lodash.once@4.1.1: {}
lodash.pickby@4.6.0: {}
lodash@4.17.23: {}
log-symbols@6.0.0:

View File

@ -53,6 +53,14 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
const DEFAULT_CODEX_BACKEND: CliBackendConfig = {
command: "codex",
args: ["exec", "--json", "--color", "never", "--sandbox", "read-only", "--skip-git-repo-check"],
env: {
HTTP_PROXY: "http://172.17.0.1:7892",
HTTPS_PROXY: "http://172.17.0.1:7892",
ALL_PROXY: "http://172.17.0.1:7892",
http_proxy: "http://172.17.0.1:7892",
https_proxy: "http://172.17.0.1:7892",
all_proxy: "http://172.17.0.1:7892",
},
resumeArgs: [
"exec",
"resume",

View File

@ -32,6 +32,25 @@ import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
const log = createSubsystemLogger("agent/claude-cli");
async function logProxyIpLocation(tag: string, env: NodeJS.ProcessEnv): Promise<void> {
try {
const result = await runCommandWithTimeout(["curl", "-s", "https://ipapi.co/json/"], {
timeoutMs: 5000,
env,
});
const payload = (result.stdout || result.stderr).trim();
if (result.code === 0 && payload) {
log.info(`codex ${tag} proxy ip: ${payload}`);
return;
}
if (payload) {
log.warn(`codex ${tag} proxy ip check failed: ${payload}`);
}
} catch (err) {
log.warn(`codex ${tag} proxy ip check failed: ${String(err)}`);
}
}
export async function runCliAgent(params: {
sessionId: string;
sessionKey?: string;
@ -211,6 +230,10 @@ export async function runCliAgent(params: {
}
return next;
})();
const shouldCheckProxy = backendResolved.id === "codex-cli";
if (shouldCheckProxy) {
await logProxyIpLocation("before", env);
}
// Cleanup suspended processes that have accumulated (regardless of sessionId)
await cleanupSuspendedCliProcesses(backend);
@ -218,12 +241,19 @@ export async function runCliAgent(params: {
await cleanupResumeProcesses(backend, cliSessionIdToSend);
}
const result = await runCommandWithTimeout([backend.command, ...args], {
timeoutMs: params.timeoutMs,
cwd: workspaceDir,
env,
input: stdinPayload,
});
let result: Awaited<ReturnType<typeof runCommandWithTimeout>>;
try {
result = await runCommandWithTimeout([backend.command, ...args], {
timeoutMs: params.timeoutMs,
cwd: workspaceDir,
env,
input: stdinPayload,
});
} finally {
if (shouldCheckProxy) {
await logProxyIpLocation("after", env);
}
}
const stdout = result.stdout.trim();
const stderr = result.stderr.trim();

View File

@ -468,7 +468,7 @@ describe("applyAuthChoice", () => {
expect(text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Paste the redirect URL (or authorization code)",
message: "Paste the redirect URL (or authorization code) — proxy enabled?",
}),
);
expect(result.config.auth?.profiles?.["chutes:remote-user"]).toMatchObject({

View File

@ -146,7 +146,7 @@ export async function loginChutes(params: {
await params.onAuth({ url });
params.onProgress?.("Waiting for redirect URL…");
const input = await params.onPrompt({
message: "Paste the redirect URL (or authorization code)",
message: "Paste the redirect URL (or authorization code) — proxy enabled?",
placeholder: `${params.app.redirectUri}?code=...&state=...`,
});
const parsed = parseOAuthCallbackInput(String(input), state);
@ -162,7 +162,7 @@ export async function loginChutes(params: {
}).catch(async () => {
params.onProgress?.("OAuth callback not detected; paste redirect URL…");
const input = await params.onPrompt({
message: "Paste the redirect URL (or authorization code)",
message: "Paste the redirect URL (or authorization code) — proxy enabled?",
placeholder: `${params.app.redirectUri}?code=...&state=...`,
});
const parsed = parseOAuthCallbackInput(String(input), state);

View File

@ -18,7 +18,7 @@ export function createVpsAwareOAuthHandlers(params: {
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
} {
const manualPromptMessage =
params.manualPromptMessage ?? "Paste the redirect URL (or authorization code)";
params.manualPromptMessage ?? "Paste the redirect URL (or authorization code) — proxy enabled?";
let manualCodePromise: Promise<string> | undefined;
return {

View File

@ -57,8 +57,8 @@ export const TelegramGroupSchema = z
const TelegramCustomCommandSchema = z
.object({
command: z.string().transform(normalizeTelegramCommandName),
description: z.string().transform(normalizeTelegramCommandDescription),
command: z.string().transform(normalizeTelegramCommandName).pipe(z.string()),
description: z.string().transform(normalizeTelegramCommandDescription).pipe(z.string()),
})
.strict();

View File

@ -1,3 +1,4 @@
import { ProxyAgent, setGlobalDispatcher } from "undici";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { parseBooleanValue } from "../utils/boolean.js";
@ -33,10 +34,42 @@ export function normalizeZaiEnv(): void {
}
}
function normalizeProxyEnvValue(raw: string): string {
const value = raw.trim();
if (!value) return value;
if (/^[a-z]+:\/\//i.test(value)) return value;
return `http://${value}`;
}
function normalizeProxyEnv(): string | undefined {
const keys = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"];
for (const key of keys) {
const raw = process.env[key];
if (!raw?.trim()) continue;
const normalized = normalizeProxyEnvValue(raw);
if (normalized !== raw) process.env[key] = normalized;
return normalized;
}
return undefined;
}
function applyGlobalProxyFromEnv(): void {
if (process.env.VITEST || process.env.NODE_ENV === "test") return;
const proxyUrl = normalizeProxyEnv();
if (!proxyUrl) return;
try {
setGlobalDispatcher(new ProxyAgent(proxyUrl));
log.info(`env: proxy enabled via ${proxyUrl}`);
} catch (err) {
log.warn(`env: proxy setup failed: ${String(err)}`);
}
}
export function isTruthyEnvValue(value?: string): boolean {
return parseBooleanValue(value) === true;
}
export function normalizeEnv(): void {
normalizeZaiEnv();
applyGlobalProxyFromEnv();
}

View File

@ -1,5 +1,39 @@
import { resolveFetch } from "../infra/fetch.js";
type FetchWithPreconnect = typeof fetch & {
preconnect?: (url: string, init?: { credentials?: RequestCredentials }) => void;
};
function wrapFetchWithNetworkFallback(fetchImpl: typeof fetch): typeof fetch {
const wrapped = (async (input: RequestInfo | URL, init?: RequestInit) => {
try {
return await fetchImpl(input, init);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (typeof Response === "undefined") {
throw err;
}
const body = JSON.stringify({
ok: false,
error_code: 599,
description: `Network error: ${message}`,
});
return new Response(body, {
status: 599,
statusText: "Network Error",
headers: { "content-type": "application/json" },
});
}
}) as FetchWithPreconnect;
const fetchWithPreconnect = fetchImpl as FetchWithPreconnect;
if (typeof fetchWithPreconnect.preconnect === "function") {
wrapped.preconnect = fetchWithPreconnect.preconnect.bind(fetchWithPreconnect);
}
return Object.assign(wrapped, fetchImpl);
}
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
if (proxyFetch) return resolveFetch(proxyFetch);
@ -7,5 +41,5 @@ export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch |
if (!fetchImpl) {
throw new Error("fetch is not available; set channels.telegram.proxy in config");
}
return fetchImpl;
return wrapFetchWithNetworkFallback(fetchImpl);
}

View File

@ -8,6 +8,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
import { createTelegramBot } from "./bot.js";
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { makeProxyFetch } from "./proxy.js";
import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js";
import { startTelegramWebhook } from "./webhook.js";
@ -70,102 +71,116 @@ const isGetUpdatesConflict = (err: unknown) => {
};
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const token = opts.token?.trim() || account.token;
if (!token) {
throw new Error(
`Telegram bot token missing for account "${account.accountId}" (set channels.telegram.accounts.${account.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`,
);
}
const proxyFetch =
opts.proxyFetch ??
(account.config.proxy ? makeProxyFetch(account.config.proxy as string) : undefined);
let lastUpdateId = await readTelegramUpdateOffset({
accountId: account.accountId,
});
const persistUpdateId = async (updateId: number) => {
if (lastUpdateId !== null && updateId <= lastUpdateId) return;
lastUpdateId = updateId;
try {
await writeTelegramUpdateOffset({
accountId: account.accountId,
updateId,
});
} catch (err) {
const unregisterUnhandled = registerUnhandledRejectionHandler((reason) => {
if (reason instanceof TypeError && reason.message === "fetch failed") {
(opts.runtime?.error ?? console.error)(
`telegram: failed to persist update offset: ${String(err)}`,
"telegram: network fetch failed (ignored to keep gateway running)",
);
return true;
}
return false;
});
try {
const cfg = opts.config ?? loadConfig();
const account = resolveTelegramAccount({
cfg,
accountId: opts.accountId,
});
const token = opts.token?.trim() || account.token;
if (!token) {
throw new Error(
`Telegram bot token missing for account "${account.accountId}" (set channels.telegram.accounts.${account.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`,
);
}
};
const bot = createTelegramBot({
token,
runtime: opts.runtime,
proxyFetch,
config: cfg,
accountId: account.accountId,
updateOffset: {
lastUpdateId,
onUpdateId: persistUpdateId,
},
});
const proxyFetch =
opts.proxyFetch ??
(account.config.proxy ? makeProxyFetch(account.config.proxy as string) : undefined);
if (opts.useWebhook) {
await startTelegramWebhook({
token,
let lastUpdateId = await readTelegramUpdateOffset({
accountId: account.accountId,
config: cfg,
path: opts.webhookPath,
port: opts.webhookPort,
secret: opts.webhookSecret,
runtime: opts.runtime as RuntimeEnv,
fetch: proxyFetch,
abortSignal: opts.abortSignal,
publicUrl: opts.webhookUrl,
});
return;
}
// Use grammyjs/runner for concurrent update processing
const log = opts.runtime?.log ?? console.log;
let restartAttempts = 0;
while (!opts.abortSignal?.aborted) {
const runner = run(bot, createTelegramRunnerOptions(cfg));
const stopOnAbort = () => {
if (opts.abortSignal?.aborted) {
void runner.stop();
const persistUpdateId = async (updateId: number) => {
if (lastUpdateId !== null && updateId <= lastUpdateId) return;
lastUpdateId = updateId;
try {
await writeTelegramUpdateOffset({
accountId: account.accountId,
updateId,
});
} catch (err) {
(opts.runtime?.error ?? console.error)(
`telegram: failed to persist update offset: ${String(err)}`,
);
}
};
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
try {
// runner.task() returns a promise that resolves when the runner stops
await runner.task();
const bot = createTelegramBot({
token,
runtime: opts.runtime,
proxyFetch,
config: cfg,
accountId: account.accountId,
updateOffset: {
lastUpdateId,
onUpdateId: persistUpdateId,
},
});
if (opts.useWebhook) {
await startTelegramWebhook({
token,
accountId: account.accountId,
config: cfg,
path: opts.webhookPath,
port: opts.webhookPort,
secret: opts.webhookSecret,
runtime: opts.runtime as RuntimeEnv,
fetch: proxyFetch,
abortSignal: opts.abortSignal,
publicUrl: opts.webhookUrl,
});
return;
} catch (err) {
if (opts.abortSignal?.aborted) {
throw err;
}
if (!isGetUpdatesConflict(err)) {
throw err;
}
restartAttempts += 1;
const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts);
log(`Telegram getUpdates conflict; retrying in ${formatDurationMs(delayMs)}.`);
try {
await sleepWithAbort(delayMs, opts.abortSignal);
} catch (sleepErr) {
if (opts.abortSignal?.aborted) return;
throw sleepErr;
}
} finally {
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
}
// Use grammyjs/runner for concurrent update processing
const log = opts.runtime?.log ?? console.log;
let restartAttempts = 0;
while (!opts.abortSignal?.aborted) {
const runner = run(bot, createTelegramRunnerOptions(cfg));
const stopOnAbort = () => {
if (opts.abortSignal?.aborted) {
void runner.stop();
}
};
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
try {
// runner.task() returns a promise that resolves when the runner stops
await runner.task();
return;
} catch (err) {
if (opts.abortSignal?.aborted) {
throw err;
}
if (!isGetUpdatesConflict(err)) {
throw err;
}
restartAttempts += 1;
const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts);
log(`Telegram getUpdates conflict; retrying in ${formatDurationMs(delayMs)}.`);
try {
await sleepWithAbort(delayMs, opts.abortSignal);
} catch (sleepErr) {
if (opts.abortSignal?.aborted) return;
throw sleepErr;
}
} finally {
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
}
}
} finally {
unregisterUnhandled();
}
}

View File

@ -192,5 +192,26 @@ function normalizeUnion(
};
}
if (
remaining.length > 0 &&
literals.length === 0 &&
remaining.every((entry) => {
const type = schemaType(entry);
return (
type === "object" ||
type === "array" ||
(!type && (entry.properties || entry.additionalProperties || entry.items))
);
})
) {
return {
schema: {
...schema,
nullable,
},
unsupportedPaths: [],
};
}
return null;
}

View File

@ -135,6 +135,34 @@ export function renderNode(params: {
});
}
}
const objectVariant = nonNull.find((variant) => {
const variantType = schemaType(variant);
return (
variantType === "object" ||
(!variantType && (variant.properties || variant.additionalProperties))
);
});
const arrayVariant = nonNull.find((variant) => {
const variantType = schemaType(variant);
return variantType === "array" || (!variantType && variant.items);
});
if (objectVariant || arrayVariant) {
const valueType = Array.isArray(value)
? "array"
: value && typeof value === "object"
? "object"
: undefined;
const preferred =
valueType === "array"
? arrayVariant
: valueType === "object"
? objectVariant
: objectVariant ?? arrayVariant;
if (preferred) {
return renderNode({ ...params, schema: preferred });
}
}
}
// Enum - use segmented for small, dropdown for large