mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
278 lines
8.8 KiB
TypeScript
278 lines
8.8 KiB
TypeScript
import { adapterMigrationLifecycle, registerAdapterShutdown } from '../common/migration-lifecycle.js'
|
|
/**
|
|
* QQ Adapter for Claude Code Desktop
|
|
*
|
|
* Uses the official QQ Open Platform SDK (`@tencent-connect/qqbot-nodejs`) over
|
|
* the WebSocket gateway, so no public callback URL is needed. Credentials
|
|
* (`appId` / `appSecret`) come from the QR binding in Desktop Settings → IM,
|
|
* or from QQ_APP_ID / QQ_APP_SECRET.
|
|
*
|
|
* 启动:QQ_APP_ID=xxx QQ_APP_SECRET=yyy bun run qq/index.ts
|
|
*/
|
|
|
|
import { MediaFileType, QQBot, type ReplyTarget, type StreamSession } from '@tencent-connect/qqbot-nodejs'
|
|
import { WsBridge, type AttachmentRef } from '../common/ws-bridge.js'
|
|
import { MessageDedup } from '../common/message-dedup.js'
|
|
import { loadConfig } from '../common/config.js'
|
|
import { splitMessage } from '../common/format.js'
|
|
import { SessionStore } from '../common/session-store.js'
|
|
import { createAdapterClient } from '../common/adapter-client.js'
|
|
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
|
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
|
|
import {
|
|
ImChatRuntime,
|
|
type ChatPort,
|
|
type OutboundImage,
|
|
type ResponseStream,
|
|
} from '../common/chat-runtime.js'
|
|
import { extractQqPayload, type QqInboundAttachment } from './extract-payload.js'
|
|
import { QqMediaService } from './media.js'
|
|
|
|
const QQ_TEXT_LIMIT = 1_500
|
|
/** QQ throttles stream frames; the SDK enforces a 300 ms floor of its own. */
|
|
const QQ_STREAM_THROTTLE_MS = 600
|
|
const TYPING_SECONDS = 30
|
|
|
|
const config = loadConfig()
|
|
if (!config.qq.appId || !config.qq.appSecret) {
|
|
console.error('[QQ] Missing QQ_APP_ID / QQ_APP_SECRET. Bind QQ in Desktop Settings > IM.')
|
|
process.exit(1)
|
|
}
|
|
|
|
const bot = new QQBot({
|
|
appId: config.qq.appId,
|
|
appSecret: config.qq.appSecret,
|
|
accountId: config.qq.appId,
|
|
transport: 'websocket',
|
|
tokenPrefetch: 'sync',
|
|
logger: {
|
|
debug: () => {},
|
|
info: () => {},
|
|
warn: (...args: unknown[]) => console.warn('[QQ][sdk]', ...args),
|
|
error: (...args: unknown[]) => console.error('[QQ][sdk]', ...args),
|
|
},
|
|
})
|
|
|
|
const bridge = new WsBridge(config.serverUrl, 'qq')
|
|
const dedup = new MessageDedup()
|
|
const sessionStore = new SessionStore()
|
|
const { httpClient, defaultWorkDir } = createAdapterClient(config, config.qq)
|
|
const attachmentStore = new AttachmentStore()
|
|
const media = new QqMediaService(attachmentStore)
|
|
attachmentStore.gc().catch((err) => {
|
|
console.warn('[QQ] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
|
|
})
|
|
|
|
/** Outbound QQ messages must name a peer, and a reply must also name the
|
|
* inbound message it answers. Both live in the last target we saw per chat. */
|
|
const replyTargets = new Map<string, ReplyTarget>()
|
|
|
|
function targetFor(chatId: string): ReplyTarget {
|
|
return replyTargets.get(chatId) ?? { scope: 'c2c', targetId: chatId }
|
|
}
|
|
|
|
async function sendPlainText(chatId: string, text: string): Promise<void> {
|
|
const target = targetFor(chatId)
|
|
for (const chunk of splitMessage(text, QQ_TEXT_LIMIT)) {
|
|
await bot.sendText(target, chunk)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One assistant turn rendered as a QQ C2C stream message.
|
|
*
|
|
* `stream_messages` is C2C-only and anchored to the inbound message id, so a
|
|
* turn with no known reply anchor degrades to ordinary messages instead of
|
|
* failing. `update()` takes the *full* text, so this class accumulates.
|
|
*/
|
|
class QqResponse implements ResponseStream {
|
|
private session: StreamSession | null = null
|
|
private accumulated = ''
|
|
/** How much of `accumulated` the stream bubble already shows. */
|
|
private streamed = 0
|
|
private failed = false
|
|
|
|
constructor(private readonly chatId: string) {
|
|
const target = targetFor(chatId)
|
|
if (target.scope !== 'c2c' || !target.msgId) return
|
|
try {
|
|
this.session = bot.openStream({ target, throttleMs: QQ_STREAM_THROTTLE_MS })
|
|
} catch (err) {
|
|
console.warn('[QQ] openStream failed:', err instanceof Error ? err.message : err)
|
|
}
|
|
}
|
|
|
|
async append(delta: string): Promise<void> {
|
|
if (!delta) return
|
|
this.accumulated += delta
|
|
if (!this.session || this.failed) return
|
|
try {
|
|
await this.session.update(this.accumulated)
|
|
this.streamed = this.accumulated.length
|
|
} catch (err) {
|
|
// Fall back to discrete messages for the rest of the turn: retrying a
|
|
// broken stream would drop the remaining text entirely.
|
|
this.failed = true
|
|
console.warn('[QQ] stream update failed:', err instanceof Error ? err.message : err)
|
|
}
|
|
}
|
|
|
|
async finish(): Promise<void> {
|
|
const session = this.session
|
|
this.session = null
|
|
|
|
if (session && !this.failed) {
|
|
try {
|
|
await session.complete()
|
|
this.accumulated = ''
|
|
return
|
|
} catch (err) {
|
|
console.warn('[QQ] stream complete failed:', err instanceof Error ? err.message : err)
|
|
}
|
|
}
|
|
session?.cancel()
|
|
// Only the tail the bubble never showed: re-sending the whole turn would
|
|
// repeat everything the user already read above it.
|
|
const remainder = this.accumulated.slice(this.streamed).trim()
|
|
this.accumulated = ''
|
|
if (remainder) await sendPlainText(this.chatId, remainder)
|
|
}
|
|
}
|
|
|
|
const port: ChatPort = {
|
|
platform: 'qq',
|
|
logPrefix: '[QQ]',
|
|
async sendNotice(chatId, text) {
|
|
await sendPlainText(chatId, text)
|
|
},
|
|
createResponse(chatId) {
|
|
return new QqResponse(chatId)
|
|
},
|
|
async sendImage(chatId, image: OutboundImage) {
|
|
const check = checkAttachmentLimit('image', image.buffer.length, image.mime)
|
|
if (!check.ok) {
|
|
console.warn('[QQ] Outbound image rejected:', check.hint)
|
|
return
|
|
}
|
|
await bot.sendMedia({
|
|
target: targetFor(chatId),
|
|
fileType: MediaFileType.IMAGE,
|
|
buffer: image.buffer,
|
|
content: image.alt,
|
|
})
|
|
},
|
|
setBusy(chatId, busy) {
|
|
if (!busy) return
|
|
const target = targetFor(chatId)
|
|
if (target.scope !== 'c2c') return
|
|
// Typing is a courtesy signal; a failure must never surface to the user.
|
|
void bot.sendTyping(target, TYPING_SECONDS).catch(() => {})
|
|
},
|
|
clearChat(chatId) {
|
|
replyTargets.delete(chatId)
|
|
},
|
|
}
|
|
|
|
const runtime = new ImChatRuntime({
|
|
port,
|
|
config,
|
|
platformConfig: config.qq,
|
|
bridge,
|
|
sessionStore,
|
|
httpClient,
|
|
defaultWorkDir,
|
|
dedup,
|
|
flushIntervalMs: 600,
|
|
flushCharThreshold: 200,
|
|
})
|
|
|
|
async function collectAttachments(
|
|
chatId: string,
|
|
attachments: QqInboundAttachment[],
|
|
seed: string,
|
|
): Promise<AttachmentRef[]> {
|
|
if (attachments.length === 0) return []
|
|
const sessionId = sessionStore.get(chatId)?.sessionId ?? chatId
|
|
const settled = await Promise.allSettled(
|
|
attachments.map((attachment, index) =>
|
|
media.downloadAttachment(attachment, sessionId, `${seed}-${index}`),
|
|
),
|
|
)
|
|
|
|
const refs: AttachmentRef[] = []
|
|
let failures = 0
|
|
for (const result of settled) {
|
|
if (result.status === 'rejected') {
|
|
failures += 1
|
|
console.error('[QQ] attachment download failed:', result.reason)
|
|
continue
|
|
}
|
|
const local = result.value
|
|
const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
|
|
if (!check.ok) {
|
|
await port.sendNotice(chatId, check.hint)
|
|
continue
|
|
}
|
|
refs.push(
|
|
local.kind === 'image'
|
|
? { type: 'image', name: local.name, data: local.buffer.toString('base64'), mimeType: local.mimeType }
|
|
: { type: 'file', name: local.name, path: local.path, mimeType: local.mimeType },
|
|
)
|
|
}
|
|
if (failures > 0) {
|
|
await port.sendNotice(
|
|
chatId,
|
|
failures === attachments.length ? '附件下载失败,请稍后重试。' : `${failures} 个附件下载失败,已跳过。`,
|
|
)
|
|
}
|
|
return refs
|
|
}
|
|
|
|
bot.on('message', async (_ctx, message) => {
|
|
const payload = extractQqPayload(message as never)
|
|
if (!payload) return
|
|
|
|
replyTargets.set(payload.chatId, {
|
|
scope: 'c2c',
|
|
targetId: payload.chatId,
|
|
msgId: message.messageId,
|
|
})
|
|
|
|
await runtime.handleInbound({
|
|
chatId: payload.chatId,
|
|
userId: payload.userId,
|
|
displayName: payload.displayName,
|
|
dedupKey: payload.dedupKey,
|
|
text: payload.text,
|
|
hasAttachments: payload.attachments.length > 0,
|
|
loadAttachments: () => collectAttachments(
|
|
payload.chatId,
|
|
payload.attachments,
|
|
payload.dedupKey,
|
|
),
|
|
})
|
|
})
|
|
|
|
bot.on('ready', () => console.log('[QQ] Gateway ready, bot is running!'))
|
|
bot.on('resumed', () => console.log('[QQ] Gateway session resumed'))
|
|
bot.on('error', (err: Error) => console.error('[QQ] Connection error:', err.message))
|
|
|
|
console.log('[QQ] Starting adapter...')
|
|
console.log(`[QQ] Server: ${config.serverUrl}`)
|
|
console.log(`[QQ] App: ${config.qq.appId}`)
|
|
void adapterMigrationLifecycle.track(bot.start()).catch((err) => {
|
|
console.error('[QQ] Failed to start:', err instanceof Error ? err.message : err)
|
|
process.exit(1)
|
|
})
|
|
|
|
registerAdapterShutdown(async () => {
|
|
console.log('[QQ] Shutting down...')
|
|
try {
|
|
await bot.stop()
|
|
} catch {
|
|
// Best-effort: the process is exiting either way.
|
|
}
|
|
bridge.destroy()
|
|
dedup.destroy()
|
|
})
|