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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user