lex-microsoft_teams 0.5.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 (53) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +16 -0
  3. data/.gitignore +15 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +56 -0
  6. data/CHANGELOG.md +59 -0
  7. data/CLAUDE.md +206 -0
  8. data/Dockerfile +6 -0
  9. data/Gemfile +12 -0
  10. data/Gemfile.lock +103 -0
  11. data/LICENSE +21 -0
  12. data/README.md +183 -0
  13. data/docs/plans/2026-03-15-meetings-transcripts-design.md +506 -0
  14. data/docs/plans/2026-03-16-delegated-oauth-browser-flow-design.md +198 -0
  15. data/docs/plans/2026-03-16-delegated-oauth-browser-flow-plan.md +1176 -0
  16. data/lex-microsoft_teams.gemspec +32 -0
  17. data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +41 -0
  18. data/lib/legion/extensions/microsoft_teams/actors/cache_sync.rb +54 -0
  19. data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +105 -0
  20. data/lib/legion/extensions/microsoft_teams/actors/message_processor.rb +23 -0
  21. data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +111 -0
  22. data/lib/legion/extensions/microsoft_teams/client.rb +68 -0
  23. data/lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb +139 -0
  24. data/lib/legion/extensions/microsoft_teams/helpers/callback_server.rb +82 -0
  25. data/lib/legion/extensions/microsoft_teams/helpers/client.rb +38 -0
  26. data/lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb +59 -0
  27. data/lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb +64 -0
  28. data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +150 -0
  29. data/lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb +140 -0
  30. data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +209 -0
  31. data/lib/legion/extensions/microsoft_teams/local_cache/extractor.rb +258 -0
  32. data/lib/legion/extensions/microsoft_teams/local_cache/record_parser.rb +199 -0
  33. data/lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb +121 -0
  34. data/lib/legion/extensions/microsoft_teams/runners/adaptive_cards.rb +59 -0
  35. data/lib/legion/extensions/microsoft_teams/runners/auth.rb +116 -0
  36. data/lib/legion/extensions/microsoft_teams/runners/bot.rb +409 -0
  37. data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +122 -0
  38. data/lib/legion/extensions/microsoft_teams/runners/channel_messages.rb +52 -0
  39. data/lib/legion/extensions/microsoft_teams/runners/channels.rb +53 -0
  40. data/lib/legion/extensions/microsoft_teams/runners/chats.rb +51 -0
  41. data/lib/legion/extensions/microsoft_teams/runners/local_cache.rb +62 -0
  42. data/lib/legion/extensions/microsoft_teams/runners/meetings.rb +68 -0
  43. data/lib/legion/extensions/microsoft_teams/runners/messages.rb +48 -0
  44. data/lib/legion/extensions/microsoft_teams/runners/presence.rb +31 -0
  45. data/lib/legion/extensions/microsoft_teams/runners/subscriptions.rb +76 -0
  46. data/lib/legion/extensions/microsoft_teams/runners/teams.rb +33 -0
  47. data/lib/legion/extensions/microsoft_teams/runners/transcripts.rb +45 -0
  48. data/lib/legion/extensions/microsoft_teams/transport/exchanges/messages.rb +15 -0
  49. data/lib/legion/extensions/microsoft_teams/transport/messages/teams_message.rb +16 -0
  50. data/lib/legion/extensions/microsoft_teams/transport/queues/messages_process.rb +16 -0
  51. data/lib/legion/extensions/microsoft_teams/version.rb +9 -0
  52. data/lib/legion/extensions/microsoft_teams.rb +44 -0
  53. metadata +139 -0
data/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # lex-microsoft_teams
2
+
3
+ Microsoft Teams integration for [LegionIO](https://github.com/LegionIO/LegionIO). Connects to Microsoft Teams via Graph API and Bot Framework for chat, channel, and bot communication.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ gem install lex-microsoft_teams
9
+ ```
10
+
11
+ ## Functions
12
+
13
+ ### Auth
14
+ - `acquire_token` — OAuth2 client credentials token for Graph API
15
+ - `acquire_bot_token` — OAuth2 token for Bot Framework
16
+ - `authorize_url` — Build Authorization Code + PKCE authorize URL for delegated consent
17
+ - `exchange_code` — Exchange authorization code for delegated access/refresh tokens
18
+ - `refresh_delegated_token` — Refresh a delegated token using a refresh token
19
+ - `request_device_code` — Start Device Code flow (headless fallback)
20
+ - `poll_device_code` — Poll for Device Code completion (RFC 8628 compliant)
21
+
22
+ ### Teams
23
+ - `list_joined_teams` — List teams the user has joined
24
+ - `get_team` — Get team details
25
+ - `list_team_members` — List members of a team
26
+
27
+ ### Chats
28
+ - `list_chats` — List 1:1 and group chats
29
+ - `get_chat` — Get chat details
30
+ - `create_chat` — Create a new chat
31
+ - `list_chat_members` — List chat participants
32
+ - `add_chat_member` — Add a member to a chat
33
+
34
+ ### Messages
35
+ - `list_chat_messages` — List messages in a chat
36
+ - `get_chat_message` — Get a specific message
37
+ - `send_chat_message` — Send a message to a chat
38
+ - `reply_to_chat_message` — Reply to a message
39
+ - `list_message_replies` — List replies to a message
40
+
41
+ ### Channels
42
+ - `list_channels` — List channels in a team
43
+ - `get_channel` — Get channel details
44
+ - `create_channel` — Create a new channel
45
+ - `update_channel` — Update channel properties
46
+ - `delete_channel` — Delete a channel
47
+ - `list_channel_members` — List channel members
48
+
49
+ ### Channel Messages
50
+ - `list_channel_messages` — List messages in a channel
51
+ - `get_channel_message` — Get a specific channel message
52
+ - `send_channel_message` — Send a message to a channel
53
+ - `reply_to_channel_message` — Reply to a channel message
54
+ - `list_channel_message_replies` — List replies to a channel message
55
+
56
+ ### Meetings
57
+ - `list_meetings` — List online meetings for a user
58
+ - `get_meeting` — Get meeting details
59
+ - `create_meeting` — Create an online meeting
60
+ - `update_meeting` — Update meeting properties
61
+ - `delete_meeting` — Delete a meeting
62
+ - `get_meeting_by_join_url` — Find a meeting by its join URL
63
+ - `list_attendance_reports` — List attendance reports for a meeting
64
+ - `get_attendance_report` — Get a specific attendance report with attendee records
65
+
66
+ ### Transcripts
67
+ - `list_transcripts` — List available transcripts for a meeting
68
+ - `get_transcript` — Get transcript metadata
69
+ - `get_transcript_content` — Get transcript content (VTT default, DOCX optional via `format:` param)
70
+
71
+ ### Presence
72
+ - `get_presence` — Get the availability and activity status for a user
73
+
74
+ ### Subscriptions (Change Notifications)
75
+ - `list_subscriptions` — List active subscriptions
76
+ - `get_subscription` — Get subscription details
77
+ - `create_subscription` — Create a change notification subscription
78
+ - `renew_subscription` — Extend subscription expiration
79
+ - `delete_subscription` — Delete a subscription
80
+ - `subscribe_to_chat_messages` — Subscribe to chat message events
81
+ - `subscribe_to_channel_messages` — Subscribe to channel message events
82
+
83
+ ### Local Cache (Offline)
84
+ - `extract_local_messages` — Extract messages from the Teams 2.x LevelDB local storage without Graph API credentials
85
+ - `local_cache_available?` — Check whether the local Teams cache exists on disk
86
+ - `local_cache_stats` — Get message count and date range stats from the local cache without extracting
87
+
88
+ ### Cache Ingest
89
+ - `ingest_cache` — Ingest messages from the local Teams cache into lex-memory as episodic traces; returns `{ stored:, skipped:, latest_time: }`
90
+
91
+ ### Adaptive Cards
92
+ - `build_card` — Build an Adaptive Card payload
93
+ - `text_block` — Create a TextBlock element
94
+ - `fact_set` — Create a FactSet element
95
+ - `action_open_url` — Create an OpenUrl action
96
+ - `action_submit` — Create a Submit action
97
+ - `message_attachment` — Wrap a card as a message attachment
98
+
99
+ ### Bot Framework
100
+ - `send_activity` — Send an activity to a conversation
101
+ - `reply_to_activity` — Reply to an existing activity
102
+ - `send_text` — Send a simple text message via bot
103
+ - `send_card` — Send an Adaptive Card via bot
104
+ - `create_conversation` — Create a new bot conversation
105
+ - `get_conversation_members` — List conversation members
106
+
107
+ ### AI Bot (v0.2.0)
108
+ - `handle_message` — LLM-powered response loop for direct 1:1 bot chats (polls Graph API, replies via Graph or Bot Framework)
109
+ - `observe_message` — Conversation observer that extracts tasks, context, and relationship data from subscribed human chats (disabled by default, compliance-gated)
110
+
111
+ **Actors:**
112
+ - `DirectChatPoller` — Polls bot DM chats every 5s, publishes to AMQP
113
+ - `ObservedChatPoller` — Polls subscribed conversations every 30s (disabled by default)
114
+ - `MessageProcessor` — AMQP subscription actor, routes messages by mode to `handle_message` or `observe_message`
115
+
116
+ **Helpers:**
117
+ - `SessionManager` — Multi-turn LLM session lifecycle with lex-memory persistence
118
+ - `PromptResolver` — Layered system prompt resolution (settings default -> mode -> per-conversation)
119
+ - `HighWaterMark` — Per-chat message deduplication via legion-cache
120
+ - `TokenCache` — In-memory OAuth token cache with pre-expiry refresh (app + delegated slots)
121
+ - `SubscriptionRegistry` — Conversation observation subscriptions (in-memory + lex-memory)
122
+ - `BrowserAuth` — Delegated OAuth orchestrator (PKCE, headless detection, browser launch)
123
+ - `CallbackServer` — Ephemeral TCP server for OAuth redirect callback
124
+
125
+ ### Delegated Authentication (v0.5.0)
126
+
127
+ Opt-in browser-based OAuth for delegated Microsoft Graph permissions (e.g., meeting transcripts).
128
+
129
+ **Authorization Code + PKCE** (primary): Opens the user's browser for Entra ID login, captures the callback on an ephemeral local port, exchanges the code with PKCE verification.
130
+
131
+ **Device Code** (fallback): Automatically selected in headless/SSH environments (no `DISPLAY`/`WAYLAND_DISPLAY`). Displays a URL and code for the user to enter on any device.
132
+
133
+ ```ruby
134
+ # Via CLI
135
+ # legion auth teams --tenant-id TENANT --client-id CLIENT
136
+
137
+ # Via code
138
+ auth = Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth.new(
139
+ tenant_id: 'your-tenant-id',
140
+ client_id: 'your-client-id'
141
+ )
142
+ result = auth.authenticate # returns token hash with access_token, refresh_token, expires_in
143
+ ```
144
+
145
+ Tokens are stored in Vault (`legionio/microsoft_teams/delegated_token`) and silently refreshed before expiry.
146
+
147
+ ## Standalone Client
148
+
149
+ The `Client` class includes all runner modules (Auth, Teams, Chats, Messages, Channels, ChannelMessages, Subscriptions, AdaptiveCards, Bot, Presence, Meetings, Transcripts, LocalCache, CacheIngest).
150
+
151
+ ```ruby
152
+ client = Legion::Extensions::MicrosoftTeams::Client.new(
153
+ tenant_id: 'your-tenant-id',
154
+ client_id: 'your-app-id',
155
+ client_secret: 'your-client-secret'
156
+ )
157
+ client.authenticate!
158
+
159
+ # Graph API
160
+ client.list_chats
161
+ client.send_chat_message(chat_id: 'chat-id', content: 'Hello!')
162
+
163
+ # Bot Framework
164
+ client.send_text(
165
+ service_url: 'https://smba.trafficmanager.net/teams/',
166
+ conversation_id: 'conv-id',
167
+ text: 'Hello from bot'
168
+ )
169
+
170
+ # Local cache (no credentials needed)
171
+ client.local_cache_available?
172
+ client.extract_local_messages(since: Time.now - 86_400)
173
+ ```
174
+
175
+ ## Requirements
176
+
177
+ - Ruby >= 3.4
178
+ - [LegionIO](https://github.com/LegionIO/LegionIO) framework
179
+ - Microsoft Entra ID application with appropriate Graph API permissions
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,506 @@
1
+ # Meetings & Transcripts Runners Design
2
+
3
+ **Date**: 2026-03-15
4
+ **Status**: Approved
5
+
6
+ ## Summary
7
+
8
+ Add two new runner modules to lex-microsoft_teams for Microsoft Graph API online meetings and meeting transcripts.
9
+
10
+ ## Runners::Meetings
11
+
12
+ Online meeting management via `/users/{userId}/onlineMeetings`.
13
+
14
+ | Method | Endpoint | Purpose |
15
+ |--------|----------|---------|
16
+ | `list_meetings` | `GET /users/{userId}/onlineMeetings` | List online meetings |
17
+ | `get_meeting` | `GET /users/{userId}/onlineMeetings/{meetingId}` | Get meeting details |
18
+ | `create_meeting` | `POST /users/{userId}/onlineMeetings` | Create meeting |
19
+ | `update_meeting` | `PATCH /users/{userId}/onlineMeetings/{meetingId}` | Update meeting |
20
+ | `delete_meeting` | `DELETE /users/{userId}/onlineMeetings/{meetingId}` | Delete meeting |
21
+ | `get_meeting_by_join_url` | `GET .../onlineMeetings?$filter=joinWebUrl eq '{url}'` | Lookup by join URL |
22
+ | `list_attendance_reports` | `GET .../attendanceReports` | List attendance reports |
23
+ | `get_attendance_report` | `GET .../attendanceReports/{reportId}` | Get report with attendees |
24
+
25
+ ## Runners::Transcripts
26
+
27
+ Transcript access via `/users/{userId}/onlineMeetings/{meetingId}/transcripts`.
28
+
29
+ | Method | Endpoint | Purpose |
30
+ |--------|----------|---------|
31
+ | `list_transcripts` | `GET .../transcripts` | List available transcripts |
32
+ | `get_transcript` | `GET .../transcripts/{transcriptId}` | Get transcript metadata |
33
+ | `get_transcript_content` | `GET .../transcripts/{transcriptId}/content` | Get content (VTT or DOCX) |
34
+
35
+ `get_transcript_content` accepts `format: :vtt` (default) or `format: :docx`. Maps to `Accept` header. VTT returns plain text, DOCX returns binary.
36
+
37
+ ## Wiring
38
+
39
+ - Both included in standalone `Client` class
40
+ - Both required in entry point `microsoft_teams.rb`
41
+ - New permissions: `OnlineMeetingTranscript.Read.All`, `OnlineMeeting.Read.All`
42
+
43
+ ## Pattern
44
+
45
+ Same flat runner pattern as all existing modules: include `Helpers::Client`, use `graph_connection(**)`, return `{ result: response.body }`.
46
+
47
+ ---
48
+
49
+ # Implementation Plan
50
+
51
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
52
+
53
+ **Goal:** Add Meetings and Transcripts runner modules to lex-microsoft_teams
54
+
55
+ **Architecture:** Two flat runner modules following the identical pattern as Channels/ChannelMessages. Each includes `Helpers::Client`, uses `graph_connection(**)`, returns `{ result: response.body }`. Both wired into the standalone `Client` class and the entry point.
56
+
57
+ **Tech Stack:** Ruby, Faraday, RSpec, Microsoft Graph API v1.0
58
+
59
+ ---
60
+
61
+ ### Task 1: Meetings runner - spec
62
+
63
+ **Files:**
64
+ - Create: `spec/legion/extensions/microsoft_teams/runners/meetings_spec.rb`
65
+
66
+ **Step 1: Write the spec file**
67
+
68
+ ```ruby
69
+ # frozen_string_literal: true
70
+
71
+ require 'spec_helper'
72
+
73
+ RSpec.describe Legion::Extensions::MicrosoftTeams::Runners::Meetings do
74
+ let(:runner) { Object.new.extend(described_class) }
75
+ let(:graph_conn) { instance_double(Faraday::Connection) }
76
+
77
+ before do
78
+ allow(runner).to receive(:graph_connection).and_return(graph_conn)
79
+ end
80
+
81
+ describe '#list_meetings' do
82
+ it 'lists online meetings for a user' do
83
+ response = instance_double(Faraday::Response, body: { 'value' => [{ 'id' => 'm1', 'subject' => 'Standup' }] })
84
+ allow(graph_conn).to receive(:get).with('/users/u1/onlineMeetings').and_return(response)
85
+
86
+ result = runner.list_meetings(user_id: 'u1')
87
+ expect(result[:result]['value'].first['subject']).to eq('Standup')
88
+ end
89
+ end
90
+
91
+ describe '#get_meeting' do
92
+ it 'retrieves a meeting by id' do
93
+ response = instance_double(Faraday::Response, body: { 'id' => 'm1', 'subject' => 'Standup' })
94
+ allow(graph_conn).to receive(:get).with('/users/u1/onlineMeetings/m1').and_return(response)
95
+
96
+ result = runner.get_meeting(user_id: 'u1', meeting_id: 'm1')
97
+ expect(result[:result]['id']).to eq('m1')
98
+ end
99
+ end
100
+
101
+ describe '#create_meeting' do
102
+ it 'creates an online meeting' do
103
+ response = instance_double(Faraday::Response, body: { 'id' => 'm2', 'subject' => 'Review' })
104
+ allow(graph_conn).to receive(:post)
105
+ .with('/users/u1/onlineMeetings', hash_including(subject: 'Review'))
106
+ .and_return(response)
107
+
108
+ result = runner.create_meeting(user_id: 'u1', subject: 'Review',
109
+ start_time: '2026-03-15T10:00:00Z',
110
+ end_time: '2026-03-15T11:00:00Z')
111
+ expect(result[:result]['subject']).to eq('Review')
112
+ end
113
+ end
114
+
115
+ describe '#update_meeting' do
116
+ it 'updates a meeting' do
117
+ response = instance_double(Faraday::Response, body: { 'id' => 'm1', 'subject' => 'Updated' })
118
+ allow(graph_conn).to receive(:patch)
119
+ .with('/users/u1/onlineMeetings/m1', hash_including(subject: 'Updated'))
120
+ .and_return(response)
121
+
122
+ result = runner.update_meeting(user_id: 'u1', meeting_id: 'm1', subject: 'Updated')
123
+ expect(result[:result]['subject']).to eq('Updated')
124
+ end
125
+ end
126
+
127
+ describe '#delete_meeting' do
128
+ it 'deletes a meeting' do
129
+ response = instance_double(Faraday::Response, body: '')
130
+ allow(graph_conn).to receive(:delete).with('/users/u1/onlineMeetings/m1').and_return(response)
131
+
132
+ result = runner.delete_meeting(user_id: 'u1', meeting_id: 'm1')
133
+ expect(result[:result]).to eq('')
134
+ end
135
+ end
136
+
137
+ describe '#get_meeting_by_join_url' do
138
+ it 'finds a meeting by join URL' do
139
+ response = instance_double(Faraday::Response,
140
+ body: { 'value' => [{ 'id' => 'm1', 'joinWebUrl' => 'https://teams.microsoft.com/l/meetup/123' }] })
141
+ allow(graph_conn).to receive(:get)
142
+ .with('/users/u1/onlineMeetings', hash_including('$filter'))
143
+ .and_return(response)
144
+
145
+ result = runner.get_meeting_by_join_url(user_id: 'u1', join_url: 'https://teams.microsoft.com/l/meetup/123')
146
+ expect(result[:result]['value'].first['id']).to eq('m1')
147
+ end
148
+ end
149
+
150
+ describe '#list_attendance_reports' do
151
+ it 'lists attendance reports for a meeting' do
152
+ response = instance_double(Faraday::Response, body: { 'value' => [{ 'id' => 'r1' }] })
153
+ allow(graph_conn).to receive(:get).with('/users/u1/onlineMeetings/m1/attendanceReports').and_return(response)
154
+
155
+ result = runner.list_attendance_reports(user_id: 'u1', meeting_id: 'm1')
156
+ expect(result[:result]['value']).not_to be_empty
157
+ end
158
+ end
159
+
160
+ describe '#get_attendance_report' do
161
+ it 'retrieves a specific attendance report' do
162
+ response = instance_double(Faraday::Response,
163
+ body: { 'id' => 'r1', 'attendanceRecords' => [{ 'identity' => { 'displayName' => 'Alice' } }] })
164
+ allow(graph_conn).to receive(:get).with('/users/u1/onlineMeetings/m1/attendanceReports/r1').and_return(response)
165
+
166
+ result = runner.get_attendance_report(user_id: 'u1', meeting_id: 'm1', report_id: 'r1')
167
+ expect(result[:result]['attendanceRecords']).not_to be_empty
168
+ end
169
+ end
170
+ end
171
+ ```
172
+
173
+ **Step 2: Run test to verify it fails**
174
+
175
+ Run: `bundle exec rspec spec/legion/extensions/microsoft_teams/runners/meetings_spec.rb`
176
+ Expected: FAIL — `uninitialized constant Legion::Extensions::MicrosoftTeams::Runners::Meetings`
177
+
178
+ ---
179
+
180
+ ### Task 2: Meetings runner - implementation
181
+
182
+ **Files:**
183
+ - Create: `lib/legion/extensions/microsoft_teams/runners/meetings.rb`
184
+
185
+ **Step 1: Write the runner**
186
+
187
+ ```ruby
188
+ # frozen_string_literal: true
189
+
190
+ require 'legion/extensions/microsoft_teams/helpers/client'
191
+
192
+ module Legion
193
+ module Extensions
194
+ module MicrosoftTeams
195
+ module Runners
196
+ module Meetings
197
+ include Legion::Extensions::MicrosoftTeams::Helpers::Client
198
+
199
+ def list_meetings(user_id:, **)
200
+ response = graph_connection(**).get("/users/#{user_id}/onlineMeetings")
201
+ { result: response.body }
202
+ end
203
+
204
+ def get_meeting(user_id:, meeting_id:, **)
205
+ response = graph_connection(**).get("/users/#{user_id}/onlineMeetings/#{meeting_id}")
206
+ { result: response.body }
207
+ end
208
+
209
+ def create_meeting(user_id:, subject:, start_time:, end_time:, **)
210
+ payload = {
211
+ subject: subject,
212
+ startDateTime: start_time,
213
+ endDateTime: end_time
214
+ }
215
+ response = graph_connection(**).post("/users/#{user_id}/onlineMeetings", payload)
216
+ { result: response.body }
217
+ end
218
+
219
+ def update_meeting(user_id:, meeting_id:, subject: nil, start_time: nil, end_time: nil, **)
220
+ payload = {}
221
+ payload[:subject] = subject if subject
222
+ payload[:startDateTime] = start_time if start_time
223
+ payload[:endDateTime] = end_time if end_time
224
+ response = graph_connection(**).patch("/users/#{user_id}/onlineMeetings/#{meeting_id}", payload)
225
+ { result: response.body }
226
+ end
227
+
228
+ def delete_meeting(user_id:, meeting_id:, **)
229
+ response = graph_connection(**).delete("/users/#{user_id}/onlineMeetings/#{meeting_id}")
230
+ { result: response.body }
231
+ end
232
+
233
+ def get_meeting_by_join_url(user_id:, join_url:, **)
234
+ params = { '$filter' => "joinWebUrl eq '#{join_url}'" }
235
+ response = graph_connection(**).get("/users/#{user_id}/onlineMeetings", params)
236
+ { result: response.body }
237
+ end
238
+
239
+ def list_attendance_reports(user_id:, meeting_id:, **)
240
+ response = graph_connection(**).get("/users/#{user_id}/onlineMeetings/#{meeting_id}/attendanceReports")
241
+ { result: response.body }
242
+ end
243
+
244
+ def get_attendance_report(user_id:, meeting_id:, report_id:, **)
245
+ response = graph_connection(**).get("/users/#{user_id}/onlineMeetings/#{meeting_id}/attendanceReports/#{report_id}")
246
+ { result: response.body }
247
+ end
248
+
249
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
250
+ Legion::Extensions::Helpers.const_defined?(:Lex)
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
256
+ ```
257
+
258
+ **Step 2: Run tests**
259
+
260
+ Run: `bundle exec rspec spec/legion/extensions/microsoft_teams/runners/meetings_spec.rb`
261
+ Expected: All 8 examples pass
262
+
263
+ **Step 3: Commit**
264
+
265
+ ```bash
266
+ git add lib/legion/extensions/microsoft_teams/runners/meetings.rb spec/legion/extensions/microsoft_teams/runners/meetings_spec.rb
267
+ git commit -m "add meetings runner with specs"
268
+ ```
269
+
270
+ ---
271
+
272
+ ### Task 3: Transcripts runner - spec
273
+
274
+ **Files:**
275
+ - Create: `spec/legion/extensions/microsoft_teams/runners/transcripts_spec.rb`
276
+
277
+ **Step 1: Write the spec file**
278
+
279
+ ```ruby
280
+ # frozen_string_literal: true
281
+
282
+ require 'spec_helper'
283
+
284
+ RSpec.describe Legion::Extensions::MicrosoftTeams::Runners::Transcripts do
285
+ let(:runner) { Object.new.extend(described_class) }
286
+ let(:graph_conn) { instance_double(Faraday::Connection) }
287
+
288
+ before do
289
+ allow(runner).to receive(:graph_connection).and_return(graph_conn)
290
+ end
291
+
292
+ describe '#list_transcripts' do
293
+ it 'lists transcripts for a meeting' do
294
+ response = instance_double(Faraday::Response,
295
+ body: { 'value' => [{ 'id' => 't1', 'createdDateTime' => '2026-03-15T12:00:00Z' }] })
296
+ allow(graph_conn).to receive(:get)
297
+ .with('/users/u1/onlineMeetings/m1/transcripts')
298
+ .and_return(response)
299
+
300
+ result = runner.list_transcripts(user_id: 'u1', meeting_id: 'm1')
301
+ expect(result[:result]['value'].first['id']).to eq('t1')
302
+ end
303
+ end
304
+
305
+ describe '#get_transcript' do
306
+ it 'retrieves transcript metadata' do
307
+ response = instance_double(Faraday::Response, body: { 'id' => 't1', 'createdDateTime' => '2026-03-15T12:00:00Z' })
308
+ allow(graph_conn).to receive(:get)
309
+ .with('/users/u1/onlineMeetings/m1/transcripts/t1')
310
+ .and_return(response)
311
+
312
+ result = runner.get_transcript(user_id: 'u1', meeting_id: 'm1', transcript_id: 't1')
313
+ expect(result[:result]['id']).to eq('t1')
314
+ end
315
+ end
316
+
317
+ describe '#get_transcript_content' do
318
+ let(:vtt_body) { "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nHello everyone" }
319
+
320
+ it 'retrieves transcript content as VTT by default' do
321
+ response = instance_double(Faraday::Response, body: vtt_body)
322
+ allow(graph_conn).to receive(:get) do |path, _params, &block|
323
+ block&.call(Faraday::Request.new)
324
+ response
325
+ end
326
+
327
+ result = runner.get_transcript_content(user_id: 'u1', meeting_id: 'm1', transcript_id: 't1')
328
+ expect(result[:result]).to include('WEBVTT')
329
+ end
330
+
331
+ it 'accepts format: :docx' do
332
+ response = instance_double(Faraday::Response, body: 'binary-docx-content')
333
+ allow(graph_conn).to receive(:get) do |path, _params, &block|
334
+ block&.call(Faraday::Request.new)
335
+ response
336
+ end
337
+
338
+ result = runner.get_transcript_content(user_id: 'u1', meeting_id: 'm1', transcript_id: 't1', format: :docx)
339
+ expect(result[:result]).to eq('binary-docx-content')
340
+ end
341
+ end
342
+ end
343
+ ```
344
+
345
+ **Step 2: Run test to verify it fails**
346
+
347
+ Run: `bundle exec rspec spec/legion/extensions/microsoft_teams/runners/transcripts_spec.rb`
348
+ Expected: FAIL — `uninitialized constant Legion::Extensions::MicrosoftTeams::Runners::Transcripts`
349
+
350
+ ---
351
+
352
+ ### Task 4: Transcripts runner - implementation
353
+
354
+ **Files:**
355
+ - Create: `lib/legion/extensions/microsoft_teams/runners/transcripts.rb`
356
+
357
+ **Step 1: Write the runner**
358
+
359
+ ```ruby
360
+ # frozen_string_literal: true
361
+
362
+ require 'legion/extensions/microsoft_teams/helpers/client'
363
+
364
+ module Legion
365
+ module Extensions
366
+ module MicrosoftTeams
367
+ module Runners
368
+ module Transcripts
369
+ include Legion::Extensions::MicrosoftTeams::Helpers::Client
370
+
371
+ CONTENT_TYPES = {
372
+ vtt: 'text/vtt',
373
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
374
+ }.freeze
375
+
376
+ def list_transcripts(user_id:, meeting_id:, **)
377
+ response = graph_connection(**).get("/users/#{user_id}/onlineMeetings/#{meeting_id}/transcripts")
378
+ { result: response.body }
379
+ end
380
+
381
+ def get_transcript(user_id:, meeting_id:, transcript_id:, **)
382
+ response = graph_connection(**).get(
383
+ "/users/#{user_id}/onlineMeetings/#{meeting_id}/transcripts/#{transcript_id}"
384
+ )
385
+ { result: response.body }
386
+ end
387
+
388
+ def get_transcript_content(user_id:, meeting_id:, transcript_id:, format: :vtt, **)
389
+ accept = CONTENT_TYPES.fetch(format, CONTENT_TYPES[:vtt])
390
+ response = graph_connection(**).get(
391
+ "/users/#{user_id}/onlineMeetings/#{meeting_id}/transcripts/#{transcript_id}/content"
392
+ ) do |req|
393
+ req.headers['Accept'] = accept
394
+ end
395
+ { result: response.body }
396
+ end
397
+
398
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
399
+ Legion::Extensions::Helpers.const_defined?(:Lex)
400
+ end
401
+ end
402
+ end
403
+ end
404
+ end
405
+ ```
406
+
407
+ **Step 2: Run tests**
408
+
409
+ Run: `bundle exec rspec spec/legion/extensions/microsoft_teams/runners/transcripts_spec.rb`
410
+ Expected: All 4 examples pass
411
+
412
+ **Step 3: Commit**
413
+
414
+ ```bash
415
+ git add lib/legion/extensions/microsoft_teams/runners/transcripts.rb spec/legion/extensions/microsoft_teams/runners/transcripts_spec.rb
416
+ git commit -m "add transcripts runner with specs"
417
+ ```
418
+
419
+ ---
420
+
421
+ ### Task 5: Wire into entry point and Client
422
+
423
+ **Files:**
424
+ - Modify: `lib/legion/extensions/microsoft_teams.rb`
425
+ - Modify: `lib/legion/extensions/microsoft_teams/client.rb`
426
+
427
+ **Step 1: Add requires to entry point**
428
+
429
+ In `lib/legion/extensions/microsoft_teams.rb`, add after the `presence` require (line 14):
430
+ ```ruby
431
+ require 'legion/extensions/microsoft_teams/runners/meetings'
432
+ require 'legion/extensions/microsoft_teams/runners/transcripts'
433
+ ```
434
+
435
+ **Step 2: Add includes to Client**
436
+
437
+ In `lib/legion/extensions/microsoft_teams/client.rb`, add requires after `presence` require (line 13):
438
+ ```ruby
439
+ require 'legion/extensions/microsoft_teams/runners/meetings'
440
+ require 'legion/extensions/microsoft_teams/runners/transcripts'
441
+ ```
442
+
443
+ And add includes after `include Runners::Presence` (line 29):
444
+ ```ruby
445
+ include Runners::Meetings
446
+ include Runners::Transcripts
447
+ ```
448
+
449
+ **Step 3: Run full test suite**
450
+
451
+ Run: `bundle exec rspec`
452
+ Expected: All specs pass (previous 132 + 12 new = 144)
453
+
454
+ **Step 4: Run linter**
455
+
456
+ Run: `bundle exec rubocop`
457
+ Expected: No offenses
458
+
459
+ **Step 5: Commit**
460
+
461
+ ```bash
462
+ git add lib/legion/extensions/microsoft_teams.rb lib/legion/extensions/microsoft_teams/client.rb
463
+ git commit -m "wire meetings and transcripts runners into client and entry point"
464
+ ```
465
+
466
+ ---
467
+
468
+ ### Task 6: Version bump, CHANGELOG, docs update
469
+
470
+ **Files:**
471
+ - Modify: `lib/legion/extensions/microsoft_teams/version.rb` — bump to `0.4.0`
472
+ - Modify: `CHANGELOG.md` — add entry
473
+ - Modify: `CLAUDE.md` — add Meetings and Transcripts to architecture and permissions table
474
+ - Modify: `README.md` — add Meetings and Transcripts if documented
475
+
476
+ **Step 1: Bump version to 0.4.0**
477
+
478
+ In `version.rb`, change `VERSION = '0.3.0'` to `VERSION = '0.4.0'`
479
+
480
+ **Step 2: Update CHANGELOG.md**
481
+
482
+ Add under `## [Unreleased]` (or create the file):
483
+ ```markdown
484
+ ## [0.4.0] - 2026-03-15
485
+
486
+ ### Added
487
+ - Meetings runner: list, get, create, update, delete online meetings, lookup by join URL, attendance reports
488
+ - Transcripts runner: list, get metadata, get content (VTT/DOCX format support)
489
+ - New Graph API permissions: `OnlineMeeting.Read.All`, `OnlineMeetingTranscript.Read.All`
490
+ ```
491
+
492
+ **Step 3: Update CLAUDE.md architecture diagram and permissions table**
493
+
494
+ Add `Meetings` and `Transcripts` to the Runners list. Add permissions to the table.
495
+
496
+ **Step 4: Run full suite one more time**
497
+
498
+ Run: `bundle exec rspec && bundle exec rubocop`
499
+ Expected: All green
500
+
501
+ **Step 5: Commit**
502
+
503
+ ```bash
504
+ git add -A
505
+ git commit -m "bump to 0.4.0, add changelog and docs for meetings/transcripts"
506
+ ```