typeclaw 0.24.0 → 0.26.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 +1 -1
- package/package.json +1 -1
- package/src/agent/index.ts +42 -5
- package/src/agent/llm-replay-sanitizer.ts +120 -0
- package/src/agent/loop-guard.ts +34 -0
- package/src/agent/multimodal/look-at.ts +1 -1
- package/src/agent/plugin-tools.ts +90 -12
- package/src/agent/session-origin.ts +58 -5
- package/src/agent/subagent-completion-reminder.ts +39 -1
- package/src/agent/subagents.ts +31 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-not-found-nudge.ts +8 -1
- package/src/agent/tools/channel-react.ts +11 -4
- package/src/agent/tools/channel-reply.ts +3 -3
- package/src/agent/tools/curl-impersonate.ts +2 -2
- package/src/agent/tools/spawn-subagent.ts +19 -2
- package/src/agent/tools/subagent-access.ts +40 -5
- package/src/agent/tools/subagent-cancel.ts +3 -1
- package/src/agent/tools/subagent-output.ts +6 -2
- package/src/agent/tools/webfetch/fetch.ts +18 -18
- package/src/agent/tools/webfetch/index.ts +1 -1
- package/src/agent/tools/webfetch/tool.ts +13 -13
- package/src/agent/tools/webfetch/types.ts +1 -1
- package/src/agent/tools/websearch.ts +6 -6
- package/src/bundled-plugins/backup/index.ts +40 -37
- package/src/bundled-plugins/backup/runner.ts +22 -1
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
- package/src/bundled-plugins/memory/README.md +11 -11
- package/src/bundled-plugins/memory/dreaming.ts +5 -0
- package/src/bundled-plugins/memory/search-tool.ts +98 -1
- package/src/bundled-plugins/operator/operator.ts +5 -1
- package/src/bundled-plugins/reviewer/reviewer.ts +18 -9
- package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/bundled-plugins/scout/scout.ts +7 -7
- package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
- package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
- package/src/bundled-plugins/tool-result-cap/README.md +1 -1
- package/src/channels/adapters/discord-bot-classify.ts +3 -0
- package/src/channels/adapters/discord-bot-reactions.ts +164 -0
- package/src/channels/adapters/discord-bot.ts +23 -0
- package/src/channels/adapters/github/inbound.ts +19 -4
- package/src/channels/adapters/github/webhook-register.ts +32 -27
- package/src/channels/adapters/slack-bot-classify.ts +2 -0
- package/src/channels/adapters/slack-bot-reactions.ts +167 -0
- package/src/channels/adapters/slack-bot.ts +24 -0
- package/src/channels/router.ts +63 -23
- package/src/channels/schema.ts +43 -1
- package/src/channels/subagent-completion-bridge.ts +18 -18
- package/src/channels/types.ts +1 -1
- package/src/cli/inspect-controller.ts +130 -38
- package/src/config/config.ts +43 -2
- package/src/container/start.ts +7 -1
- package/src/git/mutex.ts +22 -0
- package/src/git/reconcile-ignored.ts +214 -0
- package/src/hostd/daemon.ts +26 -1
- package/src/hostd/portbroker-manager.ts +7 -0
- package/src/init/dockerfile.ts +1 -1
- package/src/init/gitignore.ts +25 -16
- package/src/init/index.ts +3 -3
- package/src/inspect/index.ts +31 -4
- package/src/inspect/loop.ts +16 -12
- package/src/plugin/define.ts +2 -2
- package/src/plugin/index.ts +2 -2
- package/src/portbroker/hostd-client.ts +36 -13
- package/src/run/index.ts +14 -0
- package/src/sandbox/build.ts +10 -0
- package/src/sandbox/index.ts +9 -1
- package/src/sandbox/policy.ts +12 -0
- package/src/sandbox/session-tmp.ts +43 -0
- package/src/sandbox/writable-zones.ts +103 -3
- package/src/server/command-runner.ts +1 -1
- package/src/server/index.ts +8 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +38 -11
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/tui/format.ts +11 -11
- package/typeclaw.schema.json +1 -0
|
@@ -100,7 +100,7 @@ export function classifyUrl(rawUrl: string): SsrfClassification {
|
|
|
100
100
|
|
|
101
101
|
export function checkSsrfGuard(options: { tool: string; args: Record<string, unknown> }): SecurityBlock | undefined {
|
|
102
102
|
const { tool, args } = options
|
|
103
|
-
if (tool !== '
|
|
103
|
+
if (tool !== 'web_fetch') return undefined
|
|
104
104
|
const url = args.url
|
|
105
105
|
if (typeof url !== 'string') return undefined
|
|
106
106
|
if (isGuardAcknowledged(args, GUARD_SSRF)) return undefined
|
|
@@ -111,9 +111,9 @@ export function checkSsrfGuard(options: { tool: string; args: Record<string, unk
|
|
|
111
111
|
return {
|
|
112
112
|
block: true,
|
|
113
113
|
reason: [
|
|
114
|
-
`Guard \`${GUARD_SSRF}\` blocked
|
|
114
|
+
`Guard \`${GUARD_SSRF}\` blocked web_fetch to a non-public destination (${result.category ?? 'unknown'}): ${result.reason ?? 'classified as internal'}.`,
|
|
115
115
|
'This protects against SSRF, cloud metadata exfiltration, and accidental fetches against internal services.',
|
|
116
|
-
`If this is genuinely intentional and you trust the URL, retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_SSRF}: true\` in the
|
|
116
|
+
`If this is genuinely intentional and you trust the URL, retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_SSRF}: true\` in the web_fetch arguments.`,
|
|
117
117
|
].join(' '),
|
|
118
118
|
}
|
|
119
119
|
}
|
|
@@ -9,7 +9,7 @@ This plugin is **auto-loaded** by every TypeClaw agent. There is no `plugins[]`
|
|
|
9
9
|
`pi-coding-agent`'s built-in tools occasionally return very large payloads that the model only needed once. Two empirically observed cases:
|
|
10
10
|
|
|
11
11
|
1. **`read` on an image file** returns the base64-encoded image inline (e.g. `{type:"image", data:"<3.2MB of base64>"}`). The model uses it on the turn it was asked for, then sees the same 3.2MB of base64 as conversation context on every subsequent prompt — until compaction fires (which is token-driven, not byte-driven, so a single fat blob may sit in context for many turns before compaction is triggered).
|
|
12
|
-
2. **`
|
|
12
|
+
2. **`web_fetch` on a binary URL** (PNG, ZIP, etc.) receives the raw response body, treats it as text, and stores raw binary as a JSON-encoded string. Same effect: 100KB+ of mojibake sits in the transcript permanently.
|
|
13
13
|
|
|
14
14
|
The result is a session JSONL file that's tens of megabytes on disk but mostly one or two giant tool results, plus 3-minute first-prompt latencies after container restart because the full transcript gets re-shipped to the LLM as context.
|
|
15
15
|
|
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
9
9
|
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
10
10
|
|
|
11
|
+
import { encodeDiscordReactionRef } from './discord-bot-reactions'
|
|
12
|
+
|
|
11
13
|
export type InboundDropReason =
|
|
12
14
|
| 'self_author' // event.author.id === botUserId; we never route our own messages back to ourselves
|
|
13
15
|
| 'empty_content' // SDK delivered content: '' — usually missing MessageContent intent
|
|
@@ -87,6 +89,7 @@ export function classifyInbound(
|
|
|
87
89
|
text,
|
|
88
90
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
89
91
|
externalMessageId: event.id,
|
|
92
|
+
reactionRef: encodeDiscordReactionRef({ channel: event.channel_id, message: event.id }),
|
|
90
93
|
authorId: event.author.id,
|
|
91
94
|
// Discord's post-2023 username system allows pure-numeric handles (e.g.
|
|
92
95
|
// "1411531"); the human-facing display name lives on `global_name`. The
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { DiscordBotClient } from 'agent-messenger/discordbot'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ReactionCallback,
|
|
5
|
+
ReactionErrorCode,
|
|
6
|
+
ReactionRef,
|
|
7
|
+
ReactionResult,
|
|
8
|
+
RemoveReactionCallback,
|
|
9
|
+
} from '@/channels/types'
|
|
10
|
+
|
|
11
|
+
// The reactable target on Discord: a message is addressed by its channel id
|
|
12
|
+
// plus the message id. The classifier stamps this because both values are on
|
|
13
|
+
// the gateway event but `chat`/`externalMessageId` are the only ones that
|
|
14
|
+
// survive into the routed payload — keeping them paired in an opaque ref means
|
|
15
|
+
// the router/tool never have to reassemble Discord's addressing.
|
|
16
|
+
export type DiscordReactionTarget = { channel: string; message: string }
|
|
17
|
+
|
|
18
|
+
// `RemoveReactionRequest` carries no emoji (the router only round-trips the
|
|
19
|
+
// success ref), so removal needs the resolved unicode folded into the ref:
|
|
20
|
+
// Discord's DELETE is keyed by (channel, message, emoji). Mirrors Slack's
|
|
21
|
+
// removal-ref pattern; GitHub instead carries a per-reaction id.
|
|
22
|
+
export type DiscordReactionRemovalTarget = { channel: string; message: string; emoji: string }
|
|
23
|
+
|
|
24
|
+
export function encodeDiscordReactionRef(target: DiscordReactionTarget): ReactionRef {
|
|
25
|
+
return { adapter: 'discord-bot', value: JSON.stringify(target) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function decodeDiscordReactionRef(ref: ReactionRef): DiscordReactionTarget | null {
|
|
29
|
+
if (ref.adapter !== 'discord-bot') return null
|
|
30
|
+
const parsed = parseRecord(ref.value)
|
|
31
|
+
if (parsed === null || parsed.op !== undefined) return null
|
|
32
|
+
const channel = typeof parsed.channel === 'string' ? parsed.channel : null
|
|
33
|
+
const message = typeof parsed.message === 'string' ? parsed.message : null
|
|
34
|
+
if (channel === null || message === null) return null
|
|
35
|
+
return { channel, message }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function encodeDiscordRemovalRef(target: DiscordReactionRemovalTarget): ReactionRef {
|
|
39
|
+
return { adapter: 'discord-bot', value: JSON.stringify({ op: 'remove', ...target }) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decodeDiscordRemovalRef(ref: ReactionRef): DiscordReactionRemovalTarget | null {
|
|
43
|
+
if (ref.adapter !== 'discord-bot') return null
|
|
44
|
+
const parsed = parseRecord(ref.value)
|
|
45
|
+
if (parsed === null || parsed.op !== 'remove') return null
|
|
46
|
+
const channel = typeof parsed.channel === 'string' ? parsed.channel : null
|
|
47
|
+
const message = typeof parsed.message === 'string' ? parsed.message : null
|
|
48
|
+
const emoji = typeof parsed.emoji === 'string' ? parsed.emoji : null
|
|
49
|
+
if (channel === null || message === null || emoji === null) return null
|
|
50
|
+
return { channel, message, emoji }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Discord's reaction endpoint takes the literal unicode emoji (URL-encoded by
|
|
54
|
+
// the SDK), NOT a `:name:` shortcode — the agent passes adapter-generic bare
|
|
55
|
+
// names (`+1`, `rocket`), so we translate here. The set mirrors GitHub's fixed
|
|
56
|
+
// reaction vocabulary so the same `channel_react({ emoji })` call works on both
|
|
57
|
+
// platforms, plus a few extra chat-native acks. An unmapped name is reported as
|
|
58
|
+
// `unsupported` rather than forwarded, so a typo gets a clear signal instead of
|
|
59
|
+
// Discord's opaque `10014 Unknown Emoji`.
|
|
60
|
+
const EMOJI_UNICODE: Record<string, string> = {
|
|
61
|
+
eyes: '👀',
|
|
62
|
+
'+1': '👍',
|
|
63
|
+
thumbsup: '👍',
|
|
64
|
+
'-1': '👎',
|
|
65
|
+
thumbsdown: '👎',
|
|
66
|
+
laugh: '😄',
|
|
67
|
+
hooray: '🎉',
|
|
68
|
+
tada: '🎉',
|
|
69
|
+
confused: '😕',
|
|
70
|
+
heart: '❤️',
|
|
71
|
+
rocket: '🚀',
|
|
72
|
+
white_check_mark: '✅',
|
|
73
|
+
'white-check-mark': '✅',
|
|
74
|
+
check: '✅',
|
|
75
|
+
fire: '🔥',
|
|
76
|
+
eye: '👁️',
|
|
77
|
+
raised_hands: '🙌',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveEmoji(emoji: string): string | null {
|
|
81
|
+
const name = emoji.replace(/^:|:$/g, '')
|
|
82
|
+
return EMOJI_UNICODE[name] ?? null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createDiscordReactionCallback(deps: {
|
|
86
|
+
client: Pick<DiscordBotClient, 'addReaction'>
|
|
87
|
+
}): ReactionCallback {
|
|
88
|
+
return async (req): Promise<ReactionResult> => {
|
|
89
|
+
if (req.adapter !== 'discord-bot') {
|
|
90
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
91
|
+
}
|
|
92
|
+
const unicode = resolveEmoji(req.emoji)
|
|
93
|
+
if (unicode === null) {
|
|
94
|
+
return { ok: false, error: `discord does not support reaction "${req.emoji}"`, code: 'unsupported' }
|
|
95
|
+
}
|
|
96
|
+
const target = decodeDiscordReactionRef(req.reactionRef)
|
|
97
|
+
if (target === null) return { ok: false, error: 'unparseable discord reaction ref', code: 'unsupported' }
|
|
98
|
+
try {
|
|
99
|
+
await deps.client.addReaction(target.channel, target.message, unicode)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { ok: false, error: describe(err), code: classifyDiscordError(err) }
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, reactionRef: encodeDiscordRemovalRef({ ...target, emoji: unicode }) }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createDiscordRemoveReactionCallback(deps: {
|
|
108
|
+
client: Pick<DiscordBotClient, 'removeReaction'>
|
|
109
|
+
}): RemoveReactionCallback {
|
|
110
|
+
return async (req): Promise<ReactionResult> => {
|
|
111
|
+
if (req.adapter !== 'discord-bot') {
|
|
112
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
113
|
+
}
|
|
114
|
+
const target = decodeDiscordRemovalRef(req.reactionRef)
|
|
115
|
+
if (target === null) return { ok: false, error: 'unparseable discord reaction removal ref', code: 'unsupported' }
|
|
116
|
+
try {
|
|
117
|
+
await deps.client.removeReaction(target.channel, target.message, target.emoji)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { ok: false, error: describe(err), code: classifyDiscordError(err) }
|
|
120
|
+
}
|
|
121
|
+
return { ok: true }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// DiscordBotError exposes the Discord error code on `.code` as a string. We
|
|
126
|
+
// read it structurally so a wrapped/re-thrown error still classifies. The
|
|
127
|
+
// numeric codes are Discord's documented JSON error codes; `http_4xx` is the
|
|
128
|
+
// SDK's fallback when no JSON code is present.
|
|
129
|
+
function classifyDiscordError(err: unknown): ReactionErrorCode {
|
|
130
|
+
const code = typeof err === 'object' && err !== null && 'code' in err ? String((err as { code: unknown }).code) : ''
|
|
131
|
+
switch (code) {
|
|
132
|
+
case '10003': // Unknown Channel
|
|
133
|
+
case '10008': // Unknown Message
|
|
134
|
+
case 'http_404':
|
|
135
|
+
return 'not-found'
|
|
136
|
+
case '50001': // Missing Access
|
|
137
|
+
case '50013': // Missing Permissions
|
|
138
|
+
case 'http_403':
|
|
139
|
+
case 'http_401':
|
|
140
|
+
return 'permission-denied'
|
|
141
|
+
case '10014': // Unknown Emoji
|
|
142
|
+
return 'unsupported'
|
|
143
|
+
case 'http_429':
|
|
144
|
+
return 'rate-limited'
|
|
145
|
+
default:
|
|
146
|
+
return 'transient'
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseRecord(value: string): Record<string, unknown> | null {
|
|
151
|
+
let parsed: unknown
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(value)
|
|
154
|
+
} catch {
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
158
|
+
? (parsed as Record<string, unknown>)
|
|
159
|
+
: null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function describe(err: unknown): string {
|
|
163
|
+
return err instanceof Error ? err.message : String(err)
|
|
164
|
+
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
type InboundDropReason,
|
|
40
40
|
renderPlaceholder,
|
|
41
41
|
} from './discord-bot-classify'
|
|
42
|
+
import { createDiscordReactionCallback, createDiscordRemoveReactionCallback } from './discord-bot-reactions'
|
|
42
43
|
import { enrichDiscordMessageReferences } from './discord-bot-reference'
|
|
43
44
|
import {
|
|
44
45
|
ackInteraction,
|
|
@@ -863,6 +864,9 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
863
864
|
|
|
864
865
|
const fetchAttachmentCallback = createFetchAttachmentCallback({ token: options.token, logger })
|
|
865
866
|
|
|
867
|
+
const reactionCallback = createDiscordReactionCallback({ client })
|
|
868
|
+
const removeReactionCallback = createDiscordRemoveReactionCallback({ client })
|
|
869
|
+
|
|
866
870
|
const interactionHandler = createInteractionHandler({
|
|
867
871
|
router: options.router,
|
|
868
872
|
knownCommandNames: SLASH_COMMAND_NAMES,
|
|
@@ -999,6 +1003,8 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
999
1003
|
})
|
|
1000
1004
|
|
|
1001
1005
|
options.router.registerOutbound('discord-bot', outboundCallback)
|
|
1006
|
+
options.router.registerReaction('discord-bot', reactionCallback)
|
|
1007
|
+
options.router.registerRemoveReaction('discord-bot', removeReactionCallback)
|
|
1002
1008
|
options.router.registerTyping('discord-bot', typingCallback)
|
|
1003
1009
|
options.router.registerChannelNameResolver('discord-bot', channelResolver)
|
|
1004
1010
|
options.router.registerSelfIdentity('discord-bot', selfIdentityResolver)
|
|
@@ -1009,6 +1015,21 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
1009
1015
|
try {
|
|
1010
1016
|
await listener.start()
|
|
1011
1017
|
} catch (err) {
|
|
1018
|
+
// Listener failed after registration — roll back every callback so a
|
|
1019
|
+
// failed start leaves no router state behind (stop() returns early on
|
|
1020
|
+
// !started and would otherwise skip cleanup), mirroring the github
|
|
1021
|
+
// adapter's rollback path.
|
|
1022
|
+
options.router.unregisterOutbound('discord-bot', outboundCallback)
|
|
1023
|
+
options.router.unregisterReaction('discord-bot', reactionCallback)
|
|
1024
|
+
options.router.unregisterRemoveReaction('discord-bot', removeReactionCallback)
|
|
1025
|
+
options.router.unregisterTyping('discord-bot', typingCallback)
|
|
1026
|
+
options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
|
|
1027
|
+
options.router.unregisterSelfIdentity('discord-bot', selfIdentityResolver)
|
|
1028
|
+
options.router.unregisterHistory('discord-bot', historyCallback)
|
|
1029
|
+
options.router.unregisterFetchAttachment('discord-bot', fetchAttachmentCallback)
|
|
1030
|
+
options.router.unregisterMembership('discord-bot', membershipResolver)
|
|
1031
|
+
listener = null
|
|
1032
|
+
botUserId = null
|
|
1012
1033
|
started = false
|
|
1013
1034
|
logger.error(`[discord-bot] listener start failed: ${describe(err)}`)
|
|
1014
1035
|
throw err
|
|
@@ -1019,6 +1040,8 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
|
|
|
1019
1040
|
if (!started) return
|
|
1020
1041
|
started = false
|
|
1021
1042
|
options.router.unregisterOutbound('discord-bot', outboundCallback)
|
|
1043
|
+
options.router.unregisterReaction('discord-bot', reactionCallback)
|
|
1044
|
+
options.router.unregisterRemoveReaction('discord-bot', removeReactionCallback)
|
|
1022
1045
|
options.router.unregisterTyping('discord-bot', typingCallback)
|
|
1023
1046
|
options.router.unregisterChannelNameResolver('discord-bot', channelResolver)
|
|
1024
1047
|
options.router.unregisterSelfIdentity('discord-bot', selfIdentityResolver)
|
|
@@ -301,7 +301,12 @@ export function classifyGithubInbound(
|
|
|
301
301
|
teamIsBotMember: options?.teamIsBotMember,
|
|
302
302
|
})
|
|
303
303
|
}
|
|
304
|
-
|
|
304
|
+
// `ready_for_review` (draft→ready) is a fresh review opportunity, so it
|
|
305
|
+
// reuses the `opened` paths: same review trigger, same title-only awareness
|
|
306
|
+
// text. The draft skip in classifyOpenedReviewTrigger is a no-op here since
|
|
307
|
+
// the PR is non-draft once ready — preserving "review when no longer draft".
|
|
308
|
+
const isOpenLike = action === 'opened' || action === 'ready_for_review'
|
|
309
|
+
if (isOpenLike && reviewOn === 'opened') {
|
|
305
310
|
const trigger = classifyOpenedReviewTrigger({
|
|
306
311
|
payload,
|
|
307
312
|
pr,
|
|
@@ -314,8 +319,7 @@ export function classifyGithubInbound(
|
|
|
314
319
|
}
|
|
315
320
|
const opener = readUser(pr.user)
|
|
316
321
|
const hasBody = readString(pr, 'body')?.trim() ? true : false
|
|
317
|
-
const prText =
|
|
318
|
-
action === 'opened' ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
|
|
322
|
+
const prText = isOpenLike ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
|
|
319
323
|
return buildInbound(
|
|
320
324
|
{ ...base, chat: `pr:${number}`, thread: null },
|
|
321
325
|
prText,
|
|
@@ -324,7 +328,7 @@ export function classifyGithubInbound(
|
|
|
324
328
|
selfLogin,
|
|
325
329
|
pr.created_at,
|
|
326
330
|
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
327
|
-
|
|
331
|
+
isOpenLike && !hasBody,
|
|
328
332
|
)
|
|
329
333
|
}
|
|
330
334
|
|
|
@@ -494,6 +498,12 @@ function classifyOpenedReviewTrigger(input: OpenedReviewTriggerInput): InboundMe
|
|
|
494
498
|
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
|
|
495
499
|
if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
|
|
496
500
|
|
|
501
|
+
// A draft PR is work-in-progress, so the automatic `opened` path skips it: null
|
|
502
|
+
// here drops to awareness-only context (like a non-`opened` reviewOn) instead of
|
|
503
|
+
// waking a review. An explicit `review_requested` still triggers on a draft via
|
|
504
|
+
// classifyReviewRequest, preserving "skip until explicitly requested".
|
|
505
|
+
if (readBoolean(pr, 'draft') === true) return null
|
|
506
|
+
|
|
497
507
|
const title = readString(pr, 'title') ?? `#${number}`
|
|
498
508
|
const head = readString(readRecord(pr.head), 'ref')
|
|
499
509
|
const baseRef = readString(readRecord(pr.base), 'ref')
|
|
@@ -738,6 +748,11 @@ function readNumber(obj: Record<string, unknown> | null, key: string): number |
|
|
|
738
748
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
739
749
|
}
|
|
740
750
|
|
|
751
|
+
function readBoolean(obj: Record<string, unknown> | null, key: string): boolean | null {
|
|
752
|
+
const value = obj?.[key]
|
|
753
|
+
return typeof value === 'boolean' ? value : null
|
|
754
|
+
}
|
|
755
|
+
|
|
741
756
|
function ok(): Response {
|
|
742
757
|
return new Response('ok', { status: 200 })
|
|
743
758
|
}
|
|
@@ -54,17 +54,25 @@ export async function registerGithubWebhooks(
|
|
|
54
54
|
options: RegisterGithubWebhooksOptions,
|
|
55
55
|
): Promise<WebhookRegistrationResult> {
|
|
56
56
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
57
|
+
// Dedupe before fanning out: the serial loop self-corrected on a repeated
|
|
58
|
+
// repo (the second pass saw the first pass's hook and updated it), but
|
|
59
|
+
// concurrent passes would both list an empty set and each POST a hook,
|
|
60
|
+
// creating a duplicate. Collapsing to distinct slugs restores convergence.
|
|
61
|
+
const distinctRepos = [...new Set(options.repos)]
|
|
62
|
+
// Repos are independent (own installation token, own hooks), so register them
|
|
63
|
+
// concurrently. Every task resolves to a result (failures are caught into a
|
|
64
|
+
// `failed` entry, never thrown), so the batch never rejects and order is kept.
|
|
65
|
+
const repos = await Promise.all(
|
|
66
|
+
distinctRepos.map(async (repo): Promise<WebhookRepoResult> => {
|
|
67
|
+
let token: string
|
|
68
|
+
try {
|
|
69
|
+
token = await options.token(repo)
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { repo, action: 'failed', error: describe(err) }
|
|
72
|
+
}
|
|
73
|
+
return registerOne(fetchImpl, token, repo, options)
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
68
76
|
return { repos }
|
|
69
77
|
}
|
|
70
78
|
|
|
@@ -82,17 +90,17 @@ export async function deregisterGithubWebhooks(
|
|
|
82
90
|
options: DeregisterGithubWebhooksOptions,
|
|
83
91
|
): Promise<WebhookDeregistrationResult> {
|
|
84
92
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
85
|
-
const hooks
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
const hooks = await Promise.all(
|
|
94
|
+
options.hooks.map(async (hook): Promise<WebhookDeregistrationResult['hooks'][number]> => {
|
|
95
|
+
let token: string
|
|
96
|
+
try {
|
|
97
|
+
token = await options.token(hook.repo)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { ...hook, action: 'failed', error: describe(err) }
|
|
100
|
+
}
|
|
101
|
+
return deleteOne(fetchImpl, token, hook)
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
96
104
|
return { hooks }
|
|
97
105
|
}
|
|
98
106
|
|
|
@@ -125,11 +133,8 @@ async function registerOne(
|
|
|
125
133
|
// inspecting the repo's webhook list.
|
|
126
134
|
const [keep, ...stale] = owned.slice().sort((a, b) => a - b)
|
|
127
135
|
await updateHook(fetchImpl, token, parsed, keep!, options)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const ok = await tryDeleteHook(fetchImpl, token, parsed, id)
|
|
131
|
-
if (ok) stalePruned++
|
|
132
|
-
}
|
|
136
|
+
const pruned = await Promise.all(stale.map((id) => tryDeleteHook(fetchImpl, token, parsed, id)))
|
|
137
|
+
const stalePruned = pruned.filter(Boolean).length
|
|
133
138
|
return { repo, action: 'updated', hookId: keep!, stalePruned }
|
|
134
139
|
} catch (err) {
|
|
135
140
|
return { repo, action: 'failed', error: describe(err) }
|
|
@@ -4,6 +4,7 @@ import { matchesAnyAlias } from '@/channels/engagement'
|
|
|
4
4
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
5
5
|
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
6
6
|
|
|
7
|
+
import { encodeSlackReactionRef } from './slack-bot-reactions'
|
|
7
8
|
import { hasSlackMessageShareAttachments } from './slack-bot-reference'
|
|
8
9
|
import { slackTsToMillis } from './slack-bot-time'
|
|
9
10
|
|
|
@@ -141,6 +142,7 @@ export function classifyInbound(
|
|
|
141
142
|
text,
|
|
142
143
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
143
144
|
externalMessageId: event.ts,
|
|
145
|
+
reactionRef: encodeSlackReactionRef({ channel: event.channel, ts: event.ts }),
|
|
144
146
|
authorId: event.user,
|
|
145
147
|
authorName: event.user,
|
|
146
148
|
authorIsBot,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { SlackBotClient } from 'agent-messenger/slackbot'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ReactionCallback,
|
|
5
|
+
ReactionErrorCode,
|
|
6
|
+
ReactionRef,
|
|
7
|
+
ReactionResult,
|
|
8
|
+
RemoveReactionCallback,
|
|
9
|
+
} from '@/channels/types'
|
|
10
|
+
|
|
11
|
+
// The reactable target on Slack: a message is addressed by its channel id plus
|
|
12
|
+
// the message `ts`. The classifier stamps this because `ts` is the inbound's
|
|
13
|
+
// own message timestamp — the same value that becomes `externalMessageId`
|
|
14
|
+
// downstream, but kept in an opaque ref so the router/tool never have to know
|
|
15
|
+
// Slack's addressing. Mirrors the GitHub `GithubReactionTarget` precedent.
|
|
16
|
+
export type SlackReactionTarget = { channel: string; ts: string }
|
|
17
|
+
|
|
18
|
+
// Removal needs the emoji name too: Slack's `reactions.remove` is keyed by
|
|
19
|
+
// (channel, ts, name), unlike GitHub's per-reaction id. We fold the emoji that
|
|
20
|
+
// was added into the removal ref so `RemoveReactionCallback` can reconstruct
|
|
21
|
+
// the exact call without the caller tracking it.
|
|
22
|
+
export type SlackReactionRemovalTarget = { channel: string; ts: string; emoji: string }
|
|
23
|
+
|
|
24
|
+
export function encodeSlackReactionRef(target: SlackReactionTarget): ReactionRef {
|
|
25
|
+
return { adapter: 'slack-bot', value: JSON.stringify(target) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function decodeSlackReactionRef(ref: ReactionRef): SlackReactionTarget | null {
|
|
29
|
+
if (ref.adapter !== 'slack-bot') return null
|
|
30
|
+
const parsed = parseRecord(ref.value)
|
|
31
|
+
if (parsed === null) return null
|
|
32
|
+
if (parsed.op !== undefined) return null
|
|
33
|
+
const channel = typeof parsed.channel === 'string' ? parsed.channel : null
|
|
34
|
+
const ts = typeof parsed.ts === 'string' ? parsed.ts : null
|
|
35
|
+
if (channel === null || ts === null) return null
|
|
36
|
+
return { channel, ts }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function encodeSlackRemovalRef(target: SlackReactionRemovalTarget): ReactionRef {
|
|
40
|
+
return { adapter: 'slack-bot', value: JSON.stringify({ op: 'remove', ...target }) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function decodeSlackRemovalRef(ref: ReactionRef): SlackReactionRemovalTarget | null {
|
|
44
|
+
if (ref.adapter !== 'slack-bot') return null
|
|
45
|
+
const parsed = parseRecord(ref.value)
|
|
46
|
+
if (parsed === null || parsed.op !== 'remove') return null
|
|
47
|
+
const channel = typeof parsed.channel === 'string' ? parsed.channel : null
|
|
48
|
+
const ts = typeof parsed.ts === 'string' ? parsed.ts : null
|
|
49
|
+
const emoji = typeof parsed.emoji === 'string' ? parsed.emoji : null
|
|
50
|
+
if (channel === null || ts === null || emoji === null) return null
|
|
51
|
+
return { channel, ts, emoji }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Slack accepts any custom-emoji name the workspace has, so unlike GitHub there
|
|
55
|
+
// is no fixed allow-list to validate against up front — an unknown name comes
|
|
56
|
+
// back as `invalid_name` from the API, which we map to `unsupported`. We only
|
|
57
|
+
// strip surrounding colons here; the SDK does the same, but normalizing first
|
|
58
|
+
// keeps the removal ref's stored name canonical.
|
|
59
|
+
function normalizeEmoji(emoji: string): string {
|
|
60
|
+
return emoji.replace(/^:|:$/g, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createSlackReactionCallback(deps: { client: Pick<SlackBotClient, 'addReaction'> }): ReactionCallback {
|
|
64
|
+
return async (req): Promise<ReactionResult> => {
|
|
65
|
+
if (req.adapter !== 'slack-bot') {
|
|
66
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
67
|
+
}
|
|
68
|
+
const target = decodeSlackReactionRef(req.reactionRef)
|
|
69
|
+
if (target === null) return { ok: false, error: 'unparseable slack reaction ref', code: 'unsupported' }
|
|
70
|
+
const emoji = normalizeEmoji(req.emoji)
|
|
71
|
+
try {
|
|
72
|
+
await deps.client.addReaction(target.channel, target.ts, emoji)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// `already_reacted` is the desired end state, not a failure: a duplicate
|
|
75
|
+
// engage (or a retried tool call) that re-adds the same emoji must read
|
|
76
|
+
// as success so the model/runtime don't surface a spurious error.
|
|
77
|
+
const code = slackErrorCode(err)
|
|
78
|
+
if (code === 'already_reacted') {
|
|
79
|
+
return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, error: withScopeHint(code, describe(err)), code: classifySlackError(code) }
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, reactionRef: encodeSlackRemovalRef({ ...target, emoji }) }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createSlackRemoveReactionCallback(deps: {
|
|
88
|
+
client: Pick<SlackBotClient, 'removeReaction'>
|
|
89
|
+
}): RemoveReactionCallback {
|
|
90
|
+
return async (req): Promise<ReactionResult> => {
|
|
91
|
+
if (req.adapter !== 'slack-bot') {
|
|
92
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
93
|
+
}
|
|
94
|
+
const target = decodeSlackRemovalRef(req.reactionRef)
|
|
95
|
+
if (target === null) return { ok: false, error: 'unparseable slack reaction removal ref', code: 'unsupported' }
|
|
96
|
+
try {
|
|
97
|
+
await deps.client.removeReaction(target.channel, target.ts, target.emoji)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// `no_reaction` means the reaction is already gone — the desired end
|
|
100
|
+
// state for a removal, so treat it as success (idempotent), mirroring the
|
|
101
|
+
// `already_reacted` handling on the add path.
|
|
102
|
+
const code = slackErrorCode(err)
|
|
103
|
+
if (code === 'no_reaction') return { ok: true }
|
|
104
|
+
return { ok: false, error: describe(err), code: classifySlackError(code) }
|
|
105
|
+
}
|
|
106
|
+
return { ok: true }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// SlackBotError carries the raw Slack API error string on `.code`. We read it
|
|
111
|
+
// structurally (not by instanceof) so a re-thrown or wrapped error still maps
|
|
112
|
+
// correctly, falling back to the message when no code is present.
|
|
113
|
+
function slackErrorCode(err: unknown): string | null {
|
|
114
|
+
if (typeof err === 'object' && err !== null && 'code' in err) {
|
|
115
|
+
const code = (err as { code: unknown }).code
|
|
116
|
+
if (typeof code === 'string') return code
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// `reactions:write` is the scope the bot token needs for both add and remove.
|
|
122
|
+
// On `missing_scope` the bare Slack error is uninformative, so append the
|
|
123
|
+
// concrete operator fix — mirroring GitHub's permission-guidance precedent —
|
|
124
|
+
// since autoReactOnEngage surfaces this to host logs on every engaged inbound
|
|
125
|
+
// until the scope is granted.
|
|
126
|
+
function withScopeHint(code: string | null, error: string): string {
|
|
127
|
+
if (code !== 'missing_scope') return error
|
|
128
|
+
return `${error} (Slack bot token needs the \`reactions:write\` scope; reinstall/reauthorize the app with that scope.)`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function classifySlackError(code: string | null): ReactionErrorCode {
|
|
132
|
+
switch (code) {
|
|
133
|
+
case 'invalid_name':
|
|
134
|
+
case 'no_item_specified':
|
|
135
|
+
return 'unsupported'
|
|
136
|
+
case 'missing_scope':
|
|
137
|
+
case 'not_in_channel':
|
|
138
|
+
case 'is_archived':
|
|
139
|
+
case 'not_authed':
|
|
140
|
+
case 'invalid_auth':
|
|
141
|
+
return 'permission-denied'
|
|
142
|
+
case 'message_not_found':
|
|
143
|
+
case 'channel_not_found':
|
|
144
|
+
return 'not-found'
|
|
145
|
+
case 'ratelimited':
|
|
146
|
+
case 'rate_limited':
|
|
147
|
+
return 'rate-limited'
|
|
148
|
+
default:
|
|
149
|
+
return 'transient'
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseRecord(value: string): Record<string, unknown> | null {
|
|
154
|
+
let parsed: unknown
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(value)
|
|
157
|
+
} catch {
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
161
|
+
? (parsed as Record<string, unknown>)
|
|
162
|
+
: null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function describe(err: unknown): string {
|
|
166
|
+
return err instanceof Error ? err.message : String(err)
|
|
167
|
+
}
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
type SlackInboundMessageEvent,
|
|
44
44
|
} from './slack-bot-classify'
|
|
45
45
|
import { createSlackDedupe } from './slack-bot-dedupe'
|
|
46
|
+
import { createSlackReactionCallback, createSlackRemoveReactionCallback } from './slack-bot-reactions'
|
|
46
47
|
import { enrichSlackReferenceContext } from './slack-bot-reference'
|
|
47
48
|
import {
|
|
48
49
|
buildSlashAckPayload,
|
|
@@ -997,6 +998,9 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
997
998
|
|
|
998
999
|
const fetchAttachmentCallback = createFetchAttachmentCallback({ client, logger })
|
|
999
1000
|
|
|
1001
|
+
const reactionCallback = createSlackReactionCallback({ client })
|
|
1002
|
+
const removeReactionCallback = createSlackRemoveReactionCallback({ client })
|
|
1003
|
+
|
|
1000
1004
|
const dedupe = createSlackDedupe()
|
|
1001
1005
|
|
|
1002
1006
|
const handleSlashCommand = createSlashCommandHandler({
|
|
@@ -1206,6 +1210,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1206
1210
|
})
|
|
1207
1211
|
|
|
1208
1212
|
options.router.registerOutbound('slack-bot', outboundCallback)
|
|
1213
|
+
options.router.registerReaction('slack-bot', reactionCallback)
|
|
1214
|
+
options.router.registerRemoveReaction('slack-bot', removeReactionCallback)
|
|
1209
1215
|
options.router.registerTyping('slack-bot', typingCallback)
|
|
1210
1216
|
options.router.registerChannelNameResolver('slack-bot', channelResolver)
|
|
1211
1217
|
options.router.registerSelfIdentity('slack-bot', selfIdentityResolver)
|
|
@@ -1216,6 +1222,22 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1216
1222
|
try {
|
|
1217
1223
|
await listener.start()
|
|
1218
1224
|
} catch (err) {
|
|
1225
|
+
// Listener failed after registration — roll back every callback so a
|
|
1226
|
+
// failed start leaves no router state behind (stop() returns early on
|
|
1227
|
+
// !started and would otherwise skip cleanup), mirroring the github
|
|
1228
|
+
// adapter's rollback path.
|
|
1229
|
+
options.router.unregisterOutbound('slack-bot', outboundCallback)
|
|
1230
|
+
options.router.unregisterReaction('slack-bot', reactionCallback)
|
|
1231
|
+
options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
|
|
1232
|
+
options.router.unregisterTyping('slack-bot', typingCallback)
|
|
1233
|
+
options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
|
|
1234
|
+
options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
|
|
1235
|
+
options.router.unregisterHistory('slack-bot', historyCallback)
|
|
1236
|
+
options.router.unregisterFetchAttachment('slack-bot', fetchAttachmentCallback)
|
|
1237
|
+
options.router.unregisterMembership('slack-bot', membershipResolver)
|
|
1238
|
+
listener = null
|
|
1239
|
+
botUserId = null
|
|
1240
|
+
teamId = null
|
|
1219
1241
|
started = false
|
|
1220
1242
|
logger.error(`[slack-bot] listener start failed: ${describe(err)}`)
|
|
1221
1243
|
throw err
|
|
@@ -1226,6 +1248,8 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1226
1248
|
if (!started) return
|
|
1227
1249
|
started = false
|
|
1228
1250
|
options.router.unregisterOutbound('slack-bot', outboundCallback)
|
|
1251
|
+
options.router.unregisterReaction('slack-bot', reactionCallback)
|
|
1252
|
+
options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
|
|
1229
1253
|
options.router.unregisterTyping('slack-bot', typingCallback)
|
|
1230
1254
|
options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
|
|
1231
1255
|
options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
|