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.
Files changed (157) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +18 -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 +19 -2
  28. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  29. package/dist/src/platforms/teams/client.js +295 -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 +35 -0
  63. package/dist/src/platforms/teams/listener.d.ts.map +1 -0
  64. package/dist/src/platforms/teams/listener.js +236 -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 +31 -0
  79. package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
  80. package/dist/src/platforms/teams/trouter.js +181 -0
  81. package/dist/src/platforms/teams/trouter.js.map +1 -0
  82. package/dist/src/platforms/teams/types.d.ts +94 -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 +22 -0
  97. package/package.json +1 -1
  98. package/skills/agent-channeltalk/SKILL.md +1 -1
  99. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  100. package/skills/agent-discord/SKILL.md +1 -1
  101. package/skills/agent-discordbot/SKILL.md +1 -1
  102. package/skills/agent-imessage/SKILL.md +1 -1
  103. package/skills/agent-instagram/SKILL.md +1 -1
  104. package/skills/agent-instagram/references/authentication.md +11 -0
  105. package/skills/agent-kakaotalk/SKILL.md +1 -1
  106. package/skills/agent-line/SKILL.md +1 -1
  107. package/skills/agent-slack/SKILL.md +1 -1
  108. package/skills/agent-slackbot/SKILL.md +1 -1
  109. package/skills/agent-teams/SKILL.md +59 -6
  110. package/skills/agent-teams/references/authentication.md +31 -1
  111. package/skills/agent-telegram/SKILL.md +1 -1
  112. package/skills/agent-telegrambot/SKILL.md +1 -1
  113. package/skills/agent-webex/SKILL.md +1 -1
  114. package/skills/agent-webexbot/SKILL.md +1 -1
  115. package/skills/agent-wechatbot/SKILL.md +1 -1
  116. package/skills/agent-whatsapp/SKILL.md +1 -1
  117. package/skills/agent-whatsappbot/SKILL.md +1 -1
  118. package/src/platforms/discord/client.test.ts +86 -0
  119. package/src/platforms/discord/client.ts +10 -3
  120. package/src/platforms/instagram/client.test.ts +478 -33
  121. package/src/platforms/instagram/client.ts +449 -68
  122. package/src/platforms/instagram/commands/auth.test.ts +177 -1
  123. package/src/platforms/instagram/commands/auth.ts +182 -11
  124. package/src/platforms/instagram/credential-manager.test.ts +30 -0
  125. package/src/platforms/instagram/credential-manager.ts +23 -1
  126. package/src/platforms/instagram/types.ts +11 -8
  127. package/src/platforms/teams/app-config.ts +92 -0
  128. package/src/platforms/teams/client.test.ts +335 -1
  129. package/src/platforms/teams/client.ts +342 -7
  130. package/src/platforms/teams/commands/auth.test.ts +93 -1
  131. package/src/platforms/teams/commands/auth.ts +182 -3
  132. package/src/platforms/teams/commands/file.test.ts +92 -1
  133. package/src/platforms/teams/commands/file.ts +76 -1
  134. package/src/platforms/teams/commands/message.test.ts +95 -1
  135. package/src/platforms/teams/commands/message.ts +145 -3
  136. package/src/platforms/teams/credential-manager.test.ts +20 -0
  137. package/src/platforms/teams/credential-manager.ts +68 -3
  138. package/src/platforms/teams/device-code.test.ts +311 -0
  139. package/src/platforms/teams/device-code.ts +277 -0
  140. package/src/platforms/teams/device-login.test.ts +288 -0
  141. package/src/platforms/teams/device-login.ts +206 -0
  142. package/src/platforms/teams/ensure-auth.ts +11 -0
  143. package/src/platforms/teams/index.test.ts +10 -0
  144. package/src/platforms/teams/index.ts +16 -1
  145. package/src/platforms/teams/listener.ts +295 -0
  146. package/src/platforms/teams/realm-discovery.test.ts +73 -0
  147. package/src/platforms/teams/realm-discovery.ts +42 -0
  148. package/src/platforms/teams/token-extractor.ts +69 -0
  149. package/src/platforms/teams/token-provider.test.ts +227 -0
  150. package/src/platforms/teams/token-provider.ts +284 -0
  151. package/src/platforms/teams/trouter.test.ts +114 -0
  152. package/src/platforms/teams/trouter.ts +228 -0
  153. package/src/platforms/teams/types.test.ts +17 -0
  154. package/src/platforms/teams/types.ts +86 -0
  155. package/src/tui/adapters/instagram-adapter.ts +13 -0
  156. package/src/tui/adapters/teams-adapter.ts +23 -0
  157. 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 { TeamsAccount, TeamsAccountType, TeamsConfig, TeamsConfigLegacy, TeamsRegion } from './types'
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
- await writeFile(this.credentialsPath, JSON.stringify(config, null, 2), { mode: 0o600 })
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
+ })