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,227 @@
1
+ import { afterAll, afterEach, describe, expect, it, mock } from 'bun:test'
2
+ import { rmSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { TeamsCredentialManager } from './credential-manager'
6
+ import { TeamsTokenProvider } from './token-provider'
7
+ import { TeamsAuthCapabilityError } from './types'
8
+
9
+ const TENANT_ID = '11111111-1111-1111-1111-111111111111'
10
+ const USER_ID = '22222222-2222-2222-2222-222222222222'
11
+ const CLIENT_ID = '1fec8e78-bce4-4aaf-ab1b-5451cc387264'
12
+ const SUBSTRATE_AUD = 'https://substrate.office.com'
13
+ const GRAPH_AUD = 'https://graph.microsoft.com'
14
+ const originalFetch = globalThis.fetch
15
+ const testDirs: string[] = []
16
+
17
+ interface FetchCall {
18
+ url: string
19
+ init: RequestInit
20
+ body: URLSearchParams
21
+ }
22
+
23
+ interface TokenResponseConfig {
24
+ aud: string
25
+ refreshToken: string
26
+ expiresIn?: number
27
+ marker?: string
28
+ }
29
+
30
+ function setup(): TeamsCredentialManager {
31
+ const dir = join(import.meta.dir, `.test-teams-provider-${Date.now()}-${Math.random().toString(36).slice(2)}`)
32
+ testDirs.push(dir)
33
+ return new TeamsCredentialManager(dir)
34
+ }
35
+
36
+ async function seedDeviceAccount(manager: TeamsCredentialManager, refreshToken = 'old-refresh'): Promise<void> {
37
+ await manager.setDeviceCodeAccount({
38
+ accountType: 'work',
39
+ token: 'skype-token',
40
+ tokenExpiresAt: '2100-01-01T00:00:00Z',
41
+ aadRefreshToken: refreshToken,
42
+ aadClientId: CLIENT_ID,
43
+ teams: {},
44
+ currentTeam: null,
45
+ })
46
+ }
47
+
48
+ async function seedPersonalDeviceAccount(
49
+ manager: TeamsCredentialManager,
50
+ refreshToken = 'personal-refresh',
51
+ ): Promise<void> {
52
+ await manager.setDeviceCodeAccount({
53
+ accountType: 'personal',
54
+ token: 'personal-skype-token',
55
+ tokenExpiresAt: '2100-01-01T00:00:00Z',
56
+ aadRefreshToken: refreshToken,
57
+ aadClientId: CLIENT_ID,
58
+ teams: {},
59
+ currentTeam: null,
60
+ })
61
+ }
62
+
63
+ function createJwt(aud: string, marker = 'token'): string {
64
+ const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url')
65
+ const payload = Buffer.from(
66
+ JSON.stringify({ aud, tid: TENANT_ID, oid: USER_ID, exp: Math.floor(Date.now() / 1000) + 3600, jti: marker }),
67
+ ).toString('base64url')
68
+ return `${header}.${payload}.signature`
69
+ }
70
+
71
+ function bodyParams(body: BodyInit | null | undefined): URLSearchParams {
72
+ if (body instanceof URLSearchParams) return body
73
+ if (typeof body === 'string') return new URLSearchParams(body)
74
+ return new URLSearchParams()
75
+ }
76
+
77
+ function headerValue(init: RequestInit, name: string): string | undefined {
78
+ const headers = init.headers
79
+ if (headers instanceof Headers) return headers.get(name) ?? undefined
80
+ if (Array.isArray(headers)) {
81
+ const pair = headers.find(([key]) => key.toLowerCase() === name.toLowerCase())
82
+ return pair?.[1]
83
+ }
84
+ return headers?.[name]
85
+ }
86
+
87
+ function mockTokenResponses(responses: TokenResponseConfig[]): { calls: FetchCall[] } {
88
+ const calls: FetchCall[] = []
89
+ const queue = [...responses]
90
+ globalThis.fetch = mock((input: string | URL | Request, init?: RequestInit) => {
91
+ const requestInit = init ?? {}
92
+ calls.push({ url: String(input), init: requestInit, body: bodyParams(requestInit.body) })
93
+ const next = queue.shift()
94
+ if (!next) {
95
+ return Promise.resolve(new Response(JSON.stringify({ error: 'no_mock_response' }), { status: 500 }))
96
+ }
97
+ return Promise.resolve(
98
+ new Response(
99
+ JSON.stringify({
100
+ access_token: createJwt(next.aud, next.marker),
101
+ refresh_token: next.refreshToken,
102
+ expires_in: next.expiresIn ?? 3600,
103
+ }),
104
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
105
+ ),
106
+ )
107
+ }) as typeof fetch
108
+ return { calls }
109
+ }
110
+
111
+ afterEach(() => {
112
+ globalThis.fetch = originalFetch
113
+ })
114
+
115
+ afterAll(() => {
116
+ for (const dir of testDirs) rmSync(dir, { recursive: true, force: true })
117
+ })
118
+
119
+ describe('TeamsTokenProvider', () => {
120
+ it('refresh-exchanges substrate tokens with Origin and persists rotated refresh tokens', async () => {
121
+ const manager = setup()
122
+ await seedDeviceAccount(manager)
123
+ const { calls } = mockTokenResponses([{ aud: SUBSTRATE_AUD, refreshToken: 'rotated-refresh' }])
124
+
125
+ const provider = new TeamsTokenProvider(manager)
126
+ const token = await provider.getSubstrateToken()
127
+
128
+ expect(token).toContain('.signature')
129
+ expect(calls[0].url).toContain('/organizations/oauth2/v2.0/token')
130
+ expect(calls[0].body.get('grant_type')).toBe('refresh_token')
131
+ expect(calls[0].body.get('client_id')).toBe(CLIENT_ID)
132
+ expect(calls[0].body.get('refresh_token')).toBe('old-refresh')
133
+ expect(calls[0].body.get('scope')).toBe('https://substrate.office.com/.default offline_access')
134
+ expect(headerValue(calls[0].init, 'Origin')).toBe('https://teams.microsoft.com')
135
+
136
+ const config = await manager.loadConfig()
137
+ expect(config?.accounts.work?.aad_refresh_token).toBe('rotated-refresh')
138
+ })
139
+
140
+ it('throws when the acquired token audience does not match the requested audience', async () => {
141
+ const manager = setup()
142
+ await seedDeviceAccount(manager)
143
+ mockTokenResponses([{ aud: GRAPH_AUD, refreshToken: 'rotated-refresh' }])
144
+
145
+ const provider = new TeamsTokenProvider(manager)
146
+ await expect(provider.getSubstrateToken()).rejects.toThrow(/audience/i)
147
+ })
148
+
149
+ it('throws TeamsAuthCapabilityError for cookie-only credentials', async () => {
150
+ const manager = setup()
151
+ await manager.setToken('skype-token', 'work', '2100-01-01T00:00:00Z')
152
+
153
+ const provider = new TeamsTokenProvider(manager)
154
+ await expect(provider.getSubstrateToken()).rejects.toThrow(TeamsAuthCapabilityError)
155
+ await expect(provider.getSubstrateToken()).rejects.toThrow(/auth login/)
156
+ })
157
+
158
+ it('uses the in-memory cache until the five-minute refresh buffer', async () => {
159
+ const manager = setup()
160
+ await seedDeviceAccount(manager)
161
+ const { calls } = mockTokenResponses([{ aud: SUBSTRATE_AUD, refreshToken: 'rotated-refresh', marker: 'cached' }])
162
+
163
+ const provider = new TeamsTokenProvider(manager)
164
+ const first = await provider.getSubstrateToken()
165
+ const second = await provider.getSubstrateToken()
166
+
167
+ expect(second).toBe(first)
168
+ expect(calls).toHaveLength(1)
169
+ })
170
+
171
+ it('refreshes cached tokens inside the five-minute buffer', async () => {
172
+ const manager = setup()
173
+ await seedDeviceAccount(manager)
174
+ const { calls } = mockTokenResponses([
175
+ { aud: SUBSTRATE_AUD, refreshToken: 'near-expiry-refresh', expiresIn: 299, marker: 'near-expiry' },
176
+ { aud: SUBSTRATE_AUD, refreshToken: 'fresh-refresh', expiresIn: 3600, marker: 'fresh' },
177
+ ])
178
+
179
+ const provider = new TeamsTokenProvider(manager)
180
+ const first = await provider.getSubstrateToken()
181
+ const second = await provider.getSubstrateToken()
182
+
183
+ expect(second).not.toBe(first)
184
+ expect(calls).toHaveLength(2)
185
+ const config = await manager.loadConfig()
186
+ expect(config?.accounts.work?.aad_refresh_token).toBe('fresh-refresh')
187
+ })
188
+
189
+ it('does not reuse bearer tokens across bound accounts', async () => {
190
+ const manager = setup()
191
+ await seedDeviceAccount(manager, 'work-refresh')
192
+ await seedPersonalDeviceAccount(manager, 'personal-refresh')
193
+ const { calls } = mockTokenResponses([
194
+ { aud: SUBSTRATE_AUD, refreshToken: 'work-rotated-refresh', marker: 'work-token' },
195
+ { aud: SUBSTRATE_AUD, refreshToken: 'personal-rotated-refresh', marker: 'personal-token' },
196
+ ])
197
+
198
+ const provider = new TeamsTokenProvider(manager)
199
+ const workToken = await provider.bindAccount('work').getSubstrateToken()
200
+ const personalToken = await provider.bindAccount('personal').getSubstrateToken()
201
+
202
+ expect(personalToken).not.toBe(workToken)
203
+ expect(calls).toHaveLength(2)
204
+ expect(calls[0].body.get('refresh_token')).toBe('work-refresh')
205
+ expect(calls[1].body.get('refresh_token')).toBe('personal-refresh')
206
+ })
207
+
208
+ it('restores tenant/user identity on a cached bearer hit after switching accounts back', async () => {
209
+ const manager = setup()
210
+ await seedDeviceAccount(manager, 'work-refresh')
211
+ await seedPersonalDeviceAccount(manager, 'personal-refresh')
212
+ mockTokenResponses([
213
+ { aud: SUBSTRATE_AUD, refreshToken: 'work-rotated', marker: 'work-token' },
214
+ { aud: SUBSTRATE_AUD, refreshToken: 'personal-rotated', marker: 'personal-token' },
215
+ ])
216
+
217
+ const provider = new TeamsTokenProvider(manager)
218
+ // work mints + caches (identity set), personal switch resets identity,
219
+ // then work again is served from cache and must re-restore identity
220
+ await provider.bindAccount('work').getSubstrateToken()
221
+ await provider.bindAccount('personal').getSubstrateToken()
222
+ await provider.bindAccount('work').getSubstrateToken()
223
+
224
+ expect(await provider.getTenantId()).toBe(TENANT_ID)
225
+ expect(await provider.getUserId()).toBe(USER_ID)
226
+ })
227
+ })
@@ -0,0 +1,284 @@
1
+ import {
2
+ AAD_AUDIENCE_GRAPH,
3
+ AAD_AUDIENCE_SUBSTRATE,
4
+ AAD_SCOPE_GRAPH,
5
+ AAD_SCOPE_SUBSTRATE,
6
+ consumerTokenUrl,
7
+ getTeamsAppClientId,
8
+ organizationsTokenUrl,
9
+ TEAMS_WEB_ORIGIN,
10
+ } from './app-config'
11
+ import { TeamsCredentialManager } from './credential-manager'
12
+ import type { TeamsAccount, TeamsAccountType } from './types'
13
+ import { TeamsAuthCapabilityError, TeamsError } from './types'
14
+
15
+ export type TokenAudience = 'skype' | 'substrate' | 'graph'
16
+
17
+ type BearerAudience = Exclude<TokenAudience, 'skype'>
18
+
19
+ interface CachedToken {
20
+ accessToken: string
21
+ expiresAt: number
22
+ aud: string
23
+ tenantId?: string
24
+ userId?: string
25
+ }
26
+
27
+ type CacheKey = TokenAudience | `${TeamsAccountType}:${BearerAudience}`
28
+
29
+ interface CurrentAccountContext {
30
+ accountType: TeamsAccountType
31
+ account: TeamsAccount
32
+ }
33
+
34
+ interface BearerAudienceConfig {
35
+ scope: string
36
+ aud: string
37
+ }
38
+
39
+ interface JwtClaims {
40
+ aud?: string
41
+ tid?: string
42
+ oid?: string
43
+ exp?: number
44
+ [key: string]: unknown
45
+ }
46
+
47
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000
48
+ const DEFAULT_ACCESS_TOKEN_TTL_SEC = 3600
49
+
50
+ const BEARER_AUDIENCES: Record<BearerAudience, BearerAudienceConfig> = {
51
+ substrate: { scope: AAD_SCOPE_SUBSTRATE, aud: AAD_AUDIENCE_SUBSTRATE },
52
+ graph: { scope: AAD_SCOPE_GRAPH, aud: AAD_AUDIENCE_GRAPH },
53
+ }
54
+
55
+ export class TeamsTokenProvider {
56
+ private cache: Map<CacheKey, CachedToken> = new Map()
57
+ private tenantId?: string
58
+ private userId?: string
59
+ private boundAccountType?: TeamsAccountType
60
+ private lastAccountType?: TeamsAccountType
61
+
62
+ constructor(private credManager: TeamsCredentialManager = new TeamsCredentialManager()) {}
63
+
64
+ bindAccount(accountType: TeamsAccountType): this {
65
+ this.boundAccountType = accountType
66
+ return this
67
+ }
68
+
69
+ async getSkypeToken(): Promise<string> {
70
+ const cached = this.getCached('skype')
71
+ if (cached) return cached.accessToken
72
+
73
+ const cred = await this.credManager.getTokenWithExpiry()
74
+ if (!cred?.token) {
75
+ throw new TeamsError(
76
+ 'No Teams credentials found. Run `agent-teams auth login` or `agent-teams auth extract`.',
77
+ 'no_credentials',
78
+ )
79
+ }
80
+
81
+ const expiresAt = cred.tokenExpiresAt ? new Date(cred.tokenExpiresAt).getTime() : Number.POSITIVE_INFINITY
82
+ if (expiresAt <= Date.now()) {
83
+ throw new TeamsError('Token has expired. Run "auth login" or "auth extract" to refresh.', 'token_expired')
84
+ }
85
+
86
+ this.cache.set('skype', { accessToken: cred.token, expiresAt, aud: 'skype' })
87
+ return cred.token
88
+ }
89
+
90
+ async getSubstrateToken(): Promise<string> {
91
+ return this.getBearerToken('substrate')
92
+ }
93
+
94
+ async getGraphToken(): Promise<string> {
95
+ return this.getBearerToken('graph')
96
+ }
97
+
98
+ async getTenantId(): Promise<string> {
99
+ await this.ensureIdentity()
100
+ if (!this.tenantId) {
101
+ throw new TeamsError(
102
+ 'Microsoft token did not include a tenant id needed for Teams search.',
103
+ 'aad_identity_missing',
104
+ )
105
+ }
106
+ return this.tenantId
107
+ }
108
+
109
+ async getUserId(): Promise<string> {
110
+ await this.ensureIdentity()
111
+ if (!this.userId) {
112
+ throw new TeamsError('Microsoft token did not include a user id needed for Teams search.', 'aad_identity_missing')
113
+ }
114
+ return this.userId
115
+ }
116
+
117
+ private getCached(key: CacheKey): CachedToken | undefined {
118
+ const cached = this.cache.get(key)
119
+ if (!cached) return undefined
120
+ return cached.expiresAt - Date.now() > REFRESH_BUFFER_MS ? cached : undefined
121
+ }
122
+
123
+ private async getBearerToken(audience: BearerAudience): Promise<string> {
124
+ const current = await this.getCurrentAccount()
125
+ this.resetIdentityIfAccountChanged(current.accountType)
126
+ const cacheKey: CacheKey = `${current.accountType}:${audience}`
127
+ const cached = this.getCached(cacheKey)
128
+ if (cached) {
129
+ // a cache hit after an account switch reset our identity, so restore the
130
+ // tenant/user claims captured when this token was minted
131
+ this.tenantId = cached.tenantId ?? this.tenantId
132
+ this.userId = cached.userId ?? this.userId
133
+ return cached.accessToken
134
+ }
135
+
136
+ if (!current.account.aad_refresh_token) {
137
+ throw new TeamsAuthCapabilityError()
138
+ }
139
+
140
+ const clientId = current.account.aad_client_id ?? getTeamsAppClientId(current.accountType).clientId
141
+ const config = BEARER_AUDIENCES[audience]
142
+ const token = await this.refreshBearerToken({
143
+ accountType: current.accountType,
144
+ clientId,
145
+ refreshToken: current.account.aad_refresh_token,
146
+ audience,
147
+ config,
148
+ })
149
+
150
+ this.cache.set(cacheKey, token)
151
+ return token.accessToken
152
+ }
153
+
154
+ private async refreshBearerToken(params: {
155
+ accountType: TeamsAccountType
156
+ clientId: string
157
+ refreshToken: string
158
+ audience: BearerAudience
159
+ config: BearerAudienceConfig
160
+ }): Promise<CachedToken> {
161
+ const response = await fetch(tokenUrlForAccount(params.accountType), {
162
+ method: 'POST',
163
+ headers: {
164
+ 'Content-Type': 'application/x-www-form-urlencoded',
165
+ Origin: TEAMS_WEB_ORIGIN,
166
+ },
167
+ body: new URLSearchParams({
168
+ grant_type: 'refresh_token',
169
+ client_id: params.clientId,
170
+ refresh_token: params.refreshToken,
171
+ scope: params.config.scope,
172
+ }),
173
+ })
174
+
175
+ const body = (await response.json().catch(() => ({}))) as Record<string, unknown>
176
+ if (!response.ok) {
177
+ throw new TeamsError(
178
+ `Microsoft token refresh failed: ${describeAadError(body, response.status)}`,
179
+ 'aad_refresh_failed',
180
+ )
181
+ }
182
+
183
+ const accessToken = stringField(body, 'access_token')
184
+ if (!accessToken) {
185
+ throw new TeamsError('Microsoft token refresh response did not include an access token.', 'aad_access_missing')
186
+ }
187
+
188
+ const claims = decodeJwt(accessToken)
189
+ if (claims.aud !== params.config.aud) {
190
+ throw new TeamsError(
191
+ `Microsoft token audience mismatch: expected ${params.config.aud}, got ${claims.aud ?? 'missing'}.`,
192
+ 'aad_audience_mismatch',
193
+ )
194
+ }
195
+
196
+ this.tenantId = claims.tid ?? this.tenantId
197
+ this.userId = claims.oid ?? this.userId
198
+
199
+ const rotatedRefreshToken = stringField(body, 'refresh_token')
200
+ if (rotatedRefreshToken) {
201
+ await this.credManager.updateAadRefreshToken(params.accountType, rotatedRefreshToken, params.clientId)
202
+ }
203
+
204
+ const expiresIn = numberField(body, 'expires_in') ?? DEFAULT_ACCESS_TOKEN_TTL_SEC
205
+ return {
206
+ accessToken,
207
+ expiresAt: Date.now() + expiresIn * 1000,
208
+ aud: params.config.aud,
209
+ tenantId: claims.tid,
210
+ userId: claims.oid,
211
+ }
212
+ }
213
+
214
+ private async getCurrentAccount(): Promise<CurrentAccountContext> {
215
+ const config = await this.credManager.loadConfig()
216
+ if (!config) {
217
+ throw new TeamsError(
218
+ 'No Teams credentials found. Run `agent-teams auth login` or `agent-teams auth extract`.',
219
+ 'no_credentials',
220
+ )
221
+ }
222
+ const accountKey = this.boundAccountType ?? TeamsCredentialManager.accountOverride ?? config.current_account
223
+ if (accountKey !== 'work' && accountKey !== 'personal') {
224
+ throw new TeamsError(
225
+ 'No active Teams account selected. Run `agent-teams auth switch-account`.',
226
+ 'no_current_account',
227
+ )
228
+ }
229
+ const account = config.accounts[accountKey]
230
+ if (!account) {
231
+ throw new TeamsError(
232
+ 'No active Teams account selected. Run `agent-teams auth switch-account`.',
233
+ 'no_current_account',
234
+ )
235
+ }
236
+ return { accountType: accountKey, account }
237
+ }
238
+
239
+ private resetIdentityIfAccountChanged(accountType: TeamsAccountType): void {
240
+ if (this.lastAccountType === accountType) return
241
+ this.tenantId = undefined
242
+ this.userId = undefined
243
+ this.lastAccountType = accountType
244
+ }
245
+
246
+ private async ensureIdentity(): Promise<void> {
247
+ if (this.tenantId && this.userId) return
248
+ await this.getSubstrateToken()
249
+ }
250
+ }
251
+
252
+ function tokenUrlForAccount(accountType: TeamsAccountType): string {
253
+ return accountType === 'personal' ? consumerTokenUrl() : organizationsTokenUrl()
254
+ }
255
+
256
+ function decodeJwt(token: string): JwtClaims {
257
+ const payload = token.split('.')[1]
258
+ if (!payload) {
259
+ throw new TeamsError('Microsoft token response was not a JWT.', 'aad_token_invalid')
260
+ }
261
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<string, unknown>
262
+ return {
263
+ ...decoded,
264
+ aud: typeof decoded.aud === 'string' ? decoded.aud : undefined,
265
+ tid: typeof decoded.tid === 'string' ? decoded.tid : undefined,
266
+ oid: typeof decoded.oid === 'string' ? decoded.oid : undefined,
267
+ exp: typeof decoded.exp === 'number' ? decoded.exp : undefined,
268
+ }
269
+ }
270
+
271
+ function stringField(record: Record<string, unknown>, key: string): string | undefined {
272
+ const value = record[key]
273
+ return typeof value === 'string' ? value : undefined
274
+ }
275
+
276
+ function numberField(record: Record<string, unknown>, key: string): number | undefined {
277
+ const value = record[key]
278
+ return typeof value === 'number' ? value : undefined
279
+ }
280
+
281
+ function describeAadError(body: Record<string, unknown>, status: number): string {
282
+ const desc = body.error_description ?? body.error
283
+ return typeof desc === 'string' ? desc.split('\n')[0] : `HTTP ${status}`
284
+ }
@@ -0,0 +1,174 @@
1
+ import { expect, it } from 'bun:test'
2
+ import { gzipSync } from 'node:zlib'
3
+
4
+ import {
5
+ buildActivityFrame,
6
+ buildAuthenticateFrame,
7
+ buildEventAck,
8
+ buildPingFrame,
9
+ buildRequestAck,
10
+ buildWebSocketUrl,
11
+ decodeMessageBody,
12
+ extractChatId,
13
+ isMessageLossFrame,
14
+ isThreadConversation,
15
+ parseMentions,
16
+ parseRequestFrame,
17
+ type TrouterInfo,
18
+ } from './trouter'
19
+
20
+ const info: TrouterInfo = {
21
+ socketio: 'https://pub-ent-test.trouter.teams.microsoft.com:443/',
22
+ surl: 'https://pub-ent-test.trouter.teams.microsoft.com:3443/v4/f/HASH/',
23
+ connectparams: { sr: 'abc', issuer: 'prod-2', sig: 'xyz' },
24
+ }
25
+
26
+ it('buildWebSocketUrl targets the socket.io v1 websocket path', () => {
27
+ const url = buildWebSocketUrl(info, 'SESSION123', 'endpoint-uuid')
28
+ expect(url.startsWith(`${info.socketio}socket.io/1/websocket/SESSION123?`)).toBe(true)
29
+ expect(url).toContain('epid=endpoint-uuid')
30
+ expect(url).toContain('sr=abc')
31
+ expect(url).toContain('auth=true')
32
+ })
33
+
34
+ it('buildAuthenticateFrame embeds the Bearer id_token and connectparams', () => {
35
+ const frame = buildAuthenticateFrame(info, 'ID_TOKEN')
36
+ expect(frame.startsWith('5:::')).toBe(true)
37
+ const payload = JSON.parse(frame.slice(4))
38
+ expect(payload.name).toBe('user.authenticate')
39
+ expect(payload.args[0].headers.Authorization).toBe('Bearer ID_TOKEN')
40
+ expect(payload.args[0].connectparams).toEqual(info.connectparams)
41
+ })
42
+
43
+ it('buildActivityFrame and buildPingFrame use the sequence number', () => {
44
+ expect(buildActivityFrame(3)).toBe('5:3::{"name":"user.activity","args":[{"state":"active"}]}')
45
+ expect(buildPingFrame(7)).toBe('5:7::{"name":"ping"}')
46
+ })
47
+
48
+ it('buildRequestAck returns a 200 ack for the request id', () => {
49
+ const ack = buildRequestAck(42)
50
+ expect(ack.startsWith('3:::')).toBe(true)
51
+ expect(JSON.parse(ack.slice(4))).toEqual({ id: 42, status: 200, body: '' })
52
+ })
53
+
54
+ it('buildEventAck acks numbered event frames and ignores others', () => {
55
+ expect(buildEventAck('5:12::{"name":"trouter.message_loss"}')).toBe('6:12::')
56
+ expect(buildEventAck('5:9+::{"name":"x"}')).toBe('6:9::')
57
+ expect(buildEventAck('1::')).toBeNull()
58
+ expect(buildEventAck('6:::1["pong"]')).toBeNull()
59
+ })
60
+
61
+ it('parseRequestFrame parses 3::: frames and rejects others', () => {
62
+ const frame = '3:::{"id":1,"method":"POST","url":"/v4/f/HASH/messaging","body":"{}"}'
63
+ const parsed = parseRequestFrame(frame)
64
+ expect(parsed?.id).toBe(1)
65
+ expect(parsed?.url).toBe('/v4/f/HASH/messaging')
66
+ expect(parseRequestFrame('5:1::{}')).toBeNull()
67
+ expect(parseRequestFrame('3:::not-json')).toBeNull()
68
+ })
69
+
70
+ it('parseRequestFrame rejects frames without a numeric id', () => {
71
+ expect(parseRequestFrame('3:::{"url":"/x/messaging"}')).toBeNull()
72
+ expect(parseRequestFrame('3:::{"id":"abc"}')).toBeNull()
73
+ expect(parseRequestFrame('3:::"just-a-string"')).toBeNull()
74
+ expect(parseRequestFrame('3:::null')).toBeNull()
75
+ })
76
+
77
+ it('isMessageLossFrame detects trouter.message_loss events', () => {
78
+ expect(isMessageLossFrame('5:2::{"name":"trouter.message_loss","args":[{}]}')).toBe(true)
79
+ expect(isMessageLossFrame('5:1::{"name":"trouter.connected","args":[{}]}')).toBe(false)
80
+ expect(isMessageLossFrame('1::')).toBe(false)
81
+ })
82
+
83
+ it('extractChatId pulls the conversation id from a link', () => {
84
+ expect(extractChatId('https://notifications.skype.net/v1/users/ME/conversations/19:uni01_abc@thread.v2')).toBe(
85
+ '19:uni01_abc@thread.v2',
86
+ )
87
+ expect(extractChatId('https://x/conversations/19:uni01_abc@thread.v2/messages/123')).toBe('19:uni01_abc@thread.v2')
88
+ expect(extractChatId(undefined)).toBeNull()
89
+ expect(extractChatId('no-conversation-here')).toBeNull()
90
+ })
91
+
92
+ it('decodeMessageBody parses plain JSON bodies', () => {
93
+ const body = JSON.stringify({ resourceType: 'NewMessage', resource: { id: '1' } })
94
+ expect(decodeMessageBody({}, body)).toEqual({ resourceType: 'NewMessage', resource: { id: '1' } })
95
+ })
96
+
97
+ it('decodeMessageBody inflates gzip+base64 bodies', () => {
98
+ const inner = JSON.stringify({ resourceType: 'NewMessage' })
99
+ const gzipped = gzipSync(Buffer.from(inner)).toString('base64')
100
+ const decoded = decodeMessageBody({ 'X-Microsoft-Skype-Content-Encoding': 'gzip' }, gzipped)
101
+ expect(decoded).toEqual({ resourceType: 'NewMessage' })
102
+ })
103
+
104
+ it('decodeMessageBody unwraps nested cp (gzip+base64) payloads', () => {
105
+ const inner = JSON.stringify({ resourceType: 'NewMessage', resource: { content: 'hi' } })
106
+ const cp = gzipSync(Buffer.from(inner)).toString('base64')
107
+ const decoded = decodeMessageBody({}, JSON.stringify({ cp }))
108
+ expect(decoded).toEqual({ resourceType: 'NewMessage', resource: { content: 'hi' } })
109
+ })
110
+
111
+ it('decodeMessageBody unwraps nested gp (base64) payloads', () => {
112
+ const inner = JSON.stringify({ resourceType: 'NewMessage' })
113
+ const gp = Buffer.from(inner).toString('base64')
114
+ const decoded = decodeMessageBody({}, JSON.stringify({ gp }))
115
+ expect(decoded).toEqual({ resourceType: 'NewMessage' })
116
+ })
117
+
118
+ it('isThreadConversation flags @thread ids and rejects 1:1 conversations', () => {
119
+ expect(isThreadConversation('19:abc@thread.tacv2')).toBe(true)
120
+ expect(isThreadConversation('19:abc@thread.v2')).toBe(true)
121
+ expect(isThreadConversation('19:uni01_abc@unq.gbl.spaces')).toBe(false)
122
+ expect(isThreadConversation('8:orgid:user@oneToOne.skype')).toBe(false)
123
+ })
124
+
125
+ it('parseMentions prefers the authoritative properties.mentions array', () => {
126
+ const properties = {
127
+ mentions: [
128
+ { itemid: '0', mri: '8:orgid:aaa', mentionType: 'person', displayName: 'Alice' },
129
+ { itemid: '1', mri: '8:orgid:bbb', mentionType: 'person', displayName: 'Bob' },
130
+ ],
131
+ }
132
+ const content =
133
+ '<span itemtype="http://schema.skype.com/Mention" itemscope itemid="0">Alice</span> ' +
134
+ '<span itemtype="http://schema.skype.com/Mention" itemscope itemid="1">Bob</span> hi'
135
+ expect(parseMentions(properties, content)).toEqual([
136
+ { id: '0', mri: '8:orgid:aaa', displayName: 'Alice' },
137
+ { id: '1', mri: '8:orgid:bbb', displayName: 'Bob' },
138
+ ])
139
+ })
140
+
141
+ it('parseMentions accepts JSON-stringified properties and mentions', () => {
142
+ const properties = JSON.stringify({
143
+ mentions: JSON.stringify([{ itemid: '0', mri: '8:orgid:aaa', displayName: 'Alice' }]),
144
+ })
145
+ expect(parseMentions(properties, '')).toEqual([{ id: '0', mri: '8:orgid:aaa', displayName: 'Alice' }])
146
+ })
147
+
148
+ it('parseMentions keeps tag mentions', () => {
149
+ const properties = { mentions: [{ itemid: '0', mri: 'tag:eng', mentionType: 'tag', displayName: 'Engineering' }] }
150
+ expect(parseMentions(properties, '')).toEqual([{ id: '0', mri: 'tag:eng', displayName: 'Engineering' }])
151
+ })
152
+
153
+ it('parseMentions falls back to content spans when properties are absent', () => {
154
+ const content =
155
+ '<readonly class="skipProofing" itemtype="http://schema.skype.com/Mention" contenteditable="false">' +
156
+ '<span itemtype="http://schema.skype.com/Mention" itemscope itemid="0">Alice</span></readonly> hi'
157
+ expect(parseMentions(undefined, content)).toEqual([{ id: '0', displayName: 'Alice' }])
158
+ })
159
+
160
+ it('parseMentions skips property entries missing itemid', () => {
161
+ const properties = {
162
+ mentions: [
163
+ { mri: '8:orgid:aaa', displayName: 'Alice' },
164
+ { itemid: '1', displayName: 'Bob' },
165
+ ],
166
+ }
167
+ expect(parseMentions(properties, '')).toEqual([{ id: '1', mri: undefined, displayName: 'Bob' }])
168
+ })
169
+
170
+ it('parseMentions returns an empty array for malformed data', () => {
171
+ expect(parseMentions('not-json', '')).toEqual([])
172
+ expect(parseMentions({ mentions: 'not-json' }, '')).toEqual([])
173
+ expect(parseMentions(null, 'plain text with no mentions')).toEqual([])
174
+ })