typeclaw 0.25.0 → 0.27.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/package.json +1 -1
- package/src/agent/session-origin.ts +36 -5
- package/src/agent/subagent-completion-reminder.ts +16 -1
- package/src/agent/tools/channel-react.ts +11 -4
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -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 +60 -13
- package/src/channels/adapters/github/review-thread-resolver.ts +28 -3
- 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 +191 -7
- package/src/channels/schema.ts +41 -0
- package/src/cli/inspect.ts +216 -36
- package/src/cli/logs.ts +15 -0
- package/src/cli/tui.ts +33 -39
- package/src/compose/logs.ts +1 -1
- package/src/config/config.ts +43 -2
- package/src/container/logs.ts +70 -22
- package/src/init/index.ts +3 -3
- package/src/inspect/index.ts +128 -42
- package/src/inspect/item-list.ts +44 -0
- package/src/inspect/item.ts +17 -0
- package/src/inspect/label.ts +1 -1
- package/src/inspect/logs-item.ts +79 -0
- package/src/inspect/loop.ts +74 -3
- package/src/inspect/open-item.ts +100 -0
- package/src/inspect/preview.ts +106 -0
- package/src/inspect/session-list.ts +15 -3
- package/src/inspect/transcript-view.ts +182 -0
- package/src/inspect/tui-item.ts +97 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
- package/src/tui/index.ts +72 -32
- package/typeclaw.schema.json +1 -0
package/package.json
CHANGED
|
@@ -123,14 +123,20 @@ export const PARTICIPANTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
123
123
|
type PlatformInfo = {
|
|
124
124
|
displayName: string
|
|
125
125
|
mentionMode: 'angle-id' | 'at-username' | 'alias'
|
|
126
|
+
// Whether this adapter registers a ReactionCallback, i.e. whether
|
|
127
|
+
// `channel_react` actually does anything here. Gates the proactive-reaction
|
|
128
|
+
// prompt guidance so we never tell a KakaoTalk/Telegram agent to react when
|
|
129
|
+
// the call would no-op. Keep in sync with the adapters that call
|
|
130
|
+
// `router.registerReaction` (github, slack-bot, discord-bot today).
|
|
131
|
+
supportsReactions: boolean
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
const PLATFORM_INFO: Record<AdapterId, PlatformInfo> = {
|
|
129
|
-
'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id' },
|
|
130
|
-
'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id' },
|
|
131
|
-
github: { displayName: 'GitHub', mentionMode: 'at-username' },
|
|
132
|
-
'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username' },
|
|
133
|
-
kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias' },
|
|
135
|
+
'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id', supportsReactions: true },
|
|
136
|
+
'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id', supportsReactions: true },
|
|
137
|
+
github: { displayName: 'GitHub', mentionMode: 'at-username', supportsReactions: true },
|
|
138
|
+
'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username', supportsReactions: false },
|
|
139
|
+
kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias', supportsReactions: false },
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
function getPlatformInfo(adapter: AdapterId): PlatformInfo {
|
|
@@ -291,6 +297,7 @@ function renderChannelOrigin(
|
|
|
291
297
|
chat: string
|
|
292
298
|
chatName?: string
|
|
293
299
|
thread: string | null
|
|
300
|
+
reactionRef?: ReactionRef
|
|
294
301
|
participants?: readonly ChannelParticipant[]
|
|
295
302
|
membership?: MembershipCount
|
|
296
303
|
self?: ChannelSelfIdentity
|
|
@@ -348,6 +355,30 @@ function renderChannelOrigin(
|
|
|
348
355
|
const conversationLine = renderConversationLine(origin)
|
|
349
356
|
if (conversationLine !== null) lines.push('', conversationLine)
|
|
350
357
|
|
|
358
|
+
// Gate on `reactionRef`, not just the static `supportsReactions` platform
|
|
359
|
+
// fact: a turn only has a message to react to when the triggering inbound
|
|
360
|
+
// carried one. Reminder-only turns (restart-resume, subagent-completion,
|
|
361
|
+
// idle/todo continuation) wake the session with no inbound, so
|
|
362
|
+
// `buildLiveOrigin` omits `reactionRef`. Prompting "react like a teammate"
|
|
363
|
+
// there made the model call `channel_react`, which then denied with "this
|
|
364
|
+
// conversation has no message to react to".
|
|
365
|
+
if (platformInfo.supportsReactions && origin.reactionRef !== undefined) {
|
|
366
|
+
lines.push(
|
|
367
|
+
'',
|
|
368
|
+
'**React like a teammate would.** You can drop an emoji on the message that',
|
|
369
|
+
'triggered this turn with `channel_react({ emoji })` — it posts no comment,',
|
|
370
|
+
'just a reaction. Read the message and pick what genuinely fits its tone:',
|
|
371
|
+
'`+1` to agree or approve, `rocket` for something shipping or exciting,',
|
|
372
|
+
'`tada` to celebrate, `heart` to show appreciation, `laugh` for something',
|
|
373
|
+
'funny, `eyes` to signal you are looking. Reach for it when a reaction adds',
|
|
374
|
+
'real warmth or signal — not on every message, and not just because you can.',
|
|
375
|
+
'A reaction does NOT satisfy the reply obligation below: when the message',
|
|
376
|
+
'needs a substantive answer, still send it via `channel_reply`. Think of',
|
|
377
|
+
'reactions as the lightweight, human layer on top of your words, not a',
|
|
378
|
+
'replacement for them.',
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
351
382
|
lines.push(
|
|
352
383
|
'',
|
|
353
384
|
'**For every user message in this session, you MUST call `channel_reply`',
|
|
@@ -16,6 +16,7 @@ export type CompletionReminderArgs = {
|
|
|
16
16
|
durationMs: number
|
|
17
17
|
error?: string
|
|
18
18
|
channel?: boolean
|
|
19
|
+
adapter?: string
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const CHANNEL_REPLY_NUDGE =
|
|
@@ -28,9 +29,23 @@ const CHANNEL_REPLY_NUDGE =
|
|
|
28
29
|
'can see why the post-completion turn was silent. `NO_REPLY` is the legacy fallback only when ' +
|
|
29
30
|
'`skip_response` is unavailable.'
|
|
30
31
|
|
|
32
|
+
// Conditional carve-out for github channel sessions. The base nudge above
|
|
33
|
+
// steers EVERY completion to `channel_reply`, which on github is a plain PR
|
|
34
|
+
// comment — fine for most subagents, but wrong for a finished `reviewer`:
|
|
35
|
+
// a verdict delivered as a comment leaves the PR "awaiting review" with no
|
|
36
|
+
// formal approval. This reminder cannot tell a review turn from any other
|
|
37
|
+
// completion, so the carve-out is phrased conditionally ("if this was a PR
|
|
38
|
+
// review") to redirect only the review case without misleading the rest.
|
|
39
|
+
const GITHUB_REVIEW_NUDGE =
|
|
40
|
+
'If this was a PR review, the verdict is a formal review, not a `channel_reply`: ' +
|
|
41
|
+
'post it via `gh api -X POST /repos/owner/repo/pulls/<N>/reviews` (APPROVE/REQUEST_CHANGES/COMMENT) ' +
|
|
42
|
+
'and end the turn with `skip_response`. A `channel_reply` that merely says "Approved" posts a ' +
|
|
43
|
+
'comment and leaves the PR awaiting review.'
|
|
44
|
+
|
|
31
45
|
export function renderSubagentCompletionReminder(args: CompletionReminderArgs): string {
|
|
32
46
|
const durationStr = formatReminderDuration(args.durationMs)
|
|
33
|
-
const
|
|
47
|
+
const githubTail = args.channel === true && args.adapter === 'github' ? ` ${GITHUB_REVIEW_NUDGE}` : ''
|
|
48
|
+
const channelTail = args.channel === true ? ` ${CHANNEL_REPLY_NUDGE}${githubTail}` : ''
|
|
34
49
|
if (args.ok) {
|
|
35
50
|
return (
|
|
36
51
|
`<system-reminder>\n` +
|
|
@@ -31,12 +31,19 @@ export function createChannelReactTool({
|
|
|
31
31
|
name: 'channel_react',
|
|
32
32
|
label: 'Channel React',
|
|
33
33
|
description:
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
34
|
+
'React to the message that triggered this turn with an emoji that fits its content or tone — a lightweight, ' +
|
|
35
|
+
'human touch that posts no comment. Works on GitHub (issue/PR/comment), Slack, and Discord. ' +
|
|
36
|
+
'Pick the reaction a thoughtful teammate would leave: :+1: to agree or approve, :rocket: for something ' +
|
|
37
|
+
'shipping or exciting, :tada: to celebrate, :heart: to show appreciation, :eyes: to signal "I am looking at this", ' +
|
|
38
|
+
':laugh: for something funny. Use it when a reaction adds genuine social signal — not on every message. ' +
|
|
39
|
+
'A reaction does NOT replace `channel_reply`: when the message needs a substantive answer, still send one. ' +
|
|
40
|
+
'If a reaction alone is the whole response, call `skip_response` afterward so the turn ends cleanly. ' +
|
|
41
|
+
'Pass the bare emoji name, no colons.',
|
|
37
42
|
parameters: Type.Object({
|
|
38
43
|
emoji: Type.String({
|
|
39
|
-
description:
|
|
44
|
+
description:
|
|
45
|
+
'Bare emoji name, no surrounding colons. Choose one that matches the message: ' +
|
|
46
|
+
'e.g. "+1", "rocket", "tada", "heart", "eyes", "laugh".',
|
|
40
47
|
minLength: 1,
|
|
41
48
|
}),
|
|
42
49
|
}),
|
|
@@ -66,7 +66,9 @@ Prioritize in this order:
|
|
|
66
66
|
|
|
67
67
|
### Re-reviews must re-decide, not observe
|
|
68
68
|
|
|
69
|
-
When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes
|
|
69
|
+
When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes — your verdict's whole purpose is to **re-decide the blocking state**, so:
|
|
70
|
+
|
|
71
|
+
This includes payloads where the parent says the author **addressed your prior blocking feedback** — "fixed both issues", "addressed your review", "pushed a fix" — even when the inbound was phrased conversationally rather than as an explicit "review again". An author responding to the blocker you raised IS the re-review trigger; the absence of the words "review again" does not downgrade it to a \`comment\`. Re-decide:
|
|
70
72
|
|
|
71
73
|
- Return **approve** if the blockers that drove the prior \`request-changes\` are resolved (leftover nits do not block — \`approve\` with inline nits is correct).
|
|
72
74
|
- Return **request-changes** if any blocker remains or a new one appeared.
|
|
@@ -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)
|
|
@@ -187,6 +187,7 @@ export function classifyGithubInbound(
|
|
|
187
187
|
): InboundMessage | null {
|
|
188
188
|
const repository = readRepository(payload)
|
|
189
189
|
if (repository === null) return null
|
|
190
|
+
const mention = resolveBotMentionLogins(selfLogin, options?.authType ?? 'pat')
|
|
190
191
|
const base = {
|
|
191
192
|
adapter: 'github' as const,
|
|
192
193
|
workspace: `${repository.owner}/${repository.name}`,
|
|
@@ -209,7 +210,7 @@ export function classifyGithubInbound(
|
|
|
209
210
|
comment.body,
|
|
210
211
|
id,
|
|
211
212
|
user,
|
|
212
|
-
|
|
213
|
+
mention,
|
|
213
214
|
comment.created_at,
|
|
214
215
|
{ kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
215
216
|
)
|
|
@@ -228,7 +229,7 @@ export function classifyGithubInbound(
|
|
|
228
229
|
comment.body,
|
|
229
230
|
id,
|
|
230
231
|
readUser(comment.user),
|
|
231
|
-
|
|
232
|
+
mention,
|
|
232
233
|
comment.created_at,
|
|
233
234
|
{ kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
234
235
|
)
|
|
@@ -246,7 +247,7 @@ export function classifyGithubInbound(
|
|
|
246
247
|
comment.body,
|
|
247
248
|
id,
|
|
248
249
|
readUser(comment.user),
|
|
249
|
-
|
|
250
|
+
mention,
|
|
250
251
|
comment.created_at,
|
|
251
252
|
null,
|
|
252
253
|
)
|
|
@@ -270,7 +271,7 @@ export function classifyGithubInbound(
|
|
|
270
271
|
text,
|
|
271
272
|
id,
|
|
272
273
|
opener,
|
|
273
|
-
|
|
274
|
+
mention,
|
|
274
275
|
issue.created_at,
|
|
275
276
|
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
276
277
|
action === 'opened' && !hasBody,
|
|
@@ -301,7 +302,12 @@ export function classifyGithubInbound(
|
|
|
301
302
|
teamIsBotMember: options?.teamIsBotMember,
|
|
302
303
|
})
|
|
303
304
|
}
|
|
304
|
-
|
|
305
|
+
// `ready_for_review` (draft→ready) is a fresh review opportunity, so it
|
|
306
|
+
// reuses the `opened` paths: same review trigger, same title-only awareness
|
|
307
|
+
// text. The draft skip in classifyOpenedReviewTrigger is a no-op here since
|
|
308
|
+
// the PR is non-draft once ready — preserving "review when no longer draft".
|
|
309
|
+
const isOpenLike = action === 'opened' || action === 'ready_for_review'
|
|
310
|
+
if (isOpenLike && reviewOn === 'opened') {
|
|
305
311
|
const trigger = classifyOpenedReviewTrigger({
|
|
306
312
|
payload,
|
|
307
313
|
pr,
|
|
@@ -314,17 +320,16 @@ export function classifyGithubInbound(
|
|
|
314
320
|
}
|
|
315
321
|
const opener = readUser(pr.user)
|
|
316
322
|
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
|
|
323
|
+
const prText = isOpenLike ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
|
|
319
324
|
return buildInbound(
|
|
320
325
|
{ ...base, chat: `pr:${number}`, thread: null },
|
|
321
326
|
prText,
|
|
322
327
|
id,
|
|
323
328
|
opener,
|
|
324
|
-
|
|
329
|
+
mention,
|
|
325
330
|
pr.created_at,
|
|
326
331
|
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
327
|
-
|
|
332
|
+
isOpenLike && !hasBody,
|
|
328
333
|
)
|
|
329
334
|
}
|
|
330
335
|
|
|
@@ -348,7 +353,7 @@ export function classifyGithubInbound(
|
|
|
348
353
|
text,
|
|
349
354
|
id,
|
|
350
355
|
reviewer,
|
|
351
|
-
|
|
356
|
+
mention,
|
|
352
357
|
review.submitted_at,
|
|
353
358
|
null,
|
|
354
359
|
!hasBody,
|
|
@@ -373,7 +378,7 @@ export function classifyGithubInbound(
|
|
|
373
378
|
text,
|
|
374
379
|
id,
|
|
375
380
|
opener,
|
|
376
|
-
|
|
381
|
+
mention,
|
|
377
382
|
discussion.created_at,
|
|
378
383
|
null,
|
|
379
384
|
action === 'created' && !hasBody,
|
|
@@ -413,6 +418,48 @@ function resolveDecoyReviewerLogin(selfLogin: string, authType: 'pat' | 'app'):
|
|
|
413
418
|
return slug !== '' ? slug : null
|
|
414
419
|
}
|
|
415
420
|
|
|
421
|
+
// The @-handles that count as "addressed to us" in inbound body text. Under
|
|
422
|
+
// App auth `selfLogin` is the actor login `slug[bot]`, but GitHub renders a
|
|
423
|
+
// human's mention of the App as `@slug` (the bare slug — the decoy account's
|
|
424
|
+
// login), with no `[bot]` suffix and no way to type one. Matching only against
|
|
425
|
+
// `selfLogin` therefore never sees `@typeey` for a `typeey[bot]` actor, so a
|
|
426
|
+
// direct "@typeey review again" lands with isBotMention=false and falls through
|
|
427
|
+
// the engagement mention gate. Include the decoy slug so the bare-slug mention
|
|
428
|
+
// is recognized. Under PAT auth the bot IS a real user, so there is no decoy
|
|
429
|
+
// and only `selfLogin` applies.
|
|
430
|
+
export type BotMentionLogins = readonly string[]
|
|
431
|
+
|
|
432
|
+
export function resolveBotMentionLogins(selfLogin: string | null, authType: 'pat' | 'app'): BotMentionLogins {
|
|
433
|
+
if (selfLogin === null) return []
|
|
434
|
+
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
|
|
435
|
+
return decoyLogin !== null ? [selfLogin, decoyLogin] : [selfLogin]
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// GitHub login chars are ASCII letters, digits, and hyphen. A `@login` token is
|
|
439
|
+
// a real mention of `login` only when the char right after it is not one of
|
|
440
|
+
// these — otherwise `@${login}` is a prefix of a longer, different login. This
|
|
441
|
+
// matters for the App decoy slug: `resolveBotMentionLogins('typeclaw[bot]')`
|
|
442
|
+
// yields the bare slug `typeclaw`, and a naive substring check would treat
|
|
443
|
+
// `@typeclaw-bot` (a different user) as a self-mention. The trailing `[` of
|
|
444
|
+
// `@typeclaw[bot]` is not a login char, so the full actor handle still matches.
|
|
445
|
+
const LOGIN_CHAR = /[A-Za-z0-9-]/
|
|
446
|
+
|
|
447
|
+
function textMentionsBot(text: string, mentionLogins: BotMentionLogins): boolean {
|
|
448
|
+
return mentionLogins.some((login) => mentionsLogin(text, login))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function mentionsLogin(text: string, login: string): boolean {
|
|
452
|
+
const token = `@${login}`
|
|
453
|
+
let from = 0
|
|
454
|
+
for (;;) {
|
|
455
|
+
const at = text.indexOf(token, from)
|
|
456
|
+
if (at === -1) return false
|
|
457
|
+
const next = text[at + token.length]
|
|
458
|
+
if (next === undefined || !LOGIN_CHAR.test(next)) return true
|
|
459
|
+
from = at + 1
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
416
463
|
function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
|
|
417
464
|
const { action, payload, pr, number, base, selfLogin, authType, teamIsBotMember } = input
|
|
418
465
|
if (selfLogin === null) return null
|
|
@@ -546,7 +593,7 @@ function buildInbound(
|
|
|
546
593
|
rawText: unknown,
|
|
547
594
|
id: number,
|
|
548
595
|
user: GithubUser | null,
|
|
549
|
-
|
|
596
|
+
mention: BotMentionLogins,
|
|
550
597
|
rawTs: unknown,
|
|
551
598
|
reactionTarget: GithubReactionTarget | null,
|
|
552
599
|
synthesizedAwareness = false,
|
|
@@ -563,7 +610,7 @@ function buildInbound(
|
|
|
563
610
|
// Synthesized awareness lines carry an `@author` prefix describing who acted;
|
|
564
611
|
// that handle is the author, never a third-party mention of the bot, so the
|
|
565
612
|
// body-text mention heuristic must not fire on it.
|
|
566
|
-
const isBotMention = !synthesizedAwareness &&
|
|
613
|
+
const isBotMention = !synthesizedAwareness && textMentionsBot(text, mention)
|
|
567
614
|
return {
|
|
568
615
|
...key,
|
|
569
616
|
text,
|
|
@@ -9,7 +9,7 @@ const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
|
|
|
9
9
|
// carry more, so the resolver paginates until it matches the root comment id
|
|
10
10
|
// or exhausts the pages — stopping early on a 404-equivalent (thread absent)
|
|
11
11
|
// rather than fabricating a node id.
|
|
12
|
-
const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}`
|
|
12
|
+
const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{__typename login}}}}}}}}`
|
|
13
13
|
|
|
14
14
|
const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
|
|
15
15
|
|
|
@@ -18,6 +18,7 @@ type ReviewThreadNode = {
|
|
|
18
18
|
isResolved: boolean
|
|
19
19
|
rootCommentId: number | null
|
|
20
20
|
rootAuthorLogin: string | null
|
|
21
|
+
rootAuthorIsBot: boolean
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
type ThreadLookup =
|
|
@@ -66,7 +67,7 @@ export function createGithubReviewThreadResolver(deps: {
|
|
|
66
67
|
const thread = lookup.thread
|
|
67
68
|
// The load-bearing guard: only the bot may resolve the bot's own thread.
|
|
68
69
|
// Resolving a human reviewer's thread would erase their open question.
|
|
69
|
-
if (thread
|
|
70
|
+
if (!isSelfAuthor(thread, selfLogin)) {
|
|
70
71
|
return {
|
|
71
72
|
ok: false,
|
|
72
73
|
error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
|
|
@@ -79,6 +80,29 @@ export function createGithubReviewThreadResolver(deps: {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// A GitHub App's own login differs across the two APIs this guard straddles:
|
|
84
|
+
// REST `getSelf` returns `slug[bot]` (selfLogin) but GraphQL's `Bot` author node
|
|
85
|
+
// returns the bare `slug` (rootAuthorLogin). Strict `===` thus refused the App's
|
|
86
|
+
// OWN thread (production: "refusing to resolve thread authored by @typeey (not
|
|
87
|
+
// @typeey[bot])"). The bare-slug match is gated on the GraphQL author actually
|
|
88
|
+
// being a `Bot`: a human `User` can legitimately own the bare slug as a login
|
|
89
|
+
// (e.g. the user `typeey` exists alongside the App `typeey[bot]`), so a User
|
|
90
|
+
// author must still match `selfLogin` exactly — otherwise the suffix-strip would
|
|
91
|
+
// let the bot close a human reviewer's thread, defeating the guard above.
|
|
92
|
+
const BOT_LOGIN_SUFFIX = '[bot]'
|
|
93
|
+
|
|
94
|
+
function isSelfAuthor(thread: ReviewThreadNode, selfLogin: string): boolean {
|
|
95
|
+
if (thread.rootAuthorLogin === null) return false
|
|
96
|
+
if (thread.rootAuthorIsBot) {
|
|
97
|
+
return normalizeBotLogin(thread.rootAuthorLogin) === normalizeBotLogin(selfLogin)
|
|
98
|
+
}
|
|
99
|
+
return thread.rootAuthorLogin === selfLogin
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeBotLogin(login: string): string {
|
|
103
|
+
return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
|
|
83
107
|
|
|
84
108
|
function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
|
|
@@ -175,6 +199,7 @@ async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
|
|
|
175
199
|
isResolved: n.isResolved,
|
|
176
200
|
rootCommentId: root?.databaseId ?? null,
|
|
177
201
|
rootAuthorLogin: root?.author?.login ?? null,
|
|
202
|
+
rootAuthorIsBot: root?.author?.__typename === 'Bot',
|
|
178
203
|
}
|
|
179
204
|
})
|
|
180
205
|
return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
|
|
@@ -231,7 +256,7 @@ type GraphqlThreadsResponse = {
|
|
|
231
256
|
nodes: Array<{
|
|
232
257
|
id: string
|
|
233
258
|
isResolved: boolean
|
|
234
|
-
comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
|
|
259
|
+
comments: { nodes: Array<{ databaseId?: number; author?: { __typename?: string; login?: string } }> }
|
|
235
260
|
}>
|
|
236
261
|
}
|
|
237
262
|
}
|
|
@@ -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,
|