adapt to feishu and add proxy
Signed-off-by: Jiaxin Shui <shuijx@xiaopeng.com>
This commit is contained in:
parent
6859e1e6a6
commit
50512b97e8
1
dist/control-ui/assets/index-08nzABV3.css
vendored
1
dist/control-ui/assets/index-08nzABV3.css
vendored
File diff suppressed because one or more lines are too long
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/control-ui/index.html
vendored
4
dist/control-ui/index.html
vendored
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
11
extensions/feishu/clawdbot.plugin.json
Normal file
11
extensions/feishu/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "feishu",
|
||||
"channels": [
|
||||
"feishu"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
20
extensions/feishu/index.ts
Normal file
20
extensions/feishu/index.ts
Normal 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;
|
||||
14
extensions/feishu/package.json
Normal file
14
extensions/feishu/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
78
extensions/feishu/src/accounts.ts
Normal file
78
extensions/feishu/src/accounts.ts
Normal 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;
|
||||
}
|
||||
118
extensions/feishu/src/api.ts
Normal file
118
extensions/feishu/src/api.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
312
extensions/feishu/src/channel.ts
Normal file
312
extensions/feishu/src/channel.ts
Normal 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(),
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
44
extensions/feishu/src/config-schema.ts
Normal file
44
extensions/feishu/src/config-schema.ts
Normal 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 "*"',
|
||||
});
|
||||
});
|
||||
700
extensions/feishu/src/monitor.ts
Normal file
700
extensions/feishu/src/monitor.ts
Normal 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";
|
||||
}
|
||||
14
extensions/feishu/src/runtime.ts
Normal file
14
extensions/feishu/src/runtime.ts
Normal 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;
|
||||
}
|
||||
49
extensions/feishu/src/types.ts
Normal file
49
extensions/feishu/src/types.ts
Normal 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
55
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user