The card's follow-ups now reach the media job RPCs, and it only offers what the space can run. - media.capabilities answers which operations the actor's space can run (booleans only). The card asks once per minute per space, and only when it shows finished video or audio, so other cards cost nothing. - Export and Transcribe call media.jobs.start with the card's message, so the same bot posts the new card; Cancel calls media.jobs.cancel. A refused start shows the server's message under the card. - New files use top-level import type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
92 lines
3.2 KiB
TypeScript
92 lines
3.2 KiB
TypeScript
import type { Server } from "node:http";
|
|
import { createServer } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { NoVoiceConfigured } from "@rakazo/adapters";
|
|
import type { Actor } from "@rakazo/contracts";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type { VoiceDeps } from "./voice.js";
|
|
import { toVoiceStatus, transcribeVoice } from "./voice.js";
|
|
|
|
const actor: Actor = {
|
|
userId: "user-1",
|
|
spaceId: "space-1",
|
|
email: "user@rakazo.test",
|
|
isDeploymentOwner: false,
|
|
};
|
|
|
|
/** A user without a voice credential, on a deployment whose media settings are `settings`. */
|
|
function deps(settings: object | null, ciphertext: string): VoiceDeps {
|
|
return {
|
|
prisma: {
|
|
spaceVoicePreference: { findFirst: async () => null },
|
|
integrationProviderConfig: {
|
|
findUnique: async () => (settings ? { id: "media", ciphertext } : null),
|
|
},
|
|
},
|
|
secrets: { load: () => JSON.stringify(settings) },
|
|
} as unknown as VoiceDeps;
|
|
}
|
|
|
|
describe("dictation through the self-hosted transcription server", () => {
|
|
let server: Server;
|
|
let baseUrl = "";
|
|
const uploads: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
server = createServer((request, response) => {
|
|
const chunks: Buffer[] = [];
|
|
request.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
request.on("end", () => {
|
|
uploads.push(`${request.url} ${Buffer.concat(chunks).toString("latin1")}`);
|
|
response.setHeader("content-type", "application/json");
|
|
response.end(JSON.stringify({ text: " Remind me to call Ada. " }));
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
});
|
|
|
|
it("transcribes when no voice provider can", async () => {
|
|
const result = await transcribeVoice(
|
|
deps({ comfyui: null, transcription: { baseUrl } }, "media-a"),
|
|
actor,
|
|
{ audio: new Uint8Array([1, 2, 3]), mimeType: "audio/webm;codecs=opus" },
|
|
);
|
|
expect(result).toEqual({ text: "Remind me to call Ada." });
|
|
expect(uploads[0]).toContain("/v1/audio/transcriptions");
|
|
expect(uploads[0]).toContain('filename="audio.webm"');
|
|
});
|
|
|
|
it("keeps asking for a voice key when no server is set up either", async () => {
|
|
await expect(
|
|
transcribeVoice(deps(null, "none"), actor, {
|
|
audio: new Uint8Array([1]),
|
|
mimeType: "audio/webm",
|
|
}),
|
|
).rejects.toBeInstanceOf(NoVoiceConfigured);
|
|
});
|
|
|
|
it("refuses an empty recording before uploading it", async () => {
|
|
const before = uploads.length;
|
|
await expect(
|
|
transcribeVoice(deps({ comfyui: null, transcription: { baseUrl } }, "media-b"), actor, {
|
|
audio: new Uint8Array(),
|
|
mimeType: "audio/webm",
|
|
}),
|
|
).rejects.toThrow("That recording is empty or too large.");
|
|
expect(uploads).toHaveLength(before);
|
|
});
|
|
|
|
it("reports server dictation in the voice status", () => {
|
|
expect(toVoiceStatus(null).transcribe).toBe(false);
|
|
expect(toVoiceStatus(null, { mediaTranscription: true })).toMatchObject({
|
|
configured: false,
|
|
transcribe: true,
|
|
});
|
|
});
|
|
});
|