agent-messenger 2.28.0 → 2.29.0
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 +18 -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 +19 -2
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +295 -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 +35 -0
- package/dist/src/platforms/teams/listener.d.ts.map +1 -0
- package/dist/src/platforms/teams/listener.js +236 -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 +31 -0
- package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
- package/dist/src/platforms/teams/trouter.js +181 -0
- package/dist/src/platforms/teams/trouter.js.map +1 -0
- package/dist/src/platforms/teams/types.d.ts +94 -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 +22 -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 +59 -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 +342 -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 +16 -1
- package/src/platforms/teams/listener.ts +295 -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 +114 -0
- package/src/platforms/teams/trouter.ts +228 -0
- package/src/platforms/teams/types.test.ts +17 -0
- package/src/platforms/teams/types.ts +86 -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
|
@@ -6,12 +6,13 @@ import { formatOutput } from '@/shared/utils/output'
|
|
|
6
6
|
import { TeamsClient } from '../client'
|
|
7
7
|
import { TeamsCredentialManager } from '../credential-manager'
|
|
8
8
|
import type { TeamsMessage } from '../types'
|
|
9
|
+
import { TeamsAuthCapabilityError } from '../types'
|
|
9
10
|
|
|
10
11
|
export async function sendAction(
|
|
11
12
|
teamId: string,
|
|
12
13
|
channelId: string,
|
|
13
14
|
content: string,
|
|
14
|
-
options: { pretty?: boolean },
|
|
15
|
+
options: { pretty?: boolean; thread?: string },
|
|
15
16
|
): Promise<void> {
|
|
16
17
|
try {
|
|
17
18
|
const credManager = new TeamsCredentialManager()
|
|
@@ -28,13 +29,15 @@ export async function sendAction(
|
|
|
28
29
|
accountType: cred.accountType,
|
|
29
30
|
region: cred.region,
|
|
30
31
|
})
|
|
31
|
-
const message = await client.sendMessage(teamId, channelId, content)
|
|
32
|
+
const message = await client.sendMessage(teamId, channelId, content, options.thread)
|
|
32
33
|
|
|
33
34
|
const output = {
|
|
34
35
|
id: message.id,
|
|
35
36
|
content: message.content,
|
|
36
37
|
author: message.author.displayName,
|
|
37
38
|
timestamp: message.timestamp,
|
|
39
|
+
root_message_id: message.root_message_id,
|
|
40
|
+
parent_message_id: message.parent_message_id,
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
console.log(formatOutput(output, options.pretty))
|
|
@@ -71,14 +74,122 @@ export async function listAction(
|
|
|
71
74
|
content: msg.content,
|
|
72
75
|
author: msg.author.displayName,
|
|
73
76
|
timestamp: msg.timestamp,
|
|
77
|
+
root_message_id: msg.root_message_id,
|
|
78
|
+
is_thread_reply: msg.is_thread_reply,
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
console.log(formatOutput(output, options.pretty))
|
|
82
|
+
} catch (error) {
|
|
83
|
+
handleError(error as Error)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function repliesAction(
|
|
88
|
+
teamId: string,
|
|
89
|
+
channelId: string,
|
|
90
|
+
messageId: string,
|
|
91
|
+
options: { limit?: number; pretty?: boolean },
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
try {
|
|
94
|
+
const credManager = new TeamsCredentialManager()
|
|
95
|
+
const cred = await credManager.getTokenWithExpiry()
|
|
96
|
+
|
|
97
|
+
if (!cred) {
|
|
98
|
+
console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
|
|
99
|
+
process.exit(1)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const client = await new TeamsClient().login({
|
|
103
|
+
token: cred.token,
|
|
104
|
+
tokenExpiresAt: cred.tokenExpiresAt,
|
|
105
|
+
accountType: cred.accountType,
|
|
106
|
+
region: cred.region,
|
|
107
|
+
})
|
|
108
|
+
const limit = options.limit || 50
|
|
109
|
+
const replies = await client.getThreadReplies(teamId, channelId, messageId, limit)
|
|
110
|
+
|
|
111
|
+
const output = replies.map((msg: TeamsMessage) => ({
|
|
112
|
+
id: msg.id,
|
|
113
|
+
content: msg.content,
|
|
114
|
+
author: msg.author.displayName,
|
|
115
|
+
timestamp: msg.timestamp,
|
|
116
|
+
root_message_id: msg.root_message_id,
|
|
117
|
+
}))
|
|
118
|
+
|
|
119
|
+
console.log(formatOutput(output, options.pretty))
|
|
120
|
+
} catch (error) {
|
|
121
|
+
handleError(error as Error)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function searchAction(
|
|
126
|
+
query: string,
|
|
127
|
+
options: { limit?: number; from?: number; pretty?: boolean },
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
try {
|
|
130
|
+
if (!isPositiveInteger(options.limit)) {
|
|
131
|
+
console.error(formatOutput({ error: '--limit must be a positive integer.' }, options.pretty))
|
|
132
|
+
process.exit(1)
|
|
133
|
+
}
|
|
134
|
+
if (!isNonNegativeInteger(options.from)) {
|
|
135
|
+
console.error(formatOutput({ error: '--from must be a non-negative integer.' }, options.pretty))
|
|
136
|
+
process.exit(1)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const credManager = new TeamsCredentialManager()
|
|
140
|
+
const cred = await credManager.getTokenWithExpiry()
|
|
141
|
+
|
|
142
|
+
if (!cred) {
|
|
143
|
+
console.error(formatOutput({ error: new TeamsAuthCapabilityError().message }, options.pretty))
|
|
144
|
+
process.exit(1)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const client = await new TeamsClient().login({
|
|
148
|
+
token: cred.token,
|
|
149
|
+
tokenExpiresAt: cred.tokenExpiresAt,
|
|
150
|
+
accountType: cred.accountType,
|
|
151
|
+
region: cred.region,
|
|
152
|
+
})
|
|
153
|
+
const results = await client.searchMessages(query, { limit: options.limit, from: options.from })
|
|
154
|
+
|
|
155
|
+
const output = results.map((result) => ({
|
|
156
|
+
id: result.id,
|
|
157
|
+
content: result.content,
|
|
158
|
+
author: result.author.displayName,
|
|
159
|
+
author_id: result.author.id,
|
|
160
|
+
channel_id: result.channel_id,
|
|
161
|
+
thread_id: result.thread_id,
|
|
162
|
+
team_name: result.team_name,
|
|
163
|
+
channel_name: result.channel_name,
|
|
164
|
+
timestamp: result.timestamp,
|
|
165
|
+
permalink: result.permalink,
|
|
74
166
|
}))
|
|
75
167
|
|
|
76
168
|
console.log(formatOutput(output, options.pretty))
|
|
77
169
|
} catch (error) {
|
|
170
|
+
if (error instanceof TeamsAuthCapabilityError) {
|
|
171
|
+
console.error(formatOutput({ error: error.message }, options.pretty))
|
|
172
|
+
process.exit(1)
|
|
173
|
+
}
|
|
78
174
|
handleError(error as Error)
|
|
79
175
|
}
|
|
80
176
|
}
|
|
81
177
|
|
|
178
|
+
function isPositiveInteger(value: number | undefined): boolean {
|
|
179
|
+
return value === undefined || (Number.isInteger(value) && value >= 1)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isNonNegativeInteger(value: number | undefined): boolean {
|
|
183
|
+
return value === undefined || (Number.isInteger(value) && value >= 0)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// parseInt truncates "1.5"/"1abc" to 1, sneaking malformed input past the integer
|
|
187
|
+
// checks in searchAction, so reject anything that isn't a pure decimal integer up front
|
|
188
|
+
export function strictParseInt(value: string | undefined): number {
|
|
189
|
+
if (value === undefined) return Number.NaN
|
|
190
|
+
return /^-?\d+$/.test(value.trim()) ? Number.parseInt(value, 10) : Number.NaN
|
|
191
|
+
}
|
|
192
|
+
|
|
82
193
|
export async function getAction(
|
|
83
194
|
teamId: string,
|
|
84
195
|
channelId: string,
|
|
@@ -158,10 +269,11 @@ export const messageCommand = new Command('message')
|
|
|
158
269
|
.description('Message commands')
|
|
159
270
|
.addCommand(
|
|
160
271
|
new Command('send')
|
|
161
|
-
.description('Send message to channel')
|
|
272
|
+
.description('Send message to channel (--thread <message-id> replies to a thread)')
|
|
162
273
|
.argument('<team-id>', 'Team ID')
|
|
163
274
|
.argument('<channel-id>', 'Channel ID')
|
|
164
275
|
.argument('<content>', 'Message content')
|
|
276
|
+
.option('--thread <message-id>', 'Reply to a thread root message')
|
|
165
277
|
.option('--pretty', 'Pretty print JSON output')
|
|
166
278
|
.action(sendAction),
|
|
167
279
|
)
|
|
@@ -179,6 +291,36 @@ export const messageCommand = new Command('message')
|
|
|
179
291
|
})
|
|
180
292
|
}),
|
|
181
293
|
)
|
|
294
|
+
.addCommand(
|
|
295
|
+
new Command('replies')
|
|
296
|
+
.description('List replies for a message thread')
|
|
297
|
+
.argument('<team-id>', 'Team ID')
|
|
298
|
+
.argument('<channel-id>', 'Channel ID')
|
|
299
|
+
.argument('<message-id>', 'Message ID')
|
|
300
|
+
.option('--limit <n>', 'Number of replies to retrieve', '50')
|
|
301
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
302
|
+
.action((teamId: string, channelId: string, messageId: string, options: any) => {
|
|
303
|
+
return repliesAction(teamId, channelId, messageId, {
|
|
304
|
+
limit: parseInt(options.limit, 10),
|
|
305
|
+
pretty: options.pretty,
|
|
306
|
+
})
|
|
307
|
+
}),
|
|
308
|
+
)
|
|
309
|
+
.addCommand(
|
|
310
|
+
new Command('search')
|
|
311
|
+
.description('Search Teams messages (requires auth login)')
|
|
312
|
+
.argument('<query>', 'Search query')
|
|
313
|
+
.option('--limit <n>', 'Number of results', '20')
|
|
314
|
+
.option('--from <n>', 'Offset for pagination', '0')
|
|
315
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
316
|
+
.action((query: string, options: any) => {
|
|
317
|
+
return searchAction(query, {
|
|
318
|
+
limit: strictParseInt(options.limit),
|
|
319
|
+
from: strictParseInt(options.from),
|
|
320
|
+
pretty: options.pretty,
|
|
321
|
+
})
|
|
322
|
+
}),
|
|
323
|
+
)
|
|
182
324
|
.addCommand(
|
|
183
325
|
new Command('get')
|
|
184
326
|
.description('Get a single message by ID')
|
|
@@ -204,4 +204,24 @@ describe('TeamsCredentialManager', () => {
|
|
|
204
204
|
expect(config?.accounts?.work?.current_team).toBe('team-1')
|
|
205
205
|
expect(config?.accounts?.work?.teams['team-1']).toEqual({ team_id: 'team-1', team_name: 'Team One' })
|
|
206
206
|
})
|
|
207
|
+
|
|
208
|
+
it('setDeviceCodeAccount switches current_account to the just-authenticated account', async () => {
|
|
209
|
+
const manager = setup()
|
|
210
|
+
await manager.setToken('work-token', 'work', '2025-12-31T23:59:59Z')
|
|
211
|
+
|
|
212
|
+
await manager.setDeviceCodeAccount({
|
|
213
|
+
accountType: 'personal',
|
|
214
|
+
token: 'personal-token',
|
|
215
|
+
tokenExpiresAt: '2025-12-31T23:59:59Z',
|
|
216
|
+
aadRefreshToken: 'refresh',
|
|
217
|
+
aadClientId: 'client',
|
|
218
|
+
teams: {},
|
|
219
|
+
currentTeam: null,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const config = await manager.loadConfig()
|
|
223
|
+
expect(config?.current_account).toBe('personal')
|
|
224
|
+
expect(config?.accounts?.personal?.auth_method).toBe('device-code')
|
|
225
|
+
expect(config?.accounts?.work?.token).toBe('work-token')
|
|
226
|
+
})
|
|
207
227
|
})
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { getConfigDir } from '../../shared/utils/config-dir'
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
TeamsAccount,
|
|
8
|
+
TeamsAccountType,
|
|
9
|
+
TeamsAuthMethod,
|
|
10
|
+
TeamsConfig,
|
|
11
|
+
TeamsConfigLegacy,
|
|
12
|
+
TeamsRegion,
|
|
13
|
+
} from './types'
|
|
7
14
|
|
|
8
15
|
export class TeamsCredentialManager {
|
|
9
16
|
static accountOverride?: TeamsAccountType
|
|
@@ -32,7 +39,9 @@ export class TeamsCredentialManager {
|
|
|
32
39
|
|
|
33
40
|
async saveConfig(config: TeamsConfig): Promise<void> {
|
|
34
41
|
await mkdir(this.configDir, { recursive: true })
|
|
35
|
-
|
|
42
|
+
const tmpPath = `${this.credentialsPath}.tmp`
|
|
43
|
+
await writeFile(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 })
|
|
44
|
+
await rename(tmpPath, this.credentialsPath)
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
private migrateIfNeeded(raw: TeamsConfig | TeamsConfigLegacy): TeamsConfig {
|
|
@@ -116,6 +125,62 @@ export class TeamsCredentialManager {
|
|
|
116
125
|
await this.saveConfig(config)
|
|
117
126
|
}
|
|
118
127
|
|
|
128
|
+
async setDeviceCodeAccount(params: {
|
|
129
|
+
accountType: TeamsAccountType
|
|
130
|
+
token: string
|
|
131
|
+
tokenExpiresAt: string
|
|
132
|
+
aadRefreshToken?: string
|
|
133
|
+
aadClientId?: string
|
|
134
|
+
aadTenantId?: string
|
|
135
|
+
region?: TeamsRegion
|
|
136
|
+
userName?: string
|
|
137
|
+
teams: Record<string, { team_id: string; team_name: string }>
|
|
138
|
+
currentTeam: string | null
|
|
139
|
+
authMethod?: TeamsAuthMethod
|
|
140
|
+
makeCurrent?: boolean
|
|
141
|
+
}): Promise<void> {
|
|
142
|
+
let config = await this.loadConfig()
|
|
143
|
+
if (!config) {
|
|
144
|
+
config = { current_account: params.accountType, accounts: {} }
|
|
145
|
+
}
|
|
146
|
+
const existing = config.accounts[params.accountType]
|
|
147
|
+
config.accounts[params.accountType] = {
|
|
148
|
+
token: params.token,
|
|
149
|
+
token_expires_at: params.tokenExpiresAt,
|
|
150
|
+
region: params.region ?? existing?.region,
|
|
151
|
+
account_type: params.accountType,
|
|
152
|
+
user_name: params.userName ?? existing?.user_name,
|
|
153
|
+
current_team: params.currentTeam,
|
|
154
|
+
teams: params.teams,
|
|
155
|
+
auth_method: params.authMethod ?? 'device-code',
|
|
156
|
+
aad_refresh_token: params.aadRefreshToken,
|
|
157
|
+
aad_client_id: params.aadClientId,
|
|
158
|
+
aad_tenant_id: params.aadTenantId ?? existing?.aad_tenant_id,
|
|
159
|
+
}
|
|
160
|
+
if (params.makeCurrent ?? true) {
|
|
161
|
+
config.current_account = params.accountType
|
|
162
|
+
}
|
|
163
|
+
await this.saveConfig(config)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async getAadRefresh(accountType: TeamsAccountType): Promise<{ refreshToken: string; clientId?: string } | null> {
|
|
167
|
+
const config = await this.loadConfig()
|
|
168
|
+
const account = config?.accounts[accountType]
|
|
169
|
+
if (!account?.aad_refresh_token) return null
|
|
170
|
+
return { refreshToken: account.aad_refresh_token, clientId: account.aad_client_id }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async updateAadRefreshToken(accountType: TeamsAccountType, refreshToken: string, clientId?: string): Promise<void> {
|
|
174
|
+
const config = await this.loadConfig()
|
|
175
|
+
const account = config?.accounts[accountType]
|
|
176
|
+
if (!config || !account) return
|
|
177
|
+
account.aad_refresh_token = refreshToken
|
|
178
|
+
if (clientId !== undefined) {
|
|
179
|
+
account.aad_client_id = clientId
|
|
180
|
+
}
|
|
181
|
+
await this.saveConfig(config)
|
|
182
|
+
}
|
|
183
|
+
|
|
119
184
|
async getCurrentTeam(): Promise<{ team_id: string; team_name: string } | null> {
|
|
120
185
|
const config = await this.loadConfig()
|
|
121
186
|
if (!config) return null
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { getTeamsAppClientId } from './app-config'
|
|
4
|
+
import {
|
|
5
|
+
decodeJwtTid,
|
|
6
|
+
exchangeDeviceCode,
|
|
7
|
+
exchangeForSkypeScope,
|
|
8
|
+
isConsumerTenant,
|
|
9
|
+
mintConsumerSkypeToken,
|
|
10
|
+
mintSkypeToken,
|
|
11
|
+
pollDeviceToken,
|
|
12
|
+
requestDeviceCode,
|
|
13
|
+
resolveWorkTenantId,
|
|
14
|
+
} from './device-code'
|
|
15
|
+
|
|
16
|
+
const CLIENT_ID = '5e3ce6c0-2b1f-4285-8d4b-75ee78787346'
|
|
17
|
+
const REAL_TENANT_ID = 'c0ffee00-1111-2222-3333-444455556666'
|
|
18
|
+
const originalFetch = globalThis.fetch
|
|
19
|
+
|
|
20
|
+
function fakeJwt(claims: Record<string, unknown>): string {
|
|
21
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
|
|
22
|
+
return `header.${payload}.signature`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface FakeResponse {
|
|
26
|
+
status?: number
|
|
27
|
+
json: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mockFetch(responder: (url: string, init: RequestInit) => FakeResponse): {
|
|
31
|
+
calls: Array<{ url: string; body: URLSearchParams; init: RequestInit }>
|
|
32
|
+
} {
|
|
33
|
+
const calls: Array<{ url: string; body: URLSearchParams; init: RequestInit }> = []
|
|
34
|
+
globalThis.fetch = mock((input: string | URL | Request, init?: RequestInit) => {
|
|
35
|
+
const url = String(input)
|
|
36
|
+
const raw = (init?.body as string) ?? ''
|
|
37
|
+
calls.push({ url, body: new URLSearchParams(raw), init: init ?? {} })
|
|
38
|
+
const { status = 200, json } = responder(url, init ?? {})
|
|
39
|
+
return Promise.resolve({
|
|
40
|
+
ok: status >= 200 && status < 300,
|
|
41
|
+
status,
|
|
42
|
+
json: () => Promise.resolve(json),
|
|
43
|
+
} as Response)
|
|
44
|
+
}) as typeof fetch
|
|
45
|
+
return { calls }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
globalThis.fetch = originalFetch
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('getTeamsAppClientId', () => {
|
|
53
|
+
it('keeps the consumer client for personal login and uses Teams desktop client for work login', () => {
|
|
54
|
+
expect(getTeamsAppClientId('personal').clientId).toBe('5e3ce6c0-2b1f-4285-8d4b-75ee78787346')
|
|
55
|
+
expect(getTeamsAppClientId('work').clientId).toBe('1fec8e78-bce4-4aaf-ab1b-5451cc387264')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('requestDeviceCode', () => {
|
|
60
|
+
it('posts to consumer devicecode endpoint with teams scope and composes verificationUriComplete', async () => {
|
|
61
|
+
const { calls } = mockFetch(() => ({
|
|
62
|
+
json: {
|
|
63
|
+
device_code: 'DC',
|
|
64
|
+
user_code: 'ABCD-1234',
|
|
65
|
+
verification_uri: 'https://microsoft.com/devicelogin',
|
|
66
|
+
expires_in: 900,
|
|
67
|
+
interval: 5,
|
|
68
|
+
},
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
const info = await requestDeviceCode(CLIENT_ID)
|
|
72
|
+
|
|
73
|
+
expect(calls[0].url).toContain('/9188040d-6c67-4c5b-b112-36a304b66dad/oauth2/v2.0/devicecode')
|
|
74
|
+
expect(calls[0].body.get('client_id')).toBe(CLIENT_ID)
|
|
75
|
+
expect(calls[0].body.get('scope')).toContain('api.fl.teams.microsoft.com')
|
|
76
|
+
expect(calls[0].body.get('scope')).toContain('offline_access')
|
|
77
|
+
expect(info.verificationUriComplete).toBe('https://microsoft.com/devicelogin?otc=ABCD-1234')
|
|
78
|
+
expect(info.interval).toBe(5)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('posts work-account device code requests to organizations authority with Teams scope', async () => {
|
|
82
|
+
const { calls } = mockFetch(() => ({
|
|
83
|
+
json: {
|
|
84
|
+
device_code: 'DC',
|
|
85
|
+
user_code: 'ABCD-1234',
|
|
86
|
+
verification_uri: 'https://microsoft.com/devicelogin',
|
|
87
|
+
expires_in: 900,
|
|
88
|
+
interval: 5,
|
|
89
|
+
},
|
|
90
|
+
}))
|
|
91
|
+
|
|
92
|
+
await requestDeviceCode(CLIENT_ID, 'work')
|
|
93
|
+
|
|
94
|
+
expect(calls[0].url).toContain('/organizations/oauth2/v2.0/devicecode')
|
|
95
|
+
expect(calls[0].body.get('client_id')).toBe(CLIENT_ID)
|
|
96
|
+
expect(calls[0].body.get('scope')).toBe('https://api.spaces.skype.com/.default openid profile offline_access')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('throws on non-200', async () => {
|
|
100
|
+
mockFetch(() => ({ status: 400, json: { error: 'invalid_client', error_description: 'bad client' } }))
|
|
101
|
+
await expect(requestDeviceCode(CLIENT_ID)).rejects.toThrow(/bad client/)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('exchangeDeviceCode', () => {
|
|
106
|
+
it('maps authorization_pending to pending', async () => {
|
|
107
|
+
mockFetch(() => ({ status: 400, json: { error: 'authorization_pending' } }))
|
|
108
|
+
expect(await exchangeDeviceCode('DC', CLIENT_ID)).toEqual({ status: 'pending' })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('maps slow_down, expired_token, authorization_declined', async () => {
|
|
112
|
+
mockFetch(() => ({ status: 400, json: { error: 'slow_down' } }))
|
|
113
|
+
expect((await exchangeDeviceCode('DC', CLIENT_ID)).status).toBe('slow_down')
|
|
114
|
+
mockFetch(() => ({ status: 400, json: { error: 'expired_token' } }))
|
|
115
|
+
expect((await exchangeDeviceCode('DC', CLIENT_ID)).status).toBe('expired')
|
|
116
|
+
mockFetch(() => ({ status: 400, json: { error: 'authorization_declined' } }))
|
|
117
|
+
expect((await exchangeDeviceCode('DC', CLIENT_ID)).status).toBe('declined')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('returns success with tokens', async () => {
|
|
121
|
+
mockFetch(() => ({ json: { access_token: 'AT', refresh_token: 'RT' } }))
|
|
122
|
+
const result = await exchangeDeviceCode('DC', CLIENT_ID)
|
|
123
|
+
expect(result).toEqual({ status: 'success', token: { accessToken: 'AT', refreshToken: 'RT' } })
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('pollDeviceToken', () => {
|
|
128
|
+
it('polls until success', async () => {
|
|
129
|
+
let n = 0
|
|
130
|
+
mockFetch(() =>
|
|
131
|
+
n++ < 2
|
|
132
|
+
? { status: 400, json: { error: 'authorization_pending' } }
|
|
133
|
+
: { json: { access_token: 'AT', refresh_token: 'RT' } },
|
|
134
|
+
)
|
|
135
|
+
const token = await pollDeviceToken('DC', 0, 10, CLIENT_ID)
|
|
136
|
+
expect(token.accessToken).toBe('AT')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('throws when device code expires', async () => {
|
|
140
|
+
mockFetch(() => ({ status: 400, json: { error: 'expired_token' } }))
|
|
141
|
+
await expect(pollDeviceToken('DC', 0, 10, CLIENT_ID)).rejects.toThrow(/expired/)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('exchangeForSkypeScope', () => {
|
|
146
|
+
it('refresh-exchanges to the spaces.skype scope and keeps a refresh token', async () => {
|
|
147
|
+
const { calls } = mockFetch(() => ({ json: { access_token: 'SKYPE_AT', refresh_token: 'RT2' } }))
|
|
148
|
+
const token = await exchangeForSkypeScope('RT1', CLIENT_ID)
|
|
149
|
+
expect(calls[0].body.get('grant_type')).toBe('refresh_token')
|
|
150
|
+
expect(calls[0].body.get('scope')).toContain('api.fl.spaces.skype.com')
|
|
151
|
+
expect(calls[0].body.get('scope')).toContain('offline_access')
|
|
152
|
+
expect(token).toEqual({ accessToken: 'SKYPE_AT', refreshToken: 'RT2' })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('falls back to the input refresh token when none is rotated', async () => {
|
|
156
|
+
mockFetch(() => ({ json: { access_token: 'SKYPE_AT' } }))
|
|
157
|
+
const token = await exchangeForSkypeScope('RT1', CLIENT_ID)
|
|
158
|
+
expect(token.refreshToken).toBe('RT1')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('refresh-exchanges work accounts against organizations authority with Teams Skype scope', async () => {
|
|
162
|
+
const { calls } = mockFetch(() => ({ json: { access_token: 'WORK_SKYPE_AT', refresh_token: 'RT2' } }))
|
|
163
|
+
|
|
164
|
+
const token = await exchangeForSkypeScope('RT1', CLIENT_ID, 'work')
|
|
165
|
+
|
|
166
|
+
expect(calls[0].url).toContain('/organizations/oauth2/v2.0/token')
|
|
167
|
+
expect(calls[0].body.get('grant_type')).toBe('refresh_token')
|
|
168
|
+
expect(calls[0].body.get('scope')).toBe('https://api.spaces.skype.com/.default openid profile offline_access')
|
|
169
|
+
expect(token).toEqual({ accessToken: 'WORK_SKYPE_AT', refreshToken: 'RT2' })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('targets the tenant-specific authority when a tenant id is provided', async () => {
|
|
173
|
+
const { calls } = mockFetch(() => ({ json: { access_token: 'TENANT_SKYPE_AT', refresh_token: 'RT2' } }))
|
|
174
|
+
|
|
175
|
+
await exchangeForSkypeScope('RT1', CLIENT_ID, 'work', REAL_TENANT_ID)
|
|
176
|
+
|
|
177
|
+
expect(calls[0].url).toContain(`/${REAL_TENANT_ID}/oauth2/v2.0/token`)
|
|
178
|
+
expect(calls[0].url).not.toContain('/organizations/')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('resolveWorkTenantId', () => {
|
|
183
|
+
it('uses the token tid directly when it is already a real tenant guid', async () => {
|
|
184
|
+
const { calls } = mockFetch(() => ({ json: [] }))
|
|
185
|
+
|
|
186
|
+
const tenantId = await resolveWorkTenantId(fakeJwt({ tid: REAL_TENANT_ID }))
|
|
187
|
+
|
|
188
|
+
expect(tenantId).toBe(REAL_TENANT_ID)
|
|
189
|
+
// given a concrete tenant, discovery must be skipped
|
|
190
|
+
expect(calls).toHaveLength(0)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('discovers the tenant when the token tid is the organizations placeholder', async () => {
|
|
194
|
+
const { calls } = mockFetch(() => ({
|
|
195
|
+
json: [{ tenantId: REAL_TENANT_ID, tenantName: 'Contoso', isInvitationRedeemed: true }],
|
|
196
|
+
}))
|
|
197
|
+
|
|
198
|
+
const tenantId = await resolveWorkTenantId(fakeJwt({ tid: 'organizations' }))
|
|
199
|
+
|
|
200
|
+
expect(tenantId).toBe(REAL_TENANT_ID)
|
|
201
|
+
expect(calls[0].url).toContain('/api/mt/emea/beta/users/tenants')
|
|
202
|
+
expect((calls[0].init.headers as Record<string, string>).Authorization).toContain('Bearer ')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('discovers the tenant when the token tid is the Microsoft Services placeholder', async () => {
|
|
206
|
+
const { calls } = mockFetch(() => ({
|
|
207
|
+
json: [{ tenantId: REAL_TENANT_ID, isInvitationRedeemed: true }],
|
|
208
|
+
}))
|
|
209
|
+
|
|
210
|
+
const tenantId = await resolveWorkTenantId(fakeJwt({ tid: 'f8cdef31-a31e-4b4a-93e4-5f571e91255a' }))
|
|
211
|
+
|
|
212
|
+
expect(tenantId).toBe(REAL_TENANT_ID)
|
|
213
|
+
expect(calls[0].url).toContain('/api/mt/emea/beta/users/tenants')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('prefers the first redeemed tenant over an unredeemed one', async () => {
|
|
217
|
+
mockFetch(() => ({
|
|
218
|
+
json: [
|
|
219
|
+
{ tenantId: 'guest-tenant', isInvitationRedeemed: false },
|
|
220
|
+
{ tenantId: REAL_TENANT_ID, isInvitationRedeemed: true },
|
|
221
|
+
],
|
|
222
|
+
}))
|
|
223
|
+
|
|
224
|
+
const tenantId = await resolveWorkTenantId(fakeJwt({ tid: 'organizations' }))
|
|
225
|
+
|
|
226
|
+
expect(tenantId).toBe(REAL_TENANT_ID)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('throws an actionable error when only an unredeemed guest tenant exists', async () => {
|
|
230
|
+
mockFetch(() => ({ json: [{ tenantId: 'guest-tenant', isInvitationRedeemed: false }] }))
|
|
231
|
+
|
|
232
|
+
await expect(resolveWorkTenantId(fakeJwt({ tid: 'organizations' }))).rejects.toThrow(
|
|
233
|
+
/invitation has not been accepted/,
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('throws when no tenant is associated with the account and points to the personal flow', async () => {
|
|
238
|
+
mockFetch(() => ({ json: [] }))
|
|
239
|
+
|
|
240
|
+
await expect(resolveWorkTenantId(fakeJwt({ tid: 'organizations' }))).rejects.toThrow(/--account-type personal/)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
describe('decodeJwtTid', () => {
|
|
245
|
+
it('returns the tid claim from a JWT access token', () => {
|
|
246
|
+
expect(decodeJwtTid(fakeJwt({ tid: '9188040d-6c67-4c5b-b112-36a304b66dad' }))).toBe(
|
|
247
|
+
'9188040d-6c67-4c5b-b112-36a304b66dad',
|
|
248
|
+
)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('returns undefined for a malformed token', () => {
|
|
252
|
+
expect(decodeJwtTid('not-a-jwt')).toBeUndefined()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('returns undefined when the tid claim is absent', () => {
|
|
256
|
+
expect(decodeJwtTid(fakeJwt({ sub: 'user' }))).toBeUndefined()
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('isConsumerTenant', () => {
|
|
261
|
+
it('recognizes the consumer tenant as personal', () => {
|
|
262
|
+
expect(isConsumerTenant('9188040d-6c67-4c5b-b112-36a304b66dad')).toBe(true)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('does not treat the Microsoft Services placeholder as personal (work tokens carry it too)', () => {
|
|
266
|
+
expect(isConsumerTenant('f8cdef31-a31e-4b4a-93e4-5f571e91255a')).toBe(false)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('does not treat the organizations placeholder or a real tenant as personal', () => {
|
|
270
|
+
expect(isConsumerTenant('organizations')).toBe(false)
|
|
271
|
+
expect(isConsumerTenant(REAL_TENANT_ID)).toBe(false)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe('mintConsumerSkypeToken', () => {
|
|
276
|
+
it('parses shape A { skypeToken: { skypetoken } } and derives expiry from skypeTokenExpiresIn', async () => {
|
|
277
|
+
mockFetch(() => ({ json: { skypeToken: { skypetoken: 'SKYPE', skypeTokenExpiresIn: 3600 } } }))
|
|
278
|
+
const before = Date.now()
|
|
279
|
+
const minted = await mintConsumerSkypeToken('AT')
|
|
280
|
+
expect(minted.skypeToken).toBe('SKYPE')
|
|
281
|
+
const ttl = new Date(minted.skypeTokenExpiresAt).getTime() - before
|
|
282
|
+
expect(ttl).toBeGreaterThan(3000 * 1000)
|
|
283
|
+
expect(ttl).toBeLessThan(3700 * 1000)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('parses shape B { tokens: { skypeToken } }', async () => {
|
|
287
|
+
mockFetch(() => ({ json: { tokens: { skypeToken: 'SKYPE_B' } } }))
|
|
288
|
+
const minted = await mintConsumerSkypeToken('AT')
|
|
289
|
+
expect(minted.skypeToken).toBe('SKYPE_B')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('defaults TTL when expiry is absent', async () => {
|
|
293
|
+
mockFetch(() => ({ json: { skypeToken: { skypetoken: 'SKYPE' } } }))
|
|
294
|
+
const before = Date.now()
|
|
295
|
+
const minted = await mintConsumerSkypeToken('AT')
|
|
296
|
+
const ttl = new Date(minted.skypeTokenExpiresAt).getTime() - before
|
|
297
|
+
expect(ttl).toBeGreaterThan(21000 * 1000)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('throws when no skype token present', async () => {
|
|
301
|
+
mockFetch(() => ({ status: 403, json: { errorCode: 'UserLicenseNotPresentForbidden', message: 'Teams disabled' } }))
|
|
302
|
+
await expect(mintConsumerSkypeToken('AT')).rejects.toThrow(/UserLicenseNotPresentForbidden/)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('augments GuestUserNotRedeemed with an actionable hint', async () => {
|
|
306
|
+
mockFetch(() => ({ status: 400, json: { errorCode: 'GuestUserNotRedeemed' } }))
|
|
307
|
+
await expect(mintSkypeToken('AT', 'work')).rejects.toThrow(/GuestUserNotRedeemed/)
|
|
308
|
+
mockFetch(() => ({ status: 400, json: { errorCode: 'GuestUserNotRedeemed' } }))
|
|
309
|
+
await expect(mintSkypeToken('AT', 'work')).rejects.toThrow(/--account-type personal/)
|
|
310
|
+
})
|
|
311
|
+
})
|