@open-everywhere/adapters 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/agent/meetings/native-http.ts +72 -0
  2. package/agent/meetings/native-meet.ts +173 -0
  3. package/agent/meetings/native-sources.ts +14 -0
  4. package/agent/meetings/native-teams.ts +119 -0
  5. package/agent/meetings/native-zoom.ts +98 -0
  6. package/agent/model/gateway.ts +44 -4
  7. package/agent/model/meeting-gateway.ts +88 -0
  8. package/agent/model/transcription-routes.ts +347 -0
  9. package/agent/register-defaults.ts +11 -0
  10. package/agent/sandbox/long-lived-box.ts +464 -0
  11. package/dist/agent/meetings/native-http.d.ts +22 -0
  12. package/dist/agent/meetings/native-http.d.ts.map +1 -0
  13. package/dist/agent/meetings/native-http.js +61 -0
  14. package/dist/agent/meetings/native-http.js.map +1 -0
  15. package/dist/agent/meetings/native-meet.d.ts +6 -0
  16. package/dist/agent/meetings/native-meet.d.ts.map +1 -0
  17. package/dist/agent/meetings/native-meet.js +176 -0
  18. package/dist/agent/meetings/native-meet.js.map +1 -0
  19. package/dist/agent/meetings/native-sources.d.ts +2 -0
  20. package/dist/agent/meetings/native-sources.d.ts.map +1 -0
  21. package/dist/agent/meetings/native-sources.js +13 -0
  22. package/dist/agent/meetings/native-sources.js.map +1 -0
  23. package/dist/agent/meetings/native-teams.d.ts +6 -0
  24. package/dist/agent/meetings/native-teams.d.ts.map +1 -0
  25. package/dist/agent/meetings/native-teams.js +118 -0
  26. package/dist/agent/meetings/native-teams.js.map +1 -0
  27. package/dist/agent/meetings/native-zoom.d.ts +7 -0
  28. package/dist/agent/meetings/native-zoom.d.ts.map +1 -0
  29. package/dist/agent/meetings/native-zoom.js +98 -0
  30. package/dist/agent/meetings/native-zoom.js.map +1 -0
  31. package/dist/agent/model/gateway.d.ts +10 -0
  32. package/dist/agent/model/gateway.d.ts.map +1 -1
  33. package/dist/agent/model/gateway.js +43 -5
  34. package/dist/agent/model/gateway.js.map +1 -1
  35. package/dist/agent/model/meeting-gateway.d.ts +24 -0
  36. package/dist/agent/model/meeting-gateway.d.ts.map +1 -0
  37. package/dist/agent/model/meeting-gateway.js +75 -0
  38. package/dist/agent/model/meeting-gateway.js.map +1 -0
  39. package/dist/agent/model/transcription-routes.d.ts +89 -0
  40. package/dist/agent/model/transcription-routes.d.ts.map +1 -0
  41. package/dist/agent/model/transcription-routes.js +279 -0
  42. package/dist/agent/model/transcription-routes.js.map +1 -0
  43. package/dist/agent/register-defaults.d.ts.map +1 -1
  44. package/dist/agent/register-defaults.js +9 -0
  45. package/dist/agent/register-defaults.js.map +1 -1
  46. package/dist/agent/sandbox/long-lived-box.d.ts +70 -0
  47. package/dist/agent/sandbox/long-lived-box.d.ts.map +1 -0
  48. package/dist/agent/sandbox/long-lived-box.js +414 -0
  49. package/dist/agent/sandbox/long-lived-box.js.map +1 -0
  50. package/package.json +38 -2
@@ -0,0 +1,72 @@
1
+ // Shared plumbing for the native-transcript readers (OPE-2471): an injectable fetch, a capped body read, and
2
+ // a small token cache. Every reader is host code that calls one vendor API with the organisation's own
3
+ // credential; nothing here enters a sandbox, and no response text is ever echoed into a thread.
4
+
5
+ /** The fetch a reader uses. Injected in tests with recorded fixtures; the live one is the global fetch. */
6
+ export type NativeFetch = (url: string, init?: RequestInit) => Promise<Response>;
7
+
8
+ /** How long ONE vendor call may take. A call that runs out throws, and core counts the read as "the platform
9
+ * did not answer": OE checks again on the next sweep. It bounds each call only: a read makes up to a few calls
10
+ * in sequence, and the meeting sweep bounds a whole tick with its own time budget (meetings/native-recap.ts). */
11
+ const NATIVE_CALL_TIMEOUT_MS = 30_000;
12
+
13
+ /** The live fetch every reader uses by default: the global fetch with a per-call timeout. */
14
+ export const timedFetch: NativeFetch = (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(NATIVE_CALL_TIMEOUT_MS) });
15
+
16
+ /** The largest body a reader accepts: a three-hour transcript is well under 2 MB. */
17
+ const MAX_NATIVE_BODY_BYTES = 8 * 1024 * 1024;
18
+
19
+ /** Read a response body as text, refusing one larger than the cap. */
20
+ export async function readCappedText(res: Response): Promise<string> {
21
+ const declared = Number(res.headers.get("content-length") ?? "0");
22
+ if (Number.isFinite(declared) && declared > MAX_NATIVE_BODY_BYTES) throw new Error(`response body is ${declared} bytes, over the ${MAX_NATIVE_BODY_BYTES}-byte cap`);
23
+ const text = await res.text();
24
+ if (text.length > MAX_NATIVE_BODY_BYTES) throw new Error(`response body is over the ${MAX_NATIVE_BODY_BYTES}-byte cap`);
25
+ return text;
26
+ }
27
+
28
+ /** Read a JSON body, or `{}` when it does not parse (an error body need not be JSON). */
29
+ export async function readJsonLoose(res: Response): Promise<Record<string, unknown>> {
30
+ try {
31
+ const v = JSON.parse(await readCappedText(res)) as unknown;
32
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ /** A thrown error a reader turns into "the platform did not answer, check again later". Never echoes a body. */
39
+ export function unexpectedStatus(what: string, status: number): Error {
40
+ return new Error(`${what} answered HTTP ${status}`);
41
+ }
42
+
43
+ interface CachedToken {
44
+ readonly token: string;
45
+ readonly expiresAt: number;
46
+ }
47
+
48
+ /**
49
+ * A per-process access-token cache, keyed by the caller (credential + subject). A token is reused until one
50
+ * minute before it expires. The key never holds the secret itself: callers pass a non-secret key.
51
+ */
52
+ export class TokenCache {
53
+ readonly #tokens = new Map<string, CachedToken>();
54
+ get(key: string, now: number): string | undefined {
55
+ const t = this.#tokens.get(key);
56
+ return t !== undefined && t.expiresAt - 60_000 > now ? t.token : undefined;
57
+ }
58
+ set(key: string, token: string, expiresInSeconds: unknown, now: number): void {
59
+ const s = typeof expiresInSeconds === "number" && Number.isFinite(expiresInSeconds) && expiresInSeconds > 0 ? expiresInSeconds : 300;
60
+ this.#tokens.set(key, { token, expiresAt: now + s * 1000 });
61
+ }
62
+ }
63
+
64
+ /** True when `raw` is an https URL on `host` or one of its subdomains. A reader sends its token only there. */
65
+ export function onHost(raw: string, host: string): boolean {
66
+ try {
67
+ const u = new URL(raw);
68
+ return u.protocol === "https:" && u.port === "" && (u.hostname === host || u.hostname.endsWith(`.${host}`));
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
@@ -0,0 +1,173 @@
1
+ // The Google Meet native-transcript reader (OPE-2471): the Meet REST API v2, read through the organisation's
2
+ // service account with domain-wide delegation, acting as the meeting's organiser.
3
+ //
4
+ // 1. token: a JWT signed with the service account's key (RS256), `sub` = the organiser, scope
5
+ // `meetings.space.readonly`, exchanged at oauth2.googleapis.com/token
6
+ // 2. records: GET /v2/conferenceRecords?filter=space.meeting_code="<code>" — one per session
7
+ // 3. transcripts: GET /v2/<record>/transcripts — state STARTED, ENDED or FILE_GENERATED
8
+ // 4. entries: GET /v2/<transcript>/entries — the spoken lines, each naming a participant
9
+ // 5. names: GET /v2/<record>/participants — the participants' display names
10
+ //
11
+ // WHAT THE ADMIN GRANTS: the Google Meet REST API on the service account's Cloud project, and domain-wide
12
+ // delegation of `https://www.googleapis.com/auth/meetings.space.readonly` to the service account's client id
13
+ // in the Workspace Admin console. The meeting needs transcription on. Google keeps transcript entries for 30
14
+ // days after the conference ends, so an older record is `expired`.
15
+
16
+ import { createSign } from "node:crypto";
17
+ import type { NativeTranscriptResult, NativeTranscriptSource, NativeMeetingRef, NativeReadContext, NativeTranscriptSegment } from "@open-everywhere/core/agent/adapter-api.js";
18
+ import { TokenCache, onHost, readJsonLoose, unexpectedStatus, type NativeFetch, timedFetch } from "./native-http.js";
19
+
20
+ const MEET = "https://meet.googleapis.com/v2";
21
+ const TOKEN_URI = "https://oauth2.googleapis.com/token";
22
+ export const MEET_SCOPE = "https://www.googleapis.com/auth/meetings.space.readonly";
23
+ const MAX_PAGES = 100;
24
+
25
+ export const MEET_ACCESS_HINT =
26
+ "In the Google Workspace Admin console, grant the service account's client id domain-wide delegation of https://www.googleapis.com/auth/meetings.space.readonly, and enable the Google Meet REST API in its Cloud project.";
27
+
28
+ const b64url = (v: string | Buffer): string => Buffer.from(v).toString("base64url");
29
+
30
+ /** The signed JWT-bearer assertion for one organiser. The test verifies its signature from the token request. */
31
+ function meetAssertion(key: { readonly client_email: string; readonly private_key: string }, subject: string, nowMs: number): string {
32
+ const iat = Math.floor(nowMs / 1000);
33
+ const head = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
34
+ const claims = b64url(JSON.stringify({ iss: key.client_email, sub: subject, scope: MEET_SCOPE, aud: TOKEN_URI, iat, exp: iat + 3600 }));
35
+ const signer = createSign("RSA-SHA256");
36
+ signer.update(`${head}.${claims}`);
37
+ return `${head}.${claims}.${b64url(signer.sign(key.private_key))}`;
38
+ }
39
+
40
+ interface Record_ {
41
+ readonly name: string;
42
+ readonly startTime?: string;
43
+ readonly endTime?: string;
44
+ readonly expireTime?: string;
45
+ }
46
+
47
+ function str(o: unknown, k: string): string | undefined {
48
+ const v = (o as Record<string, unknown> | null)?.[k];
49
+ return typeof v === "string" ? v : undefined;
50
+ }
51
+
52
+ export function meetTranscriptSource(fetchImpl: NativeFetch = timedFetch, now: () => number = Date.now): NativeTranscriptSource {
53
+ const tokens = new TokenCache();
54
+
55
+ async function token(keyJson: string, subject: string): Promise<string | NativeTranscriptResult> {
56
+ let key: { client_email: string; private_key: string };
57
+ try {
58
+ const k = JSON.parse(keyJson) as Record<string, unknown>;
59
+ if (typeof k.client_email !== "string" || typeof k.private_key !== "string") throw new Error("shape");
60
+ key = { client_email: k.client_email, private_key: k.private_key };
61
+ } catch {
62
+ return { status: "refused", reason: "credential", hint: "The stored service-account key is not a Google JSON key file." };
63
+ }
64
+ const cacheKey = `${key.client_email}\n${subject}`;
65
+ const cached = tokens.get(cacheKey, now());
66
+ if (cached !== undefined) return cached;
67
+ let assertion: string;
68
+ try {
69
+ assertion = meetAssertion(key, subject, now());
70
+ } catch {
71
+ return { status: "refused", reason: "credential", hint: "The stored service-account key's private key does not sign." };
72
+ }
73
+ const res = await fetchImpl(TOKEN_URI, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/x-www-form-urlencoded" },
76
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }).toString(),
77
+ });
78
+ const body = await readJsonLoose(res);
79
+ if (!res.ok) {
80
+ // `unauthorized_client`: the delegation for this scope is not granted. Anything else on a 4xx is the key.
81
+ if (body.error === "unauthorized_client") return { status: "refused", reason: "access-off", hint: MEET_ACCESS_HINT };
82
+ if (res.status >= 400 && res.status < 500) return { status: "refused", reason: "credential", hint: "Google refused the service account's key, or the organiser is not a user in the Workspace." };
83
+ throw unexpectedStatus("the Google token endpoint", res.status);
84
+ }
85
+ if (typeof body.access_token !== "string") throw unexpectedStatus("the Google token endpoint", res.status);
86
+ tokens.set(cacheKey, body.access_token, body.expires_in, now());
87
+ return body.access_token;
88
+ }
89
+
90
+ type Got = { readonly ok: true; readonly body: Record<string, unknown> } | { readonly ok: false; readonly result: NativeTranscriptResult };
91
+
92
+ async function get(url: string, bearer: string, what: string): Promise<Got> {
93
+ if (!onHost(url, "meet.googleapis.com")) throw new Error(`${what}: refusing to send the token off meet.googleapis.com`);
94
+ const res = await fetchImpl(url, { headers: { authorization: `Bearer ${bearer}` } });
95
+ if (res.status === 401) return { ok: false, result: { status: "refused", reason: "credential", hint: "Google refused the service account's token." } };
96
+ if (res.status === 403) return { ok: false, result: { status: "refused", reason: "access-off", hint: MEET_ACCESS_HINT } };
97
+ if (res.status === 404) return { ok: false, result: { status: "refused", reason: "expired" } };
98
+ if (!res.ok) throw unexpectedStatus(what, res.status);
99
+ return { ok: true, body: await readJsonLoose(res) };
100
+ }
101
+
102
+ /** Every page of a list, by `nextPageToken`. A refusal on any page is the answer. */
103
+ async function list(base: string, field: string, bearer: string, what: string): Promise<unknown[] | NativeTranscriptResult> {
104
+ const out: unknown[] = [];
105
+ let pageToken: string | undefined;
106
+ for (let page = 0; page < MAX_PAGES; page++) {
107
+ const sep = base.includes("?") ? "&" : "?";
108
+ const got = await get(`${base}${sep}pageSize=100${pageToken !== undefined ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`, bearer, what);
109
+ if (!got.ok) return got.result;
110
+ const items = got.body[field];
111
+ if (Array.isArray(items)) out.push(...items);
112
+ pageToken = str(got.body, "nextPageToken");
113
+ if (pageToken === undefined || pageToken === "") break;
114
+ }
115
+ return out;
116
+ }
117
+
118
+ return {
119
+ platform: "meet",
120
+ disclosure: "OE reads the transcript Google Meet made, through the Meet REST API, with the organisation's own service account acting as the organiser (domain-wide delegation, read-only).",
121
+ async read(ref: NativeMeetingRef, ctx: NativeReadContext): Promise<NativeTranscriptResult> {
122
+ const m = ctx.policy.meet;
123
+ if (m === undefined) return { status: "refused", reason: "credential", hint: "The meeting policy declares no Google Meet credential." };
124
+ const subject = ref.organiserEmail;
125
+ if (subject === undefined || !m.domains.includes(subject.slice(subject.lastIndexOf("@") + 1))) return { status: "refused", reason: "not-found" };
126
+ const tok = await token(await ctx.secret(m.serviceAccountKeyRef), subject);
127
+ if (typeof tok !== "string") return tok;
128
+
129
+ const filter = `space.meeting_code = "${ref.roomId}"`;
130
+ const listed = await list(`${MEET}/conferenceRecords?filter=${encodeURIComponent(filter)}`, "conferenceRecords", tok, "the Meet conference list");
131
+ if (!Array.isArray(listed)) return listed.status === "refused" && listed.reason === "expired" ? { status: "refused", reason: "not-found" } : listed;
132
+ const records: Record_[] = listed
133
+ .map((r) => ({ name: str(r, "name") ?? "", ...(str(r, "startTime") ? { startTime: str(r, "startTime")! } : {}), ...(str(r, "endTime") ? { endTime: str(r, "endTime")! } : {}), ...(str(r, "expireTime") ? { expireTime: str(r, "expireTime")! } : {}) }))
134
+ .filter((r) => r.name.startsWith("conferenceRecords/") && (ref.startedAfter === undefined || Date.parse(r.startTime ?? "") >= ref.startedAfter))
135
+ .sort((a, b) => Date.parse(b.startTime ?? "") - Date.parse(a.startTime ?? ""));
136
+ const rec = records[0];
137
+ // No session yet, or the session is on now.
138
+ if (rec === undefined || rec.endTime === undefined) return { status: "pending" };
139
+ if (rec.expireTime !== undefined && Date.parse(rec.expireTime) <= now()) return { status: "refused", reason: "expired" };
140
+
141
+ const transcripts = await list(`${MEET}/${rec.name}/transcripts`, "transcripts", tok, "the Meet transcript list");
142
+ if (!Array.isArray(transcripts)) return transcripts;
143
+ if (transcripts.length === 0) return { status: "refused", reason: "not-transcribed" };
144
+ const done = transcripts.filter((t) => str(t, "state") === "ENDED" || str(t, "state") === "FILE_GENERATED");
145
+ if (done.length < transcripts.length) return { status: "pending" };
146
+
147
+ const people = await list(`${MEET}/${rec.name}/participants`, "participants", tok, "the Meet participant list");
148
+ if (!Array.isArray(people)) return people;
149
+ const names = new Map<string, string>();
150
+ for (const p of people) {
151
+ const o = p as Record<string, unknown>;
152
+ const who = str(o.signedinUser, "displayName") ?? str(o.anonymousUser, "displayName") ?? str(o.phoneUser, "displayName");
153
+ const name = str(o, "name");
154
+ if (name !== undefined && who !== undefined) names.set(name, who);
155
+ }
156
+
157
+ const start = Date.parse(rec.startTime ?? "");
158
+ const segments: NativeTranscriptSegment[] = [];
159
+ for (const t of done) {
160
+ const entries = await list(`${MEET}/${str(t, "name")}/entries`, "transcriptEntries", tok, "the Meet transcript entries");
161
+ if (!Array.isArray(entries)) return entries;
162
+ for (const e of entries) {
163
+ const text = str(e, "text");
164
+ if (text === undefined) continue;
165
+ const at = Date.parse(str(e, "startTime") ?? "");
166
+ segments.push({ speaker: names.get(str(e, "participant") ?? "") ?? "", text, ...(Number.isFinite(at) && Number.isFinite(start) ? { startMs: Math.max(0, at - start) } : {}) });
167
+ }
168
+ }
169
+ segments.sort((a, b) => (a.startMs ?? 0) - (b.startMs ?? 0));
170
+ return { status: "ready", segments, ...(rec.startTime !== undefined ? { startedAt: rec.startTime } : {}), endedAt: rec.endTime };
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,14 @@
1
+ // Boot registration of the three native-transcript readers (OPE-2471). Each one reads only when the meeting
2
+ // policy's `native` block declares its platform and a member approves a `meeting.recap` card, so registering
3
+ // all three is harmless on a deployment that uses none.
4
+
5
+ import { registerNativeTranscriptSource } from "@open-everywhere/core/agent/adapter-api.js";
6
+ import { teamsTranscriptSource } from "./native-teams.js";
7
+ import { meetTranscriptSource } from "./native-meet.js";
8
+ import { zoomTranscriptSource } from "./native-zoom.js";
9
+
10
+ export function registerNativeTranscriptSources(): void {
11
+ registerNativeTranscriptSource(teamsTranscriptSource());
12
+ registerNativeTranscriptSource(meetTranscriptSource());
13
+ registerNativeTranscriptSource(zoomTranscriptSource());
14
+ }
@@ -0,0 +1,119 @@
1
+ // The Microsoft Teams native-transcript reader (OPE-2471): Microsoft Graph `callTranscript`, read app-only
2
+ // with the organisation's Entra app registration (client credentials).
3
+ //
4
+ // 1. token: POST login.microsoftonline.com/<tenant>/oauth2/v2.0/token (client_credentials, `.default`)
5
+ // 2. meeting: GET /users/<organiser>/onlineMeetings?$filter=JoinWebUrl eq '<link>'
6
+ // 3. transcripts: GET /users/<organiser>/onlineMeetings/<id>/transcripts
7
+ // 4. content: GET …/transcripts/<id>/content?$format=text/vtt — WebVTT with `<v Speaker>` voice spans
8
+ //
9
+ // WHAT THE ADMIN GRANTS (docs/capabilities/meetings.md says it exactly): the application permissions
10
+ // `OnlineMeetings.Read.All` and `OnlineMeetingTranscript.Read.All` with admin consent, and an application
11
+ // access policy that covers the organisers (Graph refuses app-only meeting reads without one). A 403 at any
12
+ // step is the `access-off` refusal: the grant, the policy, or the tenant's transcript switches are off.
13
+ //
14
+ // Graph names a transcript only after the meeting ends, so "no transcript yet" is `pending`, and the watch's
15
+ // deadline turns a meeting that was never transcribed into a plain sentence in the thread.
16
+
17
+ import type { NativeTranscriptResult, NativeTranscriptSource, NativeMeetingRef, NativeReadContext } from "@open-everywhere/core/agent/adapter-api.js";
18
+ import { parseWebVtt } from "@open-everywhere/core/agent/adapter-api.js";
19
+ import { TokenCache, onHost, readCappedText, readJsonLoose, unexpectedStatus, type NativeFetch, timedFetch } from "./native-http.js";
20
+
21
+ const GRAPH = "https://graph.microsoft.com/v1.0";
22
+ const LOGIN = "https://login.microsoftonline.com";
23
+ const MAX_PAGES = 10;
24
+
25
+ /** The fixed hint for a 403: what an admin grants. Never response text. */
26
+ export const TEAMS_ACCESS_HINT =
27
+ "In Microsoft Entra, grant the app OnlineMeetings.Read.All and OnlineMeetingTranscript.Read.All (application, admin consent), and add an application access policy that covers the organiser (Grant-CsApplicationAccessPolicy). The Teams meeting policy must allow transcription.";
28
+
29
+ interface Transcript {
30
+ readonly id: string;
31
+ readonly createdDateTime?: string;
32
+ readonly endDateTime?: string;
33
+ }
34
+
35
+ export function teamsTranscriptSource(fetchImpl: NativeFetch = timedFetch, now: () => number = Date.now): NativeTranscriptSource {
36
+ const tokens = new TokenCache();
37
+
38
+ async function token(tenant: string, clientId: string, secret: string): Promise<string | NativeTranscriptResult> {
39
+ const key = `${tenant}\n${clientId}`;
40
+ const cached = tokens.get(key, now());
41
+ if (cached !== undefined) return cached;
42
+ const res = await fetchImpl(`${LOGIN}/${encodeURIComponent(tenant)}/oauth2/v2.0/token`, {
43
+ method: "POST",
44
+ headers: { "content-type": "application/x-www-form-urlencoded" },
45
+ body: new URLSearchParams({ client_id: clientId, client_secret: secret, scope: "https://graph.microsoft.com/.default", grant_type: "client_credentials" }).toString(),
46
+ });
47
+ const body = await readJsonLoose(res);
48
+ if (res.status === 400 || res.status === 401) return { status: "refused", reason: "credential", hint: "Microsoft Entra refused the app's client id or client secret." };
49
+ if (!res.ok || typeof body.access_token !== "string") throw unexpectedStatus("the Entra token endpoint", res.status);
50
+ tokens.set(key, body.access_token, body.expires_in, now());
51
+ return body.access_token;
52
+ }
53
+
54
+ /** One Graph GET. A refusal comes back as a result; anything unexpected throws (checked again later). */
55
+ async function graph(url: string, bearer: string, what: string, on404: NativeTranscriptResult): Promise<Response | NativeTranscriptResult> {
56
+ if (!onHost(url, "graph.microsoft.com")) throw new Error(`${what}: refusing to send the token off graph.microsoft.com`);
57
+ const res = await fetchImpl(url, { headers: { authorization: `Bearer ${bearer}` } });
58
+ if (res.status === 401) return { status: "refused", reason: "credential", hint: "Microsoft Graph refused the app's token." };
59
+ if (res.status === 403) return { status: "refused", reason: "access-off", hint: TEAMS_ACCESS_HINT };
60
+ if (res.status === 404) return on404;
61
+ if (!res.ok) throw unexpectedStatus(what, res.status);
62
+ return res;
63
+ }
64
+
65
+ return {
66
+ platform: "teams",
67
+ disclosure: "OE reads the transcript Microsoft Teams made, through Microsoft Graph, with the organisation's own Entra app registration (not any person's account).",
68
+ async read(ref: NativeMeetingRef, ctx: NativeReadContext): Promise<NativeTranscriptResult> {
69
+ const t = ctx.policy.teams;
70
+ if (t === undefined) return { status: "refused", reason: "credential", hint: "The meeting policy declares no Teams credential." };
71
+ if (ref.tenantId === undefined || ref.organizerId === undefined || !t.tenants.includes(ref.tenantId)) return { status: "refused", reason: "not-found" };
72
+ const tok = await token(ref.tenantId, t.clientId, await ctx.secret(t.clientSecretRef));
73
+ if (typeof tok !== "string") return tok;
74
+ const user = `${GRAPH}/users/${encodeURIComponent(ref.organizerId)}/onlineMeetings`;
75
+
76
+ const filter = `JoinWebUrl eq '${ref.url.replace(/'/g, "''")}'`;
77
+ const found = await graph(`${user}?$filter=${encodeURIComponent(filter)}`, tok, "the Graph meeting lookup", { status: "refused", reason: "not-found" });
78
+ if (!(found instanceof Response)) return found;
79
+ const meetings = (await readJsonLoose(found)).value;
80
+ const meetingId = Array.isArray(meetings) && typeof (meetings[0] as { id?: unknown } | undefined)?.id === "string" ? (meetings[0] as { id: string }).id : undefined;
81
+ if (meetingId === undefined) return { status: "refused", reason: "not-found" };
82
+
83
+ const transcripts: Transcript[] = [];
84
+ let next: string | undefined = `${user}/${encodeURIComponent(meetingId)}/transcripts`;
85
+ for (let page = 0; next !== undefined && page < MAX_PAGES; page++) {
86
+ const listed = await graph(next, tok, "the Graph transcript list", { status: "refused", reason: "not-found" });
87
+ if (!(listed instanceof Response)) return listed;
88
+ const body = await readJsonLoose(listed);
89
+ for (const v of Array.isArray(body.value) ? body.value : []) {
90
+ const o = v as Record<string, unknown>;
91
+ if (typeof o.id === "string") {
92
+ transcripts.push({
93
+ id: o.id,
94
+ ...(typeof o.createdDateTime === "string" ? { createdDateTime: o.createdDateTime } : {}),
95
+ ...(typeof o.endDateTime === "string" ? { endDateTime: o.endDateTime } : {}),
96
+ });
97
+ }
98
+ }
99
+ next = typeof body["@odata.nextLink"] === "string" ? body["@odata.nextLink"] : undefined;
100
+ }
101
+ // A recurring meeting keeps one join link, so its transcripts span every occurrence: newest first, and
102
+ // only a session that started inside the ask's window.
103
+ const eligible = transcripts
104
+ .filter((x) => ref.startedAfter === undefined || (x.createdDateTime !== undefined && Date.parse(x.createdDateTime) >= ref.startedAfter))
105
+ .sort((a, b) => Date.parse(b.createdDateTime ?? "") - Date.parse(a.createdDateTime ?? ""));
106
+ const pick = eligible[0];
107
+ // No transcript yet (the meeting has not ended), or one Teams is still writing.
108
+ if (pick === undefined || pick.endDateTime === undefined) return { status: "pending" };
109
+
110
+ const content = await graph(`${user}/${encodeURIComponent(meetingId)}/transcripts/${encodeURIComponent(pick.id)}/content?$format=text/vtt`, tok, "the Graph transcript content", {
111
+ status: "refused",
112
+ reason: "expired",
113
+ });
114
+ if (!(content instanceof Response)) return content;
115
+ const segments = parseWebVtt(await readCappedText(content));
116
+ return { status: "ready", segments, ...(pick.createdDateTime !== undefined ? { startedAt: pick.createdDateTime } : {}), endedAt: pick.endDateTime };
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,98 @@
1
+ // The Zoom native-transcript reader (OPE-2471): the cloud recording's audio transcript, read with the
2
+ // organisation's own Server-to-Server OAuth app.
3
+ //
4
+ // 1. token: POST zoom.us/oauth/token?grant_type=account_credentials&account_id=<account> (Basic auth)
5
+ // 2. recording: GET api.zoom.us/v2/meetings/<meeting id>/recordings — the newest session's files
6
+ // 3. transcript: the `TRANSCRIPT` file's `download_url`, with the token — WebVTT whose cues start
7
+ // "Speaker Name: text"
8
+ //
9
+ // WHAT THE ADMIN GRANTS: a Server-to-Server OAuth app on the org's Zoom account with the scope
10
+ // `cloud_recording:read:list_recording_files:admin` (classic scope: `recording:read:admin`), and, in the
11
+ // account settings, cloud recording with "Create audio transcript" on. Zoom needs a paid plan for both.
12
+ //
13
+ // HOW READY IS LEARNED: Zoom has a `recording.transcript_completed` webhook, and it is reported unreliable;
14
+ // OE polls this endpoint instead (meetings/native-recap.ts). Zoom writes the transcript after the recording,
15
+ // so a recording without a transcript file is `pending` until a day after it ended, then `not-transcribed`.
16
+ // The token goes only to zoom.us hosts: a `download_url` anywhere else is refused, never fetched.
17
+
18
+ import type { NativeTranscriptResult, NativeTranscriptSource, NativeMeetingRef, NativeReadContext } from "@open-everywhere/core/agent/adapter-api.js";
19
+ import { parseWebVtt } from "@open-everywhere/core/agent/adapter-api.js";
20
+ import { TokenCache, onHost, readCappedText, readJsonLoose, unexpectedStatus, type NativeFetch, timedFetch } from "./native-http.js";
21
+
22
+ const API = "https://api.zoom.us/v2";
23
+ const TOKEN = "https://zoom.us/oauth/token";
24
+ /** After this long with a finished recording and no transcript file, the transcript is not coming. */
25
+ export const ZOOM_TRANSCRIPT_GRACE_MS = 24 * 60 * 60_000;
26
+
27
+ export const ZOOM_ACCESS_HINT =
28
+ "In the Zoom App Marketplace, give the Server-to-Server OAuth app the scope cloud_recording:read:list_recording_files:admin (classic: recording:read:admin), and turn on cloud recording with Create audio transcript in the account settings.";
29
+
30
+ interface RecordingFile {
31
+ readonly file_type?: string;
32
+ readonly status?: string;
33
+ readonly download_url?: string;
34
+ readonly recording_end?: string;
35
+ }
36
+
37
+ export function zoomTranscriptSource(fetchImpl: NativeFetch = timedFetch, now: () => number = Date.now): NativeTranscriptSource {
38
+ const tokens = new TokenCache();
39
+
40
+ async function token(accountId: string, clientId: string, secret: string): Promise<string | NativeTranscriptResult> {
41
+ const key = `${accountId}\n${clientId}`;
42
+ const cached = tokens.get(key, now());
43
+ if (cached !== undefined) return cached;
44
+ const res = await fetchImpl(`${TOKEN}?grant_type=account_credentials&account_id=${encodeURIComponent(accountId)}`, {
45
+ method: "POST",
46
+ headers: { authorization: `Basic ${Buffer.from(`${clientId}:${secret}`).toString("base64")}` },
47
+ });
48
+ const body = await readJsonLoose(res);
49
+ if (res.status === 400 || res.status === 401) return { status: "refused", reason: "credential", hint: "Zoom refused the Server-to-Server app's account id, client id or client secret." };
50
+ if (!res.ok || typeof body.access_token !== "string") throw unexpectedStatus("the Zoom token endpoint", res.status);
51
+ tokens.set(key, body.access_token, body.expires_in, now());
52
+ return body.access_token;
53
+ }
54
+
55
+ return {
56
+ platform: "zoom",
57
+ disclosure: "OE reads the audio transcript of the meeting's Zoom cloud recording, through the Zoom API, with the organisation's own Server-to-Server OAuth app (not any person's account).",
58
+ async read(ref: NativeMeetingRef, ctx: NativeReadContext): Promise<NativeTranscriptResult> {
59
+ const z = ctx.policy.zoom;
60
+ if (z === undefined) return { status: "refused", reason: "credential", hint: "The meeting policy declares no Zoom credential." };
61
+ const tok = await token(z.accountId, z.clientId, await ctx.secret(z.clientSecretRef));
62
+ if (typeof tok !== "string") return tok;
63
+
64
+ const res = await fetchImpl(`${API}/meetings/${encodeURIComponent(ref.roomId)}/recordings`, { headers: { authorization: `Bearer ${tok}` } });
65
+ const body = await readJsonLoose(res);
66
+ if (res.status === 401) return { status: "refused", reason: "credential", hint: "Zoom refused the app's token." };
67
+ if (res.status === 403) return { status: "refused", reason: "access-off", hint: ZOOM_ACCESS_HINT };
68
+ if (res.status === 404) {
69
+ // 3301 "This recording does not exist": no cloud recording yet (or never). 3001: no such meeting on this account.
70
+ if (body.code === 3001) return { status: "refused", reason: "not-found" };
71
+ return { status: "pending" };
72
+ }
73
+ // A missing scope can come back as a 400 (code 4711, "does not contain scopes") as well as a 403; code 200 is "no permission".
74
+ if (res.status === 400 && (body.code === 200 || body.code === 4711)) return { status: "refused", reason: "access-off", hint: ZOOM_ACCESS_HINT };
75
+ if (!res.ok) throw unexpectedStatus("the Zoom recordings endpoint", res.status);
76
+
77
+ const startedAt = typeof body.start_time === "string" ? body.start_time : undefined;
78
+ // A recurring meeting answers with its newest session: one from before the ask's window is not the meeting asked for.
79
+ if (ref.startedAfter !== undefined && (startedAt === undefined || Date.parse(startedAt) < ref.startedAfter)) return { status: "pending" };
80
+ const files = (Array.isArray(body.recording_files) ? body.recording_files : []) as RecordingFile[];
81
+ const transcript = files.find((f) => f.file_type === "TRANSCRIPT");
82
+ if (transcript === undefined || transcript.status !== "completed" || typeof transcript.download_url !== "string") {
83
+ const ends = files.map((f) => Date.parse(f.recording_end ?? "")).filter((t) => Number.isFinite(t));
84
+ const finished = files.length > 0 && files.every((f) => f.status === "completed") && ends.length > 0;
85
+ if (transcript === undefined && finished && now() - Math.max(...ends) > ZOOM_TRANSCRIPT_GRACE_MS) return { status: "refused", reason: "not-transcribed" };
86
+ return { status: "pending" };
87
+ }
88
+ if (!onHost(transcript.download_url, "zoom.us")) throw new Error("the Zoom transcript download_url is not on zoom.us, so the token is not sent there");
89
+ const dl = await fetchImpl(transcript.download_url, { headers: { authorization: `Bearer ${tok}` } });
90
+ if (dl.status === 404) return { status: "refused", reason: "expired" };
91
+ if (dl.status === 401 || dl.status === 403) return { status: "refused", reason: "access-off", hint: ZOOM_ACCESS_HINT };
92
+ if (!dl.ok) throw unexpectedStatus("the Zoom transcript download", dl.status);
93
+ const segments = parseWebVtt(await readCappedText(dl), { namePrefix: true });
94
+ const ends = files.map((f) => f.recording_end).filter((t): t is string => typeof t === "string").sort();
95
+ return { status: "ready", segments, ...(startedAt !== undefined ? { startedAt } : {}), ...(ends.length > 0 ? { endedAt: ends[ends.length - 1]! } : {}) };
96
+ },
97
+ };
98
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type ModelBackend,
9
9
  type RoutedCallOptions,
10
10
  hostPlaneFetch,
11
+ hostPlanePolicy,
11
12
  liveIdentity,
12
13
  resolvePrimaryModel,
13
14
  } from "@open-everywhere/core/agent/adapter-api.js";
@@ -33,13 +34,46 @@ export function gatewayHost(baseUrl: string | undefined): string {
33
34
  }
34
35
  }
35
36
 
37
+ /** The one host this deployment's gateway calls reach — what every host-plane push must keep declaring. */
38
+ export const GATEWAY_TARGET_HOST = gatewayHost(GATEWAY_BASE_URL);
39
+
40
+ /**
41
+ * THE HOST-PLANE UNION (OPE-2470). The proxy REPLACES its host plane on every push; it never merges
42
+ * (deploy/egress-proxy/control/handlers.ts `setHostPlane`). So this process keeps ONE host set, and every
43
+ * host-plane call goes through ONE fetch that pushes the whole set: the turn model and the meeting bot's
44
+ * non-gateway transcription routes never push lists that drop each other. A caller adds its hosts with
45
+ * {@link sharedHostPlaneFetch} at boot; a grown set builds a new fetch, which pushes the union on its next call.
46
+ */
47
+ const HOST_PLANE_HOSTS: string[] = [GATEWAY_TARGET_HOST];
48
+ let hostPlaneCurrent = hostPlaneFetch(HOST_PLANE_HOSTS);
49
+ let hostPlaneBuiltFor = HOST_PLANE_HOSTS.length;
36
50
  /**
37
51
  * THE MODEL CALL'S TRANSPORT ON THE SELF-HOST PROXY TIER (OPE-2406). With an egress proxy wired, the model call goes out
38
- * on the app's HOST PLANE: the proxy decides it against the gateway host alone, never against a session's policy. That is
39
- * what lets a turn reach its model while the session stays default-deny. Off that tier it is `undefined` and the provider
40
- * keeps its default fetch, byte-identical to before.
52
+ * on the app's HOST PLANE: the proxy decides it against the host-plane list (the gateway host, plus the hosts of any
53
+ * non-gateway transcription route the app registers), never against a session's policy. That is what lets a turn reach
54
+ * its model while the session stays default-deny. Off that tier it is `undefined` and the provider keeps its default
55
+ * fetch, byte-identical to before. On it, the fetch always uses the newest union.
41
56
  */
42
- const HOST_PLANE_FETCH = hostPlaneFetch([gatewayHost(GATEWAY_BASE_URL)]);
57
+ const HOST_PLANE_FETCH: typeof fetch | undefined =
58
+ hostPlaneCurrent === undefined
59
+ ? undefined
60
+ : (input, init) => {
61
+ if (hostPlaneBuiltFor !== HOST_PLANE_HOSTS.length) {
62
+ hostPlaneCurrent = hostPlaneFetch(HOST_PLANE_HOSTS);
63
+ hostPlaneBuiltFor = HOST_PLANE_HOSTS.length;
64
+ }
65
+ return hostPlaneCurrent!(input, init);
66
+ };
67
+
68
+ /** Add `hosts` to this process's host plane and return the shared fetch (undefined off the proxy tier). */
69
+ export function sharedHostPlaneFetch(hosts: readonly string[]): typeof fetch | undefined {
70
+ // Validate BEFORE the list grows: a wildcard host throws here, at registration, and never reaches the shared list,
71
+ // where it would fail every model turn at the next rebuild. A blank name is skipped.
72
+ const named = hosts.map((h) => h.trim()).filter((h) => h !== "");
73
+ if (HOST_PLANE_FETCH !== undefined) hostPlanePolicy([...HOST_PLANE_HOSTS, ...named]);
74
+ for (const h of named) if (!HOST_PLANE_HOSTS.includes(h)) HOST_PLANE_HOSTS.push(h);
75
+ return HOST_PLANE_FETCH;
76
+ }
43
77
 
44
78
  /** The gateway provider this backend resolves model ids through — retargeted only when
45
79
  * {@link GATEWAY_BASE_URL} names a different host. */
@@ -86,6 +120,12 @@ export function applyGatewayOptions(
86
120
  return { ...options, providerOptions: { ...options.providerOptions, gateway: gatewayOpts } };
87
121
  }
88
122
 
123
+ /** A gateway TRANSCRIPTION model, on the same provider (retarget and host-plane fetch included) the turn
124
+ * model uses. The meeting bot's `gateway` route (meeting-gateway.ts) reads it. */
125
+ export function gatewayTranscriptionModel(id: string): ReturnType<typeof provider.transcriptionModel> {
126
+ return provider.transcriptionModel(id);
127
+ }
128
+
89
129
  /** The gateway ModelBackend (see core ModelBackend). */
90
130
  export function gatewayModelBackend(defaults?: Record<string, unknown>): ModelBackend<GatewayModel> {
91
131
  return {
@@ -0,0 +1,88 @@
1
+ // The meeting bot's two model calls (OPE-2459): the `gateway` transcription route, and the recap completion.
2
+ // Core owns both seams (meetings/transcription.ts); this file only fills them, so no meeting code calls a model SDK.
3
+ //
4
+ // TRANSCRIPTION (OPE-2470): one audio chunk per call, along the STACK'S TRANSCRIPTION ROLE — a primary route and a
5
+ // fallback route (transcription-routes.ts), named per stack in the app's `agent/model/stacks.ts`. A provider outage
6
+ // on the primary falls through to the fallback. The policy's route id stays `gateway` (core validates the id), but
7
+ // the role decides where the audio goes, and the approval card's sentence is derived from the role. With a role
8
+ // registered, the policy's `transcription.model` is NOT read.
9
+ //
10
+ // LEGACY: `registerGatewayMeetingModels(defaults)` with no role keeps the one-model route on the policy's
11
+ // `transcription.model`, for an app that has no transcription role yet.
12
+ //
13
+ // RECAP: one tool-less completion on the scope's `utility` route — the same route and call shape the dream
14
+ // judge uses. The prompt carries the fenced, untrusted transcript (core meetings/recap.ts).
15
+
16
+ import { experimental_transcribe as transcribe, generateText, gateway } from "ai";
17
+ import { registerMeetingCompletion, registerTranscriptionRoute, type MeetingCompletion, type TranscriptionInput, type TranscriptionRoute } from "@open-everywhere/core/agent/adapter-api.js";
18
+ import { gatewayTranscriptionModel, type GatewayRoutedDefaults } from "./gateway.js";
19
+ import { resolvedDreamCallSettings } from "./dream-judge.js";
20
+ import { roleDisclosure, transcribeWithFallback, type RoleTranscript, type TranscriptionDeps, type TranscriptionRoleSpec } from "./transcription-routes.js";
21
+
22
+ /** The injectable transcription call, so the legacy route is testable with no network. */
23
+ export type TranscribeCall = (modelId: string, audio: Uint8Array, mediaType: string) => Promise<string>;
24
+
25
+ // The SDK reads the container format from the bytes themselves, so the media type is not forwarded.
26
+ const defaultTranscribe: TranscribeCall = async (modelId, audio) => {
27
+ const result = await transcribe({ model: gatewayTranscriptionModel(modelId), audio });
28
+ return result.text;
29
+ };
30
+
31
+ /** The legacy one-model route: the policy's `transcription.model` through the AI Gateway, with no fallback. */
32
+ export function gatewayTranscriptionRoute(call: TranscribeCall = defaultTranscribe): TranscriptionRoute {
33
+ return {
34
+ id: "gateway",
35
+ disclosure: "Audio goes, one minute at a time, through this deployment's own AI Gateway to its transcription model.",
36
+ async transcribe(input, opts) {
37
+ if (input.kind !== "audio") throw new Error("the gateway transcription route transcribes audio — this bot sent text");
38
+ if (opts.model === undefined) throw new Error("the gateway transcription route needs transcription.model in the meeting policy");
39
+ return await call(opts.model, input.data, input.mediaType);
40
+ },
41
+ };
42
+ }
43
+
44
+ /**
45
+ * The stack route: the core seam's `transcribe` (text only), plus `transcribeTimed`, which keeps the word and segment
46
+ * timestamps for the speaker alignment (OPE-2469) and says which route answered.
47
+ */
48
+ export interface TimedTranscriptionRoute extends TranscriptionRoute {
49
+ transcribeTimed(input: TranscriptionInput, opts: { readonly scope: string; readonly prompt?: string }): Promise<RoleTranscript>;
50
+ }
51
+
52
+ export function stackTranscriptionRoute(role: TranscriptionRoleSpec, deps: TranscriptionDeps = {}): TimedTranscriptionRoute {
53
+ const run = transcribeWithFallback(role, deps);
54
+ const timed: TimedTranscriptionRoute["transcribeTimed"] = async (input, opts) => {
55
+ if (input.kind !== "audio") throw new Error("the gateway transcription route transcribes audio — this bot sent text");
56
+ return await run({ audio: input.data, mediaType: input.mediaType, ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}) });
57
+ };
58
+ return {
59
+ id: "gateway",
60
+ disclosure: roleDisclosure(role),
61
+ transcribeTimed: timed,
62
+ async transcribe(input, opts) {
63
+ return (await timed(input, { scope: opts.scope })).text;
64
+ },
65
+ };
66
+ }
67
+
68
+ function gatewayMeetingCompletion(defaults: GatewayRoutedDefaults): MeetingCompletion {
69
+ return async (scope, prompt) => {
70
+ const s = resolvedDreamCallSettings(scope, defaults);
71
+ const { text } = await generateText({
72
+ model: gateway(s.id),
73
+ prompt,
74
+ ...(s.reasoning !== undefined ? { reasoning: s.reasoning } : {}),
75
+ ...(s.providerOptions !== undefined ? { providerOptions: s.providerOptions as Parameters<typeof generateText>[0]["providerOptions"] } : {}),
76
+ });
77
+ return text;
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Boot registration from agent.ts, beside the dream judge: the recap rides the `utility` defaults, and the
83
+ * transcription route rides the stack's transcription role when the app passes one.
84
+ */
85
+ export function registerGatewayMeetingModels(defaults: GatewayRoutedDefaults, transcription?: TranscriptionRoleSpec): void {
86
+ registerTranscriptionRoute(transcription === undefined ? gatewayTranscriptionRoute() : stackTranscriptionRoute(transcription));
87
+ registerMeetingCompletion(gatewayMeetingCompletion(defaults));
88
+ }