From fc21d87a9bf95c6bd2eb2e043574f592cedc3fcc Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 2 Jun 2026 22:01:07 +0800 Subject: [PATCH] feat(onebot): fetch_ptt_text via async 0x210/61 result push (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing showed the voice-to-text text is delivered by an async push (Event 0x210 subType 61), not the request response — so the previous poll-by-resend never got it. Captured a real push and decoded its protobuf: outer { f1, f2:item }; the item echoes the request msgId at field 1 (the correlation key) and the recognised text at field 8. - proto-defs: PttTransPush / PttTransPushItem (live-verified). - protocol: Event0x210SubType.PttTransResult = 61 + decoder → internal `ptt_trans_result` event { msgId, text } (replaces the temp hex dump). - core: translatePttToText now sends ONE trigger and returns the inline text only if present (already-transcribed); empty = pending. - onebot: ptt-trans-waiter (keyed by selfUin:msgId), event-pipeline subscribes `ptt_trans_result` to resolve it; fetch_ptt_text registers the waiter, fires the trigger, and awaits the push (20s timeout). Decoded one real push as text "12311123。" end to end. --- packages/core/src/bridge/apis/extras.ts | 41 +++++--------- packages/onebot/src/event-pipeline.ts | 8 +++ packages/onebot/src/modules/media-actions.ts | 41 ++++++++++---- .../onebot/src/modules/ptt-trans-waiter.ts | 56 +++++++++++++++++++ packages/proto-defs/src/ptt-trans.ts | 19 +++++++ packages/protocol/src/events.ts | 15 ++++- .../src/msg-push/decoders/event-0x210.ts | 30 +++++----- packages/protocol/src/msg-push/enums.ts | 8 +++ 8 files changed, 164 insertions(+), 54 deletions(-) create mode 100644 packages/onebot/src/modules/ptt-trans-waiter.ts diff --git a/packages/core/src/bridge/apis/extras.ts b/packages/core/src/bridge/apis/extras.ts index f0aa4a11..557a088a 100644 --- a/packages/core/src/bridge/apis/extras.ts +++ b/packages/core/src/bridge/apis/extras.ts @@ -121,19 +121,14 @@ export class ExtrasApi { // ─────────────── Voice-to-text (pttTrans.Trans{C2C,Group}PttReq) ─────────────── /** - * Trigger QQ's native voice-to-text on a received ptt and return the text. - * - * Sends `pttTrans.TransC2CPttReq` / `pttTrans.TransGroupPttReq` (RE'd from - * wrapper.node). The response carries the text inline when transcription is - * already done; an empty text + errCode 0 means the server is transcribing - * asynchronously (there is no dedicated push command), so we re-send with a - * short backoff until the text lands or the retry budget is spent — the same - * "fire then re-read" shape NapCat uses. - * - * NOTE: the wire field tags here are reverse-engineered and not yet verified - * against a live server; confirm with one capture before trusting failures. + * Send ONE `pttTrans.Trans{C2C,Group}PttReq` to trigger transcription and + * return the recognised text IF the response carries it inline (the + * already-transcribed case). For a freshly-received voice the response is an + * empty ack and the text is delivered later via the async Event 0x210 + * subType-61 push — callers should treat `''` as "pending" and wait for the + * `ptt_trans_result` event (correlated by msgId). Live-verified end to end. */ - async translatePttToText(input: PttTransInput, maxRetries = 5, retryDelayMs = 1000): Promise { + async translatePttToText(input: PttTransInput): Promise { const md5 = hexToBytes(input.md5Hex); const req: PttTransReq = input.isGroup ? { @@ -153,21 +148,13 @@ export class ExtrasApi { }; const cmd = input.isGroup ? 'pttTrans.TransGroupPttReq' : 'pttTrans.TransC2CPttReq'; - for (let attempt = 0; attempt < maxRetries; attempt++) { - const result = await this.ctx.sendRawPacket(cmd, protobuf_encode(req)); - if (!result.success || !result.gotResponse || !result.responseData) { - throw new Error(result.errorMessage || 'ptt translate request failed'); - } - const resp = protobuf_decode(result.responseData); - const item = input.isGroup ? resp?.groupResult : resp?.c2cResult; - if (item?.errCode) throw new Error(`ptt translate failed: error=${item.errCode}`); - const text = item?.text ?? ''; - if (text) return text; - // Empty → still transcribing; back off and retry. - if (attempt < maxRetries - 1) { - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); - } + const result = await this.ctx.sendRawPacket(cmd, protobuf_encode(req)); + if (!result.success || !result.gotResponse || !result.responseData) { + throw new Error(result.errorMessage || 'ptt translate request failed'); } - throw new Error('ptt translate timed out (no text returned)'); + const resp = protobuf_decode(result.responseData); + const item = input.isGroup ? resp?.groupResult : resp?.c2cResult; + if (item?.errCode) throw new Error(`ptt translate failed: error=${item.errCode}`); + return item?.text ?? ''; // '' = transcribing async; caller awaits the push } } diff --git a/packages/onebot/src/event-pipeline.ts b/packages/onebot/src/event-pipeline.ts index 77b57049..53fce4e9 100644 --- a/packages/onebot/src/event-pipeline.ts +++ b/packages/onebot/src/event-pipeline.ts @@ -4,6 +4,7 @@ import type { QQEventVariant } from '@snowluma/protocol/events'; import { convertEvent } from './event-converter'; import type { OneBotInstanceContext } from './instance-context'; import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT, hashMessageIdInt32 } from './message-id'; +import { deliverPttTransText, pttTransKey } from './modules/ptt-trans-waiter'; const moduleLog = createLogger('Event'); @@ -39,6 +40,13 @@ export function registerEventPipeline(ctx: OneBotInstanceContext): () => void { }), ); } + // Internal-only: voice-to-text result push. Not converted to a OneBot event — + // it just unblocks the fetch_ptt_text call waiting on this msgId. + disposers.push( + ctx.bridge.events.on('ptt_trans_result', (event) => { + deliverPttTransText(pttTransKey(event.selfUin, event.msgId), event.text); + }), + ); return () => { for (const dispose of disposers) { diff --git a/packages/onebot/src/modules/media-actions.ts b/packages/onebot/src/modules/media-actions.ts index 0903ae17..5d0f7e9a 100644 --- a/packages/onebot/src/modules/media-actions.ts +++ b/packages/onebot/src/modules/media-actions.ts @@ -4,6 +4,12 @@ import type { MessageElement } from '@snowluma/protocol/events'; import type { ImageUrlResolver } from '../event-converter'; import type { MediaStore } from '../media-store'; import type { MessageStore } from '../message-store'; +import { + deliverPttTransText, + failPttTransWaiter, + pttTransKey, + waitPttTransText, +} from './ptt-trans-waiter'; import type { JsonObject } from '../types'; const log = createLogger('OneBot'); @@ -44,18 +50,29 @@ export async function fetchPttText( // c2c: receiver is self (inbound voice); group: the group uin. const peerUin = isGroup ? (Number(event.group_id) || cached.sessionId) : selfId; - const text = await bridge.apis.extras.translatePttToText({ - isGroup, - msgId: messageId, - senderUin, - peerUin, - uuid: cached.fileId || '', - md5Hex: cached.md5Hex ?? '', - duration: cached.duration ?? 0, - size: cached.fileSize ?? 0, - format: cached.voiceFormat ?? 0, - }); - return { text }; + // Register the waiter BEFORE triggering, so a fast async push can't race + // ahead of us. The trigger response may carry the text inline (already + // transcribed) — settle immediately then; otherwise the Event 0x210 + // subType-61 push resolves the waiter via the event-pipeline subscription. + const key = pttTransKey(selfId, messageId); + const waiter = waitPttTransText(key, 20000); + try { + const syncText = await bridge.apis.extras.translatePttToText({ + isGroup, + msgId: messageId, + senderUin, + peerUin, + uuid: cached.fileId || '', + md5Hex: cached.md5Hex ?? '', + duration: cached.duration ?? 0, + size: cached.fileSize ?? 0, + format: cached.voiceFormat ?? 0, + }); + if (syncText) deliverPttTransText(key, syncText); + } catch (e) { + failPttTransWaiter(key, e instanceof Error ? e : new Error(String(e))); + } + return { text: await waiter }; } export async function getImageInfo( diff --git a/packages/onebot/src/modules/ptt-trans-waiter.ts b/packages/onebot/src/modules/ptt-trans-waiter.ts new file mode 100644 index 00000000..6b6bebb1 --- /dev/null +++ b/packages/onebot/src/modules/ptt-trans-waiter.ts @@ -0,0 +1,56 @@ +// Correlates an async voice-to-text result back to the fetch_ptt_text call +// that triggered it. QQ delivers the text via an Event 0x210 subType-61 push +// (decoded into a `ptt_trans_result` event), not in the request response — so +// the action registers a waiter keyed by `${selfUin}:${msgId}`, fires the +// trigger request, and the push subscription resolves the waiter by msgId. + +interface Pending { + resolve: (text: string) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +const pending = new Map(); + +export function pttTransKey(selfUin: number, msgId: number): string { + return `${selfUin}:${msgId}`; +} + +/** + * Register a waiter for `key`'s transcription. Rejects after `timeoutMs`. + * A new waiter for the same key supersedes (rejects) the previous one. + */ +export function waitPttTransText(key: string, timeoutMs: number): Promise { + const existing = pending.get(key); + if (existing) { + clearTimeout(existing.timer); + pending.delete(key); + existing.reject(new Error('语音转文字请求被新的请求取代')); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (pending.get(key)?.timer === timer) pending.delete(key); + reject(new Error('语音转文字超时(未收到结果推送)')); + }, timeoutMs); + timer.unref?.(); + pending.set(key, { resolve, reject, timer }); + }); +} + +/** Resolve a waiting fetch_ptt_text with the recognised text. No-op if none. */ +export function deliverPttTransText(key: string, text: string): void { + const p = pending.get(key); + if (!p) return; + clearTimeout(p.timer); + pending.delete(key); + p.resolve(text); +} + +/** Reject a waiter (e.g. the trigger request itself failed). No-op if none. */ +export function failPttTransWaiter(key: string, err: Error): void { + const p = pending.get(key); + if (!p) return; + clearTimeout(p.timer); + pending.delete(key); + p.reject(err); +} diff --git a/packages/proto-defs/src/ptt-trans.ts b/packages/proto-defs/src/ptt-trans.ts index 497ddbc7..3290d581 100644 --- a/packages/proto-defs/src/ptt-trans.ts +++ b/packages/proto-defs/src/ptt-trans.ts @@ -76,3 +76,22 @@ export interface PttTransResp { groupResult?: pb<2, GroupPttTransResult>; c2cResult?: pb<3, C2CPttTransResult>; } + +/** + * Async result push (Event 0x210 subType 61), sent after the request once the + * server finishes transcribing. **Live-verified** against a real push: the + * outer carries a single result item at field 2; the item echoes the request + * `msgId` at field 1 (the correlation key) and the recognised text at field 8. + */ +export interface PttTransPushItem { + msgId?: pb<1, uint_64>; // echoes the request msgId — correlation key + text?: pb<8, string>; // recognised text + senderUin?: pb<9, uint_64>; + receiverUin?: pb<10, uint_64>; + uuid?: pb<13, string>; +} + +export interface PttTransPush { + field1?: pb<1, uint_32>; + item?: pb<2, PttTransPushItem>; +} diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 99a018eb..8eada526 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -263,6 +263,18 @@ export interface GroupMsgEmojiLikeEvent extends QQEvent { isAdd: boolean; } +/** + * Async voice-to-text result, pushed by the server (Event 0x210 subType 61) + * after a `pttTrans.Trans{C2C,Group}PttReq`. `msgId` echoes the request's + * msgId — the correlation key a pending `fetch_ptt_text` waits on. Internal + * (not surfaced to OneBot clients). + */ +export interface PttTransResultEvent extends QQEvent { + kind: 'ptt_trans_result'; + msgId: number; + text: string; +} + export type QQEventVariant = | FriendMessage | GroupMessage @@ -280,4 +292,5 @@ export type QQEventVariant = | GroupEssenceEvent | GroupFileUploadEvent | FriendAddEvent - | GroupMsgEmojiLikeEvent; + | GroupMsgEmojiLikeEvent + | PttTransResultEvent; diff --git a/packages/protocol/src/msg-push/decoders/event-0x210.ts b/packages/protocol/src/msg-push/decoders/event-0x210.ts index ad6071af..65a922c0 100644 --- a/packages/protocol/src/msg-push/decoders/event-0x210.ts +++ b/packages/protocol/src/msg-push/decoders/event-0x210.ts @@ -12,6 +12,7 @@ import type { GeneralGrayTipInfo, NewFriend, } from '@snowluma/proto-defs/notify'; +import type { PttTransPush } from '@snowluma/proto-defs/ptt-trans'; import type { MsgPushContext } from '../context'; import { Event0x210SubType } from '../enums'; import { @@ -28,6 +29,7 @@ const unknownLog = createLogger('MsgPush.Unknown'); export const decodeEvent0x210: MsgPushDecoder = (ctx) => { switch (ctx.head.subType as Event0x210SubType) { case Event0x210SubType.FriendRequestNotice: return decodeFriendRequest(ctx); + case Event0x210SubType.PttTransResult: return decodePttTransResult(ctx); // 138 (other-recalled) and 139 (self-recalled) share the same // FriendRecall wire shape; the helper picks the right uid side // based on the subType. @@ -54,24 +56,24 @@ export const decodeEvent0x210: MsgPushDecoder = (ctx) => { // until someone maps the schema. return []; } - if (ctx.head.subType === 61) { - // TEMP capture aid (#79 语音转文字): subType 61 (0x3D) is QQ-NT's ptt - // voice-to-text result push. Its protobuf wire shape is registered - // dynamically by the ptt-trans subsystem (no static sys_msg_0x210_0x3d - // handler), so we dump the raw body once to decode it from ground truth. - // Replace with a proper decoder + correlation once mapped. - unknownLog.info( - 'Event0x210 ptt-trans(subType=61) fromUin=%s len=%d hex=%s', - String(ctx.fromUin ?? ''), - ctx.content?.length ?? 0, - ctx.content ? Buffer.from(ctx.content).toString('hex') : '', - ); - return []; - } unknownLog.debug('Event0x210 unknown subType=%d', ctx.head.subType); return []; }; +// Voice-to-text async result push. Wire shape (live-verified): +// { f1: uint, f2: { f1 = msgId (echoes the request), f8 = text, ... } } +// `msgId` is the correlation key a pending fetch_ptt_text waits on; we surface +// it + the recognised text as an internal `ptt_trans_result` event. +function decodePttTransResult(ctx: MsgPushContext): QQEventVariant[] { + const push = protobuf_decode(ctx.content); + const item = push?.item; + if (!item) return []; + const msgId = Number(item.msgId ?? 0n); + const text = item.text ?? ''; + if (!msgId || !text) return []; + return [{ kind: 'ptt_trans_result', time: ctx.head.timestamp, selfUin: ctx.selfUin, msgId, text }]; +} + function decodeFriendRequest(ctx: MsgPushContext): QQEventVariant[] { const request = protobuf_decode(ctx.content); if (!request?.info) return []; diff --git a/packages/protocol/src/msg-push/enums.ts b/packages/protocol/src/msg-push/enums.ts index 74422733..e94c157c 100644 --- a/packages/protocol/src/msg-push/enums.ts +++ b/packages/protocol/src/msg-push/enums.ts @@ -36,6 +36,14 @@ export enum Event0x2DCSubType { export enum Event0x210SubType { FriendRequestNotice = 35, + /** + * Voice-to-text (语音转文字) async result push, fired after a + * `pttTrans.Trans{C2C,Group}PttReq` once the server finishes transcribing. + * Live-verified body: `{ f1:uint, f2:{ f1=msgId, f8=text, ... } }`. There's + * no static sys_msg_0x210_0x3d handler — the ptt-trans subsystem registers + * it dynamically; we decode it into an internal `ptt_trans_result` event. + */ + PttTransResult = 61, /** * Outgoing friend-message recall — bot recalled its own message that * was sent to a friend. Same `FriendRecall` wire shape as 138; the