Skip to content

Commit 1e0958f

Browse files
7418claude
andcommitted
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>
1 parent c18776f commit 1e0958f

File tree

19 files changed

+1356
-32
lines changed

19 files changed

+1356
-32
lines changed

package-lock.json

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.28.0",
3+
"version": "0.28.1",
44
"private": true,
55
"author": {
66
"name": "op7418",
@@ -73,6 +73,7 @@
7373
"tailwind-merge": "^3.4.0",
7474
"use-stick-to-bottom": "^1.1.2",
7575
"uuid": "^13.0.0",
76+
"ws": "^8.19.0",
7677
"zlib-sync": "^0.1.10"
7778
},
7879
"devDependencies": {
@@ -85,6 +86,7 @@
8586
"@types/react-dom": "^19",
8687
"@types/react-syntax-highlighter": "^15.5.13",
8788
"@types/uuid": "^10.0.0",
89+
"@types/ws": "^8.18.1",
8890
"concurrently": "^9.2.1",
8991
"electron": "^40.2.1",
9092
"electron-builder": "^26.7.0",

src/app/api/bridge/settings/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ const BRIDGE_SETTING_KEYS = [
3535
'bridge_discord_stream_max_chars',
3636
'bridge_discord_max_attachment_size',
3737
'bridge_discord_image_enabled',
38+
'bridge_qq_enabled',
39+
'bridge_qq_app_id',
40+
'bridge_qq_app_secret',
41+
'bridge_qq_allowed_users',
42+
'bridge_qq_image_enabled',
43+
'bridge_qq_max_image_size',
3844
] as const;
3945

4046
export async function GET() {

src/app/api/settings/qq/route.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getSetting, setSetting } from '@/lib/db';
3+
4+
/**
5+
* QQ Bot bridge settings.
6+
* Stored in the SQLite settings table (same as other app settings).
7+
*/
8+
9+
const QQ_KEYS = [
10+
'bridge_qq_enabled',
11+
'bridge_qq_app_id',
12+
'bridge_qq_app_secret',
13+
'bridge_qq_allowed_users',
14+
'bridge_qq_image_enabled',
15+
'bridge_qq_max_image_size',
16+
] as const;
17+
18+
export async function GET() {
19+
try {
20+
const result: Record<string, string> = {};
21+
for (const key of QQ_KEYS) {
22+
const value = getSetting(key);
23+
if (value !== undefined) {
24+
// Mask app secret for security
25+
if (key === 'bridge_qq_app_secret' && value.length > 8) {
26+
result[key] = '***' + value.slice(-8);
27+
} else {
28+
result[key] = value;
29+
}
30+
}
31+
}
32+
33+
return NextResponse.json({ settings: result });
34+
} catch (error) {
35+
const message = error instanceof Error ? error.message : 'Failed to read QQ settings';
36+
return NextResponse.json({ error: message }, { status: 500 });
37+
}
38+
}
39+
40+
export async function PUT(request: NextRequest) {
41+
try {
42+
const body = await request.json();
43+
const { settings } = body;
44+
45+
if (!settings || typeof settings !== 'object') {
46+
return NextResponse.json({ error: 'Invalid settings data' }, { status: 400 });
47+
}
48+
49+
for (const [key, value] of Object.entries(settings)) {
50+
if (!QQ_KEYS.includes(key as typeof QQ_KEYS[number])) continue;
51+
const strValue = String(value ?? '').trim();
52+
53+
// Don't overwrite secret if user sent the masked version back
54+
if (key === 'bridge_qq_app_secret' && strValue.startsWith('***')) {
55+
continue;
56+
}
57+
58+
setSetting(key, strValue);
59+
}
60+
61+
return NextResponse.json({ success: true });
62+
} catch (error) {
63+
const message = error instanceof Error ? error.message : 'Failed to save QQ settings';
64+
return NextResponse.json({ error: message }, { status: 500 });
65+
}
66+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getSetting } from '@/lib/db';
3+
4+
/**
5+
* POST /api/settings/qq/verify
6+
*
7+
* Verifies QQ Bot credentials by:
8+
* 1. Getting an access token via appId + appSecret
9+
* 2. Fetching the WebSocket gateway URL
10+
*
11+
* If both succeed, credentials are valid.
12+
*/
13+
export async function POST(request: NextRequest) {
14+
try {
15+
const body = await request.json();
16+
let { app_id, app_secret } = body;
17+
18+
// Fall back to stored values if not provided or masked
19+
if (!app_id) {
20+
app_id = getSetting('bridge_qq_app_id') || '';
21+
}
22+
if (!app_secret || app_secret.startsWith('***')) {
23+
app_secret = getSetting('bridge_qq_app_secret') || '';
24+
}
25+
26+
if (!app_id || !app_secret) {
27+
return NextResponse.json(
28+
{ verified: false, error: 'App ID and App Secret are required' },
29+
{ status: 400 },
30+
);
31+
}
32+
33+
// Step 1: Get access token
34+
const tokenRes = await fetch('https://bots.qq.com/app/getAppAccessToken', {
35+
method: 'POST',
36+
headers: { 'Content-Type': 'application/json' },
37+
body: JSON.stringify({ appId: app_id, clientSecret: app_secret }),
38+
signal: AbortSignal.timeout(10_000),
39+
});
40+
41+
const tokenData = await tokenRes.json();
42+
if (!tokenData.access_token) {
43+
return NextResponse.json({
44+
verified: false,
45+
error: tokenData.message || 'Failed to get access token',
46+
});
47+
}
48+
49+
// Step 2: Verify gateway is reachable
50+
const gatewayRes = await fetch('https://api.sgroup.qq.com/gateway', {
51+
method: 'GET',
52+
headers: { Authorization: `QQBot ${tokenData.access_token}` },
53+
signal: AbortSignal.timeout(10_000),
54+
});
55+
56+
const gatewayData = await gatewayRes.json();
57+
if (!gatewayData.url) {
58+
return NextResponse.json({
59+
verified: false,
60+
error: 'Failed to get gateway URL',
61+
});
62+
}
63+
64+
return NextResponse.json({
65+
verified: true,
66+
gatewayUrl: gatewayData.url,
67+
});
68+
} catch (error) {
69+
const message = error instanceof Error ? error.message : 'Verification failed';
70+
return NextResponse.json({ verified: false, error: message }, { status: 500 });
71+
}
72+
}

src/components/bridge/BridgeLayout.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
import { useState, useCallback, useSyncExternalStore } from "react";
44
import { HugeiconsIcon } from "@hugeicons/react";
55
import type { IconSvgElement } from "@hugeicons/react";
6-
import { Wifi01Icon, TelegramIcon, BubbleChatIcon, GameController01Icon } from "@hugeicons/core-free-icons";
6+
import { Wifi01Icon, TelegramIcon, BubbleChatIcon, GameController01Icon, MessageMultiple02Icon } from "@hugeicons/core-free-icons";
77
import { cn } from "@/lib/utils";
88
import { BridgeSection } from "./BridgeSection";
99
import { TelegramBridgeSection } from "./TelegramBridgeSection";
1010
import { FeishuBridgeSection } from "./FeishuBridgeSection";
1111
import { DiscordBridgeSection } from "./DiscordBridgeSection";
12+
import { QqBridgeSection } from "./QqBridgeSection";
1213
import { useTranslation } from "@/hooks/useTranslation";
1314
import type { TranslationKey } from "@/i18n";
1415

15-
type Section = "bridge" | "telegram" | "feishu" | "discord";
16+
type Section = "bridge" | "telegram" | "feishu" | "discord" | "qq";
1617

1718
interface SidebarItem {
1819
id: Section;
@@ -25,6 +26,7 @@ const sidebarItems: SidebarItem[] = [
2526
{ id: "telegram", label: "Telegram", icon: TelegramIcon },
2627
{ id: "feishu", label: "Feishu", icon: BubbleChatIcon },
2728
{ id: "discord", label: "Discord", icon: GameController01Icon },
29+
{ id: "qq", label: "QQ", icon: MessageMultiple02Icon },
2830
];
2931

3032
function getSectionFromHash(): Section {
@@ -53,6 +55,7 @@ export function BridgeLayout() {
5355
'Telegram': 'bridge.telegramSettings',
5456
'Feishu': 'bridge.feishuSettings',
5557
'Discord': 'bridge.discordSettings',
58+
'QQ': 'bridge.qqSettings',
5659
};
5760

5861
const handleSectionChange = useCallback((section: Section) => {
@@ -94,6 +97,7 @@ export function BridgeLayout() {
9497
{activeSection === "telegram" && <TelegramBridgeSection />}
9598
{activeSection === "feishu" && <FeishuBridgeSection />}
9699
{activeSection === "discord" && <DiscordBridgeSection />}
100+
{activeSection === "qq" && <QqBridgeSection />}
97101
</div>
98102
</div>
99103
</div>

src/components/bridge/BridgeSection.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
SelectValue,
1515
} from "@/components/ui/select";
1616
import { HugeiconsIcon } from "@hugeicons/react";
17-
import { Loading02Icon, CheckmarkCircle02Icon, Alert02Icon, TelegramIcon, BubbleChatIcon, GameController01Icon } from "@hugeicons/core-free-icons";
17+
import { Loading02Icon, CheckmarkCircle02Icon, Alert02Icon, TelegramIcon, BubbleChatIcon, GameController01Icon, MessageMultiple02Icon } from "@hugeicons/core-free-icons";
1818
import { useTranslation } from "@/hooks/useTranslation";
1919
import type { ProviderModelGroup } from "@/types";
2020

@@ -37,6 +37,7 @@ interface BridgeSettings {
3737
bridge_telegram_enabled: string;
3838
bridge_feishu_enabled: string;
3939
bridge_discord_enabled: string;
40+
bridge_qq_enabled: string;
4041
bridge_auto_start: string;
4142
bridge_default_work_dir: string;
4243
bridge_default_model: string;
@@ -48,6 +49,7 @@ const DEFAULT_SETTINGS: BridgeSettings = {
4849
bridge_telegram_enabled: "",
4950
bridge_feishu_enabled: "",
5051
bridge_discord_enabled: "",
52+
bridge_qq_enabled: "",
5153
bridge_auto_start: "",
5254
bridge_default_work_dir: "",
5355
bridge_default_model: "",
@@ -169,6 +171,10 @@ export function BridgeSection() {
169171
saveSettings({ bridge_discord_enabled: checked ? "true" : "" });
170172
};
171173

174+
const handleToggleQQ = (checked: boolean) => {
175+
saveSettings({ bridge_qq_enabled: checked ? "true" : "" });
176+
};
177+
172178
const handleSaveDefaults = () => {
173179
// Split composite "provider_id::model" value
174180
const parts = model.split("::");
@@ -240,6 +246,7 @@ export function BridgeSection() {
240246
const isTelegramEnabled = settings.bridge_telegram_enabled === "true";
241247
const isFeishuEnabled = settings.bridge_feishu_enabled === "true";
242248
const isDiscordEnabled = settings.bridge_discord_enabled === "true";
249+
const isQQEnabled = settings.bridge_qq_enabled === "true";
243250
const isAutoStart = settings.bridge_auto_start === "true";
244251
const isRunning = bridgeStatus?.running ?? false;
245252
const adapterCount = bridgeStatus?.adapters?.length ?? 0;
@@ -409,6 +416,26 @@ export function BridgeSection() {
409416
/>
410417
</div>
411418

419+
<div className="flex items-center justify-between border-t border-border/30 pt-3">
420+
<div className="flex items-center gap-3">
421+
<HugeiconsIcon
422+
icon={MessageMultiple02Icon}
423+
className="h-4 w-4 text-muted-foreground"
424+
/>
425+
<div>
426+
<p className="text-sm">{t("bridge.qqChannel")}</p>
427+
<p className="text-xs text-muted-foreground">
428+
{t("bridge.qqChannelDesc")}
429+
</p>
430+
</div>
431+
</div>
432+
<Switch
433+
checked={isQQEnabled}
434+
onCheckedChange={handleToggleQQ}
435+
disabled={saving}
436+
/>
437+
</div>
438+
412439
<div className="flex items-center justify-between border-t border-border/30 pt-3">
413440
<div>
414441
<p className="text-sm">{t("bridge.autoStart")}</p>

0 commit comments

Comments
 (0)