typeclaw 0.7.0 → 0.8.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/README.md +9 -3
- package/package.json +1 -1
- package/scripts/dump-system-prompt.ts +12 -1
- package/src/agent/auth.ts +3 -3
- package/src/agent/index.ts +61 -8
- package/src/agent/multimodal/read-redirect.ts +43 -0
- package/src/agent/plugin-tools.ts +95 -13
- package/src/agent/session-origin.ts +6 -13
- package/src/agent/system-prompt.ts +40 -8
- package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
- package/src/channels/adapters/discord-bot.ts +163 -1
- package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
- package/src/channels/adapters/slack-bot.ts +139 -1
- package/src/channels/router.ts +127 -1
- package/src/cli/role.ts +7 -2
- package/src/cli/tunnel.ts +13 -1
- package/src/cli/ui.ts +25 -1
- package/src/config/index.ts +1 -0
- package/src/config/models-mutation.ts +10 -2
- package/src/init/dockerfile.ts +89 -2
- package/src/shared/index.ts +1 -1
- package/src/shared/local-time.ts +17 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +30 -5
- package/src/skills/typeclaw-config/SKILL.md +37 -32
- package/src/skills/typeclaw-git/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { DiscordGatewayInteractionEvent } from 'agent-messenger/discordbot'
|
|
2
|
+
|
|
3
|
+
import type { ChannelKey } from '@/channels/types'
|
|
4
|
+
|
|
5
|
+
const DISCORD_API_BASE = 'https://discord.com/api/v10'
|
|
6
|
+
|
|
7
|
+
// CHAT_INPUT is the only Discord application-command type that maps to the
|
|
8
|
+
// existing text-prefix command registry. USER (2) and MESSAGE (3) are
|
|
9
|
+
// right-click context-menu surfaces with no /name args equivalent — we don't
|
|
10
|
+
// register them and we drop their interactions.
|
|
11
|
+
const APPLICATION_COMMAND_TYPE_CHAT_INPUT = 1
|
|
12
|
+
|
|
13
|
+
// type 4 = CHANNEL_MESSAGE_WITH_SOURCE; flag 64 = EPHEMERAL (only the invoker
|
|
14
|
+
// sees it). Ephemeral keeps /stop replies out of the channel transcript.
|
|
15
|
+
// Discord drops the interaction with "This interaction failed" if we don't
|
|
16
|
+
// ack within ~3 seconds.
|
|
17
|
+
const INTERACTION_CALLBACK_TYPE_CHANNEL_MESSAGE_WITH_SOURCE = 4
|
|
18
|
+
const INTERACTION_MESSAGE_FLAG_EPHEMERAL = 64
|
|
19
|
+
export const DISCORD_INTERACTION_ACK_BUDGET_MS = 3000
|
|
20
|
+
|
|
21
|
+
export type DiscordCommandDeclaration = {
|
|
22
|
+
name: string
|
|
23
|
+
description: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RegisterCommandsArgs = {
|
|
27
|
+
token: string
|
|
28
|
+
applicationId: string
|
|
29
|
+
commands: readonly DiscordCommandDeclaration[]
|
|
30
|
+
fetchImpl?: typeof fetch
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RegisterCommandsResult = { ok: true } | { ok: false; error: string }
|
|
34
|
+
|
|
35
|
+
// Bulk-overwrite is idempotent — Discord replaces the entire registered set
|
|
36
|
+
// with whatever the body declares, so re-running `typeclaw start` with the
|
|
37
|
+
// same commands is a no-op server-side. Global (vs. per-guild) registration
|
|
38
|
+
// avoids the bot-needs-to-know-its-guilds bootstrap, at the cost of
|
|
39
|
+
// Discord's documented up-to-1-hour propagation for new commands. Text-
|
|
40
|
+
// prefix /stop continues to work the entire time, so the propagation
|
|
41
|
+
// window doesn't regress existing behavior.
|
|
42
|
+
//
|
|
43
|
+
// CAUTION: this PUT replaces ALL global commands on the application with the
|
|
44
|
+
// declared list. Sharing the bot application with another integration that
|
|
45
|
+
// also registers global commands would delete those commands. Don't share
|
|
46
|
+
// the application; TypeClaw owns the application's command set.
|
|
47
|
+
export async function registerCommands(args: RegisterCommandsArgs): Promise<RegisterCommandsResult> {
|
|
48
|
+
const fetchImpl = args.fetchImpl ?? fetch
|
|
49
|
+
const body = args.commands.map((cmd) => ({
|
|
50
|
+
name: cmd.name,
|
|
51
|
+
description: cmd.description,
|
|
52
|
+
type: APPLICATION_COMMAND_TYPE_CHAT_INPUT,
|
|
53
|
+
}))
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetchImpl(`${DISCORD_API_BASE}/applications/${encodeURIComponent(args.applicationId)}/commands`, {
|
|
56
|
+
method: 'PUT',
|
|
57
|
+
headers: { Authorization: `Bot ${args.token}`, 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify(body),
|
|
59
|
+
})
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const text = await res.text().catch(() => '')
|
|
62
|
+
return { ok: false, error: `http ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}` }
|
|
63
|
+
}
|
|
64
|
+
return { ok: true }
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ParsedSlashCommand = {
|
|
71
|
+
name: string
|
|
72
|
+
key: ChannelKey
|
|
73
|
+
invokerId: string
|
|
74
|
+
interactionId: string
|
|
75
|
+
interactionToken: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ParseInteractionResult =
|
|
79
|
+
| { kind: 'parsed'; command: ParsedSlashCommand }
|
|
80
|
+
| { kind: 'ignore'; reason: 'not-application-command' | 'unknown-command' | 'no-invoker' | 'no-channel' }
|
|
81
|
+
|
|
82
|
+
export function parseInteractionAsCommand(
|
|
83
|
+
event: DiscordGatewayInteractionEvent,
|
|
84
|
+
knownCommands: ReadonlySet<string>,
|
|
85
|
+
): ParseInteractionResult {
|
|
86
|
+
const data = event.data as { name?: string; type?: number } | undefined
|
|
87
|
+
if (!data || data.type !== APPLICATION_COMMAND_TYPE_CHAT_INPUT) {
|
|
88
|
+
return { kind: 'ignore', reason: 'not-application-command' }
|
|
89
|
+
}
|
|
90
|
+
const name = typeof data.name === 'string' ? data.name.toLowerCase() : ''
|
|
91
|
+
if (name === '' || !knownCommands.has(name)) {
|
|
92
|
+
return { kind: 'ignore', reason: 'unknown-command' }
|
|
93
|
+
}
|
|
94
|
+
// Guild interactions carry the invoker in member.user.id; DM interactions
|
|
95
|
+
// carry it in user.id. Exactly one is present.
|
|
96
|
+
const member = event.member as { user?: { id?: string } } | undefined
|
|
97
|
+
const invokerId = member?.user?.id ?? event.user?.id ?? ''
|
|
98
|
+
if (invokerId === '') {
|
|
99
|
+
return { kind: 'ignore', reason: 'no-invoker' }
|
|
100
|
+
}
|
|
101
|
+
if (typeof event.channel_id !== 'string' || event.channel_id === '') {
|
|
102
|
+
return { kind: 'ignore', reason: 'no-channel' }
|
|
103
|
+
}
|
|
104
|
+
// Mirror discord-bot-classify: DM workspace is '@dm', threads are stored
|
|
105
|
+
// as their channel id in `chat` with `thread: null` (Discord treats threads
|
|
106
|
+
// as channels; interaction.channel_id is the thread id when the user
|
|
107
|
+
// invoked from a thread).
|
|
108
|
+
const workspace = typeof event.guild_id === 'string' && event.guild_id !== '' ? event.guild_id : '@dm'
|
|
109
|
+
return {
|
|
110
|
+
kind: 'parsed',
|
|
111
|
+
command: {
|
|
112
|
+
name,
|
|
113
|
+
key: { adapter: 'discord-bot', workspace, chat: event.channel_id, thread: null },
|
|
114
|
+
invokerId,
|
|
115
|
+
interactionId: event.id,
|
|
116
|
+
interactionToken: event.token,
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Content is required even when there's nothing to stop, because Discord
|
|
122
|
+
// rejects empty CHANNEL_MESSAGE_WITH_SOURCE responses.
|
|
123
|
+
export function buildInteractionAck(content: string): {
|
|
124
|
+
type: number
|
|
125
|
+
data: { content: string; flags: number }
|
|
126
|
+
} {
|
|
127
|
+
return {
|
|
128
|
+
type: INTERACTION_CALLBACK_TYPE_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
129
|
+
data: { content, flags: INTERACTION_MESSAGE_FLAG_EPHEMERAL },
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type AckInteractionArgs = {
|
|
134
|
+
interactionId: string
|
|
135
|
+
interactionToken: string
|
|
136
|
+
content: string
|
|
137
|
+
fetchImpl?: typeof fetch
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type AckInteractionResult = { ok: true } | { ok: false; error: string }
|
|
141
|
+
|
|
142
|
+
// Interaction acks must NOT carry the bot token — the interaction token in
|
|
143
|
+
// the URL is the only credential Discord expects on this endpoint, and
|
|
144
|
+
// adding Authorization sometimes triggers a 401.
|
|
145
|
+
//
|
|
146
|
+
// Errors are scrubbed before being returned: a thrown network error from
|
|
147
|
+
// fetch may include the full request URL (including the interaction token,
|
|
148
|
+
// which is a short-lived credential) in its message string depending on
|
|
149
|
+
// the runtime. We surface only the error class to avoid leaking the token
|
|
150
|
+
// into logs.
|
|
151
|
+
export async function ackInteraction(args: AckInteractionArgs): Promise<AckInteractionResult> {
|
|
152
|
+
const fetchImpl = args.fetchImpl ?? fetch
|
|
153
|
+
const body = buildInteractionAck(args.content)
|
|
154
|
+
try {
|
|
155
|
+
const res = await fetchImpl(
|
|
156
|
+
`${DISCORD_API_BASE}/interactions/${encodeURIComponent(args.interactionId)}/${encodeURIComponent(args.interactionToken)}/callback`,
|
|
157
|
+
{
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify(body),
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
if (!res.ok) {
|
|
164
|
+
const text = await res.text().catch(() => '')
|
|
165
|
+
return { ok: false, error: `http ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}` }
|
|
166
|
+
}
|
|
167
|
+
return { ok: true }
|
|
168
|
+
} catch (err) {
|
|
169
|
+
return { ok: false, error: `network error: ${sanitizeErrorName(err)}` }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Returns the error class name without the message, so callers can log the
|
|
174
|
+
// failure mode without leaking URLs/tokens that some runtimes embed in
|
|
175
|
+
// error.message (e.g. Node's "fetch failed: TypeError: fetch failed,
|
|
176
|
+
// cause: Error: ... https://discord.com/api/v10/interactions/123/<token>/callback").
|
|
177
|
+
function sanitizeErrorName(err: unknown): string {
|
|
178
|
+
if (err instanceof Error) return err.name
|
|
179
|
+
return typeof err === 'string' ? 'string error' : 'unknown error'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function synthesizeCommandText(name: string): string {
|
|
183
|
+
return `/${name}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const DISCORD_SLASH_COMMAND_TYPE_CHAT_INPUT = APPLICATION_COMMAND_TYPE_CHAT_INPUT
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { DiscordBotClient, DiscordBotListener } from 'agent-messenger/discordbot'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DiscordIntent,
|
|
4
|
+
type DiscordGatewayInteractionEvent,
|
|
5
|
+
type DiscordGatewayMessageCreateEvent,
|
|
6
|
+
} from 'agent-messenger/discordbot'
|
|
3
7
|
|
|
4
8
|
import {
|
|
5
9
|
MEMBERSHIP_ENUMERATION_CAP,
|
|
@@ -26,6 +30,26 @@ import type {
|
|
|
26
30
|
|
|
27
31
|
import { createDiscordChannelResolver } from './discord-bot-channel-resolver'
|
|
28
32
|
import { classifyInbound, type InboundDropReason } from './discord-bot-classify'
|
|
33
|
+
import {
|
|
34
|
+
ackInteraction,
|
|
35
|
+
parseInteractionAsCommand,
|
|
36
|
+
registerCommands,
|
|
37
|
+
type DiscordCommandDeclaration,
|
|
38
|
+
} from './discord-bot-slash-commands'
|
|
39
|
+
|
|
40
|
+
// One declared slash command per logical agent gesture. /stop maps to the
|
|
41
|
+
// existing channel-command of the same name in the router. Adding new
|
|
42
|
+
// commands here is the documented extension point: declare the entry here,
|
|
43
|
+
// then add the matching handler in createChannelRouter's command registry.
|
|
44
|
+
const SLASH_COMMANDS: readonly DiscordCommandDeclaration[] = [
|
|
45
|
+
{ name: 'stop', description: 'Abort the current turn in this channel' },
|
|
46
|
+
]
|
|
47
|
+
const SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(SLASH_COMMANDS.map((c) => c.name))
|
|
48
|
+
|
|
49
|
+
const STOP_REPLY_ABORTED = 'Stopped the current turn.'
|
|
50
|
+
const STOP_REPLY_NO_LIVE_SESSION = 'Nothing to stop — no active turn in this channel.'
|
|
51
|
+
const STOP_REPLY_FAILED = 'Could not stop the current turn (internal error).'
|
|
52
|
+
const STOP_REPLY_PERMISSION_DENIED = 'You do not have permission to stop the current turn in this channel.'
|
|
29
53
|
|
|
30
54
|
const DISCORD_API_BASE = 'https://discord.com/api/v10'
|
|
31
55
|
|
|
@@ -66,6 +90,10 @@ export type DiscordBotAdapterOptions = {
|
|
|
66
90
|
configRef: () => ChannelAdapterConfig
|
|
67
91
|
token: string
|
|
68
92
|
logger?: DiscordBotAdapterLogger
|
|
93
|
+
// Injectable for tests so adapter integration tests can assert on the
|
|
94
|
+
// exact REST calls without monkey-patching globalThis.fetch. Production
|
|
95
|
+
// callers leave it undefined to use the global fetch.
|
|
96
|
+
fetchImpl?: typeof fetch
|
|
69
97
|
}
|
|
70
98
|
|
|
71
99
|
export type DiscordBotAdapter = {
|
|
@@ -433,9 +461,91 @@ export function createFetchAttachmentCallback(deps: {
|
|
|
433
461
|
}
|
|
434
462
|
}
|
|
435
463
|
|
|
464
|
+
export type InteractionHandlerDeps = {
|
|
465
|
+
router: Pick<ChannelRouter, 'executeCommand'>
|
|
466
|
+
knownCommandNames: ReadonlySet<string>
|
|
467
|
+
logger: DiscordBotAdapterLogger
|
|
468
|
+
formatChannelTag: (workspace: string, chat: string) => Promise<string>
|
|
469
|
+
fetchImpl?: typeof fetch
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function createInteractionHandler(
|
|
473
|
+
deps: InteractionHandlerDeps,
|
|
474
|
+
): (event: DiscordGatewayInteractionEvent) => Promise<void> {
|
|
475
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
476
|
+
return async (event) => {
|
|
477
|
+
try {
|
|
478
|
+
const parsed = parseInteractionAsCommand(event, deps.knownCommandNames)
|
|
479
|
+
if (parsed.kind === 'ignore') {
|
|
480
|
+
// 'not-application-command' is the common case (buttons, modals,
|
|
481
|
+
// autocomplete); emit at warn only when we dropped something we
|
|
482
|
+
// ostensibly handle.
|
|
483
|
+
if (parsed.reason !== 'not-application-command') {
|
|
484
|
+
deps.logger.warn(`[discord-bot] interaction id=${event.id} dropped reason=${parsed.reason}`)
|
|
485
|
+
}
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
const { command } = parsed
|
|
489
|
+
|
|
490
|
+
// Pre-ACK: emit ONE line with bare ids only (no formatChannelTag).
|
|
491
|
+
// Discord's 3s ack budget covers everything until the callback POST
|
|
492
|
+
// returns 2xx; name resolution involves two Discord REST calls that
|
|
493
|
+
// can blow the budget on a slow API minute. Decorative logging with
|
|
494
|
+
// resolved names happens AFTER the ack.
|
|
495
|
+
deps.logger.info(
|
|
496
|
+
`[discord-bot] interaction /${command.name} id=${event.id} invoker=${command.invokerId} guild=${command.key.workspace} channel=${command.key.chat}`,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
const result = await deps.router.executeCommand(command.key, command.name, {
|
|
500
|
+
invokerId: command.invokerId,
|
|
501
|
+
})
|
|
502
|
+
const replyContent =
|
|
503
|
+
result.kind === 'handled'
|
|
504
|
+
? STOP_REPLY_ABORTED
|
|
505
|
+
: result.kind === 'no-live-session'
|
|
506
|
+
? STOP_REPLY_NO_LIVE_SESSION
|
|
507
|
+
: result.kind === 'permission-denied'
|
|
508
|
+
? STOP_REPLY_PERMISSION_DENIED
|
|
509
|
+
: STOP_REPLY_FAILED
|
|
510
|
+
|
|
511
|
+
const ack = await ackInteraction({
|
|
512
|
+
interactionId: command.interactionId,
|
|
513
|
+
interactionToken: command.interactionToken,
|
|
514
|
+
content: replyContent,
|
|
515
|
+
fetchImpl,
|
|
516
|
+
})
|
|
517
|
+
if (!ack.ok) {
|
|
518
|
+
// Discord's interaction token is single-use per callback type and
|
|
519
|
+
// ~15min total; once we miss the 3s ack window the user sees
|
|
520
|
+
// "This interaction failed" in the UI. The abort still happened
|
|
521
|
+
// server-side — only the user-visible confirmation is lost.
|
|
522
|
+
deps.logger.warn(`[discord-bot] interaction /${command.name} ack failed: ${ack.error}`)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Decorative post-ack logging: resolve channel/guild names now that
|
|
526
|
+
// the 3s budget is no longer a concern. Best-effort — if name
|
|
527
|
+
// resolution fails we already logged bare ids above.
|
|
528
|
+
try {
|
|
529
|
+
const inboundTag = await deps.formatChannelTag(command.key.workspace, command.key.chat)
|
|
530
|
+
deps.logger.info(`[discord-bot] interaction /${command.name} result=${result.kind} ${inboundTag}`)
|
|
531
|
+
} catch (err) {
|
|
532
|
+
deps.logger.info(
|
|
533
|
+
`[discord-bot] interaction /${command.name} result=${result.kind} (channel-tag resolution failed: ${describe(err)})`,
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
} catch (err) {
|
|
537
|
+
deps.logger.error(`[discord-bot] handleInteraction failed: ${describe(err)}`)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export const DISCORD_SLASH_COMMANDS = SLASH_COMMANDS
|
|
543
|
+
export const DISCORD_SLASH_COMMAND_NAMES = SLASH_COMMAND_NAMES
|
|
544
|
+
|
|
436
545
|
export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): DiscordBotAdapter {
|
|
437
546
|
const logger = options.logger ?? consoleLogger
|
|
438
547
|
const client = new DiscordBotClient()
|
|
548
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
439
549
|
let listener: DiscordBotListener | null = null
|
|
440
550
|
let botUserId: string | null = null
|
|
441
551
|
let started = false
|
|
@@ -479,6 +589,28 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
479
589
|
|
|
480
590
|
const fetchAttachmentCallback = createFetchAttachmentCallback({ token: options.token, logger })
|
|
481
591
|
|
|
592
|
+
const interactionHandler = createInteractionHandler({
|
|
593
|
+
router: options.router,
|
|
594
|
+
knownCommandNames: SLASH_COMMAND_NAMES,
|
|
595
|
+
logger,
|
|
596
|
+
formatChannelTag,
|
|
597
|
+
fetchImpl,
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
const handleInteractionCreate = async (event: DiscordGatewayInteractionEvent): Promise<void> => {
|
|
601
|
+
inflightInbounds++
|
|
602
|
+
try {
|
|
603
|
+
await interactionHandler(event)
|
|
604
|
+
} finally {
|
|
605
|
+
inflightInbounds--
|
|
606
|
+
if (inflightInbounds === 0 && stopWaiters.length > 0) {
|
|
607
|
+
const waiters = stopWaiters
|
|
608
|
+
stopWaiters = []
|
|
609
|
+
for (const w of waiters) w()
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
482
614
|
const handleMessageCreate = async (event: DiscordGatewayMessageCreateEvent): Promise<void> => {
|
|
483
615
|
inflightInbounds++
|
|
484
616
|
try {
|
|
@@ -530,6 +662,33 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
530
662
|
listener.on('connected', (info) => {
|
|
531
663
|
botUserId = info.user.id
|
|
532
664
|
logger.info(`[discord-bot] connected as ${info.user.username} (${info.user.id})`)
|
|
665
|
+
// For bots, the gateway's user.id IS the application id — the same
|
|
666
|
+
// value is required for both /me lookups and /applications/{id}/
|
|
667
|
+
// commands. Fire-and-forget registration so a slow Discord API
|
|
668
|
+
// call (or a 403 from missing applications.commands scope) doesn't
|
|
669
|
+
// block the listener from receiving messages. Text-prefix /stop
|
|
670
|
+
// keeps working regardless.
|
|
671
|
+
void registerCommands({
|
|
672
|
+
token: options.token,
|
|
673
|
+
applicationId: info.user.id,
|
|
674
|
+
commands: SLASH_COMMANDS,
|
|
675
|
+
fetchImpl,
|
|
676
|
+
}).then((result) => {
|
|
677
|
+
if (result.ok) {
|
|
678
|
+
logger.info(
|
|
679
|
+
`[discord-bot] slash commands registered (${SLASH_COMMANDS.map((c) => `/${c.name}`).join(' ')})`,
|
|
680
|
+
)
|
|
681
|
+
} else {
|
|
682
|
+
// 403 here is almost always missing applications.commands scope
|
|
683
|
+
// on the OAuth invite URL — operator-fixable, but the listener
|
|
684
|
+
// continues. Adding the hint inline so an operator doesn't have
|
|
685
|
+
// to grep docs to recognize the failure mode.
|
|
686
|
+
logger.warn(
|
|
687
|
+
`[discord-bot] slash command registration failed: ${result.error}` +
|
|
688
|
+
' (if 403, re-invite the bot with the applications.commands scope)',
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
})
|
|
533
692
|
})
|
|
534
693
|
listener.on('disconnected', () => {
|
|
535
694
|
logger.warn('[discord-bot] disconnected; SDK will reconnect with backoff')
|
|
@@ -540,6 +699,9 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
540
699
|
listener.on('message_create', (event) => {
|
|
541
700
|
void handleMessageCreate(event)
|
|
542
701
|
})
|
|
702
|
+
listener.on('interaction_create', (event) => {
|
|
703
|
+
void handleInteractionCreate(event)
|
|
704
|
+
})
|
|
543
705
|
|
|
544
706
|
options.router.registerOutbound('discord-bot', outboundCallback)
|
|
545
707
|
options.router.registerTyping('discord-bot', typingCallback)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { SlackSocketModeSlashCommandArgs } from 'agent-messenger/slackbot'
|
|
2
|
+
|
|
3
|
+
import type { ChannelKey } from '@/channels/types'
|
|
4
|
+
|
|
5
|
+
// Slack channel ids: 'C' = public, 'G' = private/legacy multi-party DM,
|
|
6
|
+
// 'D' = direct message. Slash-command payloads don't carry `channel_type`,
|
|
7
|
+
// so we read the id prefix directly. The slack-bot inbound classifier uses
|
|
8
|
+
// `event.channel_type === 'im'` for the same purpose, but that field isn't
|
|
9
|
+
// in the slash-command body. Group DMs ('G' prefix) are NOT treated as DMs
|
|
10
|
+
// here — they map to `workspace: team_id` like a regular channel, matching
|
|
11
|
+
// how the inbound classifier handles MPIM messages (channel_type 'mpim'
|
|
12
|
+
// is not 'im' and therefore falls through to the team workspace branch).
|
|
13
|
+
const SLACK_DM_CHANNEL_PREFIXES: readonly string[] = ['D']
|
|
14
|
+
|
|
15
|
+
export type ParsedSlackSlashCommand = {
|
|
16
|
+
name: string
|
|
17
|
+
key: ChannelKey
|
|
18
|
+
invokerId: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ParseSlashCommandResult =
|
|
22
|
+
| { kind: 'parsed'; command: ParsedSlackSlashCommand }
|
|
23
|
+
| { kind: 'ignore'; reason: 'unknown-command' | 'no-invoker' | 'no-channel' | 'no-team' | 'malformed' }
|
|
24
|
+
|
|
25
|
+
export function parseSlashCommand(
|
|
26
|
+
body: SlackSocketModeSlashCommandArgs['body'],
|
|
27
|
+
knownCommands: ReadonlySet<string>,
|
|
28
|
+
): ParseSlashCommandResult {
|
|
29
|
+
if (typeof body.command !== 'string' || !body.command.startsWith('/')) {
|
|
30
|
+
return { kind: 'ignore', reason: 'malformed' }
|
|
31
|
+
}
|
|
32
|
+
const name = body.command.slice(1).toLowerCase()
|
|
33
|
+
if (name === '' || !knownCommands.has(name)) {
|
|
34
|
+
return { kind: 'ignore', reason: 'unknown-command' }
|
|
35
|
+
}
|
|
36
|
+
if (typeof body.user_id !== 'string' || body.user_id === '') {
|
|
37
|
+
return { kind: 'ignore', reason: 'no-invoker' }
|
|
38
|
+
}
|
|
39
|
+
if (typeof body.channel_id !== 'string' || body.channel_id === '') {
|
|
40
|
+
return { kind: 'ignore', reason: 'no-channel' }
|
|
41
|
+
}
|
|
42
|
+
// team_id is required for slash commands per Slack's API, but defensively
|
|
43
|
+
// refuse to construct a ChannelKey without it — otherwise the workspace
|
|
44
|
+
// field would collide with a real workspace id named '' downstream.
|
|
45
|
+
if (typeof body.team_id !== 'string' || body.team_id === '') {
|
|
46
|
+
return { kind: 'ignore', reason: 'no-team' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isDm = SLACK_DM_CHANNEL_PREFIXES.some((prefix) => body.channel_id.startsWith(prefix))
|
|
50
|
+
const workspace = isDm ? '@dm' : body.team_id
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
kind: 'parsed',
|
|
54
|
+
command: {
|
|
55
|
+
name,
|
|
56
|
+
// thread is null because Slack slash commands cannot be invoked from
|
|
57
|
+
// inside a thread — Slack's compose box always targets the top-level
|
|
58
|
+
// channel. The router's executeCommand falls back to any live session
|
|
59
|
+
// in the same workspace+chat when an exact key match misses, so a
|
|
60
|
+
// thread-keyed live session still gets hit by a thread-less slash.
|
|
61
|
+
key: { adapter: 'slack-bot', workspace, chat: body.channel_id, thread: null },
|
|
62
|
+
invokerId: body.user_id,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const SLACK_SLASH_REPLY_ABORTED = 'Stopped the current turn.'
|
|
68
|
+
export const SLACK_SLASH_REPLY_NO_LIVE_SESSION = 'Nothing to stop — no active turn in this channel.'
|
|
69
|
+
export const SLACK_SLASH_REPLY_FAILED = 'Could not stop the current turn (internal error).'
|
|
70
|
+
export const SLACK_SLASH_REPLY_PERMISSION_DENIED =
|
|
71
|
+
'You do not have permission to stop the current turn in this channel.'
|
|
72
|
+
export const SLACK_SLASH_REPLY_AMBIGUOUS =
|
|
73
|
+
'Multiple active turns in this channel. Reply `/stop` from inside the specific thread you want to stop.'
|
|
74
|
+
|
|
75
|
+
// Slack's ack callback accepts an optional response payload that becomes
|
|
76
|
+
// the user-visible reply. `response_type: 'ephemeral'` keeps the reply
|
|
77
|
+
// visible only to the invoker (vs. 'in_channel' which posts to everyone).
|
|
78
|
+
// Control gestures should stay ephemeral — same rationale as Discord's
|
|
79
|
+
// EPHEMERAL flag on interaction callbacks.
|
|
80
|
+
export function buildSlashAckPayload(text: string): { response_type: 'ephemeral'; text: string } {
|
|
81
|
+
return { response_type: 'ephemeral', text }
|
|
82
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SlackBotClient, SlackBotListener } from 'agent-messenger/slackbot'
|
|
1
|
+
import { SlackBotClient, SlackBotListener, type SlackSocketModeSlashCommandArgs } from 'agent-messenger/slackbot'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
MEMBERSHIP_ENUMERATION_CAP,
|
|
@@ -33,8 +33,27 @@ import {
|
|
|
33
33
|
type SlackInboundMessageEvent,
|
|
34
34
|
} from './slack-bot-classify'
|
|
35
35
|
import { createSlackDedupe } from './slack-bot-dedupe'
|
|
36
|
+
import {
|
|
37
|
+
buildSlashAckPayload,
|
|
38
|
+
parseSlashCommand,
|
|
39
|
+
SLACK_SLASH_REPLY_ABORTED,
|
|
40
|
+
SLACK_SLASH_REPLY_AMBIGUOUS,
|
|
41
|
+
SLACK_SLASH_REPLY_FAILED,
|
|
42
|
+
SLACK_SLASH_REPLY_NO_LIVE_SESSION,
|
|
43
|
+
SLACK_SLASH_REPLY_PERMISSION_DENIED,
|
|
44
|
+
} from './slack-bot-slash-commands'
|
|
36
45
|
import { slackTsToMillis } from './slack-bot-time'
|
|
37
46
|
|
|
47
|
+
// One slash command per logical agent gesture. Mirrors the discord-bot
|
|
48
|
+
// SLASH_COMMANDS constant so the cross-platform set stays consistent — when
|
|
49
|
+
// we add a new command (e.g. /memory), it appears in both adapters together.
|
|
50
|
+
// The actual registration lives in the Slack App Manifest at src/cli/ui.ts;
|
|
51
|
+
// this constant is the runtime allow-list that gates which delivered
|
|
52
|
+
// slash_commands events we route vs drop. The ui.test.ts manifest-drift
|
|
53
|
+
// test asserts equality between this set and SLACK_APP_MANIFEST.features.
|
|
54
|
+
// slash_commands so the two can never silently diverge.
|
|
55
|
+
export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['stop'])
|
|
56
|
+
|
|
38
57
|
// Resolvers fall back to the raw id on failure, so a name equal to the id
|
|
39
58
|
// means resolution failed; we render the bare id rather than `id(id)`. The
|
|
40
59
|
// prefix is intentionally only applied to the named form so we never log
|
|
@@ -44,6 +63,101 @@ function formatLabel(name: string | undefined, id: string, prefix = ''): string
|
|
|
44
63
|
return `${prefix}${name}(${id})`
|
|
45
64
|
}
|
|
46
65
|
|
|
66
|
+
export type SlackBotAdapterLoggerLike = {
|
|
67
|
+
info: (msg: string) => void
|
|
68
|
+
warn: (msg: string) => void
|
|
69
|
+
error: (msg: string) => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type SlashCommandHandlerDeps = {
|
|
73
|
+
router: Pick<ChannelRouter, 'executeCommand'>
|
|
74
|
+
knownCommandNames: ReadonlySet<string>
|
|
75
|
+
logger: SlackBotAdapterLoggerLike
|
|
76
|
+
formatChannelTag: (workspace: string, chat: string) => Promise<string>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Ack-first invariant: the handler must call args.ack() exactly once on
|
|
80
|
+
// every path AND must do so before any slow network work (resolver calls,
|
|
81
|
+
// post-ack logging). Slack's 3s ack deadline starts when the slash command
|
|
82
|
+
// envelope arrives on the WebSocket; missing it shows the user
|
|
83
|
+
// "/stop didn't respond in time". The synchronous executeCommand happy
|
|
84
|
+
// path is fast (in-memory map lookup + abort), so ack-after-execute is
|
|
85
|
+
// safe; everything else (formatChannelTag, post-ack logging) runs after.
|
|
86
|
+
//
|
|
87
|
+
// Ack failure handling: a thrown ack on the happy path is logged but does
|
|
88
|
+
// NOT trigger the catch-all error-ack below, which would attempt a second
|
|
89
|
+
// ack call and break the exactly-once contract.
|
|
90
|
+
export function createSlashCommandHandler(
|
|
91
|
+
deps: SlashCommandHandlerDeps,
|
|
92
|
+
): (args: SlackSocketModeSlashCommandArgs) => Promise<void> {
|
|
93
|
+
return async ({ ack, body }) => {
|
|
94
|
+
const parsed = parseSlashCommand(body, deps.knownCommandNames)
|
|
95
|
+
if (parsed.kind === 'ignore') {
|
|
96
|
+
deps.logger.warn(`[slack-bot] slash command dropped reason=${parsed.reason} command=${body.command}`)
|
|
97
|
+
try {
|
|
98
|
+
ack(buildSlashAckPayload(SLACK_SLASH_REPLY_FAILED))
|
|
99
|
+
} catch (err) {
|
|
100
|
+
deps.logger.warn(`[slack-bot] slash command ack (drop path) failed: ${describe(err)}`)
|
|
101
|
+
}
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const { command } = parsed
|
|
105
|
+
|
|
106
|
+
// Pre-ACK log: bare ids only (no formatChannelTag — would burn ack budget
|
|
107
|
+
// on a slow Slack API minute via the channel-name resolver).
|
|
108
|
+
deps.logger.info(
|
|
109
|
+
`[slack-bot] slash /${command.name} invoker=${command.invokerId} team=${command.key.workspace} channel=${command.key.chat}`,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
let result: Awaited<ReturnType<typeof deps.router.executeCommand>>
|
|
113
|
+
try {
|
|
114
|
+
result = await deps.router.executeCommand(command.key, command.name, {
|
|
115
|
+
invokerId: command.invokerId,
|
|
116
|
+
})
|
|
117
|
+
} catch (err) {
|
|
118
|
+
deps.logger.error(`[slack-bot] slash command handler failed: ${describe(err)}`)
|
|
119
|
+
try {
|
|
120
|
+
ack(buildSlashAckPayload(SLACK_SLASH_REPLY_FAILED))
|
|
121
|
+
} catch (ackErr) {
|
|
122
|
+
deps.logger.warn(`[slack-bot] slash command error-ack failed: ${describe(ackErr)}`)
|
|
123
|
+
}
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const replyContent =
|
|
128
|
+
result.kind === 'handled'
|
|
129
|
+
? SLACK_SLASH_REPLY_ABORTED
|
|
130
|
+
: result.kind === 'no-live-session'
|
|
131
|
+
? SLACK_SLASH_REPLY_NO_LIVE_SESSION
|
|
132
|
+
: result.kind === 'permission-denied'
|
|
133
|
+
? SLACK_SLASH_REPLY_PERMISSION_DENIED
|
|
134
|
+
: result.kind === 'ambiguous'
|
|
135
|
+
? SLACK_SLASH_REPLY_AMBIGUOUS
|
|
136
|
+
: SLACK_SLASH_REPLY_FAILED
|
|
137
|
+
|
|
138
|
+
// Final ack on the happy path: own try/catch so a thrown ack here does
|
|
139
|
+
// NOT cascade into the error-path ack above (which would violate the
|
|
140
|
+
// exactly-once contract). The abort already happened server-side; only
|
|
141
|
+
// the user-visible confirmation is lost.
|
|
142
|
+
try {
|
|
143
|
+
ack(buildSlashAckPayload(replyContent))
|
|
144
|
+
} catch (err) {
|
|
145
|
+
deps.logger.warn(`[slack-bot] slash command ack failed: ${describe(err)}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Decorative post-ack logging: resolve channel names now that the 3s
|
|
149
|
+
// budget is no longer a concern. Best-effort.
|
|
150
|
+
try {
|
|
151
|
+
const inboundTag = await deps.formatChannelTag(command.key.workspace, command.key.chat)
|
|
152
|
+
deps.logger.info(`[slack-bot] slash /${command.name} result=${result.kind} ${inboundTag}`)
|
|
153
|
+
} catch (err) {
|
|
154
|
+
deps.logger.info(
|
|
155
|
+
`[slack-bot] slash /${command.name} result=${result.kind} (channel-tag resolution failed: ${describe(err)})`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
47
161
|
// app_mention payloads omit channel_type and never carry a subtype, so we
|
|
48
162
|
// promote them to a message-shaped event for the shared classifier. The
|
|
49
163
|
// promoted event is classified as a regular channel message; the
|
|
@@ -661,6 +775,13 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
661
775
|
|
|
662
776
|
const dedupe = createSlackDedupe()
|
|
663
777
|
|
|
778
|
+
const handleSlashCommand = createSlashCommandHandler({
|
|
779
|
+
router: options.router,
|
|
780
|
+
knownCommandNames: SLACK_SLASH_COMMAND_NAMES,
|
|
781
|
+
logger,
|
|
782
|
+
formatChannelTag,
|
|
783
|
+
})
|
|
784
|
+
|
|
664
785
|
const handleMessageEvent = async (
|
|
665
786
|
event: SlackInboundMessageEvent,
|
|
666
787
|
source: 'message' | 'app_mention',
|
|
@@ -777,6 +898,23 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
777
898
|
ack()
|
|
778
899
|
void handleMessageEvent(promoteAppMentionToMessage(event as SlackInboundAppMentionEvent), 'app_mention')
|
|
779
900
|
})
|
|
901
|
+
listener.on('slash_commands', (args) => {
|
|
902
|
+
// The handler owns the ack call itself (the ack payload carries the
|
|
903
|
+
// user-visible reply text), so we do NOT ack here. inflightInbounds
|
|
904
|
+
// wrapping mirrors handleMessageEvent so stop() can drain the
|
|
905
|
+
// handler before tearing down the listener — otherwise a /stop
|
|
906
|
+
// arriving during stop() would lose its ack and the user sees
|
|
907
|
+
// "didn't respond in time" even though the abort succeeded.
|
|
908
|
+
inflightInbounds++
|
|
909
|
+
void handleSlashCommand(args).finally(() => {
|
|
910
|
+
inflightInbounds--
|
|
911
|
+
if (inflightInbounds === 0 && stopWaiters.length > 0) {
|
|
912
|
+
const waiters = stopWaiters
|
|
913
|
+
stopWaiters = []
|
|
914
|
+
for (const w of waiters) w()
|
|
915
|
+
}
|
|
916
|
+
})
|
|
917
|
+
})
|
|
780
918
|
|
|
781
919
|
options.router.registerOutbound('slack-bot', outboundCallback)
|
|
782
920
|
options.router.registerTyping('slack-bot', typingCallback)
|