agent-messenger 2.6.2 → 2.7.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 (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/webex/client.d.ts +3 -0
  4. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  5. package/dist/src/platforms/webex/client.js +22 -8
  6. package/dist/src/platforms/webex/client.js.map +1 -1
  7. package/dist/src/platforms/webex/commands/snapshot.d.ts +0 -3
  8. package/dist/src/platforms/webex/commands/snapshot.d.ts.map +1 -1
  9. package/dist/src/platforms/webex/commands/snapshot.js +9 -47
  10. package/dist/src/platforms/webex/commands/snapshot.js.map +1 -1
  11. package/dist/src/platforms/webex/markdown-to-html.d.ts +3 -0
  12. package/dist/src/platforms/webex/markdown-to-html.d.ts.map +1 -0
  13. package/dist/src/platforms/webex/markdown-to-html.js +161 -0
  14. package/dist/src/platforms/webex/markdown-to-html.js.map +1 -0
  15. package/docs/content/docs/cli/webex.mdx +3 -11
  16. package/package.json +1 -1
  17. package/skills/agent-channeltalk/SKILL.md +1 -1
  18. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  19. package/skills/agent-discord/SKILL.md +1 -1
  20. package/skills/agent-discordbot/SKILL.md +1 -1
  21. package/skills/agent-instagram/SKILL.md +1 -1
  22. package/skills/agent-kakaotalk/SKILL.md +1 -1
  23. package/skills/agent-line/SKILL.md +1 -1
  24. package/skills/agent-slack/SKILL.md +1 -1
  25. package/skills/agent-slackbot/SKILL.md +1 -1
  26. package/skills/agent-teams/SKILL.md +1 -1
  27. package/skills/agent-telegram/SKILL.md +1 -1
  28. package/skills/agent-webex/SKILL.md +6 -14
  29. package/skills/agent-webex/references/common-patterns.md +3 -32
  30. package/skills/agent-wechatbot/SKILL.md +1 -1
  31. package/skills/agent-whatsapp/SKILL.md +1 -1
  32. package/skills/agent-whatsappbot/SKILL.md +1 -1
  33. package/src/platforms/webex/client.test.ts +35 -0
  34. package/src/platforms/webex/client.ts +26 -9
  35. package/src/platforms/webex/commands/snapshot.test.ts +13 -41
  36. package/src/platforms/webex/commands/snapshot.ts +10 -61
  37. package/src/platforms/webex/markdown-to-html.test.ts +153 -0
  38. package/src/platforms/webex/markdown-to-html.ts +194 -0
  39. package/src/platforms/wechatbot/cli.ts +0 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.6.2
4
+ version: 2.7.0
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -116,7 +116,7 @@ At the **start of every task**, read `~/.config/agent-messenger/MEMORY.md` using
116
116
  After discovering useful information, update `~/.config/agent-messenger/MEMORY.md` using the `Write` tool. Write triggers include:
117
117
 
118
118
  - After discovering space IDs and titles (from `space list`, `snapshot`, etc.)
119
- - After discovering member IDs and names (from `member list`, `snapshot`, etc.)
119
+ - After discovering member IDs and names (from `member list`, etc.)
120
120
  - After the user gives you an alias or preference ("call this the standup space", "my main space is X")
121
121
  - After discovering space structure (group vs direct spaces)
122
122
 
@@ -247,25 +247,17 @@ agent-webex member list <space-id> --limit 100
247
247
 
248
248
  ### Snapshot Command
249
249
 
250
- Get comprehensive workspace state for AI agents:
250
+ Get workspace spaces overview for AI agents:
251
251
 
252
252
  ```bash
253
- # Full snapshot
254
253
  agent-webex snapshot
255
-
256
- # Filtered snapshots
257
- agent-webex snapshot --spaces-only
258
- agent-webex snapshot --members-only
259
-
260
- # Limit messages per space
261
- agent-webex snapshot --limit 10
262
254
  ```
263
255
 
264
256
  Returns JSON with:
265
257
 
266
- - Spaces (id, title, type, created)
267
- - Recent messages (id, text, personEmail, created)
268
- - Members (id, personDisplayName, personEmail)
258
+ - Spaces (id, title, type, lastActivity) — only spaces you're a member of
259
+
260
+ For messages or members, use `message list <space-id>` or `member list <space-id>`.
269
261
 
270
262
  ## Output Format
271
263
 
@@ -323,9 +323,9 @@ echo "Found: $(echo "$MATCH" | jq -r '.personDisplayName') ($(echo "$MATCH" | jq
323
323
 
324
324
  ## Snapshot Patterns
325
325
 
326
- ### Pattern 19: Full Workspace Snapshot
326
+ ### Pattern 19: Workspace Snapshot
327
327
 
328
- **Use case**: Get complete workspace state for AI context
328
+ **Use case**: Get spaces overview for AI context
329
329
 
330
330
  ```bash
331
331
  #!/bin/bash
@@ -339,36 +339,7 @@ echo "Total spaces: $SPACE_COUNT"
339
339
  echo "$SNAPSHOT" | jq -r '.spaces[] | " \(.title) (\(.type))"'
340
340
  ```
341
341
 
342
- ### Pattern 20: Spaces-Only Snapshot
343
-
344
- **Use case**: Quick overview without messages or members
345
-
346
- ```bash
347
- #!/bin/bash
348
-
349
- agent-webex snapshot --spaces-only
350
- ```
351
-
352
- ### Pattern 21: Members-Only Snapshot
353
-
354
- **Use case**: Get member lists across all spaces
355
-
356
- ```bash
357
- #!/bin/bash
358
-
359
- agent-webex snapshot --members-only
360
- ```
361
-
362
- ### Pattern 22: Snapshot with Message Limit
363
-
364
- **Use case**: Control how many messages per space
365
-
366
- ```bash
367
- #!/bin/bash
368
-
369
- # Get snapshot with last 5 messages per space
370
- agent-webex snapshot --limit 5
371
- ```
342
+ **When to use**: Quick workspace overview to discover space IDs and titles. Use `message list <space-id>` or `member list <space-id>` for details.
372
343
 
373
344
  ## Pipeline Patterns
374
345
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.6.2
4
+ version: 2.7.0
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.6.2
4
+ version: 2.7.0
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.6.2
4
+ version: 2.7.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -483,6 +483,29 @@ describe('WebexClient', () => {
483
483
  expect(message.personEmail).toBe('test@example.com')
484
484
  expect(message.created).toBe('2026-01-01T00:00:00.000Z')
485
485
  })
486
+
487
+ test('markdown option converts content to HTML and strips displayName', async () => {
488
+ mockResponse(mockActivity('bold text'))
489
+
490
+ const client = await createExtractedClient()
491
+ await client.sendMessage(TEST_ROOM_ID, '**bold text**', { markdown: true })
492
+
493
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
494
+ expect(body.object.displayName).toBe('bold text')
495
+ expect(body.object.content).toBe('<strong>bold text</strong>')
496
+ expect(body.object.markdown).toBeUndefined()
497
+ })
498
+
499
+ test('markdown option does not affect plain text messages', async () => {
500
+ mockResponse(mockActivity('Hello world'))
501
+
502
+ const client = await createExtractedClient()
503
+ await client.sendMessage(TEST_ROOM_ID, 'Hello world')
504
+
505
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
506
+ expect(body.object.displayName).toBe('Hello world')
507
+ expect(body.object.content).toBe('Hello world')
508
+ })
486
509
  })
487
510
 
488
511
  describe('listMessages', () => {
@@ -637,6 +660,18 @@ describe('WebexClient', () => {
637
660
  const body = JSON.parse(fetchCalls[0].options?.body as string)
638
661
  expect(body.target.id).toBe(TEST_CONV_UUID)
639
662
  })
663
+
664
+ test('markdown option converts content to HTML and strips displayName', async () => {
665
+ mockResponse(mockActivity('italic text'))
666
+
667
+ const client = await createExtractedClient()
668
+ await client.editMessage('activity-123', TEST_ROOM_ID, '_italic text_', { markdown: true })
669
+
670
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
671
+ expect(body.object.displayName).toBe('italic text')
672
+ expect(body.object.content).toBe('<em>italic text</em>')
673
+ expect(body.object.markdown).toBeUndefined()
674
+ })
640
675
  })
641
676
 
642
677
  describe('sendDirectMessage', () => {
@@ -2,6 +2,7 @@ import type { WebexMembership, WebexMessage, WebexPerson, WebexSpace } from './t
2
2
  import { WebexError } from './types'
3
3
  import { WebexCredentialManager } from './credential-manager'
4
4
  import { WebexEncryptionService } from './encryption'
5
+ import { markdownToHtml, stripMarkdown } from './markdown-to-html'
5
6
 
6
7
  const BASE_URL = 'https://webexapis.com/v1'
7
8
  const MAX_RETRIES = 3
@@ -237,7 +238,7 @@ export class WebexClient {
237
238
  }
238
239
 
239
240
  private async activityToMessage(a: InternalActivity, roomId: string): Promise<WebexMessage> {
240
- let text = a.object?.content ?? a.object?.displayName
241
+ let text = a.object?.displayName ?? a.object?.content
241
242
 
242
243
  if (this.encryption && text?.startsWith('eyJ')) {
243
244
  const keyUrl = a.encryptionKeyUrl ?? a.object?.encryptionKeyUrl
@@ -265,10 +266,8 @@ export class WebexClient {
265
266
  text: string,
266
267
  options?: { markdown?: boolean },
267
268
  ): Promise<{ object: Record<string, string>; encryptionKeyUrl?: string }> {
268
- const buildObject = (content: string): Record<string, string> =>
269
- options?.markdown
270
- ? { objectType: 'comment', displayName: content, content, markdown: content }
271
- : { objectType: 'comment', displayName: content, content }
269
+ const displayName = options?.markdown ? stripMarkdown(text) : text
270
+ const content = options?.markdown ? markdownToHtml(text) : text
272
271
 
273
272
  if (this.encryption) {
274
273
  const conv = await this.internalRequest<InternalConversation>(
@@ -276,14 +275,22 @@ export class WebexClient {
276
275
  )
277
276
  const keyUri = conv.defaultActivityEncryptionKeyUrl
278
277
  if (keyUri) {
279
- const encrypted = await this.encryption.encryptText(keyUri, text)
280
- if (encrypted) {
281
- return { object: buildObject(encrypted), encryptionKeyUrl: keyUri }
278
+ const encryptedDisplayName = await this.encryption.encryptText(keyUri, displayName)
279
+ const encryptedContent = await this.encryption.encryptText(keyUri, content)
280
+ if (encryptedDisplayName && encryptedContent) {
281
+ return {
282
+ object: {
283
+ objectType: 'comment',
284
+ displayName: encryptedDisplayName,
285
+ content: encryptedContent,
286
+ },
287
+ encryptionKeyUrl: keyUri,
288
+ }
282
289
  }
283
290
  }
284
291
  }
285
292
 
286
- return { object: buildObject(text) }
293
+ return { object: { objectType: 'comment', displayName, content } }
287
294
  }
288
295
 
289
296
  private async sendMessageInternal(
@@ -438,6 +445,16 @@ export class WebexClient {
438
445
  return data.items
439
446
  }
440
447
 
448
+ async listMyMemberships(options?: { max?: number }): Promise<WebexMembership[]> {
449
+ const params = new URLSearchParams()
450
+ params.set('max', String(options?.max ?? 100))
451
+ const data = await this.request<{ items: WebexMembership[] }>(
452
+ 'GET',
453
+ `/memberships?${params}`,
454
+ )
455
+ return data.items
456
+ }
457
+
441
458
  async listMemberships(
442
459
  roomId: string,
443
460
  options?: { max?: number },
@@ -11,24 +11,19 @@ mock.module('@/shared/utils/error-handler', () => ({
11
11
 
12
12
  const mockSpaces = [
13
13
  { id: 'space-1', title: 'General', type: 'group', isLocked: false, lastActivity: '2024-01-15T00:00:00.000Z', created: '2024-01-01T00:00:00.000Z', creatorId: 'person-1' },
14
+ { id: 'space-2', title: 'Random', type: 'group', isLocked: false, lastActivity: '2024-01-14T00:00:00.000Z', created: '2024-01-01T00:00:00.000Z', creatorId: 'person-1' },
14
15
  ]
15
16
 
16
- const mockMessages = [
17
- { id: 'msg-1', roomId: 'space-1', roomType: 'group', text: 'Hello', personId: 'person-1', personEmail: 'alice@example.com', created: '2024-01-15T00:00:00.000Z' },
18
- ]
19
-
20
- const mockMembers = [
17
+ const mockMyMemberships = [
21
18
  { id: 'mem-1', roomId: 'space-1', personId: 'person-1', personEmail: 'alice@example.com', personDisplayName: 'Alice', isModerator: true, created: '2024-01-01T00:00:00.000Z' },
22
19
  ]
23
20
 
24
21
  const mockListSpaces = mock(() => Promise.resolve(mockSpaces as any))
25
- const mockListMessages = mock(() => Promise.resolve(mockMessages as any))
26
- const mockListMemberships = mock(() => Promise.resolve(mockMembers as any))
22
+ const mockListMyMemberships = mock(() => Promise.resolve(mockMyMemberships as any))
27
23
 
28
24
  const mockClient = {
29
25
  listSpaces: mockListSpaces,
30
- listMessages: mockListMessages,
31
- listMemberships: mockListMemberships,
26
+ listMyMemberships: mockListMyMemberships,
32
27
  }
33
28
 
34
29
  const mockLogin = mock(() => Promise.resolve(mockClient))
@@ -46,8 +41,7 @@ describe('snapshot command', () => {
46
41
 
47
42
  beforeEach(() => {
48
43
  mockListSpaces.mockReset().mockImplementation(() => Promise.resolve(mockSpaces as any))
49
- mockListMessages.mockReset().mockImplementation(() => Promise.resolve(mockMessages as any))
50
- mockListMemberships.mockReset().mockImplementation(() => Promise.resolve(mockMembers as any))
44
+ mockListMyMemberships.mockReset().mockImplementation(() => Promise.resolve(mockMyMemberships as any))
51
45
  mockLogin.mockReset().mockImplementation(() => Promise.resolve(mockClient))
52
46
  mockHandleError.mockReset().mockImplementation((err: Error) => {
53
47
  throw err
@@ -60,40 +54,24 @@ describe('snapshot command', () => {
60
54
  consoleSpy.mockRestore()
61
55
  })
62
56
 
63
- test('full snapshot includes spaces, recent_messages, members', async () => {
57
+ test('returns spaces with id, title, type, lastActivity', async () => {
64
58
  await snapshotAction({})
65
59
 
66
60
  expect(consoleSpy).toHaveBeenCalled()
67
61
  const output = JSON.parse(consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0])
68
- expect(output.spaces).toBeDefined()
62
+ expect(output.spaces).toHaveLength(1)
69
63
  expect(output.spaces[0].id).toBe('space-1')
70
64
  expect(output.spaces[0].title).toBe('General')
71
- expect(output.recent_messages).toBeDefined()
72
- expect(output.recent_messages[0].id).toBe('msg-1')
73
- expect(output.recent_messages[0].author).toBe('alice@example.com')
74
- expect(output.members).toBeDefined()
75
- expect(output.members[0].personEmail).toBe('alice@example.com')
76
- })
77
-
78
- test('--spaces-only includes only spaces (no messages, no members)', async () => {
79
- await snapshotAction({ spacesOnly: true })
80
-
81
- expect(consoleSpy).toHaveBeenCalled()
82
- const output = JSON.parse(consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0])
83
- expect(output.spaces).toBeDefined()
84
- expect(output.recent_messages).toBeUndefined()
85
- expect(output.members).toBeUndefined()
65
+ expect(output.spaces[0].type).toBe('group')
66
+ expect(output.spaces[0].lastActivity).toBe('2024-01-15T00:00:00.000Z')
86
67
  })
87
68
 
88
- test('--members-only includes only members (no spaces, no messages)', async () => {
89
- await snapshotAction({ membersOnly: true })
69
+ test('filters spaces to only those in my memberships', async () => {
70
+ await snapshotAction({})
90
71
 
91
- expect(consoleSpy).toHaveBeenCalled()
92
72
  const output = JSON.parse(consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0])
93
- expect(output.spaces).toBeUndefined()
94
- expect(output.recent_messages).toBeUndefined()
95
- expect(output.members).toBeDefined()
96
- expect(output.members[0].personEmail).toBe('alice@example.com')
73
+ expect(output.spaces).toHaveLength(1)
74
+ expect(output.spaces[0].id).toBe('space-1')
97
75
  })
98
76
 
99
77
  test('not authenticated outputs error', async () => {
@@ -105,10 +83,4 @@ describe('snapshot command', () => {
105
83
 
106
84
  expect(mockHandleError).toHaveBeenCalledWith(expect.any(WebexError))
107
85
  })
108
-
109
- test('passes limit option to listMessages', async () => {
110
- await snapshotAction({ limit: 5 })
111
-
112
- expect(mockListMessages).toHaveBeenCalledWith('space-1', { max: 5 })
113
- })
114
86
  })
@@ -1,72 +1,27 @@
1
1
  import { Command } from 'commander'
2
- import { parallelMap } from '@/shared/utils/concurrency'
3
2
  import { handleError } from '@/shared/utils/error-handler'
4
3
  import { formatOutput } from '@/shared/utils/output'
5
4
  import { WebexClient } from '../client'
6
- import type { WebexSpace } from '../types'
7
5
 
8
6
  export async function snapshotAction(options: {
9
- spacesOnly?: boolean
10
- membersOnly?: boolean
11
- limit?: number
12
7
  pretty?: boolean
13
8
  }): Promise<void> {
14
9
  try {
15
10
  const client = await new WebexClient().login()
16
- const messageLimit = options.limit || 20
17
- const snapshot: Record<string, unknown> = {}
18
11
 
19
- if (!options.membersOnly) {
20
- const spaces = await client.listSpaces({ max: 50 })
21
- snapshot.spaces = spaces.map((s) => ({
12
+ const myMemberships = await client.listMyMemberships({ max: 100 })
13
+ const myRoomIds = new Set(myMemberships.map((m) => m.roomId))
14
+
15
+ const allSpaces = await client.listSpaces({ max: 100 })
16
+ const spaces = allSpaces.filter((s) => myRoomIds.has(s.id))
17
+
18
+ const snapshot = {
19
+ spaces: spaces.map((s) => ({
22
20
  id: s.id,
23
21
  title: s.title,
24
22
  type: s.type,
25
23
  lastActivity: s.lastActivity,
26
- }))
27
-
28
- if (!options.spacesOnly) {
29
- const spaceMessages = await parallelMap(
30
- spaces,
31
- async (space: WebexSpace) => {
32
- const messages = await client.listMessages(space.id, { max: messageLimit })
33
- return messages.map((msg) => ({
34
- ...msg,
35
- space_title: space.title,
36
- }))
37
- },
38
- 5,
39
- )
40
-
41
- snapshot.recent_messages = spaceMessages.flat().map((msg) => ({
42
- space_id: msg.roomId,
43
- space_title: msg.space_title,
44
- id: msg.id,
45
- author: msg.personEmail,
46
- text: msg.text || msg.markdown || '',
47
- created: msg.created,
48
- }))
49
- }
50
- }
51
-
52
- if (!options.spacesOnly) {
53
- // Get members for the first few spaces (avoid massive API calls)
54
- const spaces = await client.listSpaces({ max: 10 })
55
- const spaceMembers = await parallelMap(
56
- spaces,
57
- async (space: WebexSpace) => {
58
- const members = await client.listMemberships(space.id, { max: 100 })
59
- return members.map((m) => ({
60
- space_id: space.id,
61
- space_title: space.title,
62
- personEmail: m.personEmail,
63
- personDisplayName: m.personDisplayName,
64
- isModerator: m.isModerator,
65
- }))
66
- },
67
- 5,
68
- )
69
- snapshot.members = spaceMembers.flat()
24
+ })),
70
25
  }
71
26
 
72
27
  console.log(formatOutput(snapshot, options.pretty))
@@ -76,16 +31,10 @@ export async function snapshotAction(options: {
76
31
  }
77
32
 
78
33
  export const snapshotCommand = new Command('snapshot')
79
- .description('Get comprehensive workspace state for AI agents')
80
- .option('--spaces-only', 'Include only spaces (exclude messages and members)')
81
- .option('--members-only', 'Include only members (exclude spaces and messages)')
82
- .option('--limit <n>', 'Number of recent messages per space (default: 20)', '20')
34
+ .description('Get workspace spaces overview for AI agents')
83
35
  .option('--pretty', 'Pretty print JSON output')
84
36
  .action(async (options) => {
85
37
  await snapshotAction({
86
- spacesOnly: options.spacesOnly,
87
- membersOnly: options.membersOnly,
88
- limit: parseInt(options.limit, 10),
89
38
  pretty: options.pretty,
90
39
  })
91
40
  })
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { markdownToHtml, stripMarkdown } from './markdown-to-html'
4
+
5
+ describe('markdownToHtml', () => {
6
+ test('converts bold text', () => {
7
+ expect(markdownToHtml('**bold**')).toBe('<strong>bold</strong>')
8
+ })
9
+
10
+ test('converts italic text', () => {
11
+ expect(markdownToHtml('_italic_')).toBe('<em>italic</em>')
12
+ })
13
+
14
+ test('does not italicize mid-word underscores', () => {
15
+ expect(markdownToHtml('some_variable_name')).toBe('some_variable_name')
16
+ })
17
+
18
+ test('converts bold and italic text', () => {
19
+ expect(markdownToHtml('***both***')).toBe('<strong><em>both</em></strong>')
20
+ })
21
+
22
+ test('converts inline code', () => {
23
+ expect(markdownToHtml('Use `code` here')).toBe('Use <code>code</code> here')
24
+ })
25
+
26
+ test('converts code blocks with language', () => {
27
+ expect(markdownToHtml('```ts\nconst x = 1 < 2\n```')).toBe(
28
+ '<pre><code class="language-ts">const x = 1 &lt; 2</code></pre>',
29
+ )
30
+ })
31
+
32
+ test('converts code blocks without language', () => {
33
+ expect(markdownToHtml('```\nconst x = 1 & 2\n```')).toBe(
34
+ '<pre><code>const x = 1 &amp; 2</code></pre>',
35
+ )
36
+ })
37
+
38
+ test('does not process markdown inside code blocks', () => {
39
+ expect(markdownToHtml('```\n**bold** _italic_\n```')).toBe(
40
+ '<pre><code>**bold** _italic_</code></pre>',
41
+ )
42
+ })
43
+
44
+ test('converts links', () => {
45
+ expect(markdownToHtml('[Webex](https://example.com?a=1&b=2)')).toBe(
46
+ '<a href="https://example.com?a=1&amp;b=2">Webex</a>',
47
+ )
48
+ })
49
+
50
+ test('strips unsafe javascript: URLs to plain text', () => {
51
+ expect(markdownToHtml('[click](javascript:void)')).toBe('click')
52
+ })
53
+
54
+ test('strips unsafe data: URLs to plain text', () => {
55
+ expect(markdownToHtml('[x](data:text/html,payload)')).toBe('x')
56
+ })
57
+
58
+ test('allows mailto: links', () => {
59
+ expect(markdownToHtml('[email](mailto:a@b.com)')).toBe(
60
+ '<a href="mailto:a@b.com">email</a>',
61
+ )
62
+ })
63
+
64
+ test('escapes quotes in URLs to prevent attribute breakout', () => {
65
+ expect(markdownToHtml('[x](https://a.com?q="test")')).toBe(
66
+ '<a href="https://a.com?q=&quot;test&quot;">x</a>',
67
+ )
68
+ })
69
+
70
+ test('converts unordered lists', () => {
71
+ expect(markdownToHtml('- one\n- two')).toBe('<ul><li>one</li><li>two</li></ul>')
72
+ })
73
+
74
+ test('converts ordered lists', () => {
75
+ expect(markdownToHtml('1. one\n2. two')).toBe('<ol><li>one</li><li>two</li></ol>')
76
+ })
77
+
78
+ test('converts blockquotes', () => {
79
+ expect(markdownToHtml('> one\n> two')).toBe('<blockquote>one<br/>two</blockquote>')
80
+ })
81
+
82
+ test('converts headings', () => {
83
+ expect(markdownToHtml('# one\n###### six')).toBe('<h1>one</h1><br/><br/><h6>six</h6>')
84
+ })
85
+
86
+ test('converts horizontal rules', () => {
87
+ expect(markdownToHtml('before\n---\nafter')).toBe('before<br/><br/><hr><br/><br/>after')
88
+ })
89
+
90
+ test('converts paragraph newlines to br', () => {
91
+ expect(markdownToHtml('one\ntwo')).toBe('one<br/>two')
92
+ })
93
+
94
+ test('supports nested formatting', () => {
95
+ expect(markdownToHtml('**bold _and italic_**')).toBe(
96
+ '<strong>bold <em>and italic</em></strong>',
97
+ )
98
+ })
99
+
100
+ test('escapes html special characters in text', () => {
101
+ expect(markdownToHtml('5 < 7 & 8 > 3')).toBe('5 &lt; 7 &amp; 8 &gt; 3')
102
+ })
103
+
104
+ test('separates multiple paragraphs with br', () => {
105
+ expect(markdownToHtml('first\n\nsecond')).toBe('first<br/><br/>second')
106
+ })
107
+
108
+ test('renders mixed content', () => {
109
+ expect(markdownToHtml('Hello **team**\n\n- one\n- two\n\n```js\nconst x = 1\n```')).toBe(
110
+ 'Hello <strong>team</strong><br/><br/><ul><li>one</li><li>two</li></ul><br/><br/><pre><code class="language-js">const x = 1</code></pre>',
111
+ )
112
+ })
113
+
114
+ test('returns empty string for empty input', () => {
115
+ expect(markdownToHtml('')).toBe('')
116
+ })
117
+
118
+ test('returns plain text when there is no markdown', () => {
119
+ expect(markdownToHtml('hello world')).toBe('hello world')
120
+ })
121
+
122
+ test('preserves whitespace-only input', () => {
123
+ expect(markdownToHtml(' ')).toBe(' ')
124
+ })
125
+ })
126
+
127
+ describe('stripMarkdown', () => {
128
+ test('strips inline markdown syntax', () => {
129
+ expect(stripMarkdown('**bold** _italic_ `code`')).toBe('bold italic code')
130
+ })
131
+
132
+ test('strips links to their labels', () => {
133
+ expect(stripMarkdown('[Webex](https://example.com)')).toBe('Webex')
134
+ })
135
+
136
+ test('strips block markdown syntax', () => {
137
+ expect(stripMarkdown('# Title\n> quote\n- item\n1. first\n---')).toBe(
138
+ 'Title\nquote\nitem\nfirst\n',
139
+ )
140
+ })
141
+
142
+ test('keeps code block content', () => {
143
+ expect(stripMarkdown('```ts\nconst x = 1\n```')).toBe('const x = 1')
144
+ })
145
+
146
+ test('handles nested formatting', () => {
147
+ expect(stripMarkdown('**bold _and italic_**')).toBe('bold and italic')
148
+ })
149
+
150
+ test('returns plain text unchanged', () => {
151
+ expect(stripMarkdown('hello world')).toBe('hello world')
152
+ })
153
+ })