agent-messenger 2.21.0 → 2.23.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 (134) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/webex/client.d.ts +25 -0
  5. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  6. package/dist/src/platforms/webex/client.js +115 -5
  7. package/dist/src/platforms/webex/client.js.map +1 -1
  8. package/dist/src/platforms/webex/commands/auth.d.ts +9 -1
  9. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  10. package/dist/src/platforms/webex/commands/auth.js +141 -25
  11. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  12. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  13. package/dist/src/platforms/webex/credential-manager.js +8 -4
  14. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  15. package/dist/src/platforms/webex/id-normalizer.d.ts +19 -0
  16. package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -0
  17. package/dist/src/platforms/webex/id-normalizer.js +60 -0
  18. package/dist/src/platforms/webex/id-normalizer.js.map +1 -0
  19. package/dist/src/platforms/webex/index.d.ts +4 -0
  20. package/dist/src/platforms/webex/index.d.ts.map +1 -1
  21. package/dist/src/platforms/webex/index.js +2 -0
  22. package/dist/src/platforms/webex/index.js.map +1 -1
  23. package/dist/src/platforms/webex/listener.d.ts +61 -0
  24. package/dist/src/platforms/webex/listener.d.ts.map +1 -0
  25. package/dist/src/platforms/webex/listener.js +222 -0
  26. package/dist/src/platforms/webex/listener.js.map +1 -0
  27. package/dist/src/platforms/webex/password-login.d.ts +18 -0
  28. package/dist/src/platforms/webex/password-login.d.ts.map +1 -0
  29. package/dist/src/platforms/webex/password-login.js +259 -0
  30. package/dist/src/platforms/webex/password-login.js.map +1 -0
  31. package/dist/src/platforms/webex/types.d.ts +2 -1
  32. package/dist/src/platforms/webex/types.d.ts.map +1 -1
  33. package/dist/src/platforms/webex/types.js +1 -1
  34. package/dist/src/platforms/webex/types.js.map +1 -1
  35. package/dist/src/platforms/webex/wdm-discovery.d.ts.map +1 -0
  36. package/dist/src/platforms/{webexbot → webex}/wdm-discovery.js +3 -3
  37. package/dist/src/platforms/webex/wdm-discovery.js.map +1 -0
  38. package/dist/src/platforms/webexbot/cli.d.ts.map +1 -1
  39. package/dist/src/platforms/webexbot/cli.js +4 -1
  40. package/dist/src/platforms/webexbot/cli.js.map +1 -1
  41. package/dist/src/platforms/webexbot/client.d.ts +24 -0
  42. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  43. package/dist/src/platforms/webexbot/client.js +81 -5
  44. package/dist/src/platforms/webexbot/client.js.map +1 -1
  45. package/dist/src/platforms/webexbot/commands/file.d.ts +22 -0
  46. package/dist/src/platforms/webexbot/commands/file.d.ts.map +1 -0
  47. package/dist/src/platforms/webexbot/commands/file.js +64 -0
  48. package/dist/src/platforms/webexbot/commands/file.js.map +1 -0
  49. package/dist/src/platforms/webexbot/commands/index.d.ts +3 -0
  50. package/dist/src/platforms/webexbot/commands/index.d.ts.map +1 -1
  51. package/dist/src/platforms/webexbot/commands/index.js +3 -0
  52. package/dist/src/platforms/webexbot/commands/index.js.map +1 -1
  53. package/dist/src/platforms/webexbot/commands/message.d.ts +7 -0
  54. package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -1
  55. package/dist/src/platforms/webexbot/commands/message.js +52 -1
  56. package/dist/src/platforms/webexbot/commands/message.js.map +1 -1
  57. package/dist/src/platforms/webexbot/commands/snapshot.d.ts +24 -0
  58. package/dist/src/platforms/webexbot/commands/snapshot.d.ts.map +1 -0
  59. package/dist/src/platforms/webexbot/commands/snapshot.js +37 -0
  60. package/dist/src/platforms/webexbot/commands/snapshot.js.map +1 -0
  61. package/dist/src/platforms/webexbot/commands/user.d.ts +30 -0
  62. package/dist/src/platforms/webexbot/commands/user.d.ts.map +1 -0
  63. package/dist/src/platforms/webexbot/commands/user.js +66 -0
  64. package/dist/src/platforms/webexbot/commands/user.js.map +1 -0
  65. package/dist/src/platforms/webexbot/index.d.ts +2 -0
  66. package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
  67. package/dist/src/platforms/webexbot/index.js +1 -0
  68. package/dist/src/platforms/webexbot/index.js.map +1 -1
  69. package/dist/src/platforms/webexbot/listener.d.ts +3 -41
  70. package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
  71. package/dist/src/platforms/webexbot/listener.js +13 -208
  72. package/dist/src/platforms/webexbot/listener.js.map +1 -1
  73. package/dist/src/platforms/webexbot/types.d.ts +1 -18
  74. package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
  75. package/dist/src/platforms/webexbot/types.js.map +1 -1
  76. package/docs/content/docs/cli/webex.mdx +38 -12
  77. package/docs/content/docs/cli/webexbot.mdx +2 -0
  78. package/docs/content/docs/sdk/webexbot.mdx +18 -0
  79. package/package.json +1 -1
  80. package/skills/agent-channeltalk/SKILL.md +1 -1
  81. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  82. package/skills/agent-discord/SKILL.md +1 -1
  83. package/skills/agent-discordbot/SKILL.md +1 -1
  84. package/skills/agent-instagram/SKILL.md +1 -1
  85. package/skills/agent-kakaotalk/SKILL.md +1 -1
  86. package/skills/agent-line/SKILL.md +1 -1
  87. package/skills/agent-slack/SKILL.md +1 -1
  88. package/skills/agent-slackbot/SKILL.md +1 -1
  89. package/skills/agent-teams/SKILL.md +1 -1
  90. package/skills/agent-telegram/SKILL.md +1 -1
  91. package/skills/agent-telegrambot/SKILL.md +1 -1
  92. package/skills/agent-webex/SKILL.md +76 -22
  93. package/skills/agent-webex/references/authentication.md +55 -14
  94. package/skills/agent-webex/references/common-patterns.md +5 -2
  95. package/skills/agent-webexbot/SKILL.md +60 -5
  96. package/skills/agent-webexbot/references/common-patterns.md +118 -0
  97. package/skills/agent-wechatbot/SKILL.md +1 -1
  98. package/skills/agent-whatsapp/SKILL.md +1 -1
  99. package/skills/agent-whatsappbot/SKILL.md +1 -1
  100. package/src/platforms/webex/cli.test.ts +31 -1
  101. package/src/platforms/webex/client.test.ts +67 -0
  102. package/src/platforms/webex/client.ts +136 -7
  103. package/src/platforms/webex/commands/auth.test.ts +189 -28
  104. package/src/platforms/webex/commands/auth.ts +194 -35
  105. package/src/platforms/webex/credential-manager.test.ts +40 -0
  106. package/src/platforms/webex/credential-manager.ts +7 -4
  107. package/src/platforms/webex/id-normalizer.test.ts +207 -0
  108. package/src/platforms/webex/id-normalizer.ts +76 -0
  109. package/src/platforms/webex/index.test.ts +6 -0
  110. package/src/platforms/webex/index.ts +4 -0
  111. package/src/platforms/webex/listener.test.ts +243 -0
  112. package/src/platforms/webex/listener.ts +285 -0
  113. package/src/platforms/webex/password-login.test.ts +193 -0
  114. package/src/platforms/webex/password-login.ts +332 -0
  115. package/src/platforms/webex/types.test.ts +16 -0
  116. package/src/platforms/webex/types.ts +2 -2
  117. package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
  118. package/src/platforms/webexbot/cli.ts +6 -0
  119. package/src/platforms/webexbot/client.test.ts +322 -0
  120. package/src/platforms/webexbot/client.ts +104 -7
  121. package/src/platforms/webexbot/commands/file.ts +104 -0
  122. package/src/platforms/webexbot/commands/index.ts +3 -0
  123. package/src/platforms/webexbot/commands/message.ts +68 -2
  124. package/src/platforms/webexbot/commands/snapshot.ts +60 -0
  125. package/src/platforms/webexbot/commands/user.test.ts +77 -0
  126. package/src/platforms/webexbot/commands/user.ts +98 -0
  127. package/src/platforms/webexbot/index.ts +2 -0
  128. package/src/platforms/webexbot/listener.test.ts +37 -224
  129. package/src/platforms/webexbot/listener.ts +18 -250
  130. package/src/platforms/webexbot/types.ts +2 -23
  131. package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
  132. package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
  133. /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
  134. /package/src/platforms/{webexbot → webex}/wdm-discovery.test.ts +0 -0
@@ -40,11 +40,27 @@ function formatMessage(message: WebexMessage): MessageResult {
40
40
  export async function sendAction(
41
41
  space: string,
42
42
  text: string,
43
+ options: BotOption & { markdown?: boolean; parent?: string },
44
+ ): Promise<MessageResult> {
45
+ try {
46
+ const client = await getClient(options)
47
+ const message = await client.sendMessage(space, text, { markdown: options.markdown, parentId: options.parent })
48
+
49
+ return formatMessage(message)
50
+ } catch (error) {
51
+ return { error: (error as Error).message }
52
+ }
53
+ }
54
+
55
+ export async function replyAction(
56
+ space: string,
57
+ parentId: string,
58
+ text: string,
43
59
  options: BotOption & { markdown?: boolean },
44
60
  ): Promise<MessageResult> {
45
61
  try {
46
62
  const client = await getClient(options)
47
- const message = await client.sendMessage(space, text, { markdown: options.markdown })
63
+ const message = await client.sendMessage(space, text, { markdown: options.markdown, parentId })
48
64
 
49
65
  return formatMessage(message)
50
66
  } catch (error) {
@@ -52,6 +68,30 @@ export async function sendAction(
52
68
  }
53
69
  }
54
70
 
71
+ export async function repliesAction(
72
+ space: string,
73
+ parentId: string,
74
+ options: BotOption & { max?: string },
75
+ ): Promise<MessageResult> {
76
+ try {
77
+ const client = await getClient(options)
78
+ const max = options.max ? parseInt(options.max, 10) : 50
79
+ const messages = await client.listReplies(space, parentId, { max })
80
+
81
+ return {
82
+ messages: messages.map((msg) => ({
83
+ id: msg.id,
84
+ roomId: msg.roomId,
85
+ text: msg.text,
86
+ personEmail: msg.personEmail,
87
+ created: msg.created,
88
+ })),
89
+ }
90
+ } catch (error) {
91
+ return { error: (error as Error).message }
92
+ }
93
+ }
94
+
55
95
  export async function dmAction(
56
96
  email: string,
57
97
  text: string,
@@ -133,12 +173,38 @@ export const messageCommand = new Command('message')
133
173
  .argument('<space>', 'Space/Room ID')
134
174
  .argument('<text>', 'Message text')
135
175
  .option('--markdown', 'Send as markdown')
176
+ .option('--parent <id>', 'Reply within a thread (parent message ID)')
136
177
  .option('--bot <id>', 'Use specific bot')
137
178
  .option('--pretty', 'Pretty print JSON output')
138
- .action(async (space: string, text: string, opts: BotOption & { markdown?: boolean }) => {
179
+ .action(async (space: string, text: string, opts: BotOption & { markdown?: boolean; parent?: string }) => {
139
180
  cliOutput(await sendAction(space, text, opts), opts.pretty)
140
181
  }),
141
182
  )
183
+ .addCommand(
184
+ new Command('reply')
185
+ .description('Reply to a message in a thread')
186
+ .argument('<space>', 'Space/Room ID')
187
+ .argument('<parent>', 'Parent message ID')
188
+ .argument('<text>', 'Reply text')
189
+ .option('--markdown', 'Send as markdown')
190
+ .option('--bot <id>', 'Use specific bot')
191
+ .option('--pretty', 'Pretty print JSON output')
192
+ .action(async (space: string, parent: string, text: string, opts: BotOption & { markdown?: boolean }) => {
193
+ cliOutput(await replyAction(space, parent, text, opts), opts.pretty)
194
+ }),
195
+ )
196
+ .addCommand(
197
+ new Command('replies')
198
+ .description('List replies in a thread')
199
+ .argument('<space>', 'Space/Room ID')
200
+ .argument('<parent>', 'Parent message ID')
201
+ .option('--max <n>', 'Number of replies to fetch', '50')
202
+ .option('--bot <id>', 'Use specific bot')
203
+ .option('--pretty', 'Pretty print JSON output')
204
+ .action(async (space: string, parent: string, opts: BotOption & { max?: string }) => {
205
+ cliOutput(await repliesAction(space, parent, opts), opts.pretty)
206
+ }),
207
+ )
142
208
  .addCommand(
143
209
  new Command('dm')
144
210
  .description('Send a direct message by recipient email')
@@ -0,0 +1,60 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { cliOutput } from '@/shared/utils/cli-output'
4
+
5
+ import type { BotOption } from './shared'
6
+ import { getClient } from './shared'
7
+
8
+ interface SnapshotResult {
9
+ bot?: {
10
+ id: string
11
+ displayName: string
12
+ emails: string[]
13
+ }
14
+ spaces?: Array<{
15
+ id: string
16
+ title: string
17
+ type?: 'group' | 'direct'
18
+ lastActivity?: string
19
+ }>
20
+ hint?: string
21
+ error?: string
22
+ }
23
+
24
+ export async function snapshotAction(options: BotOption & { full?: boolean; max?: string }): Promise<SnapshotResult> {
25
+ try {
26
+ const client = await getClient(options)
27
+ const max = options.max ? parseInt(options.max, 10) : 100
28
+
29
+ const [me, spaces] = await Promise.all([client.testAuth(), client.listSpaces({ max })])
30
+
31
+ const result: SnapshotResult = {
32
+ bot: {
33
+ id: me.id,
34
+ displayName: me.displayName,
35
+ emails: me.emails,
36
+ },
37
+ spaces: options.full
38
+ ? spaces.map((s) => ({ id: s.id, title: s.title, type: s.type, lastActivity: s.lastActivity }))
39
+ : spaces.map((s) => ({ id: s.id, title: s.title })),
40
+ }
41
+
42
+ if (!options.full) {
43
+ result.hint = "Use 'message list <space>' for messages, 'space info <space>' for details."
44
+ }
45
+
46
+ return result
47
+ } catch (error) {
48
+ return { error: (error as Error).message }
49
+ }
50
+ }
51
+
52
+ export const snapshotCommand = new Command('snapshot')
53
+ .description('Workspace overview for AI agents (brief by default, --full for details)')
54
+ .option('--full', 'Include full space details')
55
+ .option('--max <n>', 'Number of spaces to retrieve', '100')
56
+ .option('--bot <id>', 'Use specific bot')
57
+ .option('--pretty', 'Pretty print JSON output')
58
+ .action(async (opts: BotOption & { full?: boolean; max?: string }) => {
59
+ cliOutput(await snapshotAction(opts), opts.pretty)
60
+ })
@@ -0,0 +1,77 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+ import { existsSync, rmSync } from 'node:fs'
3
+ import { mkdir } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ const mockListPeople = mock(() =>
8
+ Promise.resolve([
9
+ {
10
+ id: 'p1',
11
+ emails: ['alice@example.com'],
12
+ displayName: 'Alice',
13
+ orgId: 'o1',
14
+ type: 'person' as const,
15
+ created: '',
16
+ },
17
+ ]),
18
+ )
19
+
20
+ const mockGetPerson = mock(() =>
21
+ Promise.resolve({
22
+ id: 'p1',
23
+ emails: ['alice@example.com'],
24
+ displayName: 'Alice',
25
+ orgId: 'o1',
26
+ type: 'person' as const,
27
+ created: '2024-01-01T00:00:00Z',
28
+ }),
29
+ )
30
+
31
+ mock.module('../client', () => ({
32
+ WebexBotClient: class MockWebexBotClient {
33
+ async login(): Promise<this> {
34
+ return this
35
+ }
36
+ listPeople = mockListPeople
37
+ getPerson = mockGetPerson
38
+ },
39
+ }))
40
+
41
+ import { WebexBotCredentialManager } from '../credential-manager'
42
+ import { infoAction, listAction } from './user'
43
+
44
+ describe('webexbot user commands', () => {
45
+ let tempDir: string
46
+ let manager: WebexBotCredentialManager
47
+
48
+ beforeEach(async () => {
49
+ tempDir = join(tmpdir(), `webexbot-user-test-${Date.now()}`)
50
+ await mkdir(tempDir, { recursive: true })
51
+ manager = new WebexBotCredentialManager(tempDir)
52
+ await manager.setCredentials({ token: 'token123', bot_id: 'bot1', bot_name: 'Bot' })
53
+ mockListPeople.mockClear()
54
+ mockGetPerson.mockClear()
55
+ })
56
+
57
+ afterEach(() => {
58
+ if (existsSync(tempDir)) {
59
+ rmSync(tempDir, { recursive: true })
60
+ }
61
+ })
62
+
63
+ it('lists people by email', async () => {
64
+ const result = await listAction({ _credManager: manager, email: 'alice@example.com' })
65
+
66
+ expect(result.users).toHaveLength(1)
67
+ expect(result.users?.[0].displayName).toBe('Alice')
68
+ expect(mockListPeople).toHaveBeenCalled()
69
+ })
70
+
71
+ it('gets a person by id', async () => {
72
+ const result = await infoAction('p1', { _credManager: manager })
73
+
74
+ expect(result.id).toBe('p1')
75
+ expect(result.displayName).toBe('Alice')
76
+ })
77
+ })
@@ -0,0 +1,98 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { cliOutput } from '@/shared/utils/cli-output'
4
+
5
+ import type { WebexPerson } from '../../webex/types'
6
+ import type { BotOption } from './shared'
7
+ import { getClient } from './shared'
8
+
9
+ interface UserResult {
10
+ id?: string
11
+ emails?: string[]
12
+ displayName?: string
13
+ nickName?: string
14
+ firstName?: string
15
+ lastName?: string
16
+ avatar?: string
17
+ orgId?: string
18
+ type?: 'person' | 'bot'
19
+ created?: string
20
+ users?: Array<{
21
+ id: string
22
+ emails: string[]
23
+ displayName: string
24
+ type: 'person' | 'bot'
25
+ }>
26
+ error?: string
27
+ }
28
+
29
+ function formatPerson(person: WebexPerson): UserResult {
30
+ return {
31
+ id: person.id,
32
+ emails: person.emails,
33
+ displayName: person.displayName,
34
+ nickName: person.nickName,
35
+ firstName: person.firstName,
36
+ lastName: person.lastName,
37
+ avatar: person.avatar,
38
+ orgId: person.orgId,
39
+ type: person.type,
40
+ created: person.created,
41
+ }
42
+ }
43
+
44
+ export async function listAction(
45
+ options: BotOption & { email?: string; displayName?: string; max?: string },
46
+ ): Promise<UserResult> {
47
+ try {
48
+ const client = await getClient(options)
49
+ const max = options.max ? parseInt(options.max, 10) : undefined
50
+ const people = await client.listPeople({ email: options.email, displayName: options.displayName, max })
51
+
52
+ return {
53
+ users: people.map((p) => ({
54
+ id: p.id,
55
+ emails: p.emails,
56
+ displayName: p.displayName,
57
+ type: p.type,
58
+ })),
59
+ }
60
+ } catch (error) {
61
+ return { error: (error as Error).message }
62
+ }
63
+ }
64
+
65
+ export async function infoAction(personId: string, options: BotOption): Promise<UserResult> {
66
+ try {
67
+ const client = await getClient(options)
68
+ const person = await client.getPerson(personId)
69
+ return formatPerson(person)
70
+ } catch (error) {
71
+ return { error: (error as Error).message }
72
+ }
73
+ }
74
+
75
+ export const userCommand = new Command('user')
76
+ .description('User commands')
77
+ .addCommand(
78
+ new Command('list')
79
+ .description('Search people by email or display name')
80
+ .option('--email <email>', 'Filter by exact email')
81
+ .option('--display-name <name>', 'Filter by display name prefix')
82
+ .option('--max <n>', 'Number of users to retrieve')
83
+ .option('--bot <id>', 'Use specific bot')
84
+ .option('--pretty', 'Pretty print JSON output')
85
+ .action(async (opts: BotOption & { email?: string; displayName?: string; max?: string }) => {
86
+ cliOutput(await listAction(opts), opts.pretty)
87
+ }),
88
+ )
89
+ .addCommand(
90
+ new Command('info')
91
+ .description('Get details for a person')
92
+ .argument('<id>', 'Person ID')
93
+ .option('--bot <id>', 'Use specific bot')
94
+ .option('--pretty', 'Pretty print JSON output')
95
+ .action(async (personId: string, opts: BotOption) => {
96
+ cliOutput(await infoAction(personId, opts), opts.pretty)
97
+ }),
98
+ )
@@ -1,5 +1,7 @@
1
1
  export { WebexBotClient } from './client'
2
2
  export { WebexBotCredentialManager } from './credential-manager'
3
+ export { fromRestId, toRestId } from '../webex/id-normalizer'
4
+ export type { WebexRestIdType } from '../webex/id-normalizer'
3
5
  export { WebexBotListener } from './listener'
4
6
  export type { WebexBotListenerOptions } from './listener'
5
7
  export type { WebexBotConfig, WebexBotCredentials, WebexBotEntry, WebexBotListenerEventMap } from './types'
@@ -1,234 +1,47 @@
1
1
  import { describe, expect, it, mock } from 'bun:test'
2
- import { EventEmitter } from 'events'
3
-
4
- import type {
5
- DecryptedMessage,
6
- HandlerStatus,
7
- MercuryActivity,
8
- WebexMessageHandlerConfig,
9
- WebexMessageHandlerEvents,
10
- } from 'webex-message-handler'
11
2
 
3
+ import { WebexListener } from '../webex/listener'
12
4
  import { WebexBotListener } from './listener'
13
5
 
14
- const STATUS: HandlerStatus = {
15
- status: 'connected',
16
- webSocketOpen: true,
17
- kmsInitialized: true,
18
- deviceRegistered: true,
19
- reconnectAttempt: 0,
20
- }
21
-
22
- const RAW_ACTIVITY: MercuryActivity = {
23
- id: 'activity-123',
24
- verb: 'post',
25
- actor: { id: 'person-123', objectType: 'person', emailAddress: 'user@example.com' },
26
- object: { id: 'object-123', objectType: 'comment', displayName: 'hello' },
27
- target: { id: 'room-123', objectType: 'conversation' },
28
- published: '2024-01-01T00:00:00Z',
29
- }
30
-
31
- const MESSAGE: DecryptedMessage = {
32
- id: 'message-123',
33
- roomId: 'room-123',
34
- personId: 'person-123',
35
- personEmail: 'user@example.com',
36
- text: 'hello',
37
- created: '2024-01-01T00:00:00Z',
38
- mentionedPeople: [],
39
- mentionedGroups: [],
40
- files: [],
41
- raw: RAW_ACTIVITY,
42
- }
43
-
44
- class FakeWebexMessageHandler extends EventEmitter {
45
- connect = mock(() => Promise.resolve())
46
- disconnect = mock(() => Promise.resolve())
47
- connected = true
48
-
49
- status(): HandlerStatus {
50
- return STATUS
51
- }
52
-
53
- override on<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this {
54
- return super.on(event, listener)
55
- }
56
-
57
- override off<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this {
58
- return super.off(event, listener)
59
- }
60
-
61
- override once<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this {
62
- return super.once(event, listener)
6
+ const httpError = (): Response => new Response('', { status: 500 })
7
+ const missingWdm = (): Response =>
8
+ new Response(JSON.stringify({ serviceLinks: {} }), { status: 200, headers: { 'Content-Type': 'application/json' } })
9
+
10
+ async function withFetch(makeResponse: () => Response, run: () => Promise<void>): Promise<void> {
11
+ const original = globalThis.fetch
12
+ globalThis.fetch = mock(() => Promise.resolve(makeResponse())) as typeof fetch
13
+ try {
14
+ await run()
15
+ } finally {
16
+ globalThis.fetch = original
63
17
  }
64
18
  }
65
19
 
66
20
  describe('WebexBotListener', () => {
67
- it('bridges handler message events and webex_event', async () => {
68
- const handler = new FakeWebexMessageHandler()
69
- const client = { getToken: () => 'token123' }
70
- const listener = new WebexBotListener(client, {
71
- _handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
72
- })
73
- const messageCreated = mock((_event: DecryptedMessage) => undefined)
74
- const webexEvent = mock((_event: DecryptedMessage) => undefined)
75
- listener.on('message_created', messageCreated)
76
- listener.on('webex_event', webexEvent)
77
-
78
- await listener.start()
79
- handler.emit('message:created', MESSAGE)
80
-
81
- expect(messageCreated).toHaveBeenCalledWith(MESSAGE)
82
- expect(webexEvent).toHaveBeenCalledWith(MESSAGE)
83
- })
84
-
85
- it('stop calls handler disconnect', async () => {
86
- const handler = new FakeWebexMessageHandler()
87
- const client = { getToken: () => 'token123' }
88
- const listener = new WebexBotListener(client, {
89
- _handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
90
- })
91
-
92
- await listener.start()
93
- await listener.stop()
94
-
95
- expect(handler.disconnect).toHaveBeenCalled()
96
- })
97
-
98
- it('start is idempotent', async () => {
99
- const handler = new FakeWebexMessageHandler()
100
- const client = { getToken: () => 'token123' }
101
- const listener = new WebexBotListener(client, {
102
- _handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
103
- })
104
-
105
- await listener.start()
106
- await listener.start()
107
-
108
- expect(handler.connect).toHaveBeenCalledTimes(1)
109
- })
110
-
111
- it('start rethrows and resets state when connect fails, allowing retry', async () => {
112
- const failing = new FakeWebexMessageHandler()
113
- failing.connect = mock(() => Promise.reject(new Error('device registration failed')))
114
- const ok = new FakeWebexMessageHandler()
115
- const handlers = [failing, ok]
116
- const client = { getToken: () => 'token123' }
117
- const listener = new WebexBotListener(client, {
118
- _handlerFactory: (_config: WebexMessageHandlerConfig) => handlers.shift()!,
119
- })
120
-
121
- await expect(listener.start()).rejects.toThrow('device registration failed')
122
- expect(failing.disconnect).toHaveBeenCalled()
123
-
124
- await listener.start()
125
- expect(ok.connect).toHaveBeenCalledTimes(1)
126
- })
127
-
128
- it('does not throw when handler emits error with no error listener', async () => {
129
- const handler = new FakeWebexMessageHandler()
130
- const client = { getToken: () => 'token123' }
131
- const listener = new WebexBotListener(client, {
132
- _handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
133
- })
134
- listener.on('message_created', () => undefined)
135
-
136
- await listener.start()
137
-
138
- expect(() => handler.emit('error', new Error('boom'))).not.toThrow()
139
- })
140
-
141
- it('ignores stale handler events after stop', async () => {
142
- const handler = new FakeWebexMessageHandler()
143
- const client = { getToken: () => 'token123' }
144
- const listener = new WebexBotListener(client, {
145
- _handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
146
- })
147
- const messageCreated = mock((_event: DecryptedMessage) => undefined)
148
- listener.on('message_created', messageCreated)
149
-
150
- await listener.start()
151
- await listener.stop()
152
- handler.emit('message:created', MESSAGE)
153
-
154
- expect(messageCreated).not.toHaveBeenCalled()
155
- })
156
-
157
- it('start-stop-start does not cross-talk between handlers', async () => {
158
- const first = new FakeWebexMessageHandler()
159
- const second = new FakeWebexMessageHandler()
160
- const handlers = [first, second]
161
- const client = { getToken: () => 'token123' }
162
- const listener = new WebexBotListener(client, {
163
- _handlerFactory: (_config: WebexMessageHandlerConfig) => handlers.shift()!,
164
- })
165
- const messageCreated = mock((_event: DecryptedMessage) => undefined)
166
- listener.on('message_created', messageCreated)
167
-
168
- await listener.start()
169
- await listener.stop()
170
- await listener.start()
171
-
172
- first.emit('message:created', MESSAGE)
173
- expect(messageCreated).not.toHaveBeenCalled()
174
-
175
- second.emit('message:created', MESSAGE)
176
- expect(messageCreated).toHaveBeenCalledTimes(1)
177
- })
178
-
179
- it('preserves disconnected reason', async () => {
180
- const handler = new FakeWebexMessageHandler()
181
- const client = { getToken: () => 'token123' }
182
- const listener = new WebexBotListener(client, {
183
- _handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
184
- })
185
- const disconnected = mock((_reason: string) => undefined)
186
- listener.on('disconnected', disconnected)
187
-
188
- await listener.start()
189
- handler.emit('disconnected', 'network lost')
190
-
191
- expect(disconnected).toHaveBeenCalledWith('network lost')
192
- })
193
-
194
- it('concurrent start() calls share the same connect failure', async () => {
195
- const handler = new FakeWebexMessageHandler()
196
- handler.connect = mock(() => Promise.reject(new Error('connect failed')))
197
- const client = { getToken: () => 'token123' }
198
- const listener = new WebexBotListener(client, {
199
- _handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
200
- })
201
-
202
- const first = listener.start()
203
- const second = listener.start()
204
- const firstResult = first.then(
205
- () => 'ok',
206
- (e: Error) => e.message,
207
- )
208
- const secondResult = second.then(
209
- () => 'ok',
210
- (e: Error) => e.message,
211
- )
212
-
213
- expect(await firstResult).toBe('connect failed')
214
- expect(await secondResult).toBe('connect failed')
215
- expect(handler.connect).toHaveBeenCalledTimes(1)
216
- })
217
-
218
- it('disconnects a handler whose connect resolves after stop', async () => {
219
- const handler = new FakeWebexMessageHandler()
220
- let resolveConnect: () => void = () => undefined
221
- handler.connect = mock(() => new Promise<void>((resolve) => (resolveConnect = resolve)))
222
- const client = { getToken: () => 'token123' }
223
- const listener = new WebexBotListener(client, {
224
- _handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
21
+ it('is a WebexListener', () => {
22
+ expect(new WebexBotListener({ getToken: () => 'token' })).toBeInstanceOf(WebexListener)
23
+ })
24
+
25
+ for (const [label, makeResponse] of [
26
+ ['HTTP error', httpError],
27
+ ['missing serviceLinks.wdm', missingWdm],
28
+ ] as const) {
29
+ it(`surfaces WDM discovery failures (${label}) as WebexBotError`, async () => {
30
+ await withFetch(makeResponse, async () => {
31
+ await expect(new WebexBotListener({ getToken: () => 'token' }).start()).rejects.toMatchObject({
32
+ name: 'WebexBotError',
33
+ code: 'wdm_discovery_failed',
34
+ })
35
+ })
36
+ })
37
+
38
+ it(`WebexListener surfaces WDM discovery failures (${label}) as WebexError`, async () => {
39
+ await withFetch(makeResponse, async () => {
40
+ await expect(new WebexListener({ getToken: () => 'token' }).start()).rejects.toMatchObject({
41
+ name: 'WebexError',
42
+ code: 'wdm_discovery_failed',
43
+ })
44
+ })
225
45
  })
226
-
227
- const starting = listener.start()
228
- const stopping = listener.stop()
229
- resolveConnect()
230
- await Promise.all([starting, stopping])
231
-
232
- expect(handler.disconnect).toHaveBeenCalled()
233
- })
46
+ }
234
47
  })