agent-messenger 2.28.0 → 2.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +29 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/client.d.ts +6 -2
  5. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/client.js +10 -3
  7. package/dist/src/platforms/discord/client.js.map +1 -1
  8. package/dist/src/platforms/instagram/client.d.ts +24 -2
  9. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  10. package/dist/src/platforms/instagram/client.js +343 -53
  11. package/dist/src/platforms/instagram/client.js.map +1 -1
  12. package/dist/src/platforms/instagram/commands/auth.d.ts +1 -0
  13. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  14. package/dist/src/platforms/instagram/commands/auth.js +123 -11
  15. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  16. package/dist/src/platforms/instagram/credential-manager.d.ts +4 -1
  17. package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
  18. package/dist/src/platforms/instagram/credential-manager.js +17 -1
  19. package/dist/src/platforms/instagram/credential-manager.js.map +1 -1
  20. package/dist/src/platforms/instagram/types.d.ts +10 -8
  21. package/dist/src/platforms/instagram/types.d.ts.map +1 -1
  22. package/dist/src/platforms/instagram/types.js.map +1 -1
  23. package/dist/src/platforms/teams/app-config.d.ts +36 -0
  24. package/dist/src/platforms/teams/app-config.d.ts.map +1 -0
  25. package/dist/src/platforms/teams/app-config.js +69 -0
  26. package/dist/src/platforms/teams/app-config.js.map +1 -0
  27. package/dist/src/platforms/teams/client.d.ts +20 -2
  28. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  29. package/dist/src/platforms/teams/client.js +311 -7
  30. package/dist/src/platforms/teams/client.js.map +1 -1
  31. package/dist/src/platforms/teams/commands/auth.d.ts +10 -0
  32. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  33. package/dist/src/platforms/teams/commands/auth.js +137 -3
  34. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  35. package/dist/src/platforms/teams/commands/file.d.ts +3 -0
  36. package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
  37. package/dist/src/platforms/teams/commands/file.js +59 -2
  38. package/dist/src/platforms/teams/commands/file.js.map +1 -1
  39. package/dist/src/platforms/teams/commands/message.d.ts +11 -0
  40. package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
  41. package/dist/src/platforms/teams/commands/message.js +121 -2
  42. package/dist/src/platforms/teams/commands/message.js.map +1 -1
  43. package/dist/src/platforms/teams/credential-manager.d.ts +23 -1
  44. package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
  45. package/dist/src/platforms/teams/credential-manager.js +46 -2
  46. package/dist/src/platforms/teams/credential-manager.js.map +1 -1
  47. package/dist/src/platforms/teams/device-code.d.ts +48 -0
  48. package/dist/src/platforms/teams/device-code.d.ts.map +1 -0
  49. package/dist/src/platforms/teams/device-code.js +199 -0
  50. package/dist/src/platforms/teams/device-code.js.map +1 -0
  51. package/dist/src/platforms/teams/device-login.d.ts +37 -0
  52. package/dist/src/platforms/teams/device-login.d.ts.map +1 -0
  53. package/dist/src/platforms/teams/device-login.js +129 -0
  54. package/dist/src/platforms/teams/device-login.js.map +1 -0
  55. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  56. package/dist/src/platforms/teams/ensure-auth.js +12 -0
  57. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  58. package/dist/src/platforms/teams/index.d.ts +7 -3
  59. package/dist/src/platforms/teams/index.d.ts.map +1 -1
  60. package/dist/src/platforms/teams/index.js +5 -2
  61. package/dist/src/platforms/teams/index.js.map +1 -1
  62. package/dist/src/platforms/teams/listener.d.ts +40 -0
  63. package/dist/src/platforms/teams/listener.d.ts.map +1 -0
  64. package/dist/src/platforms/teams/listener.js +286 -0
  65. package/dist/src/platforms/teams/listener.js.map +1 -0
  66. package/dist/src/platforms/teams/realm-discovery.d.ts +3 -0
  67. package/dist/src/platforms/teams/realm-discovery.d.ts.map +1 -0
  68. package/dist/src/platforms/teams/realm-discovery.js +33 -0
  69. package/dist/src/platforms/teams/realm-discovery.js.map +1 -0
  70. package/dist/src/platforms/teams/token-extractor.d.ts +3 -0
  71. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  72. package/dist/src/platforms/teams/token-extractor.js +60 -0
  73. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  74. package/dist/src/platforms/teams/token-provider.d.ts +25 -0
  75. package/dist/src/platforms/teams/token-provider.d.ts.map +1 -0
  76. package/dist/src/platforms/teams/token-provider.js +190 -0
  77. package/dist/src/platforms/teams/token-provider.js.map +1 -0
  78. package/dist/src/platforms/teams/trouter.d.ts +38 -0
  79. package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
  80. package/dist/src/platforms/teams/trouter.js +266 -0
  81. package/dist/src/platforms/teams/trouter.js.map +1 -0
  82. package/dist/src/platforms/teams/types.d.ts +103 -0
  83. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  84. package/dist/src/platforms/teams/types.js +30 -0
  85. package/dist/src/platforms/teams/types.js.map +1 -1
  86. package/dist/src/platforms/webex/types.d.ts +1 -1
  87. package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
  88. package/dist/src/tui/adapters/instagram-adapter.js +16 -0
  89. package/dist/src/tui/adapters/instagram-adapter.js.map +1 -1
  90. package/dist/src/tui/adapters/teams-adapter.d.ts +3 -0
  91. package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
  92. package/dist/src/tui/adapters/teams-adapter.js +21 -0
  93. package/dist/src/tui/adapters/teams-adapter.js.map +1 -1
  94. package/dist/src/tui/app.js +3 -3
  95. package/docs/content/docs/cli/instagram.mdx +1 -0
  96. package/docs/content/docs/cli/teams.mdx +36 -0
  97. package/docs/content/docs/sdk/teams.mdx +30 -0
  98. package/package.json +1 -1
  99. package/skills/agent-channeltalk/SKILL.md +1 -1
  100. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  101. package/skills/agent-discord/SKILL.md +1 -1
  102. package/skills/agent-discordbot/SKILL.md +1 -1
  103. package/skills/agent-imessage/SKILL.md +1 -1
  104. package/skills/agent-instagram/SKILL.md +1 -1
  105. package/skills/agent-instagram/references/authentication.md +11 -0
  106. package/skills/agent-kakaotalk/SKILL.md +1 -1
  107. package/skills/agent-line/SKILL.md +1 -1
  108. package/skills/agent-slack/SKILL.md +1 -1
  109. package/skills/agent-slackbot/SKILL.md +1 -1
  110. package/skills/agent-teams/SKILL.md +82 -6
  111. package/skills/agent-teams/references/authentication.md +31 -1
  112. package/skills/agent-telegram/SKILL.md +1 -1
  113. package/skills/agent-telegrambot/SKILL.md +1 -1
  114. package/skills/agent-webex/SKILL.md +1 -1
  115. package/skills/agent-webexbot/SKILL.md +1 -1
  116. package/skills/agent-wechatbot/SKILL.md +1 -1
  117. package/skills/agent-whatsapp/SKILL.md +1 -1
  118. package/skills/agent-whatsappbot/SKILL.md +1 -1
  119. package/src/platforms/discord/client.test.ts +86 -0
  120. package/src/platforms/discord/client.ts +10 -3
  121. package/src/platforms/instagram/client.test.ts +478 -33
  122. package/src/platforms/instagram/client.ts +449 -68
  123. package/src/platforms/instagram/commands/auth.test.ts +177 -1
  124. package/src/platforms/instagram/commands/auth.ts +182 -11
  125. package/src/platforms/instagram/credential-manager.test.ts +30 -0
  126. package/src/platforms/instagram/credential-manager.ts +23 -1
  127. package/src/platforms/instagram/types.ts +11 -8
  128. package/src/platforms/teams/app-config.ts +92 -0
  129. package/src/platforms/teams/client.test.ts +335 -1
  130. package/src/platforms/teams/client.ts +370 -7
  131. package/src/platforms/teams/commands/auth.test.ts +93 -1
  132. package/src/platforms/teams/commands/auth.ts +182 -3
  133. package/src/platforms/teams/commands/file.test.ts +92 -1
  134. package/src/platforms/teams/commands/file.ts +76 -1
  135. package/src/platforms/teams/commands/message.test.ts +95 -1
  136. package/src/platforms/teams/commands/message.ts +145 -3
  137. package/src/platforms/teams/credential-manager.test.ts +20 -0
  138. package/src/platforms/teams/credential-manager.ts +68 -3
  139. package/src/platforms/teams/device-code.test.ts +311 -0
  140. package/src/platforms/teams/device-code.ts +277 -0
  141. package/src/platforms/teams/device-login.test.ts +288 -0
  142. package/src/platforms/teams/device-login.ts +206 -0
  143. package/src/platforms/teams/ensure-auth.ts +11 -0
  144. package/src/platforms/teams/index.test.ts +10 -0
  145. package/src/platforms/teams/index.ts +17 -1
  146. package/src/platforms/teams/listener.test.ts +243 -0
  147. package/src/platforms/teams/listener.ts +351 -0
  148. package/src/platforms/teams/realm-discovery.test.ts +73 -0
  149. package/src/platforms/teams/realm-discovery.ts +42 -0
  150. package/src/platforms/teams/token-extractor.ts +69 -0
  151. package/src/platforms/teams/token-provider.test.ts +227 -0
  152. package/src/platforms/teams/token-provider.ts +284 -0
  153. package/src/platforms/teams/trouter.test.ts +174 -0
  154. package/src/platforms/teams/trouter.ts +323 -0
  155. package/src/platforms/teams/types.test.ts +17 -0
  156. package/src/platforms/teams/types.ts +96 -0
  157. package/src/tui/adapters/instagram-adapter.ts +13 -0
  158. package/src/tui/adapters/teams-adapter.ts +23 -0
  159. package/src/tui/app.ts +3 -3
@@ -1,7 +1,10 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { readFile } from 'node:fs/promises'
2
3
  import { basename } from 'node:path'
3
4
 
5
+ import { SUBSTRATE_SEARCH_URL } from './app-config'
4
6
  import { TeamsCredentialManager } from './credential-manager'
7
+ import { TeamsTokenProvider } from './token-provider'
5
8
  import type {
6
9
  TeamsAccountType,
7
10
  TeamsChannel,
@@ -10,6 +13,7 @@ import type {
10
13
  TeamsFile,
11
14
  TeamsMessage,
12
15
  TeamsRegion,
16
+ TeamsSearchResult,
13
17
  TeamsTeam,
14
18
  TeamsUser,
15
19
  } from './types'
@@ -20,12 +24,35 @@ interface RateLimitBucket {
20
24
  resetAt: number
21
25
  }
22
26
 
27
+ interface RawTeamsMessage extends TeamsMessage {
28
+ rootMessageId?: string
29
+ parentMessageId?: string
30
+ }
31
+
32
+ type JsonRecord = Record<string, unknown>
33
+
23
34
  const PERSONAL_MSG_API_BASE = 'https://msgapi.teams.live.com/v1'
24
35
  const CSA_API_BASE = 'https://teams.microsoft.com/api'
25
36
  const MAX_RETRIES = 3
26
37
  const BASE_BACKOFF_MS = 100
27
38
  const DEFAULT_REGION: TeamsRegion = 'amer'
28
39
  const REGIONS: TeamsRegion[] = ['amer', 'emea', 'apac']
40
+ const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0'
41
+
42
+ // Personal (Teams for Life) skypetokens carry a consumer `skypeid` (e.g.
43
+ // "live:..." or "8:live:..."); work/school tokens carry an org identity. Used
44
+ // only to guess the account type when a caller logs in with a bare token.
45
+ function isPersonalToken(token: string): boolean {
46
+ try {
47
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')) as {
48
+ skypeid?: string
49
+ }
50
+ const skypeId = payload.skypeid ?? ''
51
+ return skypeId.includes('live:') || skypeId.startsWith('8:live:')
52
+ } catch {
53
+ return false
54
+ }
55
+ }
29
56
 
30
57
  function stripHtml(content: string | undefined): string | undefined {
31
58
  if (content === undefined) return undefined
@@ -40,6 +67,169 @@ function stripHtml(content: string | undefined): string | undefined {
40
67
  .trim()
41
68
  }
42
69
 
70
+ function isRecord(value: unknown): value is JsonRecord {
71
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
72
+ }
73
+
74
+ function stringFrom(record: JsonRecord, keys: string[]): string | undefined {
75
+ for (const key of keys) {
76
+ const value = record[key]
77
+ if (typeof value === 'string' && value.length > 0) return value
78
+ }
79
+ return undefined
80
+ }
81
+
82
+ function recordFrom(record: JsonRecord, keys: string[]): JsonRecord | undefined {
83
+ for (const key of keys) {
84
+ const value = record[key]
85
+ if (isRecord(value)) return value
86
+ }
87
+ return undefined
88
+ }
89
+
90
+ function arrayFrom(record: JsonRecord, keys: string[]): unknown[] {
91
+ for (const key of keys) {
92
+ const value = record[key]
93
+ if (Array.isArray(value)) return value
94
+ }
95
+ return []
96
+ }
97
+
98
+ function propertyValue(record: JsonRecord, names: string[]): string | undefined {
99
+ const properties = arrayFrom(record, ['Properties', 'properties'])
100
+ const normalized = names.map((name) => name.toLowerCase())
101
+ for (const property of properties) {
102
+ if (!isRecord(property)) continue
103
+ const name = stringFrom(property, ['Name', 'name', 'Key', 'key'])
104
+ if (!name || !normalized.includes(name.toLowerCase())) continue
105
+ const value = property.Value ?? property.value
106
+ if (typeof value === 'string' && value.length > 0) return value
107
+ }
108
+ return undefined
109
+ }
110
+
111
+ function resultString(record: JsonRecord, keys: string[]): string | undefined {
112
+ return stringFrom(record, keys) ?? propertyValue(record, keys)
113
+ }
114
+
115
+ function parseSubstrateResult(value: unknown): TeamsSearchResult | null {
116
+ if (!isRecord(value)) return null
117
+ const author = recordFrom(value, ['Author', 'author', 'From', 'from'])
118
+ const id = resultString(value, ['id', 'Id', 'ReferenceId', 'MessageId'])
119
+ const channelId = resultString(value, ['channel_id', 'ChannelId', 'ConversationId', 'ThreadId'])
120
+ if (!id || !channelId) return null
121
+
122
+ return {
123
+ id,
124
+ content:
125
+ stripHtml(resultString(value, ['content', 'Content', 'HitHighlightedSummary', 'Summary', 'Preview'])) ?? '',
126
+ author: {
127
+ id: author ? (stringFrom(author, ['id', 'Id', 'ObjectId']) ?? '') : (propertyValue(value, ['AuthorId']) ?? ''),
128
+ displayName: author
129
+ ? (stringFrom(author, ['displayName', 'DisplayName', 'Name']) ?? 'Unknown')
130
+ : (propertyValue(value, ['AuthorDisplayName', 'Author']) ?? 'Unknown'),
131
+ },
132
+ channel_id: channelId,
133
+ thread_id: resultString(value, ['thread_id', 'ThreadId']),
134
+ team_name: resultString(value, ['team_name', 'TeamName']),
135
+ channel_name: resultString(value, ['channel_name', 'ChannelName']),
136
+ timestamp: resultString(value, ['timestamp', 'Timestamp', 'DateTimeSent', 'LastModifiedTime']) ?? '',
137
+ permalink: resultString(value, ['permalink', 'Permalink', 'WebUrl', 'Url']),
138
+ }
139
+ }
140
+
141
+ function parseSubstrateResults(data: unknown): TeamsSearchResult[] {
142
+ if (!isRecord(data)) return []
143
+ const results: TeamsSearchResult[] = []
144
+ for (const entitySet of arrayFrom(data, ['EntitySets', 'entitySets'])) {
145
+ if (!isRecord(entitySet)) continue
146
+ for (const resultSet of arrayFrom(entitySet, ['ResultSets', 'resultSets'])) {
147
+ if (!isRecord(resultSet)) continue
148
+ for (const rawResult of arrayFrom(resultSet, ['Results', 'results'])) {
149
+ const result = parseSubstrateResult(rawResult)
150
+ if (result) results.push(result)
151
+ }
152
+ }
153
+ }
154
+ return results
155
+ }
156
+
157
+ function validateSearchLimit(value: number | undefined): number {
158
+ if (value === undefined) return 20
159
+ if (!Number.isInteger(value) || value < 1) {
160
+ throw new TeamsError('Search limit must be a positive integer.', 'invalid_pagination')
161
+ }
162
+ return value
163
+ }
164
+
165
+ function validateSearchFrom(value: number | undefined): number {
166
+ if (value === undefined) return 0
167
+ if (!Number.isInteger(value) || value < 0) {
168
+ throw new TeamsError('Search from offset must be a non-negative integer.', 'invalid_pagination')
169
+ }
170
+ return value
171
+ }
172
+
173
+ function isSharePointOrOneDriveUrl(url: string): boolean {
174
+ try {
175
+ const { hostname } = new URL(url)
176
+ const normalizedHost = hostname.toLowerCase()
177
+ return (
178
+ normalizedHost.includes('sharepoint.com') ||
179
+ normalizedHost.includes('-my.sharepoint') ||
180
+ normalizedHost === '1drv.ms' ||
181
+ normalizedHost.endsWith('.1drv.ms') ||
182
+ normalizedHost === 'onedrive.live.com' ||
183
+ normalizedHost.endsWith('.onedrive.live.com')
184
+ )
185
+ } catch {
186
+ return false
187
+ }
188
+ }
189
+
190
+ // Only these hosts receive the Skype token on a raw download fetch. Teams file
191
+ // metadata can carry arbitrary URLs, so we never attach credentials to a host
192
+ // outside this allowlist — that would leak the token to a third party.
193
+ function isTrustedSkypeDownloadHost(url: string): boolean {
194
+ try {
195
+ const host = new URL(url).hostname.toLowerCase()
196
+ return (
197
+ host === 'teams.microsoft.com' ||
198
+ host.endsWith('.teams.microsoft.com') ||
199
+ host.endsWith('.asm.skype.com') ||
200
+ host.endsWith('.asyncgw.teams.microsoft.com') ||
201
+ host === 'substrate.office.com' ||
202
+ host.endsWith('.substrate.office.com')
203
+ )
204
+ } catch {
205
+ return false
206
+ }
207
+ }
208
+
209
+ function getFileDownloadSource(file: TeamsFile): { route: 'graph' | 'skype'; url: string } {
210
+ const shareUrl = [file.sharepoint_url, file.url, file.object_url].find((candidate): candidate is string =>
211
+ Boolean(candidate && isSharePointOrOneDriveUrl(candidate)),
212
+ )
213
+ if (shareUrl) return { route: 'graph', url: shareUrl }
214
+
215
+ const directUrl = file.object_url ?? file.url
216
+ if (!directUrl) {
217
+ throw new TeamsError(`File has no downloadable URL: ${file.id}`, 'file_url_missing')
218
+ }
219
+ if (!isTrustedSkypeDownloadHost(directUrl)) {
220
+ throw new TeamsError(`Refusing to download ${file.id} from an untrusted host: ${directUrl}`, 'file_url_untrusted')
221
+ }
222
+ return { route: 'skype', url: directUrl }
223
+ }
224
+
225
+ async function readDownloadResponse(response: Response, codePrefix: string): Promise<Buffer> {
226
+ if (!response.ok) {
227
+ throw new TeamsError(`File download failed with HTTP ${response.status}`, `${codePrefix}_${response.status}`)
228
+ }
229
+
230
+ return Buffer.from(await response.arrayBuffer())
231
+ }
232
+
43
233
  function escapeHtml(value: string): string {
44
234
  return value
45
235
  .replaceAll('&', '&amp;')
@@ -49,6 +239,23 @@ function escapeHtml(value: string): string {
49
239
  .replaceAll("'", '&#39;')
50
240
  }
51
241
 
242
+ function withThreadMetadata(message: RawTeamsMessage, rootMessageId?: string): TeamsMessage {
243
+ const { rootMessageId: messageRootMessageId, parentMessageId, ...teamsMessage } = message
244
+ const rawRootMessageId = rootMessageId ?? messageRootMessageId
245
+ const isThreadReply = Boolean(
246
+ rootMessageId ||
247
+ (messageRootMessageId !== undefined && messageRootMessageId !== message.id) ||
248
+ (parentMessageId !== undefined && parentMessageId !== message.id),
249
+ )
250
+
251
+ return {
252
+ ...teamsMessage,
253
+ root_message_id: isThreadReply ? rawRootMessageId : undefined,
254
+ parent_message_id: isThreadReply ? (parentMessageId ?? rawRootMessageId) : undefined,
255
+ is_thread_reply: isThreadReply ? true : undefined,
256
+ }
257
+ }
258
+
52
259
  // groupId => Teams/channel thread (handled by listTeams). "48:notes"/
53
260
  // streamofnotes => the user's self ("to me") chat. Anything else without a
54
261
  // non-chat threadType is a normal 1:1 (no topic) or group (has topic) chat.
@@ -68,9 +275,12 @@ export class TeamsClient {
68
275
  private isPersonalAccount: boolean = false
69
276
  private region: TeamsRegion = DEFAULT_REGION
70
277
  private regionDiscovered: boolean = false
278
+ private tokenProvider?: TeamsTokenProvider
71
279
  private buckets: Map<string, RateLimitBucket> = new Map()
72
280
  private globalRateLimitUntil: number = 0
73
281
 
282
+ constructor(private credManager: TeamsCredentialManager = new TeamsCredentialManager()) {}
283
+
74
284
  async login(credentials?: {
75
285
  token: string
76
286
  tokenExpiresAt?: string
@@ -85,18 +295,22 @@ export class TeamsClient {
85
295
  if (credentials.tokenExpiresAt) {
86
296
  this.tokenExpiresAt = new Date(credentials.tokenExpiresAt)
87
297
  }
88
- this.isPersonalAccount = credentials.accountType === 'personal'
298
+ this.isPersonalAccount = credentials.accountType
299
+ ? credentials.accountType === 'personal'
300
+ : isPersonalToken(credentials.token)
89
301
  if (credentials.region) {
90
302
  this.region = credentials.region
91
303
  this.regionDiscovered = true
92
304
  }
305
+ if (credentials.accountType) {
306
+ this.getTokenProvider().bindAccount(credentials.accountType)
307
+ }
93
308
  return this
94
309
  }
95
310
 
96
311
  const { ensureTeamsAuth } = await import('./ensure-auth')
97
312
  await ensureTeamsAuth()
98
- const credManager = new TeamsCredentialManager()
99
- const creds = await credManager.getTokenWithExpiry()
313
+ const creds = await this.credManager.getTokenWithExpiry()
100
314
  if (!creds) {
101
315
  throw new TeamsError(
102
316
  'No Teams credentials found. Make sure Microsoft Teams is logged in via the desktop app or a supported Chromium browser.',
@@ -115,6 +329,20 @@ export class TeamsClient {
115
329
  return this.region
116
330
  }
117
331
 
332
+ getToken(): string {
333
+ return this.ensureAuth()
334
+ }
335
+
336
+ getAccountType(): TeamsAccountType {
337
+ return this.isPersonalAccount ? 'personal' : 'work'
338
+ }
339
+
340
+ async getIdToken(): Promise<string | null> {
341
+ const { TeamsTokenExtractor } = await import('./token-extractor')
342
+ const extractor = new TeamsTokenExtractor()
343
+ return extractor.extractIdToken(this.getAccountType())
344
+ }
345
+
118
346
  private ensureAuth(): string {
119
347
  if (this.token === null) {
120
348
  throw new TeamsError('Not authenticated. Call .login() first.', 'not_authenticated')
@@ -210,7 +438,7 @@ export class TeamsClient {
210
438
 
211
439
  private async request<T>(method: string, path: string, body?: unknown, baseUrl?: string): Promise<T> {
212
440
  if (this.isTokenExpired()) {
213
- throw new TeamsError('Token has expired. Run "auth extract" to refresh.', 'token_expired')
441
+ throw new TeamsError('Token has expired. Run "auth login" or "auth extract" to refresh.', 'token_expired')
214
442
  }
215
443
 
216
444
  if (baseUrl === undefined && !this.regionDiscovered) {
@@ -279,7 +507,7 @@ export class TeamsClient {
279
507
 
280
508
  private async requestFormData<T>(path: string, formData: FormData, baseUrl?: string): Promise<T> {
281
509
  if (this.isTokenExpired()) {
282
- throw new TeamsError('Token has expired. Run "auth extract" to refresh.', 'token_expired')
510
+ throw new TeamsError('Token has expired. Run "auth login" or "auth extract" to refresh.', 'token_expired')
283
511
  }
284
512
 
285
513
  if (baseUrl === undefined && !this.regionDiscovered) {
@@ -361,6 +589,34 @@ export class TeamsClient {
361
589
  return Array.from(teamsMap.values())
362
590
  }
363
591
 
592
+ // Realtime messages only carry a conversation id; a channel's parent teamId
593
+ // (== groupId) lives on the conversation, so the listener resolves it through
594
+ // this channelId -> teamId map.
595
+ async buildChannelTeamMap(): Promise<Map<string, string>> {
596
+ interface Conversation {
597
+ id: string
598
+ threadProperties?: {
599
+ groupId?: string
600
+ productThreadType?: string
601
+ threadType?: string
602
+ }
603
+ }
604
+ interface ConversationsResponse {
605
+ conversations: Conversation[]
606
+ }
607
+ const data = await this.request<ConversationsResponse>('GET', '/users/ME/conversations')
608
+
609
+ const channelToTeam = new Map<string, string>()
610
+ for (const conv of data.conversations ?? []) {
611
+ const tp = conv.threadProperties
612
+ if (!tp?.groupId) continue
613
+ if (!tp.productThreadType?.includes('Teams') && tp.threadType !== 'space') continue
614
+ channelToTeam.set(conv.id, tp.groupId)
615
+ }
616
+
617
+ return channelToTeam
618
+ }
619
+
364
620
  async listChats(): Promise<TeamsChat[]> {
365
621
  interface ConversationMessage {
366
622
  content?: string
@@ -474,7 +730,17 @@ export class TeamsClient {
474
730
  )
475
731
  }
476
732
 
477
- async sendMessage(teamId: string, channelId: string, content: string): Promise<TeamsMessage> {
733
+ async sendMessage(teamId: string, channelId: string, content: string, rootMessageId?: string): Promise<TeamsMessage> {
734
+ if (rootMessageId) {
735
+ const response = await this.request<RawTeamsMessage>(
736
+ 'POST',
737
+ `/csa/${this.region}/api/v2/teams/${teamId}/channels/${channelId}/messages/${rootMessageId}/replies`,
738
+ { content, parentMessageId: rootMessageId },
739
+ CSA_API_BASE,
740
+ )
741
+ return withThreadMetadata(response, rootMessageId)
742
+ }
743
+
478
744
  return this.request<TeamsMessage>(
479
745
  'POST',
480
746
  `/csa/${this.region}/api/v2/teams/${teamId}/channels/${channelId}/messages`,
@@ -484,12 +750,75 @@ export class TeamsClient {
484
750
  }
485
751
 
486
752
  async getMessages(teamId: string, channelId: string, limit: number = 50): Promise<TeamsMessage[]> {
487
- return this.request<TeamsMessage[]>(
753
+ const messages = await this.request<RawTeamsMessage[]>(
488
754
  'GET',
489
755
  `/csa/${this.region}/api/v2/teams/${teamId}/channels/${channelId}/messages?limit=${limit}`,
490
756
  undefined,
491
757
  CSA_API_BASE,
492
758
  )
759
+ return messages.map((message) => withThreadMetadata(message))
760
+ }
761
+
762
+ async getThreadReplies(
763
+ teamId: string,
764
+ channelId: string,
765
+ rootMessageId: string,
766
+ limit: number = 50,
767
+ ): Promise<TeamsMessage[]> {
768
+ const replies = await this.request<RawTeamsMessage[]>(
769
+ 'GET',
770
+ `/csa/${this.region}/api/v2/teams/${teamId}/channels/${channelId}/messages/${rootMessageId}/replies?limit=${limit}`,
771
+ undefined,
772
+ CSA_API_BASE,
773
+ )
774
+ return replies.map((reply) => withThreadMetadata(reply, rootMessageId))
775
+ }
776
+
777
+ async searchMessages(query: string, opts: { limit?: number; from?: number } = {}): Promise<TeamsSearchResult[]> {
778
+ const size = validateSearchLimit(opts.limit)
779
+ const from = validateSearchFrom(opts.from)
780
+ const tokenProvider = this.getTokenProvider()
781
+ const substrateToken = await tokenProvider.getSubstrateToken()
782
+ const tenantId = await tokenProvider.getTenantId()
783
+ const userId = await tokenProvider.getUserId()
784
+
785
+ const response = await fetch(SUBSTRATE_SEARCH_URL, {
786
+ method: 'POST',
787
+ headers: {
788
+ Authorization: `Bearer ${substrateToken}`,
789
+ 'Content-Type': 'application/json',
790
+ 'x-anchormailbox': `Oid:${userId}@${tenantId}`,
791
+ },
792
+ body: JSON.stringify({
793
+ cvid: randomUUID(),
794
+ logicalId: randomUUID(),
795
+ query: { queryString: query },
796
+ entityRequests: [
797
+ {
798
+ entityType: 'Message',
799
+ contentSources: ['Teams'],
800
+ from,
801
+ size,
802
+ query: { queryString: query },
803
+ },
804
+ ],
805
+ }),
806
+ })
807
+
808
+ const data = (await response.json().catch(() => ({}))) as unknown
809
+ if (!response.ok) {
810
+ const message = isRecord(data)
811
+ ? stringFrom(data, ['message', 'Message', 'error_description', 'error'])
812
+ : undefined
813
+ throw new TeamsError(message ?? `HTTP ${response.status}`, `substrate_${response.status}`)
814
+ }
815
+
816
+ return parseSubstrateResults(data)
817
+ }
818
+
819
+ private getTokenProvider(): TeamsTokenProvider {
820
+ this.tokenProvider ??= new TeamsTokenProvider(this.credManager)
821
+ return this.tokenProvider
493
822
  }
494
823
 
495
824
  async getMessage(teamId: string, channelId: string, messageId: string): Promise<TeamsMessage> {
@@ -558,4 +887,38 @@ export class TeamsClient {
558
887
  CSA_API_BASE,
559
888
  )
560
889
  }
890
+
891
+ async downloadFile(teamId: string, channelId: string, fileId: string): Promise<{ buffer: Buffer; file: TeamsFile }> {
892
+ const files = await this.listFiles(teamId, channelId)
893
+ const file = files.find((candidate) => candidate.id === fileId)
894
+ if (!file) {
895
+ throw new TeamsError(`File not found: ${fileId}`, 'file_not_found')
896
+ }
897
+
898
+ const source = getFileDownloadSource(file)
899
+ if (source.route === 'graph') {
900
+ const graphToken = await new TeamsTokenProvider(this.credManager).getGraphToken()
901
+ const shareId = `u!${Buffer.from(source.url).toString('base64url').replace(/=+$/, '')}`
902
+ const response = await fetch(`${GRAPH_API_BASE}/shares/${shareId}/driveItem/content`, {
903
+ headers: {
904
+ Authorization: `Bearer ${graphToken}`,
905
+ },
906
+ redirect: 'follow',
907
+ })
908
+ return { buffer: await readDownloadResponse(response, 'graph_download'), file }
909
+ }
910
+
911
+ if (this.isTokenExpired()) {
912
+ throw new TeamsError('Token has expired. Run "auth login" or "auth extract" to refresh.', 'token_expired')
913
+ }
914
+ const skypeToken = this.getToken()
915
+ const response = await fetch(source.url, {
916
+ headers: {
917
+ Authorization: `Bearer ${skypeToken}`,
918
+ 'X-Skypetoken': skypeToken,
919
+ },
920
+ redirect: 'follow',
921
+ })
922
+ return { buffer: await readDownloadResponse(response, 'skype_download'), file }
923
+ }
561
924
  }
@@ -1,9 +1,13 @@
1
1
  import { afterEach, beforeEach, expect, spyOn, it } from 'bun:test'
2
2
 
3
+ import * as interactive from '@/shared/utils/interactive'
4
+
3
5
  import { TeamsClient } from '../client'
4
6
  import { TeamsCredentialManager } from '../credential-manager'
7
+ import * as deviceLogin from '../device-login'
8
+ import * as realmDiscovery from '../realm-discovery'
5
9
  import { TeamsTokenExtractor } from '../token-extractor'
6
- import { getNoTeamsTokenFoundMessage } from './auth'
10
+ import { getNoTeamsTokenFoundMessage, loginAction } from './auth'
7
11
 
8
12
  let extractorExtractSpy: ReturnType<typeof spyOn>
9
13
  let clientTestAuthSpy: ReturnType<typeof spyOn>
@@ -13,6 +17,7 @@ let credManagerSaveConfigSpy: ReturnType<typeof spyOn>
13
17
  let credManagerClearCredentialsSpy: ReturnType<typeof spyOn>
14
18
  let credManagerIsTokenExpiredSpy: ReturnType<typeof spyOn>
15
19
  let clientGetRegionSpy: ReturnType<typeof spyOn>
20
+ const originalConsoleLog = console.log
16
21
 
17
22
  beforeEach(() => {
18
23
  extractorExtractSpy = spyOn(TeamsTokenExtractor.prototype, 'extract').mockResolvedValue([
@@ -52,6 +57,7 @@ afterEach(() => {
52
57
  credManagerClearCredentialsSpy?.mockRestore()
53
58
  credManagerIsTokenExpiredSpy?.mockRestore()
54
59
  clientGetRegionSpy?.mockRestore()
60
+ console.log = originalConsoleLog
55
61
  })
56
62
 
57
63
  it('extract: calls TeamsTokenExtractor', async () => {
@@ -98,3 +104,89 @@ it('status: checks token expiry', async () => {
98
104
  it('no-token message mentions desktop app and browser fallback', () => {
99
105
  expect(getNoTeamsTokenFoundMessage()).toContain('desktop app or a supported Chromium browser')
100
106
  })
107
+
108
+ it('login pending hint preserves explicit account type', async () => {
109
+ const completeSpy = spyOn(deviceLogin, 'completeDeviceCode').mockRejectedValue(new deviceLogin.PendingApprovalError())
110
+ const consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
111
+ const exitSpy = spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
112
+ throw new Error(`exit:${code}`)
113
+ })
114
+
115
+ await expect(loginAction({ deviceCode: 'device-code', accountType: 'personal', pretty: false })).rejects.toThrow(
116
+ 'exit:1',
117
+ )
118
+
119
+ expect(completeSpy).toHaveBeenCalled()
120
+ expect(exitSpy.mock.calls[0][0]).toBe(0)
121
+ const output = consoleSpy.mock.calls[0][0]
122
+ expect(output).toContain('agent-teams auth login --device-code <device_code> --account-type personal')
123
+ completeSpy.mockRestore()
124
+ consoleSpy.mockRestore()
125
+ exitSpy.mockRestore()
126
+ })
127
+
128
+ it('routes to the personal flow when the email probe reports a personal account', async () => {
129
+ const interactiveSpy = spyOn(interactive, 'isInteractive').mockReturnValue(true)
130
+ const probeSpy = spyOn(realmDiscovery, 'probeAccountType').mockResolvedValue('personal')
131
+ const consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
132
+ const loginSpy = spyOn(deviceLogin, 'loginWithDeviceCode').mockResolvedValue({
133
+ accountType: 'personal',
134
+ userName: 'Personal User',
135
+ teams: [],
136
+ current: null,
137
+ })
138
+
139
+ await loginAction({ email: 'user@outlook.com', pretty: false })
140
+
141
+ expect(probeSpy).toHaveBeenCalledWith('user@outlook.com')
142
+ expect(loginSpy.mock.calls[0][0].accountType).toBe('personal')
143
+
144
+ interactiveSpy.mockRestore()
145
+ probeSpy.mockRestore()
146
+ consoleSpy.mockRestore()
147
+ loginSpy.mockRestore()
148
+ })
149
+
150
+ it('skips the email probe when an account type is forced explicitly', async () => {
151
+ const interactiveSpy = spyOn(interactive, 'isInteractive').mockReturnValue(true)
152
+ const probeSpy = spyOn(realmDiscovery, 'probeAccountType').mockResolvedValue('personal')
153
+ const consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
154
+ const loginSpy = spyOn(deviceLogin, 'loginWithDeviceCode').mockResolvedValue({
155
+ accountType: 'work',
156
+ userName: 'Work User',
157
+ teams: [],
158
+ current: null,
159
+ })
160
+
161
+ await loginAction({ email: 'user@outlook.com', accountType: 'work', pretty: false })
162
+
163
+ expect(probeSpy).not.toHaveBeenCalled()
164
+ expect(loginSpy.mock.calls[0][0].accountType).toBe('work')
165
+
166
+ interactiveSpy.mockRestore()
167
+ probeSpy.mockRestore()
168
+ consoleSpy.mockRestore()
169
+ loginSpy.mockRestore()
170
+ })
171
+
172
+ it('falls back to the default flow when the email probe is inconclusive', async () => {
173
+ const interactiveSpy = spyOn(interactive, 'isInteractive').mockReturnValue(true)
174
+ const probeSpy = spyOn(realmDiscovery, 'probeAccountType').mockResolvedValue(undefined)
175
+ const consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
176
+ const loginSpy = spyOn(deviceLogin, 'loginWithDeviceCode').mockResolvedValue({
177
+ accountType: 'work',
178
+ userName: 'Work User',
179
+ teams: [],
180
+ current: null,
181
+ })
182
+
183
+ await loginAction({ email: 'user@unknown.test', pretty: false })
184
+
185
+ expect(probeSpy).toHaveBeenCalledWith('user@unknown.test')
186
+ expect(loginSpy.mock.calls[0][0].accountType).toBe('work')
187
+
188
+ interactiveSpy.mockRestore()
189
+ probeSpy.mockRestore()
190
+ consoleSpy.mockRestore()
191
+ loginSpy.mockRestore()
192
+ })