agent-messenger 2.28.0 → 2.29.1

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 (159) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +29 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/client.d.ts +6 -2
  5. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/client.js +10 -3
  7. package/dist/src/platforms/discord/client.js.map +1 -1
  8. package/dist/src/platforms/instagram/client.d.ts +24 -2
  9. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  10. package/dist/src/platforms/instagram/client.js +343 -53
  11. package/dist/src/platforms/instagram/client.js.map +1 -1
  12. package/dist/src/platforms/instagram/commands/auth.d.ts +1 -0
  13. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  14. package/dist/src/platforms/instagram/commands/auth.js +123 -11
  15. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  16. package/dist/src/platforms/instagram/credential-manager.d.ts +4 -1
  17. package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
  18. package/dist/src/platforms/instagram/credential-manager.js +17 -1
  19. package/dist/src/platforms/instagram/credential-manager.js.map +1 -1
  20. package/dist/src/platforms/instagram/types.d.ts +10 -8
  21. package/dist/src/platforms/instagram/types.d.ts.map +1 -1
  22. package/dist/src/platforms/instagram/types.js.map +1 -1
  23. package/dist/src/platforms/teams/app-config.d.ts +36 -0
  24. package/dist/src/platforms/teams/app-config.d.ts.map +1 -0
  25. package/dist/src/platforms/teams/app-config.js +69 -0
  26. package/dist/src/platforms/teams/app-config.js.map +1 -0
  27. package/dist/src/platforms/teams/client.d.ts +20 -2
  28. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  29. package/dist/src/platforms/teams/client.js +311 -7
  30. package/dist/src/platforms/teams/client.js.map +1 -1
  31. package/dist/src/platforms/teams/commands/auth.d.ts +10 -0
  32. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  33. package/dist/src/platforms/teams/commands/auth.js +137 -3
  34. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  35. package/dist/src/platforms/teams/commands/file.d.ts +3 -0
  36. package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
  37. package/dist/src/platforms/teams/commands/file.js +59 -2
  38. package/dist/src/platforms/teams/commands/file.js.map +1 -1
  39. package/dist/src/platforms/teams/commands/message.d.ts +11 -0
  40. package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
  41. package/dist/src/platforms/teams/commands/message.js +121 -2
  42. package/dist/src/platforms/teams/commands/message.js.map +1 -1
  43. package/dist/src/platforms/teams/credential-manager.d.ts +23 -1
  44. package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
  45. package/dist/src/platforms/teams/credential-manager.js +46 -2
  46. package/dist/src/platforms/teams/credential-manager.js.map +1 -1
  47. package/dist/src/platforms/teams/device-code.d.ts +48 -0
  48. package/dist/src/platforms/teams/device-code.d.ts.map +1 -0
  49. package/dist/src/platforms/teams/device-code.js +199 -0
  50. package/dist/src/platforms/teams/device-code.js.map +1 -0
  51. package/dist/src/platforms/teams/device-login.d.ts +37 -0
  52. package/dist/src/platforms/teams/device-login.d.ts.map +1 -0
  53. package/dist/src/platforms/teams/device-login.js +129 -0
  54. package/dist/src/platforms/teams/device-login.js.map +1 -0
  55. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  56. package/dist/src/platforms/teams/ensure-auth.js +12 -0
  57. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  58. package/dist/src/platforms/teams/index.d.ts +7 -3
  59. package/dist/src/platforms/teams/index.d.ts.map +1 -1
  60. package/dist/src/platforms/teams/index.js +5 -2
  61. package/dist/src/platforms/teams/index.js.map +1 -1
  62. package/dist/src/platforms/teams/listener.d.ts +40 -0
  63. package/dist/src/platforms/teams/listener.d.ts.map +1 -0
  64. package/dist/src/platforms/teams/listener.js +286 -0
  65. package/dist/src/platforms/teams/listener.js.map +1 -0
  66. package/dist/src/platforms/teams/realm-discovery.d.ts +3 -0
  67. package/dist/src/platforms/teams/realm-discovery.d.ts.map +1 -0
  68. package/dist/src/platforms/teams/realm-discovery.js +33 -0
  69. package/dist/src/platforms/teams/realm-discovery.js.map +1 -0
  70. package/dist/src/platforms/teams/token-extractor.d.ts +3 -0
  71. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  72. package/dist/src/platforms/teams/token-extractor.js +60 -0
  73. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  74. package/dist/src/platforms/teams/token-provider.d.ts +25 -0
  75. package/dist/src/platforms/teams/token-provider.d.ts.map +1 -0
  76. package/dist/src/platforms/teams/token-provider.js +190 -0
  77. package/dist/src/platforms/teams/token-provider.js.map +1 -0
  78. package/dist/src/platforms/teams/trouter.d.ts +38 -0
  79. package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
  80. package/dist/src/platforms/teams/trouter.js +266 -0
  81. package/dist/src/platforms/teams/trouter.js.map +1 -0
  82. package/dist/src/platforms/teams/types.d.ts +103 -0
  83. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  84. package/dist/src/platforms/teams/types.js +30 -0
  85. package/dist/src/platforms/teams/types.js.map +1 -1
  86. package/dist/src/platforms/webex/types.d.ts +1 -1
  87. package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
  88. package/dist/src/tui/adapters/instagram-adapter.js +16 -0
  89. package/dist/src/tui/adapters/instagram-adapter.js.map +1 -1
  90. package/dist/src/tui/adapters/teams-adapter.d.ts +3 -0
  91. package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
  92. package/dist/src/tui/adapters/teams-adapter.js +21 -0
  93. package/dist/src/tui/adapters/teams-adapter.js.map +1 -1
  94. package/dist/src/tui/app.js +3 -3
  95. package/docs/content/docs/cli/instagram.mdx +1 -0
  96. package/docs/content/docs/cli/teams.mdx +36 -0
  97. package/docs/content/docs/sdk/teams.mdx +30 -0
  98. package/package.json +1 -1
  99. package/skills/agent-channeltalk/SKILL.md +1 -1
  100. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  101. package/skills/agent-discord/SKILL.md +1 -1
  102. package/skills/agent-discordbot/SKILL.md +1 -1
  103. package/skills/agent-imessage/SKILL.md +1 -1
  104. package/skills/agent-instagram/SKILL.md +1 -1
  105. package/skills/agent-instagram/references/authentication.md +11 -0
  106. package/skills/agent-kakaotalk/SKILL.md +1 -1
  107. package/skills/agent-line/SKILL.md +1 -1
  108. package/skills/agent-slack/SKILL.md +1 -1
  109. package/skills/agent-slackbot/SKILL.md +1 -1
  110. package/skills/agent-teams/SKILL.md +82 -6
  111. package/skills/agent-teams/references/authentication.md +31 -1
  112. package/skills/agent-telegram/SKILL.md +1 -1
  113. package/skills/agent-telegrambot/SKILL.md +1 -1
  114. package/skills/agent-webex/SKILL.md +1 -1
  115. package/skills/agent-webexbot/SKILL.md +1 -1
  116. package/skills/agent-wechatbot/SKILL.md +1 -1
  117. package/skills/agent-whatsapp/SKILL.md +1 -1
  118. package/skills/agent-whatsappbot/SKILL.md +1 -1
  119. package/src/platforms/discord/client.test.ts +86 -0
  120. package/src/platforms/discord/client.ts +10 -3
  121. package/src/platforms/instagram/client.test.ts +478 -33
  122. package/src/platforms/instagram/client.ts +449 -68
  123. package/src/platforms/instagram/commands/auth.test.ts +177 -1
  124. package/src/platforms/instagram/commands/auth.ts +182 -11
  125. package/src/platforms/instagram/credential-manager.test.ts +30 -0
  126. package/src/platforms/instagram/credential-manager.ts +23 -1
  127. package/src/platforms/instagram/types.ts +11 -8
  128. package/src/platforms/teams/app-config.ts +92 -0
  129. package/src/platforms/teams/client.test.ts +335 -1
  130. package/src/platforms/teams/client.ts +370 -7
  131. package/src/platforms/teams/commands/auth.test.ts +93 -1
  132. package/src/platforms/teams/commands/auth.ts +182 -3
  133. package/src/platforms/teams/commands/file.test.ts +92 -1
  134. package/src/platforms/teams/commands/file.ts +76 -1
  135. package/src/platforms/teams/commands/message.test.ts +95 -1
  136. package/src/platforms/teams/commands/message.ts +145 -3
  137. package/src/platforms/teams/credential-manager.test.ts +20 -0
  138. package/src/platforms/teams/credential-manager.ts +68 -3
  139. package/src/platforms/teams/device-code.test.ts +311 -0
  140. package/src/platforms/teams/device-code.ts +277 -0
  141. package/src/platforms/teams/device-login.test.ts +288 -0
  142. package/src/platforms/teams/device-login.ts +206 -0
  143. package/src/platforms/teams/ensure-auth.ts +11 -0
  144. package/src/platforms/teams/index.test.ts +10 -0
  145. package/src/platforms/teams/index.ts +17 -1
  146. package/src/platforms/teams/listener.test.ts +243 -0
  147. package/src/platforms/teams/listener.ts +351 -0
  148. package/src/platforms/teams/realm-discovery.test.ts +73 -0
  149. package/src/platforms/teams/realm-discovery.ts +42 -0
  150. package/src/platforms/teams/token-extractor.ts +69 -0
  151. package/src/platforms/teams/token-provider.test.ts +227 -0
  152. package/src/platforms/teams/token-provider.ts +284 -0
  153. package/src/platforms/teams/trouter.test.ts +174 -0
  154. package/src/platforms/teams/trouter.ts +323 -0
  155. package/src/platforms/teams/types.test.ts +17 -0
  156. package/src/platforms/teams/types.ts +96 -0
  157. package/src/tui/adapters/instagram-adapter.ts +13 -0
  158. package/src/tui/adapters/teams-adapter.ts +23 -0
  159. package/src/tui/app.ts +3 -3
@@ -0,0 +1,351 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { EventEmitter } from 'node:events'
3
+
4
+ import WebSocket from 'ws'
5
+
6
+ import type { TeamsClient } from './client'
7
+ import {
8
+ buildActivityFrame,
9
+ buildAuthenticateFrame,
10
+ buildEventAck,
11
+ buildPingFrame,
12
+ buildRequestAck,
13
+ buildWebSocketUrl,
14
+ decodeMessageBody,
15
+ extractChatId,
16
+ fetchTrouterInfo,
17
+ fetchTrouterSessionId,
18
+ isMessageLossFrame,
19
+ isThreadConversation,
20
+ parseMentions,
21
+ parseRequestFrame,
22
+ registerEndpoint,
23
+ type TrouterInfo,
24
+ } from './trouter'
25
+ import { TeamsError } from './types'
26
+ import type { TeamsListenerEventMap, TeamsRealtimeMessage } from './types'
27
+
28
+ const PING_INTERVAL = 30_000
29
+ const RECONNECT_BASE_DELAY = 1_000
30
+ const RECONNECT_MAX_DELAY = 30_000
31
+ const REGISTRATION_REFRESH_INTERVAL = (86400 - 60) * 1000
32
+ const MESSAGE_CACHE_LIMIT = 500
33
+ const TEXT_MESSAGE_TYPES = new Set(['Text', 'RichText/Html'])
34
+
35
+ type EventKey = keyof TeamsListenerEventMap
36
+
37
+ interface IncomingResource {
38
+ id?: string
39
+ messagetype?: string
40
+ content?: string
41
+ from?: string
42
+ imdisplayname?: string
43
+ conversationLink?: string
44
+ resourceLink?: string
45
+ properties?: unknown
46
+ }
47
+
48
+ export class TeamsListener {
49
+ private client: TeamsClient
50
+ private running = false
51
+ private ws: WebSocket | null = null
52
+ private emitter = new EventEmitter()
53
+ private endpointId = randomUUID()
54
+ private idToken: string | null = null
55
+ private info: TrouterInfo | null = null
56
+ private sequence = 0
57
+ private reconnectAttempts = 0
58
+ private pingTimer: ReturnType<typeof setInterval> | null = null
59
+ private reregisterTimer: ReturnType<typeof setInterval> | null = null
60
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
61
+ private seenMessageIds = new Set<string>()
62
+ private channelTeamMap = new Map<string, string>()
63
+ private channelMapRefreshing: Promise<void> | null = null
64
+ private nonChannelThreads = new Set<string>()
65
+
66
+ constructor(client: TeamsClient) {
67
+ this.client = client
68
+ }
69
+
70
+ async start(): Promise<void> {
71
+ if (this.running) return
72
+ this.running = true
73
+ this.reconnectAttempts = 0
74
+ await this.refreshChannelMap()
75
+ await this.connect()
76
+ }
77
+
78
+ stop(): void {
79
+ this.running = false
80
+ this.clearTimers()
81
+ if (this.ws) {
82
+ this.ws.close()
83
+ this.ws = null
84
+ }
85
+ }
86
+
87
+ on<K extends EventKey>(event: K, listener: (...args: TeamsListenerEventMap[K]) => void): this {
88
+ this.emitter.on(event, listener as (...args: any[]) => void)
89
+ return this
90
+ }
91
+
92
+ off<K extends EventKey>(event: K, listener: (...args: TeamsListenerEventMap[K]) => void): this {
93
+ this.emitter.off(event, listener as (...args: any[]) => void)
94
+ return this
95
+ }
96
+
97
+ once<K extends EventKey>(event: K, listener: (...args: TeamsListenerEventMap[K]) => void): this {
98
+ this.emitter.once(event, listener as (...args: any[]) => void)
99
+ return this
100
+ }
101
+
102
+ private async connect(): Promise<void> {
103
+ if (!this.running) return
104
+
105
+ try {
106
+ const skypeToken = this.client.getToken()
107
+
108
+ // Re-extract on every attempt so a reconnect after token expiry picks up
109
+ // a fresh id_token rather than reusing the stale cached one.
110
+ this.idToken = await this.client.getIdToken()
111
+ if (!this.idToken) {
112
+ throw new TeamsError(
113
+ 'Could not obtain Teams id_token for real-time auth. Ensure the Teams desktop app is logged in, then re-run "auth extract".',
114
+ 'no_id_token',
115
+ )
116
+ }
117
+
118
+ const info = await fetchTrouterInfo(skypeToken, this.endpointId)
119
+ if (!this.running) return
120
+ const sessionId = await fetchTrouterSessionId(info, skypeToken, this.endpointId)
121
+ if (!this.running) return
122
+
123
+ this.info = info
124
+ const ws = new WebSocket(buildWebSocketUrl(info, sessionId, this.endpointId), {
125
+ headers: { 'X-Skypetoken': skypeToken, 'User-Agent': 'AgentMessenger' },
126
+ })
127
+ this.ws = ws
128
+
129
+ ws.on('message', (raw) => this.handleFrame(raw.toString()))
130
+
131
+ ws.on('close', () => {
132
+ this.clearTimers()
133
+ if (this.ws === ws) this.ws = null
134
+ if (this.running) {
135
+ this.emitter.emit('disconnected')
136
+ this.scheduleReconnect()
137
+ }
138
+ })
139
+
140
+ ws.on('error', (err) => {
141
+ this.emitter.emit('error', err instanceof Error ? err : new Error(String(err)))
142
+ })
143
+ } catch (error) {
144
+ this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
145
+ if (this.running) this.scheduleReconnect()
146
+ }
147
+ }
148
+
149
+ private handleFrame(frame: string): void {
150
+ if (frame.startsWith('1::')) {
151
+ void this.onConnected()
152
+ return
153
+ }
154
+
155
+ if (frame.startsWith('3:::')) {
156
+ this.handleRequestFrame(frame)
157
+ return
158
+ }
159
+
160
+ if (frame.startsWith('5:')) {
161
+ const ack = buildEventAck(frame)
162
+ if (ack) this.ws?.send(ack)
163
+ if (isMessageLossFrame(frame)) void this.register()
164
+ }
165
+ }
166
+
167
+ private async onConnected(): Promise<void> {
168
+ if (!this.ws || !this.info || !this.idToken) return
169
+
170
+ this.ws.send(buildAuthenticateFrame(this.info, this.idToken))
171
+ this.ws.send(buildActivityFrame(++this.sequence))
172
+
173
+ // Only report the endpoint as connected once it has actually registered;
174
+ // an unregistered endpoint receives no messages. On failure, drop the
175
+ // possibly-stale id_token and close so the reconnect path re-authenticates.
176
+ const registered = await this.register()
177
+ if (!registered) {
178
+ this.idToken = null
179
+ this.ws.close()
180
+ return
181
+ }
182
+
183
+ this.reconnectAttempts = 0
184
+ this.pingTimer = setInterval(() => {
185
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(buildPingFrame(++this.sequence))
186
+ }, PING_INTERVAL)
187
+ this.reregisterTimer = setInterval(() => void this.register(), REGISTRATION_REFRESH_INTERVAL)
188
+
189
+ this.emitter.emit('connected', { endpointId: this.endpointId })
190
+ }
191
+
192
+ private async register(): Promise<boolean> {
193
+ if (!this.info || !this.idToken) return false
194
+ try {
195
+ await registerEndpoint(
196
+ this.info,
197
+ this.client.getToken(),
198
+ this.idToken,
199
+ this.endpointId,
200
+ this.client.getAccountType(),
201
+ () => randomUUID(),
202
+ )
203
+ return true
204
+ } catch (error) {
205
+ this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
206
+ return false
207
+ }
208
+ }
209
+
210
+ private handleRequestFrame(frame: string): void {
211
+ const request = parseRequestFrame(frame)
212
+ if (!request) return
213
+
214
+ this.ws?.send(buildRequestAck(request.id))
215
+
216
+ if (!request.url?.endsWith('/messaging') || !request.body) return
217
+
218
+ try {
219
+ const decoded = decodeMessageBody(request.headers ?? {}, request.body) as {
220
+ resourceType?: string
221
+ resource?: IncomingResource
222
+ }
223
+ if (decoded.resourceType === 'NewMessage' && decoded.resource) {
224
+ void this.emitMessage(decoded.resource).catch((error) => {
225
+ this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
226
+ })
227
+ }
228
+ this.emitter.emit('teams_event', { resourceType: decoded.resourceType ?? 'unknown', ...decoded })
229
+ } catch (error) {
230
+ this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
231
+ }
232
+ }
233
+
234
+ private async emitMessage(resource: IncomingResource): Promise<void> {
235
+ if (!resource.messagetype || !TEXT_MESSAGE_TYPES.has(resource.messagetype)) return
236
+
237
+ const chatId = extractChatId(resource.conversationLink ?? resource.resourceLink)
238
+ if (!chatId || !resource.id) return
239
+
240
+ // Dedup before the async classification so duplicate frames don't trigger
241
+ // redundant channel-map refreshes.
242
+ if (this.seenMessageIds.has(resource.id)) return
243
+ this.rememberMessageId(resource.id)
244
+
245
+ const classification = await this.classifyConversation(chatId)
246
+ if (!this.running) return
247
+
248
+ const rawContent = resource.content ?? ''
249
+ const message: TeamsRealtimeMessage = {
250
+ id: resource.id,
251
+ chatId,
252
+ conversationType: classification.conversationType,
253
+ teamId: classification.teamId,
254
+ channelId: classification.channelId,
255
+ content: stripHtml(rawContent),
256
+ mentions: parseMentions(resource.properties, rawContent),
257
+ author: {
258
+ id: extractUserId(resource.from) ?? 'unknown',
259
+ displayName: resource.imdisplayname || extractUserId(resource.from) || 'unknown',
260
+ },
261
+ messageType: resource.messagetype,
262
+ timestamp: new Date().toISOString(),
263
+ }
264
+ this.emitter.emit('message', message)
265
+ }
266
+
267
+ private async classifyConversation(
268
+ chatId: string,
269
+ ): Promise<Pick<TeamsRealtimeMessage, 'conversationType' | 'teamId' | 'channelId'>> {
270
+ if (!isThreadConversation(chatId)) return { conversationType: 'chat' }
271
+
272
+ let teamId = this.channelTeamMap.get(chatId)
273
+ // A thread we haven't cached may be a channel created/joined since start;
274
+ // one best-effort refresh resolves it. Group chats also look like threads
275
+ // but never carry a groupId, so remember the miss to avoid re-fetching
276
+ // /users/ME/conversations on every one of their messages.
277
+ if (!teamId && !this.nonChannelThreads.has(chatId)) {
278
+ await this.refreshChannelMap()
279
+ teamId = this.channelTeamMap.get(chatId)
280
+ if (!teamId) this.nonChannelThreads.add(chatId)
281
+ }
282
+ if (!teamId) return { conversationType: 'chat' }
283
+
284
+ return { conversationType: 'channel', teamId, channelId: chatId }
285
+ }
286
+
287
+ private async refreshChannelMap(): Promise<void> {
288
+ if (this.channelMapRefreshing) return this.channelMapRefreshing
289
+ this.channelMapRefreshing = this.client
290
+ .buildChannelTeamMap()
291
+ .then((map) => {
292
+ this.channelTeamMap = map
293
+ })
294
+ .catch((error) => {
295
+ this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
296
+ })
297
+ .finally(() => {
298
+ this.channelMapRefreshing = null
299
+ })
300
+ return this.channelMapRefreshing
301
+ }
302
+
303
+ private rememberMessageId(id: string): void {
304
+ this.seenMessageIds.add(id)
305
+ if (this.seenMessageIds.size > MESSAGE_CACHE_LIMIT) {
306
+ const oldest = this.seenMessageIds.values().next().value
307
+ if (oldest !== undefined) this.seenMessageIds.delete(oldest)
308
+ }
309
+ }
310
+
311
+ private scheduleReconnect(): void {
312
+ const delay = Math.min(RECONNECT_BASE_DELAY * 2 ** this.reconnectAttempts, RECONNECT_MAX_DELAY)
313
+ this.reconnectAttempts++
314
+ this.endpointId = randomUUID()
315
+ this.sequence = 0
316
+ this.reconnectTimer = setTimeout(() => void this.connect(), delay)
317
+ }
318
+
319
+ private clearTimers(): void {
320
+ if (this.pingTimer) {
321
+ clearInterval(this.pingTimer)
322
+ this.pingTimer = null
323
+ }
324
+ if (this.reregisterTimer) {
325
+ clearInterval(this.reregisterTimer)
326
+ this.reregisterTimer = null
327
+ }
328
+ if (this.reconnectTimer) {
329
+ clearTimeout(this.reconnectTimer)
330
+ this.reconnectTimer = null
331
+ }
332
+ }
333
+ }
334
+
335
+ function stripHtml(html: string): string {
336
+ return html
337
+ .replace(/<[^>]*>/g, '')
338
+ .replace(/&amp;/g, '&')
339
+ .replace(/&lt;/g, '<')
340
+ .replace(/&gt;/g, '>')
341
+ .replace(/&quot;/g, '"')
342
+ .replace(/&#39;/g, "'")
343
+ .replace(/\s+/g, ' ')
344
+ .trim()
345
+ }
346
+
347
+ function extractUserId(from: string | undefined): string | null {
348
+ if (!from) return null
349
+ const match = from.match(/contacts\/(.+)$/)
350
+ return match ? match[1] : from
351
+ }
@@ -0,0 +1,73 @@
1
+ import { afterEach, describe, expect, it, mock } from 'bun:test'
2
+
3
+ import { probeAccountType } from './realm-discovery'
4
+
5
+ const originalFetch = globalThis.fetch
6
+
7
+ function mockFetch(response: { status?: number; json?: unknown; reject?: boolean }): {
8
+ calls: Array<{ url: string; init: RequestInit }>
9
+ } {
10
+ const calls: Array<{ url: string; init: RequestInit }> = []
11
+ globalThis.fetch = mock((input: string | URL | Request, init?: RequestInit) => {
12
+ calls.push({ url: String(input), init: init ?? {} })
13
+ if (response.reject) return Promise.reject(new Error('network down'))
14
+ const status = response.status ?? 200
15
+ return Promise.resolve({
16
+ ok: status >= 200 && status < 300,
17
+ status,
18
+ json: () => Promise.resolve(response.json),
19
+ } as Response)
20
+ }) as typeof fetch
21
+ return { calls }
22
+ }
23
+
24
+ afterEach(() => {
25
+ globalThis.fetch = originalFetch
26
+ })
27
+
28
+ describe('probeAccountType', () => {
29
+ it('posts the email to GetCredentialType as JSON', async () => {
30
+ const { calls } = mockFetch({ json: { EstsProperties: { DomainType: 3 } } })
31
+
32
+ await probeAccountType('user@contoso.com')
33
+
34
+ expect(calls[0].url).toBe('https://login.microsoftonline.com/common/GetCredentialType')
35
+ expect(calls[0].init.method).toBe('POST')
36
+ expect(JSON.parse(calls[0].init.body as string)).toEqual({ Username: 'user@contoso.com' })
37
+ })
38
+
39
+ it('maps a consumer DomainType to personal', async () => {
40
+ mockFetch({ json: { EstsProperties: { DomainType: 2 } } })
41
+ expect(await probeAccountType('user@outlook.com')).toBe('personal')
42
+ })
43
+
44
+ it('treats a known consumer domain as personal even without DomainType', async () => {
45
+ mockFetch({ json: { EstsProperties: { IsConsumerDomain: true } } })
46
+ expect(await probeAccountType('user@hotmail.com')).toBe('personal')
47
+ })
48
+
49
+ it('maps a managed DomainType to work', async () => {
50
+ mockFetch({ json: { EstsProperties: { DomainType: 3 } } })
51
+ expect(await probeAccountType('user@contoso.com')).toBe('work')
52
+ })
53
+
54
+ it('maps a federated DomainType to work', async () => {
55
+ mockFetch({ json: { EstsProperties: { DomainType: 4 } } })
56
+ expect(await probeAccountType('user@fabrikam.com')).toBe('work')
57
+ })
58
+
59
+ it('returns undefined when the domain type is missing', async () => {
60
+ mockFetch({ json: { EstsProperties: {} } })
61
+ expect(await probeAccountType('user@unknown.test')).toBeUndefined()
62
+ })
63
+
64
+ it('returns undefined on a non-ok response', async () => {
65
+ mockFetch({ status: 429, json: {} })
66
+ expect(await probeAccountType('user@contoso.com')).toBeUndefined()
67
+ })
68
+
69
+ it('returns undefined when the request throws', async () => {
70
+ mockFetch({ reject: true })
71
+ expect(await probeAccountType('user@contoso.com')).toBeUndefined()
72
+ })
73
+ })
@@ -0,0 +1,42 @@
1
+ import { GET_CREDENTIAL_TYPE_URL } from './app-config'
2
+ import type { TeamsAccountType } from './types'
3
+
4
+ // GetCredentialType (the endpoint Microsoft's own login page calls) reports
5
+ // EstsProperties.DomainType, which distinguishes a personal account from a
6
+ // work account before any device code is issued.
7
+ const DOMAIN_TYPE_CONSUMER = 2
8
+
9
+ interface CredentialTypeResponse {
10
+ IfExistsResult?: number
11
+ EstsProperties?: {
12
+ DomainType?: number
13
+ IsConsumerDomain?: boolean
14
+ }
15
+ }
16
+
17
+ // Returns the account type for an email, or undefined when it can't be
18
+ // determined (network error, throttling, unknown domain) so the caller can
19
+ // fall back to its default flow rather than block sign-in.
20
+ export async function probeAccountType(email: string): Promise<TeamsAccountType | undefined> {
21
+ let body: CredentialTypeResponse
22
+ try {
23
+ const res = await fetch(GET_CREDENTIAL_TYPE_URL, {
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json' },
26
+ body: JSON.stringify({ Username: email }),
27
+ })
28
+ if (!res.ok) return undefined
29
+ body = (await res.json()) as CredentialTypeResponse
30
+ } catch {
31
+ return undefined
32
+ }
33
+
34
+ const ests = body.EstsProperties
35
+ if (ests?.DomainType === DOMAIN_TYPE_CONSUMER || ests?.IsConsumerDomain === true) {
36
+ return 'personal'
37
+ }
38
+ if (typeof ests?.DomainType === 'number') {
39
+ return 'work'
40
+ }
41
+ return undefined
42
+ }
@@ -49,6 +49,12 @@ const TEAMS_HOST_PATTERNS = [
49
49
  '.microsoft.com',
50
50
  ]
51
51
 
52
+ // The trouter real-time WebSocket needs an OAuth Bearer token (a JWE) for its
53
+ // user.authenticate step. Teams stores it in the `authtoken` cookie, prefixed
54
+ // with "Bearer=" and URL-encoded.
55
+ const AUTHTOKEN_COOKIE_NAME = 'authtoken'
56
+ const AUTHTOKEN_HOST_PATTERNS = ['teams.live.com', 'teams.microsoft.com']
57
+
52
58
  const TEAMS_KEYCHAIN_VARIANTS: KeychainVariant[] = [
53
59
  { service: 'Microsoft Teams Safe Storage', account: 'Microsoft Teams' },
54
60
  {
@@ -252,6 +258,69 @@ export class TeamsTokenExtractor {
252
258
  return this.extractFromCookiesDB()
253
259
  }
254
260
 
261
+ async extractIdToken(accountType?: TeamsAccountType): Promise<string | null> {
262
+ await this.decryptor.loadCachedKey()
263
+
264
+ const desktopPaths = this.getDesktopCookiesPaths().filter(
265
+ (p) => accountType === undefined || !p.accountTypeKnown || p.accountType === accountType,
266
+ )
267
+ const candidatePaths = [...desktopPaths, ...this.getBrowserCookiesPaths()]
268
+
269
+ for (const { path: dbPath } of candidatePaths) {
270
+ if (!dbPath || !existsSync(dbPath)) continue
271
+
272
+ const bearer = await this.extractAuthTokenFromSQLite(dbPath)
273
+ if (bearer) return bearer
274
+ }
275
+
276
+ return null
277
+ }
278
+
279
+ private async extractAuthTokenFromSQLite(dbPath: string): Promise<string | null> {
280
+ try {
281
+ let localStatePath: string | undefined
282
+ if (this.platform === 'win32') {
283
+ localStatePath = findLocalStatePath(dbPath) ?? undefined
284
+ }
285
+
286
+ for (const hostPattern of AUTHTOKEN_HOST_PATTERNS) {
287
+ const sql = `
288
+ SELECT value, encrypted_value
289
+ FROM cookies
290
+ WHERE name = '${AUTHTOKEN_COOKIE_NAME}'
291
+ AND host_key LIKE '%${hostPattern}%'
292
+ LIMIT 1
293
+ `
294
+
295
+ type CookieRow = { value?: string; encrypted_value?: Uint8Array | Buffer } | null
296
+ const row = await this.cookieReader.queryFirst<CookieRow>(dbPath, sql)
297
+ if (!row) continue
298
+
299
+ let value = row.value ?? ''
300
+ if ((!value || value.length < 20) && row.encrypted_value && row.encrypted_value.length > 0) {
301
+ const decrypted = this.decryptor.decryptCookieRaw(Buffer.from(row.encrypted_value), localStatePath)
302
+ if (!decrypted) continue
303
+ value = ChromiumCookieDecryptor.stripIntegrityHash(decrypted).toString('utf8')
304
+ }
305
+
306
+ const bearer = this.normalizeAuthToken(value)
307
+ if (bearer) return bearer
308
+ }
309
+
310
+ return null
311
+ } catch (error) {
312
+ this.debug(` authtoken query error: ${(error as Error).message}`)
313
+ return null
314
+ }
315
+ }
316
+
317
+ private normalizeAuthToken(rawValue: string): string | null {
318
+ if (!rawValue) return null
319
+ const decoded = decodeURIComponent(rawValue)
320
+ const token = decoded.replace(/^Bearer=/i, '').trim()
321
+ return token.length > 20 ? token : null
322
+ }
323
+
255
324
  async clearKeyCache(): Promise<void> {
256
325
  await this.decryptor.clearKeyCache()
257
326
  }