agent-messenger 2.8.0 → 2.10.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 (169) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +0 -11
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/channeltalk/commands/snapshot.d.ts +4 -2
  5. package/dist/src/platforms/channeltalk/commands/snapshot.d.ts.map +1 -1
  6. package/dist/src/platforms/channeltalk/commands/snapshot.js +86 -31
  7. package/dist/src/platforms/channeltalk/commands/snapshot.js.map +1 -1
  8. package/dist/src/platforms/channeltalkbot/commands/snapshot.d.ts +3 -1
  9. package/dist/src/platforms/channeltalkbot/commands/snapshot.d.ts.map +1 -1
  10. package/dist/src/platforms/channeltalkbot/commands/snapshot.js +110 -60
  11. package/dist/src/platforms/channeltalkbot/commands/snapshot.js.map +1 -1
  12. package/dist/src/platforms/discord/commands/snapshot.d.ts +1 -0
  13. package/dist/src/platforms/discord/commands/snapshot.d.ts.map +1 -1
  14. package/dist/src/platforms/discord/commands/snapshot.js +48 -34
  15. package/dist/src/platforms/discord/commands/snapshot.js.map +1 -1
  16. package/dist/src/platforms/discordbot/commands/snapshot.d.ts +2 -0
  17. package/dist/src/platforms/discordbot/commands/snapshot.d.ts.map +1 -1
  18. package/dist/src/platforms/discordbot/commands/snapshot.js +46 -34
  19. package/dist/src/platforms/discordbot/commands/snapshot.js.map +1 -1
  20. package/dist/src/platforms/slack/commands/snapshot.d.ts.map +1 -1
  21. package/dist/src/platforms/slack/commands/snapshot.js +75 -55
  22. package/dist/src/platforms/slack/commands/snapshot.js.map +1 -1
  23. package/dist/src/platforms/teams/client.d.ts +9 -1
  24. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  25. package/dist/src/platforms/teams/client.js +69 -18
  26. package/dist/src/platforms/teams/client.js.map +1 -1
  27. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  28. package/dist/src/platforms/teams/commands/auth.js +7 -2
  29. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  30. package/dist/src/platforms/teams/commands/channel.d.ts.map +1 -1
  31. package/dist/src/platforms/teams/commands/channel.js +18 -3
  32. package/dist/src/platforms/teams/commands/channel.js.map +1 -1
  33. package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
  34. package/dist/src/platforms/teams/commands/file.js +18 -3
  35. package/dist/src/platforms/teams/commands/file.js.map +1 -1
  36. package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
  37. package/dist/src/platforms/teams/commands/message.js +24 -4
  38. package/dist/src/platforms/teams/commands/message.js.map +1 -1
  39. package/dist/src/platforms/teams/commands/reaction.d.ts.map +1 -1
  40. package/dist/src/platforms/teams/commands/reaction.js +12 -2
  41. package/dist/src/platforms/teams/commands/reaction.js.map +1 -1
  42. package/dist/src/platforms/teams/commands/snapshot.d.ts +1 -0
  43. package/dist/src/platforms/teams/commands/snapshot.d.ts.map +1 -1
  44. package/dist/src/platforms/teams/commands/snapshot.js +50 -32
  45. package/dist/src/platforms/teams/commands/snapshot.js.map +1 -1
  46. package/dist/src/platforms/teams/commands/team.d.ts.map +1 -1
  47. package/dist/src/platforms/teams/commands/team.js +6 -1
  48. package/dist/src/platforms/teams/commands/team.js.map +1 -1
  49. package/dist/src/platforms/teams/commands/user.d.ts.map +1 -1
  50. package/dist/src/platforms/teams/commands/user.js +18 -3
  51. package/dist/src/platforms/teams/commands/user.js.map +1 -1
  52. package/dist/src/platforms/teams/commands/whoami.d.ts.map +1 -1
  53. package/dist/src/platforms/teams/commands/whoami.js +6 -1
  54. package/dist/src/platforms/teams/commands/whoami.js.map +1 -1
  55. package/dist/src/platforms/teams/credential-manager.d.ts +3 -1
  56. package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
  57. package/dist/src/platforms/teams/credential-manager.js +6 -1
  58. package/dist/src/platforms/teams/credential-manager.js.map +1 -1
  59. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  60. package/dist/src/platforms/teams/ensure-auth.js +7 -2
  61. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  62. package/dist/src/platforms/teams/token-extractor.d.ts +3 -1
  63. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  64. package/dist/src/platforms/teams/token-extractor.js +67 -10
  65. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  66. package/dist/src/platforms/teams/types.d.ts +17 -0
  67. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  68. package/dist/src/platforms/teams/types.js +2 -0
  69. package/dist/src/platforms/teams/types.js.map +1 -1
  70. package/dist/src/platforms/webex/client.d.ts +3 -0
  71. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  72. package/dist/src/platforms/webex/client.js +58 -13
  73. package/dist/src/platforms/webex/client.js.map +1 -1
  74. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  75. package/dist/src/platforms/webex/commands/auth.js +61 -10
  76. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  77. package/dist/src/platforms/webex/commands/snapshot.d.ts +1 -0
  78. package/dist/src/platforms/webex/commands/snapshot.d.ts.map +1 -1
  79. package/dist/src/platforms/webex/commands/snapshot.js +14 -7
  80. package/dist/src/platforms/webex/commands/snapshot.js.map +1 -1
  81. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  82. package/dist/src/platforms/webex/credential-manager.js +18 -6
  83. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  84. package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
  85. package/dist/src/platforms/webex/encryption.js +3 -1
  86. package/dist/src/platforms/webex/encryption.js.map +1 -1
  87. package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -1
  88. package/dist/src/platforms/webex/ensure-auth.js +10 -2
  89. package/dist/src/platforms/webex/ensure-auth.js.map +1 -1
  90. package/dist/src/platforms/webex/token-extractor.d.ts +1 -0
  91. package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -1
  92. package/dist/src/platforms/webex/token-extractor.js +21 -4
  93. package/dist/src/platforms/webex/token-extractor.js.map +1 -1
  94. package/docs/content/docs/agent-skills.mdx +0 -10
  95. package/docs/content/docs/cli/channeltalk.mdx +18 -8
  96. package/docs/content/docs/cli/channeltalkbot.mdx +16 -6
  97. package/docs/content/docs/cli/discord.mdx +23 -7
  98. package/docs/content/docs/cli/discordbot.mdx +23 -7
  99. package/docs/content/docs/cli/slack.mdx +24 -7
  100. package/docs/content/docs/cli/teams.mdx +24 -8
  101. package/docs/content/docs/cli/webex.mdx +15 -2
  102. package/e2e/webex.e2e.test.ts +57 -0
  103. package/package.json +1 -1
  104. package/skills/agent-channeltalk/SKILL.md +19 -9
  105. package/skills/agent-channeltalk/references/common-patterns.md +10 -9
  106. package/skills/agent-channeltalkbot/SKILL.md +19 -9
  107. package/skills/agent-channeltalkbot/references/common-patterns.md +10 -9
  108. package/skills/agent-discord/SKILL.md +18 -9
  109. package/skills/agent-discord/references/common-patterns.md +8 -7
  110. package/skills/agent-discordbot/SKILL.md +18 -9
  111. package/skills/agent-instagram/SKILL.md +1 -1
  112. package/skills/agent-kakaotalk/SKILL.md +1 -1
  113. package/skills/agent-line/SKILL.md +1 -1
  114. package/skills/agent-slack/SKILL.md +19 -10
  115. package/skills/agent-slack/references/common-patterns.md +4 -7
  116. package/skills/agent-slackbot/SKILL.md +1 -1
  117. package/skills/agent-teams/SKILL.md +18 -9
  118. package/skills/agent-teams/references/common-patterns.md +9 -7
  119. package/skills/agent-telegram/SKILL.md +1 -1
  120. package/skills/agent-webex/SKILL.md +13 -4
  121. package/skills/agent-webex/references/common-patterns.md +8 -2
  122. package/skills/agent-wechatbot/SKILL.md +1 -1
  123. package/skills/agent-whatsapp/SKILL.md +1 -1
  124. package/skills/agent-whatsappbot/SKILL.md +1 -1
  125. package/src/platforms/channeltalk/commands/snapshot.test.ts +58 -26
  126. package/src/platforms/channeltalk/commands/snapshot.ts +107 -33
  127. package/src/platforms/channeltalkbot/commands/snapshot.test.ts +26 -8
  128. package/src/platforms/channeltalkbot/commands/snapshot.ts +131 -64
  129. package/src/platforms/discord/commands/snapshot.test.ts +1 -1
  130. package/src/platforms/discord/commands/snapshot.ts +58 -42
  131. package/src/platforms/discordbot/commands/snapshot.test.ts +40 -18
  132. package/src/platforms/discordbot/commands/snapshot.ts +54 -37
  133. package/src/platforms/slack/commands/snapshot.test.ts +63 -8
  134. package/src/platforms/slack/commands/snapshot.ts +98 -66
  135. package/src/platforms/teams/client.test.ts +34 -30
  136. package/src/platforms/teams/client.ts +92 -20
  137. package/src/platforms/teams/commands/auth.test.ts +6 -2
  138. package/src/platforms/teams/commands/auth.ts +7 -2
  139. package/src/platforms/teams/commands/channel.test.ts +6 -6
  140. package/src/platforms/teams/commands/channel.ts +18 -3
  141. package/src/platforms/teams/commands/file.ts +18 -3
  142. package/src/platforms/teams/commands/message.ts +24 -4
  143. package/src/platforms/teams/commands/reaction.ts +12 -2
  144. package/src/platforms/teams/commands/snapshot.test.ts +1 -1
  145. package/src/platforms/teams/commands/snapshot.ts +59 -39
  146. package/src/platforms/teams/commands/team.test.ts +2 -2
  147. package/src/platforms/teams/commands/team.ts +6 -1
  148. package/src/platforms/teams/commands/user.ts +18 -3
  149. package/src/platforms/teams/commands/whoami.ts +6 -1
  150. package/src/platforms/teams/credential-manager.test.ts +25 -0
  151. package/src/platforms/teams/credential-manager.ts +13 -3
  152. package/src/platforms/teams/ensure-auth.test.ts +6 -1
  153. package/src/platforms/teams/ensure-auth.ts +7 -2
  154. package/src/platforms/teams/token-extractor.ts +77 -12
  155. package/src/platforms/teams/types.test.ts +17 -0
  156. package/src/platforms/teams/types.ts +6 -0
  157. package/src/platforms/webex/client.test.ts +157 -13
  158. package/src/platforms/webex/client.ts +64 -15
  159. package/src/platforms/webex/commands/auth.test.ts +122 -1
  160. package/src/platforms/webex/commands/auth.ts +72 -17
  161. package/src/platforms/webex/commands/snapshot.test.ts +14 -1
  162. package/src/platforms/webex/commands/snapshot.ts +17 -9
  163. package/src/platforms/webex/credential-manager.test.ts +63 -0
  164. package/src/platforms/webex/credential-manager.ts +22 -8
  165. package/src/platforms/webex/encryption.test.ts +54 -0
  166. package/src/platforms/webex/encryption.ts +3 -1
  167. package/src/platforms/webex/ensure-auth.ts +10 -2
  168. package/src/platforms/webex/token-extractor.test.ts +32 -3
  169. package/src/platforms/webex/token-extractor.ts +26 -5
@@ -1,6 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
 
3
+ import * as jose from 'node-jose'
4
+
3
5
  import { WebexClient } from './client'
6
+ import { WebexEncryptionService } from './encryption'
4
7
  import { WebexError } from './types'
5
8
 
6
9
  describe('WebexClient', () => {
@@ -56,6 +59,17 @@ describe('WebexClient', () => {
56
59
  await expect(new WebexClient().login({ token: '' })).rejects.toThrow(WebexError)
57
60
  await expect(new WebexClient().login({ token: '' })).rejects.toThrow('Token is required')
58
61
  })
62
+
63
+ test('accepts deviceUrl and tokenType', async () => {
64
+ const client = await new WebexClient().login({
65
+ token: 'test-token',
66
+ deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/dev-1',
67
+ tokenType: 'extracted',
68
+ })
69
+ expect(client).toBeInstanceOf(WebexClient)
70
+ expect((client as any).deviceUrl).toBe('https://wdm-r.wbx2.com/wdm/api/v1/devices/dev-1')
71
+ expect((client as any).tokenType).toBe('extracted')
72
+ })
59
73
  })
60
74
 
61
75
  describe('testAuth', () => {
@@ -84,6 +98,59 @@ describe('WebexClient', () => {
84
98
  const client = await new WebexClient().login({ token: 'bad-token' })
85
99
  await expect(client.testAuth()).rejects.toThrow(WebexError)
86
100
  })
101
+
102
+ test('falls back to internal API when public API fails for extracted tokens', async () => {
103
+ // given - public API rejects, internal API succeeds
104
+ mockResponse({ message: 'Unauthorized' }, 401)
105
+ fetchResponses.push(
106
+ new Response(JSON.stringify({ id: 'conv-1', activities: { items: [] } }), {
107
+ status: 200,
108
+ headers: { 'Content-Type': 'application/json' },
109
+ }),
110
+ )
111
+
112
+ const client = await new WebexClient().login({
113
+ token: 'extracted-token',
114
+ deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/dev-1',
115
+ tokenType: 'extracted',
116
+ })
117
+
118
+ // when
119
+ const person = await client.testAuth()
120
+
121
+ // then - succeeds via internal API
122
+ expect(fetchCalls.length).toBe(2)
123
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/people/me')
124
+ expect(fetchCalls[1].url).toContain('conv-r.wbx2.com/conversation/api/v1/conversations')
125
+ expect(person).toBeTruthy()
126
+ })
127
+
128
+ test('throws when both public and internal APIs fail for extracted tokens', async () => {
129
+ // given - both APIs reject
130
+ mockResponse({ message: 'Unauthorized' }, 401)
131
+ fetchResponses.push(
132
+ new Response(JSON.stringify({ message: 'Unauthorized' }), {
133
+ status: 401,
134
+ headers: { 'Content-Type': 'application/json' },
135
+ }),
136
+ )
137
+
138
+ const client = await new WebexClient().login({
139
+ token: 'bad-extracted-token',
140
+ deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/dev-1',
141
+ tokenType: 'extracted',
142
+ })
143
+
144
+ await expect(client.testAuth()).rejects.toThrow(WebexError)
145
+ })
146
+
147
+ test('does not use internal API fallback for non-extracted tokens', async () => {
148
+ mockResponse({ message: 'Unauthorized' }, 401)
149
+
150
+ const client = await new WebexClient().login({ token: 'bad-token' })
151
+ await expect(client.testAuth()).rejects.toThrow(WebexError)
152
+ expect(fetchCalls.length).toBe(1)
153
+ })
87
154
  })
88
155
 
89
156
  describe('listSpaces', () => {
@@ -402,10 +469,11 @@ describe('WebexClient', () => {
402
469
  })
403
470
 
404
471
  const createExtractedClient = async () => {
405
- const client = await new WebexClient().login({ token: 'extracted-token' })
406
- ;(client as any).deviceUrl = TEST_DEVICE_URL
407
- ;(client as any).tokenType = 'extracted'
408
- return client
472
+ return new WebexClient().login({
473
+ token: 'extracted-token',
474
+ deviceUrl: TEST_DEVICE_URL,
475
+ tokenType: 'extracted',
476
+ })
409
477
  }
410
478
 
411
479
  describe('sendMessage', () => {
@@ -419,7 +487,7 @@ describe('WebexClient', () => {
419
487
  expect(fetchCalls[0].options?.method).toBe('POST')
420
488
  })
421
489
 
422
- test('body has verb, object type, displayName, and content', async () => {
490
+ test('body has verb, object type, and displayName (no content for plain text)', async () => {
423
491
  mockResponse(mockActivity('Hello world'))
424
492
 
425
493
  const client = await createExtractedClient()
@@ -429,7 +497,7 @@ describe('WebexClient', () => {
429
497
  expect(body.verb).toBe('post')
430
498
  expect(body.object.objectType).toBe('comment')
431
499
  expect(body.object.displayName).toBe('Hello world')
432
- expect(body.object.content).toBe('Hello world')
500
+ expect(body.object.content).toBeUndefined()
433
501
  })
434
502
 
435
503
  test('body has target with decoded conv UUID and conversation type', async () => {
@@ -488,7 +556,7 @@ describe('WebexClient', () => {
488
556
  expect(body.object.markdown).toBeUndefined()
489
557
  })
490
558
 
491
- test('markdown option does not affect plain text messages', async () => {
559
+ test('plain text messages omit content field', async () => {
492
560
  mockResponse(mockActivity('Hello world'))
493
561
 
494
562
  const client = await createExtractedClient()
@@ -496,7 +564,7 @@ describe('WebexClient', () => {
496
564
 
497
565
  const body = JSON.parse(fetchCalls[0].options?.body as string)
498
566
  expect(body.object.displayName).toBe('Hello world')
499
- expect(body.object.content).toBe('Hello world')
567
+ expect(body.object.content).toBeUndefined()
500
568
  })
501
569
  })
502
570
 
@@ -620,8 +688,11 @@ describe('WebexClient', () => {
620
688
  })
621
689
 
622
690
  describe('editMessage', () => {
691
+ const mockEditActivity = (text: string, parentId = 'activity-123') =>
692
+ mockActivity(text, { parent: { id: parentId, type: 'edit' } })
693
+
623
694
  test('posts activity with verb post and parent edit reference', async () => {
624
- mockResponse(mockActivity('Edited text'))
695
+ mockResponse(mockEditActivity('Edited text'))
625
696
 
626
697
  const client = await createExtractedClient()
627
698
  await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
@@ -631,8 +702,8 @@ describe('WebexClient', () => {
631
702
  expect(body.parent).toEqual({ id: 'activity-123', type: 'edit' })
632
703
  })
633
704
 
634
- test('body has object with comment type and new text', async () => {
635
- mockResponse(mockActivity('Edited text'))
705
+ test('plain text edit populates both displayName and content to avoid auto-tombstone', async () => {
706
+ mockResponse(mockEditActivity('Edited text'))
636
707
 
637
708
  const client = await createExtractedClient()
638
709
  await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
@@ -643,8 +714,18 @@ describe('WebexClient', () => {
643
714
  expect(body.object.content).toBe('Edited text')
644
715
  })
645
716
 
717
+ test('clientTempId uses -edit suffix to match Webex web client format', async () => {
718
+ mockResponse(mockEditActivity('Edited text'))
719
+
720
+ const client = await createExtractedClient()
721
+ await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
722
+
723
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
724
+ expect(body.clientTempId).toMatch(/^tmp-\d+-edit$/)
725
+ })
726
+
646
727
  test('target has decoded conv UUID', async () => {
647
- mockResponse(mockActivity('Edited text'))
728
+ mockResponse(mockEditActivity('Edited text'))
648
729
 
649
730
  const client = await createExtractedClient()
650
731
  await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
@@ -654,7 +735,7 @@ describe('WebexClient', () => {
654
735
  })
655
736
 
656
737
  test('markdown option converts content to HTML and strips displayName', async () => {
657
- mockResponse(mockActivity('italic text'))
738
+ mockResponse(mockEditActivity('italic text'))
658
739
 
659
740
  const client = await createExtractedClient()
660
741
  await client.editMessage('activity-123', TEST_ROOM_ID, '_italic text_', { markdown: true })
@@ -664,6 +745,69 @@ describe('WebexClient', () => {
664
745
  expect(body.object.content).toBe('<em>italic text</em>')
665
746
  expect(body.object.markdown).toBeUndefined()
666
747
  })
748
+
749
+ test('tolerates responses that omit parent (minimal success shape)', async () => {
750
+ mockResponse(mockActivity('Edited text'))
751
+
752
+ const client = await createExtractedClient()
753
+ const message = await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
754
+ expect(message.id).toBe('activity-123')
755
+ })
756
+
757
+ test('throws when server returns activity linked to a different parent', async () => {
758
+ mockResponse(mockEditActivity('Edited text', 'activity-999'))
759
+
760
+ const client = await createExtractedClient()
761
+ await expect(client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')).rejects.toThrow(/Edit rejected/)
762
+ })
763
+ })
764
+
765
+ describe('encrypted send and edit', () => {
766
+ const TEST_KEY_URI = 'kms://kms-aore.wbx2.com/keys/test-key-id'
767
+
768
+ const decodeJweHeader = (jwe: string): Record<string, unknown> => {
769
+ const [header = ''] = jwe.split('.')
770
+ const padded = header + '='.repeat((4 - (header.length % 4)) % 4)
771
+ return JSON.parse(Buffer.from(padded, 'base64url').toString('utf8')) as Record<string, unknown>
772
+ }
773
+
774
+ const createEncryptedClient = async () => {
775
+ const keystore = jose.JWK.createKeyStore()
776
+ const key = await keystore.generate('oct', 256, { alg: 'A256GCM' })
777
+ const rawKeys = new Map<string, string>([[TEST_KEY_URI, JSON.stringify({ jwk: key.toJSON(true) })]])
778
+ const service = new WebexEncryptionService(rawKeys)
779
+ const client = await createExtractedClient()
780
+ ;(client as unknown as { encryption: WebexEncryptionService }).encryption = service
781
+ return client
782
+ }
783
+
784
+ test('plain text send omits content field on encrypted path (preserves prior fix)', async () => {
785
+ mockResponse({ id: TEST_CONV_UUID, defaultActivityEncryptionKeyUrl: TEST_KEY_URI })
786
+ mockResponse(mockActivity('Hello world'))
787
+
788
+ const client = await createEncryptedClient()
789
+ await client.sendMessage(TEST_ROOM_ID, 'Hello world')
790
+
791
+ const body = JSON.parse(fetchCalls[1].options?.body as string)
792
+ expect(body.object.content).toBeUndefined()
793
+ expect(body.object.displayName.startsWith('eyJ')).toBe(true)
794
+ expect(body.encryptionKeyUrl).toBe(TEST_KEY_URI)
795
+ })
796
+
797
+ test('plain text edit encrypts both displayName and content with kid in JWE header', async () => {
798
+ mockResponse({ id: TEST_CONV_UUID, defaultActivityEncryptionKeyUrl: TEST_KEY_URI })
799
+ mockResponse(mockActivity('Edited text', { parent: { id: 'activity-123', type: 'edit' } }))
800
+
801
+ const client = await createEncryptedClient()
802
+ await client.editMessage('activity-123', TEST_ROOM_ID, 'Edited text')
803
+
804
+ const body = JSON.parse(fetchCalls[1].options?.body as string)
805
+ expect(body.object.displayName.startsWith('eyJ')).toBe(true)
806
+ expect(body.object.content.startsWith('eyJ')).toBe(true)
807
+ expect(body.encryptionKeyUrl).toBe(TEST_KEY_URI)
808
+ expect(decodeJweHeader(body.object.displayName).kid).toBe(TEST_KEY_URI)
809
+ expect(decodeJweHeader(body.object.content).kid).toBe(TEST_KEY_URI)
810
+ })
667
811
  })
668
812
 
669
813
  describe('sendDirectMessage', () => {
@@ -21,12 +21,14 @@ export class WebexClient {
21
21
  private globalRateLimitUntil: number = 0
22
22
  private encryption: WebexEncryptionService | null = null
23
23
 
24
- async login(credentials?: { token: string }): Promise<this> {
24
+ async login(credentials?: { token: string; deviceUrl?: string; tokenType?: string }): Promise<this> {
25
25
  if (credentials) {
26
26
  if (!credentials.token) {
27
27
  throw new WebexError('Token is required', 'missing_token')
28
28
  }
29
29
  this.token = credentials.token
30
+ if (credentials.deviceUrl !== undefined) this.deviceUrl = credentials.deviceUrl
31
+ if (credentials.tokenType !== undefined) this.tokenType = credentials.tokenType
30
32
  return this
31
33
  }
32
34
 
@@ -161,9 +163,28 @@ export class WebexClient {
161
163
  }
162
164
 
163
165
  async testAuth(): Promise<WebexPerson> {
166
+ if (this.useInternalAPI) {
167
+ try {
168
+ return await this.request<WebexPerson>('GET', '/people/me')
169
+ } catch (err) {
170
+ const isAuthError = err instanceof WebexError && (err.code === 'http_401' || err.code === 'http_403')
171
+ if (!isAuthError) throw err
172
+ await this.testAuthInternal()
173
+ return { id: '', emails: [], displayName: '', orgId: '', type: 'person', created: '' } as WebexPerson
174
+ }
175
+ }
164
176
  return this.request<WebexPerson>('GET', '/people/me')
165
177
  }
166
178
 
179
+ private async testAuthInternal(): Promise<void> {
180
+ if (!this.deviceUrl) {
181
+ throw new WebexError('No device URL available for internal API validation', 'no_device_url')
182
+ }
183
+ await this.internalRequest<InternalConversation>(
184
+ '/conversations?participantsLimit=0&activitiesLimit=0&conversationsLimit=1',
185
+ )
186
+ }
187
+
167
188
  async listSpaces(options?: { type?: string; max?: number }): Promise<WebexSpace[]> {
168
189
  const params = new URLSearchParams()
169
190
  if (options?.type) params.set('type', options.type)
@@ -248,10 +269,15 @@ export class WebexClient {
248
269
  private async buildEncryptedObject(
249
270
  convUuid: string,
250
271
  text: string,
251
- options?: { markdown?: boolean },
272
+ options?: { markdown?: boolean; forEdit?: boolean },
252
273
  ): Promise<{ object: Record<string, string>; encryptionKeyUrl?: string }> {
253
274
  const displayName = options?.markdown ? stripMarkdown(text) : text
254
- const content = options?.markdown ? markdownToHtml(text) : text
275
+ let content: string | undefined
276
+ if (options?.markdown) {
277
+ content = markdownToHtml(text)
278
+ } else if (options?.forEdit) {
279
+ content = text
280
+ }
255
281
 
256
282
  if (this.encryption) {
257
283
  const conv = await this.internalRequest<InternalConversation>(
@@ -260,21 +286,25 @@ export class WebexClient {
260
286
  const keyUri = conv.defaultActivityEncryptionKeyUrl
261
287
  if (keyUri) {
262
288
  const encryptedDisplayName = await this.encryption.encryptText(keyUri, displayName)
263
- const encryptedContent = await this.encryption.encryptText(keyUri, content)
264
- if (encryptedDisplayName && encryptedContent) {
265
- return {
266
- object: {
267
- objectType: 'comment',
268
- displayName: encryptedDisplayName,
269
- content: encryptedContent,
270
- },
271
- encryptionKeyUrl: keyUri,
289
+ const encryptedContent = content ? await this.encryption.encryptText(keyUri, content) : undefined
290
+ if (encryptedDisplayName) {
291
+ const object: Record<string, string> = {
292
+ objectType: 'comment',
293
+ displayName: encryptedDisplayName,
294
+ }
295
+ if (encryptedContent) {
296
+ object.content = encryptedContent
272
297
  }
298
+ return { object, encryptionKeyUrl: keyUri }
273
299
  }
274
300
  }
275
301
  }
276
302
 
277
- return { object: { objectType: 'comment', displayName, content } }
303
+ const object: Record<string, string> = { objectType: 'comment', displayName }
304
+ if (content) {
305
+ object.content = content
306
+ }
307
+ return { object }
278
308
  }
279
309
 
280
310
  private async sendMessageInternal(
@@ -349,6 +379,11 @@ export class WebexClient {
349
379
  if (this.useInternalAPI) {
350
380
  const activity = await this.internalRequest<InternalActivity>(`/activities/${messageId}`)
351
381
  const convId = activity.target?.id ?? ''
382
+ // Internal API responses don't carry the cluster shard (e.g. `us-west-2_r`) the
383
+ // public roomId encoding requires. The `unknown` placeholder is a sentinel — it
384
+ // round-trips through other internal API calls because they decode only the
385
+ // conversation UUID suffix. Callers that need a public-API-safe roomId should
386
+ // obtain it from `listSpaces()` or pass it through from a prior `sendMessage`.
352
387
  const roomId = convId ? Buffer.from(`ciscospark://urn:TEAM:unknown/ROOM/${convId}`).toString('base64') : ''
353
388
  return this.activityToMessage(activity, roomId)
354
389
  }
@@ -381,14 +416,17 @@ export class WebexClient {
381
416
  ): Promise<WebexMessage> {
382
417
  if (this.useInternalAPI) {
383
418
  const convUuid = this.decodeConvUuid(roomId)
384
- const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text, options)
419
+ const { object, encryptionKeyUrl } = await this.buildEncryptedObject(convUuid, text, {
420
+ ...options,
421
+ forEdit: true,
422
+ })
385
423
 
386
424
  const activity: Record<string, unknown> = {
387
425
  verb: 'post',
388
426
  object,
389
427
  target: { id: convUuid, objectType: 'conversation' },
390
428
  parent: { id: messageId, type: 'edit' },
391
- clientTempId: `tmp-${Date.now()}`,
429
+ clientTempId: `tmp-${Date.now()}-edit`,
392
430
  }
393
431
 
394
432
  if (encryptionKeyUrl) {
@@ -399,6 +437,16 @@ export class WebexClient {
399
437
  method: 'POST',
400
438
  body: JSON.stringify(activity),
401
439
  })
440
+
441
+ // Tolerate responses that omit `parent` (server may return minimal shape) —
442
+ // only fail on an explicit mismatch between the echoed parent and the edited id.
443
+ if (result.parent && result.parent.id !== messageId) {
444
+ throw new WebexError(
445
+ `Edit rejected: server linked the new activity ${result.id} to ${result.parent.id} instead of ${messageId}.`,
446
+ 'edit_failed',
447
+ )
448
+ }
449
+
402
450
  return this.activityToMessage(result, roomId)
403
451
  }
404
452
  const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
@@ -443,6 +491,7 @@ interface InternalActivity {
443
491
  encryptionKeyUrl?: string
444
492
  }
445
493
  target?: { id: string; encryptionKeyUrl?: string }
494
+ parent?: { id: string; type: string }
446
495
  published: string
447
496
  encryptionKeyUrl?: string
448
497
  }
@@ -3,7 +3,9 @@ import * as childProcess from 'node:child_process'
3
3
 
4
4
  import { WebexClient } from '../client'
5
5
  import { WebexCredentialManager } from '../credential-manager'
6
- import { loginAction, logoutAction, statusAction } from './auth'
6
+ import { WebexTokenExtractor } from '../token-extractor'
7
+ import { WebexError } from '../types'
8
+ import { extractAction, loginAction, logoutAction, statusAction } from './auth'
7
9
 
8
10
  describe('auth commands', () => {
9
11
  let consoleSpy: ReturnType<typeof spyOn>
@@ -208,6 +210,125 @@ describe('auth commands', () => {
208
210
  })
209
211
  })
210
212
 
213
+ describe('extractAction', () => {
214
+ test('passes deviceUrl and tokenType to client.login', async () => {
215
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
216
+ accessToken: 'extracted-token-at-least-twenty-chars',
217
+ refreshToken: 'refresh-token',
218
+ expiresAt: Date.now() + 3600000,
219
+ deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device-id',
220
+ userId: 'user-1',
221
+ })
222
+ const loginSpy = protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
223
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
224
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
225
+
226
+ await extractAction({ pretty: false })
227
+
228
+ expect(loginSpy).toHaveBeenCalledWith({
229
+ token: 'extracted-token-at-least-twenty-chars',
230
+ deviceUrl: 'https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device-id',
231
+ tokenType: 'extracted',
232
+ })
233
+ })
234
+
235
+ test('attempts refresh when token is expired', async () => {
236
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
237
+ accessToken: 'expired-token-at-least-twenty-chars-',
238
+ refreshToken: 'valid-refresh-token',
239
+ expiresAt: Date.now() - 7200000,
240
+ })
241
+ const refreshSpy = protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue({
242
+ accessToken: 'refreshed-token-at-least-twenty-ch',
243
+ refreshToken: 'new-refresh',
244
+ expiresAt: Date.now() + 3600000,
245
+ })
246
+ const loginSpy = protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
247
+ protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
248
+ protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
249
+
250
+ await extractAction({ pretty: false })
251
+
252
+ expect(refreshSpy).toHaveBeenCalled()
253
+ expect(loginSpy).toHaveBeenCalledWith(expect.objectContaining({ token: 'refreshed-token-at-least-twenty-ch' }))
254
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
255
+ const output = JSON.parse(lastCall)
256
+ expect(output.authenticated).toBe(true)
257
+ expect(output.refreshed).toBe(true)
258
+ })
259
+
260
+ test('reports expired token with actionable hint when refresh fails', async () => {
261
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
262
+ accessToken: 'expired-token-at-least-twenty-chars-',
263
+ refreshToken: 'bad-refresh-token',
264
+ expiresAt: Date.now() - 7200000,
265
+ })
266
+ protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue(null)
267
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
268
+ protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new WebexError('Unauthorized', 'http_401'))
269
+ const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
270
+
271
+ await extractAction({ pretty: false })
272
+
273
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
274
+ const output = JSON.parse(lastCall)
275
+ expect(output.error).toContain('expired')
276
+ expect(output.hint).toContain('web.webex.com')
277
+ expect(output.hint).toContain('not webex.com')
278
+ expect(exitSpy).toHaveBeenCalledWith(1)
279
+ })
280
+
281
+ test('rethrows non-auth errors even when token is expired', async () => {
282
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
283
+ accessToken: 'expired-token-at-least-twenty-chars-',
284
+ refreshToken: 'bad-refresh-token',
285
+ expiresAt: Date.now() - 7200000,
286
+ })
287
+ protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue(null)
288
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
289
+ protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('Network error'))
290
+ protoSpy(process, 'exit').mockImplementation(() => undefined as never)
291
+
292
+ await extractAction({ pretty: false })
293
+
294
+ const lastCall = consoleErrorSpy.mock.calls[consoleErrorSpy.mock.calls.length - 1]?.[0] as string | undefined
295
+ if (lastCall) {
296
+ const output = JSON.parse(lastCall)
297
+ expect(output.error).toContain('Network error')
298
+ }
299
+ })
300
+
301
+ test('rethrows non-expiry auth errors', async () => {
302
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue({
303
+ accessToken: 'valid-token-at-least-twenty-chars-xx',
304
+ expiresAt: Date.now() + 3600000,
305
+ })
306
+ protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
307
+ protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('Network error'))
308
+ protoSpy(process, 'exit').mockImplementation(() => undefined as never)
309
+
310
+ await extractAction({ pretty: false })
311
+
312
+ const lastCall = consoleErrorSpy.mock.calls[consoleErrorSpy.mock.calls.length - 1]?.[0] as string | undefined
313
+ if (lastCall) {
314
+ const output = JSON.parse(lastCall)
315
+ expect(output.error).toContain('Network error')
316
+ }
317
+ })
318
+
319
+ test('outputs no token found when extract returns null', async () => {
320
+ protoSpy(WebexTokenExtractor.prototype, 'extract').mockResolvedValue(null)
321
+ const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
322
+
323
+ await extractAction({ pretty: false })
324
+
325
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
326
+ const output = JSON.parse(lastCall)
327
+ expect(output.error).toContain('No Webex token found')
328
+ expect(exitSpy).toHaveBeenCalledWith(1)
329
+ })
330
+ })
331
+
211
332
  describe('logoutAction', () => {
212
333
  test('clears credentials when authenticated', async () => {
213
334
  protoSpy(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue({
@@ -8,6 +8,7 @@ import { getWebexAppCredentials } from '../app-config'
8
8
  import { WebexClient } from '../client'
9
9
  import { WebexCredentialManager } from '../credential-manager'
10
10
  import { WebexTokenExtractor } from '../token-extractor'
11
+ import { WebexError } from '../types'
11
12
 
12
13
  interface ResolvedCredentials {
13
14
  clientId: string
@@ -142,7 +143,8 @@ export async function statusAction(options: { pretty?: boolean }): Promise<void>
142
143
 
143
144
  export async function extractAction(options: { pretty?: boolean; debug?: boolean }): Promise<void> {
144
145
  try {
145
- const extractor = new WebexTokenExtractor(undefined, options.debug ? (msg) => debug(`[debug] ${msg}`) : undefined)
146
+ const debugLog = options.debug ? (msg: string) => debug(`[debug] ${msg}`) : undefined
147
+ const extractor = new WebexTokenExtractor(undefined, debugLog)
146
148
 
147
149
  if (options.debug) {
148
150
  debug('[debug] Searching browser profiles for Webex tokens...')
@@ -155,7 +157,7 @@ export async function extractAction(options: { pretty?: boolean; debug?: boolean
155
157
  formatOutput(
156
158
  {
157
159
  error:
158
- 'No Webex token found in any browser. Make sure you are logged in to web.webex.com in Chrome, Edge, Arc, or Brave.',
160
+ 'No Webex token found in any browser. Make sure you are logged in at https://web.webex.com (not webex.com) in Chrome, Edge, Arc, or Brave.',
159
161
  hint: 'Run "auth login" for OAuth Device Grant flow, or --debug for more info.',
160
162
  },
161
163
  options.pretty,
@@ -165,30 +167,83 @@ export async function extractAction(options: { pretty?: boolean; debug?: boolean
165
167
  return
166
168
  }
167
169
 
168
- const client = await new WebexClient().login({ token: extracted.accessToken })
169
- const person = await client.testAuth()
170
+ const isExpired = extracted.expiresAt != null && extracted.expiresAt > 0 && extracted.expiresAt < Date.now()
171
+ if (isExpired && options.debug) {
172
+ const agoMs = Date.now() - extracted.expiresAt!
173
+ const agoHours = Math.round(agoMs / 3_600_000)
174
+ debugLog?.(`Token expired ${agoHours > 0 ? `${agoHours}h ago` : 'recently'}.`)
175
+ }
176
+
177
+ let activeToken = extracted.accessToken
178
+ let refreshedConfig: { accessToken: string; refreshToken: string; expiresAt: number } | null = null
179
+
180
+ if (isExpired && extracted.refreshToken) {
181
+ debugLog?.('Attempting token refresh...')
182
+ const credManager = new WebexCredentialManager()
183
+ const { clientId, clientSecret } = getWebexAppCredentials()
184
+ refreshedConfig = await credManager.refreshToken(extracted.refreshToken, clientId, clientSecret)
185
+ if (refreshedConfig) {
186
+ debugLog?.('Token refreshed successfully.')
187
+ activeToken = refreshedConfig.accessToken
188
+ } else {
189
+ debugLog?.('Token refresh failed. Will attempt validation with expired token.')
190
+ }
191
+ }
192
+
193
+ const client = await new WebexClient().login({
194
+ token: activeToken,
195
+ deviceUrl: extracted.deviceUrl,
196
+ tokenType: 'extracted',
197
+ })
198
+
199
+ let person: { id: string; displayName: string; emails: string[] } | null = null
200
+ try {
201
+ const result = await client.testAuth()
202
+ if (result.id) {
203
+ person = { id: result.id, displayName: result.displayName, emails: result.emails }
204
+ }
205
+ } catch (authError) {
206
+ const isAuthFailure =
207
+ authError instanceof WebexError && (authError.code === 'http_401' || authError.code === 'http_403')
208
+ if (isExpired && isAuthFailure) {
209
+ console.log(
210
+ formatOutput(
211
+ {
212
+ error: 'Extracted browser token is expired and could not be refreshed.',
213
+ hint: 'Log in at https://web.webex.com (not webex.com) in your browser, then run "auth extract" again. Or use "auth login" for OAuth Device Grant flow.',
214
+ },
215
+ options.pretty,
216
+ ),
217
+ )
218
+ process.exit(1)
219
+ return
220
+ }
221
+ throw authError
222
+ }
170
223
 
171
224
  const credManager = new WebexCredentialManager()
172
225
  await credManager.saveConfig({
173
- accessToken: extracted.accessToken,
174
- refreshToken: extracted.refreshToken ?? '',
175
- expiresAt: extracted.expiresAt ?? 0,
226
+ accessToken: activeToken,
227
+ refreshToken: refreshedConfig?.refreshToken ?? extracted.refreshToken ?? '',
228
+ expiresAt: refreshedConfig?.expiresAt ?? extracted.expiresAt ?? 0,
176
229
  tokenType: 'extracted',
177
230
  deviceUrl: extracted.deviceUrl,
178
231
  userId: extracted.userId,
179
232
  encryptionKeys: extracted.encryptionKeys ? Object.fromEntries(extracted.encryptionKeys) : undefined,
180
233
  })
181
234
 
182
- console.log(
183
- formatOutput(
184
- {
185
- user: { id: person.id, displayName: person.displayName, emails: person.emails },
186
- authenticated: true,
187
- tokenType: 'extracted',
188
- },
189
- options.pretty,
190
- ),
191
- )
235
+ const output: Record<string, unknown> = {
236
+ authenticated: true,
237
+ tokenType: 'extracted',
238
+ }
239
+ if (refreshedConfig) {
240
+ output['refreshed'] = true
241
+ }
242
+ if (person) {
243
+ output['user'] = person
244
+ }
245
+
246
+ console.log(formatOutput(output, options.pretty))
192
247
  } catch (error) {
193
248
  handleError(error as Error)
194
249
  }