lex-microsoft_teams 0.6.45 → 0.6.46

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +2 -0
  4. data/CLAUDE.md +29 -266
  5. data/lex-microsoft_teams.gemspec +1 -0
  6. data/lib/legion/extensions/microsoft_teams/absorbers/channel.rb +29 -17
  7. data/lib/legion/extensions/microsoft_teams/absorbers/chat.rb +20 -14
  8. data/lib/legion/extensions/microsoft_teams/absorbers/meeting.rb +21 -14
  9. data/lib/legion/extensions/microsoft_teams/actors/absorb_channel.rb +7 -4
  10. data/lib/legion/extensions/microsoft_teams/actors/absorb_chat.rb +7 -4
  11. data/lib/legion/extensions/microsoft_teams/actors/absorb_meeting.rb +7 -4
  12. data/lib/legion/extensions/microsoft_teams/actors/api_ingest.rb +13 -15
  13. data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +3 -3
  14. data/lib/legion/extensions/microsoft_teams/actors/cache_sync.rb +2 -1
  15. data/lib/legion/extensions/microsoft_teams/actors/channel_poller.rb +25 -16
  16. data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +16 -10
  17. data/lib/legion/extensions/microsoft_teams/actors/incremental_sync.rb +8 -8
  18. data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +30 -22
  19. data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +14 -8
  20. data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +14 -8
  21. data/lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb +13 -16
  22. data/lib/legion/extensions/microsoft_teams/helpers/client.rb +10 -4
  23. data/lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb +3 -2
  24. data/lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb +4 -1
  25. data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +8 -2
  26. data/lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb +5 -3
  27. data/lib/legion/extensions/microsoft_teams/helpers/trace_retriever.rb +6 -1
  28. data/lib/legion/extensions/microsoft_teams/local_cache/extractor.rb +1 -1
  29. data/lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb +2 -1
  30. data/lib/legion/extensions/microsoft_teams/runners/activities.rb +42 -0
  31. data/lib/legion/extensions/microsoft_teams/runners/ai_insights.rb +61 -0
  32. data/lib/legion/extensions/microsoft_teams/runners/api_ingest.rb +19 -14
  33. data/lib/legion/extensions/microsoft_teams/runners/app_installations.rb +85 -0
  34. data/lib/legion/extensions/microsoft_teams/runners/auth.rb +3 -107
  35. data/lib/legion/extensions/microsoft_teams/runners/bot.rb +20 -12
  36. data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +7 -5
  37. data/lib/legion/extensions/microsoft_teams/runners/call_events.rb +72 -0
  38. data/lib/legion/extensions/microsoft_teams/runners/channel_messages.rb +85 -0
  39. data/lib/legion/extensions/microsoft_teams/runners/channels.rb +68 -0
  40. data/lib/legion/extensions/microsoft_teams/runners/chats.rb +56 -0
  41. data/lib/legion/extensions/microsoft_teams/runners/files.rb +77 -0
  42. data/lib/legion/extensions/microsoft_teams/runners/local_cache.rb +4 -0
  43. data/lib/legion/extensions/microsoft_teams/runners/loop.rb +5 -0
  44. data/lib/legion/extensions/microsoft_teams/runners/meeting_artifacts.rb +54 -0
  45. data/lib/legion/extensions/microsoft_teams/runners/meetings.rb +92 -0
  46. data/lib/legion/extensions/microsoft_teams/runners/messages.rb +61 -0
  47. data/lib/legion/extensions/microsoft_teams/runners/ownership.rb +11 -0
  48. data/lib/legion/extensions/microsoft_teams/runners/people.rb +24 -0
  49. data/lib/legion/extensions/microsoft_teams/runners/presence.rb +14 -0
  50. data/lib/legion/extensions/microsoft_teams/runners/profile_ingest.rb +17 -4
  51. data/lib/legion/extensions/microsoft_teams/runners/subscriptions.rb +86 -0
  52. data/lib/legion/extensions/microsoft_teams/runners/teams.rb +30 -0
  53. data/lib/legion/extensions/microsoft_teams/runners/transcripts.rb +35 -0
  54. data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
  55. data/lib/legion/extensions/microsoft_teams.rb +10 -3
  56. metadata +20 -8
  57. data/lib/legion/extensions/microsoft_teams/actors/auth_validator.rb +0 -123
  58. data/lib/legion/extensions/microsoft_teams/actors/token_refresher.rb +0 -122
  59. data/lib/legion/extensions/microsoft_teams/cli/auth.rb +0 -94
  60. data/lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb +0 -270
  61. data/lib/legion/extensions/microsoft_teams/helpers/callback_server.rb +0 -90
  62. data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +0 -412
  63. data/lib/legion/extensions/microsoft_teams/hooks/auth.rb +0 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a1c2dc593a74891fd6a394c6da088eeea72e5c44ec5a13f412dfafe9ce43999
4
- data.tar.gz: bd6da610d115de11d6d8196a2f8123044e77089fe2d8ad8ed9b8f27a4a69d4d2
3
+ metadata.gz: ad43c9c0712ae13673149df6f3a51a7ba24c977bfcef37739fc6caea74d7e989
4
+ data.tar.gz: 8691eecd15c48afe8849adb1e959d29a19c75b5ddfb9e4083b2070f007de8984
5
5
  SHA512:
6
- metadata.gz: f90c34c0164a9350f5a5d8b345b508c94fcc45ebc4985c52bf2bef6f42582b2d58bca2681810aed7226f65d6a1f74060bdf842e6587b21614bb7c2cc18d9e7e2
7
- data.tar.gz: 5a5c0f04323cd85c10cec26cf259d96c2f0770064e407e760847a412e0e1076da7afc9aabc48bc793c5d7e321393c3affb267d7256f81539e5ddffd1f1db6bb8
6
+ metadata.gz: 56678da313af129c9d60071cd113fc174c39983122aa4ac9b11a3c8b49e05c29672c347cd1c433a9ea623823085cef41cfa18043fb5b39ccc00329fba89b5636
7
+ data.tar.gz: 8ecdc1951210be162921639b7eb0c0bf05f7bce9268ea5b0548b4f6f3503869539df632575af822f230e54634a99037a9d8ae30271c96c252ab2978b1daa79a8
data/.gitignore CHANGED
@@ -6,6 +6,7 @@
6
6
  /pkg/
7
7
  /spec/reports/
8
8
  /tmp/
9
+ /Gemfile.lock
9
10
 
10
11
  # rspec failure tracking
11
12
  .rspec_status
data/CHANGELOG.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Changelog
2
2
 
3
3
  ## [Unreleased]
4
+ ### Fixed
5
+ - Bot response and observation extraction now pass explicit system and user messages to native `Legion::LLM.chat` dispatch instead of routing through the legacy nil-input `llm_chat` helper path.
4
6
 
5
7
  ## [0.6.45] - 2026-04-23
6
8
 
data/CLAUDE.md CHANGED
@@ -1,273 +1,36 @@
1
- # lex-microsoft_teams: Microsoft Teams Integration for LegionIO
1
+ # lex-microsoft_teams
2
2
 
3
- **Repository Level 3 Documentation**
4
- - **Parent (Level 2)**: `/Users/miverso2/rubymine/legion/extensions/CLAUDE.md`
5
- - **Parent (Level 1)**: `/Users/miverso2/rubymine/legion/CLAUDE.md`
6
-
7
- ## Purpose
8
-
9
- Legion Extension that connects LegionIO to Microsoft Teams via Graph API and Bot Framework. Provides runners for chats, channels, messages, subscriptions (change notifications), adaptive cards, bot communication, and an AI-powered bot with conversation observation.
10
-
11
- **GitHub**: https://github.com/LegionIO/lex-microsoft_teams
12
- **License**: MIT
13
- **Version**: 0.6.34
3
+ Microsoft Teams integration via Graph API and Bot Framework. Provides runners for chats, channels, messages, subscriptions, adaptive cards, bot communication (direct + conversation observer), presence, meetings, transcripts, local cache ingestion, cognitive profile pipeline, and delegated OAuth authentication.
14
4
 
15
5
  ## Architecture
16
6
 
17
7
  ```
18
8
  Legion::Extensions::MicrosoftTeams
19
- ├── Runners/
20
- ├── Auth # OAuth2 client credentials (Graph + Bot Framework) + auth_callback for hook
21
- ├── Teams # List/get teams, members
22
- ├── Chats # 1:1 and group chat CRUD
23
- ├── Messages # Chat message send/read/reply
24
- ├── Channels # Team channel CRUD
25
- ├── ChannelMessages # Channel message send/read/reply
26
- ├── Subscriptions # Graph change notification webhooks
27
- ├── AdaptiveCards # Adaptive Card payload builder
28
- ├── Bot # Bot Framework + AI bot (handle_message, handle_command, observe_message)
29
- ├── Presence # Graph API user presence
30
- ├── Meetings # Online meeting CRUD, join URL lookup, attendance reports
31
- ├── Transcripts # Meeting transcript list/get/content (VTT/DOCX)
32
- ├── LocalCache # Offline message extraction from local LevelDB cache
33
- ├── CacheIngest # Ingest cached messages into lex-memory as episodic traces
34
- │ ├── People # Graph API /me and /me/people (profile + relevant contacts)
35
- │ ├── ProfileIngest # Four-phase cognitive pipeline (self, people, conversations, teams/meetings)
36
- │ ├── ApiIngest # Graph API ingest (top contacts, 1:1 chat messages, HWM dedup)
37
- │ ├── AiInsights # Graph API meeting AI insights, recordings, call records
38
- │ └── Ownership # Graph API ownership sync
39
- ├── Actors/
40
- │ ├── CacheBulkIngest # Once: full cache ingest at startup (imprint window support)
41
- │ ├── CacheSync # Every 5min: incremental ingest of new messages
42
- │ ├── DirectChatPoller # Every 5s: polls bot DM chats via Graph API
43
- │ ├── ObservedChatPoller # Every 30s: polls subscribed human conversations (compliance-gated)
44
- │ ├── MessageProcessor # Subscription: consumes AMQP queue, routes by mode
45
- │ ├── AuthValidator # Once (90s delay): validates/restores delegated tokens on boot
46
- │ ├── TokenRefresher # Every 15min (configurable): keeps delegated tokens fresh
47
- │ ├── ProfileIngest # Once (95s delay): four-phase data pipeline after auth
48
- │ ├── ApiIngest # Every 30min (95s delay): Graph API ingest with HWM dedup
49
- │ ├── ChannelPoller # Every 60s: polls joined team channels for new messages
50
- │ ├── MeetingIngest # Every 5min: polls online meetings, fetches transcripts and AI insights
51
- │ ├── PresencePoller # Every 60s: polls Graph API presence, logs changes
52
- │ ├── AbsorbMeeting # Subscription: absorbs Teams meeting data via absorber framework
53
- │ └── IncrementalSync # Every 15min: periodic re-sync with HWM dedup
54
- ├── Transport/
55
- │ ├── Exchanges/Messages # teams.messages topic exchange
56
- │ ├── Queues/MessagesProcess # teams.messages.process durable queue
57
- │ └── Messages/TeamsMessage # Message schema with routing key
58
- ├── LocalCache/
59
- │ ├── SSTableReader # Pure Ruby LevelDB .ldb file reader (Snappy decompression)
60
- │ ├── RecordParser # Chromium IndexedDB value parser (field-value pairing)
61
- │ └── Extractor # Message extraction, filtering, dedup from local cache
62
- ├── Helpers/
63
- │ ├── Client # Three connection builders (Graph, Bot, OAuth)
64
- │ ├── HighWaterMark # Per-chat message dedup via legion-cache (with in-memory fallback)
65
- │ ├── PromptResolver # Layered system prompt resolution (settings -> mode -> per-conversation)
66
- │ ├── SessionManager # Multi-turn LLM session lifecycle with lex-memory persistence
67
- │ ├── TokenCache # In-memory OAuth token cache with pre-expiry refresh (app + delegated slots, authenticated?/previously_authenticated? predicates)
68
- │ ├── SubscriptionRegistry # Conversation observation subscriptions (in-memory + lex-memory)
69
- │ ├── BrowserAuth # Delegated OAuth orchestrator (PKCE, headless detection, browser launch, API hook detection)
70
- │ ├── CallbackServer # Ephemeral TCP server for OAuth redirect callback
71
- │ ├── PermissionGuard # Circuit breaker for 403 errors with exponential backoff
72
- │ ├── TraceRetriever # Retrieves memory traces from the shared store for bot context (2000-token budget, strength-ranked dedup)
73
- │ ├── TransformDefinitions # lex-transformer definitions for conversation extraction and person summary
74
- │ └── GraphClient # Graph API wrapper mixin (graph_get, graph_post, graph_paginate, GraphError)
75
- ├── Hooks/
76
- │ └── Auth # OAuth callback hook (mount '/callback') → /api/extensions/microsoft_teams/hooks/auth/handle
77
- ├── CLI/
78
- │ └── Auth # CLI module for `legion lex exec teams auth login/status`
79
- └── Client # Standalone client (includes all runners)
80
- ```
81
-
82
- ## Delegated Authentication (v0.5.0)
83
-
84
- Opt-in browser-based OAuth for delegated Microsoft Graph permissions. Two flows:
85
-
86
- - **Authorization Code + PKCE** (primary): Opens browser for Entra ID login. When the Legion API is running, uses the hook URL (`/api/extensions/microsoft_teams/hooks/auth/handle`) with `Legion::Events` for callback notification; otherwise falls back to an ephemeral local port via `CallbackServer`
87
- - **Device Code** (fallback): Auto-selected in headless/SSH environments (no `DISPLAY`/`WAYLAND_DISPLAY`)
88
-
89
- Tokens stored in Vault at a per-user path (`{USER}/microsoft_teams/delegated_token`, where `{USER}` is the system username) with configurable pre-expiry silent refresh. CLI command: `legion auth teams`. Hook route: `POST /api/extensions/microsoft_teams/hooks/auth/handle` for daemon re-auth (routed through LexDispatch for RBAC/audit).
90
-
91
- Key files: `Helpers::BrowserAuth` (orchestrator), `Helpers::CallbackServer` (ephemeral TCP), `Runners::Auth` (authorize_url, exchange_code, refresh_delegated_token, auth_callback), `Helpers::TokenCache` (delegated slot), `Hooks::Auth` (hook class with mount path).
92
-
93
- ## Token Lifecycle (v0.5.4)
94
-
95
- Automatic delegated token management: validate on boot, refresh on a timer, re-authenticate via browser when a previously authenticated user's token expires.
96
-
97
- - **AuthValidator** (Once actor, 2s delay): Loads token from Vault/local file on boot, attempts refresh. If refresh fails and user previously authenticated (`previously_authenticated?` — local file exists), fires BrowserAuth. Silent for users who never opted in.
98
- - **TokenRefresher** (Every actor, 15min default): Guards with `authenticated?` (live token in memory). Refreshes and persists on each tick. On failure, same re-auth logic as AuthValidator.
99
- - **TokenCache predicates**: `authenticated?` = live token in `@delegated_cache`. `previously_authenticated?` = local token file exists on disk. This distinction controls auto re-auth (returning users only) vs silence (never-authenticated users).
100
-
101
- Configuration: `settings[:microsoft_teams][:auth][:delegated][:refresh_interval]` (default 900 seconds).
102
-
103
- Design doc: `docs/plans/2026-03-19-teams-token-lifecycle-design.md`
104
-
105
- ## Cognitive Pipeline (v0.6.0)
106
-
107
- Four-phase data ingestion that runs after delegated auth to build the agent's social context:
108
-
109
- 1. **Self** (`ingest_self`): Fetches `/me` profile and `/me/presence`, stores as identity trace
110
- 2. **People** (`ingest_people`): Fetches `/me/people` (top 25), stores each as semantic trace
111
- 3. **Conversations** (`ingest_conversations`): For top N people, fetches recent chat messages, stores as episodic traces
112
- 4. **Teams & Meetings** (`ingest_teams_and_meetings`): Fetches joined teams and recent meetings, stores as semantic + episodic traces
113
-
114
- ### Actors
115
-
116
- - **ProfileIngest** (Once, 5s delay): Fires `full_ingest` after boot. Only enabled when lex-memory is available and a delegated token exists.
117
- - **IncrementalSync** (Every, 15min): Fires `incremental_sync` using extended high-water marks for dedup. Configurable via `settings[:microsoft_teams][:ingest][:incremental_interval]`.
118
-
119
- ### Supporting Components
120
-
121
- - **Runners::People**: Graph API `/me` and `/me/people` endpoints with `user_id:` flexibility
122
- - **Helpers::PermissionGuard**: Circuit breaker for Graph API 403 errors with exponential backoff (60s → 5min → 30min → 2hr → 8hr cap). Wraps API calls via `guarded_request(endpoint) { block }`.
123
- - **Helpers::TransformDefinitions**: Structured extraction schemas for lex-transformer (`conversation_extract`, `person_summary`)
124
- - **Extended HWM**: `get/set/update_extended_hwm` with dual timestamps (last_message_at + last_ingested_at) and `persist_hwm_as_trace` / `restore_hwm_from_traces` for cross-boot memory
125
-
126
- ### CLI
127
-
128
- `CLI::Auth` provides `legion lex exec teams auth login` and `legion lex exec teams auth status` via the LEX CLI manifest system. Uses `cli_alias: 'teams'` for short-form dispatch. The Thor command is `invoke_ext` with `exec` as an alias (`run` is a Thor reserved word).
129
-
130
- Design doc: `docs/plans/2026-03-20-teams-cognitive-pipeline-implementation.md`
131
-
132
- ## AI Bot (v0.2.0)
133
-
134
- Two operating modes, both using polling (Graph API) with AMQP-based message routing:
135
-
136
- ### Mode 1: Direct Chat
137
- User DMs the bot 1:1. Bot responds via legion-llm with multi-turn session context.
138
-
139
- ```
140
- DirectChatPoller (5s) → AMQP exchange → MessageProcessor → Bot::handle_message
141
- → TraceRetriever.retrieve → SessionManager.get_or_create → llm_session.ask(text) → Graph API reply
142
- ```
143
-
144
- `TraceRetriever` (v0.6.17) fetches memory traces from the shared store (sender, teams, chat-scoped domains) before each response. Up to a 2000-token budget; strength-ranked with deduplication. Appended to the resolved system prompt via `PromptResolver#resolve_prompt(trace_context:)`. Degrades gracefully when lex-memory is unavailable.
145
-
146
- ### Mode 2: Conversation Observer
147
- User subscribes the bot to watch a human 1:1 conversation. Bot passively extracts tasks, context, and relationship data.
148
-
149
- ```
150
- ObservedChatPoller (30s) → AMQP exchange → MessageProcessor → Bot::observe_message
151
- → LLM extraction → lex-memory episodic trace → optional notification to owner
152
- ```
153
-
154
- **Observer is disabled by default** (`settings[:bot][:observe][:enabled] = false`). Compliance gate — must be explicitly enabled.
155
-
156
- ### Message Flow
157
-
158
- Both pollers publish to the same `teams.messages` AMQP exchange. The MessageProcessor subscription actor consumes from the queue and routes by `mode` field (`:direct` → `handle_message`, `:observe` → `observe_message`). This architecture supports a future webhook path: a `POST /api/hooks/microsoft_teams/bot` endpoint would publish to the same exchange with zero runner changes.
159
-
160
- ### Configuration
161
-
162
- Layered config cascade in `Legion::Settings[:microsoft_teams]`:
163
-
164
- ```yaml
165
- microsoft_teams:
166
- auth:
167
- tenant_id: "..."
168
- client_id: "..."
169
- client_secret: "vault://secret/teams/client_secret"
170
- delegated:
171
- auto_authenticate: false # when true, opens browser OAuth on boot even for first-time users
172
- refresh_interval: 900 # seconds (TokenRefresher interval)
173
- bot:
174
- bot_id: "28:your-bot-id"
175
- direct_poll_interval: 5 # seconds
176
- observe_poll_interval: 30 # seconds
177
- system_prompt: "You are a helpful assistant."
178
- direct:
179
- system_prompt: ~ # nil = inherit base
180
- observe:
181
- enabled: false # compliance gate
182
- notify: false # DM notifications for action items
183
- system_prompt: "Extract action items. Return structured JSON."
184
- llm:
185
- model: ~ # nil = use legion-llm router
186
- intent:
187
- capability: moderate
188
- session:
189
- flush_threshold: 20 # messages before auto-persist
190
- idle_timeout: 900 # seconds (15 min)
191
- max_recent_messages: 5 # kept raw on persist
192
- ```
193
-
194
- Per-conversation overrides stored in lex-memory (system_prompt_append, llm model/intent).
195
-
196
- ### Key Design Decisions
197
-
198
- - **Polling first, webhook later**: All connections outbound from user's local LegionIO instance. No public endpoint needed.
9
+ ├── Runners/ Auth, Teams, Chats, Messages, Channels, ChannelMessages,
10
+ Subscriptions, AdaptiveCards, Bot, Presence, Meetings,
11
+ Transcripts, LocalCache, CacheIngest, People, ProfileIngest,
12
+ ApiIngest, AiInsights, Ownership
13
+ ├── Actors/ CacheBulkIngest, CacheSync(5m), DirectChatPoller(5s),
14
+ ObservedChatPoller(30s), MessageProcessor, AuthValidator,
15
+ TokenRefresher(15m), ProfileIngest, ApiIngest(30m),
16
+ ChannelPoller(60s), MeetingIngest(5m), PresencePoller(60s),
17
+ AbsorbMeeting, IncrementalSync(15m)
18
+ ├── Transport/ teams.messages exchange + queue
19
+ ├── LocalCache/ SSTableReader, RecordParser, Extractor (LevelDB offline)
20
+ ├── Helpers/ Client, HighWaterMark, PromptResolver, SessionManager,
21
+ TokenCache, SubscriptionRegistry, BrowserAuth, CallbackServer,
22
+ PermissionGuard, TraceRetriever, TransformDefinitions, GraphClient
23
+ ├── Hooks/Auth OAuth callback hook
24
+ └── CLI/Auth `legion lex exec teams auth login/status`
25
+ ```
26
+
27
+ ## Key Design Decisions
28
+
29
+ - **Polling first, webhook later**: All connections outbound. No public endpoint needed.
199
30
  - **AMQP-first routing**: Pollers and future webhooks publish to the same exchange. Decouples ingestion from processing.
200
- - **High-water marks**: Per-chat last-seen timestamp in legion-cache prevents reprocessing. Falls back to in-memory when cache unavailable.
201
- - **Session persistence**: Multi-turn sessions flush to lex-memory on threshold (20 msgs), idle timeout (15 min), or shutdown. Restored on restart via summary + recent messages.
202
- - **Token caching**: In-memory OAuth token cache refreshes 60 seconds before expiry. Both pollers share a `TokenCache` instance instead of hitting OAuth every cycle.
203
- - **Subscription registry**: In-memory working set of observed conversations, persisted to lex-memory on change. No legion-data migration needed.
204
- - **Design docs**: `docs/work/completed/2026-03-15-teams-ai-bot-design.md`, `docs/work/completed/2026-03-15-teams-bot-commands-design.md`
205
-
206
- ### Bot Commands (v0.3.0)
207
-
208
- Keyword-based command detection in bot DMs, checked before LLM response:
209
-
210
- | Command | Action |
211
- |---------|--------|
212
- | `watch <name>` | Find chat via Graph API, subscribe to observe |
213
- | `stop watching <name>` / `unwatch <name>` | Unsubscribe |
214
- | `watching` / `list` / `subscriptions` | List active subscriptions |
215
- | `pause <name>` | Disable subscription temporarily |
216
- | `resume <name>` | Re-enable paused subscription |
217
- | `prefer <value>` | Set preference (concise, detailed, formal, casual, etc.) |
218
- | `preferences` / `my preferences` | Show current resolved preferences |
219
- | `reset preferences` | Clear explicit preferences, fall back to observed/defaults |
220
- | anything else | LLM response (existing flow) |
221
-
222
- ## API Surface
223
-
224
- Four distinct APIs accessed via Faraday + one local data source:
225
- - **Microsoft Graph API** (`graph.microsoft.com/v1.0`) — chats, channels, messages, teams, subscriptions, presence
226
- - **Bot Framework Service** (`service_url` per conversation) — send activities, create conversations
227
- - **Entra ID OAuth** (`login.microsoftonline.com`) — client_credentials token acquisition
228
- - **Local LevelDB Cache** (Chromium IndexedDB) — offline message extraction from Teams 2.x local storage
229
-
230
- ## Graph API Permissions Required
231
-
232
- | Permission | Type | Purpose |
233
- |-----------|------|---------|
234
- | `Chat.Read.All` | Application | Read chat messages |
235
- | `Chat.ReadWrite.All` | Application | Send chat messages |
236
- | `ChannelMessage.Read.All` | Application | Read channel messages |
237
- | `ChannelMessage.Send` | Delegated | Send channel messages |
238
- | `Team.ReadBasic.All` | Application | List teams and members |
239
- | `Channel.ReadBasic.All` | Application | List channels |
240
- | `Presence.Read.All` | Application | Read user presence |
241
- | `OnlineMeetings.Read` | Delegated | Read online meetings (user context) |
242
- | `OnlineMeetings.Read.All` | Application | Read online meetings |
243
- | `OnlineMeetingTranscript.Read.All` | Application/Delegated | Read meeting transcripts |
244
- | `People.Read` | Delegated | Read relevant people for cognitive pipeline |
245
-
246
- For bot scenarios, register the Entra app as a Teams Bot via Bot Framework portal.
247
-
248
- ## Dependencies
249
-
250
- | Gem | Purpose |
251
- |-----|---------|
252
- | `faraday` (>= 2.0) | HTTP client for Graph API, Bot Framework, and OAuth |
253
- | `snappy` (>= 0.5) | Snappy decompression for LevelDB SSTable blocks |
254
- | `base64` (>= 0.1) | Base64 encoding for PKCE (removed from Ruby 3.4 default gems) |
255
-
256
- Optional framework dependencies (guarded with `defined?`, not in gemspec):
257
- - `legion-transport` — AMQP exchange/queue/message for bot message routing
258
- - `legion-llm` — LLM routing for bot responses (`llm_chat`, `llm_session`)
259
- - `legion-cache` — High-water mark storage for message dedup
260
- - `lex-memory` — Session persistence and episodic trace storage
261
- - `lex-mesh` — PreferenceProfile for per-user preference resolution
262
-
263
- ## Testing
264
-
265
- ```bash
266
- bundle install
267
- bundle exec rspec # 352 specs across 44 spec files
268
- bundle exec rubocop # Clean
269
- ```
270
-
271
- ---
272
-
273
- **Maintained By**: Matthew Iverson (@Esity)
31
+ - **High-water marks**: Per-chat last-seen timestamp in legion-cache prevents reprocessing.
32
+ - **Delegated auth**: Browser-based OAuth (PKCE primary, device code fallback for headless). Tokens stored in Vault with silent refresh.
33
+ - **Cognitive pipeline**: Four-phase data ingestion (self, people, conversations, teams/meetings) builds social context after auth.
34
+ - **Observer disabled by default**: Compliance gate (`settings[:bot][:observe][:enabled] = false`).
35
+ - **Session persistence**: Multi-turn sessions flush to lex-memory on threshold/idle/shutdown.
36
+ - **Token lifecycle**: AuthValidator on boot + TokenRefresher every 15min. `authenticated?` vs `previously_authenticated?` controls auto re-auth behavior.
@@ -35,5 +35,6 @@ Gem::Specification.new do |spec|
35
35
  spec.add_dependency 'legion-logging', '>= 1.3.2'
36
36
  spec.add_dependency 'legion-settings', '>= 1.3.14'
37
37
  spec.add_dependency 'legion-transport', '>= 1.3.9'
38
+ spec.add_dependency 'lex-identity-entra', '>= 0.3.0'
38
39
  spec.add_dependency 'snappy', '>= 0.5'
39
40
  end
@@ -10,6 +10,7 @@ module Legion
10
10
  description 'Absorbs a Teams channel thread (messages, replies, members) into Apollo'
11
11
 
12
12
  def absorb(url: nil, content: nil, metadata: {}, context: {}) # rubocop:disable Lint/UnusedMethodArgument
13
+ log.debug("Channel#absorb url=#{url.inspect}")
13
14
  report_progress(message: 'extracting ids from url')
14
15
  ids = extract_ids(url)
15
16
  return { success: false, error: 'could not extract team/channel ids from url' } unless ids
@@ -36,9 +37,10 @@ module Legion
36
37
  ingest_members(team_id, channel_id, channel_name, results)
37
38
 
38
39
  report_progress(message: 'done', percent: 100)
40
+ log.info("Channel#absorb complete team_id=#{team_id} channel=#{channel_name} chunks=#{results[:chunks]}")
39
41
  results.merge(success: true)
40
42
  rescue StandardError => e
41
- log.error("Channel absorber failed: #{e.message}")
43
+ handle_exception(e, level: :error, operation: 'Channel#absorb', url: url)
42
44
  { success: false, error: e.message }
43
45
  end
44
46
 
@@ -53,20 +55,17 @@ module Legion
53
55
  end
54
56
 
55
57
  def graph_token
56
- return @graph_token if defined?(@graph_token)
57
-
58
- @graph_token = begin
59
- Helpers::TokenCache.instance.cached_delegated_token if defined?(Helpers::TokenCache)
60
- rescue StandardError => e
61
- log.warn("graph_token unavailable: #{e.message}")
62
- nil
63
- end
58
+ Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(:delegated)
59
+ rescue StandardError => e
60
+ handle_exception(e, level: :debug, operation: 'Channel#graph_token')
61
+ nil
64
62
  end
65
63
 
66
64
  # Teams channel URL formats:
67
65
  # /l/channel/<encoded_channel_id>/<channel_name>?groupId=<team_id>&...
68
66
  # /l/message/<encoded_channel_id>/<message_id>?groupId=<team_id>&...
69
67
  def extract_ids(url)
68
+ log.debug("Channel#extract_ids url=#{url.inspect}")
70
69
  return nil unless url.is_a?(String)
71
70
 
72
71
  uri = URI.parse(url)
@@ -86,22 +85,25 @@ module Legion
86
85
 
87
86
  { team_id: team_id, channel_id: channel_id, message_id: message_id }
88
87
  rescue StandardError => e
89
- log.debug("extract_ids failed: #{e.message}")
88
+ handle_exception(e, level: :debug, operation: 'Channel#extract_ids', url: url)
90
89
  nil
91
90
  end
92
91
 
93
92
  def resolve_channel(team_id, channel_id)
93
+ log.debug("Channel#resolve_channel team_id=#{team_id} channel_id=#{channel_id}")
94
94
  response = channels_runner.get_channel(team_id: team_id, channel_id: channel_id, token: graph_token)
95
95
  body = response.is_a?(Hash) ? response[:result] : nil
96
96
  return nil unless body.is_a?(Hash) && !body['error'] && !body[:error]
97
97
 
98
98
  body
99
99
  rescue StandardError => e
100
- log.warn("resolve_channel failed: #{e.message}")
100
+ handle_exception(e, level: :warn, operation: 'Channel#resolve_channel',
101
+ team_id: team_id, channel_id: channel_id)
101
102
  nil
102
103
  end
103
104
 
104
105
  def ingest_messages(team_id, channel_id, channel_name, results)
106
+ log.debug("Channel#ingest_messages team_id=#{team_id} channel_id=#{channel_id}")
105
107
  report_progress(message: 'fetching channel messages', percent: 25)
106
108
  response = channel_messages_runner.list_channel_messages(
107
109
  team_id: team_id, channel_id: channel_id, top: 50, token: graph_token
@@ -114,10 +116,12 @@ module Legion
114
116
  ingest_single_message(team_id, channel_id, msg, channel_name, results)
115
117
  end
116
118
  rescue StandardError => e
117
- log.warn("Channel message ingest failed: #{e.message}")
119
+ handle_exception(e, level: :warn, operation: 'Channel#ingest_messages',
120
+ team_id: team_id, channel_id: channel_id)
118
121
  end
119
122
 
120
123
  def ingest_thread(team_id, channel_id, message_id, channel_name, results)
124
+ log.debug("Channel#ingest_thread team_id=#{team_id} channel_id=#{channel_id} message_id=#{message_id}")
121
125
  report_progress(message: 'fetching thread root message', percent: 20)
122
126
  response = channel_messages_runner.get_channel_message(
123
127
  team_id: team_id, channel_id: channel_id, message_id: message_id, token: graph_token
@@ -127,7 +131,8 @@ module Legion
127
131
 
128
132
  ingest_single_message(team_id, channel_id, body, channel_name, results, scoped_thread: true)
129
133
  rescue StandardError => e
130
- log.warn("Thread ingest failed: #{e.message}")
134
+ handle_exception(e, level: :warn, operation: 'Channel#ingest_thread',
135
+ team_id: team_id, channel_id: channel_id, message_id: message_id)
131
136
  end
132
137
 
133
138
  def ingest_single_message(team_id, channel_id, msg, channel_name, results, scoped_thread: false)
@@ -135,7 +140,8 @@ module Legion
135
140
  return if msg['deletedDateTime'] || msg[:deletedDateTime]
136
141
  return if (msg['messageType'] || msg[:messageType]) == 'unknownFutureValue'
137
142
 
138
- msg_id = msg['id'] || msg[:id]
143
+ msg_id = msg['id'] || msg[:id]
144
+ log.debug("Channel#ingest_single_message msg_id=#{msg_id} channel=#{channel_name}")
139
145
  sender = msg.dig('from', 'user', 'displayName') ||
140
146
  msg.dig(:from, :user, :displayName) ||
141
147
  'unknown'
@@ -166,12 +172,14 @@ module Legion
166
172
  )
167
173
  results[:chunks] += 1
168
174
  rescue StandardError => e
169
- log.warn("ingest_single_message failed: #{e.message}")
175
+ handle_exception(e, level: :warn, operation: 'Channel#ingest_single_message',
176
+ msg_id: msg_id, channel: channel_name)
170
177
  end
171
178
 
172
179
  def fetch_reply_lines(team_id, channel_id, message_id)
173
180
  return [] unless message_id
174
181
 
182
+ log.debug("Channel#fetch_reply_lines team_id=#{team_id} channel_id=#{channel_id} message_id=#{message_id}")
175
183
  response = channel_messages_runner.list_channel_message_replies(
176
184
  team_id: team_id, channel_id: channel_id, message_id: message_id, top: 50, token: graph_token
177
185
  )
@@ -193,11 +201,13 @@ module Legion
193
201
  " ↳ [#{timestamp}] #{sender}: #{text}"
194
202
  end
195
203
  rescue StandardError => e
196
- log.debug("fetch_reply_lines failed: #{e.message}")
204
+ handle_exception(e, level: :debug, operation: 'Channel#fetch_reply_lines',
205
+ team_id: team_id, channel_id: channel_id, message_id: message_id)
197
206
  []
198
207
  end
199
208
 
200
209
  def ingest_members(team_id, channel_id, channel_name, results)
210
+ log.debug("Channel#ingest_members team_id=#{team_id} channel_id=#{channel_id}")
201
211
  report_progress(message: 'fetching channel members', percent: 85)
202
212
  response = channels_runner.list_channel_members(
203
213
  team_id: team_id, channel_id: channel_id, token: graph_token
@@ -216,8 +226,10 @@ module Legion
216
226
  metadata: { team_id: team_id, channel_id: channel_id, member_count: names.length }
217
227
  )
218
228
  results[:chunks] += 1
229
+ log.info("Channel#ingest_members stored #{names.length} members for channel=#{channel_name}")
219
230
  rescue StandardError => e
220
- log.warn("Member ingest failed: #{e.message}")
231
+ handle_exception(e, level: :warn, operation: 'Channel#ingest_members',
232
+ team_id: team_id, channel_id: channel_id)
221
233
  end
222
234
  end
223
235
  end
@@ -10,6 +10,7 @@ module Legion
10
10
  description 'Absorbs a Teams chat thread (messages, replies, participants) into Apollo'
11
11
 
12
12
  def absorb(url: nil, content: nil, metadata: {}, context: {}) # rubocop:disable Lint/UnusedMethodArgument
13
+ log.debug("Chat#absorb url=#{url.inspect}")
13
14
  report_progress(message: 'extracting chat id from url')
14
15
  chat_id = extract_chat_id(url)
15
16
  return { success: false, error: 'could not extract chat id from url' } unless chat_id
@@ -25,9 +26,10 @@ module Legion
25
26
  ingest_members(chat_id, topic, results)
26
27
 
27
28
  report_progress(message: 'done', percent: 100)
29
+ log.info("Chat#absorb complete chat_id=#{chat_id} topic=#{topic} chunks=#{results[:chunks]}")
28
30
  results.merge(success: true)
29
31
  rescue StandardError => e
30
- log.error("Chat absorber failed: #{e.message}")
32
+ handle_exception(e, level: :error, operation: 'Chat#absorb', url: url)
31
33
  { success: false, error: e.message }
32
34
  end
33
35
 
@@ -42,17 +44,14 @@ module Legion
42
44
  end
43
45
 
44
46
  def graph_token
45
- return @graph_token if defined?(@graph_token)
46
-
47
- @graph_token = begin
48
- Helpers::TokenCache.instance.cached_delegated_token if defined?(Helpers::TokenCache)
49
- rescue StandardError => e
50
- log.warn("graph_token unavailable: #{e.message}")
51
- nil
52
- end
47
+ Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(:delegated)
48
+ rescue StandardError => e
49
+ handle_exception(e, level: :debug, operation: 'Chat#graph_token')
50
+ nil
53
51
  end
54
52
 
55
53
  def extract_chat_id(url)
54
+ log.debug("Chat#extract_chat_id url=#{url.inspect}")
56
55
  return nil unless url.is_a?(String)
57
56
 
58
57
  # teams.microsoft.com/l/chat/19:XXXXX@unq.gbl.spaces/...
@@ -61,22 +60,24 @@ module Legion
61
60
 
62
61
  URI.decode_uri_component(match[1])
63
62
  rescue StandardError => e
64
- log.debug("extract_chat_id failed: #{e.message}")
63
+ handle_exception(e, level: :debug, operation: 'Chat#extract_chat_id', url: url)
65
64
  nil
66
65
  end
67
66
 
68
67
  def resolve_chat(chat_id)
68
+ log.debug("Chat#resolve_chat chat_id=#{chat_id}")
69
69
  response = chats_runner.get_chat(chat_id: chat_id, token: graph_token)
70
70
  body = response.is_a?(Hash) ? response[:result] : nil
71
71
  return nil unless body.is_a?(Hash) && !body['error'] && !body[:error]
72
72
 
73
73
  body
74
74
  rescue StandardError => e
75
- log.warn("resolve_chat failed: #{e.message}")
75
+ handle_exception(e, level: :warn, operation: 'Chat#resolve_chat', chat_id: chat_id)
76
76
  nil
77
77
  end
78
78
 
79
79
  def ingest_messages(chat_id, topic, results)
80
+ log.debug("Chat#ingest_messages chat_id=#{chat_id} topic=#{topic}")
80
81
  report_progress(message: 'fetching messages', percent: 25)
81
82
  response = messages_runner.list_chat_messages(chat_id: chat_id, top: 50, token: graph_token)
82
83
  body = response.is_a?(Hash) ? response[:result] : nil
@@ -118,13 +119,15 @@ module Legion
118
119
  content_type: 'teams_chat_thread'
119
120
  )
120
121
  results[:chunks] += 1
122
+ log.info("Chat#ingest_messages stored #{lines.length} lines for chat_id=#{chat_id}")
121
123
  rescue StandardError => e
122
- log.warn("Message ingest failed: #{e.message}")
124
+ handle_exception(e, level: :warn, operation: 'Chat#ingest_messages', chat_id: chat_id)
123
125
  end
124
126
 
125
127
  def fetch_reply_lines(chat_id, message_id, _topic)
126
128
  return [] unless message_id
127
129
 
130
+ log.debug("Chat#fetch_reply_lines chat_id=#{chat_id} message_id=#{message_id}")
128
131
  response = messages_runner.list_message_replies(
129
132
  chat_id: chat_id, message_id: message_id, top: 50, token: graph_token
130
133
  )
@@ -146,11 +149,13 @@ module Legion
146
149
  " ↳ [#{timestamp}] #{sender}: #{text}"
147
150
  end
148
151
  rescue StandardError => e
149
- log.debug("fetch_reply_lines failed: #{e.message}")
152
+ handle_exception(e, level: :debug, operation: 'Chat#fetch_reply_lines',
153
+ chat_id: chat_id, message_id: message_id)
150
154
  []
151
155
  end
152
156
 
153
157
  def ingest_members(chat_id, topic, results)
158
+ log.debug("Chat#ingest_members chat_id=#{chat_id} topic=#{topic}")
154
159
  report_progress(message: 'fetching members', percent: 80)
155
160
  response = chats_runner.list_chat_members(chat_id: chat_id, token: graph_token)
156
161
  body = response.is_a?(Hash) ? response[:result] : nil
@@ -171,8 +176,9 @@ module Legion
171
176
  metadata: { chat_id: chat_id, participant_count: names.length }
172
177
  )
173
178
  results[:chunks] += 1
179
+ log.info("Chat#ingest_members stored #{names.length} participants for chat_id=#{chat_id}")
174
180
  rescue StandardError => e
175
- log.warn("Member ingest failed: #{e.message}")
181
+ handle_exception(e, level: :warn, operation: 'Chat#ingest_members', chat_id: chat_id)
176
182
  end
177
183
  end
178
184
  end