/** * 文字おこしくん HTTP API の最小のクライアント(依存なし・1 ファイル)。 * * Node 22.18 以降(型を取り除いてそのまま実行できる)/ Deno / Bun / tsx で動く。ブラウザでは使わない * (API キーはサーバー側にだけ置く。ブラウザには mintReadToken で発行したセッショントークンだけを渡す)。 * * const api = createMojiokoshiHttp({ apiKey: process.env.MOJIOKOSHI_API_KEY! }); * const { sessions } = await api.listSessions({ status: "active", limit: 5 }); * * 守っている決まり: エラーは problem details の code で投げ分ける / 429 では Retry-After の秒数だけ待ってやり直す * (quota_exceeded は翌日まで戻らないのでやり直さない)/ トークンの発行は Idempotency-Key 付きで、やり直しでも同じキーを使う。 * 仕様: https://moji.kaitoranma.com/docs/openapi.yaml */ export const DEFAULT_BASE_URL = "https://moji.kaitoranma.com"; export interface MojiokoshiHttpOptions { /** API キー(mjk_live_…)。 */ readonly apiKey: string; /** 既定 https://moji.kaitoranma.com(末尾の / は無くてよい)。セルフホストのサーバーならその URL。 */ readonly baseUrl?: string | undefined; /** 差し替え用(テストなど)。既定は globalThis.fetch。 */ readonly fetch?: typeof fetch; /** 429 rate_limited のときに Retry-After だけ待ってやり直す回数。既定 2。 */ readonly maxRetries?: number; readonly sleep?: (ms: number) => Promise; } /** RFC 9457 の problem details(HTTP のエラーの本文)。 */ export interface Problem { readonly type: string; readonly title: string; readonly status: number; readonly detail: string; readonly code: string; readonly retryAfterSeconds?: number; readonly [member: string]: unknown; } export class MojiokoshiHttpError extends Error { readonly status: number; /** problem details の code(本文が problem でなければ "http_<ステータス>")。 */ readonly code: string; readonly problem: Problem | undefined; constructor(status: number, problem: Problem | undefined) { super(problem !== undefined ? `${problem.code}: ${problem.detail}` : `HTTP ${status}`); this.name = "MojiokoshiHttpError"; this.status = status; this.code = problem?.code ?? `http_${status}`; this.problem = problem; } } export interface SessionSummary { readonly sessionId: string; readonly label: string; readonly externalRef?: string; readonly tenantId?: string; readonly createdAt: number; readonly endedAt?: number; readonly live: boolean; } export interface SessionList { readonly sessions: readonly SessionSummary[]; readonly nextCursor?: string; } export interface SessionDetail extends SessionSummary { readonly ingestConnected: boolean; readonly viewers: number; readonly audioSeconds: number; readonly retainUntil?: number; } export interface IssuedToken { readonly token: string; readonly expiresAt: number; } export interface Transcript { readonly sessionId: string; readonly label: string; readonly speakers: readonly { readonly speakerId: string; readonly displayName: string; readonly avatarUrl?: string }[]; readonly utterances: readonly { readonly utteranceId: string; readonly trackId: string; readonly speakerId?: string; readonly speakerName: string; readonly stage: "final" | "refined"; readonly text: string; readonly rawText: string; readonly startedAt: number; }[]; } export interface UsageReport { readonly tenantId: string; readonly plan: string; readonly from: string; readonly to: string; readonly days: readonly { readonly date: string; readonly audioSeconds: number }[]; readonly totalAudioSeconds: number; readonly today: { readonly date: string; readonly audioSeconds: number; /** null なら 1 日の上限なし(公式のサーバーはこちら)。 */ readonly limitAudioSeconds: number | null; readonly remainingAudioSeconds: number | null; readonly exceeded: boolean; readonly resetsAt: number; }; } export interface ListSessionsQuery { readonly status?: "active" | "ended"; /** 1〜100(既定 20)。 */ readonly limit?: number; readonly cursor?: string; } export function createMojiokoshiHttp(options: MojiokoshiHttpOptions) { const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); const doFetch = options.fetch ?? ((input: string | URL | Request, init?: RequestInit) => globalThis.fetch(input, init)); const maxRetries = options.maxRetries ?? 2; const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); async function request(method: string, path: string, body?: unknown, headers: Record = {}): Promise { for (let attempt = 0; ; attempt++) { const response = await doFetch(`${baseUrl}${path}`, { method, headers: { authorization: `Bearer ${options.apiKey}`, ...(body !== undefined ? { "content-type": "application/json" } : {}), ...headers, }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); if (response.ok) return response; const problem = parseProblem(await response.text()); const retryAfter = Number(response.headers.get("retry-after")); // quota_exceeded も 429 だが、次の UTC 0 時まで戻らないので待たない。 if (response.status === 429 && problem?.code === "rate_limited" && attempt < maxRetries && retryAfter > 0) { await sleep(retryAfter * 1000); continue; } throw new MojiokoshiHttpError(response.status, problem); } } const json = async (method: string, path: string, body?: unknown, headers?: Record): Promise => (await (await request(method, path, body, headers)).json()) as T; /** 自分のテナントのセッション(新しい順)。transcripts:read。 */ function listSessions(query: ListSessionsQuery = {}): Promise { const params = new URLSearchParams(); if (query.status !== undefined) params.set("status", query.status); if (query.limit !== undefined) params.set("limit", String(query.limit)); if (query.cursor !== undefined) params.set("cursor", query.cursor); return json("GET", `/v1/sessions${querySuffix(params)}`); } return { listSessions, /** nextCursor をたどってすべてのページを読む。 */ async *allSessions(query: Omit = {}): AsyncGenerator { let cursor: string | undefined; do { const page = await listSessions(cursor === undefined ? query : { ...query, cursor }); yield* page.sessions; cursor = page.nextCursor; } while (cursor !== undefined); }, /** セッションの状態。transcripts:read。 */ getSession(sessionId: string): Promise { return json("GET", `/v1/sessions/${encodeURIComponent(sessionId)}`); }, /** 書き出し(JSON)。transcripts:read。 */ exportTranscript(sessionId: string): Promise { return json("GET", `/v1/sessions/${encodeURIComponent(sessionId)}/transcript`); }, /** 書き出し(1 発話 1 行のテキスト)。tz は IANA のタイムゾーン名。 */ async exportTranscriptText(sessionId: string, tz = "Asia/Tokyo"): Promise { const path = `/v1/sessions/${encodeURIComponent(sessionId)}/transcript?format=text&tz=${encodeURIComponent(tz)}`; return (await request("GET", path)).text(); }, /** * ブラウザ・OBS・ボットに渡す transcript:read のセッショントークン(既定 1 時間、最大 24 時間)。transcripts:read。 * やり直しても二重に発行しないよう、Idempotency-Key を付ける(同じ呼び出しの中のやり直しは同じキー)。 */ mintReadToken(sessionId: string, ttlSeconds = 3600): Promise { return json( "POST", `/v1/sessions/${encodeURIComponent(sessionId)}/tokens`, { scope: "transcript:read", ttlSeconds }, { "idempotency-key": globalThis.crypto.randomUUID() }, ); }, /** 日ごとの利用量と今日の上限。usage:read。from / to は UTC の YYYY-MM-DD(省くと今日)。 */ getUsage(range: { readonly from?: string; readonly to?: string } = {}): Promise { const params = new URLSearchParams(); if (range.from !== undefined) params.set("from", range.from); if (range.to !== undefined) params.set("to", range.to); return json("GET", `/v1/usage${querySuffix(params)}`); }, /** Subscribe の WebSocket の URL(https → wss)。 */ subscribeUrl(): string { return `${baseUrl.replace(/^http/, "ws")}/v1/subscribe`; }, }; } export type MojiokoshiHttp = ReturnType; function querySuffix(params: URLSearchParams): string { const query = params.toString(); return query === "" ? "" : `?${query}`; } function parseProblem(text: string): Problem | undefined { try { const value: unknown = JSON.parse(text); return typeof value === "object" && value !== null && typeof (value as { code?: unknown }).code === "string" ? (value as Problem) : undefined; } catch { return undefined; } }