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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/client.d.ts +6 -2
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +10 -3
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/instagram/client.d.ts +24 -2
- package/dist/src/platforms/instagram/client.d.ts.map +1 -1
- package/dist/src/platforms/instagram/client.js +343 -53
- package/dist/src/platforms/instagram/client.js.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.d.ts +1 -0
- package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.js +123 -11
- package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
- package/dist/src/platforms/instagram/credential-manager.d.ts +4 -1
- package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/instagram/credential-manager.js +17 -1
- package/dist/src/platforms/instagram/credential-manager.js.map +1 -1
- package/dist/src/platforms/instagram/types.d.ts +10 -8
- package/dist/src/platforms/instagram/types.d.ts.map +1 -1
- package/dist/src/platforms/instagram/types.js.map +1 -1
- package/dist/src/platforms/teams/app-config.d.ts +36 -0
- package/dist/src/platforms/teams/app-config.d.ts.map +1 -0
- package/dist/src/platforms/teams/app-config.js +69 -0
- package/dist/src/platforms/teams/app-config.js.map +1 -0
- package/dist/src/platforms/teams/client.d.ts +20 -2
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +311 -7
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/commands/auth.d.ts +10 -0
- package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/auth.js +137 -3
- package/dist/src/platforms/teams/commands/auth.js.map +1 -1
- package/dist/src/platforms/teams/commands/file.d.ts +3 -0
- package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/file.js +59 -2
- package/dist/src/platforms/teams/commands/file.js.map +1 -1
- package/dist/src/platforms/teams/commands/message.d.ts +11 -0
- package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/message.js +121 -2
- package/dist/src/platforms/teams/commands/message.js.map +1 -1
- package/dist/src/platforms/teams/credential-manager.d.ts +23 -1
- package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/teams/credential-manager.js +46 -2
- package/dist/src/platforms/teams/credential-manager.js.map +1 -1
- package/dist/src/platforms/teams/device-code.d.ts +48 -0
- package/dist/src/platforms/teams/device-code.d.ts.map +1 -0
- package/dist/src/platforms/teams/device-code.js +199 -0
- package/dist/src/platforms/teams/device-code.js.map +1 -0
- package/dist/src/platforms/teams/device-login.d.ts +37 -0
- package/dist/src/platforms/teams/device-login.d.ts.map +1 -0
- package/dist/src/platforms/teams/device-login.js +129 -0
- package/dist/src/platforms/teams/device-login.js.map +1 -0
- package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/ensure-auth.js +12 -0
- package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
- package/dist/src/platforms/teams/index.d.ts +7 -3
- package/dist/src/platforms/teams/index.d.ts.map +1 -1
- package/dist/src/platforms/teams/index.js +5 -2
- package/dist/src/platforms/teams/index.js.map +1 -1
- package/dist/src/platforms/teams/listener.d.ts +40 -0
- package/dist/src/platforms/teams/listener.d.ts.map +1 -0
- package/dist/src/platforms/teams/listener.js +286 -0
- package/dist/src/platforms/teams/listener.js.map +1 -0
- package/dist/src/platforms/teams/realm-discovery.d.ts +3 -0
- package/dist/src/platforms/teams/realm-discovery.d.ts.map +1 -0
- package/dist/src/platforms/teams/realm-discovery.js +33 -0
- package/dist/src/platforms/teams/realm-discovery.js.map +1 -0
- package/dist/src/platforms/teams/token-extractor.d.ts +3 -0
- package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/teams/token-extractor.js +60 -0
- package/dist/src/platforms/teams/token-extractor.js.map +1 -1
- package/dist/src/platforms/teams/token-provider.d.ts +25 -0
- package/dist/src/platforms/teams/token-provider.d.ts.map +1 -0
- package/dist/src/platforms/teams/token-provider.js +190 -0
- package/dist/src/platforms/teams/token-provider.js.map +1 -0
- package/dist/src/platforms/teams/trouter.d.ts +38 -0
- package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
- package/dist/src/platforms/teams/trouter.js +266 -0
- package/dist/src/platforms/teams/trouter.js.map +1 -0
- package/dist/src/platforms/teams/types.d.ts +103 -0
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/types.js +30 -0
- package/dist/src/platforms/teams/types.js.map +1 -1
- package/dist/src/platforms/webex/types.d.ts +1 -1
- package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/instagram-adapter.js +16 -0
- package/dist/src/tui/adapters/instagram-adapter.js.map +1 -1
- package/dist/src/tui/adapters/teams-adapter.d.ts +3 -0
- package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/teams-adapter.js +21 -0
- package/dist/src/tui/adapters/teams-adapter.js.map +1 -1
- package/dist/src/tui/app.js +3 -3
- package/docs/content/docs/cli/instagram.mdx +1 -0
- package/docs/content/docs/cli/teams.mdx +36 -0
- package/docs/content/docs/sdk/teams.mdx +30 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-instagram/references/authentication.md +11 -0
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +82 -6
- package/skills/agent-teams/references/authentication.md +31 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/discord/client.test.ts +86 -0
- package/src/platforms/discord/client.ts +10 -3
- package/src/platforms/instagram/client.test.ts +478 -33
- package/src/platforms/instagram/client.ts +449 -68
- package/src/platforms/instagram/commands/auth.test.ts +177 -1
- package/src/platforms/instagram/commands/auth.ts +182 -11
- package/src/platforms/instagram/credential-manager.test.ts +30 -0
- package/src/platforms/instagram/credential-manager.ts +23 -1
- package/src/platforms/instagram/types.ts +11 -8
- package/src/platforms/teams/app-config.ts +92 -0
- package/src/platforms/teams/client.test.ts +335 -1
- package/src/platforms/teams/client.ts +370 -7
- package/src/platforms/teams/commands/auth.test.ts +93 -1
- package/src/platforms/teams/commands/auth.ts +182 -3
- package/src/platforms/teams/commands/file.test.ts +92 -1
- package/src/platforms/teams/commands/file.ts +76 -1
- package/src/platforms/teams/commands/message.test.ts +95 -1
- package/src/platforms/teams/commands/message.ts +145 -3
- package/src/platforms/teams/credential-manager.test.ts +20 -0
- package/src/platforms/teams/credential-manager.ts +68 -3
- package/src/platforms/teams/device-code.test.ts +311 -0
- package/src/platforms/teams/device-code.ts +277 -0
- package/src/platforms/teams/device-login.test.ts +288 -0
- package/src/platforms/teams/device-login.ts +206 -0
- package/src/platforms/teams/ensure-auth.ts +11 -0
- package/src/platforms/teams/index.test.ts +10 -0
- package/src/platforms/teams/index.ts +17 -1
- package/src/platforms/teams/listener.test.ts +243 -0
- package/src/platforms/teams/listener.ts +351 -0
- package/src/platforms/teams/realm-discovery.test.ts +73 -0
- package/src/platforms/teams/realm-discovery.ts +42 -0
- package/src/platforms/teams/token-extractor.ts +69 -0
- package/src/platforms/teams/token-provider.test.ts +227 -0
- package/src/platforms/teams/token-provider.ts +284 -0
- package/src/platforms/teams/trouter.test.ts +174 -0
- package/src/platforms/teams/trouter.ts +323 -0
- package/src/platforms/teams/types.test.ts +17 -0
- package/src/platforms/teams/types.ts +96 -0
- package/src/tui/adapters/instagram-adapter.ts +13 -0
- package/src/tui/adapters/teams-adapter.ts +23 -0
- 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(/&/g, '&')
|
|
339
|
+
.replace(/</g, '<')
|
|
340
|
+
.replace(/>/g, '>')
|
|
341
|
+
.replace(/"/g, '"')
|
|
342
|
+
.replace(/'/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
|
}
|