Add PTT (voice) upload support with FFmpeg addon
Introduce NT SILK-based voice (PTT) upload pipeline and native FFmpeg addon wrapper. - Add ffmpeg-addon wrapper (packages/core/src/bridge/highway/ffmpeg-addon.ts) to load platform/arch .node binaries, detect SILK files, and convert inputs to NT SILK via the native addon. Provides encodeSilk, isSilkFile and default temp dir helpers. - Implement ptt-upload pipeline (packages/core/src/bridge/highway/ptt-upload.ts) to: resolve/fetch audio bytes, convert to NT SILK if needed, send OIDB upload request, perform highway HTTP upload when required, and produce Encodable MsgInfo bytes for commonElem. - Wire record element support into element-builder: uploadPttMsgInfo is used to build a commonElem for 'record' sends; when SendContext is absent, record sends are skipped with a warning (graceful no-bridge path). - Update message parsing to accept record segments from data.file, data.url or data.path and drop empty sources. - Enable canSendRecord in OneBot instance context. - Add tests covering record parsing and the graceful send path (packages/core/tests/message-parser.test.ts). - Include prebuilt native ffmpeg addon binaries under packages/runtime/native/ffmpeg and update vite config to validate & copy the ffmpeg addon into the dist layout. These changes enable sending voice messages by converting/encoding sources to the expected NT SILK format and uploading them through the existing highway/NTV2 flows.
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
MarkdownDataSchema,
|
||||
} from '../proto/action';
|
||||
import { uploadImageMsgInfo } from '../highway/image-upload';
|
||||
import { uploadPttMsgInfo } from '../highway/ptt-upload';
|
||||
|
||||
type ProtoElem = Partial<ProtoDecoded<typeof ElemSchema>>;
|
||||
|
||||
@@ -151,10 +152,28 @@ async function makeImageElem(ctx: SendContext, element: MessageElement): Promise
|
||||
};
|
||||
}
|
||||
|
||||
async function makePttElem(ctx: SendContext, element: MessageElement): Promise<ProtoElem> {
|
||||
const isGroup = ctx.groupId !== undefined;
|
||||
const targetIdOrUid = isGroup ? ctx.groupId! : (ctx.userUid ?? '');
|
||||
if (!isGroup && !targetIdOrUid) {
|
||||
throw new Error('private record target uid is missing');
|
||||
}
|
||||
|
||||
const msgInfo = await uploadPttMsgInfo(ctx.bridge, isGroup, targetIdOrUid, element);
|
||||
|
||||
return {
|
||||
commonElem: {
|
||||
serviceType: 48,
|
||||
pbElem: msgInfo,
|
||||
businessType: 22,
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build proto Elem objects from an array of MessageElements.
|
||||
* Supports: text, face, at, reply, json, xml, markdown, image.
|
||||
* Image elements trigger highway upload via the SendContext.
|
||||
* Supports: text, face, at, reply, json, xml, markdown, image, record, forward.
|
||||
* Image and record elements trigger NTV2 highway upload via the SendContext.
|
||||
*/
|
||||
export async function buildSendElems(elements: MessageElement[], ctx?: SendContext): Promise<ProtoElem[]> {
|
||||
const result: ProtoElem[] = [];
|
||||
@@ -202,6 +221,13 @@ export async function buildSendElems(elements: MessageElement[], ctx?: SendConte
|
||||
break;
|
||||
|
||||
case 'record':
|
||||
if (ctx) {
|
||||
result.push(await makePttElem(ctx, elem));
|
||||
} else {
|
||||
console.warn('[ElemBuilder] record send requires SendContext');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'video':
|
||||
console.warn(`[ElemBuilder] ${elem.type} send not yet implemented`);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// FFmpeg native addon wrapper — port of NapCat's FFmpegAddonAdapter.
|
||||
// No fallback: throws if the addon isn't available on this platform/arch.
|
||||
//
|
||||
// The addon is shipped under `native/ffmpeg/ffmpegAddon.<platform>.<arch>.node`
|
||||
// to match the layout used by the upstream NapCat build so the binaries
|
||||
// can be dropped in as-is.
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface FFmpegVideoInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
format: string;
|
||||
videoCodec: string;
|
||||
image: Buffer;
|
||||
}
|
||||
|
||||
interface FFmpegNativeAddon {
|
||||
convertFile(inputFile: string, outputFile: string, format: string): Promise<{ success: boolean }>;
|
||||
getVideoInfo(filePath: string, format?: 'bmp' | 'bmp24'): Promise<FFmpegVideoInfo>;
|
||||
getDuration(filePath: string): Promise<number>;
|
||||
convertToNTSilkTct(inputPath: string, outputPath: string): Promise<void>;
|
||||
decodeAudioToPCM(filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean; sampleRate: number }>;
|
||||
decodeAudioToFmt(filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string }>;
|
||||
}
|
||||
|
||||
let cachedAddon: FFmpegNativeAddon | null = null;
|
||||
let cachedLoadError: string | null = null;
|
||||
|
||||
function addonFileName(): string {
|
||||
return `ffmpegAddon.${process.platform}.${process.arch}.node`;
|
||||
}
|
||||
|
||||
function addonSearchDirs(): string[] {
|
||||
// Mirrors the hook injector's resolution strategy so the addon is found
|
||||
// regardless of whether we're running from:
|
||||
// 1. A released zip (dist/native/ffmpeg/<file>.node), where __dirname is
|
||||
// the dist root.
|
||||
// 2. The bundled build (repoRoot/dist/native/ffmpeg/<file>.node).
|
||||
// 3. `tsx` dev mode (packages/runtime/native/ffmpeg/<file>.node), where
|
||||
// __dirname is `packages/core/src/bridge/highway` — four levels deep
|
||||
// under `packages/`. Note: the hook injector lives at
|
||||
// `packages/core/src/hook` so it only needs 3 `..`; we need 4.
|
||||
return [
|
||||
path.resolve(__dirname, 'native', 'ffmpeg'),
|
||||
path.resolve(__dirname, '..', '..', '..', '..', 'runtime', 'native', 'ffmpeg'),
|
||||
path.resolve(process.cwd(), 'dist', 'native', 'ffmpeg'),
|
||||
path.resolve(process.cwd(), 'packages', 'runtime', 'native', 'ffmpeg'),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveAddonPath(): string | null {
|
||||
const fileName = addonFileName();
|
||||
for (const dir of addonSearchDirs()) {
|
||||
const full = path.join(dir, fileName);
|
||||
if (fs.existsSync(full)) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the ffmpegAddon once and return a cached instance.
|
||||
* Throws on first-use failure; subsequent calls replay the same error message
|
||||
* so callers don't silently fall back.
|
||||
*/
|
||||
export function getFFmpegAddon(): FFmpegNativeAddon {
|
||||
if (cachedAddon) return cachedAddon;
|
||||
if (cachedLoadError) throw new Error(cachedLoadError);
|
||||
|
||||
const addonPath = resolveAddonPath();
|
||||
if (!addonPath) {
|
||||
cachedLoadError = `ffmpegAddon not found for ${process.platform}-${process.arch} (looked for ${addonFileName()})`;
|
||||
throw new Error(cachedLoadError);
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = { exports: {} as Record<string, unknown> };
|
||||
process.dlopen(mod, addonPath);
|
||||
cachedAddon = mod.exports as unknown as FFmpegNativeAddon;
|
||||
return cachedAddon;
|
||||
} catch (error) {
|
||||
cachedLoadError = `failed to load ffmpegAddon (${addonPath}): ${error instanceof Error ? error.message : String(error)}`;
|
||||
throw new Error(cachedLoadError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `filePath` is already an NT SILK container. The addon can
|
||||
* read any input directly so the only reason we peek at the header is to
|
||||
* skip the conversion (and the temp file) when the caller already fed us
|
||||
* a ready-to-upload silk file.
|
||||
*/
|
||||
export function isSilkFile(filePath: string): boolean {
|
||||
try {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(10);
|
||||
fs.readSync(fd, buf, 0, 10, 0);
|
||||
const header = buf.toString();
|
||||
return header.includes('#!SILK') || header.includes('\x02#!SILK');
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface EncodeSilkResult {
|
||||
/** Absolute path to the silk file ready for highway upload. */
|
||||
path: string;
|
||||
/** Voice duration in whole seconds (>= 1). */
|
||||
duration: number;
|
||||
/** `true` if we wrote a new silk file and the caller should unlink it. */
|
||||
converted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert `inputFile` to NT SILK when necessary and return the output path
|
||||
* + duration. The caller is responsible for unlinking the returned path
|
||||
* when `converted === true`.
|
||||
*/
|
||||
export async function encodeSilk(inputFile: string, tempDir: string): Promise<EncodeSilkResult> {
|
||||
const addon = getFFmpegAddon();
|
||||
|
||||
// Already silk — trust the file, just measure duration via the addon.
|
||||
if (isSilkFile(inputFile)) {
|
||||
let duration = 1;
|
||||
try {
|
||||
duration = Math.max(1, Math.round(await addon.getDuration(inputFile)));
|
||||
} catch {
|
||||
// getDuration on a silk file occasionally fails on certain encodings;
|
||||
// the upload path only needs a rough, non-zero value, so fall back
|
||||
// to the size-based heuristic used by NapCat's audio.ts.
|
||||
const stat = fs.statSync(inputFile);
|
||||
duration = Math.max(1, Math.floor(stat.size / 1024 / 3));
|
||||
}
|
||||
return { path: inputFile, duration, converted: false };
|
||||
}
|
||||
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
const outPath = path.join(tempDir, crypto.randomUUID());
|
||||
await addon.convertToNTSilkTct(inputFile, outPath);
|
||||
if (!fs.existsSync(outPath)) {
|
||||
throw new Error('convertToNTSilkTct produced no output file');
|
||||
}
|
||||
const duration = Math.max(1, Math.round(await addon.getDuration(inputFile)));
|
||||
return { path: outPath, duration, converted: true };
|
||||
}
|
||||
|
||||
/** Default location for temporary silk files. */
|
||||
export function defaultPttTempDir(): string {
|
||||
return path.join(os.tmpdir(), 'snowluma-ptt');
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// Voice (PTT) upload pipeline:
|
||||
// 1. resolve / fetch the source audio bytes,
|
||||
// 2. convert to NT SILK via the ffmpegAddon (skip if already silk),
|
||||
// 3. NTV2 OIDB upload request — 0x126E (group) / 0x126D (private),
|
||||
// 4. highway HTTP upload — commandId 1008 (group) / 1007 (private),
|
||||
// 5. return the encoded `MsgInfo` bytes ready for the commonElem.
|
||||
//
|
||||
// Port of NapCat's `UploadGroupPtt` / `UploadPrivatePtt` transformer + the
|
||||
// `uploadGroupPtt` / `uploadC2CPtt` paths in `PacketHighwayContext`.
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import type { Bridge } from '../bridge';
|
||||
import type { MessageElement } from '../events';
|
||||
import { protoEncode, protoDecode } from '../../protobuf/decode';
|
||||
import { makeOidbBaseSchema } from '../proto/oidb';
|
||||
import {
|
||||
NTV2UploadRichMediaReqSchema,
|
||||
NTV2UploadRichMediaRespSchema,
|
||||
EncodableMediaMsgInfoSchema,
|
||||
} from '../proto/highway';
|
||||
import { loadBinarySource, computeHashes } from './utils';
|
||||
import {
|
||||
fetchHighwaySession,
|
||||
uploadHighwayHttp,
|
||||
buildHighwayExtend,
|
||||
} from './highway-client';
|
||||
import { encodeSilk, defaultPttTempDir } from './ffmpeg-addon';
|
||||
import { createLogger } from '../../utils/logger';
|
||||
|
||||
const log = createLogger('Ptt');
|
||||
|
||||
export const PRIVATE_PTT_CMD_ID = 1007;
|
||||
export const GROUP_PTT_CMD_ID = 1008;
|
||||
|
||||
interface PttPayload {
|
||||
/** Silk bytes that get uploaded to highway. */
|
||||
bytes: Uint8Array;
|
||||
md5: Uint8Array;
|
||||
sha1: Uint8Array;
|
||||
md5Hex: string;
|
||||
sha1Hex: string;
|
||||
fileName: string;
|
||||
/** Whole seconds, >= 1. */
|
||||
duration: number;
|
||||
/** Cleanup hooks invoked once the OIDB request + highway upload finish. */
|
||||
cleanups: Array<() => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether `source` looks like a local filesystem path. The shape
|
||||
* matches the heuristics used inside `loadBinarySource`: we only reuse the
|
||||
* caller-supplied path verbatim for things the addon can read directly.
|
||||
*/
|
||||
function asLocalFilePath(source: string): string | null {
|
||||
if (!source) return null;
|
||||
if (source.startsWith('base64://')) return null;
|
||||
if (source.startsWith('http://') || source.startsWith('https://')) return null;
|
||||
|
||||
let filePath = source;
|
||||
if (filePath.startsWith('file://')) filePath = filePath.slice(7);
|
||||
if (/^\/[a-zA-Z]:/.test(filePath)) filePath = filePath.slice(1);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function loadPtt(element: MessageElement, tempDir: string): Promise<PttPayload> {
|
||||
const source = element.url || element.fileId || '';
|
||||
if (!source) throw new Error('record source is empty');
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
// Run all queued cleanups; callable both on the success path (via the
|
||||
// returned `cleanups` array) and the failure path (try/catch below).
|
||||
const runCleanups = () => {
|
||||
while (cleanups.length > 0) {
|
||||
const fn = cleanups.pop();
|
||||
if (!fn) continue;
|
||||
try { fn(); } catch { /* best-effort */ }
|
||||
}
|
||||
};
|
||||
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Resolve the on-disk path the addon should read. Anything that isn't
|
||||
// already a local file (base64, HTTP) gets staged into the temp dir.
|
||||
let inputPath: string;
|
||||
const local = asLocalFilePath(source);
|
||||
if (local && fs.existsSync(local)) {
|
||||
inputPath = local;
|
||||
} else {
|
||||
const loaded = await loadBinarySource(source, 'record');
|
||||
inputPath = path.join(tempDir, `snowluma-ptt-in-${crypto.randomUUID()}`);
|
||||
fs.writeFileSync(inputPath, Buffer.from(loaded.bytes));
|
||||
cleanups.push(() => { try { fs.unlinkSync(inputPath); } catch { /* ignore */ } });
|
||||
}
|
||||
|
||||
const silk = await encodeSilk(inputPath, tempDir);
|
||||
if (silk.converted) {
|
||||
cleanups.push(() => { try { fs.unlinkSync(silk.path); } catch { /* ignore */ } });
|
||||
log.debug('converted record to silk: %s -> %s (duration=%ds)', inputPath, silk.path, silk.duration);
|
||||
}
|
||||
|
||||
const silkBytes = new Uint8Array(fs.readFileSync(silk.path));
|
||||
if (silkBytes.length === 0) throw new Error('silk file is empty after conversion');
|
||||
|
||||
const hashes = computeHashes(silkBytes);
|
||||
return {
|
||||
bytes: silkBytes,
|
||||
md5: hashes.md5,
|
||||
sha1: hashes.sha1,
|
||||
md5Hex: hashes.md5Hex,
|
||||
sha1Hex: hashes.sha1Hex,
|
||||
fileName: `${hashes.md5Hex}.amr`,
|
||||
duration: silk.duration,
|
||||
cleanups: [...cleanups],
|
||||
};
|
||||
} catch (err) {
|
||||
runCleanups();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function makeClientRandomId(): bigint {
|
||||
// Same trick NapCat uses: 8 random bytes, masked into the positive int64
|
||||
// range so it survives proto signed-int64 encoding without surprises.
|
||||
const buf = crypto.randomBytes(8);
|
||||
return buf.readBigUInt64BE() & 0x7FFFFFFFFFFFFFFFn;
|
||||
}
|
||||
|
||||
async function startPttUpload(
|
||||
bridge: Bridge,
|
||||
isGroup: boolean,
|
||||
targetIdOrUid: string | number,
|
||||
ptt: PttPayload,
|
||||
): Promise<any> {
|
||||
const body: any = {
|
||||
reqHead: {
|
||||
// NapCat uses requestId=1 for group / 4 for c2c. Mirror it.
|
||||
common: { requestId: isGroup ? 1 : 4, command: 100 },
|
||||
scene: {
|
||||
requestType: 2,
|
||||
businessType: 3,
|
||||
sceneType: isGroup ? 2 : 1,
|
||||
...(isGroup
|
||||
? { group: { groupUin: Number(targetIdOrUid) } }
|
||||
: { c2c: { accountType: 2, targetUid: String(targetIdOrUid) } }),
|
||||
},
|
||||
client: { agentType: 2 },
|
||||
},
|
||||
upload: {
|
||||
uploadInfo: [{
|
||||
fileInfo: {
|
||||
fileSize: ptt.bytes.length,
|
||||
fileHash: ptt.md5Hex,
|
||||
fileSha1: ptt.sha1Hex,
|
||||
fileName: ptt.fileName,
|
||||
// type=3 voice, voiceFormat=1 (silk).
|
||||
type: { type: 3, picFormat: 0, videoFormat: 0, voiceFormat: 1 },
|
||||
width: 0,
|
||||
height: 0,
|
||||
time: ptt.duration,
|
||||
original: 0,
|
||||
},
|
||||
subFileType: 0,
|
||||
}],
|
||||
tryFastUploadCompleted: true,
|
||||
srvSendMsg: false,
|
||||
clientRandomId: makeClientRandomId(),
|
||||
compatQmsgSceneType: isGroup ? 2 : 1,
|
||||
extBizInfo: {
|
||||
// NapCat fills in a placeholder textSummary so the legacy compat
|
||||
// QMsg path still has something to render. Mirror it.
|
||||
pic: { textSummary: 'Nya~' },
|
||||
video: { bytesPbReserve: new Uint8Array(0) },
|
||||
ptt: {
|
||||
bytesReserve: new Uint8Array([0x08, 0x00, 0x38, 0x00]),
|
||||
bytesPbReserve: new Uint8Array(0),
|
||||
// `bytesGeneralFlags` differs between group / c2c voice. Lifted
|
||||
// verbatim from NapCat (UploadGroupPtt.ts / UploadPrivatePtt.ts).
|
||||
bytesGeneralFlags: isGroup
|
||||
? new Uint8Array([0x9a, 0x01, 0x07, 0xaa, 0x03, 0x04, 0x08, 0x08, 0x12, 0x00])
|
||||
: new Uint8Array([0x9a, 0x01, 0x0b, 0xaa, 0x03, 0x08, 0x08, 0x04, 0x12, 0x04, 0x00, 0x00, 0x00, 0x00]),
|
||||
},
|
||||
},
|
||||
clientSeq: 0,
|
||||
noNeedCompatMsg: false,
|
||||
},
|
||||
};
|
||||
|
||||
const oidbCmd = isGroup ? 0x126E : 0x126D;
|
||||
const serviceCmd = isGroup ? 'OidbSvcTrpcTcp.0x126e_100' : 'OidbSvcTrpcTcp.0x126d_100';
|
||||
|
||||
const baseSchema = makeOidbBaseSchema(NTV2UploadRichMediaReqSchema);
|
||||
const request = protoEncode({
|
||||
command: oidbCmd, subCommand: 100, errorCode: 0, body, errorMsg: '', reserved: 1,
|
||||
}, baseSchema);
|
||||
|
||||
const result = await bridge.sendRawPacket(serviceCmd, request);
|
||||
if (!result.success || !result.gotResponse || !result.responseData) {
|
||||
throw new Error(result.errorMessage || 'ptt upload request failed');
|
||||
}
|
||||
|
||||
const respBaseSchema = makeOidbBaseSchema(NTV2UploadRichMediaRespSchema);
|
||||
const resp: any = protoDecode(result.responseData, respBaseSchema);
|
||||
if (!resp) throw new Error('failed to decode ptt upload response');
|
||||
if (resp.errorCode && resp.errorCode !== 0) {
|
||||
throw new Error(`OIDB error ${resp.errorCode}: ${resp.errorMsg ?? ''}`);
|
||||
}
|
||||
|
||||
const uploadBody = resp.body;
|
||||
if (!uploadBody) throw new Error('ptt upload response body missing');
|
||||
if (uploadBody.respHead?.retCode && uploadBody.respHead.retCode !== 0) {
|
||||
throw new Error(uploadBody.respHead.message ?? 'ptt upload failed');
|
||||
}
|
||||
return uploadBody.upload;
|
||||
}
|
||||
|
||||
function finalizePttMsgInfo(upload: any): Uint8Array {
|
||||
if (!upload?.msgInfo) throw new Error('ptt upload response missing msgInfo');
|
||||
|
||||
const msgInfoBody = (upload.msgInfo.msgInfoBody ?? []).map((b: any) => ({
|
||||
index: b.index, picture: b.picture, fileExist: b.fileExist, hashSum: b.hashSum,
|
||||
}));
|
||||
|
||||
const extBizInfo: any = {};
|
||||
if (upload.msgInfo.extBizInfo?.pic) extBizInfo.pic = upload.msgInfo.extBizInfo.pic;
|
||||
if (upload.msgInfo.extBizInfo?.video) extBizInfo.video = upload.msgInfo.extBizInfo.video;
|
||||
if (upload.msgInfo.extBizInfo?.ptt) extBizInfo.ptt = upload.msgInfo.extBizInfo.ptt;
|
||||
if (upload.msgInfo.extBizInfo?.busiType !== undefined) {
|
||||
extBizInfo.busiType = upload.msgInfo.extBizInfo.busiType;
|
||||
}
|
||||
|
||||
return protoEncode({ msgInfoBody, extBizInfo }, EncodableMediaMsgInfoSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a voice clip and return the encoded `MsgInfo` bytes that go inside
|
||||
* a `commonElem { serviceType: 48, businessType: 22 }`.
|
||||
*/
|
||||
export async function uploadPttMsgInfo(
|
||||
bridge: Bridge,
|
||||
isGroup: boolean,
|
||||
targetIdOrUid: string | number,
|
||||
element: MessageElement,
|
||||
): Promise<Uint8Array> {
|
||||
const tempDir = defaultPttTempDir();
|
||||
const ptt = await loadPtt(element, tempDir);
|
||||
try {
|
||||
const upload = await startPttUpload(bridge, isGroup, targetIdOrUid, ptt);
|
||||
|
||||
// Highway upload only happens when the server didn't fast-path the file
|
||||
// (uKey present == "we want the bytes"). Otherwise we just embed the
|
||||
// returned MsgInfo and trust the dedupe.
|
||||
const uKey = upload?.uKey ?? '';
|
||||
if (uKey && upload?.msgInfo) {
|
||||
log.debug('highway upload: bytes=%d md5=%s scene=%s', ptt.bytes.length, ptt.md5Hex, isGroup ? 'group' : 'c2c');
|
||||
const session = await fetchHighwaySession(bridge);
|
||||
const extend = buildHighwayExtend(uKey, upload.msgInfo, upload.ipv4s ?? [], ptt.sha1);
|
||||
const commandId = isGroup ? GROUP_PTT_CMD_ID : PRIVATE_PTT_CMD_ID;
|
||||
await uploadHighwayHttp(bridge, session, commandId, ptt.bytes, ptt.md5, extend);
|
||||
} else {
|
||||
log.debug('ptt fast-uploaded (server already has md5=%s)', ptt.md5Hex);
|
||||
}
|
||||
|
||||
return finalizePttMsgInfo(upload);
|
||||
} finally {
|
||||
for (const fn of ptt.cleanups) {
|
||||
try { fn(); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext {
|
||||
getMessage: (messageId) => messageStore.findEvent(messageId),
|
||||
getMessageMeta: (messageId) => messageStore.findMeta(messageId),
|
||||
canSendImage: () => true,
|
||||
canSendRecord: () => false,
|
||||
canSendRecord: () => true,
|
||||
sendPrivateMessage: (userId, message, autoEscape) => handleSendPrivate(ref, userId, message, autoEscape),
|
||||
sendGroupMessage: (groupId, message, autoEscape) => handleSendGroup(ref, groupId, message, autoEscape),
|
||||
deleteMessage: (messageId, meta) => handleDeleteMessage(bridge, meta),
|
||||
|
||||
@@ -150,9 +150,11 @@ async function segmentToElement(type: string, data: Record<string, unknown>, opt
|
||||
};
|
||||
}
|
||||
case 'record': {
|
||||
const source = String(data.file ?? data.url ?? data.path ?? '');
|
||||
if (!source) return null;
|
||||
return {
|
||||
type: 'record',
|
||||
url: String(data.file ?? data.url ?? ''),
|
||||
url: source,
|
||||
};
|
||||
}
|
||||
case 'video': {
|
||||
|
||||
@@ -87,6 +87,30 @@ describe('parseMessage', () => {
|
||||
expect(result[0].url).toBe('https://example.com/img.png');
|
||||
});
|
||||
|
||||
it('parses record segment from data.file', async () => {
|
||||
const result = await parseMessage(
|
||||
[{ type: 'record', data: { file: 'file:///tmp/voice.amr' } }] as any,
|
||||
false,
|
||||
);
|
||||
expect(result).toEqual([{ type: 'record', url: 'file:///tmp/voice.amr' }]);
|
||||
});
|
||||
|
||||
it('parses record segment from data.path (NapCat parity)', async () => {
|
||||
const result = await parseMessage(
|
||||
[{ type: 'record', data: { path: 'C:\\voices\\hi.silk' } }] as any,
|
||||
false,
|
||||
);
|
||||
expect(result).toEqual([{ type: 'record', url: 'C:\\voices\\hi.silk' }]);
|
||||
});
|
||||
|
||||
it('drops record segment with empty source', async () => {
|
||||
const result = await parseMessage(
|
||||
[{ type: 'record', data: {} }] as any,
|
||||
false,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses at segment', async () => {
|
||||
const result = await parseMessage(
|
||||
[{ type: 'at', data: { qq: 12345 } }] as any,
|
||||
@@ -144,6 +168,24 @@ describe('parseMessage', () => {
|
||||
expect(protoElems[0].text?.str).toBe('@User ');
|
||||
});
|
||||
|
||||
it('skips record send when SendContext is absent (graceful no-bridge path)', async () => {
|
||||
// Without a SendContext, buildSendElems can't run highway upload —
|
||||
// it should warn and drop the element instead of throwing into the
|
||||
// ffmpegAddon (which would explode for callers that don't actually
|
||||
// need to send).
|
||||
const elements = await parseMessage(
|
||||
[
|
||||
{ type: 'text', data: { text: 'hi ' } },
|
||||
{ type: 'record', data: { file: '/tmp/x.silk' } },
|
||||
] as any,
|
||||
false,
|
||||
);
|
||||
const protoElems = await buildSendElems(elements);
|
||||
// Only the leading text should make it through.
|
||||
expect(protoElems).toHaveLength(1);
|
||||
expect(protoElems[0].text?.str).toBe('hi ');
|
||||
});
|
||||
|
||||
it('parses multiple segments', async () => {
|
||||
const result = await parseMessage(
|
||||
[
|
||||
|
||||
@@ -26,7 +26,7 @@ const nativeSrc = toPosix(nativeDir);
|
||||
// Target selection: `SNOWLUMA_TARGET=<platform>-<arch>` overrides the host
|
||||
// detection, enabling cross-target packaging on CI.
|
||||
const targetTriple = process.env.SNOWLUMA_TARGET ?? `${process.platform}-${process.arch}`;
|
||||
const targetPlatform = targetTriple.split('-')[0];
|
||||
const [targetPlatform, targetArch] = targetTriple.split('-');
|
||||
|
||||
// Runtime scaffolding files copied into dist/. The NTQQ hook is Windows-only,
|
||||
// so its launcher/shell script differ per target.
|
||||
@@ -47,12 +47,22 @@ const nativeFiles = [
|
||||
: []),
|
||||
];
|
||||
|
||||
// FFmpeg native addon ported from NapCat. Uses NapCat's
|
||||
// `<platform>.<arch>` naming so the prebuilt binaries can be vendored
|
||||
// as-is without renaming. Lives under `native/ffmpeg/` to keep it
|
||||
// separate from the SnowLuma flat-naming addons above.
|
||||
const ffmpegAddonFile = `ffmpegAddon.${targetPlatform}.${targetArch}.node`;
|
||||
const ffmpegSrcDir = path.resolve(nativeDir, 'ffmpeg');
|
||||
|
||||
// Fail fast if any expected native binary is missing from packages/runtime/native/.
|
||||
// vite-plugin-cp only emits a warning on missing source files; we want a hard
|
||||
// error so CI can't accidentally ship an incomplete archive.
|
||||
const missingNatives = nativeFiles.filter(
|
||||
(f) => !fs.existsSync(path.join(nativeDir, f)),
|
||||
);
|
||||
if (!fs.existsSync(path.join(ffmpegSrcDir, ffmpegAddonFile))) {
|
||||
missingNatives.push(`ffmpeg/${ffmpegAddonFile}`);
|
||||
}
|
||||
if (missingNatives.length > 0) {
|
||||
throw new Error(
|
||||
`Missing native binaries for target ${targetTriple}:\n` +
|
||||
@@ -73,6 +83,11 @@ const BaseConfigPlugin: PluginOption[] = [
|
||||
dest: path.join(distDir, 'native'),
|
||||
flatten: true,
|
||||
})),
|
||||
{
|
||||
src: `${toPosix(ffmpegSrcDir)}/${ffmpegAddonFile}`,
|
||||
dest: path.join(distDir, 'native', 'ffmpeg'),
|
||||
flatten: true,
|
||||
},
|
||||
]
|
||||
})
|
||||
];
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user