From b8642a0dfa458f35b8fa978a8ea48b7375ae964f Mon Sep 17 00:00:00 2001 From: xiexiyang Date: Fri, 13 Mar 2026 13:57:31 +0800 Subject: [PATCH] feat: Add WeCom bridge integration with user identity and settings UI --- package-lock.json | 12 + package.json | 1 + src/__tests__/unit/wecom-bridge.test.ts | 62 ++ src/app/api/bridge/settings/route.ts | 6 + src/app/api/settings/wecom/route.ts | 59 ++ src/app/api/settings/wecom/verify/route.ts | 89 +++ src/components/bridge/BridgeLayout.tsx | 7 +- src/components/bridge/BridgeSection.tsx | 51 +- src/components/bridge/WecomBridgeSection.tsx | 317 ++++++++++ src/i18n/en.ts | 36 +- src/i18n/zh.ts | 36 +- src/lib/bridge/adapters/index.ts | 1 + src/lib/bridge/adapters/wecom-adapter.ts | 632 +++++++++++++++++++ src/lib/bridge/bridge-manager.ts | 32 +- src/lib/bridge/conversation-engine.ts | 3 +- src/lib/bridge/markdown/wecom.ts | 97 +++ src/lib/bridge/types.ts | 1 + src/lib/claude-client.ts | 1 + 18 files changed, 1416 insertions(+), 27 deletions(-) create mode 100644 src/__tests__/unit/wecom-bridge.test.ts create mode 100644 src/app/api/settings/wecom/route.ts create mode 100644 src/app/api/settings/wecom/verify/route.ts create mode 100644 src/components/bridge/WecomBridgeSection.tsx create mode 100644 src/lib/bridge/adapters/wecom-adapter.ts create mode 100644 src/lib/bridge/markdown/wecom.ts diff --git a/package-lock.json b/package-lock.json index 7fbd7692..f27ddeb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@streamdown/code": "^1.0.1", "@streamdown/math": "^1.0.1", "@streamdown/mermaid": "^1.0.1", + "@wecom/aibot-node-sdk": "^1.0.1", "ai": "^6.0.73", "ansi-to-react": "^6.2.6", "better-sqlite3": "^12.6.2", @@ -9864,6 +9865,17 @@ "npm": ">=7.0.0" } }, + "node_modules/@wecom/aibot-node-sdk": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@wecom/aibot-node-sdk/-/aibot-node-sdk-1.0.1.tgz", + "integrity": "sha512-c/sa1IvRKIP+4rZfRV2v70FaXB92+BJIh+vedZkPa8wZ1dwIUyvGg7ydkfYRIwFDzjO9IJZUX5V14EUQYVopAg==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.7", + "eventemitter3": "^5.0.1", + "ws": "^8.16.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", diff --git a/package.json b/package.json index 3caf2964..82da6413 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@streamdown/code": "^1.0.1", "@streamdown/math": "^1.0.1", "@streamdown/mermaid": "^1.0.1", + "@wecom/aibot-node-sdk": "^1.0.1", "ai": "^6.0.73", "ansi-to-react": "^6.2.6", "better-sqlite3": "^12.6.2", diff --git a/src/__tests__/unit/wecom-bridge.test.ts b/src/__tests__/unit/wecom-bridge.test.ts new file mode 100644 index 00000000..9147a84f --- /dev/null +++ b/src/__tests__/unit/wecom-bridge.test.ts @@ -0,0 +1,62 @@ +/** + * Unit tests for WeCom bridge markdown/card helpers. + * + * Run with: npx tsx --test src/__tests__/unit/wecom-bridge.test.ts + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildMarkdownMessage, + buildPermissionCard, + buildPermissionCommandText, + hasComplexMarkdown, + htmlToWecomMarkdown, + preprocessWecomMarkdown, +} from '../../lib/bridge/markdown/wecom'; + +describe('WeCom markdown helpers', () => { + it('detects fenced code blocks as complex markdown', () => { + assert.equal(hasComplexMarkdown('hello\n```ts\nconst x = 1;\n```'), true); + }); + + it('detects tables as complex markdown', () => { + assert.equal(hasComplexMarkdown('| a | b |\n| - | - |\n| 1 | 2 |'), true); + }); + + it('adds a newline before code fences when needed', () => { + assert.equal(preprocessWecomMarkdown('Intro```ts\nconst x = 1;\n```'), 'Intro\n```ts\nconst x = 1;\n```'); + }); + + it('converts simple html to markdown', () => { + assert.equal(htmlToWecomMarkdown('bold
x'), '**bold**\n`x`'); + }); + + it('builds markdown message payloads', () => { + assert.deepEqual(buildMarkdownMessage('hello'), { + msgtype: 'markdown', + markdown: { content: 'hello' }, + }); + }); +}); + +describe('WeCom permission helpers', () => { + const buttons = [[ + { text: 'Allow once', callbackData: 'perm:allow:req-1' }, + { text: 'Deny', callbackData: 'perm:deny:req-1' }, + ]]; + + it('renders /perm fallback text', () => { + const text = buildPermissionCommandText('Need approval', buttons); + assert.ok(text.includes('/perm allow req-1')); + assert.ok(text.includes('/perm deny req-1')); + }); + + it('builds clickable permission cards', () => { + const card = buildPermissionCard(buttons, 'perm_fixed'); + assert.equal(card.msgtype, 'template_card'); + assert.equal(card.template_card.card_type, 'button_interaction'); + assert.equal(card.template_card.task_id, 'perm_fixed'); + assert.equal(card.template_card.button_list?.[0]?.key, 'perm:allow:req-1'); + }); +}); \ No newline at end of file diff --git a/src/app/api/bridge/settings/route.ts b/src/app/api/bridge/settings/route.ts index 4787e210..e0d003f5 100644 --- a/src/app/api/bridge/settings/route.ts +++ b/src/app/api/bridge/settings/route.ts @@ -22,6 +22,12 @@ const BRIDGE_SETTING_KEYS = [ 'bridge_feishu_group_policy', 'bridge_feishu_group_allow_from', 'bridge_feishu_require_mention', + 'bridge_wecom_enabled', + 'bridge_wecom_bot_id', + 'bridge_wecom_secret', + 'bridge_wecom_allowed_users', + 'bridge_wecom_group_policy', + 'bridge_wecom_group_allow_from', 'bridge_discord_enabled', 'bridge_discord_bot_token', 'bridge_discord_allowed_users', diff --git a/src/app/api/settings/wecom/route.ts b/src/app/api/settings/wecom/route.ts new file mode 100644 index 00000000..b2ac1871 --- /dev/null +++ b/src/app/api/settings/wecom/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSetting, setSetting } from '@/lib/db'; + +const WECOM_KEYS = [ + 'bridge_wecom_enabled', + 'bridge_wecom_bot_id', + 'bridge_wecom_secret', + 'bridge_wecom_allowed_users', + 'bridge_wecom_group_policy', + 'bridge_wecom_group_allow_from', +] as const; + +export async function GET() { + try { + const result: Record = {}; + for (const key of WECOM_KEYS) { + const value = getSetting(key); + if (value === undefined) continue; + + if (key === 'bridge_wecom_secret' && value.length > 8) { + result[key] = '***' + value.slice(-8); + } else { + result[key] = value; + } + } + + return NextResponse.json({ settings: result }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to read WeCom settings'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const { settings } = body; + + if (!settings || typeof settings !== 'object') { + return NextResponse.json({ error: 'Invalid settings data' }, { status: 400 }); + } + + for (const [key, value] of Object.entries(settings)) { + if (!WECOM_KEYS.includes(key as typeof WECOM_KEYS[number])) continue; + const strValue = String(value ?? '').trim(); + + if (key === 'bridge_wecom_secret' && strValue.startsWith('***')) { + continue; + } + + setSetting(key, strValue); + } + + return NextResponse.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save WeCom settings'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/settings/wecom/verify/route.ts b/src/app/api/settings/wecom/verify/route.ts new file mode 100644 index 00000000..69043d98 --- /dev/null +++ b/src/app/api/settings/wecom/verify/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { WSClient } from '@wecom/aibot-node-sdk'; +import { getSetting } from '@/lib/db'; + +export const runtime = 'nodejs'; + +/** + * POST /api/settings/wecom/verify + * + * Verifies WeCom AI Bot credentials by establishing a short-lived WebSocket + * connection and waiting for the SDK authenticated event. + * If secret starts with *** (masked), falls back to the stored secret. + */ +export async function POST(request: NextRequest) { + let client: WSClient | null = null; + + try { + const body = await request.json(); + let { bot_id, secret } = body; + + if (!bot_id) { + bot_id = getSetting('bridge_wecom_bot_id') || ''; + } + if (!secret || secret.startsWith('***')) { + secret = getSetting('bridge_wecom_secret') || ''; + } + + if (!bot_id || !secret) { + return NextResponse.json( + { verified: false, error: 'Bot ID and Secret are required' }, + { status: 400 }, + ); + } + + client = new WSClient({ + botId: bot_id, + secret, + requestTimeout: 10_000, + reconnectInterval: 1_000, + maxReconnectAttempts: 0, + }); + const verifyClient = client; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('Timed out while waiting for WeCom authentication')); + }, 10_000); + + const cleanup = () => { + clearTimeout(timeout); + verifyClient.off('authenticated', onAuthenticated); + verifyClient.off('error', onError); + verifyClient.off('disconnected', onDisconnected); + }; + + const onAuthenticated = () => { + cleanup(); + resolve(); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onDisconnected = (reason: string) => { + cleanup(); + reject(new Error(reason || 'WeCom connection disconnected before authentication')); + }; + + verifyClient.once('authenticated', onAuthenticated); + verifyClient.once('error', onError); + verifyClient.once('disconnected', onDisconnected); + verifyClient.connect(); + }); + + return NextResponse.json({ verified: true, botId: bot_id }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Verification failed'; + return NextResponse.json({ verified: false, error: message }, { status: 500 }); + } finally { + try { + client?.disconnect(); + } catch { + // ignore cleanup failure + } + } +} \ No newline at end of file diff --git a/src/components/bridge/BridgeLayout.tsx b/src/components/bridge/BridgeLayout.tsx index 06276bab..162ca1f5 100644 --- a/src/components/bridge/BridgeLayout.tsx +++ b/src/components/bridge/BridgeLayout.tsx @@ -7,12 +7,13 @@ import { Button } from "@/components/ui/button"; import { BridgeSection } from "./BridgeSection"; import { TelegramBridgeSection } from "./TelegramBridgeSection"; import { FeishuBridgeSection } from "./FeishuBridgeSection"; +import { WecomBridgeSection } from "./WecomBridgeSection"; import { DiscordBridgeSection } from "./DiscordBridgeSection"; import { QqBridgeSection } from "./QqBridgeSection"; import { useTranslation } from "@/hooks/useTranslation"; import type { TranslationKey } from "@/i18n"; -type Section = "bridge" | "telegram" | "feishu" | "discord" | "qq"; +type Section = "bridge" | "telegram" | "feishu" | "wecom" | "discord" | "qq"; interface SidebarItem { id: Section; @@ -24,10 +25,12 @@ const sidebarItems: SidebarItem[] = [ { id: "bridge", label: "Bridge", icon: WifiHigh }, { id: "telegram", label: "Telegram", icon: TelegramLogo }, { id: "feishu", label: "Feishu", icon: ChatTeardrop }, + { id: "wecom", label: "WeCom", icon: ChatTeardrop }, { id: "discord", label: "Discord", icon: GameController }, { id: "qq", label: "QQ", icon: ChatsCircle }, ]; + function getSectionFromHash(): Section { if (typeof window === "undefined") return "bridge"; const hash = window.location.hash.replace("#", ""); @@ -53,6 +56,7 @@ export function BridgeLayout() { 'Bridge': 'bridge.title', 'Telegram': 'bridge.telegramSettings', 'Feishu': 'bridge.feishuSettings', + 'WeCom': 'bridge.wecomSettings', 'Discord': 'bridge.discordSettings', 'QQ': 'bridge.qqSettings', }; @@ -96,6 +100,7 @@ export function BridgeLayout() { {activeSection === "bridge" && } {activeSection === "telegram" && } {activeSection === "feishu" && } + {activeSection === "wecom" && } {activeSection === "discord" && } {activeSection === "qq" && } diff --git a/src/components/bridge/BridgeSection.tsx b/src/components/bridge/BridgeSection.tsx index d57815f9..e658c301 100644 --- a/src/components/bridge/BridgeSection.tsx +++ b/src/components/bridge/BridgeSection.tsx @@ -25,6 +25,7 @@ interface BridgeSettings { remote_bridge_enabled: string; bridge_telegram_enabled: string; bridge_feishu_enabled: string; + bridge_wecom_enabled: string; bridge_discord_enabled: string; bridge_qq_enabled: string; bridge_auto_start: string; @@ -37,6 +38,7 @@ const DEFAULT_SETTINGS: BridgeSettings = { remote_bridge_enabled: "", bridge_telegram_enabled: "", bridge_feishu_enabled: "", + bridge_wecom_enabled: "", bridge_discord_enabled: "", bridge_qq_enabled: "", bridge_auto_start: "", @@ -125,6 +127,10 @@ export function BridgeSection() { saveSettings({ bridge_feishu_enabled: checked ? "true" : "" }); }; + const handleToggleWecom = (checked: boolean) => { + saveSettings({ bridge_wecom_enabled: checked ? "true" : "" }); + }; + const handleToggleDiscord = (checked: boolean) => { saveSettings({ bridge_discord_enabled: checked ? "true" : "" }); }; @@ -171,6 +177,7 @@ export function BridgeSection() { const isEnabled = settings.remote_bridge_enabled === "true"; const isTelegramEnabled = settings.bridge_telegram_enabled === "true"; const isFeishuEnabled = settings.bridge_feishu_enabled === "true"; + const isWecomEnabled = settings.bridge_wecom_enabled === "true"; const isDiscordEnabled = settings.bridge_discord_enabled === "true"; const isQQEnabled = settings.bridge_qq_enabled === "true"; const isAutoStart = settings.bridge_auto_start === "true"; @@ -213,11 +220,10 @@ export function BridgeSection() {
{isRunning ? : } {isRunning @@ -298,7 +304,27 @@ export function BridgeSection() {
- + +
+

{t("bridge.wecomChannel")}

+

+ {t("bridge.wecomChannelDesc")} +

+
+
+ +
+ +
+
+

{t("bridge.discordChannel")}

{t("bridge.discordChannelDesc")}

@@ -358,11 +384,10 @@ export function BridgeSection() { {adapter.channelType}
{adapter.running ? t("bridge.adapterRunning") @@ -463,6 +488,10 @@ export function BridgeSection() { > {saving ? t("common.loading") : t("common.save")} + +
+ {t("bridge.defaultsApplyHint")} +
)}
diff --git a/src/components/bridge/WecomBridgeSection.tsx b/src/components/bridge/WecomBridgeSection.tsx new file mode 100644 index 00000000..28c8592f --- /dev/null +++ b/src/components/bridge/WecomBridgeSection.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + SpinnerGap, + CheckCircle, + Warning, +} from "@/components/ui/icon"; +import { useTranslation } from "@/hooks/useTranslation"; + +interface WecomBridgeSettings { + bridge_wecom_bot_id: string; + bridge_wecom_secret: string; + bridge_wecom_allowed_users: string; + bridge_wecom_group_policy: string; + bridge_wecom_group_allow_from: string; +} + +const DEFAULT_SETTINGS: WecomBridgeSettings = { + bridge_wecom_bot_id: "", + bridge_wecom_secret: "", + bridge_wecom_allowed_users: "", + bridge_wecom_group_policy: "open", + bridge_wecom_group_allow_from: "", +}; + +export function WecomBridgeSection() { + const [, setSettings] = useState(DEFAULT_SETTINGS); + const [botId, setBotId] = useState(""); + const [secret, setSecret] = useState(""); + const [allowedUsers, setAllowedUsers] = useState(""); + const [groupPolicy, setGroupPolicy] = useState("open"); + const [groupAllowFrom, setGroupAllowFrom] = useState(""); + const [saving, setSaving] = useState(false); + const [verifying, setVerifying] = useState(false); + const [verifyResult, setVerifyResult] = useState<{ + ok: boolean; + message: string; + } | null>(null); + const { t } = useTranslation(); + + const fetchSettings = useCallback(async () => { + try { + const res = await fetch("/api/settings/wecom"); + if (!res.ok) return; + + const data = await res.json(); + const s = { ...DEFAULT_SETTINGS, ...data.settings }; + setSettings(s); + setBotId(s.bridge_wecom_bot_id); + setSecret(s.bridge_wecom_secret); + setAllowedUsers(s.bridge_wecom_allowed_users); + setGroupPolicy(s.bridge_wecom_group_policy || "open"); + setGroupAllowFrom(s.bridge_wecom_group_allow_from); + } catch { + // ignore + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + const saveSettings = async (updates: Partial) => { + setSaving(true); + try { + const res = await fetch("/api/settings/wecom", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: updates }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...updates })); + } + } catch { + // ignore + } finally { + setSaving(false); + } + }; + + const handleSaveCredentials = () => { + const updates: Partial = { + bridge_wecom_bot_id: botId, + }; + if (secret && !secret.startsWith("***")) { + updates.bridge_wecom_secret = secret; + } + saveSettings(updates); + }; + + const handleVerify = async () => { + setVerifying(true); + setVerifyResult(null); + try { + if (!botId || !secret) { + setVerifyResult({ + ok: false, + message: t("wecom.enterCredentialsFirst"), + }); + return; + } + + const res = await fetch("/api/settings/wecom/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bot_id: botId, + secret, + }), + }); + const data = await res.json(); + + if (data.verified) { + setVerifyResult({ + ok: true, + message: data.botId + ? t("wecom.verifiedAs", { name: data.botId }) + : t("wecom.verified"), + }); + } else { + setVerifyResult({ + ok: false, + message: data.error || t("wecom.verifyFailed"), + }); + } + } catch { + setVerifyResult({ ok: false, message: t("wecom.verifyFailed") }); + } finally { + setVerifying(false); + } + }; + + const handleSaveAllowedUsers = () => { + saveSettings({ + bridge_wecom_allowed_users: allowedUsers, + }); + }; + + const handleSaveAccessSettings = () => { + saveSettings({ + bridge_wecom_group_policy: groupPolicy, + bridge_wecom_group_allow_from: groupAllowFrom, + }); + }; + + return ( +
+
+
+

{t("wecom.credentials")}

+

+ {t("wecom.credentialsDesc")} +

+
+ +
+
+ + setBotId(e.target.value)} + placeholder="wbot-xxxxxxxxxxxxxxxx" + className="font-mono text-sm" + /> +
+ +
+ + setSecret(e.target.value)} + placeholder="xxxxxxxxxxxxxxxxxxxxxxxx" + className="font-mono text-sm" + /> +
+
+ +
+ + +
+ + {verifyResult && ( +
+ {verifyResult.ok ? ( + + ) : ( + + )} + {verifyResult.message} +
+ )} +
+ +
+
+

{t("wecom.allowedUsers")}

+

+ {t("wecom.allowedUsersDesc")} +

+
+ +
+ setAllowedUsers(e.target.value)} + placeholder="zhangsan, chat_123456" + className="font-mono text-sm" + /> +

+ {t("wecom.allowedUsersHint")} +

+
+ + +
+ +
+
+

{t("wecom.groupSettings")}

+

+ {t("wecom.groupSettingsDesc")} +

+
+ +
+
+ + +
+ + {groupPolicy === "allowlist" && ( +
+ + setGroupAllowFrom(e.target.value)} + placeholder="chat_123456, chat_789012" + className="font-mono text-sm" + /> +

+ {t("wecom.groupAllowFromHint")} +

+
+ )} +
+ + +
+ +
+

{t("wecom.setupGuide")}

+
    +
  1. {t("wecom.step1")}
  2. +
  3. {t("wecom.step2")}
  4. +
  5. {t("wecom.step3")}
  6. +
  7. {t("wecom.step4")}
  8. +
  9. {t("wecom.step5")}
  10. +
  11. {t("wecom.step6")}
  12. +
+
+
+ ); +} \ No newline at end of file diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 55d88133..3129bcc4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -543,7 +543,7 @@ const en = { // ── Settings: Remote Bridge ──────────────────────────────── 'settings.bridge': 'Remote Bridge', 'bridge.title': 'Remote Bridge', - 'bridge.description': 'Control Claude from external channels like Telegram or Feishu', + 'bridge.description': 'Control Claude from external channels like Telegram, WeCom, or Feishu', 'bridge.enabled': 'Enable Remote Bridge', 'bridge.enabledDesc': 'Allow external messaging channels to interact with Claude', 'bridge.activeHint': 'Bridge is active. External channels can send tasks to Claude.', @@ -567,6 +567,7 @@ const en = { 'bridge.noActiveBindings': 'No active session bindings', 'bridge.defaults': 'Defaults', 'bridge.defaultsDesc': 'Default settings for bridge-initiated sessions', + 'bridge.defaultsApplyHint': 'Changes here apply to newly created bridge sessions. Existing channel chats keep their current bound session; send /new in the bridge chat to start a fresh session with the new provider/model.', 'bridge.defaultWorkDir': 'Working Directory', 'bridge.defaultWorkDirHint': 'Default project folder for bridge sessions', 'bridge.defaultModel': 'Model', @@ -593,6 +594,10 @@ const en = { 'bridge.feishuChannelDesc': 'Receive and respond to messages via Feishu Bot', 'bridge.feishuSettings': 'Feishu Settings', 'bridge.feishuSettingsDesc': 'Configure Feishu App credentials for bridge', + 'bridge.wecomChannel': 'WeCom', + 'bridge.wecomChannelDesc': 'Receive and respond to messages via WeCom AI Bot', + 'bridge.wecomSettings': 'WeCom Settings', + 'bridge.wecomSettingsDesc': 'Configure WeCom AI Bot credentials for bridge', 'bridge.discordChannel': 'Discord', 'bridge.discordChannelDesc': 'Receive and respond to messages via Discord Bot', 'bridge.discordSettings': 'Discord Settings', @@ -668,6 +673,35 @@ const en = { 'qq.step4': 'Go back to the Bridge page, enable the QQ channel toggle, and start the bridge', 'qq.step5': 'Add your QQ bot as a friend and send it a message to start chatting', + // ── Settings: WeCom Bridge ───────────────────────────────── + 'wecom.credentials': 'Bot Credentials', + 'wecom.credentialsDesc': 'Enter your WeCom AI Bot ID and Secret', + 'wecom.botId': 'Bot ID', + 'wecom.secret': 'Secret', + 'wecom.verify': 'Test Connection', + 'wecom.verified': 'Connection verified', + 'wecom.verifiedAs': 'Connected as {name}', + 'wecom.verifyFailed': 'Connection failed', + 'wecom.enterCredentialsFirst': 'Enter Bot ID and Secret first', + 'wecom.allowedUsers': 'Allowed Users', + 'wecom.allowedUsersDesc': 'Comma-separated userid or chatid values allowed to use bridge', + 'wecom.allowedUsersHint': 'Leave empty to allow all users and chats', + 'wecom.groupSettings': 'Group Chat Settings', + 'wecom.groupSettingsDesc': 'Control how the bot responds in WeCom group chats', + 'wecom.groupPolicy': 'Group Policy', + 'wecom.groupPolicyOpen': 'Open — respond in all groups', + 'wecom.groupPolicyAllowlist': 'Allowlist — only specified groups', + 'wecom.groupPolicyDisabled': 'Disabled — ignore all group messages', + 'wecom.groupAllowFrom': 'Allowed Groups', + 'wecom.groupAllowFromHint': 'Comma-separated chatid values of allowed groups', + 'wecom.setupGuide': 'Setup Guide', + 'wecom.step1': 'Create or open your WeCom AI Bot in the Enterprise WeCom developer console', + 'wecom.step2': 'Copy the Bot ID and Secret from the bot credentials page', + 'wecom.step3': 'Paste the credentials above and click Save', + 'wecom.step4': 'Choose the group message policy and optionally configure an allowlist', + 'wecom.step5': 'Go back to the Bridge page, enable the WeCom channel toggle, and start the bridge', + 'wecom.step6': 'Open a bot chat or add the bot to a group chat, then send a message to test the bridge', + // ── Assistant Workspace ────────────────────────────── 'settings.assistant': 'Assistant', 'assistant.workspaceTitle': 'Assistant Workspace', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 77e88a99..4c11e1c7 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -540,7 +540,7 @@ const zh: Record = { // ── Settings: Remote Bridge ──────────────────────────────── 'settings.bridge': '远程桥接', 'bridge.title': '远程桥接', - 'bridge.description': '通过 Telegram、飞书等外部渠道控制 Claude', + 'bridge.description': '通过 Telegram、企业微信、飞书等外部渠道控制 Claude', 'bridge.enabled': '启用远程桥接', 'bridge.enabledDesc': '允许外部消息渠道与 Claude 交互', 'bridge.activeHint': '桥接已激活。外部渠道可以向 Claude 发送任务。', @@ -564,6 +564,7 @@ const zh: Record = { 'bridge.noActiveBindings': '无活跃会话绑定', 'bridge.defaults': '默认设置', 'bridge.defaultsDesc': '桥接发起会话的默认设置', + 'bridge.defaultsApplyHint': '这里的修改只会作用于新创建的桥接会话。已有渠道聊天会继续绑定当前 session;请在桥接聊天里发送 /new,使用新的 provider / model 重新开始。', 'bridge.defaultWorkDir': '工作目录', 'bridge.defaultWorkDirHint': '桥接会话的默认项目文件夹', 'bridge.defaultModel': '模型', @@ -590,6 +591,10 @@ const zh: Record = { 'bridge.feishuChannelDesc': '通过飞书机器人接收和回复消息', 'bridge.feishuSettings': '飞书设置', 'bridge.feishuSettingsDesc': '配置桥接使用的飞书应用凭据', + 'bridge.wecomChannel': '企业微信', + 'bridge.wecomChannelDesc': '通过企业微信智能机器人接收和回复消息', + 'bridge.wecomSettings': '企业微信设置', + 'bridge.wecomSettingsDesc': '配置桥接使用的企业微信智能机器人凭据', 'bridge.discordChannel': 'Discord', 'bridge.discordChannelDesc': '通过 Discord Bot 接收和回复消息', 'bridge.discordSettings': 'Discord 设置', @@ -665,6 +670,35 @@ const zh: Record = { 'qq.step4': '回到桥接主页,打开 QQ 渠道开关,启动桥接', 'qq.step5': '添加 QQ 机器人为好友并发送消息开始聊天', + // ── Settings: WeCom Bridge ───────────────────────────────── + 'wecom.credentials': '机器人凭据', + 'wecom.credentialsDesc': '输入您的企业微信智能机器人 Bot ID 和 Secret', + 'wecom.botId': 'Bot ID', + 'wecom.secret': 'Secret', + 'wecom.verify': '测试连接', + 'wecom.verified': '连接验证成功', + 'wecom.verifiedAs': '已连接为 {name}', + 'wecom.verifyFailed': '连接失败', + 'wecom.enterCredentialsFirst': '请先输入 Bot ID 和 Secret', + 'wecom.allowedUsers': '允许的用户', + 'wecom.allowedUsersDesc': '允许使用桥接的 userid 或 chatid,逗号分隔', + 'wecom.allowedUsersHint': '留空则允许所有用户和会话', + 'wecom.groupSettings': '群聊设置', + 'wecom.groupSettingsDesc': '控制机器人在企业微信群聊中的响应方式', + 'wecom.groupPolicy': '群聊策略', + 'wecom.groupPolicyOpen': '开放 — 响应所有群消息', + 'wecom.groupPolicyAllowlist': '白名单 — 仅指定群组', + 'wecom.groupPolicyDisabled': '禁用 — 忽略所有群消息', + 'wecom.groupAllowFrom': '允许的群组', + 'wecom.groupAllowFromHint': '允许的群组 chatid,逗号分隔', + 'wecom.setupGuide': '设置指南', + 'wecom.step1': '前往企业微信开发者后台,创建或打开您的智能机器人', + 'wecom.step2': '在机器人凭据页面复制 Bot ID 和 Secret', + 'wecom.step3': '将凭据粘贴到上方后点击「保存」', + 'wecom.step4': '选择群聊消息策略,并按需配置群组白名单', + 'wecom.step5': '回到桥接主页,打开企业微信渠道开关并启动桥接', + 'wecom.step6': '打开机器人单聊或将机器人加入群聊,发送消息验证桥接链路', + // ── Assistant Workspace ────────────────────────────── 'settings.assistant': '助理', 'assistant.workspaceTitle': '助理工作区', diff --git a/src/lib/bridge/adapters/index.ts b/src/lib/bridge/adapters/index.ts index d0cfdeb0..919d4920 100644 --- a/src/lib/bridge/adapters/index.ts +++ b/src/lib/bridge/adapters/index.ts @@ -11,5 +11,6 @@ import './telegram-adapter'; import './feishu-adapter'; +import './wecom-adapter'; import './discord-adapter'; import './qq-adapter'; diff --git a/src/lib/bridge/adapters/wecom-adapter.ts b/src/lib/bridge/adapters/wecom-adapter.ts new file mode 100644 index 00000000..69d60b05 --- /dev/null +++ b/src/lib/bridge/adapters/wecom-adapter.ts @@ -0,0 +1,632 @@ +/** + * Enterprise WeCom Adapter — implements BaseChannelAdapter for the + * @wecom/aibot-node-sdk WebSocket channel. + */ + +import crypto from 'crypto'; +import path from 'path'; +import { + WSClient, + type EventMessageWith, + type FileMessage, + type ImageMessage, + type MixedMessage, + type TemplateCardEventData, + type TextMessage, + type VoiceMessage, + type WsFrame, +} from '@wecom/aibot-node-sdk'; +import type { FileAttachment } from '@/types'; +import type { ChannelType, InboundMessage, OutboundMessage, SendResult } from '../types'; +import { BaseChannelAdapter, registerAdapterFactory } from '../channel-adapter'; +import { getSetting, insertAuditLog } from '../../db'; +import { + buildMarkdownMessage, + buildPermissionCard, + buildPermissionCommandText, + htmlToWecomMarkdown, + preprocessWecomMarkdown, +} from '../markdown/wecom'; + +const DEDUP_MAX = 1000; +const MAX_FILE_SIZE = 20 * 1024 * 1024; + +export const WECOM_PROCESSING_STARTED_TEXT = '已收到,正在处理中...'; + +type WecomEventBody = { + event?: { + event_key?: string; + key?: string; + task_id?: string; + eventtype?: string; + template_card_event?: { + event_key?: string; + key?: string; + task_id?: string; + card_type?: string; + }; + }; + event_key?: string; +} | undefined; + +export function extractWecomTemplateCardCallbackData(body: WecomEventBody): string | undefined { + if (!body) return undefined; + + const event = body.event as { + event_key?: string; + key?: string; + task_id?: string; + eventtype?: string; + template_card_event?: { + event_key?: string; + key?: string; + task_id?: string; + }; + } | undefined; + + return event?.template_card_event?.event_key + || event?.template_card_event?.key + || event?.event_key + || event?.key + || (body as { event_key?: string }).event_key; +} + +export function isWecomTemplateCardEvent(body: WecomEventBody): boolean { + if (!body) return false; + + const event = body.event as { + event_key?: string; + key?: string; + task_id?: string; + eventtype?: string; + template_card_event?: { + event_key?: string; + key?: string; + task_id?: string; + }; + } | undefined; + + return event?.eventtype === 'template_card_event' + || Boolean(event?.template_card_event?.event_key) + || Boolean(event?.template_card_event?.key) + || Boolean(event?.event_key) + || Boolean(event?.key) + || Boolean((body as { event_key?: string }).event_key) + || Boolean(event?.template_card_event?.task_id) + || Boolean(event?.task_id); +} + +const MIME_BY_EXTENSION: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.json': 'application/json', + '.csv': 'text/csv', + '.zip': 'application/zip', +}; + +export class WecomAdapter extends BaseChannelAdapter { + readonly channelType: ChannelType = 'wecom'; + + private running = false; + private queue: InboundMessage[] = []; + private waiters: Array<(msg: InboundMessage | null) => void> = []; + private wsClient: WSClient | null = null; + private seenMessageIds = new Map(); + + async start(): Promise { + if (this.running) return; + + const configError = this.validateConfig(); + if (configError) { + console.warn('[wecom-adapter] Cannot start:', configError); + return; + } + + const botId = getSetting('bridge_wecom_bot_id') || ''; + const secret = getSetting('bridge_wecom_secret') || ''; + + const client = new WSClient({ botId, secret }); + this.wsClient = client; + this.bindClientHandlers(client); + + try { + await this.connectAndWait(client); + this.running = true; + console.log('[wecom-adapter] Started'); + } catch (err) { + try { + client.disconnect(); + } catch { + // ignore disconnect cleanup errors + } + this.wsClient = null; + throw err; + } + } + + async stop(): Promise { + if (!this.running && !this.wsClient) return; + + this.running = false; + + if (this.wsClient) { + try { + this.wsClient.disconnect(); + } catch (err) { + console.warn('[wecom-adapter] disconnect error:', err instanceof Error ? err.message : err); + } + this.wsClient = null; + } + + for (const waiter of this.waiters) { + waiter(null); + } + this.waiters = []; + this.queue = []; + this.seenMessageIds.clear(); + + console.log('[wecom-adapter] Stopped'); + } + + isRunning(): boolean { + return this.running; + } + + consumeOne(): Promise { + const queued = this.queue.shift(); + if (queued) return Promise.resolve(queued); + + if (!this.running) return Promise.resolve(null); + + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + async send(message: OutboundMessage): Promise { + if (!this.wsClient) { + return { ok: false, error: 'WeCom client not initialized' }; + } + + let text = message.text; + if (message.parseMode === 'HTML') { + text = htmlToWecomMarkdown(text); + } + if (message.parseMode === 'HTML' || message.parseMode === 'Markdown') { + text = preprocessWecomMarkdown(text); + } + + if (message.inlineButtons && message.inlineButtons.length > 0) { + return this.sendPermissionCard(message.address.chatId, text, message.inlineButtons); + } + + return this.sendMarkdown(message.address.chatId, text); + } + + validateConfig(): string | null { + const enabled = getSetting('bridge_wecom_enabled'); + if (enabled !== 'true') return 'bridge_wecom_enabled is not true'; + + const botId = getSetting('bridge_wecom_bot_id'); + if (!botId) return 'bridge_wecom_bot_id not configured'; + + const secret = getSetting('bridge_wecom_secret'); + if (!secret) return 'bridge_wecom_secret not configured'; + + return null; + } + + isAuthorized(userId: string, chatId: string): boolean { + const allowedUsers = getSetting('bridge_wecom_allowed_users') || ''; + if (!allowedUsers) return true; + + const allowed = allowedUsers + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + if (allowed.length === 0) return true; + return allowed.includes(userId) || allowed.includes(chatId); + } + + onMessageStart(chatId: string): void { + void this.send({ + address: { + channelType: this.channelType, + chatId, + }, + text: WECOM_PROCESSING_STARTED_TEXT, + parseMode: 'plain', + }).then((result) => { + if (!result.ok) { + console.warn('[wecom-adapter] Processing-start feedback failed:', result.error || 'Send failed'); + } + }).catch((err) => { + console.warn('[wecom-adapter] Processing-start feedback errored:', err instanceof Error ? err.message : err); + }); + } + + private enqueue(msg: InboundMessage): void { + const waiter = this.waiters.shift(); + if (waiter) { + waiter(msg); + } else { + this.queue.push(msg); + } + } + + private bindClientHandlers(client: WSClient): void { + client.on('connected', () => { + console.log('[wecom-adapter] WebSocket connected'); + }); + client.on('authenticated', () => { + console.log('[wecom-adapter] WebSocket authenticated'); + }); + client.on('disconnected', (reason) => { + console.log('[wecom-adapter] WebSocket disconnected:', reason); + }); + client.on('reconnecting', (attempt) => { + console.log('[wecom-adapter] WebSocket reconnecting, attempt:', attempt); + }); + client.on('error', (error) => { + console.error('[wecom-adapter] WebSocket error:', error.message); + }); + + client.on('message.text', (frame) => { + this.handleTextMessage(frame).catch(err => this.logHandlerError('message.text', err)); + }); + client.on('message.image', (frame) => { + this.handleImageMessage(frame).catch(err => this.logHandlerError('message.image', err)); + }); + client.on('message.file', (frame) => { + this.handleFileMessage(frame).catch(err => this.logHandlerError('message.file', err)); + }); + client.on('message.mixed', (frame) => { + this.handleMixedMessage(frame).catch(err => this.logHandlerError('message.mixed', err)); + }); + client.on('message.voice', (frame) => { + this.handleVoiceMessage(frame).catch(err => this.logHandlerError('message.voice', err)); + }); + client.on('event', (frame) => { + this.handleEventFrame(frame).catch(err => this.logHandlerError('event', err)); + }); + } + + private async handleEventFrame(frame: WsFrame<{ event?: { eventtype?: string; event_key?: string; task_id?: string; template_card_event?: { event_key?: string; task_id?: string } } }>): Promise { + if (!isWecomTemplateCardEvent(frame.body)) return; + await this.handleTemplateCardEvent(frame as WsFrame>); + } + + private async connectAndWait(client: WSClient): Promise { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('WeCom authentication timeout')); + }, 10_000); + + const cleanup = () => { + clearTimeout(timeout); + client.off('authenticated', handleAuthenticated); + client.off('error', handleError); + }; + + const handleAuthenticated = () => { + cleanup(); + resolve(); + }; + + const handleError = (error: Error) => { + cleanup(); + reject(error); + }; + + client.once('authenticated', handleAuthenticated); + client.once('error', handleError); + client.connect(); + }); + } + + private logHandlerError(label: string, err: unknown): void { + console.error( + `[wecom-adapter] ${label} handler error:`, + err instanceof Error ? err.stack || err.message : err, + ); + } + + private async sendMarkdown(chatId: string, text: string): Promise { + try { + const res = await this.wsClient!.sendMessage(chatId, buildMarkdownMessage(text)); + return { ok: true, messageId: this.extractOutboundMessageId(res) }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Send failed' }; + } + } + + private async sendPermissionCard( + chatId: string, + text: string, + inlineButtons: import('../types').InlineButton[][], + ): Promise { + let explanationMessageId: string | undefined; + + if (text.trim()) { + const explanation = await this.sendMarkdown(chatId, text); + if (!explanation.ok) return explanation; + explanationMessageId = explanation.messageId; + } + + try { + const res = await this.wsClient!.sendMessage(chatId, buildPermissionCard(inlineButtons)); + return { + ok: true, + messageId: this.extractOutboundMessageId(res) || explanationMessageId, + }; + } catch (err) { + console.warn('[wecom-adapter] Permission card send failed, falling back to /perm text:', err); + const fallbackText = buildPermissionCommandText( + explanationMessageId ? '' : text, + inlineButtons, + ); + const fallback = await this.sendMarkdown(chatId, fallbackText); + if (fallback.ok && !fallback.messageId) { + fallback.messageId = explanationMessageId; + } + return fallback; + } + } + + private extractOutboundMessageId(frame?: WsFrame): string | undefined { + return frame?.headers?.req_id; + } + + private async handleTextMessage(frame: WsFrame): Promise { + const body = frame.body; + if (!body) return; + await this.enqueueStandardMessage(body, body.text?.content || '', undefined, frame); + } + + private async handleVoiceMessage(frame: WsFrame): Promise { + const body = frame.body; + if (!body) return; + await this.enqueueStandardMessage(body, body.voice?.content || '', undefined, frame); + } + + private async handleImageMessage(frame: WsFrame): Promise { + const body = frame.body; + if (!body) return; + + const attachment = body.image?.url + ? await this.downloadAttachment(body.image.url, body.image.aeskey, `image-${body.msgid}.png`, 'image/png') + : null; + + await this.enqueueStandardMessage( + body, + attachment ? '' : '[image download failed]', + attachment ? [attachment] : undefined, + frame, + ); + } + + private async handleFileMessage(frame: WsFrame): Promise { + const body = frame.body; + if (!body) return; + + const attachment = body.file?.url + ? await this.downloadAttachment(body.file.url, body.file.aeskey, `file-${body.msgid}.bin`, 'application/octet-stream') + : null; + + await this.enqueueStandardMessage( + body, + attachment ? '' : '[file download failed]', + attachment ? [attachment] : undefined, + frame, + ); + } + + private async handleMixedMessage(frame: WsFrame): Promise { + const body = frame.body; + if (!body) return; + + const textParts: string[] = []; + const attachments: FileAttachment[] = []; + + for (const item of body.mixed?.msg_item || []) { + if (item.msgtype === 'text' && item.text?.content) { + textParts.push(item.text.content); + continue; + } + + if (item.msgtype === 'image' && item.image?.url) { + const attachment = await this.downloadAttachment( + item.image.url, + item.image.aeskey, + `mixed-image-${body.msgid}-${attachments.length + 1}.png`, + 'image/png', + ); + if (attachment) { + attachments.push(attachment); + } else { + textParts.push('[image download failed]'); + } + } + } + + await this.enqueueStandardMessage( + body, + textParts.join('\n').trim(), + attachments.length > 0 ? attachments : undefined, + frame, + ); + } + + private async handleTemplateCardEvent( + frame: WsFrame>, + ): Promise { + const body = frame.body; + const callbackData = extractWecomTemplateCardCallbackData(body); + if (!body || !callbackData) { + console.warn('[wecom-adapter] Ignoring template card event without callback data:', JSON.stringify(body)); + return; + } + + const userId = body.from?.userid || ''; + const chatId = body.chatid || userId; + if (!chatId) return; + + if (!this.shouldAcceptInbound(body.msgid, userId, chatId, body.chattype === 'group')) { + return; + } + + const inbound: InboundMessage = { + messageId: body.msgid, + address: { + channelType: 'wecom', + chatId, + userId, + }, + text: callbackData, + timestamp: body.create_time || Date.now(), + callbackData, + raw: frame, + }; + + this.auditInbound(chatId, body.msgid, `[callback] ${callbackData}`); + this.enqueue(inbound); + } + + private async enqueueStandardMessage( + body: TextMessage | VoiceMessage | ImageMessage | FileMessage | MixedMessage, + text: string, + attachments: FileAttachment[] | undefined, + raw: unknown, + ): Promise { + const userId = body.from?.userid || ''; + const chatId = body.chatid || userId; + if (!chatId) return; + + if (!this.shouldAcceptInbound(body.msgid, userId, chatId, body.chattype === 'group')) { + return; + } + + const trimmedText = text.trim(); + if (!trimmedText && (!attachments || attachments.length === 0)) return; + + const inbound: InboundMessage = { + messageId: body.msgid, + address: { + channelType: 'wecom', + chatId, + userId, + }, + text: trimmedText, + timestamp: body.create_time || Date.now(), + attachments: attachments && attachments.length > 0 ? attachments : undefined, + raw, + }; + + const summary = attachments && attachments.length > 0 + ? `[${attachments.length} attachment(s)] ${trimmedText.slice(0, 150)}` + : trimmedText.slice(0, 200); + this.auditInbound(chatId, body.msgid, summary); + this.enqueue(inbound); + } + + private shouldAcceptInbound( + messageId: string, + userId: string, + chatId: string, + isGroup: boolean, + ): boolean { + if (this.seenMessageIds.has(messageId)) return false; + this.addToDedup(messageId); + + if (!this.isAuthorized(userId, chatId)) { + console.warn('[wecom-adapter] Unauthorized message from userId:', userId, 'chatId:', chatId); + return false; + } + + if (!isGroup) return true; + + const policy = getSetting('bridge_wecom_group_policy') || 'open'; + if (policy === 'disabled') { + console.log('[wecom-adapter] Group message ignored (policy=disabled), chatId:', chatId); + return false; + } + + if (policy === 'allowlist') { + const allowedGroups = (getSetting('bridge_wecom_group_allow_from') || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (!allowedGroups.includes(chatId)) { + console.log('[wecom-adapter] Group message ignored (not in allowlist), chatId:', chatId); + return false; + } + } + + return true; + } + + private addToDedup(messageId: string): void { + this.seenMessageIds.set(messageId, true); + if (this.seenMessageIds.size <= DEDUP_MAX) return; + + const oldest = this.seenMessageIds.keys().next().value; + if (oldest) this.seenMessageIds.delete(oldest); + } + + private auditInbound(chatId: string, messageId: string, summary: string): void { + try { + insertAuditLog({ + channelType: 'wecom', + chatId, + direction: 'inbound', + messageId, + summary, + }); + } catch { + // best effort + } + } + + private async downloadAttachment( + url: string, + aesKey: string | undefined, + fallbackName: string, + fallbackMime: string, + ): Promise { + if (!this.wsClient) return null; + + try { + const { buffer, filename } = await this.wsClient.downloadFile(url, aesKey); + if (!buffer || buffer.length === 0 || buffer.length > MAX_FILE_SIZE) { + return null; + } + + const name = filename || fallbackName; + return { + id: crypto.randomUUID(), + name, + type: this.guessMimeType(name, fallbackMime), + size: buffer.length, + data: buffer.toString('base64'), + }; + } catch (err) { + console.warn('[wecom-adapter] download failed:', err instanceof Error ? err.message : err); + return null; + } + } + + private guessMimeType(filename: string, fallback: string): string { + const ext = path.extname(filename).toLowerCase(); + return MIME_BY_EXTENSION[ext] || fallback; + } +} + +registerAdapterFactory('wecom', () => new WecomAdapter()); \ No newline at end of file diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts index 912b27b5..6a4f2457 100644 --- a/src/lib/bridge/bridge-manager.ts +++ b/src/lib/bridge/bridge-manager.ts @@ -565,18 +565,26 @@ async function handleMessage( // Use text or empty string for image-only messages (prompt is still required by streamClaude) const promptText = text || (hasAttachments ? 'Describe this image.' : ''); - const result = await engine.processMessage(binding, promptText, async (perm) => { - await broker.forwardPermissionRequest( - adapter, - msg.address, - perm.permissionRequestId, - perm.toolName, - perm.toolInput, - binding.codepilotSessionId, - perm.suggestions, - msg.messageId, - ); - }, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText); + const result = await engine.processMessage( + binding, + promptText, + async (perm) => { + await broker.forwardPermissionRequest( + adapter, + msg.address, + perm.permissionRequestId, + perm.toolName, + perm.toolInput, + binding.codepilotSessionId, + perm.suggestions, + msg.messageId, + ); + }, + taskAbort.signal, + hasAttachments ? msg.attachments : undefined, + onPartialText, + msg.address.userId, + ); // Send response text — render via channel-appropriate format if (result.responseText) { diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index f290c5bb..532f558c 100644 --- a/src/lib/bridge/conversation-engine.ts +++ b/src/lib/bridge/conversation-engine.ts @@ -90,6 +90,7 @@ export async function processMessage( abortSignal?: AbortSignal, files?: FileAttachment[], onPartialText?: OnPartialText, + userId?: string, // Add userId to support identity resolution ): Promise { const sessionId = binding.codepilotSessionId; @@ -199,7 +200,7 @@ export async function processMessage( sessionId, sdkSessionId: binding.sdkSessionId || undefined, model: effectiveModel, - systemPrompt: session?.system_prompt || undefined, + systemPrompt: (userId ? `[User Identity Context]\nThe current user talking to you is: ${userId}\n\n` : '') + (session?.system_prompt || ''), workingDirectory: binding.workingDirectory || session?.working_directory || undefined, abortController, permissionMode, diff --git a/src/lib/bridge/markdown/wecom.ts b/src/lib/bridge/markdown/wecom.ts new file mode 100644 index 00000000..f582a67f --- /dev/null +++ b/src/lib/bridge/markdown/wecom.ts @@ -0,0 +1,97 @@ +/** + * Enterprise WeCom-specific Markdown and card helpers. + */ + +import { + TemplateCardType, + type SendMarkdownMsgBody, + type SendTemplateCardMsgBody, +} from '@wecom/aibot-node-sdk'; +import type { InlineButton } from '../types'; + +/** Detect complex markdown that may benefit from card-style delivery later. */ +export function hasComplexMarkdown(text: string): boolean { + if (/```[\s\S]*?```/.test(text)) return true; + if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true; + return false; +} + +/** Ensure code fences start on a new line without stripping language tags. */ +export function preprocessWecomMarkdown(text: string): string { + return text.replace(/([^\n])```/g, '$1\n```'); +} + +/** Convert simple HTML responses into WeCom markdown. */ +export function htmlToWecomMarkdown(html: string): string { + return html + .replace(/(.*?)<\/b>/gi, '**$1**') + .replace(/(.*?)<\/strong>/gi, '**$1**') + .replace(/(.*?)<\/i>/gi, '*$1*') + .replace(/(.*?)<\/em>/gi, '*$1*') + .replace(/(.*?)<\/code>/gi, '`$1`') + .replace(/
([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
+    .replace(//gi, '\n')
+    .replace(/<\/p>/gi, '\n')
+    .replace(/<[^>]+>/g, '')
+    .replace(/&/g, '&')
+    .replace(/</g, '<')
+    .replace(/>/g, '>')
+    .replace(/"/g, '"')
+    .replace(/'/g, "'")
+    .replace(/\n{3,}/g, '\n\n')
+    .trim();
+}
+
+export function buildMarkdownMessage(text: string): SendMarkdownMsgBody {
+  return {
+    msgtype: 'markdown',
+    markdown: { content: text },
+  };
+}
+
+export function buildPermissionCommands(inlineButtons: InlineButton[][]): string[] {
+  return inlineButtons.flat().map((btn) => {
+    if (!btn.callbackData.startsWith('perm:')) return btn.text;
+
+    const parts = btn.callbackData.split(':');
+    const action = parts[1];
+    const permId = parts.slice(2).join(':');
+    return `/perm ${action} ${permId}`;
+  });
+}
+
+export function buildPermissionCommandText(
+  text: string,
+  inlineButtons: InlineButton[][],
+): string {
+  const sections = [text.trim(), 'Reply with one of these commands:', ...buildPermissionCommands(inlineButtons)]
+    .filter(Boolean);
+  return sections.join('\n\n');
+}
+
+export function buildPermissionCard(
+  inlineButtons: InlineButton[][],
+  taskId = `perm_${Date.now()}`,
+  title = '需要操作权限',
+  desc = '请选择一个审批动作'
+): SendTemplateCardMsgBody {
+  return {
+    msgtype: 'template_card',
+    template_card: {
+      card_type: TemplateCardType.ButtonInteraction,
+      main_title: {
+        title,
+        desc,
+      },
+      sub_title_text: '请通过点击下方按钮批准或拒绝来自 WeCom Bridge 的请求。',
+      button_list: inlineButtons
+        .flat()
+        .slice(0, 6)
+        .map((btn) => ({
+          text: btn.text.slice(0, 24),
+          key: btn.callbackData,
+        })),
+      task_id: taskId,
+    },
+  };
+}
\ No newline at end of file
diff --git a/src/lib/bridge/types.ts b/src/lib/bridge/types.ts
index 69b780f1..fabfd6e2 100644
--- a/src/lib/bridge/types.ts
+++ b/src/lib/bridge/types.ts
@@ -172,5 +172,6 @@ export const PLATFORM_LIMITS: Record = {
   discord: 2000,
   slack: 40000,
   feishu: 30000,
+  wecom: 20000,
   qq: 2000,
 };
diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts
index 6de134fd..d7b8e8bc 100644
--- a/src/lib/claude-client.ts
+++ b/src/lib/claude-client.ts
@@ -394,6 +394,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream