agent-messenger 2.38.0 → 2.39.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 (115) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/discordbot/client-message.d.ts +15 -0
  4. package/dist/src/platforms/discordbot/client-message.d.ts.map +1 -0
  5. package/dist/src/platforms/discordbot/client-message.js +70 -0
  6. package/dist/src/platforms/discordbot/client-message.js.map +1 -0
  7. package/dist/src/platforms/discordbot/client-threads.d.ts +10 -0
  8. package/dist/src/platforms/discordbot/client-threads.d.ts.map +1 -0
  9. package/dist/src/platforms/discordbot/client-threads.js +31 -0
  10. package/dist/src/platforms/discordbot/client-threads.js.map +1 -0
  11. package/dist/src/platforms/discordbot/client.d.ts +14 -2
  12. package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
  13. package/dist/src/platforms/discordbot/client.js +21 -28
  14. package/dist/src/platforms/discordbot/client.js.map +1 -1
  15. package/dist/src/platforms/discordbot/commands/file.d.ts +14 -10
  16. package/dist/src/platforms/discordbot/commands/file.d.ts.map +1 -1
  17. package/dist/src/platforms/discordbot/commands/file.js +58 -24
  18. package/dist/src/platforms/discordbot/commands/file.js.map +1 -1
  19. package/dist/src/platforms/discordbot/commands/message.d.ts +3 -9
  20. package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
  21. package/dist/src/platforms/discordbot/commands/message.js +9 -51
  22. package/dist/src/platforms/discordbot/commands/message.js.map +1 -1
  23. package/dist/src/platforms/discordbot/commands/reaction.d.ts +9 -0
  24. package/dist/src/platforms/discordbot/commands/reaction.d.ts.map +1 -1
  25. package/dist/src/platforms/discordbot/commands/reaction.js +39 -0
  26. package/dist/src/platforms/discordbot/commands/reaction.js.map +1 -1
  27. package/dist/src/platforms/discordbot/commands/shared.d.ts +36 -0
  28. package/dist/src/platforms/discordbot/commands/shared.d.ts.map +1 -1
  29. package/dist/src/platforms/discordbot/commands/shared.js +36 -0
  30. package/dist/src/platforms/discordbot/commands/shared.js.map +1 -1
  31. package/dist/src/platforms/discordbot/commands/thread.d.ts +14 -0
  32. package/dist/src/platforms/discordbot/commands/thread.d.ts.map +1 -1
  33. package/dist/src/platforms/discordbot/commands/thread.js +40 -0
  34. package/dist/src/platforms/discordbot/commands/thread.js.map +1 -1
  35. package/dist/src/platforms/discordbot/index.d.ts +1 -1
  36. package/dist/src/platforms/discordbot/index.d.ts.map +1 -1
  37. package/dist/src/platforms/discordbot/index.js.map +1 -1
  38. package/dist/src/platforms/discordbot/types.d.ts +68 -17
  39. package/dist/src/platforms/discordbot/types.d.ts.map +1 -1
  40. package/dist/src/platforms/discordbot/types.js +20 -16
  41. package/dist/src/platforms/discordbot/types.js.map +1 -1
  42. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  43. package/dist/src/platforms/kakaotalk/client.js +4 -32
  44. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  45. package/dist/src/platforms/kakaotalk/sync-state-store.d.ts +11 -0
  46. package/dist/src/platforms/kakaotalk/sync-state-store.d.ts.map +1 -0
  47. package/dist/src/platforms/kakaotalk/sync-state-store.js +89 -0
  48. package/dist/src/platforms/kakaotalk/sync-state-store.js.map +1 -0
  49. package/dist/src/platforms/slack/cli.d.ts.map +1 -1
  50. package/dist/src/platforms/slack/cli.js +5 -0
  51. package/dist/src/platforms/slack/cli.js.map +1 -1
  52. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  53. package/dist/src/platforms/slack/commands/auth.js +9 -3
  54. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  55. package/dist/src/platforms/slack/credential-manager.d.ts +2 -0
  56. package/dist/src/platforms/slack/credential-manager.d.ts.map +1 -1
  57. package/dist/src/platforms/slack/credential-manager.js +17 -3
  58. package/dist/src/platforms/slack/credential-manager.js.map +1 -1
  59. package/docs/content/docs/cli/discordbot.mdx +60 -27
  60. package/docs/content/docs/cli/slack.mdx +8 -1
  61. package/docs/content/docs/sdk/discordbot.mdx +101 -3
  62. package/e2e/README.md +3 -1
  63. package/e2e/config.ts +1 -1
  64. package/e2e/discordbot.e2e.test.ts +206 -11
  65. package/package.json +1 -1
  66. package/skills/agent-channeltalk/SKILL.md +1 -1
  67. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  68. package/skills/agent-discord/SKILL.md +1 -1
  69. package/skills/agent-discordbot/SKILL.md +57 -11
  70. package/skills/agent-discordbot/references/authentication.md +13 -11
  71. package/skills/agent-discordbot/references/common-patterns.md +36 -8
  72. package/skills/agent-imessage/SKILL.md +1 -1
  73. package/skills/agent-instagram/SKILL.md +1 -1
  74. package/skills/agent-kakaotalk/SKILL.md +1 -1
  75. package/skills/agent-line/SKILL.md +1 -1
  76. package/skills/agent-slack/SKILL.md +9 -2
  77. package/skills/agent-slack/references/authentication.md +12 -0
  78. package/skills/agent-slack/references/common-patterns.md +18 -0
  79. package/skills/agent-slackbot/SKILL.md +1 -1
  80. package/skills/agent-teams/SKILL.md +1 -1
  81. package/skills/agent-telegram/SKILL.md +1 -1
  82. package/skills/agent-telegrambot/SKILL.md +1 -1
  83. package/skills/agent-webex/SKILL.md +1 -1
  84. package/skills/agent-webexbot/SKILL.md +1 -1
  85. package/skills/agent-wechatbot/SKILL.md +1 -1
  86. package/skills/agent-whatsapp/SKILL.md +1 -1
  87. package/skills/agent-whatsappbot/SKILL.md +1 -1
  88. package/src/platforms/discordbot/client-message.ts +91 -0
  89. package/src/platforms/discordbot/client-threads.test.ts +177 -0
  90. package/src/platforms/discordbot/client-threads.ts +69 -0
  91. package/src/platforms/discordbot/client.test.ts +277 -1
  92. package/src/platforms/discordbot/client.ts +60 -40
  93. package/src/platforms/discordbot/commands/file.test.ts +242 -90
  94. package/src/platforms/discordbot/commands/file.ts +78 -36
  95. package/src/platforms/discordbot/commands/message.test.ts +183 -26
  96. package/src/platforms/discordbot/commands/message.ts +13 -61
  97. package/src/platforms/discordbot/commands/reaction.test.ts +73 -1
  98. package/src/platforms/discordbot/commands/reaction.ts +46 -0
  99. package/src/platforms/discordbot/commands/shared.test.ts +98 -0
  100. package/src/platforms/discordbot/commands/shared.ts +67 -0
  101. package/src/platforms/discordbot/commands/thread.test.ts +58 -1
  102. package/src/platforms/discordbot/commands/thread.ts +58 -0
  103. package/src/platforms/discordbot/index.ts +3 -0
  104. package/src/platforms/discordbot/types.test.ts +46 -0
  105. package/src/platforms/discordbot/types.ts +44 -19
  106. package/src/platforms/kakaotalk/client-listener-integration.test.ts +21 -3
  107. package/src/platforms/kakaotalk/client.test.ts +42 -4
  108. package/src/platforms/kakaotalk/client.ts +4 -39
  109. package/src/platforms/kakaotalk/sync-state-store.test.ts +197 -0
  110. package/src/platforms/kakaotalk/sync-state-store.ts +99 -0
  111. package/src/platforms/slack/cli.test.ts +111 -1
  112. package/src/platforms/slack/cli.ts +5 -0
  113. package/src/platforms/slack/commands/auth.ts +14 -3
  114. package/src/platforms/slack/credential-manager.ts +21 -3
  115. package/src/platforms/slack/credential-manager.workspace-select.test.ts +70 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slack
3
3
  description: Interact with Slack workspaces - send messages, read channels, manage reactions
4
- version: 2.38.0
4
+ version: 2.39.0
5
5
  allowed-tools: Bash(agent-slack:*)
6
6
  metadata:
7
7
  openclaw:
@@ -74,8 +74,14 @@ agent-slack workspace remove <workspace-id>
74
74
 
75
75
  # Check auth status
76
76
  agent-slack auth status
77
+
78
+ # Run one command against another workspace without switching
79
+ agent-slack --workspace <workspace-id> channel list
80
+ agent-slack --workspace <workspace-id> auth status
77
81
  ```
78
82
 
83
+ The global `--workspace <id>` flag goes before the command and applies to every command that uses a stored workspace, including `auth status` and `auth logout`. It targets that workspace for this one invocation only and does not change the current workspace. If the id is not stored, `auth status` fails with `Workspace not found: <id>`. `auth extract` and `auth qr` discover workspaces instead of targeting one, so the flag does not affect them; they set the current workspace only when none is set yet.
84
+
79
85
  ## Memory
80
86
 
81
87
  The agent maintains a `~/.config/agent-messenger/MEMORY.md` file as persistent memory across sessions. This is agent-managed — the CLI does not read or write this file. Use the `Read` and `Write` tools to manage your memory file.
@@ -171,9 +177,10 @@ agent-slack auth qr --debug # show each redirect hop for troubleshooting
171
177
  # Check auth status
172
178
  agent-slack auth status
173
179
 
174
- # Logout from a workspace (defaults to current)
180
+ # Logout from a workspace (defaults to the --workspace selection, then current)
175
181
  agent-slack auth logout
176
182
  agent-slack auth logout <workspace-id>
183
+ agent-slack --workspace <workspace-id> auth logout
177
184
  ```
178
185
 
179
186
  ### Whoami Command
@@ -152,6 +152,18 @@ agent-slack workspace switch T789012
152
152
 
153
153
  All subsequent commands will use the selected workspace until you switch again.
154
154
 
155
+ ### Use a Workspace for One Command
156
+
157
+ Pass the global `--workspace <id>` flag before the command to target another workspace for a single invocation without switching:
158
+
159
+ ```bash
160
+ agent-slack --workspace T789012 channel list
161
+ agent-slack --workspace T789012 auth status
162
+ agent-slack --workspace T789012 auth logout
163
+ ```
164
+
165
+ The flag applies to every command that uses a stored workspace, including `auth status` and `auth logout`, and does not change `current_workspace`. `auth extract` and `auth qr` discover workspaces instead of targeting one, so the flag does not affect them; they set `current_workspace` only when none is set yet. `auth logout` removes the selected workspace when no positional id is given (a positional id still wins), and `auth status` fails with `Workspace not found: <id>` if the selected id is not stored.
166
+
155
167
  ### Current Workspace
156
168
 
157
169
  Check which workspace is active:
@@ -612,6 +612,24 @@ agent-slack reminder complete Rm001
612
612
  agent-slack reminder delete Rm002
613
613
  ```
614
614
 
615
+ ## Pattern: Work Across Workspaces
616
+
617
+ **Use case**: Read or act in a second workspace without changing the current one
618
+
619
+ ```bash
620
+ #!/bin/bash
621
+
622
+ # The global --workspace flag targets one workspace for a single command
623
+ agent-slack --workspace T789012 auth status
624
+ agent-slack --workspace T789012 channel list
625
+ agent-slack --workspace T789012 message send general "Cross-workspace update"
626
+
627
+ # The current workspace is unchanged afterwards
628
+ agent-slack workspace current
629
+ ```
630
+
631
+ **When to use**: Occasional commands in another workspace. Use `workspace switch` only when the default should change for every later command.
632
+
615
633
  ## See Also
616
634
 
617
635
  - [Authentication Guide](authentication.md) - Setting up credentials
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slackbot
3
3
  description: Interact with Slack workspaces using bot tokens - send messages, read channels, manage reactions
4
- version: 2.38.0
4
+ version: 2.39.0
5
5
  allowed-tools: Bash(agent-slackbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-teams
3
3
  description: Interact with Microsoft Teams - send messages, read channels, manage reactions
4
- version: 2.38.0
4
+ version: 2.39.0
5
5
  allowed-tools: Bash(agent-teams:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegram
3
3
  description: Interact with Telegram through TDLib - authenticate, inspect chats, and send messages
4
- version: 2.38.0
4
+ version: 2.39.0
5
5
  allowed-tools: Bash(agent-telegram:*)
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegrambot
3
3
  description: Interact with Telegram using bot tokens - send messages, read chats, manage reactions
4
- version: 2.38.0
4
+ version: 2.39.0
5
5
  allowed-tools: Bash(agent-telegrambot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -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.38.0
4
+ version: 2.39.0
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webexbot
3
3
  description: Interact with Cisco Webex using bot tokens - send messages, reply in threads, upload and download files, look up people, read spaces, manage memberships, stream real-time events
4
- version: 2.38.0
4
+ version: 2.39.0
5
5
  allowed-tools: Bash(agent-webexbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -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.38.0
4
+ version: 2.39.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.38.0
4
+ version: 2.39.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.38.0
4
+ version: 2.39.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -0,0 +1,91 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ import type { DiscordCreateMessageOptions, DiscordFile, DiscordMessage } from './types'
4
+ import { DiscordBotError } from './types'
5
+
6
+ type Request = <T>(method: string, path: string, body?: unknown) => Promise<T>
7
+ type RequestFormData = <T>(path: string, formData: FormData) => Promise<T>
8
+
9
+ const MESSAGE_PAGE_LIMIT = 100
10
+ export const FILE_LOOKUP_MAX_PAGES = 10
11
+
12
+ export async function createMessage(
13
+ request: Request,
14
+ requestFormData: RequestFormData,
15
+ channelId: string,
16
+ options: DiscordCreateMessageOptions,
17
+ ): Promise<DiscordMessage> {
18
+ const files = options.files ?? []
19
+ if ((options.content === undefined || options.content.trim() === '') && files.length === 0) {
20
+ throw new DiscordBotError('Message must have content or at least one file', 'empty_message')
21
+ }
22
+ if (files.length > 10) {
23
+ throw new DiscordBotError('Discord allows at most 10 files per message', 'too_many_files')
24
+ }
25
+
26
+ const partNames = files.map((file) => {
27
+ if (
28
+ file.filename !== undefined &&
29
+ (file.filename === '' || file.filename === '.' || file.filename === '..' || /[\\/]/.test(file.filename))
30
+ ) {
31
+ throw new DiscordBotError('Invalid filename', 'invalid_filename')
32
+ }
33
+ return (file.filename ?? file.path).replaceAll('\\', '/').split('/').pop() || 'file'
34
+ })
35
+ const target = options.thread_id ?? channelId
36
+ const payload: Record<string, unknown> = {}
37
+
38
+ if (options.content !== undefined) payload.content = options.content
39
+ if (options.reply_to !== undefined) payload.message_reference = { message_id: options.reply_to }
40
+
41
+ if (files.length === 0) return request<DiscordMessage>('POST', `/channels/${target}/messages`, payload)
42
+
43
+ const formData = new FormData()
44
+ formData.append(
45
+ 'payload_json',
46
+ JSON.stringify({ ...payload, attachments: files.map((_, index) => ({ id: index, filename: partNames[index] })) }),
47
+ )
48
+ for (const [index, file] of files.entries()) {
49
+ formData.append(`files[${index}]`, new Blob([await readFile(file.path)]), partNames[index])
50
+ }
51
+ return requestFormData<DiscordMessage>(`/channels/${target}/messages`, formData)
52
+ }
53
+
54
+ export async function uploadFile(
55
+ create: (channelId: string, options: DiscordCreateMessageOptions) => Promise<DiscordMessage>,
56
+ channelId: string,
57
+ filePath: string,
58
+ options?: { content?: string; filename?: string; reply_to?: string; thread_id?: string },
59
+ ): Promise<DiscordFile> {
60
+ const message = await create(channelId, {
61
+ content: options?.content,
62
+ files: [{ path: filePath, filename: options?.filename }],
63
+ reply_to: options?.reply_to,
64
+ thread_id: options?.thread_id,
65
+ })
66
+ const first = message.attachments?.[0]
67
+ if (!first) throw new DiscordBotError('Upload succeeded but no attachments returned', 'no_attachments')
68
+ return first
69
+ }
70
+
71
+ export async function listFiles(request: Request, channelId: string): Promise<DiscordFile[]> {
72
+ const messages = await request<DiscordMessage[]>('GET', `/channels/${channelId}/messages?limit=${MESSAGE_PAGE_LIMIT}`)
73
+ return messages.flatMap((message) => message.attachments ?? [])
74
+ }
75
+
76
+ export async function findFile(request: Request, channelId: string, fileId: string): Promise<DiscordFile | undefined> {
77
+ let before: string | undefined
78
+ for (let page = 0; page < FILE_LOOKUP_MAX_PAGES; page += 1) {
79
+ const query = before
80
+ ? `?limit=${MESSAGE_PAGE_LIMIT}&before=${encodeURIComponent(before)}`
81
+ : `?limit=${MESSAGE_PAGE_LIMIT}`
82
+ const messages = await request<DiscordMessage[]>('GET', `/channels/${channelId}/messages${query}`)
83
+ const found = messages.flatMap((message) => message.attachments ?? []).find((file) => file.id === fileId)
84
+ if (found) return found
85
+ if (messages.length < MESSAGE_PAGE_LIMIT) return undefined
86
+ const oldest = messages[messages.length - 1]?.id
87
+ if (!oldest || oldest === before) return undefined
88
+ before = oldest
89
+ }
90
+ return undefined
91
+ }
@@ -0,0 +1,177 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+
3
+ import { DiscordBotClient } from './client'
4
+ import { listThreads, resolveThread } from './client-threads'
5
+ import type { DiscordChannel } from './types'
6
+
7
+ interface FetchCall {
8
+ url: string
9
+ options?: RequestInit
10
+ }
11
+
12
+ describe('Discord thread client helpers', () => {
13
+ const originalFetch = globalThis.fetch
14
+ let fetchCalls: FetchCall[] = []
15
+ let fetchResponses: Response[] = []
16
+ let fetchIndex = 0
17
+
18
+ beforeEach(() => {
19
+ fetchCalls = []
20
+ fetchResponses = []
21
+ fetchIndex = 0
22
+ ;(globalThis as Record<string, unknown>).fetch = async (
23
+ url: string | URL | Request,
24
+ options?: RequestInit,
25
+ ): Promise<Response> => {
26
+ fetchCalls.push({ url: url.toString(), options })
27
+ const response = fetchResponses[fetchIndex]
28
+ fetchIndex++
29
+ if (!response) {
30
+ throw new Error('No mock response configured')
31
+ }
32
+ return response
33
+ }
34
+ })
35
+
36
+ afterEach(() => {
37
+ globalThis.fetch = originalFetch
38
+ })
39
+
40
+ const mockResponse = (body: unknown, status = 200) => {
41
+ fetchResponses.push(
42
+ new Response(body === null ? null : JSON.stringify(body), {
43
+ status,
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ 'X-RateLimit-Remaining': '10',
47
+ 'X-RateLimit-Reset': String(Date.now() / 1000 + 60),
48
+ 'X-RateLimit-Bucket': 'test-bucket',
49
+ },
50
+ }),
51
+ )
52
+ }
53
+
54
+ const fetchTransport = {
55
+ request: async <T>(method: string, path: string): Promise<T> => {
56
+ const response = await fetch(`https://discord.com/api/v10${path}`, { method })
57
+ return response.json() as Promise<T>
58
+ },
59
+ }
60
+
61
+ const thread = (id: string, name: string, parentId: string): DiscordChannel => ({
62
+ id,
63
+ guild_id: 'guild1',
64
+ name,
65
+ type: 11,
66
+ parent_id: parentId,
67
+ })
68
+
69
+ it('numeric passthrough makes zero fetch calls', async () => {
70
+ await expect(resolveThread(fetchTransport, 'guild1', 'parent1', '123')).resolves.toBe('123')
71
+ expect(fetchCalls).toHaveLength(0)
72
+ })
73
+
74
+ it('numeric-looking names are treated as ids', async () => {
75
+ await expect(resolveThread(fetchTransport, 'guild1', 'parent1', '123')).resolves.toBe('123')
76
+ expect(fetchCalls).toHaveLength(0)
77
+ })
78
+
79
+ it('resolves a name from active threads filtered by parent', async () => {
80
+ mockResponse({ threads: [thread('thread1', 'ops', 'parent1'), thread('thread2', 'other', 'parent1')], members: [] })
81
+
82
+ await expect(resolveThread(fetchTransport, 'guild1', 'parent1', 'ops')).resolves.toBe('thread1')
83
+ expect(fetchCalls).toHaveLength(1)
84
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/guild1/threads/active')
85
+ })
86
+
87
+ it('strips a leading hash when resolving a name', async () => {
88
+ mockResponse({ threads: [thread('thread1', 'ops', 'parent1')], members: [] })
89
+
90
+ await expect(resolveThread(fetchTransport, 'guild1', 'parent1', '#ops')).resolves.toBe('thread1')
91
+ })
92
+
93
+ it('ignores a same-named thread under a different parent', async () => {
94
+ mockResponse({ threads: [thread('thread2', 'ops', 'parent2')], members: [] })
95
+
96
+ const error = await resolveThread(fetchTransport, 'guild1', 'parent1', 'ops').catch((caught) => caught)
97
+ expect(error.code).toBe('thread_not_found')
98
+ })
99
+
100
+ it('rejects two same-named active threads under one parent as ambiguous', async () => {
101
+ mockResponse({ threads: [thread('thread1', 'ops', 'parent1'), thread('thread2', 'ops', 'parent1')], members: [] })
102
+
103
+ const error = await resolveThread(fetchTransport, 'guild1', 'parent1', 'ops').catch((caught) => caught)
104
+ expect(error.code).toBe('thread_ambiguous')
105
+ expect(error.message).toContain('thread1')
106
+ expect(error.message).toContain('thread2')
107
+ })
108
+
109
+ it('does not resolve archived-only names', async () => {
110
+ mockResponse({ threads: [], members: [] })
111
+
112
+ const error = await resolveThread(fetchTransport, 'guild1', 'parent1', 'old').catch((caught) => caught)
113
+ expect(error.code).toBe('thread_not_found')
114
+ expect(error.message).toContain('--archived')
115
+ expect(fetchCalls).toHaveLength(1)
116
+ expect(fetchCalls[0].url).not.toContain('/threads/archived/')
117
+ })
118
+
119
+ it('names the missing thread in the not-found error', async () => {
120
+ mockResponse({ threads: [], members: [] })
121
+
122
+ const error = await resolveThread(fetchTransport, 'guild1', 'parent1', 'missing').catch((caught) => caught)
123
+ expect(error.code).toBe('thread_not_found')
124
+ expect(error.message).toContain('Thread not found: "missing"')
125
+ })
126
+
127
+ it('requires a parent for archived thread listing', async () => {
128
+ const error = await listThreads(fetchTransport, 'guild1', { archived: true }).catch((caught) => caught)
129
+ expect(error.code).toBe('parent_required')
130
+ expect(fetchCalls).toHaveLength(0)
131
+ })
132
+
133
+ it('lists archived public threads for a parent and returns has_more', async () => {
134
+ const archived = thread('thread1', 'old', 'parent1')
135
+ mockResponse({ threads: [archived], members: [], has_more: true })
136
+
137
+ await expect(listThreads(fetchTransport, 'guild1', { parentId: 'parent1', archived: true })).resolves.toEqual({
138
+ threads: [archived],
139
+ has_more: true,
140
+ })
141
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/parent1/threads/archived/public?limit=100')
142
+ })
143
+
144
+ it('lists all active threads by default', async () => {
145
+ const threads = [thread('thread1', 'ops', 'parent1'), thread('thread2', 'other', 'parent2')]
146
+ mockResponse({ threads, members: [] })
147
+
148
+ await expect(listThreads(fetchTransport, 'guild1')).resolves.toEqual({ threads })
149
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/guild1/threads/active')
150
+ })
151
+
152
+ it('filters active threads by parent', async () => {
153
+ const matching = thread('thread1', 'ops', 'parent1')
154
+ mockResponse({ threads: [matching, thread('thread2', 'other', 'parent2')], members: [] })
155
+
156
+ await expect(listThreads(fetchTransport, 'guild1', { parentId: 'parent1' })).resolves.toEqual({
157
+ threads: [matching],
158
+ })
159
+ })
160
+
161
+ it('delegates listThreads through the client', async () => {
162
+ const threads = [thread('thread1', 'ops', 'parent1')]
163
+ mockResponse({ threads, members: [] })
164
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
165
+
166
+ await expect(client.listThreads('guild1', { parentId: 'parent1' })).resolves.toEqual({ threads })
167
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/guild1/threads/active')
168
+ })
169
+
170
+ it('delegates resolveThread through the client', async () => {
171
+ mockResponse({ threads: [thread('thread1', 'ops', 'parent1')], members: [] })
172
+ const client = await new DiscordBotClient().login({ token: 'bot-token' })
173
+
174
+ await expect(client.resolveThread('guild1', 'parent1', 'ops')).resolves.toBe('thread1')
175
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/guild1/threads/active')
176
+ })
177
+ })
@@ -0,0 +1,69 @@
1
+ import type { DiscordThreadListResult } from './types'
2
+ import { DiscordBotError } from './types'
3
+
4
+ export interface ThreadTransport {
5
+ request<T>(method: string, path: string, body?: unknown): Promise<T>
6
+ }
7
+
8
+ interface ThreadListResponse {
9
+ threads: DiscordThreadListResult['threads']
10
+ members: unknown[]
11
+ has_more?: boolean
12
+ }
13
+
14
+ export async function listThreads(
15
+ t: ThreadTransport,
16
+ guildId: string,
17
+ options?: { parentId?: string; archived?: boolean },
18
+ ): Promise<DiscordThreadListResult> {
19
+ if (options?.archived === true) {
20
+ if (!options.parentId) {
21
+ throw new DiscordBotError('thread list --archived requires a channel', 'parent_required')
22
+ }
23
+
24
+ const response = await t.request<ThreadListResponse>(
25
+ 'GET',
26
+ `/channels/${options.parentId}/threads/archived/public?limit=100`,
27
+ )
28
+ return { threads: response.threads, has_more: response.has_more }
29
+ }
30
+
31
+ const response = await t.request<ThreadListResponse>('GET', `/guilds/${guildId}/threads/active`)
32
+ const threads = options?.parentId
33
+ ? response.threads.filter((thread) => thread.parent_id === options.parentId)
34
+ : response.threads
35
+ return { threads }
36
+ }
37
+
38
+ export async function resolveThread(
39
+ t: ThreadTransport,
40
+ guildId: string,
41
+ parentChannelId: string,
42
+ thread: string,
43
+ ): Promise<string> {
44
+ if (/^\d+$/.test(thread)) {
45
+ return thread
46
+ }
47
+
48
+ const name = thread.replace(/^#/, '')
49
+ const response = await t.request<ThreadListResponse>('GET', `/guilds/${guildId}/threads/active`)
50
+ const matches = response.threads.filter(
51
+ (candidate) => candidate.parent_id === parentChannelId && candidate.name === name,
52
+ )
53
+
54
+ if (matches.length === 1) {
55
+ return matches[0].id
56
+ }
57
+
58
+ if (matches.length > 1) {
59
+ throw new DiscordBotError(
60
+ `Ambiguous thread "${name}" in this channel: ${matches.map((match) => match.id).join(', ')}. Use the thread ID.`,
61
+ 'thread_ambiguous',
62
+ )
63
+ }
64
+
65
+ throw new DiscordBotError(
66
+ `Thread not found: "${name}". Run "thread list <channel>" (add --archived for archived threads) or use the thread ID. If <channel> is itself a thread, send to it directly without --thread.`,
67
+ 'thread_not_found',
68
+ )
69
+ }