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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +16 -0
- data/.gitignore +15 -0
- data/.rspec +3 -0
- data/.rubocop.yml +56 -0
- data/CHANGELOG.md +59 -0
- data/CLAUDE.md +206 -0
- data/Dockerfile +6 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +103 -0
- data/LICENSE +21 -0
- data/README.md +183 -0
- data/docs/plans/2026-03-15-meetings-transcripts-design.md +506 -0
- data/docs/plans/2026-03-16-delegated-oauth-browser-flow-design.md +198 -0
- data/docs/plans/2026-03-16-delegated-oauth-browser-flow-plan.md +1176 -0
- data/lex-microsoft_teams.gemspec +32 -0
- data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +41 -0
- data/lib/legion/extensions/microsoft_teams/actors/cache_sync.rb +54 -0
- data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +105 -0
- data/lib/legion/extensions/microsoft_teams/actors/message_processor.rb +23 -0
- data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +111 -0
- data/lib/legion/extensions/microsoft_teams/client.rb +68 -0
- data/lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb +139 -0
- data/lib/legion/extensions/microsoft_teams/helpers/callback_server.rb +82 -0
- data/lib/legion/extensions/microsoft_teams/helpers/client.rb +38 -0
- data/lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb +59 -0
- data/lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb +64 -0
- data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +150 -0
- data/lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb +140 -0
- data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +209 -0
- data/lib/legion/extensions/microsoft_teams/local_cache/extractor.rb +258 -0
- data/lib/legion/extensions/microsoft_teams/local_cache/record_parser.rb +199 -0
- data/lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb +121 -0
- data/lib/legion/extensions/microsoft_teams/runners/adaptive_cards.rb +59 -0
- data/lib/legion/extensions/microsoft_teams/runners/auth.rb +116 -0
- data/lib/legion/extensions/microsoft_teams/runners/bot.rb +409 -0
- data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +122 -0
- data/lib/legion/extensions/microsoft_teams/runners/channel_messages.rb +52 -0
- data/lib/legion/extensions/microsoft_teams/runners/channels.rb +53 -0
- data/lib/legion/extensions/microsoft_teams/runners/chats.rb +51 -0
- data/lib/legion/extensions/microsoft_teams/runners/local_cache.rb +62 -0
- data/lib/legion/extensions/microsoft_teams/runners/meetings.rb +68 -0
- data/lib/legion/extensions/microsoft_teams/runners/messages.rb +48 -0
- data/lib/legion/extensions/microsoft_teams/runners/presence.rb +31 -0
- data/lib/legion/extensions/microsoft_teams/runners/subscriptions.rb +76 -0
- data/lib/legion/extensions/microsoft_teams/runners/teams.rb +33 -0
- data/lib/legion/extensions/microsoft_teams/runners/transcripts.rb +45 -0
- data/lib/legion/extensions/microsoft_teams/transport/exchanges/messages.rb +15 -0
- data/lib/legion/extensions/microsoft_teams/transport/messages/teams_message.rb +16 -0
- data/lib/legion/extensions/microsoft_teams/transport/queues/messages_process.rb +16 -0
- data/lib/legion/extensions/microsoft_teams/version.rb +9 -0
- data/lib/legion/extensions/microsoft_teams.rb +44 -0
- 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
|
+
```
|