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,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
|
+
})
|