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:
1 parent
c18776f4ca
commit
1e0958f598
19 files changed
+1356
-32
No files matched your search
Generated
+4
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '助理工作区',
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
import './telegram-adapter';
|
||||
import './feishu-adapter';
|
||||
import './discord-adapter';
|
||||
import './qq-adapter';
|
||||
@@ -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());
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -172,4 +172,5 @@ export const PLATFORM_LIMITS: Record<string, number> = {
|
||||
discord: 2000,
|
||||
slack: 40000,
|
||||
feishu: 30000,
|
||||
qq: 2000,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user