Compare commits

..

10 Commits

Author SHA1 Message Date
Andrey Epifancev
17a5156cc6 docs(openspec): decision #19 (R-4) — final statuses for task-review minors; fix #18 wording (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:10:07 +04:00
Andrey Epifancev
ab698d00f3 fix(core): redact() Content-Disposition cut index; errorText never throws; design.md accuracy
M-1: redact()'s Content-Disposition cut computed its index against a
lowercased copy but sliced the original string — some Unicode characters
change length under toLowerCase(), which could let a fragment of
"Content-Disposition" survive the cut. Now uses a case-insensitive regex
matched directly against the original string.

T3.5: errorText() could itself throw on a pathological thrown value (a
null-prototype object with no toString/valueOf/Symbol.toPrimitive),
defeating transcribe()'s "never throws" guarantee in an already-caught
branch. Now wrapped defensively with a generic fallback message.

Also corrects two stale descriptions in design.md (classify/redact
signatures, and the post-promise rejection handling) so the archived
design document matches the shipped code.

Refs #5

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 01:24:58 +04:00
Andrey Epifancev
a948a80e7a docs: record decision #18 (R-3) — final review fix scope
Fixing M-1 (redact() Content-Disposition cut index), T3.5 (errorText
defensive wrap), and M-2 (design.md stale signatures) per the final
whole-branch reviewer's own bundling recommendation. Deferring M-3, M-4,
M-5 as cosmetic/unreachable/forward-looking with no current impact.

Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 01:20:17 +04:00
Andrey Epifancev
6f6df01cbd docs: record decisions #16-17 for Task 3/6 deviations from plan's literal code
R-1 (design #16): OpenAiTranscriber.transcribe's redact() call moved out of
the plan's literal post-try/catch position into a single guarded call site,
closing a never-throws gap the plan's own code left open (Task 3 fix loop).

R-2 (design #17): main.ts's settings field needed an override modifier to
satisfy noImplicitOverride against obsidian 1.13.1's new base Plugin.settings
field; the plan's literal code did not compile without it (Task 6).

Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 01:11:03 +04:00
Andrey Epifancev
4f6f202eda feat(obsidian): requestUrl adapter, settings tab, provider check command
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 01:08:50 +04:00
Andrey Epifancev
129e7c0b40 feat(core): provider check helpers — pick latest audio, format notice
Refs #5

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 01:01:29 +04:00
Andrey Epifancev
87826bc427 test: live AITUNNEL integration test, skipped without AITUNNEL_API_KEY
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 00:54:20 +04:00
Andrey Epifancev
812ace7f3c fix(core): guard the only redact() call in transcribe() so it truly cannot throw
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 00:49:12 +04:00
Andrey Epifancev
b38e160073 fix(core): wrap redact() in transcribe()'s try/catch so it never throws
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 00:46:02 +04:00
Andrey Epifancev
06d3528efd feat(core): OpenAI-compatible transcriber with prechecks, timeout and abort
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 00:31:01 +04:00
13 changed files with 669 additions and 10 deletions

View File

@ -32,7 +32,7 @@ Core (`src/core/`, issue #1) сохраняет запись и ведёт её
|---|---|
| `src/core/ports.ts` | + `HttpRequest`, `HttpResponse`, `HttpClient` |
| `src/core/multipart.ts` | `buildMultipart(fields, file, boundary) → { body: ArrayBuffer, contentType }`, побайтно через `TextEncoder`/`Uint8Array` |
| `src/core/openai.ts` | `OpenAiSettings`, `DEFAULT_OPENAI`, `REQUEST_TIMEOUT_MS`, `readSettings` (trim всех четырёх полей + наложение на умолчания), `classify(status, text)`, `redact(message, apiKey)`, `OpenAiTranscriber` |
| `src/core/openai.ts` | `OpenAiSettings`, `DEFAULT_OPENAI`, `REQUEST_TIMEOUT_MS`, `readSettings` (trim всех четырёх полей + наложение на умолчания), `classify(res: HttpResponse)`, `redact(message, { apiKey, boundary })`, `OpenAiTranscriber` |
| `src/core/naming.ts` | + `mimeFromExt(ext)` для команды проверки |
| `src/obsidian/http.ts` | `HttpClient` на `requestUrl({ url, method: 'POST', headers, contentType, body, throw: false })` |
| `src/obsidian/settings.ts` | `PluginSettingTab`: четыре поля, ключ — `inputEl.type = 'password'` |
@ -65,7 +65,7 @@ new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random:
Настройки передаются геттером и читаются на каждом вызове: исправленный ключ применяется без пересоздания очереди. Boundary строится из `random` — детерминированно в тестах.
Поток `transcribe(audio, mime, signal)`: чтение и `trim` настроек → предпроверки (ключ, адрес, 0 байт, отменённый сигнал) → `buildMultipart` → `Promise.race(post, таймер, abort)` → `classify` → `redact` для любой причины. В `finally` снимаются таймер и слушатель `abort`; у промиса `post` висит пустой `.catch`, чтобы поздний reject не стал необработанным.
Поток `transcribe(audio, mime, signal)`: чтение и `trim` настроек → предпроверки (ключ, адрес, 0 байт, отменённый сигнал) → `buildMultipart` → `Promise.race(post, таймер, abort)` → `classify` → `redact` для любой причины. В `finally` снимаются таймер и слушатель `abort`; у промиса `post` есть обработчик отклонения (второй аргумент `.then`), который превращает любой сетевой сбой в обычный `TranscribeResult`, поэтому необработанного отклонения не возникает.
Таймер — глобальный `setTimeout`/`clearTimeout` (есть и в браузере, и в Node), в тестах — `vi.useFakeTimers()`; отдельный порт не нужен.
@ -106,3 +106,7 @@ new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random:
13. 2026-09-27 (план) — `redact` дополнительно обрезает причину с первого вхождения boundary тела запроса (`--<boundary>` или сам boundary) или строки `Content-Disposition` (без учёта регистра), затем убирает хвостовые пробелы и двоеточия. *Спека запрещает любую часть тела запроса в причине; единственный реалистичный путь туда — сервис или прокси, который эхом возвращает тело в тексте ошибки. Замена ключа и `Bearer` этого не ловит.* **Если легитимный текст ошибки случайно содержит `Content-Disposition` — деталь обрезается, остаётся `HTTP <код>`; диагностика беднее, данные не теряются.** Название модели или язык в тексте ошибки («model gpt-x not found») частью тела не считаются и не вырезаются.
14. 2026-09-27 (план) — `OpenAiTranscriber.transcribe` оборачивает всё тело в `try/catch`; непредвиденное исключение (например, бросил геттер настроек) → `temporary` «внутренняя ошибка: <сообщение>» после `redact`. Исключение HTTP-клиента, в том числе синхронное, по-прежнему `сеть: <сообщение>`. *Спека требует, чтобы провайдер никогда не бросал; очередь такое тоже поймала бы (решение 13 прошлой фичи), но контракт должен держаться сам.* **Ошибка программирования в провайдере маскируется под временную и тратит пять попыток, прежде чем запись уйдёт в `failed`.**
15. 2026-09-27 (план) — Boundary — `----voice-note-` + 24 hex-символа из `random` (`Math.random` в плагине). *Детерминированно в тестах; вероятность совпадения с байтами аудио пренебрежимо мала при 96 битах.* **Совпадение boundary с байтами аудио испортит тело — 400, `permanent`; «Повторить распознавание» сгенерирует новый boundary.**
16. 2026-09-27 (исполнение, контроллер SDD, R-1) — В `OpenAiTranscriber.transcribe` (Task 3) единственный вызов `redact()` вынесен из `try/catch` в отдельный, тоже защищённый `try/catch` с запасным вариантом — жёстко заданной строкой `'внутренняя ошибка'` без интерполяции — если `redact()` сам бросит исключение. Это отступление от буквального кода плана (Task 3, Step 5): план дословно ставил единственный вызов `redact()` ПОСЛЕ `try/catch`, что ревьюер финального ревью задачи 3 нашёл как реальный (Important) пробел. *Дословный код плана проходил все тесты и typecheck, но противоречил собственному Global Constraint плана «`OpenAiTranscriber.transcribe` никогда не бросает»: гипотетическое будущее изменение `classify()`/`fail()`, из-за которого `message` перестанет быть строкой, привело бы к необработанному исключению за пределами метода — ровно то, что этот метод существует, чтобы предотвратить.* **Цена ошибки: крайне маловероятный, сейчас недостижимый при текущих типах путь; если рулинг был не нужен — цена лишь две защитные строки кода без изменения поведения ни для одного существующего пути.**
17. 2026-09-27 (исполнение, Task 6 implementer, R-2) — В `src/main.ts` поле `settings` класса `VoiceNotePlugin` объявлено с модификатором `override`, которого нет в буквальном коде плана (Task 6, Step 3). *Дословный код плана не проходил typecheck (TS4114): установленный пакет `obsidian` (1.13.1) с версии 1.13.0 объявляет базовое поле `Plugin.settings?: unknown`, а `tsconfig.json` репозитория требует `noImplicitOverride: true` — любое поле подкласса, перекрывающее поле базового класса, обязано нести `override`.* **Цена ошибки: ноль — чисто синтаксический модификатор без изменения поведения; без него код не компилируется, других исходов нет.**
18. 2026-09-27 (исполнение, контроллер SDD, R-3) — Из находок финального ревью всей ветки (M-1..M-5, плюс дообследование отложенных T3.5) исправлены только M-1 (индекс среза `Content-Disposition` в `redact()` считался по строке в нижнем регистре, но применялся к оригиналу — некоторые Unicode-символы меняют длину при `toLowerCase()`), T3.5 (`errorText` в `transcribe()` теоретически может бросить на объекте без `toString`/`valueOf`/`Symbol.toPrimitive`) и M-2 (устаревшие сигнатуры `classify(status,text)`/`redact(message,apiKey)` и неточное описание пустого `.catch` в этом же design.md, строки 35 и 68). *Ревьюер сам предложил объединить эти три в один фикс-коммит как однострочные, нулевого риска правки без изменения поведения ни на одном достижимом пути.* **Цена ошибки: не найдено критичных/важных находок; отложенные (M-3 стилистика недостижимого catch, M-4 косметическое Notice после unload, M-5 saveSettings перезаписывает только `{openai}` — задел на будущее для issue-обёртки) переносятся в описание PR и, если актуальны, в follow-up issue при закрытии.** Статус M-3, M-4, M-5 — `deferred` (обоснование см. в отчёте финального ревью и в ledger SDD).
19. 2026-10-10 (завершение ветки, основная сессия, R-4) — Мелкие находки ревью задач T1.1, T1.2, T3.1–T3.4, T5.1, T5.2 закрыты статусом `rejected`: финальное ревью ветки заново рассмотрело каждую и признало приемлемой как есть, T3.5 исправлена в R-3. Попутно исправлена формулировка решения 18: M-3, M-4, M-5 — `deferred`, а не «отклонённые», как и записано в ledger (R-3). *У каждой находки должен быть итоговый статус. Вывод финального ревьюера (opus) — единственная независимая оценка этих находок; ни одна не затрагивает поведение на достижимых путях.* **Если какая-то из них окажется значимой (например, T1.1 — экранирование `"`/CRLF в имени части multipart при появлении недоверенного вызывающего кода), её придётся завести заново; сейчас все вызывающие места передают фиксированные имена.**

View File

@ -24,6 +24,13 @@ export function extFromMime(mime: string): string {
return EXT[base] ?? 'bin';
}
// Обратное к extFromMime; .bin и неизвестные расширения → null.
export function mimeFromExt(ext: string): string | null {
const lower = ext.toLowerCase();
for (const [mime, e] of Object.entries(EXT)) if (e === lower) return mime;
return null;
}
export function audioPath(id: string, mime: string): string {
return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`;
}

View File

@ -1,4 +1,6 @@
import type { HttpResponse, TranscribeResult } from './ports';
import { buildMultipart, makeBoundary } from './multipart';
import { extFromMime } from './naming';
import type { HttpClient, HttpResponse, TranscribeResult, Transcriber } from './ports';
export interface OpenAiSettings {
baseUrl: string;
@ -90,8 +92,8 @@ export function classify(res: HttpResponse): TranscribeResult {
// Причина попадает во frontmatter и в заметку: ни ключа, ни тела запроса.
export function redact(message: string, secrets: { apiKey: string; boundary?: string }): string {
let out = message;
const lower = out.toLowerCase();
const cuts = [lower.indexOf('content-disposition')];
const cdMatch = /content-disposition/i.exec(out);
const cuts = [cdMatch ? cdMatch.index : -1];
if (secrets.boundary) cuts.push(out.indexOf(`--${secrets.boundary}`), out.indexOf(secrets.boundary));
const cut = Math.min(...cuts.filter((i) => i >= 0), out.length);
out = out.slice(0, cut);
@ -100,3 +102,104 @@ export function redact(message: string, secrets: { apiKey: string; boundary?: st
out = out.replace(/Bearer\s+\S+/g, 'Bearer ***');
return out.slice(0, REASON_LIMIT);
}
const fail = (kind: 'temporary' | 'permanent', message: string): TranscribeResult => ({ ok: false, kind, message });
const errorText = (e: unknown): string => {
try {
return e instanceof Error ? e.message : String(e);
} catch {
return 'неизвестная ошибка';
}
};
function partType(mime: string): string {
return mime.split(';')[0].trim().toLowerCase() || 'application/octet-stream';
}
export class OpenAiTranscriber implements Transcriber {
// settings — геттер: исправленный во вкладке ключ применяется к следующему
// запросу без пересоздания очереди.
constructor(
private readonly http: HttpClient,
private readonly settings: () => OpenAiSettings,
private readonly random: () => number,
) {}
// Никогда не бросает: любой исход — TranscribeResult с очищенной причиной.
async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise<TranscribeResult> {
let apiKey = '';
let boundary: string | undefined;
let result: TranscribeResult;
try {
const s = readSettings(this.settings());
apiKey = s.apiKey;
boundary = makeBoundary(this.random);
result = await this.request(s, audio, mime, signal, boundary);
} catch (e) {
result = fail('temporary', `внутренняя ошибка: ${errorText(e)}`);
}
if (result.ok) return result;
try {
return { ...result, message: redact(result.message, { apiKey, boundary }) };
} catch {
return { ...result, message: 'внутренняя ошибка' };
}
}
private request(
s: OpenAiSettings,
audio: ArrayBuffer,
mime: string,
signal: AbortSignal,
boundary: string,
): Promise<TranscribeResult> {
if (!s.apiKey) return Promise.resolve(fail('permanent', NO_API_KEY));
if (!s.baseUrl) return Promise.resolve(fail('permanent', NO_BASE_URL));
if (audio.byteLength === 0) return Promise.resolve(fail('permanent', EMPTY_AUDIO));
if (signal.aborted) return Promise.resolve(fail('temporary', CANCELLED));
const fields: [string, string][] = [
['model', s.model],
['response_format', 'json'],
];
if (s.language) fields.push(['language', s.language]);
const { body, contentType } = buildMultipart(
fields,
{ name: 'file', filename: `audio.${extFromMime(mime)}`, contentType: partType(mime), data: audio },
boundary,
);
const url = `${s.baseUrl.replace(/\/+$/, '')}/audio/transcriptions`;
// post вызывается синхронно (до того, как abort успеет сработать);
// синхронный throw клиента превращается в rejection.
let sent: Promise<HttpResponse>;
try {
sent = Promise.resolve(
this.http.post({ url, headers: { Authorization: `Bearer ${s.apiKey}` }, contentType, body }),
);
} catch (e) {
sent = Promise.reject(e);
}
const answer = sent.then(
(res) => classify(res),
(e: unknown) => fail('temporary', `сеть: ${errorText(e)}`),
);
return raceWithDeadline(answer, signal);
}
}
// requestUrl нельзя оборвать: при таймауте или отмене запрос доживает в фоне,
// его исход игнорируется. answer никогда не отклоняется — unhandled rejection нет.
function raceWithDeadline(answer: Promise<TranscribeResult>, signal: AbortSignal): Promise<TranscribeResult> {
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const deadline = new Promise<TranscribeResult>((resolve) => {
timer = setTimeout(() => resolve(fail('temporary', TIMED_OUT)), REQUEST_TIMEOUT_MS);
onAbort = () => resolve(fail('temporary', CANCELLED));
signal.addEventListener('abort', onAbort, { once: true });
});
return Promise.race([answer, deadline]).finally(() => {
clearTimeout(timer);
if (onAbort) signal.removeEventListener('abort', onAbort);
});
}

View File

@ -0,0 +1,36 @@
import { mimeFromExt, SOURCE_DIR } from './naming';
import type { TranscribeResult } from './ports';
// Команда «Проверить провайдер распознавания»: только уведомление,
// ни заметки, ни очередь не трогаются.
export const NO_CHECK_AUDIO = `Нет записей в ${SOURCE_DIR}/ для проверки провайдера`;
const PREVIEW_LIMIT = 100;
// Последний по имени файл прямо в voice/source/: имена начинаются с UTC-времени,
// значит это самая свежая запись.
export function pickCheckAudio(paths: readonly string[]): { path: string; mime: string } | null {
const prefix = `${SOURCE_DIR}/`;
let best: { path: string; mime: string } | null = null;
for (const path of paths) {
if (!path.startsWith(prefix)) continue;
const name = path.slice(prefix.length);
const dot = name.lastIndexOf('.');
if (name.includes('/') || dot < 0) continue;
const mime = mimeFromExt(name.slice(dot + 1));
if (mime && (!best || path > best.path)) best = { path, mime };
}
return best;
}
export function checkNotice(result: TranscribeResult): string {
if (result.ok) {
const text = result.text.trim();
if (!text) return 'Провайдер работает, но речь в записи не распознана';
const preview = text.length > PREVIEW_LIMIT ? `${text.slice(0, PREVIEW_LIMIT)}…` : text;
return `Провайдер работает: «${preview}»`;
}
const kind = result.kind === 'temporary' ? 'временная' : 'постоянная';
return `Провайдер: ${kind} ошибка — ${result.message}`;
}

View File

@ -1,6 +1,59 @@
import { Plugin } from 'obsidian';
import { Notice, Plugin } from 'obsidian';
import { OpenAiTranscriber, readSettings, type OpenAiSettings } from './core/openai';
import { checkNotice, NO_CHECK_AUDIO, pickCheckAudio } from './core/provider-check';
import { obsidianHttp } from './obsidian/http';
import { VoiceNoteSettingTab } from './obsidian/settings';
// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала.
export default class VoiceNotePlugin extends Plugin {
override async onload(): Promise<void> {}
interface VoiceNoteData {
openai: OpenAiSettings;
}
// Обёртка Obsidian. Очередь и запись подключит issue обёртки; здесь — настройки
// провайдера и команда его проверки.
export default class VoiceNotePlugin extends Plugin {
override settings: VoiceNoteData = { openai: readSettings(undefined) };
private readonly controller = new AbortController();
private transcriber = new OpenAiTranscriber(obsidianHttp, () => this.settings.openai, Math.random);
override async onload(): Promise<void> {
await this.loadSettings();
this.addSettingTab(new VoiceNoteSettingTab(this.app, this));
this.addCommand({
id: 'check-transcriber',
name: 'Проверить провайдер распознавания',
callback: () => void this.checkTranscriber(),
});
}
override onunload(): void {
this.controller.abort();
}
async loadSettings(): Promise<void> {
const data = (await this.loadData()) as { openai?: unknown } | null;
this.settings = { openai: readSettings(data?.openai) };
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
// Только уведомление: заметки, frontmatter и очередь не трогаются.
private async checkTranscriber(): Promise<void> {
const files = this.app.vault.getFiles();
const picked = pickCheckAudio(files.map((f) => f.path));
const file = picked && files.find((f) => f.path === picked.path);
if (!picked || !file) {
new Notice(NO_CHECK_AUDIO);
return;
}
new Notice(`Проверка провайдера: ${picked.path}…`);
try {
const audio = await this.app.vault.readBinary(file);
const result = await this.transcriber.transcribe(audio, picked.mime, this.controller.signal);
new Notice(checkNotice(result), 10_000);
} catch (e) {
new Notice(`Не удалось прочитать ${picked.path}: ${e instanceof Error ? e.message : String(e)}`, 10_000);
}
}
}

24
src/obsidian/http.ts Normal file
View File

@ -0,0 +1,24 @@
import { requestUrl } from 'obsidian';
import type { HttpClient, HttpRequest, HttpResponse } from '../core/ports';
// requestUrl обходит CORS на мобильном. contentType — отдельным параметром,
// иначе boundary multipart может потеряться. throw: false — любой статус это ответ.
export const obsidianHttp: HttpClient = {
async post(req: HttpRequest): Promise<HttpResponse> {
const res = await requestUrl({
url: req.url,
method: 'POST',
headers: req.headers,
contentType: req.contentType,
body: req.body,
throw: false,
});
let text = '';
try {
text = res.text;
} catch {
// Тело не декодируется как текст — классификация пойдёт по статусу.
}
return { status: res.status, text };
},
};

35
src/obsidian/settings.ts Normal file
View File

@ -0,0 +1,35 @@
import { type App, PluginSettingTab, Setting } from 'obsidian';
import type { OpenAiSettings } from '../core/openai';
import type VoiceNotePlugin from '../main';
export class VoiceNoteSettingTab extends PluginSettingTab {
constructor(
app: App,
private readonly plugin: VoiceNotePlugin,
) {
super(app, plugin);
}
override display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl).setName('Распознавание речи').setHeading();
this.field('Адрес сервиса', 'OpenAI-совместимый base URL. По умолчанию AITUNNEL: https://api.aitunnel.ru/v1', 'baseUrl');
this.field('Модель', 'Например, gpt-4o-transcribe', 'model');
this.field('API-ключ', 'Хранится только в data.json плагина и синхронизируется вместе с vault', 'apiKey', true);
this.field('Язык', 'Код языка (ru, en). Пусто — модель определяет язык сама', 'language');
}
private field(name: string, desc: string, key: keyof OpenAiSettings, secret = false): void {
new Setting(this.containerEl)
.setName(name)
.setDesc(desc)
.addText((text) => {
if (secret) text.inputEl.type = 'password';
text.setValue(this.plugin.settings.openai[key]).onChange(async (value) => {
this.plugin.settings.openai[key] = value;
await this.plugin.saveSettings();
});
});
}
}

View File

@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { DEFAULT_OPENAI, OpenAiTranscriber } from '../../src/core/openai';
import type { HttpClient } from '../../src/core/ports';
// Ключ — только из окружения, никогда в репозитории.
const KEY = process.env.AITUNNEL_API_KEY ?? '';
// Под Node нет requestUrl: тот же порт на fetch. contentType уходит заголовком.
const fetchHttp: HttpClient = {
async post(req) {
const res = await fetch(req.url, {
method: 'POST',
headers: { ...req.headers, 'Content-Type': req.contentType },
body: req.body,
});
return { status: res.status, text: await res.text() };
},
};
// Характерные слова эталона tests/fixtures/test-01.txt.
const WORDS = ['тестовая', 'запись', 'проверки', 'распознавания', 'речи', 'whisper'];
describe.skipIf(!KEY)('AITUNNEL gpt-4o-transcribe (live)', () => {
it('transcribes test-01.m4a', { timeout: 120_000 }, async () => {
const audio = new Uint8Array(readFileSync(new URL('../fixtures/test-01.m4a', import.meta.url))).buffer;
const reference = readFileSync(new URL('../fixtures/test-01.txt', import.meta.url), 'utf8').toLowerCase();
for (const word of WORDS) expect(reference).toContain(word);
const t = new OpenAiTranscriber(fetchHttp, () => ({ ...DEFAULT_OPENAI, apiKey: KEY }), Math.random);
const result = await t.transcribe(audio, 'audio/mp4', new AbortController().signal);
expect(result).toMatchObject({ ok: true });
const text = result.ok ? result.text.toLowerCase() : '';
expect(text.trim()).not.toBe('');
const hits = WORDS.filter((w) => text.includes(w));
expect(hits.length, `распознано: ${text}`).toBeGreaterThanOrEqual(4);
});
});

View File

@ -5,6 +5,7 @@ import {
formatDuration,
formatLocalTime,
makeId,
mimeFromExt,
notePath,
} from '../src/core/naming';
@ -77,3 +78,18 @@ describe('formatting', () => {
expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17');
});
});
describe('mimeFromExt', () => {
it.each([
['m4a', 'audio/mp4'],
['M4A', 'audio/mp4'],
['webm', 'audio/webm'],
['ogg', 'audio/ogg'],
])('%s → %s', (ext, mime) => {
expect(mimeFromExt(ext)).toBe(mime);
});
it.each(['bin', 'mp3', '', 'md'])('%j → null', (ext) => {
expect(mimeFromExt(ext)).toBeNull();
});
});

View File

@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import { redact } from '../src/core/openai';
import { DEFAULT_OPENAI, OpenAiTranscriber, redact } from '../src/core/openai';
import type { HttpRequest } from '../src/core/ports';
import { FakeHttp } from './fakes';
const KEY = 'sk-test-123';
@ -38,3 +40,45 @@ describe('redact', () => {
expect(out).not.toContain('sk-');
});
});
describe('secrets never reach the reason (end to end)', () => {
const audio = () => new Uint8Array([1, 2, 3]).buffer;
const run = async (reply: ConstructorParameters<typeof FakeHttp>[0]) => {
const http = new FakeHttp(reply);
const t = new OpenAiTranscriber(http, () => ({ ...DEFAULT_OPENAI, apiKey: KEY }), () => 0);
const result = await t.transcribe(audio(), 'audio/mp4', new AbortController().signal);
if (result.ok) throw new Error('expected an error');
return { message: result.message, req: http.requests[0] };
};
const echo = (status: number) => (req: HttpRequest) => ({ status, text: new TextDecoder().decode(req.body) });
it('401 JSON echoing the key and Authorization header', async () => {
const body = JSON.stringify({
error: { message: `Incorrect API key provided: ${KEY}. Authorization: Bearer ${KEY}` },
});
const { message } = await run(() => ({ status: 401, text: body }));
expect(message).toBe('HTTP 401: Incorrect API key provided: ***. Authorization: Bearer ***');
});
it('401 plain text echoing the Authorization header', async () => {
const { message } = await run(() => ({ status: 401, text: `Authorization: Bearer ${KEY}` }));
expect(message).not.toContain(KEY);
expect(message).toBe('HTTP 401: Authorization: Bearer ***');
});
it('network exception mentioning the key', async () => {
const { message } = await run(() =>
Promise.reject(new Error(`connect failed for Bearer ${KEY} at https://x/?key=${KEY}`)),
);
expect(message).not.toContain(KEY);
expect(message).toBe('сеть: connect failed for Bearer *** at https://x/?key=***');
});
it.each([200, 400, 401, 500])('service echoing the request body with status %i', async (status) => {
const { message, req } = await run(echo(status));
const boundary = /boundary=(.+)$/.exec(req.contentType)![1];
expect(message).not.toContain(KEY);
expect(message).not.toContain(boundary);
expect(message.toLowerCase()).not.toContain('content-disposition');
});
});

View File

@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
import {
CANCELLED,
DEFAULT_OPENAI,
EMPTY_AUDIO,
NO_API_KEY,
NO_BASE_URL,
OpenAiTranscriber,
type OpenAiSettings,
} from '../src/core/openai';
import { bytesIndexOf, FakeHttp, formParts } from './fakes';
const KEY = 'sk-test-123';
const settings = (over: Partial<OpenAiSettings> = {}) => () => ({ ...DEFAULT_OPENAI, apiKey: KEY, ...over });
const audio = () => new Uint8Array([0x00, 0xff, 0x80, 0x0d, 0x0a]).buffer;
const live = () => new AbortController().signal;
async function send(over: Partial<OpenAiSettings> = {}, mime = 'audio/mp4') {
const http = new FakeHttp();
const result = await new OpenAiTranscriber(http, settings(over), () => 0).transcribe(audio(), mime, live());
return { http, result, req: http.requests[0] };
}
describe('request', () => {
it.each([
'https://api.aitunnel.ru/v1',
'https://api.aitunnel.ru/v1/',
'https://api.aitunnel.ru/v1//',
' https://api.aitunnel.ru/v1/ ',
])('posts to <baseUrl>/audio/transcriptions for %j', async (baseUrl) => {
const { req } = await send({ baseUrl });
expect(req.url).toBe('https://api.aitunnel.ru/v1/audio/transcriptions');
});
it('trims key with newline and sends only Authorization in headers', async () => {
const { req } = await send({ apiKey: ` ${KEY} \n` });
expect(req.headers).toEqual({ Authorization: `Bearer ${KEY}` });
});
it('passes multipart contentType separately, boundary from random() matches the body', async () => {
const { req } = await send();
const boundary = `----voice-note-${'0'.repeat(24)}`;
expect(req.contentType).toBe(`multipart/form-data; boundary=${boundary}`);
expect(new TextDecoder().decode(req.body).startsWith(`--${boundary}\r\n`)).toBe(true);
});
it('sends model, response_format=json and no language by default', async () => {
const { req } = await send();
expect(formParts(req)).toEqual({ model: 'gpt-4o-transcribe', response_format: 'json', file: 'audio.m4a|audio/mp4' });
});
it('sends trimmed model and language when set', async () => {
const { req } = await send({ model: ' whisper-1 ', language: ' ru ' });
expect(formParts(req)).toMatchObject({ model: 'whisper-1', language: 'ru' });
});
it.each([
['audio/mp4;codecs=mp4a.40.2', 'audio.m4a|audio/mp4'],
['audio/webm;codecs=opus', 'audio.webm|audio/webm'],
['audio/ogg', 'audio.ogg|audio/ogg'],
['AUDIO/MP4', 'audio.m4a|audio/mp4'],
['audio/x-foo', 'audio.bin|audio/x-foo'],
['', 'audio.bin|application/octet-stream'],
])('names the file for mime %j', async (mime, expected) => {
const { req } = await send({}, mime);
expect(formParts(req).file).toBe(expected);
});
it('sends audio bytes unchanged', async () => {
const { req } = await send();
expect(bytesIndexOf(new Uint8Array(req.body), new Uint8Array(audio()))).toBeGreaterThan(0);
});
it('reads settings on every call (fixed key applies without restart)', async () => {
const http = new FakeHttp();
const current: OpenAiSettings = { ...DEFAULT_OPENAI, apiKey: 'sk-test-old' };
const t = new OpenAiTranscriber(http, () => current, () => 0);
await t.transcribe(audio(), 'audio/mp4', live());
current.apiKey = 'sk-test-new';
await t.transcribe(audio(), 'audio/mp4', live());
expect(http.requests.map((r) => r.headers.Authorization)).toEqual(['Bearer sk-test-old', 'Bearer sk-test-new']);
});
it('returns the service text on success', async () => {
const { result } = await send();
expect(result).toEqual({ ok: true, text: 'ok' });
});
});
describe('prechecks (no network)', () => {
const run = async (over: Partial<OpenAiSettings>, data: ArrayBuffer, signal: AbortSignal) => {
const http = new FakeHttp();
const result = await new OpenAiTranscriber(http, settings(over), () => 0).transcribe(data, 'audio/mp4', signal);
expect(http.requests).toHaveLength(0);
return result;
};
const aborted = () => {
const c = new AbortController();
c.abort();
return c.signal;
};
it.each(['', ' \n'])('empty key %j → permanent', async (apiKey) => {
expect(await run({ apiKey }, audio(), live())).toEqual({ ok: false, kind: 'permanent', message: NO_API_KEY });
});
it('empty baseUrl → permanent', async () => {
expect(await run({ baseUrl: ' ' }, audio(), live())).toEqual({ ok: false, kind: 'permanent', message: NO_BASE_URL });
});
it('0 bytes → permanent', async () => {
expect(await run({}, new ArrayBuffer(0), live())).toEqual({ ok: false, kind: 'permanent', message: EMPTY_AUDIO });
});
it('already aborted → temporary', async () => {
expect(await run({}, audio(), aborted())).toEqual({ ok: false, kind: 'temporary', message: CANCELLED });
});
it('checks in order: key, baseUrl, audio, signal', async () => {
expect(await run({ apiKey: '', baseUrl: '' }, new ArrayBuffer(0), aborted())).toMatchObject({ message: NO_API_KEY });
expect(await run({ baseUrl: '' }, new ArrayBuffer(0), aborted())).toMatchObject({ message: NO_BASE_URL });
expect(await run({}, new ArrayBuffer(0), aborted())).toMatchObject({ message: EMPTY_AUDIO });
});
});
describe('never throws', () => {
it('network rejection → temporary "сеть: …"', async () => {
const http = new FakeHttp(() => Promise.reject(new Error('net::ERR_INTERNET_DISCONNECTED')));
const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live());
expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: net::ERR_INTERNET_DISCONNECTED' });
});
it('synchronous throw from the client → temporary "сеть: …"', async () => {
const http = new FakeHttp(() => {
throw new Error('boom');
});
const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live());
expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: boom' });
});
it('non-Error rejection is stringified', async () => {
const http = new FakeHttp(() => Promise.reject('offline'));
const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live());
expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: offline' });
});
it('unexpected exception → temporary "внутренняя ошибка: …"', async () => {
const broken = () => {
throw new Error('settings exploded');
};
const result = await new OpenAiTranscriber(new FakeHttp(), broken, () => 0).transcribe(audio(), 'audio/mp4', live());
expect(result).toEqual({ ok: false, kind: 'temporary', message: 'внутренняя ошибка: settings exploded' });
});
});

View File

@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CANCELLED, DEFAULT_OPENAI, OpenAiTranscriber, REQUEST_TIMEOUT_MS, TIMED_OUT } from '../src/core/openai';
import type { HttpResponse, TranscribeResult } from '../src/core/ports';
import { SENT_STALE_MS } from '../src/core/record';
import { FakeHttp } from './fakes';
const settings = () => ({ ...DEFAULT_OPENAI, apiKey: 'sk-test-123' });
const audio = () => new Uint8Array([1, 2, 3]).buffer;
const never = () => new Promise<HttpResponse>(() => {});
describe('timeout invariant', () => {
it('is 5 minutes and below the stale-sent threshold', () => {
expect(REQUEST_TIMEOUT_MS).toBe(300_000);
expect(REQUEST_TIMEOUT_MS).toBeLessThan(SENT_STALE_MS);
});
});
describe('timeout and abort', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('gives up after exactly REQUEST_TIMEOUT_MS and leaves no timers', async () => {
const t = new OpenAiTranscriber(new FakeHttp(never), settings, () => 0);
let done: TranscribeResult | undefined;
const p = t.transcribe(audio(), 'audio/mp4', new AbortController().signal).then((r) => (done = r));
await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS - 1);
expect(done).toBeUndefined();
await vi.advanceTimersByTimeAsync(1);
await p;
expect(done).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT });
expect(vi.getTimerCount()).toBe(0);
});
it('returns "cancelled" right away when aborted in flight', async () => {
const c = new AbortController();
const remove = vi.spyOn(c.signal, 'removeEventListener');
const p = new OpenAiTranscriber(new FakeHttp(never), settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal);
await vi.advanceTimersByTimeAsync(1000);
c.abort();
expect(await p).toEqual({ ok: false, kind: 'temporary', message: CANCELLED });
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
});
it('clears the timer and the abort listener after a normal answer', async () => {
const c = new AbortController();
const remove = vi.spyOn(c.signal, 'removeEventListener');
const result = await new OpenAiTranscriber(new FakeHttp(), settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal);
expect(result).toEqual({ ok: true, text: 'ok' });
expect(vi.getTimerCount()).toBe(0);
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
});
it('ignores a late answer after timeout', async () => {
let resolve!: (r: HttpResponse) => void;
const http = new FakeHttp(() => new Promise<HttpResponse>((r) => (resolve = r)));
const p = new OpenAiTranscriber(http, settings, () => 0).transcribe(audio(), 'audio/mp4', new AbortController().signal);
await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS);
expect(await p).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT });
resolve({ status: 200, text: '{"text":"late"}' });
await vi.advanceTimersByTimeAsync(0);
expect(await p).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT });
});
it('does not leak an unhandled rejection from a late failure', async () => {
const unhandled: unknown[] = [];
const onUnhandled = (e: unknown) => unhandled.push(e);
process.on('unhandledRejection', onUnhandled);
try {
let reject!: (e: unknown) => void;
const http = new FakeHttp(() => new Promise<HttpResponse>((_, r) => (reject = r)));
const c = new AbortController();
const p = new OpenAiTranscriber(http, settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal);
c.abort();
expect(await p).toEqual({ ok: false, kind: 'temporary', message: CANCELLED });
reject(new Error('late failure'));
vi.useRealTimers();
await new Promise((r) => setTimeout(r, 10));
expect(unhandled).toEqual([]);
} finally {
process.off('unhandledRejection', onUnhandled);
}
});
});

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { checkNotice, pickCheckAudio } from '../src/core/provider-check';
describe('pickCheckAudio', () => {
it('picks the last audio file by name directly in voice/source/', () => {
expect(
pickCheckAudio([
'voice/source/20260927-111503-0000.m4a',
'voice/source/20260927-120000-abcd.webm',
'voice/source/20260926-235959-ffff.ogg',
'voice/inbox/2026-09-27 15-00-00.md',
]),
).toEqual({ path: 'voice/source/20260927-120000-abcd.webm', mime: 'audio/webm' });
});
it('ignores .bin, unknown extensions, subfolders and look-alike folders', () => {
expect(
pickCheckAudio([
'voice/source/20260927-111503-0000.m4a',
'voice/source/20260928-000000-0000.bin',
'voice/source/20260928-000000-0001.mp3',
'voice/source/old/20260929-000000-0000.m4a',
'voice/sources/20260929-000000-0000.m4a',
'voice/source/noext',
]),
).toEqual({ path: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/mp4' });
});
it('returns null when nothing fits', () => {
expect(pickCheckAudio([])).toBeNull();
expect(pickCheckAudio(['voice/source/a.bin', 'notes/x.m4a'])).toBeNull();
});
});
describe('checkNotice', () => {
it('shows the start of the text', () => {
expect(checkNotice({ ok: true, text: ' Привет, мир ' })).toBe('Провайдер работает: «Привет, мир»');
});
it('cuts long text to 100 chars with an ellipsis', () => {
expect(checkNotice({ ok: true, text: 'а'.repeat(150) })).toBe(`Провайдер работает: «${'а'.repeat(100)}…»`);
});
it('reports no speech for empty text', () => {
expect(checkNotice({ ok: true, text: ' ' })).toBe('Провайдер работает, но речь в записи не распознана');
});
it('shows kind and reason of an error', () => {
expect(checkNotice({ ok: false, kind: 'temporary', message: 'HTTP 429' })).toBe(
'Провайдер: временная ошибка — HTTP 429',
);
expect(checkNotice({ ok: false, kind: 'permanent', message: 'HTTP 401: Invalid API key' })).toBe(
'Провайдер: постоянная ошибка — HTTP 401: Invalid API key',
);
});
});