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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +2 -0
- data/CLAUDE.md +29 -266
- data/lex-microsoft_teams.gemspec +1 -0
- data/lib/legion/extensions/microsoft_teams/absorbers/channel.rb +29 -17
- data/lib/legion/extensions/microsoft_teams/absorbers/chat.rb +20 -14
- data/lib/legion/extensions/microsoft_teams/absorbers/meeting.rb +21 -14
- data/lib/legion/extensions/microsoft_teams/actors/absorb_channel.rb +7 -4
- data/lib/legion/extensions/microsoft_teams/actors/absorb_chat.rb +7 -4
- data/lib/legion/extensions/microsoft_teams/actors/absorb_meeting.rb +7 -4
- data/lib/legion/extensions/microsoft_teams/actors/api_ingest.rb +13 -15
- data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +3 -3
- data/lib/legion/extensions/microsoft_teams/actors/cache_sync.rb +2 -1
- data/lib/legion/extensions/microsoft_teams/actors/channel_poller.rb +25 -16
- data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +16 -10
- data/lib/legion/extensions/microsoft_teams/actors/incremental_sync.rb +8 -8
- data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +30 -22
- data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +14 -8
- data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +14 -8
- data/lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb +13 -16
- data/lib/legion/extensions/microsoft_teams/helpers/client.rb +10 -4
- data/lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb +3 -2
- data/lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb +4 -1
- data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +8 -2
- data/lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb +5 -3
- data/lib/legion/extensions/microsoft_teams/helpers/trace_retriever.rb +6 -1
- data/lib/legion/extensions/microsoft_teams/local_cache/extractor.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb +2 -1
- data/lib/legion/extensions/microsoft_teams/runners/activities.rb +42 -0
- data/lib/legion/extensions/microsoft_teams/runners/ai_insights.rb +61 -0
- data/lib/legion/extensions/microsoft_teams/runners/api_ingest.rb +19 -14
- data/lib/legion/extensions/microsoft_teams/runners/app_installations.rb +85 -0
- data/lib/legion/extensions/microsoft_teams/runners/auth.rb +3 -107
- data/lib/legion/extensions/microsoft_teams/runners/bot.rb +20 -12
- data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +7 -5
- data/lib/legion/extensions/microsoft_teams/runners/call_events.rb +72 -0
- data/lib/legion/extensions/microsoft_teams/runners/channel_messages.rb +85 -0
- data/lib/legion/extensions/microsoft_teams/runners/channels.rb +68 -0
- data/lib/legion/extensions/microsoft_teams/runners/chats.rb +56 -0
- data/lib/legion/extensions/microsoft_teams/runners/files.rb +77 -0
- data/lib/legion/extensions/microsoft_teams/runners/local_cache.rb +4 -0
- data/lib/legion/extensions/microsoft_teams/runners/loop.rb +5 -0
- data/lib/legion/extensions/microsoft_teams/runners/meeting_artifacts.rb +54 -0
- data/lib/legion/extensions/microsoft_teams/runners/meetings.rb +92 -0
- data/lib/legion/extensions/microsoft_teams/runners/messages.rb +61 -0
- data/lib/legion/extensions/microsoft_teams/runners/ownership.rb +11 -0
- data/lib/legion/extensions/microsoft_teams/runners/people.rb +24 -0
- data/lib/legion/extensions/microsoft_teams/runners/presence.rb +14 -0
- data/lib/legion/extensions/microsoft_teams/runners/profile_ingest.rb +17 -4
- data/lib/legion/extensions/microsoft_teams/runners/subscriptions.rb +86 -0
- data/lib/legion/extensions/microsoft_teams/runners/teams.rb +30 -0
- data/lib/legion/extensions/microsoft_teams/runners/transcripts.rb +35 -0
- data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
- data/lib/legion/extensions/microsoft_teams.rb +10 -3
- metadata +20 -8
- data/lib/legion/extensions/microsoft_teams/actors/auth_validator.rb +0 -123
- data/lib/legion/extensions/microsoft_teams/actors/token_refresher.rb +0 -122
- data/lib/legion/extensions/microsoft_teams/cli/auth.rb +0 -94
- data/lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb +0 -270
- data/lib/legion/extensions/microsoft_teams/helpers/callback_server.rb +0 -90
- data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +0 -412
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad43c9c0712ae13673149df6f3a51a7ba24c977bfcef37739fc6caea74d7e989
|
|
4
|
+
data.tar.gz: 8691eecd15c48afe8849adb1e959d29a19c75b5ddfb9e4083b2070f007de8984
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56678da313af129c9d60071cd113fc174c39983122aa4ac9b11a3c8b49e05c29672c347cd1c433a9ea623823085cef41cfa18043fb5b39ccc00329fba89b5636
|
|
7
|
+
data.tar.gz: 8ecdc1951210be162921639b7eb0c0bf05f7bce9268ea5b0548b4f6f3503869539df632575af822f230e54634a99037a9d8ae30271c96c252ab2978b1daa79a8
|
data/.gitignore
CHANGED
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
|
|
1
|
+
# lex-microsoft_teams
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
│
|
|
21
|
-
│
|
|
22
|
-
│
|
|
23
|
-
|
|
24
|
-
│
|
|
25
|
-
│
|
|
26
|
-
│
|
|
27
|
-
│
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
│
|
|
32
|
-
│
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
201
|
-
- **
|
|
202
|
-
- **
|
|
203
|
-
- **
|
|
204
|
-
- **
|
|
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.
|
data/lex-microsoft_teams.gemspec
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
handle_exception(e, level: :warn, operation: 'Chat#ingest_members', chat_id: chat_id)
|
|
176
182
|
end
|
|
177
183
|
end
|
|
178
184
|
end
|