toga-ai 1.0.69 → 1.0.71
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.
|
@@ -7,3 +7,4 @@
|
|
|
7
7
|
| [Creating Worker Actions](features/creating-worker-actions.md) | How to add a new callable Worker action — a PHP class whose `public static` methods are invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). | worker2/Worker/, worker2/Controller/Index.php, _underscore/Worker.php |
|
|
8
8
|
| [Elite Freshservice Sync (worker2)](features/elite-freshservice-sync.md) | `_Worker_Elite` processes Freshservice webhook events and syncs them into TOGA 2. | worker2/Worker/Elite.php, worker2/Config/dev-kmaramreddy-laptop.ini |
|
|
9
9
|
| [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Notification/Email.php, dbchanges2/Core/2026-05-21 - Monitors.sql |
|
|
10
|
+
| [Teams Meeting Transcript Export](features/teams-transcript-export.md) | `_Worker_Team_Transcripts` (action `Team/Transcripts/Export`) polls Microsoft Graph for Teams meeting transcripts produced by a set of organizers, classifies ea | worker2/Worker/Team/Transcripts.php, worker2/Config/production.ini |
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Teams Meeting Transcript Export
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-12
|
|
10
|
+
owners: ["ajean"]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/Worker/Team/Transcripts.php
|
|
13
|
+
- worker2/Config/production.ini
|
|
14
|
+
related:
|
|
15
|
+
- ../architecture.md
|
|
16
|
+
- ./creating-worker-actions.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Summary
|
|
20
|
+
|
|
21
|
+
`_Worker_Team_Transcripts` (action `Team/Transcripts/Export`) polls Microsoft Graph for
|
|
22
|
+
Teams meeting transcripts produced by a set of organizers, classifies each meeting by
|
|
23
|
+
client (from the meeting title), and archives the raw WebVTT to S3. Scheduled via three
|
|
24
|
+
`Core.CronJobs` rows (weekdays 10:00 / 13:00 / 17:30 Central).
|
|
25
|
+
|
|
26
|
+
## Key files / entry points
|
|
27
|
+
|
|
28
|
+
- `Worker/Team/Transcripts.php` — the whole feature (abstract class, static methods).
|
|
29
|
+
- `Config/production.ini` `[teams]` section — Entra app creds, organizer source, S3 target.
|
|
30
|
+
- `[teamsClientAliases]` config section — alias → canonical client name for classification.
|
|
31
|
+
- Ledger table `Team.TranscriptExports` (DB alias `_underscore::DB_TEAM`, core cluster).
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
Per run, after acquiring a Graph client-credentials token:
|
|
36
|
+
|
|
37
|
+
1. **Resolve organizers** via `resolveConfiguredOrganizers()`:
|
|
38
|
+
- **Preferred:** the Entra security group in `[teams] organizer_group_id`. `getGroupMemberIds()`
|
|
39
|
+
calls `GET /groups/{id}/members/microsoft.graph.user?$select=id`, which returns member
|
|
40
|
+
**object-ids (GUIDs)** directly — exactly what `getAllTranscripts` requires. Uses the
|
|
41
|
+
`GroupMember.Read.All` app permission. Admins add/remove organizers in Entra with **no redeploy**.
|
|
42
|
+
- **Fallback:** the comma-separated `[teams] organizer_user_ids` list (used only when no group
|
|
43
|
+
is configured or the group resolves no members; group-lookup failures are caught so they
|
|
44
|
+
can't abort the run).
|
|
45
|
+
2. **Per organizer**, `resolveUserId()` ensures a GUID: a value already shaped like a GUID
|
|
46
|
+
(the group path) passes through; a UPN is looked up via `/users/{upn}?$select=id` and
|
|
47
|
+
returns the object-id, or **`null`** if that lookup fails. The loop **fails fast** on null —
|
|
48
|
+
it throws a clear per-organizer error (caught and recorded in `summary['errors']`, the run
|
|
49
|
+
continues for other organizers) rather than sending a UPN to Graph. `listAllTranscripts()`
|
|
50
|
+
also re-asserts the GUID shape before embedding it in the OData literal.
|
|
51
|
+
3. `getAllTranscripts(meetingOrganizerUserId='{guid}', startDateTime, endDateTime)`
|
|
52
|
+
over an incremental window (`MAX(dtCreated)` watermark from the ledger, else `now − lookbackDays`),
|
|
53
|
+
following `@odata.nextLink`.
|
|
54
|
+
4. Per new transcript (dedup by `transcriptIdentifier`): fetch meeting `subject`+`startDateTime`
|
|
55
|
+
(cached per meeting), classify, download VTT (`?$format=text/vtt`), `putObject` to S3, insert
|
|
56
|
+
ledger row.
|
|
57
|
+
|
|
58
|
+
**Classification:** case-insensitive substring match of the meeting title against active
|
|
59
|
+
`Core.Clients` names + `[teamsClientAliases]`, longest needle wins, min length 3. No match → `general`.
|
|
60
|
+
|
|
61
|
+
**S3 layout:** `s3://{[teams] s3_bucket}/{s3_prefix}{client-or-general}/{YYYY-MM-DD}/{HHMM}_{title-slug}_{shortId}.vtt`
|
|
62
|
+
(date/time in Central). Currently `toga-private/transcripts/…`.
|
|
63
|
+
|
|
64
|
+
## Data model
|
|
65
|
+
|
|
66
|
+
`Team.TranscriptExports` — unique `transcriptIdentifier` (idempotency + per-organizer watermark
|
|
67
|
+
via `MAX(dtCreated)`). Columns: `uuid, dtExported, dtCreated, dtMeeting, meetingSubject,
|
|
68
|
+
sizeBytes, classification, transcriptIdentifier, meetingIdentifier, organizerUserIdentifier, s3Key`.
|
|
69
|
+
|
|
70
|
+
## Entra app permissions (app-only / client credentials)
|
|
71
|
+
|
|
72
|
+
The dedicated Entra app **Teams-Worker2-TranscriptExport** needs, admin-consented:
|
|
73
|
+
|
|
74
|
+
- `OnlineMeetingTranscript.Read.All` — read transcript content.
|
|
75
|
+
- `OnlineMeetings.Read.All` — read meeting metadata.
|
|
76
|
+
- `GroupMember.Read.All` — read the organizer group's members (the object-ids).
|
|
77
|
+
|
|
78
|
+
`User.Read.All` is **deliberately not used** — the group-members call already returns object-ids,
|
|
79
|
+
so the old per-UPN `/users/{upn}?$select=id` lookup is unnecessary.
|
|
80
|
+
|
|
81
|
+
No Teams **Application Access Policy** is required for this tenant — verified the app can read
|
|
82
|
+
the organizers' transcripts directly once given a valid object-id.
|
|
83
|
+
|
|
84
|
+
## Client variations
|
|
85
|
+
|
|
86
|
+
None — uniform. Output is partitioned per client by meeting-title classification, but the
|
|
87
|
+
process is identical for all.
|
|
88
|
+
|
|
89
|
+
## Gotchas / known issues
|
|
90
|
+
|
|
91
|
+
- **`getAllTranscripts` requires an AAD object-id (GUID), NOT a UPN.** Passing a UPN
|
|
92
|
+
(e.g. `ajean@togatech.com`) returns the opaque `HTTP 400 BadRequest / "UnknownError"`.
|
|
93
|
+
This is the single most likely cause of an export failure.
|
|
94
|
+
- **The earlier UPN→GUID fix (PR #77) silently failed.** It resolved each UPN via
|
|
95
|
+
`GET /users/{upn}?$select=id`, but the app lacks `User.Read.All`, so that call returns
|
|
96
|
+
`403 Authorization_RequestDenied`, which `resolveUserId()` swallows and falls back to the UPN —
|
|
97
|
+
producing the same 400. Lesson: a "fix" that depends on an ungranted permission and silently
|
|
98
|
+
falls back will look correct in code review but never work in prod. The group-members approach
|
|
99
|
+
avoids the `/users` endpoint entirely.
|
|
100
|
+
- `resolveUserId()` returns `?string`: a GUID passes through, a UPN is resolved via `/users`,
|
|
101
|
+
and it returns **`null`** when a non-GUID can't be resolved — it never echoes the UPN back.
|
|
102
|
+
The Export loop rejects null and skips that organizer with a clear error, so a UPN can never
|
|
103
|
+
reach Graph. `listAllTranscripts()` re-asserts the GUID shape (`preg_match`) before embedding
|
|
104
|
+
the id in the OData string literal — defense-in-depth so the no-injection invariant can't
|
|
105
|
+
silently regress. Note: the `organizer_user_ids` UPN fallback only actually works in a tenant
|
|
106
|
+
that grants `User.Read.All`; in this tenant it does not, so the group is the only working source.
|
|
107
|
+
- Graph meeting/transcript ids are base64 and contain `+ / =` — they must be `rawurlencode()`'d
|
|
108
|
+
at every Graph call site (`getMeeting`, `downloadTranscriptVtt` fallback URL).
|
|
109
|
+
- `resolveWatermark()` guards against an unparseable stored `dtCreated`: without the guard,
|
|
110
|
+
`strtotime(...) === false` minus 60 yields a 1969 date and a decades-wide Graph query window.
|
|
111
|
+
- Plaintext `client_secret` lives in `Config/production.ini` `[teams]` — should be rotated /
|
|
112
|
+
moved to a secret store (out of scope as of this writing).
|
|
113
|
+
|
|
114
|
+
## Diagnosing a 400
|
|
115
|
+
|
|
116
|
+
When a 400 recurs, reproduce the Graph call chain **outside** the worker, step-by-step
|
|
117
|
+
(OAuth token → `GET /groups/{id}/members/microsoft.graph.user?$select=id` → `getAllTranscripts`
|
|
118
|
+
with one member's object-id) with curl, reading creds from `Config/production.ini` `[teams]`.
|
|
119
|
+
This surfaces the **full** Graph error body (`error.code` + `innerError`) that the worker
|
|
120
|
+
discards — `curlRequest()` keeps only `error.message`, which is why prod logs showed an opaque
|
|
121
|
+
`UnknownError`. A `400 UnknownError` on a *valid GUID* points to a Teams Application Access
|
|
122
|
+
Policy gap; a `400` only on a UPN means the id was never resolved to a GUID. (A throwaway
|
|
123
|
+
`diagnose-transcripts.sh` was used this way during the fix but is not committed.)
|
|
124
|
+
|
|
125
|
+
## Change history
|
|
126
|
+
|
|
127
|
+
- 2026-06-12 — **Merged (PR #78) and verified in production**: a real `Team/Transcripts/Export`
|
|
128
|
+
run returned 6/6 organizers, 33 found, 33 exported, 0 errors (classified 30 general / 3 Elite).
|
|
129
|
+
Added fail-fast on unresolvable organizers (`resolveUserId` → null, loop throws clear error
|
|
130
|
+
instead of sending a UPN to Graph) and a GUID assertion in `listAllTranscripts`. (ajean)
|
|
131
|
+
- 2026-06-12 — Resolve organizers from the `organizer_group_id` Entra group (object-ids via
|
|
132
|
+
GroupMember.Read.All) instead of per-UPN lookup; fixes the recurring HTTP 400 that PR #77's
|
|
133
|
+
`/users` approach couldn't (no User.Read.All). Hardened watermark parsing, URL-encoding, and
|
|
134
|
+
silent catches. (ajean)
|
|
135
|
+
|
|
136
|
+
## Related docs
|
|
137
|
+
|
|
138
|
+
- [Worker (worker2) Architecture](../architecture.md)
|
|
139
|
+
- [Creating Worker Actions](./creating-worker-actions.md)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -12,7 +12,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
12
12
|
## 2.0 framework
|
|
13
13
|
|
|
14
14
|
- **_underscore** (_Underscore) _(framework core)_ — 5 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
15
|
-
- **worker2** (Worker) —
|
|
15
|
+
- **worker2** (Worker) — 6 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
16
16
|
- **api2** (API) — 1 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
17
17
|
- **dbchanges2** (Database Changes) _(framework core)_ — 1 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
18
18
|
- **toga2-supply** (TOGa Supply) — 2 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
package/package.json
CHANGED