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
@@ -1,10 +1,16 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
- import { unlinkSync } from 'node:fs'
2
+ import { rmSync, unlinkSync } from 'node:fs'
3
+ import { join } from 'node:path'
3
4
 
4
5
  import { TeamsClient } from './client'
6
+ import { TeamsCredentialManager } from './credential-manager'
5
7
  import { TeamsError } from './types'
6
8
 
7
9
  const TEMP_FILES_TO_CLEANUP = ['/tmp/test-teams-upload.txt']
10
+ const TEMP_DIRS_TO_CLEANUP: string[] = []
11
+ const SEARCH_TENANT_ID = '11111111-1111-1111-1111-111111111111'
12
+ const SEARCH_USER_ID = '22222222-2222-2222-2222-222222222222'
13
+ const GRAPH_AUDIENCE = 'https://graph.microsoft.com'
8
14
 
9
15
  describe('TeamsClient', () => {
10
16
  const originalFetch = globalThis.fetch
@@ -36,8 +42,63 @@ describe('TeamsClient', () => {
36
42
  // File may not exist, ignore
37
43
  }
38
44
  }
45
+ for (const dir of TEMP_DIRS_TO_CLEANUP.splice(0)) {
46
+ rmSync(dir, { recursive: true, force: true })
47
+ }
39
48
  })
40
49
 
50
+ const setupCredentialManager = async (): Promise<TeamsCredentialManager> => {
51
+ const dir = join(import.meta.dir, `.test-teams-client-${Date.now()}-${Math.random().toString(36).slice(2)}`)
52
+ TEMP_DIRS_TO_CLEANUP.push(dir)
53
+ const manager = new TeamsCredentialManager(dir)
54
+ await manager.setDeviceCodeAccount({
55
+ accountType: 'work',
56
+ token: 'skype-token',
57
+ tokenExpiresAt: '2100-01-01T00:00:00Z',
58
+ aadRefreshToken: 'refresh-token',
59
+ aadClientId: 'client-id',
60
+ teams: {},
61
+ currentTeam: null,
62
+ })
63
+ return manager
64
+ }
65
+
66
+ const createSearchJwt = (): string => {
67
+ const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url')
68
+ const payload = Buffer.from(
69
+ JSON.stringify({
70
+ aud: 'https://substrate.office.com',
71
+ tid: SEARCH_TENANT_ID,
72
+ oid: SEARCH_USER_ID,
73
+ exp: Math.floor(Date.now() / 1000) + 3600,
74
+ }),
75
+ ).toString('base64url')
76
+ return `${header}.${payload}.signature`
77
+ }
78
+
79
+ const createGraphJwt = (): string => {
80
+ const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url')
81
+ const payload = Buffer.from(
82
+ JSON.stringify({
83
+ aud: GRAPH_AUDIENCE,
84
+ tid: SEARCH_TENANT_ID,
85
+ oid: SEARCH_USER_ID,
86
+ exp: Math.floor(Date.now() / 1000) + 3600,
87
+ }),
88
+ ).toString('base64url')
89
+ return `${header}.${payload}.signature`
90
+ }
91
+
92
+ const headerValue = (init: RequestInit | undefined, name: string): string | undefined => {
93
+ const headers = init?.headers
94
+ if (headers instanceof Headers) return headers.get(name) ?? undefined
95
+ if (Array.isArray(headers)) {
96
+ const pair = headers.find(([key]) => key.toLowerCase() === name.toLowerCase())
97
+ return pair?.[1]
98
+ }
99
+ return headers?.[name]
100
+ }
101
+
41
102
  const mockResponse = (body: unknown, status = 200, headers: Record<string, string> = {}) => {
42
103
  const defaultHeaders: Record<string, string> = {
43
104
  'Content-Type': 'application/json',
@@ -53,6 +114,10 @@ describe('TeamsClient', () => {
53
114
  )
54
115
  }
55
116
 
117
+ const mockBinaryResponse = (body: string, status = 200) => {
118
+ fetchResponses.push(new Response(body, { status }))
119
+ }
120
+
56
121
  describe('login', () => {
57
122
  it('requires token', async () => {
58
123
  await expect(new TeamsClient().login({ token: '', region: 'emea' })).rejects.toThrow(TeamsError)
@@ -325,6 +390,29 @@ describe('TeamsClient', () => {
325
390
  expect(fetchCalls[0].options?.method).toBe('POST')
326
391
  expect(fetchCalls[0].options?.body).toBe(JSON.stringify({ content: 'Hello world' }))
327
392
  })
393
+
394
+ it('sends a reply to a channel thread', async () => {
395
+ mockResponse({
396
+ id: 'reply1',
397
+ channel_id: 'ch1',
398
+ author: { id: '123', displayName: 'Test User' },
399
+ content: 'Thread reply',
400
+ timestamp: '2024-01-01T00:01:00.000Z',
401
+ })
402
+
403
+ const client = await new TeamsClient().login({ token: 'test-token', region: 'emea' })
404
+ const message = await client.sendMessage('111', 'ch1', 'Thread reply', 'root1')
405
+
406
+ expect(fetchCalls[0].url).toBe(
407
+ 'https://teams.microsoft.com/api/csa/emea/api/v2/teams/111/channels/ch1/messages/root1/replies',
408
+ )
409
+ expect(fetchCalls[0].options?.method).toBe('POST')
410
+ expect(fetchCalls[0].options?.body).toBe(JSON.stringify({ content: 'Thread reply', parentMessageId: 'root1' }))
411
+ // the reply the API echoes back omits thread ids, so the client normalizes them from the root
412
+ expect(message.root_message_id).toBe('root1')
413
+ expect(message.parent_message_id).toBe('root1')
414
+ expect(message.is_thread_reply).toBe(true)
415
+ })
328
416
  })
329
417
 
330
418
  describe('getMessages', () => {
@@ -359,6 +447,168 @@ describe('TeamsClient', () => {
359
447
  'https://teams.microsoft.com/api/csa/emea/api/v2/teams/111/channels/ch1/messages?limit=50',
360
448
  )
361
449
  })
450
+
451
+ it('preserves thread fields for replies without inventing them for top-level messages', async () => {
452
+ mockResponse([
453
+ {
454
+ id: 'reply1',
455
+ channel_id: 'ch1',
456
+ author: { id: '123', displayName: 'User 1' },
457
+ content: 'Reply',
458
+ timestamp: '2024-01-01T00:01:00.000Z',
459
+ rootMessageId: 'root1',
460
+ parentMessageId: 'root1',
461
+ },
462
+ {
463
+ id: 'root1',
464
+ channel_id: 'ch1',
465
+ author: { id: '123', displayName: 'User 1' },
466
+ content: 'Top level',
467
+ timestamp: '2024-01-01T00:00:00.000Z',
468
+ },
469
+ ])
470
+
471
+ const client = await new TeamsClient().login({ token: 'test-token', region: 'emea' })
472
+ const messages = await client.getMessages('111', 'ch1')
473
+
474
+ expect(messages[0].root_message_id).toBe('root1')
475
+ expect(messages[0].parent_message_id).toBe('root1')
476
+ expect(messages[0].is_thread_reply).toBe(true)
477
+ expect(messages[1].root_message_id).toBeUndefined()
478
+ expect(messages[1].is_thread_reply).toBeFalsy()
479
+ })
480
+ })
481
+
482
+ describe('getThreadReplies', () => {
483
+ it('returns replies for a channel thread with root metadata', async () => {
484
+ mockResponse([
485
+ {
486
+ id: 'reply1',
487
+ channel_id: 'ch1',
488
+ author: { id: '123', displayName: 'User 1' },
489
+ content: 'Reply 1',
490
+ timestamp: '2024-01-01T00:01:00.000Z',
491
+ },
492
+ ])
493
+
494
+ const client = await new TeamsClient().login({ token: 'test-token', region: 'emea' })
495
+ const replies = await client.getThreadReplies('111', 'ch1', 'root1', 10)
496
+
497
+ expect(replies).toHaveLength(1)
498
+ expect(replies[0].root_message_id).toBe('root1')
499
+ expect(replies[0].parent_message_id).toBe('root1')
500
+ expect(replies[0].is_thread_reply).toBe(true)
501
+ expect(fetchCalls[0].url).toBe(
502
+ 'https://teams.microsoft.com/api/csa/emea/api/v2/teams/111/channels/ch1/messages/root1/replies?limit=10',
503
+ )
504
+ })
505
+ })
506
+
507
+ describe('searchMessages', () => {
508
+ it('posts to Substrate search with bearer token, anchor mailbox, and parses nested results', async () => {
509
+ const manager = await setupCredentialManager()
510
+ const searchJwt = createSearchJwt()
511
+ mockResponse({ access_token: searchJwt, refresh_token: 'rotated-refresh', expires_in: 3600 })
512
+ mockResponse({
513
+ EntitySets: [
514
+ {
515
+ ResultSets: [
516
+ {
517
+ Results: [
518
+ {
519
+ Id: 'msg-1',
520
+ Content: '<p>Deploy complete</p>',
521
+ Author: { Id: 'author-1', DisplayName: 'Alice' },
522
+ ChannelId: 'channel-1',
523
+ ThreadId: 'thread-1',
524
+ TeamName: 'Team One',
525
+ ChannelName: 'General',
526
+ DateTimeSent: '2024-01-01T00:00:00.000Z',
527
+ WebUrl: 'https://teams.microsoft.com/l/message/msg-1',
528
+ },
529
+ ],
530
+ },
531
+ ],
532
+ },
533
+ ],
534
+ })
535
+
536
+ const client = await new TeamsClient(manager).login({ token: 'skype-token', region: 'emea' })
537
+ const results = await client.searchMessages('deploy', { limit: 10, from: 5 })
538
+
539
+ expect(fetchCalls[1].url).toBe('https://substrate.office.com/searchservice/api/v2/query')
540
+ expect(fetchCalls[1].options?.method).toBe('POST')
541
+ expect(headerValue(fetchCalls[1].options, 'Authorization')).toBe(`Bearer ${searchJwt}`)
542
+ expect(headerValue(fetchCalls[1].options, 'x-anchormailbox')).toBe(`Oid:${SEARCH_USER_ID}@${SEARCH_TENANT_ID}`)
543
+ const payload = JSON.parse(String(fetchCalls[1].options?.body)) as {
544
+ entityRequests: Array<{ entityType: string; contentSources: string[]; from: number; size: number }>
545
+ }
546
+ expect(payload.entityRequests[0]).toMatchObject({
547
+ entityType: 'Message',
548
+ contentSources: ['Teams'],
549
+ from: 5,
550
+ size: 10,
551
+ })
552
+ expect(results).toEqual([
553
+ {
554
+ id: 'msg-1',
555
+ content: 'Deploy complete',
556
+ author: { id: 'author-1', displayName: 'Alice' },
557
+ channel_id: 'channel-1',
558
+ thread_id: 'thread-1',
559
+ team_name: 'Team One',
560
+ channel_name: 'General',
561
+ timestamp: '2024-01-01T00:00:00.000Z',
562
+ permalink: 'https://teams.microsoft.com/l/message/msg-1',
563
+ },
564
+ ])
565
+ })
566
+
567
+ it('returns an empty array when Substrate has no nested results', async () => {
568
+ const manager = await setupCredentialManager()
569
+ mockResponse({ access_token: createSearchJwt(), refresh_token: 'rotated-refresh', expires_in: 3600 })
570
+ mockResponse({ EntitySets: [] })
571
+
572
+ const client = await new TeamsClient(manager).login({ token: 'skype-token', region: 'emea' })
573
+ const results = await client.searchMessages('zzimprobablequery_xyz')
574
+
575
+ expect(results).toEqual([])
576
+ })
577
+
578
+ it('uses the logged-in account refresh token when current account differs', async () => {
579
+ const manager = await setupCredentialManager()
580
+ await manager.setDeviceCodeAccount({
581
+ accountType: 'personal',
582
+ token: 'personal-skype-token',
583
+ tokenExpiresAt: '2100-01-01T00:00:00Z',
584
+ aadRefreshToken: 'personal-refresh-token',
585
+ aadClientId: 'personal-client-id',
586
+ teams: {},
587
+ currentTeam: null,
588
+ })
589
+ const searchJwt = createSearchJwt()
590
+ mockResponse({ access_token: searchJwt, refresh_token: 'work-rotated-refresh', expires_in: 3600 })
591
+ mockResponse({ EntitySets: [] })
592
+
593
+ const client = await new TeamsClient(manager).login({ token: 'skype-token', accountType: 'work', region: 'emea' })
594
+ await client.searchMessages('deploy')
595
+
596
+ const tokenRequestBody = new URLSearchParams(String(fetchCalls[0].options?.body))
597
+ expect(tokenRequestBody.get('refresh_token')).toBe('refresh-token')
598
+ expect(tokenRequestBody.get('client_id')).toBe('client-id')
599
+ })
600
+
601
+ it('rejects invalid pagination options', async () => {
602
+ const manager = await setupCredentialManager()
603
+ const client = await new TeamsClient(manager).login({ token: 'skype-token', region: 'emea' })
604
+
605
+ await expect(client.searchMessages('deploy', { limit: Number.NaN })).rejects.toThrow('positive integer')
606
+ await expect(client.searchMessages('deploy', { limit: 0 })).rejects.toThrow('positive integer')
607
+ await expect(client.searchMessages('deploy', { limit: -1 })).rejects.toThrow('positive integer')
608
+ await expect(client.searchMessages('deploy', { from: -1 })).rejects.toThrow('non-negative integer')
609
+ await expect(client.searchMessages('deploy', { from: 1.5 })).rejects.toThrow('non-negative integer')
610
+ expect(fetchCalls).toHaveLength(0)
611
+ })
362
612
  })
363
613
 
364
614
  describe('getMessage', () => {
@@ -490,6 +740,90 @@ describe('TeamsClient', () => {
490
740
  })
491
741
  })
492
742
 
743
+ describe('downloadFile', () => {
744
+ it('downloads SharePoint files through Graph shares with base64url share id', async () => {
745
+ const manager = await setupCredentialManager()
746
+ const shareUrl = 'https://contoso.sharepoint.com/sites/team/Shared%20Documents/report.docx'
747
+ mockResponse([
748
+ { id: 'file1', name: 'report.docx', size: 11, url: shareUrl, contentType: 'application/vnd.ms-word' },
749
+ ])
750
+ mockResponse({ access_token: createGraphJwt(), refresh_token: 'rotated-refresh', expires_in: 3600 })
751
+ mockBinaryResponse('graph-bytes')
752
+
753
+ const client = await new TeamsClient(manager).login({ token: 'skype-token', region: 'emea' })
754
+ const result = await client.downloadFile('111', 'ch1', 'file1')
755
+
756
+ const shareId = `u!${Buffer.from(shareUrl).toString('base64url').replace(/=+$/, '')}`
757
+ expect(fetchCalls[2].url).toBe(`https://graph.microsoft.com/v1.0/shares/${shareId}/driveItem/content`)
758
+ expect(headerValue(fetchCalls[2].options, 'Authorization')).toBe(`Bearer ${createGraphJwt()}`)
759
+ expect(Buffer.from(result.buffer).toString()).toBe('graph-bytes')
760
+ expect(result.file.id).toBe('file1')
761
+ })
762
+
763
+ it('downloads inline object URLs with the Skype token', async () => {
764
+ mockResponse([
765
+ {
766
+ id: 'file2',
767
+ name: 'image.png',
768
+ size: 10,
769
+ url: 'https://teams.microsoft.com/files/image.png',
770
+ object_url: 'https://us-api.asm.skype.com/v1/objects/0-weu-d1/image.png',
771
+ contentType: 'image/png',
772
+ },
773
+ ])
774
+ mockBinaryResponse('image-bytes')
775
+
776
+ const client = await new TeamsClient().login({ token: 'skype-token', region: 'emea' })
777
+ const result = await client.downloadFile('111', 'ch1', 'file2')
778
+
779
+ expect(fetchCalls[1].url).toBe('https://us-api.asm.skype.com/v1/objects/0-weu-d1/image.png')
780
+ expect(headerValue(fetchCalls[1].options, 'Authorization')).toBe('Bearer skype-token')
781
+ expect(headerValue(fetchCalls[1].options, 'X-Skypetoken')).toBe('skype-token')
782
+ expect(Buffer.from(result.buffer).toString()).toBe('image-bytes')
783
+ })
784
+
785
+ it('throws TeamsAuthCapabilityError for SharePoint files with cookie-only credentials', async () => {
786
+ const dir = join(
787
+ import.meta.dir,
788
+ `.test-teams-client-cookie-${Date.now()}-${Math.random().toString(36).slice(2)}`,
789
+ )
790
+ TEMP_DIRS_TO_CLEANUP.push(dir)
791
+ const manager = new TeamsCredentialManager(dir)
792
+ await manager.setToken('skype-token', 'work', '2100-01-01T00:00:00Z')
793
+ mockResponse([
794
+ {
795
+ id: 'file3',
796
+ name: 'deck.pptx',
797
+ size: 10,
798
+ url: 'https://contoso.sharepoint.com/sites/team/Shared%20Documents/deck.pptx',
799
+ },
800
+ ])
801
+
802
+ const client = await new TeamsClient(manager).login({ token: 'skype-token', region: 'emea' })
803
+
804
+ await expect(client.downloadFile('111', 'ch1', 'file3')).rejects.toThrow('Requires `agent-teams auth login`')
805
+ expect(fetchCalls).toHaveLength(1)
806
+ })
807
+
808
+ it('refuses to send the Skype token to an untrusted host', async () => {
809
+ mockResponse([
810
+ {
811
+ id: 'file4',
812
+ name: 'evil.bin',
813
+ size: 10,
814
+ url: 'https://evil.example.com/steal',
815
+ object_url: 'https://evil.example.com/steal',
816
+ },
817
+ ])
818
+
819
+ const client = await new TeamsClient().login({ token: 'skype-token', region: 'emea' })
820
+
821
+ await expect(client.downloadFile('111', 'ch1', 'file4')).rejects.toThrow('untrusted host')
822
+ // only the listFiles call happened — no credentialed download fetch to the untrusted host
823
+ expect(fetchCalls).toHaveLength(1)
824
+ })
825
+ })
826
+
493
827
  describe('rate limiting', () => {
494
828
  it('waits when bucket is exhausted before making request', async () => {
495
829
  mockResponse({ userDetails: JSON.stringify({ name: 'User 1' }), locale: 'en-us' }, 200, {