diff --git a/package-lock.json b/package-lock.json index a5dd1bd..488a2b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.28.0", + "version": "0.28.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.28.0", + "version": "0.28.1", "dependencies": { "@ai-sdk/anthropic": "^3.0.47", "@ai-sdk/google": "^3.0.31", @@ -49,6 +49,7 @@ "tailwind-merge": "^3.4.0", "use-stick-to-bottom": "^1.1.2", "uuid": "^13.0.0", + "ws": "^8.19.0", "zlib-sync": "^0.1.10" }, "devDependencies": { @@ -61,6 +62,7 @@ "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", "concurrently": "^9.2.1", "electron": "^40.2.1", "electron-builder": "^26.7.0", diff --git a/package.json b/package.json index 6f50bfb..ba00b01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.28.0", + "version": "0.28.1", "private": true, "author": { "name": "op7418", @@ -73,6 +73,7 @@ "tailwind-merge": "^3.4.0", "use-stick-to-bottom": "^1.1.2", "uuid": "^13.0.0", + "ws": "^8.19.0", "zlib-sync": "^0.1.10" }, "devDependencies": { @@ -85,6 +86,7 @@ "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", "concurrently": "^9.2.1", "electron": "^40.2.1", "electron-builder": "^26.7.0", diff --git a/src/app/api/bridge/settings/route.ts b/src/app/api/bridge/settings/route.ts index ccb66d1..4787e21 100644 --- a/src/app/api/bridge/settings/route.ts +++ b/src/app/api/bridge/settings/route.ts @@ -35,6 +35,12 @@ const BRIDGE_SETTING_KEYS = [ 'bridge_discord_stream_max_chars', 'bridge_discord_max_attachment_size', 'bridge_discord_image_enabled', + 'bridge_qq_enabled', + 'bridge_qq_app_id', + 'bridge_qq_app_secret', + 'bridge_qq_allowed_users', + 'bridge_qq_image_enabled', + 'bridge_qq_max_image_size', ] as const; export async function GET() { diff --git a/src/app/api/settings/qq/route.ts b/src/app/api/settings/qq/route.ts new file mode 100644 index 0000000..5f6a605 --- /dev/null +++ b/src/app/api/settings/qq/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSetting, setSetting } from '@/lib/db'; + +/** + * QQ Bot bridge settings. + * Stored in the SQLite settings table (same as other app settings). + */ + +const QQ_KEYS = [ + 'bridge_qq_enabled', + 'bridge_qq_app_id', + 'bridge_qq_app_secret', + 'bridge_qq_allowed_users', + 'bridge_qq_image_enabled', + 'bridge_qq_max_image_size', +] as const; + +export async function GET() { + try { + const result: Record = {}; + for (const key of QQ_KEYS) { + const value = getSetting(key); + if (value !== undefined) { + // Mask app secret for security + if (key === 'bridge_qq_app_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 QQ 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 (!QQ_KEYS.includes(key as typeof QQ_KEYS[number])) continue; + const strValue = String(value ?? '').trim(); + + // Don't overwrite secret if user sent the masked version back + if (key === 'bridge_qq_app_secret' && strValue.startsWith('***')) { + continue; + } + + setSetting(key, strValue); + } + + return NextResponse.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save QQ settings'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/settings/qq/verify/route.ts b/src/app/api/settings/qq/verify/route.ts new file mode 100644 index 0000000..784957c --- /dev/null +++ b/src/app/api/settings/qq/verify/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSetting } from '@/lib/db'; + +/** + * POST /api/settings/qq/verify + * + * Verifies QQ Bot credentials by: + * 1. Getting an access token via appId + appSecret + * 2. Fetching the WebSocket gateway URL + * + * If both succeed, credentials are valid. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + let { app_id, app_secret } = body; + + // Fall back to stored values if not provided or masked + if (!app_id) { + app_id = getSetting('bridge_qq_app_id') || ''; + } + if (!app_secret || app_secret.startsWith('***')) { + app_secret = getSetting('bridge_qq_app_secret') || ''; + } + + if (!app_id || !app_secret) { + return NextResponse.json( + { verified: false, error: 'App ID and App Secret are required' }, + { status: 400 }, + ); + } + + // Step 1: Get access token + const tokenRes = await fetch('https://bots.qq.com/app/getAppAccessToken', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appId: app_id, clientSecret: app_secret }), + signal: AbortSignal.timeout(10_000), + }); + + const tokenData = await tokenRes.json(); + if (!tokenData.access_token) { + return NextResponse.json({ + verified: false, + error: tokenData.message || 'Failed to get access token', + }); + } + + // Step 2: Verify gateway is reachable + const gatewayRes = await fetch('https://api.sgroup.qq.com/gateway', { + method: 'GET', + headers: { Authorization: `QQBot ${tokenData.access_token}` }, + signal: AbortSignal.timeout(10_000), + }); + + const gatewayData = await gatewayRes.json(); + if (!gatewayData.url) { + return NextResponse.json({ + verified: false, + error: 'Failed to get gateway URL', + }); + } + + return NextResponse.json({ + verified: true, + gatewayUrl: gatewayData.url, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Verification failed'; + return NextResponse.json({ verified: false, error: message }, { status: 500 }); + } +} diff --git a/src/components/bridge/BridgeLayout.tsx b/src/components/bridge/BridgeLayout.tsx index 0009d26..fb5bdd4 100644 --- a/src/components/bridge/BridgeLayout.tsx +++ b/src/components/bridge/BridgeLayout.tsx @@ -3,16 +3,17 @@ import { useState, useCallback, useSyncExternalStore } from "react"; import { HugeiconsIcon } from "@hugeicons/react"; import type { IconSvgElement } from "@hugeicons/react"; -import { Wifi01Icon, TelegramIcon, BubbleChatIcon, GameController01Icon } from "@hugeicons/core-free-icons"; +import { Wifi01Icon, TelegramIcon, BubbleChatIcon, GameController01Icon, MessageMultiple02Icon } from "@hugeicons/core-free-icons"; import { cn } from "@/lib/utils"; import { BridgeSection } from "./BridgeSection"; import { TelegramBridgeSection } from "./TelegramBridgeSection"; import { FeishuBridgeSection } from "./FeishuBridgeSection"; import { DiscordBridgeSection } from "./DiscordBridgeSection"; +import { QqBridgeSection } from "./QqBridgeSection"; import { useTranslation } from "@/hooks/useTranslation"; import type { TranslationKey } from "@/i18n"; -type Section = "bridge" | "telegram" | "feishu" | "discord"; +type Section = "bridge" | "telegram" | "feishu" | "discord" | "qq"; interface SidebarItem { id: Section; @@ -25,6 +26,7 @@ const sidebarItems: SidebarItem[] = [ { id: "telegram", label: "Telegram", icon: TelegramIcon }, { id: "feishu", label: "Feishu", icon: BubbleChatIcon }, { id: "discord", label: "Discord", icon: GameController01Icon }, + { id: "qq", label: "QQ", icon: MessageMultiple02Icon }, ]; function getSectionFromHash(): Section { @@ -53,6 +55,7 @@ export function BridgeLayout() { 'Telegram': 'bridge.telegramSettings', 'Feishu': 'bridge.feishuSettings', 'Discord': 'bridge.discordSettings', + 'QQ': 'bridge.qqSettings', }; const handleSectionChange = useCallback((section: Section) => { @@ -94,6 +97,7 @@ export function BridgeLayout() { {activeSection === "telegram" && } {activeSection === "feishu" && } {activeSection === "discord" && } + {activeSection === "qq" && } diff --git a/src/components/bridge/BridgeSection.tsx b/src/components/bridge/BridgeSection.tsx index 4de82cf..91e8330 100644 --- a/src/components/bridge/BridgeSection.tsx +++ b/src/components/bridge/BridgeSection.tsx @@ -14,7 +14,7 @@ import { SelectValue, } from "@/components/ui/select"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Loading02Icon, CheckmarkCircle02Icon, Alert02Icon, TelegramIcon, BubbleChatIcon, GameController01Icon } from "@hugeicons/core-free-icons"; +import { Loading02Icon, CheckmarkCircle02Icon, Alert02Icon, TelegramIcon, BubbleChatIcon, GameController01Icon, MessageMultiple02Icon } from "@hugeicons/core-free-icons"; import { useTranslation } from "@/hooks/useTranslation"; import type { ProviderModelGroup } from "@/types"; @@ -37,6 +37,7 @@ interface BridgeSettings { bridge_telegram_enabled: string; bridge_feishu_enabled: string; bridge_discord_enabled: string; + bridge_qq_enabled: string; bridge_auto_start: string; bridge_default_work_dir: string; bridge_default_model: string; @@ -48,6 +49,7 @@ const DEFAULT_SETTINGS: BridgeSettings = { bridge_telegram_enabled: "", bridge_feishu_enabled: "", bridge_discord_enabled: "", + bridge_qq_enabled: "", bridge_auto_start: "", bridge_default_work_dir: "", bridge_default_model: "", @@ -169,6 +171,10 @@ export function BridgeSection() { saveSettings({ bridge_discord_enabled: checked ? "true" : "" }); }; + const handleToggleQQ = (checked: boolean) => { + saveSettings({ bridge_qq_enabled: checked ? "true" : "" }); + }; + const handleSaveDefaults = () => { // Split composite "provider_id::model" value const parts = model.split("::"); @@ -240,6 +246,7 @@ export function BridgeSection() { const isTelegramEnabled = settings.bridge_telegram_enabled === "true"; const isFeishuEnabled = settings.bridge_feishu_enabled === "true"; const isDiscordEnabled = settings.bridge_discord_enabled === "true"; + const isQQEnabled = settings.bridge_qq_enabled === "true"; const isAutoStart = settings.bridge_auto_start === "true"; const isRunning = bridgeStatus?.running ?? false; const adapterCount = bridgeStatus?.adapters?.length ?? 0; @@ -409,6 +416,26 @@ export function BridgeSection() { /> +
+
+ +
+

{t("bridge.qqChannel")}

+

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

+
+
+ +
+

{t("bridge.autoStart")}

diff --git a/src/components/bridge/QqBridgeSection.tsx b/src/components/bridge/QqBridgeSection.tsx new file mode 100644 index 0000000..58acec7 --- /dev/null +++ b/src/components/bridge/QqBridgeSection.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Loading02Icon, + CheckmarkCircle02Icon, + Alert02Icon, +} from "@hugeicons/core-free-icons"; +import { useTranslation } from "@/hooks/useTranslation"; + +interface QqBridgeSettings { + bridge_qq_app_id: string; + bridge_qq_app_secret: string; + bridge_qq_allowed_users: string; + bridge_qq_image_enabled: string; + bridge_qq_max_image_size: string; +} + +const DEFAULT_SETTINGS: QqBridgeSettings = { + bridge_qq_app_id: "", + bridge_qq_app_secret: "", + bridge_qq_allowed_users: "", + bridge_qq_image_enabled: "true", + bridge_qq_max_image_size: "20", +}; + +export function QqBridgeSection() { + const [, setSettings] = useState(DEFAULT_SETTINGS); + const [appId, setAppId] = useState(""); + const [appSecret, setAppSecret] = useState(""); + const [allowedUsers, setAllowedUsers] = useState(""); + const [imageEnabled, setImageEnabled] = useState(true); + const [maxImageSize, setMaxImageSize] = useState("20"); + 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/qq"); + if (res.ok) { + const data = await res.json(); + const s = { ...DEFAULT_SETTINGS, ...data.settings }; + setSettings(s); + setAppId(s.bridge_qq_app_id); + setAppSecret(s.bridge_qq_app_secret); + setAllowedUsers(s.bridge_qq_allowed_users); + setImageEnabled(s.bridge_qq_image_enabled !== "false"); + setMaxImageSize(s.bridge_qq_max_image_size || "20"); + } + } catch { + // ignore + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + const saveSettings = async (updates: Partial) => { + setSaving(true); + try { + const res = await fetch("/api/settings/qq", { + 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_qq_app_id: appId, + }; + if (appSecret && !appSecret.startsWith("***")) { + updates.bridge_qq_app_secret = appSecret; + } + saveSettings(updates); + }; + + const handleSaveAllowedUsers = () => { + saveSettings({ + bridge_qq_allowed_users: allowedUsers, + }); + }; + + const handleSaveImageSettings = () => { + saveSettings({ + bridge_qq_image_enabled: imageEnabled ? "true" : "false", + bridge_qq_max_image_size: maxImageSize, + }); + }; + + const handleVerify = async () => { + setVerifying(true); + setVerifyResult(null); + try { + if (!appId) { + setVerifyResult({ + ok: false, + message: t("qq.enterCredentialsFirst"), + }); + return; + } + + const res = await fetch("/api/settings/qq/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + app_id: appId, + app_secret: appSecret, + }), + }); + const data = await res.json(); + + if (data.verified) { + setVerifyResult({ + ok: true, + message: t("qq.verified"), + }); + } else { + setVerifyResult({ + ok: false, + message: data.error || t("qq.verifyFailed"), + }); + } + } catch { + setVerifyResult({ ok: false, message: t("qq.verifyFailed") }); + } finally { + setVerifying(false); + } + }; + + return ( +
+ {/* App Credentials */} +
+
+

{t("qq.credentials")}

+

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

+
+ +
+
+ + setAppId(e.target.value)} + placeholder="xxxxxxxxxx" + className="font-mono text-sm" + /> +
+ +
+ + setAppSecret(e.target.value)} + placeholder="xxxxxxxxxxxxxxxxxxxxxxxx" + className="font-mono text-sm" + /> +
+
+ +
+ + +
+ + {verifyResult && ( +
+ + {verifyResult.message} +
+ )} +
+ + {/* Allowed Users */} +
+
+

{t("qq.allowedUsers")}

+

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

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

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

+
+ + +
+ + {/* Image Settings */} +
+
+

{t("qq.imageSettings")}

+

+ {t("qq.imageSettingsDesc")} +

+
+ +
+
+
+

{t("qq.imageEnabled")}

+

+ {t("qq.imageEnabledDesc")} +

+
+ +
+ + {imageEnabled && ( +
+ + setMaxImageSize(e.target.value)} + placeholder="20" + className="w-32 text-sm" + /> +

+ {t("qq.maxImageSizeHint")} +

+
+ )} +
+ + +
+ + {/* Setup Guide */} +
+

{t("qq.setupGuide")}

+
    +
  1. {t("qq.step1")}
  2. +
  3. {t("qq.step2")}
  4. +
  5. {t("qq.step3")}
  6. +
  7. {t("qq.step4")}
  8. +
  9. {t("qq.step5")}
  10. +
+
+
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 78399c2..570e887 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -583,6 +583,10 @@ const en = { 'bridge.discordChannelDesc': 'Receive and respond to messages via Discord Bot', 'bridge.discordSettings': 'Discord Settings', 'bridge.discordSettingsDesc': 'Configure Discord Bot credentials for bridge', + 'bridge.qqChannel': 'QQ', + 'bridge.qqChannelDesc': 'Receive and respond to messages via QQ Bot (private chat)', + 'bridge.qqSettings': 'QQ Settings', + 'bridge.qqSettingsDesc': 'Configure QQ Bot credentials for bridge', // ── Settings: Discord Bridge ───────────────────────────────── 'discord.credentials': 'Bot Credentials', @@ -625,6 +629,31 @@ const en = { 'discord.stepChannelId': 'Channel ID: right-click a channel name in the sidebar → "Copy Channel ID"', 'discord.stepGuildId': 'Server ID: right-click the server name at the top-left → "Copy Server ID"', + // ── Settings: QQ Bridge ──────────────────────────────────── + 'qq.credentials': 'Bot Credentials', + 'qq.credentialsDesc': 'Enter your QQ Bot App ID and App Secret', + 'qq.appId': 'App ID', + 'qq.appSecret': 'App Secret', + 'qq.verify': 'Test Connection', + 'qq.verified': 'Connection verified', + 'qq.verifyFailed': 'Connection failed', + 'qq.enterCredentialsFirst': 'Enter App ID and App Secret first', + 'qq.allowedUsers': 'Allowed Users', + 'qq.allowedUsersDesc': 'Comma-separated user_openid values allowed to use bridge', + 'qq.allowedUsersHint': 'Leave empty to allow all users', + 'qq.imageSettings': 'Image Settings', + 'qq.imageSettingsDesc': 'Control how the bot handles images from QQ', + 'qq.imageEnabled': 'Enable Image Input', + 'qq.imageEnabledDesc': 'Allow receiving and processing images from QQ', + 'qq.maxImageSize': 'Max Image Size (MB)', + 'qq.maxImageSizeHint': 'Maximum image file size in MB (default: 20)', + 'qq.setupGuide': 'Setup Guide', + 'qq.step1': 'Go to QQ Bot Quick Setup page (q.qq.com/qqbot/openclaw) to create a bot and generate App ID & App Secret', + 'qq.step2': 'Paste the App ID and App Secret above and click "Save"', + 'qq.step3': 'Click "Test Connection" to verify the credentials are valid', + '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', + // ── Assistant Workspace ────────────────────────────── 'settings.assistant': 'Assistant', 'assistant.workspaceTitle': 'Assistant Workspace', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 0a51824..766799b 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -580,6 +580,10 @@ const zh: Record = { 'bridge.discordChannelDesc': '通过 Discord Bot 接收和回复消息', 'bridge.discordSettings': 'Discord 设置', 'bridge.discordSettingsDesc': '配置桥接使用的 Discord Bot 凭据', + 'bridge.qqChannel': 'QQ', + 'bridge.qqChannelDesc': '通过 QQ 机器人接收和回复私聊消息', + 'bridge.qqSettings': 'QQ 设置', + 'bridge.qqSettingsDesc': '配置桥接使用的 QQ 机器人凭据', // ── Settings: Discord Bridge ───────────────────────────────── 'discord.credentials': 'Bot 凭据', @@ -622,6 +626,31 @@ const zh: Record = { 'discord.stepChannelId': '获取频道 ID:右键点击左侧频道名称 →「复制频道 ID」', 'discord.stepGuildId': '获取服务器 ID:右键点击左上角服务器名称 →「复制服务器 ID」', + // ── Settings: QQ Bridge ──────────────────────────────────── + 'qq.credentials': '机器人凭据', + 'qq.credentialsDesc': '输入您的 QQ 机器人 App ID 和 App Secret', + 'qq.appId': 'App ID', + 'qq.appSecret': 'App Secret', + 'qq.verify': '测试连接', + 'qq.verified': '连接验证成功', + 'qq.verifyFailed': '连接失败', + 'qq.enterCredentialsFirst': '请先输入 App ID 和 App Secret', + 'qq.allowedUsers': '允许的用户', + 'qq.allowedUsersDesc': '允许使用桥接的 user_openid,逗号分隔', + 'qq.allowedUsersHint': '留空则允许所有用户', + 'qq.imageSettings': '图片设置', + 'qq.imageSettingsDesc': '控制机器人如何处理 QQ 中的图片', + 'qq.imageEnabled': '启用图片输入', + 'qq.imageEnabledDesc': '允许接收和处理来自 QQ 的图片', + 'qq.maxImageSize': '最大图片大小 (MB)', + 'qq.maxImageSizeHint': '图片文件最大大小,单位 MB(默认:20)', + 'qq.setupGuide': '设置指南', + 'qq.step1': '前往 QQ 机器人快速创建页面 (q.qq.com/qqbot/openclaw) 创建机器人并生成 App ID 和 App Secret', + 'qq.step2': '将 App ID 和 App Secret 粘贴到上方,点击「保存」', + 'qq.step3': '点击「测试连接」验证凭据是否有效', + 'qq.step4': '回到桥接主页,打开 QQ 渠道开关,启动桥接', + 'qq.step5': '添加 QQ 机器人为好友并发送消息开始聊天', + // ── Assistant Workspace ────────────────────────────── 'settings.assistant': '助理', 'assistant.workspaceTitle': '助理工作区', diff --git a/src/lib/bridge/adapters/index.ts b/src/lib/bridge/adapters/index.ts index cf81750..d0cfdeb 100644 --- a/src/lib/bridge/adapters/index.ts +++ b/src/lib/bridge/adapters/index.ts @@ -12,3 +12,4 @@ import './telegram-adapter'; import './feishu-adapter'; import './discord-adapter'; +import './qq-adapter'; diff --git a/src/lib/bridge/adapters/qq-adapter.ts b/src/lib/bridge/adapters/qq-adapter.ts new file mode 100644 index 0000000..400b8e4 --- /dev/null +++ b/src/lib/bridge/adapters/qq-adapter.ts @@ -0,0 +1,491 @@ +/** + * QQ Adapter — implements BaseChannelAdapter for QQ Bot API. + * + * Uses WebSocket Gateway for real-time event subscription. + * First version: private chat (C2C) only, text + image inbound, + * text-only outbound (passive reply). + * + * No streaming preview — QQ passive reply window is tight and + * each reply consumes quota. + * + * No inline buttons — permission handled via /perm text command. + */ + +import WebSocket from 'ws'; +import type { + ChannelType, + InboundMessage, + OutboundMessage, + SendResult, +} from '../types'; +import type { FileAttachment } from '@/types'; +import { BaseChannelAdapter, registerAdapterFactory } from '../channel-adapter'; +import { getSetting, insertAuditLog } from '../../db'; +import { + getAccessToken, + getGatewayUrl, + clearTokenCache, + sendPrivateMessage, + nextMsgSeq, + buildIdentify, + buildHeartbeat, + buildResume, + OP, + INTENTS, + type GatewayPayload, +} from './qq-api'; +import crypto from 'crypto'; + +/** Max number of message_ids to keep for dedup. */ +const DEDUP_MAX = 1000; + +/** Max file download size (20 MB). */ +const MAX_FILE_SIZE = 20 * 1024 * 1024; + +/** Shape of C2C_MESSAGE_CREATE event data. */ +interface QQC2CMessageData { + id: string; + author: { + user_openid: string; + }; + content: string; + timestamp: string; + attachments?: Array<{ + content_type: string; + filename?: string; + url: string; + size?: number; + }>; +} + +export class QQAdapter extends BaseChannelAdapter { + readonly channelType: ChannelType = 'qq'; + + private _running = false; + private queue: InboundMessage[] = []; + private waiters: Array<(msg: InboundMessage | null) => void> = []; + private ws: WebSocket | null = null; + private heartbeatTimer: ReturnType | null = null; + private lastSequence: number | null = null; + private sessionId: string | null = null; + private seenMessageIds = new Map(); + private reconnectAttempts = 0; + private maxReconnectAttempts = 10; + private shouldReconnect = false; + + // ── Lifecycle ─────────────────────────────────────────────── + + async start(): Promise { + if (this._running) return; + + const appId = getSetting('bridge_qq_app_id') || ''; + const appSecret = getSetting('bridge_qq_app_secret') || ''; + + clearTokenCache(); + const token = await getAccessToken(appId, appSecret); + const gatewayUrl = await getGatewayUrl(token); + + this._running = true; + this.shouldReconnect = true; + this.reconnectAttempts = 0; + + await this.connectGateway(gatewayUrl, token); + console.log('[qq-adapter] Started'); + } + + async stop(): Promise { + if (!this._running) return; + this._running = false; + this.shouldReconnect = false; + + this.stopHeartbeat(); + + if (this.ws) { + this.ws.close(1000, 'Bridge stopping'); + this.ws = null; + } + + // Wake up any pending consumeOne() calls + for (const waiter of this.waiters) { + waiter(null); + } + this.waiters = []; + this.queue = []; + this.seenMessageIds.clear(); + + console.log('[qq-adapter] Stopped'); + } + + isRunning(): boolean { + return this._running; + } + + // ── Message Queue ──────────────────────────────────────────── + + async consumeOne(): Promise { + if (this.queue.length > 0) { + return this.queue.shift()!; + } + if (!this._running) return null; + + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + private enqueue(msg: InboundMessage): void { + if (this.waiters.length > 0) { + const waiter = this.waiters.shift()!; + waiter(msg); + } else { + this.queue.push(msg); + } + } + + // ── Send ──────────────────────────────────────────────────── + + async send(message: OutboundMessage): Promise { + const appId = getSetting('bridge_qq_app_id') || ''; + const appSecret = getSetting('bridge_qq_app_secret') || ''; + + try { + const token = await getAccessToken(appId, appSecret); + + // QQ requires msg_id for passive reply. Use replyToMessageId from outbound message. + const msgId = message.replyToMessageId || ''; + if (!msgId) { + console.warn('[qq-adapter] No replyToMessageId — QQ requires msg_id for passive reply'); + return { ok: false, error: 'Missing replyToMessageId for QQ passive reply' }; + } + + const msgSeq = nextMsgSeq(msgId); + + // QQ only supports plain text in v1 — strip any HTML tags + let content = message.text; + if (message.parseMode === 'HTML') { + content = content.replace(/<[^>]+>/g, ''); + } + + return await sendPrivateMessage(token, { + openid: message.address.chatId, + content, + msgId, + msgSeq, + }); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + // ── Config & Auth ──────────────────────────────────────────── + + validateConfig(): string | null { + const appId = getSetting('bridge_qq_app_id') || ''; + const appSecret = getSetting('bridge_qq_app_secret') || ''; + + if (!appId) return 'QQ App ID is required'; + if (!appSecret) return 'QQ App Secret is required'; + return null; + } + + isAuthorized(userId: string, _chatId: string): boolean { + const allowedUsers = getSetting('bridge_qq_allowed_users') || ''; + if (!allowedUsers.trim()) return true; // empty = allow all + const allowed = allowedUsers.split(',').map(s => s.trim()).filter(Boolean); + return allowed.includes(userId); + } + + // ── WebSocket Gateway ─────────────────────────────────────── + + private async connectGateway(gatewayUrl: string, token: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(gatewayUrl); + this.ws = ws; + + let identified = false; + + ws.on('open', () => { + console.log('[qq-adapter] WebSocket connected'); + }); + + ws.on('message', (data: WebSocket.Data) => { + try { + const payload = JSON.parse(data.toString()) as GatewayPayload; + this.handleGatewayPayload(payload, token, ws); + + // Resolve the connection promise once we get READY + if (payload.op === OP.DISPATCH && payload.t === 'READY' && !identified) { + identified = true; + const readyData = payload.d as { session_id?: string }; + this.sessionId = readyData?.session_id || null; + this.reconnectAttempts = 0; + resolve(); + } + } catch (err) { + console.error('[qq-adapter] Error parsing gateway message:', err); + } + }); + + ws.on('close', (code: number, reason: Buffer) => { + console.log(`[qq-adapter] WebSocket closed: ${code} ${reason.toString()}`); + this.stopHeartbeat(); + + if (this.shouldReconnect && this._running) { + this.scheduleReconnect(); + } + + if (!identified) { + reject(new Error(`WebSocket closed before READY: ${code}`)); + } + }); + + ws.on('error', (err: Error) => { + console.error('[qq-adapter] WebSocket error:', err.message); + if (!identified) { + reject(err); + } + }); + }); + } + + private handleGatewayPayload(payload: GatewayPayload, token: string, ws: WebSocket): void { + switch (payload.op) { + case OP.HELLO: { + // Start heartbeat + const helloData = payload.d as { heartbeat_interval: number }; + const interval = helloData?.heartbeat_interval || 41250; + this.startHeartbeat(ws, interval); + + // Send Identify (or Resume if we have a session) + if (this.sessionId && this.lastSequence !== null) { + ws.send(JSON.stringify(buildResume(token, this.sessionId, this.lastSequence))); + } else { + ws.send(JSON.stringify(buildIdentify(token, INTENTS.PUBLIC_MESSAGES))); + } + break; + } + + case OP.DISPATCH: { + // Update sequence number + if (payload.s !== undefined) { + this.lastSequence = payload.s; + } + + if (payload.t === 'C2C_MESSAGE_CREATE') { + this.handleC2CMessage(payload.d as QQC2CMessageData); + } else if (payload.t === 'RESUMED') { + console.log('[qq-adapter] Session resumed'); + this.reconnectAttempts = 0; + } + break; + } + + case OP.HEARTBEAT_ACK: + // Heartbeat acknowledged — nothing to do + break; + + case OP.RECONNECT: + console.log('[qq-adapter] Server requested reconnect'); + ws.close(4000, 'Server requested reconnect'); + break; + + case OP.INVALID_SESSION: { + console.warn('[qq-adapter] Invalid session, will re-identify'); + this.sessionId = null; + this.lastSequence = null; + ws.close(4000, 'Invalid session'); + break; + } + } + } + + private handleC2CMessage(data: QQC2CMessageData): void { + if (!data?.id || !data?.author?.user_openid) return; + + // Dedup + if (this.seenMessageIds.has(data.id)) return; + this.seenMessageIds.set(data.id, true); + if (this.seenMessageIds.size > DEDUP_MAX) { + const keys = Array.from(this.seenMessageIds.keys()); + for (let i = 0; i < keys.length - DEDUP_MAX; i++) { + this.seenMessageIds.delete(keys[i]); + } + } + + const userId = data.author.user_openid; + + // Authorization check + if (!this.isAuthorized(userId, userId)) { + console.warn(`[qq-adapter] Unauthorized user: ${userId.slice(0, 8)}...`); + return; + } + + // Process attachments (images only) + const imageEnabled = getSetting('bridge_qq_image_enabled') !== 'false'; + const imageAttachments = imageEnabled + ? (data.attachments || []).filter(a => a.content_type?.startsWith('image/')) + : []; + + // Build inbound message + const msg: InboundMessage = { + messageId: data.id, + address: { + channelType: 'qq', + chatId: userId, // For C2C, chatId = user's openid + userId, + displayName: userId.slice(0, 8), + }, + text: (data.content || '').trim(), + timestamp: new Date(data.timestamp).getTime() || Date.now(), + }; + + // Download images asynchronously and enqueue + if (imageAttachments.length > 0) { + this.downloadImages(imageAttachments).then(attachments => { + if (attachments.length > 0) { + msg.attachments = attachments; + } + this.enqueue(msg); + }).catch(err => { + console.error('[qq-adapter] Image download failed:', err); + // Still enqueue the message with text (if any) and an error note + if (msg.text || imageAttachments.length === 0) { + this.enqueue(msg); + } else { + // Image-only message with download failure — notify user + this.sendImageError(userId, data.id, err instanceof Error ? err.message : 'Download failed'); + } + }); + } else { + this.enqueue(msg); + } + + insertAuditLog({ + channelType: 'qq', + chatId: userId, + direction: 'inbound', + messageId: data.id, + summary: (data.content || '').slice(0, 200), + }); + } + + // ── Image Download ──────────────────────────────────────────── + + private async downloadImages( + attachments: QQC2CMessageData['attachments'] & Array, + ): Promise { + const maxSizeMB = parseInt(getSetting('bridge_qq_max_image_size') || '', 10) || 20; + const maxSize = maxSizeMB * 1024 * 1024; + const results: FileAttachment[] = []; + + for (const att of attachments as Array<{ content_type: string; filename?: string; url: string; size?: number }>) { + try { + // Fix protocol-relative URLs + let url = att.url; + if (url.startsWith('//')) { + url = 'https:' + url; + } + + // Check size if provided + if (att.size && att.size > maxSize) { + throw new Error(`Image too large: ${att.size} bytes (max ${maxSize})`); + } + + const res = await fetch(url, { + signal: AbortSignal.timeout(30_000), + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status} downloading image`); + } + + const arrayBuffer = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + if (buffer.length > maxSize) { + throw new Error(`Image too large: ${buffer.length} bytes (max ${maxSize})`); + } + + const base64 = buffer.toString('base64'); + const filename = att.filename || `image_${crypto.randomUUID().slice(0, 8)}.${att.content_type.split('/')[1] || 'png'}`; + + results.push({ + id: crypto.randomUUID(), + name: filename, + type: att.content_type, + size: buffer.length, + data: base64, + }); + } catch (err) { + console.error(`[qq-adapter] Failed to download image: ${err instanceof Error ? err.message : err}`); + throw err; // Re-throw to let caller handle + } + } + + return results; + } + + private async sendImageError(userId: string, msgId: string, errorMsg: string): Promise { + const appId = getSetting('bridge_qq_app_id') || ''; + const appSecret = getSetting('bridge_qq_app_secret') || ''; + try { + const token = await getAccessToken(appId, appSecret); + await sendPrivateMessage(token, { + openid: userId, + content: `Failed to process image: ${errorMsg}`, + msgId, + msgSeq: nextMsgSeq(msgId), + }); + } catch { /* best effort */ } + } + + // ── Heartbeat ──────────────────────────────────────────────── + + private startHeartbeat(ws: WebSocket, intervalMs: number): void { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(buildHeartbeat(this.lastSequence))); + } + }, intervalMs); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + // ── Reconnection ──────────────────────────────────────────── + + private scheduleReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('[qq-adapter] Max reconnect attempts reached, giving up'); + this._running = false; + return; + } + + this.reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts - 1), 60_000); + console.log(`[qq-adapter] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); + + setTimeout(async () => { + if (!this._running || !this.shouldReconnect) return; + try { + const appId = getSetting('bridge_qq_app_id') || ''; + const appSecret = getSetting('bridge_qq_app_secret') || ''; + const token = await getAccessToken(appId, appSecret); + const gatewayUrl = await getGatewayUrl(token); + await this.connectGateway(gatewayUrl, token); + } catch (err) { + console.error('[qq-adapter] Reconnect failed:', err); + this.scheduleReconnect(); + } + }, delay); + } +} + +// ── Self-Registration ────────────────────────────────────── + +registerAdapterFactory('qq', () => new QQAdapter()); diff --git a/src/lib/bridge/adapters/qq-api.ts b/src/lib/bridge/adapters/qq-api.ts new file mode 100644 index 0000000..4c28228 --- /dev/null +++ b/src/lib/bridge/adapters/qq-api.ts @@ -0,0 +1,219 @@ +/** + * QQ Bot HTTP/WS protocol helpers. + * + * Handles: + * - AppAccessToken acquisition and refresh + * - WebSocket Gateway URL discovery + * - Heartbeat management + * - Private message sending (passive reply) + * - msg_seq auto-increment per reply chain + * + * No business logic here — pure QQ protocol layer. + */ + +import WebSocket from 'ws'; + +// ── Token Management ──────────────────────────────────────── + +interface AccessTokenResponse { + access_token: string; + expires_in: number; // seconds +} + +interface TokenState { + token: string; + expiresAt: number; // epoch ms +} + +let tokenState: TokenState | null = null; + +/** + * Get a valid access token, refreshing if necessary. + * Uses POST https://bots.qq.com/app/getAppAccessToken + */ +export async function getAccessToken(appId: string, clientSecret: string): Promise { + if (tokenState && Date.now() < tokenState.expiresAt - 60_000) { + return tokenState.token; + } + + const res = await fetch('https://bots.qq.com/app/getAppAccessToken', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appId, clientSecret }), + signal: AbortSignal.timeout(10_000), + }); + + if (!res.ok) { + throw new Error(`QQ token request failed: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as AccessTokenResponse; + if (!data.access_token) { + throw new Error('QQ token response missing access_token'); + } + + tokenState = { + token: data.access_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; + + return tokenState.token; +} + +/** Clear cached token (e.g. on credential change). */ +export function clearTokenCache(): void { + tokenState = null; +} + +// ── Gateway ───────────────────────────────────────────────── + +/** + * Get the WebSocket Gateway URL. + * GET https://api.sgroup.qq.com/gateway + */ +export async function getGatewayUrl(accessToken: string): Promise { + const res = await fetch('https://api.sgroup.qq.com/gateway', { + method: 'GET', + headers: { Authorization: `QQBot ${accessToken}` }, + signal: AbortSignal.timeout(10_000), + }); + + if (!res.ok) { + throw new Error(`QQ gateway request failed: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as { url: string }; + if (!data.url) { + throw new Error('QQ gateway response missing url'); + } + + return data.url; +} + +// ── WebSocket Protocol ────────────────────────────────────── + +/** QQ Gateway opcodes */ +export const OP = { + DISPATCH: 0, // Receive — server dispatches an event + HEARTBEAT: 1, // Send/Receive + IDENTIFY: 2, // Send — identify after Hello + RESUME: 6, // Send — resume a disconnected session + RECONNECT: 7, // Receive — server asks client to reconnect + INVALID_SESSION: 9, // Receive — session is invalid + HELLO: 10, // Receive — first message after connect, contains heartbeat_interval + HEARTBEAT_ACK: 11, // Receive — heartbeat acknowledged +} as const; + +export interface GatewayPayload { + op: number; + d?: unknown; + s?: number; // sequence number (only for op=0) + t?: string; // event name (only for op=0) +} + +/** Build an Identify payload. */ +export function buildIdentify(token: string, intents: number): GatewayPayload { + return { + op: OP.IDENTIFY, + d: { + token: `QQBot ${token}`, + intents, + shard: [0, 1], + }, + }; +} + +/** Build a Heartbeat payload. */ +export function buildHeartbeat(lastSequence: number | null): GatewayPayload { + return { + op: OP.HEARTBEAT, + d: lastSequence, + }; +} + +/** Build a Resume payload. */ +export function buildResume(token: string, sessionId: string, seq: number): GatewayPayload { + return { + op: OP.RESUME, + d: { + token: `QQBot ${token}`, + session_id: sessionId, + seq, + }, + }; +} + +/** + * QQ Bot intents for C2C (private) messages. + * PUBLIC_MESSAGES = 1 << 25 covers C2C_MESSAGE_CREATE. + */ +export const INTENTS = { + PUBLIC_MESSAGES: 1 << 25, +} as const; + +// ── Message Sending ───────────────────────────────────────── + +/** Auto-incrementing msg_seq counter per reply chain. */ +const msgSeqCounters = new Map(); + +/** Get the next msg_seq for a given inbound message ID. */ +export function nextMsgSeq(inboundMsgId: string): number { + const current = msgSeqCounters.get(inboundMsgId) || 0; + const next = current + 1; + msgSeqCounters.set(inboundMsgId, next); + // Clean up old entries (keep last 500) + if (msgSeqCounters.size > 500) { + const keys = Array.from(msgSeqCounters.keys()); + for (let i = 0; i < keys.length - 500; i++) { + msgSeqCounters.delete(keys[i]); + } + } + return next; +} + +export interface QQSendMessageParams { + openid: string; + content: string; + msgId: string; // inbound message ID for passive reply + msgSeq: number; +} + +/** + * Send a private text message (passive reply). + * POST /v2/users/{openid}/messages + */ +export async function sendPrivateMessage( + accessToken: string, + params: QQSendMessageParams, +): Promise<{ ok: boolean; messageId?: string; error?: string }> { + const url = `https://api.sgroup.qq.com/v2/users/${params.openid}/messages`; + + const body = { + content: params.content, + msg_type: 0, // plain text + msg_id: params.msgId, + msg_seq: params.msgSeq, + }; + + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `QQBot ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + return { ok: false, error: `QQ send failed: ${res.status} ${text}` }; + } + + const data = (await res.json()) as { id?: string }; + return { ok: true, messageId: data.id }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts index d743796..15ef2bb 100644 --- a/src/lib/bridge/bridge-manager.ts +++ b/src/lib/bridge/bridge-manager.ts @@ -15,7 +15,8 @@ import './adapters'; import * as router from './channel-router'; import * as engine from './conversation-engine'; import * as broker from './permission-broker'; -import { deliver, deliverRendered } from './delivery-layer'; +import { deliver, deliverRendered, chunkText } from './delivery-layer'; +import { PLATFORM_LIMITS as limits } from './types'; import { markdownToTelegramChunks } from './markdown/telegram'; import { markdownToDiscordChunks } from './markdown/discord'; import { getSetting, insertAuditLog, updateChannelBinding } from '../db'; @@ -96,6 +97,7 @@ async function deliverResponse( address: ChannelAddress, responseText: string, sessionId: string, + replyToMessageId?: string, ): Promise { if (adapter.channelType === 'telegram') { const chunks = markdownToTelegramChunks(responseText, 4096); @@ -112,6 +114,7 @@ async function deliverResponse( address, text: chunks[i].text, parseMode: 'Markdown', + replyToMessageId, }, { sessionId }); if (!result.ok) return result; } @@ -123,13 +126,39 @@ async function deliverResponse( address, text: responseText, parseMode: 'Markdown', + replyToMessageId, }, { sessionId }); } - // Generic fallback: deliver as plain text (deliver() handles chunking internally) + if (adapter.channelType === 'qq') { + // QQ passive replies have a limited budget per msg_id (typically 5). + // Limit chunks to avoid exhausting the budget and failing mid-response. + const QQ_MAX_CHUNKS = 3; + const limit = limits.qq || 2000; + const fullText = responseText; + const chunks = chunkText(fullText, limit); + + const effectiveChunks = chunks.length > QQ_MAX_CHUNKS + ? [...chunks.slice(0, QQ_MAX_CHUNKS - 1), chunks.slice(QQ_MAX_CHUNKS - 1).join('\n').slice(0, limit - 30) + '\n\n[... response truncated]'] + : chunks; + + for (let i = 0; i < effectiveChunks.length; i++) { + const result = await deliver(adapter, { + address, + text: effectiveChunks[i], + parseMode: 'plain', + replyToMessageId, + }, { sessionId }); + if (!result.ok) return result; + } + return { ok: true }; + } + + // Generic fallback: deliver as plain text return deliver(adapter, { address, text: responseText, parseMode: 'plain', + replyToMessageId, }, { sessionId }); } @@ -424,6 +453,7 @@ async function handleMessage( address: msg.address, text: 'Permission response recorded.', parseMode: 'plain', + replyToMessageId: msg.messageId, }; await deliver(adapter, confirmMsg); } @@ -437,7 +467,7 @@ async function handleMessage( // Check for IM commands (before sanitization — commands are validated individually) if (rawText.startsWith('/')) { - await handleCommand(adapter, msg, rawText); + await handleCommand(adapter, msg, rawText, msg.messageId); ack(); return; } @@ -544,30 +574,33 @@ async function handleMessage( perm.toolInput, binding.codepilotSessionId, perm.suggestions, + msg.messageId, ); }, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText); // Send response text — render via channel-appropriate format if (result.responseText) { - await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId); + await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId); } else if (result.hasError) { const errorResponse: OutboundMessage = { address: msg.address, text: `Error: ${escapeHtml(result.errorMessage)}`, parseMode: 'HTML', + replyToMessageId: msg.messageId, }; await deliver(adapter, errorResponse); } // Persist the actual SDK session ID for future resume. - // If the result has an error and no session ID was captured, clear the - // stale ID so the next message starts fresh instead of retrying a broken resume. + // On error, ALWAYS clear — the SDK may emit a session_id before crashing, + // and saving that broken ID would cause all subsequent messages to fail + // by repeatedly trying to resume a corrupted session. if (binding.id) { try { - if (result.sdkSessionId) { - updateChannelBinding(binding.id, { sdkSessionId: result.sdkSessionId }); - } else if (result.hasError && binding.sdkSessionId) { + if (result.hasError) { updateChannelBinding(binding.id, { sdkSessionId: '' }); + } else if (result.sdkSessionId) { + updateChannelBinding(binding.id, { sdkSessionId: result.sdkSessionId }); } } catch { /* best effort */ } } @@ -596,6 +629,7 @@ async function handleCommand( adapter: BaseChannelAdapter, msg: InboundMessage, text: string, + replyToMessageId?: string, ): Promise { // Extract command and args (handle /command@botname format) const parts = text.split(/\s+/); @@ -617,6 +651,7 @@ async function handleCommand( address: msg.address, text: `Command rejected: invalid input detected.`, parseMode: 'plain', + replyToMessageId, }); return; } @@ -790,6 +825,7 @@ async function handleCommand( address: msg.address, text: response, parseMode: 'HTML', + replyToMessageId, }); } } diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index 10eb6ab..f60f3e7 100644 --- a/src/lib/bridge/conversation-engine.ts +++ b/src/lib/bridge/conversation-engine.ts @@ -101,6 +101,8 @@ export async function processMessage( // Save user message — persist file attachments to disk using the same // format as the desktop chat route, so the UI can render them. + // Also attach filePath to the file objects so streamClaude() can reuse + // on-disk copies (matching the desktop route behavior, preventing duplicate writes). let savedContent = text; if (files && files.length > 0) { const workDir = binding.workingDirectory || session?.working_directory || ''; @@ -115,6 +117,9 @@ export async function processMessage( const filePath = path.join(uploadDir, `${Date.now()}-${safeName}`); const buffer = Buffer.from(f.data, 'base64'); fs.writeFileSync(filePath, buffer); + // Attach filePath to the original file object so streamClaude() + // can reference the on-disk copy via getUploadedFilePaths() + f.filePath = filePath; return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath }; }); savedContent = `${text}`; diff --git a/src/lib/bridge/delivery-layer.ts b/src/lib/bridge/delivery-layer.ts index 1963e8a..da7ff2f 100644 --- a/src/lib/bridge/delivery-layer.ts +++ b/src/lib/bridge/delivery-layer.ts @@ -38,7 +38,7 @@ setInterval(() => { rateLimiter.cleanup(); }, 5 * 60_000); * Split text into chunks that fit within a platform's message size limit. * Tries to split at line boundaries when possible. */ -function chunkText(text: string, maxLength: number): string[] { +export function chunkText(text: string, maxLength: number): string[] { if (text.length <= maxLength) return [text]; const chunks: string[] = []; diff --git a/src/lib/bridge/permission-broker.ts b/src/lib/bridge/permission-broker.ts index 71a5d9b..395a124 100644 --- a/src/lib/bridge/permission-broker.ts +++ b/src/lib/bridge/permission-broker.ts @@ -34,6 +34,7 @@ export async function forwardPermissionRequest( toolInput: Record, sessionId?: string, suggestions?: unknown[], + replyToMessageId?: string, ): Promise { // Check if this session uses full_access permission profile — auto-approve without IM notification if (sessionId) { @@ -65,26 +66,46 @@ export async function forwardPermissionRequest( ? inputStr.slice(0, 300) + '...' : inputStr; - const text = [ + // Channels without inline button support (e.g. QQ) need text-based + // permission commands. Check if the adapter ignores inlineButtons. + const supportsButtons = adapter.channelType !== 'qq'; + + const textLines = [ `Permission Required`, ``, `Tool: ${escapeHtml(toolName)}`, `
${escapeHtml(truncatedInput)}
`, ``, - `Choose an action:`, - ].join('\n'); + ]; + + if (supportsButtons) { + textLines.push(`Choose an action:`); + } else { + // Text-based permission commands for channels without inline buttons + textLines.push( + `Reply with one of:`, + `/perm allow ${permissionRequestId}`, + `/perm allow_session ${permissionRequestId}`, + `/perm deny ${permissionRequestId}`, + ); + } + + const text = textLines.join('\n'); const message: OutboundMessage = { address, text, - parseMode: 'HTML', - inlineButtons: [ - [ - { text: 'Allow', callbackData: `perm:allow:${permissionRequestId}` }, - { text: 'Allow Session', callbackData: `perm:allow_session:${permissionRequestId}` }, - { text: 'Deny', callbackData: `perm:deny:${permissionRequestId}` }, - ], - ], + parseMode: supportsButtons ? 'HTML' : 'plain', + inlineButtons: supportsButtons + ? [ + [ + { text: 'Allow', callbackData: `perm:allow:${permissionRequestId}` }, + { text: 'Allow Session', callbackData: `perm:allow_session:${permissionRequestId}` }, + { text: 'Deny', callbackData: `perm:deny:${permissionRequestId}` }, + ], + ] + : undefined, + replyToMessageId, }; const result = await deliver(adapter, message, { sessionId }); diff --git a/src/lib/bridge/types.ts b/src/lib/bridge/types.ts index 5b67776..69b780f 100644 --- a/src/lib/bridge/types.ts +++ b/src/lib/bridge/types.ts @@ -172,4 +172,5 @@ export const PLATFORM_LIMITS: Record = { discord: 2000, slack: 40000, feishu: 30000, + qq: 2000, }; diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 87e922a..cb08977 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -962,7 +962,9 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream isImageFile(f.type)); + const imageHint = hasImages ? '\n• Provider may not support image/vision input' : ''; + errorMessage = `Claude Code process exited with an error${providerHint}. This is often caused by:\n• Invalid or missing API Key\n• Incorrect Base URL configuration\n• Network connectivity issues${imageHint}${detailHint}\n\nOriginal error: ${rawMessage}`; } else if (rawMessage.includes('exited with code')) { const providerHint = activeProvider?.name ? ` (Provider: ${activeProvider.name})` : ''; errorMessage = `Claude Code process crashed unexpectedly${providerHint}.\n\nOriginal error: ${rawMessage}`; @@ -982,10 +984,11 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream