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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discordbot/client-message.d.ts +15 -0
- package/dist/src/platforms/discordbot/client-message.d.ts.map +1 -0
- package/dist/src/platforms/discordbot/client-message.js +70 -0
- package/dist/src/platforms/discordbot/client-message.js.map +1 -0
- package/dist/src/platforms/discordbot/client-threads.d.ts +10 -0
- package/dist/src/platforms/discordbot/client-threads.d.ts.map +1 -0
- package/dist/src/platforms/discordbot/client-threads.js +31 -0
- package/dist/src/platforms/discordbot/client-threads.js.map +1 -0
- package/dist/src/platforms/discordbot/client.d.ts +14 -2
- package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/client.js +21 -28
- package/dist/src/platforms/discordbot/client.js.map +1 -1
- package/dist/src/platforms/discordbot/commands/file.d.ts +14 -10
- package/dist/src/platforms/discordbot/commands/file.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/commands/file.js +58 -24
- package/dist/src/platforms/discordbot/commands/file.js.map +1 -1
- package/dist/src/platforms/discordbot/commands/message.d.ts +3 -9
- package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/commands/message.js +9 -51
- package/dist/src/platforms/discordbot/commands/message.js.map +1 -1
- package/dist/src/platforms/discordbot/commands/reaction.d.ts +9 -0
- package/dist/src/platforms/discordbot/commands/reaction.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/commands/reaction.js +39 -0
- package/dist/src/platforms/discordbot/commands/reaction.js.map +1 -1
- package/dist/src/platforms/discordbot/commands/shared.d.ts +36 -0
- package/dist/src/platforms/discordbot/commands/shared.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/commands/shared.js +36 -0
- package/dist/src/platforms/discordbot/commands/shared.js.map +1 -1
- package/dist/src/platforms/discordbot/commands/thread.d.ts +14 -0
- package/dist/src/platforms/discordbot/commands/thread.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/commands/thread.js +40 -0
- package/dist/src/platforms/discordbot/commands/thread.js.map +1 -1
- package/dist/src/platforms/discordbot/index.d.ts +1 -1
- package/dist/src/platforms/discordbot/index.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/index.js.map +1 -1
- package/dist/src/platforms/discordbot/types.d.ts +68 -17
- package/dist/src/platforms/discordbot/types.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/types.js +20 -16
- package/dist/src/platforms/discordbot/types.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +4 -32
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/sync-state-store.d.ts +11 -0
- package/dist/src/platforms/kakaotalk/sync-state-store.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/sync-state-store.js +89 -0
- package/dist/src/platforms/kakaotalk/sync-state-store.js.map +1 -0
- package/dist/src/platforms/slack/cli.d.ts.map +1 -1
- package/dist/src/platforms/slack/cli.js +5 -0
- package/dist/src/platforms/slack/cli.js.map +1 -1
- package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/commands/auth.js +9 -3
- package/dist/src/platforms/slack/commands/auth.js.map +1 -1
- package/dist/src/platforms/slack/credential-manager.d.ts +2 -0
- package/dist/src/platforms/slack/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/slack/credential-manager.js +17 -3
- package/dist/src/platforms/slack/credential-manager.js.map +1 -1
- package/docs/content/docs/cli/discordbot.mdx +60 -27
- package/docs/content/docs/cli/slack.mdx +8 -1
- package/docs/content/docs/sdk/discordbot.mdx +101 -3
- package/e2e/README.md +3 -1
- package/e2e/config.ts +1 -1
- package/e2e/discordbot.e2e.test.ts +206 -11
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +57 -11
- package/skills/agent-discordbot/references/authentication.md +13 -11
- package/skills/agent-discordbot/references/common-patterns.md +36 -8
- package/skills/agent-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +9 -2
- package/skills/agent-slack/references/authentication.md +12 -0
- package/skills/agent-slack/references/common-patterns.md +18 -0
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/discordbot/client-message.ts +91 -0
- package/src/platforms/discordbot/client-threads.test.ts +177 -0
- package/src/platforms/discordbot/client-threads.ts +69 -0
- package/src/platforms/discordbot/client.test.ts +277 -1
- package/src/platforms/discordbot/client.ts +60 -40
- package/src/platforms/discordbot/commands/file.test.ts +242 -90
- package/src/platforms/discordbot/commands/file.ts +78 -36
- package/src/platforms/discordbot/commands/message.test.ts +183 -26
- package/src/platforms/discordbot/commands/message.ts +13 -61
- package/src/platforms/discordbot/commands/reaction.test.ts +73 -1
- package/src/platforms/discordbot/commands/reaction.ts +46 -0
- package/src/platforms/discordbot/commands/shared.test.ts +98 -0
- package/src/platforms/discordbot/commands/shared.ts +67 -0
- package/src/platforms/discordbot/commands/thread.test.ts +58 -1
- package/src/platforms/discordbot/commands/thread.ts +58 -0
- package/src/platforms/discordbot/index.ts +3 -0
- package/src/platforms/discordbot/types.test.ts +46 -0
- package/src/platforms/discordbot/types.ts +44 -19
- package/src/platforms/kakaotalk/client-listener-integration.test.ts +21 -3
- package/src/platforms/kakaotalk/client.test.ts +42 -4
- package/src/platforms/kakaotalk/client.ts +4 -39
- package/src/platforms/kakaotalk/sync-state-store.test.ts +197 -0
- package/src/platforms/kakaotalk/sync-state-store.ts +99 -0
- package/src/platforms/slack/cli.test.ts +111 -1
- package/src/platforms/slack/cli.ts +5 -0
- package/src/platforms/slack/commands/auth.ts +14 -3
- package/src/platforms/slack/credential-manager.ts +21 -3
- 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.
|
|
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-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.
|
|
4
|
+
version: 2.39.0
|
|
5
5
|
allowed-tools: Bash(agent-webexbot:*)
|
|
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
|
+
}
|