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>
|
<title>Clawdbot Control</title>
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
<link rel="icon" href="./favicon.ico" sizes="any" />
|
||||||
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
|
<script type="module" crossorigin src="./assets/index-DpnB3DIH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-CjW_qQ45.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<clawdbot-app></clawdbot-app>
|
<clawdbot-app></clawdbot-app>
|
||||||
|
|||||||
@ -5,9 +5,14 @@ services:
|
|||||||
HOME: /home/node
|
HOME: /home/node
|
||||||
TERM: xterm-256color
|
TERM: xterm-256color
|
||||||
CLAWDBOT_GATEWAY_TOKEN: ${CLAWDBOT_GATEWAY_TOKEN}
|
CLAWDBOT_GATEWAY_TOKEN: ${CLAWDBOT_GATEWAY_TOKEN}
|
||||||
|
CLAWDBOT_GATEWAY_PASSWORD: ${CLAWDBOT_GATEWAY_PASSWORD}
|
||||||
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
|
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
|
||||||
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
|
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
|
||||||
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
|
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:
|
volumes:
|
||||||
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
||||||
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
|
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
|
||||||
@ -24,7 +29,9 @@ services:
|
|||||||
"--bind",
|
"--bind",
|
||||||
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
||||||
"--port",
|
"--port",
|
||||||
"${CLAWDBOT_GATEWAY_PORT:-18789}"
|
"${CLAWDBOT_GATEWAY_PORT:-18789}",
|
||||||
|
"--auth",
|
||||||
|
"password"
|
||||||
]
|
]
|
||||||
|
|
||||||
clawdbot-cli:
|
clawdbot-cli:
|
||||||
@ -35,7 +42,12 @@ services:
|
|||||||
BROWSER: echo
|
BROWSER: echo
|
||||||
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
|
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
|
||||||
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
|
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
|
||||||
|
CLAWDBOT_GATEWAY_PASSWORD: ${CLAWDBOT_GATEWAY_PASSWORD}
|
||||||
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
|
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:
|
volumes:
|
||||||
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
||||||
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
|
- ${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/discord: {}
|
||||||
|
|
||||||
|
extensions/feishu:
|
||||||
|
dependencies:
|
||||||
|
'@larksuiteoapi/node-sdk':
|
||||||
|
specifier: 1.40.1
|
||||||
|
version: 1.40.1
|
||||||
|
|
||||||
extensions/google-antigravity-auth: {}
|
extensions/google-antigravity-auth: {}
|
||||||
|
|
||||||
extensions/google-gemini-cli-auth: {}
|
extensions/google-gemini-cli-auth: {}
|
||||||
@ -1269,6 +1275,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
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':
|
'@line/bot-sdk@10.6.0':
|
||||||
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@ -2981,6 +2990,9 @@ packages:
|
|||||||
aws4@1.13.2:
|
aws4@1.13.2:
|
||||||
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
|
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:
|
axios@1.13.2:
|
||||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||||
|
|
||||||
@ -4059,6 +4071,13 @@ packages:
|
|||||||
lodash.debounce@4.0.8:
|
lodash.debounce@4.0.8:
|
||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
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:
|
lodash.includes@4.3.0:
|
||||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
@ -4077,9 +4096,15 @@ packages:
|
|||||||
lodash.isstring@4.0.1:
|
lodash.isstring@4.0.1:
|
||||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
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:
|
lodash.once@4.1.1:
|
||||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
|
lodash.pickby@4.6.0:
|
||||||
|
resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==}
|
||||||
|
|
||||||
lodash@4.17.23:
|
lodash@4.17.23:
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
@ -6732,6 +6757,21 @@ snapshots:
|
|||||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||||
'@lancedb/lancedb-win32-x64-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':
|
'@line/bot-sdk@10.6.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.10.9
|
'@types/node': 24.10.9
|
||||||
@ -8693,6 +8733,13 @@ snapshots:
|
|||||||
|
|
||||||
aws4@1.13.2: {}
|
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):
|
axios@1.13.2(debug@4.4.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.15.11(debug@4.4.3)
|
follow-redirects: 1.15.11(debug@4.4.3)
|
||||||
@ -9913,6 +9960,10 @@ snapshots:
|
|||||||
lodash.debounce@4.0.8:
|
lodash.debounce@4.0.8:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
lodash.get@4.4.2: {}
|
||||||
|
|
||||||
|
lodash.identity@3.0.0: {}
|
||||||
|
|
||||||
lodash.includes@4.3.0: {}
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3: {}
|
lodash.isboolean@3.0.3: {}
|
||||||
@ -9925,8 +9976,12 @@ snapshots:
|
|||||||
|
|
||||||
lodash.isstring@4.0.1: {}
|
lodash.isstring@4.0.1: {}
|
||||||
|
|
||||||
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash.once@4.1.1: {}
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
|
lodash.pickby@4.6.0: {}
|
||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
log-symbols@6.0.0:
|
log-symbols@6.0.0:
|
||||||
|
|||||||
@ -53,6 +53,14 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
|
|||||||
const DEFAULT_CODEX_BACKEND: CliBackendConfig = {
|
const DEFAULT_CODEX_BACKEND: CliBackendConfig = {
|
||||||
command: "codex",
|
command: "codex",
|
||||||
args: ["exec", "--json", "--color", "never", "--sandbox", "read-only", "--skip-git-repo-check"],
|
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: [
|
resumeArgs: [
|
||||||
"exec",
|
"exec",
|
||||||
"resume",
|
"resume",
|
||||||
|
|||||||
@ -32,6 +32,25 @@ import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
|
|||||||
|
|
||||||
const log = createSubsystemLogger("agent/claude-cli");
|
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: {
|
export async function runCliAgent(params: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
@ -211,6 +230,10 @@ export async function runCliAgent(params: {
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
})();
|
})();
|
||||||
|
const shouldCheckProxy = backendResolved.id === "codex-cli";
|
||||||
|
if (shouldCheckProxy) {
|
||||||
|
await logProxyIpLocation("before", env);
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup suspended processes that have accumulated (regardless of sessionId)
|
// Cleanup suspended processes that have accumulated (regardless of sessionId)
|
||||||
await cleanupSuspendedCliProcesses(backend);
|
await cleanupSuspendedCliProcesses(backend);
|
||||||
@ -218,12 +241,19 @@ export async function runCliAgent(params: {
|
|||||||
await cleanupResumeProcesses(backend, cliSessionIdToSend);
|
await cleanupResumeProcesses(backend, cliSessionIdToSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await runCommandWithTimeout([backend.command, ...args], {
|
let result: Awaited<ReturnType<typeof runCommandWithTimeout>>;
|
||||||
timeoutMs: params.timeoutMs,
|
try {
|
||||||
cwd: workspaceDir,
|
result = await runCommandWithTimeout([backend.command, ...args], {
|
||||||
env,
|
timeoutMs: params.timeoutMs,
|
||||||
input: stdinPayload,
|
cwd: workspaceDir,
|
||||||
});
|
env,
|
||||||
|
input: stdinPayload,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (shouldCheckProxy) {
|
||||||
|
await logProxyIpLocation("after", env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stdout = result.stdout.trim();
|
const stdout = result.stdout.trim();
|
||||||
const stderr = result.stderr.trim();
|
const stderr = result.stderr.trim();
|
||||||
|
|||||||
@ -468,7 +468,7 @@ describe("applyAuthChoice", () => {
|
|||||||
|
|
||||||
expect(text).toHaveBeenCalledWith(
|
expect(text).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
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({
|
expect(result.config.auth?.profiles?.["chutes:remote-user"]).toMatchObject({
|
||||||
|
|||||||
@ -146,7 +146,7 @@ export async function loginChutes(params: {
|
|||||||
await params.onAuth({ url });
|
await params.onAuth({ url });
|
||||||
params.onProgress?.("Waiting for redirect URL…");
|
params.onProgress?.("Waiting for redirect URL…");
|
||||||
const input = await params.onPrompt({
|
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=...`,
|
placeholder: `${params.app.redirectUri}?code=...&state=...`,
|
||||||
});
|
});
|
||||||
const parsed = parseOAuthCallbackInput(String(input), state);
|
const parsed = parseOAuthCallbackInput(String(input), state);
|
||||||
@ -162,7 +162,7 @@ export async function loginChutes(params: {
|
|||||||
}).catch(async () => {
|
}).catch(async () => {
|
||||||
params.onProgress?.("OAuth callback not detected; paste redirect URL…");
|
params.onProgress?.("OAuth callback not detected; paste redirect URL…");
|
||||||
const input = await params.onPrompt({
|
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=...`,
|
placeholder: `${params.app.redirectUri}?code=...&state=...`,
|
||||||
});
|
});
|
||||||
const parsed = parseOAuthCallbackInput(String(input), state);
|
const parsed = parseOAuthCallbackInput(String(input), state);
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export function createVpsAwareOAuthHandlers(params: {
|
|||||||
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
|
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
|
||||||
} {
|
} {
|
||||||
const manualPromptMessage =
|
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;
|
let manualCodePromise: Promise<string> | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -57,8 +57,8 @@ export const TelegramGroupSchema = z
|
|||||||
|
|
||||||
const TelegramCustomCommandSchema = z
|
const TelegramCustomCommandSchema = z
|
||||||
.object({
|
.object({
|
||||||
command: z.string().transform(normalizeTelegramCommandName),
|
command: z.string().transform(normalizeTelegramCommandName).pipe(z.string()),
|
||||||
description: z.string().transform(normalizeTelegramCommandDescription),
|
description: z.string().transform(normalizeTelegramCommandDescription).pipe(z.string()),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { parseBooleanValue } from "../utils/boolean.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 {
|
export function isTruthyEnvValue(value?: string): boolean {
|
||||||
return parseBooleanValue(value) === true;
|
return parseBooleanValue(value) === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeEnv(): void {
|
export function normalizeEnv(): void {
|
||||||
normalizeZaiEnv();
|
normalizeZaiEnv();
|
||||||
|
applyGlobalProxyFromEnv();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,39 @@
|
|||||||
import { resolveFetch } from "../infra/fetch.js";
|
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.
|
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
|
||||||
export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
|
export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
|
||||||
if (proxyFetch) return resolveFetch(proxyFetch);
|
if (proxyFetch) return resolveFetch(proxyFetch);
|
||||||
@ -7,5 +41,5 @@ export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch |
|
|||||||
if (!fetchImpl) {
|
if (!fetchImpl) {
|
||||||
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
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 { resolveTelegramAccount } from "./accounts.js";
|
||||||
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
|
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
|
||||||
import { createTelegramBot } from "./bot.js";
|
import { createTelegramBot } from "./bot.js";
|
||||||
|
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||||
import { makeProxyFetch } from "./proxy.js";
|
import { makeProxyFetch } from "./proxy.js";
|
||||||
import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js";
|
import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js";
|
||||||
import { startTelegramWebhook } from "./webhook.js";
|
import { startTelegramWebhook } from "./webhook.js";
|
||||||
@ -70,102 +71,116 @@ const isGetUpdatesConflict = (err: unknown) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
||||||
const cfg = opts.config ?? loadConfig();
|
const unregisterUnhandled = registerUnhandledRejectionHandler((reason) => {
|
||||||
const account = resolveTelegramAccount({
|
if (reason instanceof TypeError && reason.message === "fetch failed") {
|
||||||
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) {
|
|
||||||
(opts.runtime?.error ?? console.error)(
|
(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({
|
const proxyFetch =
|
||||||
token,
|
opts.proxyFetch ??
|
||||||
runtime: opts.runtime,
|
(account.config.proxy ? makeProxyFetch(account.config.proxy as string) : undefined);
|
||||||
proxyFetch,
|
|
||||||
config: cfg,
|
|
||||||
accountId: account.accountId,
|
|
||||||
updateOffset: {
|
|
||||||
lastUpdateId,
|
|
||||||
onUpdateId: persistUpdateId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (opts.useWebhook) {
|
let lastUpdateId = await readTelegramUpdateOffset({
|
||||||
await startTelegramWebhook({
|
|
||||||
token,
|
|
||||||
accountId: account.accountId,
|
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;
|
const persistUpdateId = async (updateId: number) => {
|
||||||
}
|
if (lastUpdateId !== null && updateId <= lastUpdateId) return;
|
||||||
|
lastUpdateId = updateId;
|
||||||
// Use grammyjs/runner for concurrent update processing
|
try {
|
||||||
const log = opts.runtime?.log ?? console.log;
|
await writeTelegramUpdateOffset({
|
||||||
let restartAttempts = 0;
|
accountId: account.accountId,
|
||||||
|
updateId,
|
||||||
while (!opts.abortSignal?.aborted) {
|
});
|
||||||
const runner = run(bot, createTelegramRunnerOptions(cfg));
|
} catch (err) {
|
||||||
const stopOnAbort = () => {
|
(opts.runtime?.error ?? console.error)(
|
||||||
if (opts.abortSignal?.aborted) {
|
`telegram: failed to persist update offset: ${String(err)}`,
|
||||||
void runner.stop();
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
|
|
||||||
try {
|
const bot = createTelegramBot({
|
||||||
// runner.task() returns a promise that resolves when the runner stops
|
token,
|
||||||
await runner.task();
|
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;
|
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;
|
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
|
// Enum - use segmented for small, dropdown for large
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user