feat: QQ bridge channel + SDK crash resilience fixes

QQ Bridge Adapter (new):
- qq-api.ts: AppAccessToken, WebSocket Gateway, heartbeat, passive reply,
  msg_seq auto-increment — pure QQ protocol layer
- qq-adapter.ts: BaseChannelAdapter impl with WebSocket lifecycle,
  C2C_MESSAGE_CREATE handling, image download (base64), dedup, reconnect
- Settings UI (QqBridgeSection.tsx) + API routes (/api/settings/qq)
- i18n keys for en/zh, PLATFORM_LIMITS qq: 2000

replyToMessageId threading:
- bridge-manager.ts: thread msg.messageId through all deliver/command/
  permission paths — required for QQ passive reply (msg_id mandatory)
- permission-broker.ts: accept and forward replyToMessageId

SDK crash resilience (bug fix):
- bridge-manager.ts: check hasError BEFORE saving sdkSessionId — the SDK
  may emit a session_id before crashing, and saving that broken ID caused
  ALL subsequent messages to fail by repeatedly resuming a corrupted session
- claude-client.ts: always clear sdk_session_id on crash, even for fresh
  sessions (SDK can persist a new ID via status event before dying)
- conversation-engine.ts: attach filePath to file objects after disk write
  so streamClaude() reuses on-disk copies (prevents duplicate writes,
  matches desktop route behavior)

QQ permission handling (P1 fix):
- permission-broker.ts: for channels without inline button support (QQ),
  render text-based /perm allow|deny|allow_session commands instead of
  invisible inlineButtons that would deadlock the stream

QQ passive reply budget (P2 fix):
- bridge-manager.ts: QQ-specific delivery limits chunks to 3 max,
  truncates with notice to avoid exhausting passive reply quota
- delivery-layer.ts: export chunkText for reuse

Error message improvement:
- claude-client.ts: when exit code 1 with image files, hint "Provider may
  not support image/vision input" instead of generic auth/network message

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-07 22:24:54 +08:00
1 parent c18776f4ca
commit 1e0958f598
19 files changed
+1356 -32

No files matched your search

+4 -2
View File
@@ -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",
+3 -1
View File
@@ -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",
+6
View File
@@ -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() {
+66
View File
@@ -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<string, string> = {};
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 });
}
}
+72
View File
@@ -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 });
}
}
+6 -2
View File
@@ -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" && <TelegramBridgeSection />}
{activeSection === "feishu" && <FeishuBridgeSection />}
{activeSection === "discord" && <DiscordBridgeSection />}
{activeSection === "qq" && <QqBridgeSection />}
</div>
</div>
</div>
+28 -1
View File
@@ -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() {
/>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-3">
<div className="flex items-center gap-3">
<HugeiconsIcon
icon={MessageMultiple02Icon}
className="h-4 w-4 text-muted-foreground"
/>
<div>
<p className="text-sm">{t("bridge.qqChannel")}</p>
<p className="text-xs text-muted-foreground">
{t("bridge.qqChannelDesc")}
</p>
</div>
</div>
<Switch
checked={isQQEnabled}
onCheckedChange={handleToggleQQ}
disabled={saving}
/>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-3">
<div>
<p className="text-sm">{t("bridge.autoStart")}</p>
+310
View File
@@ -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<QqBridgeSettings>(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<QqBridgeSettings>) => {
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<QqBridgeSettings> = {
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 (
<div className="max-w-3xl space-y-6">
{/* App Credentials */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">{t("qq.credentials")}</h2>
<p className="text-xs text-muted-foreground">
{t("qq.credentialsDesc")}
</p>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("qq.appId")}
</label>
<Input
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="xxxxxxxxxx"
className="font-mono text-sm"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("qq.appSecret")}
</label>
<Input
type="password"
value={appSecret}
onChange={(e) => setAppSecret(e.target.value)}
placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
className="font-mono text-sm"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSaveCredentials} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleVerify}
disabled={verifying || !appId}
>
{verifying ? (
<HugeiconsIcon
icon={Loading02Icon}
className="h-3.5 w-3.5 animate-spin mr-1.5"
/>
) : null}
{t("qq.verify")}
</Button>
</div>
{verifyResult && (
<div
className={`flex items-center gap-2 rounded-md px-3 py-2 text-xs ${
verifyResult.ok
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-red-500/10 text-red-600 dark:text-red-400"
}`}
>
<HugeiconsIcon
icon={verifyResult.ok ? CheckmarkCircle02Icon : Alert02Icon}
className="h-4 w-4 shrink-0"
/>
{verifyResult.message}
</div>
)}
</div>
{/* Allowed Users */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">{t("qq.allowedUsers")}</h2>
<p className="text-xs text-muted-foreground">
{t("qq.allowedUsersDesc")}
</p>
</div>
<div>
<Input
value={allowedUsers}
onChange={(e) => setAllowedUsers(e.target.value)}
placeholder="user_openid_1, user_openid_2"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("qq.allowedUsersHint")}
</p>
</div>
<Button size="sm" onClick={handleSaveAllowedUsers} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
</div>
{/* Image Settings */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">{t("qq.imageSettings")}</h2>
<p className="text-xs text-muted-foreground">
{t("qq.imageSettingsDesc")}
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm">{t("qq.imageEnabled")}</p>
<p className="text-xs text-muted-foreground">
{t("qq.imageEnabledDesc")}
</p>
</div>
<Switch
checked={imageEnabled}
onCheckedChange={setImageEnabled}
/>
</div>
{imageEnabled && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("qq.maxImageSize")}
</label>
<Input
type="number"
value={maxImageSize}
onChange={(e) => setMaxImageSize(e.target.value)}
placeholder="20"
className="w-32 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("qq.maxImageSizeHint")}
</p>
</div>
)}
</div>
<Button size="sm" onClick={handleSaveImageSettings} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
</div>
{/* Setup Guide */}
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<h2 className="text-sm font-medium mb-2">{t("qq.setupGuide")}</h2>
<ol className="text-xs text-muted-foreground space-y-1.5 list-decimal pl-4">
<li>{t("qq.step1")}</li>
<li>{t("qq.step2")}</li>
<li>{t("qq.step3")}</li>
<li>{t("qq.step4")}</li>
<li>{t("qq.step5")}</li>
</ol>
</div>
</div>
);
}
+29
View File
@@ -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',
+29
View File
@@ -580,6 +580,10 @@ const zh: Record<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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': '助理工作区',
+1
View File
@@ -12,3 +12,4 @@
import './telegram-adapter';
import './feishu-adapter';
import './discord-adapter';
import './qq-adapter';
+491
View File
@@ -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<typeof setInterval> | null = null;
private lastSequence: number | null = null;
private sessionId: string | null = null;
private seenMessageIds = new Map<string, boolean>();
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private shouldReconnect = false;
// ── Lifecycle ───────────────────────────────────────────────
async start(): Promise<void> {
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<void> {
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<InboundMessage | null> {
if (this.queue.length > 0) {
return this.queue.shift()!;
}
if (!this._running) return null;
return new Promise<InboundMessage | null>((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<SendResult> {
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<void> {
return new Promise<void>((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<unknown>,
): Promise<FileAttachment[]> {
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<void> {
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());
+219
View File
@@ -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<string> {
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<string> {
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<string, number>();
/** 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) };
}
}
+45 -9
View File
@@ -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<SendResult> {
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: `<b>Error:</b> ${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<void> {
// 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,
});
}
}
+5
View File
@@ -101,6 +101,8 @@ export async function processMessage(
// Save user message — persist file attachments to disk using the same
// <!--files:JSON--> 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 = `<!--files:${JSON.stringify(fileMeta)}-->${text}`;
+1 -1
View File
@@ -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[] = [];
+32 -11
View File
@@ -34,6 +34,7 @@ export async function forwardPermissionRequest(
toolInput: Record<string, unknown>,
sessionId?: string,
suggestions?: unknown[],
replyToMessageId?: string,
): Promise<void> {
// 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 = [
`<b>Permission Required</b>`,
``,
`Tool: <code>${escapeHtml(toolName)}</code>`,
`<pre>${escapeHtml(truncatedInput)}</pre>`,
``,
`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 });
+1
View File
@@ -172,4 +172,5 @@ export const PLATFORM_LIMITS: Record<string, number> = {
discord: 2000,
slack: 40000,
feishu: 30000,
qq: 2000,
};
+8 -5
View File
@@ -962,7 +962,9 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
} else if (rawMessage.includes('exited with code 1') || rawMessage.includes('exit code 1')) {
const providerHint = activeProvider?.name ? ` (Provider: ${activeProvider.name})` : '';
const detailHint = extraDetail ? `\n\nDetails: ${extraDetail}` : '';
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${detailHint}\n\nOriginal error: ${rawMessage}`;
const hasImages = files && files.some(f => 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<strin
controller.enqueue(formatSSE({ type: 'error', data: errorMessage }));
controller.enqueue(formatSSE({ type: 'done', data: '' }));
// If we were resuming a session and it crashed mid-stream, clear the
// stale sdk_session_id so the next message starts a fresh SDK session
// instead of repeatedly hitting the same broken resume.
if (sdkSessionId && sessionId) {
// Always clear sdk_session_id on crash so the next message starts fresh.
// Even for fresh sessions — the SDK may emit a session_id via status
// event before crashing, which gets persisted by consumeStream/SSE
// handlers. Leaving it would cause repeated resume failures.
if (sessionId) {
try {
updateSdkSessionId(sessionId, '');
console.warn('[claude-client] Cleared stale sdk_session_id for session', sessionId);