feat(onebot): fetch_ptt_text via async 0x210/61 result push (#79)

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.
This commit is contained in:
DrSmoothl
2026-06-02 22:01:07 +08:00
parent e036e70acc
commit fc21d87a9b
8 changed files with 164 additions and 54 deletions
+14 -27
View File
@@ -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<string> {
async translatePttToText(input: PttTransInput): Promise<string> {
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<PttTransReq>(req));
if (!result.success || !result.gotResponse || !result.responseData) {
throw new Error(result.errorMessage || 'ptt translate request failed');
}
const resp = protobuf_decode<PttTransResp>(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<void>((resolve) => setTimeout(resolve, retryDelayMs));
}
const result = await this.ctx.sendRawPacket(cmd, protobuf_encode<PttTransReq>(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<PttTransResp>(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
}
}
+8
View File
@@ -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) {
+29 -12
View File
@@ -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(
@@ -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<typeof setTimeout>;
}
const pending = new Map<string, Pending>();
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<string> {
const existing = pending.get(key);
if (existing) {
clearTimeout(existing.timer);
pending.delete(key);
existing.reject(new Error('语音转文字请求被新的请求取代'));
}
return new Promise<string>((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);
}
+19
View File
@@ -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>;
}
+14 -1
View File
@@ -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;
@@ -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<PttTransPush>(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<FriendRequest>(ctx.content);
if (!request?.info) return [];
+8
View File
@@ -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