typeclaw 0.19.0 → 0.21.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/index.ts +7 -0
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +265 -22
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +79 -0
- package/src/channels/adapters/github/index.ts +19 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +276 -0
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +25 -2
- package/src/channels/engagement.ts +81 -44
- package/src/channels/router.ts +255 -18
- package/src/channels/types.ts +57 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +147 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/inspect.ts +3 -0
- package/src/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -0
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/server/index.ts +49 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
- package/src/tui/index.ts +70 -18
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { DiscordBotClient, DiscordBotListener } from 'agent-messenger/discordbot'
|
|
2
2
|
import {
|
|
3
3
|
DiscordIntent,
|
|
4
|
+
type DiscordFile,
|
|
5
|
+
type DiscordGatewayEmbed,
|
|
4
6
|
type DiscordGatewayInteractionEvent,
|
|
5
7
|
type DiscordGatewayMessageCreateEvent,
|
|
8
|
+
type DiscordGatewayStickerItem,
|
|
6
9
|
} from 'agent-messenger/discordbot'
|
|
7
10
|
|
|
8
11
|
import {
|
|
@@ -29,7 +32,12 @@ import type {
|
|
|
29
32
|
} from '@/channels/types'
|
|
30
33
|
|
|
31
34
|
import { createDiscordChannelResolver } from './discord-bot-channel-resolver'
|
|
32
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
classifyInbound,
|
|
37
|
+
describeDiscordMedia,
|
|
38
|
+
type InboundDropReason,
|
|
39
|
+
renderPlaceholder,
|
|
40
|
+
} from './discord-bot-classify'
|
|
33
41
|
import {
|
|
34
42
|
ackInteraction,
|
|
35
43
|
parseInteractionAsCommand,
|
|
@@ -148,6 +156,116 @@ type DiscordGuildPreview = {
|
|
|
148
156
|
|
|
149
157
|
type DiscordGuildMember = {
|
|
150
158
|
user?: { id?: string; bot?: boolean }
|
|
159
|
+
roles?: string[]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type DiscordPermissionOverwrite = {
|
|
163
|
+
id?: string
|
|
164
|
+
type?: number
|
|
165
|
+
allow?: string
|
|
166
|
+
deny?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Discord channel `type` values that are threads. A thread's own
|
|
170
|
+
// permission_overwrites are empty and its `parent_id` points at its parent
|
|
171
|
+
// channel (not a category), so the normal "compute from this channel's
|
|
172
|
+
// overwrites" path would treat a thread as fully public. We refuse to count
|
|
173
|
+
// threads channel-scoped and fall back instead — fail-closed.
|
|
174
|
+
const DISCORD_THREAD_CHANNEL_TYPES: ReadonlySet<number> = new Set([10, 11, 12])
|
|
175
|
+
|
|
176
|
+
type DiscordChannelObject = {
|
|
177
|
+
type?: number
|
|
178
|
+
permission_overwrites?: DiscordPermissionOverwrite[]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type DiscordRole = {
|
|
182
|
+
id?: string
|
|
183
|
+
permissions?: string
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type DiscordGuildObject = {
|
|
187
|
+
owner_id?: string
|
|
188
|
+
roles?: DiscordRole[]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Discord permission bits. Discord serialises permission bitsets as decimal
|
|
192
|
+
// STRINGS that can exceed Number.MAX_SAFE_INTEGER, so all bit math is done in
|
|
193
|
+
// BigInt. Only the two bits this resolver needs are named.
|
|
194
|
+
const DISCORD_PERMISSION_VIEW_CHANNEL = 0x400n
|
|
195
|
+
const DISCORD_PERMISSION_ADMINISTRATOR = 0x8n
|
|
196
|
+
|
|
197
|
+
function parsePermissionBits(raw: string | undefined): bigint {
|
|
198
|
+
if (raw === undefined || raw === '') return 0n
|
|
199
|
+
try {
|
|
200
|
+
return BigInt(raw)
|
|
201
|
+
} catch {
|
|
202
|
+
return 0n
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Computes whether a single member can VIEW_CHANNEL on a normal guild channel,
|
|
207
|
+
// following Discord's documented resolution order:
|
|
208
|
+
// base = @everyone role perms OR all of the member's role perms
|
|
209
|
+
// → owner or ADMINISTRATOR short-circuits to visible
|
|
210
|
+
// → @everyone channel overwrite (clear deny bits, then set allow bits)
|
|
211
|
+
// → all matching role overwrites combined (OR every deny, OR every allow;
|
|
212
|
+
// apply denies then allows)
|
|
213
|
+
// → member-specific overwrite (clear deny, set allow)
|
|
214
|
+
// Returns null when the data is insufficient to decide (a member references a
|
|
215
|
+
// role absent from the guild role map), so the caller can fail closed rather
|
|
216
|
+
// than guess.
|
|
217
|
+
function memberCanViewChannel(args: {
|
|
218
|
+
guildId: string
|
|
219
|
+
ownerId: string | undefined
|
|
220
|
+
rolePermissions: ReadonlyMap<string, bigint>
|
|
221
|
+
overwrites: ReadonlyMap<string, { allow: bigint; deny: bigint }>
|
|
222
|
+
memberId: string | undefined
|
|
223
|
+
memberRoleIds: readonly string[]
|
|
224
|
+
}): boolean | null {
|
|
225
|
+
const { guildId, ownerId, rolePermissions, overwrites, memberId, memberRoleIds } = args
|
|
226
|
+
|
|
227
|
+
if (memberId !== undefined && ownerId !== undefined && memberId === ownerId) return true
|
|
228
|
+
|
|
229
|
+
// Discord's `@everyone` role id always equals the guild id, so the guild id
|
|
230
|
+
// keys both the base @everyone permissions and the @everyone overwrite.
|
|
231
|
+
let base = rolePermissions.get(guildId) ?? 0n
|
|
232
|
+
for (const roleId of memberRoleIds) {
|
|
233
|
+
const perms = rolePermissions.get(roleId)
|
|
234
|
+
if (perms === undefined) return null
|
|
235
|
+
base |= perms
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if ((base & DISCORD_PERMISSION_ADMINISTRATOR) !== 0n) return true
|
|
239
|
+
|
|
240
|
+
let perms = base
|
|
241
|
+
|
|
242
|
+
const everyoneOverwrite = overwrites.get(guildId)
|
|
243
|
+
if (everyoneOverwrite !== undefined) {
|
|
244
|
+
perms &= ~everyoneOverwrite.deny
|
|
245
|
+
perms |= everyoneOverwrite.allow
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let roleDeny = 0n
|
|
249
|
+
let roleAllow = 0n
|
|
250
|
+
for (const roleId of memberRoleIds) {
|
|
251
|
+
const overwrite = overwrites.get(roleId)
|
|
252
|
+
if (overwrite !== undefined) {
|
|
253
|
+
roleDeny |= overwrite.deny
|
|
254
|
+
roleAllow |= overwrite.allow
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
perms &= ~roleDeny
|
|
258
|
+
perms |= roleAllow
|
|
259
|
+
|
|
260
|
+
if (memberId !== undefined) {
|
|
261
|
+
const memberOverwrite = overwrites.get(memberId)
|
|
262
|
+
if (memberOverwrite !== undefined) {
|
|
263
|
+
perms &= ~memberOverwrite.deny
|
|
264
|
+
perms |= memberOverwrite.allow
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return (perms & DISCORD_PERMISSION_VIEW_CHANNEL) !== 0n
|
|
151
269
|
}
|
|
152
270
|
|
|
153
271
|
export function createDiscordMembershipResolver(deps: {
|
|
@@ -207,28 +325,136 @@ export function createDiscordMembershipResolver(deps: {
|
|
|
207
325
|
return members.failure
|
|
208
326
|
}
|
|
209
327
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
328
|
+
// Guild `/members` returns the whole guild, not the channel. A bot or
|
|
329
|
+
// human that cannot VIEW_CHANNEL the target channel is not in the room and
|
|
330
|
+
// not a prompt-injection surface, so it must not be counted — otherwise a
|
|
331
|
+
// private channel of "operator + agent bot" inside a multi-bot guild reads
|
|
332
|
+
// as bots>1 and the grant_role relaxation (provesOnlyAgentBotPresent)
|
|
333
|
+
// false-refuses. Resolve channel visibility for each member; on ANY
|
|
334
|
+
// inability to decide, fall back rather than over- or under-count.
|
|
335
|
+
// `key.thread ?? key.chat` mirrors the history callback's channel-id
|
|
336
|
+
// resolution: today Discord stores a thread's own snowflake in `chat` with
|
|
337
|
+
// `thread` null, but a future caller that passes `chat=parent,
|
|
338
|
+
// thread=threadId` must scope visibility to the thread, not the parent.
|
|
339
|
+
const scoped = await scopeMembersToChannel({
|
|
340
|
+
fetchFn,
|
|
341
|
+
token: deps.token,
|
|
342
|
+
logger: deps.logger,
|
|
343
|
+
guildId: key.workspace,
|
|
344
|
+
channelId: key.thread ?? key.chat,
|
|
345
|
+
members: members.value,
|
|
346
|
+
})
|
|
347
|
+
if (scoped === 'fallback') return await fallback()
|
|
348
|
+
|
|
349
|
+
return scoped.everyHumanIdentified
|
|
350
|
+
? {
|
|
351
|
+
humans: scoped.humans,
|
|
352
|
+
bots: scoped.bots,
|
|
353
|
+
fetchedAt: now(),
|
|
354
|
+
truncated: false,
|
|
355
|
+
humanMemberIds: scoped.humanMemberIds,
|
|
356
|
+
}
|
|
357
|
+
: { humans: scoped.humans, bots: scoped.bots, fetchedAt: now(), truncated: false }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
type ScopedMembership = {
|
|
362
|
+
humans: number
|
|
363
|
+
bots: number
|
|
364
|
+
humanMemberIds: string[]
|
|
365
|
+
everyHumanIdentified: boolean
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Filters a guild member list down to those who can VIEW_CHANNEL the target
|
|
369
|
+
// channel, then counts humans/bots over that visible set. Returns 'fallback'
|
|
370
|
+
// when channel visibility cannot be computed completely (channel/guild fetch
|
|
371
|
+
// failure, a member referencing an unknown role, or a thread channel whose
|
|
372
|
+
// visibility this resolver does not model) — the caller then derives from
|
|
373
|
+
// history (truncated:true), keeping every security consumer fail-closed.
|
|
374
|
+
async function scopeMembersToChannel(args: {
|
|
375
|
+
fetchFn: typeof fetch
|
|
376
|
+
token: string
|
|
377
|
+
logger: DiscordBotAdapterLogger
|
|
378
|
+
guildId: string
|
|
379
|
+
channelId: string
|
|
380
|
+
members: readonly DiscordGuildMember[]
|
|
381
|
+
}): Promise<ScopedMembership | 'fallback'> {
|
|
382
|
+
const { fetchFn, token, logger, guildId, channelId, members } = args
|
|
383
|
+
|
|
384
|
+
const [channel, guild] = await Promise.all([
|
|
385
|
+
fetchDiscordJson<DiscordChannelObject>(fetchFn, `${DISCORD_API_BASE}/channels/${channelId}`, token),
|
|
386
|
+
fetchDiscordJson<DiscordGuildObject>(fetchFn, `${DISCORD_API_BASE}/guilds/${guildId}`, token),
|
|
387
|
+
])
|
|
388
|
+
if (!channel.ok) {
|
|
389
|
+
logger.warn(
|
|
390
|
+
`[discord-bot] membership channel=${channelId} fetch failed: ${channel.reason}; deriving from recent message authors`,
|
|
391
|
+
)
|
|
392
|
+
return 'fallback'
|
|
393
|
+
}
|
|
394
|
+
if (!guild.ok) {
|
|
395
|
+
logger.warn(
|
|
396
|
+
`[discord-bot] membership guild=${guildId} fetch failed: ${guild.reason}; deriving from recent message authors`,
|
|
397
|
+
)
|
|
398
|
+
return 'fallback'
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (channel.value.type !== undefined && DISCORD_THREAD_CHANNEL_TYPES.has(channel.value.type)) {
|
|
402
|
+
// A thread inherits visibility from its parent channel and (for private
|
|
403
|
+
// threads) an explicit member list; we do not model either here. Fall
|
|
404
|
+
// back rather than treat the thread as world-visible.
|
|
405
|
+
logger.warn(
|
|
406
|
+
`[discord-bot] membership channel=${channelId} is a thread; deriving from recent message authors instead of channel-scoped enumeration`,
|
|
407
|
+
)
|
|
408
|
+
return 'fallback'
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const rolePermissions = new Map<string, bigint>()
|
|
412
|
+
for (const role of guild.value.roles ?? []) {
|
|
413
|
+
if (role.id !== undefined) rolePermissions.set(role.id, parsePermissionBits(role.permissions))
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const overwrites = new Map<string, { allow: bigint; deny: bigint }>()
|
|
417
|
+
for (const overwrite of channel.value.permission_overwrites ?? []) {
|
|
418
|
+
if (overwrite.id === undefined) continue
|
|
419
|
+
overwrites.set(overwrite.id, {
|
|
420
|
+
allow: parsePermissionBits(overwrite.allow),
|
|
421
|
+
deny: parsePermissionBits(overwrite.deny),
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let humans = 0
|
|
426
|
+
let bots = 0
|
|
427
|
+
const humanMemberIds: string[] = []
|
|
428
|
+
let everyHumanIdentified = true
|
|
429
|
+
|
|
430
|
+
for (const member of members) {
|
|
431
|
+
const visible = memberCanViewChannel({
|
|
432
|
+
guildId,
|
|
433
|
+
ownerId: guild.value.owner_id,
|
|
434
|
+
rolePermissions,
|
|
435
|
+
overwrites,
|
|
436
|
+
memberId: member.user?.id,
|
|
437
|
+
memberRoleIds: member.roles ?? [],
|
|
438
|
+
})
|
|
439
|
+
if (visible === null) {
|
|
440
|
+
logger.warn(
|
|
441
|
+
`[discord-bot] membership channel=${channelId} member references unknown role; deriving from recent message authors`,
|
|
442
|
+
)
|
|
443
|
+
return 'fallback'
|
|
223
444
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
445
|
+
if (!visible) continue
|
|
446
|
+
|
|
447
|
+
if (member.user?.bot === true) {
|
|
448
|
+
bots++
|
|
449
|
+
continue
|
|
450
|
+
}
|
|
451
|
+
humans++
|
|
452
|
+
const userId = member.user?.id
|
|
453
|
+
if (userId === undefined) everyHumanIdentified = false
|
|
454
|
+
else humanMemberIds.push(userId)
|
|
231
455
|
}
|
|
456
|
+
|
|
457
|
+
return { humans, bots, humanMemberIds, everyHumanIdentified }
|
|
232
458
|
}
|
|
233
459
|
|
|
234
460
|
type DiscordFetchResult<T> =
|
|
@@ -269,6 +495,9 @@ type DiscordRawHistoryMessage = {
|
|
|
269
495
|
content: string
|
|
270
496
|
timestamp: string
|
|
271
497
|
message_reference?: { message_id?: string }
|
|
498
|
+
attachments?: DiscordFile[]
|
|
499
|
+
embeds?: DiscordGatewayEmbed[]
|
|
500
|
+
sticker_items?: DiscordGatewayStickerItem[]
|
|
272
501
|
}
|
|
273
502
|
|
|
274
503
|
// Discord treats threads as separate channels with their own snowflake ids,
|
|
@@ -333,14 +562,28 @@ export function createDiscordHistoryCallback(deps: {
|
|
|
333
562
|
function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | null): ChannelHistoryMessage {
|
|
334
563
|
const isBot = msg.author.bot === true || (botUserId !== null && msg.author.id === botUserId)
|
|
335
564
|
const ts = Date.parse(msg.timestamp)
|
|
565
|
+
// The REST history fetch bypasses the inbound classifier, so attachments,
|
|
566
|
+
// embeds, and stickers on already-posted messages (e.g. an image on a thread
|
|
567
|
+
// root the agent is later @-mentioned under) must be mapped here too —
|
|
568
|
+
// otherwise they are silently dropped and look_at_channel_attachment can
|
|
569
|
+
// never resolve them. Mirror the classifier's splitInbound: bake placeholders
|
|
570
|
+
// into text and carry the structured attachments so the router can resolve ids.
|
|
571
|
+
const attachments = describeDiscordMedia(msg)
|
|
572
|
+
const text =
|
|
573
|
+
attachments.length === 0
|
|
574
|
+
? msg.content
|
|
575
|
+
: msg.content === ''
|
|
576
|
+
? attachments.map(renderPlaceholder).join('\n')
|
|
577
|
+
: `${msg.content}\n${attachments.map(renderPlaceholder).join('\n')}`
|
|
336
578
|
return {
|
|
337
579
|
externalMessageId: msg.id,
|
|
338
580
|
authorId: msg.author.id,
|
|
339
581
|
authorName: msg.author.global_name ?? msg.author.username ?? msg.author.id,
|
|
340
|
-
text
|
|
582
|
+
text,
|
|
341
583
|
ts: Number.isFinite(ts) ? ts : 0,
|
|
342
584
|
isBot,
|
|
343
585
|
replyToBotMessageId: msg.message_reference?.message_id ?? null,
|
|
586
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
344
587
|
}
|
|
345
588
|
}
|
|
346
589
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
2
|
+
|
|
3
|
+
// `absent` separates "GitHub says the reviewer is already not requested"
|
|
4
|
+
// (404/422 — never on the list, already removed, or invalid for the repo) from
|
|
5
|
+
// `failed` ("couldn't reach GitHub"), so callers warn only on the latter.
|
|
6
|
+
export type RemoveRequestedReviewerResult =
|
|
7
|
+
| { kind: 'removed'; status: number }
|
|
8
|
+
| { kind: 'absent'; status: number; message: string }
|
|
9
|
+
| { kind: 'failed'; status?: number; reason: string }
|
|
10
|
+
|
|
11
|
+
export async function removeRequestedReviewer(params: {
|
|
12
|
+
fetchImpl: typeof fetch
|
|
13
|
+
token: string
|
|
14
|
+
owner: string
|
|
15
|
+
repo: string
|
|
16
|
+
pullNumber: number
|
|
17
|
+
reviewerLogin: string
|
|
18
|
+
}): Promise<RemoveRequestedReviewerResult> {
|
|
19
|
+
const url = `${GITHUB_API_BASE}/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}/requested_reviewers`
|
|
20
|
+
try {
|
|
21
|
+
const response = await params.fetchImpl(url, {
|
|
22
|
+
method: 'DELETE',
|
|
23
|
+
headers: githubJsonHeaders(params.token),
|
|
24
|
+
body: JSON.stringify({ reviewers: [params.reviewerLogin] }),
|
|
25
|
+
})
|
|
26
|
+
if (response.ok) return { kind: 'removed', status: response.status }
|
|
27
|
+
const message = await response.text().catch(() => '')
|
|
28
|
+
// 404 (PR/reviewer not found) and 422 (reviewer not currently requested,
|
|
29
|
+
// or not a valid reviewer for this repo) mean there is nothing to remove —
|
|
30
|
+
// the desired end state already holds. Everything else (401/403 auth,
|
|
31
|
+
// 429 rate, 5xx) is a real failure worth surfacing.
|
|
32
|
+
if (response.status === 404 || response.status === 422) {
|
|
33
|
+
return { kind: 'absent', status: response.status, message }
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
kind: 'failed',
|
|
37
|
+
status: response.status,
|
|
38
|
+
reason: `GitHub API ${response.status}${message !== '' ? `: ${message}` : ''}`,
|
|
39
|
+
}
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { kind: 'failed', reason: err instanceof Error ? err.message : String(err) }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -2,8 +2,11 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
|
2
2
|
|
|
3
3
|
import type { InboundMessage } from '@/channels/types'
|
|
4
4
|
|
|
5
|
+
import type { GithubAuthContext } from './auth'
|
|
6
|
+
import { removeRequestedReviewer } from './decoy-reviewer'
|
|
5
7
|
import type { DeliveryDedup } from './dedup'
|
|
6
8
|
import { isGithubEventAllowed } from './event-allowlist'
|
|
9
|
+
import { encodeGithubReactionRef, type GithubReactionTarget } from './reactions'
|
|
7
10
|
|
|
8
11
|
export type GithubInboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
|
|
9
12
|
|
|
@@ -22,6 +25,13 @@ export type GithubWebhookHandlerOptions = {
|
|
|
22
25
|
// omitted, team-reviewer requests are silently dropped (the v1 fallback
|
|
23
26
|
// behavior). The adapter wires this in production; tests inject a fake.
|
|
24
27
|
isBotInTeam?: (input: { org: string; slug: string; login: string }) => Promise<boolean>
|
|
28
|
+
// App-auth only: mints a repo-scoped token used to drop the decoy reviewer
|
|
29
|
+
// once the bot's own review lands. Omitted under PAT auth (no decoy exists).
|
|
30
|
+
authToken?: (context?: GithubAuthContext) => Promise<string>
|
|
31
|
+
// Schedules the decoy-drop off the webhook ACK path so the 200 stays fast.
|
|
32
|
+
// Defaults to fire-and-forget; tests inject a recorder to await the task.
|
|
33
|
+
scheduleBackgroundTask?: (task: () => Promise<void>) => void
|
|
34
|
+
fetchImpl?: typeof fetch
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions): (req: Request) => Promise<Response> {
|
|
@@ -50,6 +60,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
50
60
|
const selfLogin = options.selfLogin()
|
|
51
61
|
const author = readAuthor(event, payload)
|
|
52
62
|
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
63
|
+
maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
|
|
53
64
|
options.logger.info(
|
|
54
65
|
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
55
66
|
)
|
|
@@ -69,6 +80,65 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
69
80
|
}
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
// GitHub auto-records the App as a reviewer the moment its review posts, but
|
|
84
|
+
// leaves the decoy user pinned as a perpetual "review requested". When the bot
|
|
85
|
+
// drops its own review (the self-authored event we're about to discard), fire a
|
|
86
|
+
// background DELETE to remove the decoy. The DELETE is authenticated as the App,
|
|
87
|
+
// so the resulting review_request_removed webhook has the bot actor as sender
|
|
88
|
+
// and is dropped by classifyReviewRequest's self-loop guard — no fresh session.
|
|
89
|
+
function maybeScheduleDecoyReviewerDrop(input: {
|
|
90
|
+
event: string
|
|
91
|
+
action: string | null
|
|
92
|
+
payload: Record<string, unknown>
|
|
93
|
+
selfLogin: string | null
|
|
94
|
+
options: GithubWebhookHandlerOptions
|
|
95
|
+
}): void {
|
|
96
|
+
const { event, action, payload, selfLogin, options } = input
|
|
97
|
+
if (event !== 'pull_request_review' || action !== 'submitted') return
|
|
98
|
+
if (selfLogin === null) return
|
|
99
|
+
const authToken = options.authToken
|
|
100
|
+
if (authToken === undefined) return
|
|
101
|
+
if ((options.authType?.() ?? 'pat') !== 'app') return
|
|
102
|
+
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, 'app')
|
|
103
|
+
if (decoyLogin === null) return
|
|
104
|
+
|
|
105
|
+
const repository = readRepository(payload)
|
|
106
|
+
const pr = readRecord(payload.pull_request)
|
|
107
|
+
const pullNumber = readNumber(pr, 'number')
|
|
108
|
+
if (repository === null || pullNumber === null) return
|
|
109
|
+
|
|
110
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
111
|
+
const schedule = options.scheduleBackgroundTask ?? defaultScheduleBackgroundTask
|
|
112
|
+
const target = `${repository.owner}/${repository.name}#${pullNumber}`
|
|
113
|
+
schedule(async () => {
|
|
114
|
+
// authToken can throw (installation lookup / token mint), and a thrown
|
|
115
|
+
// failure must still warn — the default scheduler swallows rejections, so
|
|
116
|
+
// catching here is the only place the failure is observable.
|
|
117
|
+
try {
|
|
118
|
+
const token = await authToken({ repoSlug: `${repository.owner}/${repository.name}` })
|
|
119
|
+
const result = await removeRequestedReviewer({
|
|
120
|
+
fetchImpl,
|
|
121
|
+
token,
|
|
122
|
+
owner: repository.owner,
|
|
123
|
+
repo: repository.name,
|
|
124
|
+
pullNumber,
|
|
125
|
+
reviewerLogin: decoyLogin,
|
|
126
|
+
})
|
|
127
|
+
if (result.kind === 'failed') {
|
|
128
|
+
options.logger.warn(`[github] failed to drop decoy reviewer @${decoyLogin} from ${target}: ${result.reason}`)
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
options.logger.warn(
|
|
132
|
+
`[github] failed to drop decoy reviewer @${decoyLogin} from ${target}: ${err instanceof Error ? err.message : String(err)}`,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function defaultScheduleBackgroundTask(task: () => Promise<void>): void {
|
|
139
|
+
void task().catch(() => {})
|
|
140
|
+
}
|
|
141
|
+
|
|
72
142
|
export async function verifySignature(body: string, secret: string, sigHeader: string): Promise<boolean> {
|
|
73
143
|
const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
|
|
74
144
|
const a = Buffer.from(expected)
|
|
@@ -109,6 +179,7 @@ export function classifyGithubInbound(
|
|
|
109
179
|
user,
|
|
110
180
|
selfLogin,
|
|
111
181
|
comment.created_at,
|
|
182
|
+
{ kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
112
183
|
)
|
|
113
184
|
}
|
|
114
185
|
|
|
@@ -127,6 +198,7 @@ export function classifyGithubInbound(
|
|
|
127
198
|
readUser(comment.user),
|
|
128
199
|
selfLogin,
|
|
129
200
|
comment.created_at,
|
|
201
|
+
{ kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
130
202
|
)
|
|
131
203
|
}
|
|
132
204
|
|
|
@@ -144,6 +216,7 @@ export function classifyGithubInbound(
|
|
|
144
216
|
readUser(comment.user),
|
|
145
217
|
selfLogin,
|
|
146
218
|
comment.created_at,
|
|
219
|
+
null,
|
|
147
220
|
)
|
|
148
221
|
}
|
|
149
222
|
|
|
@@ -160,6 +233,7 @@ export function classifyGithubInbound(
|
|
|
160
233
|
readUser(issue.user),
|
|
161
234
|
selfLogin,
|
|
162
235
|
issue.created_at,
|
|
236
|
+
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
163
237
|
)
|
|
164
238
|
}
|
|
165
239
|
|
|
@@ -189,6 +263,7 @@ export function classifyGithubInbound(
|
|
|
189
263
|
readUser(pr.user),
|
|
190
264
|
selfLogin,
|
|
191
265
|
pr.created_at,
|
|
266
|
+
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
192
267
|
)
|
|
193
268
|
}
|
|
194
269
|
|
|
@@ -206,6 +281,7 @@ export function classifyGithubInbound(
|
|
|
206
281
|
readUser(review.user),
|
|
207
282
|
selfLogin,
|
|
208
283
|
review.submitted_at,
|
|
284
|
+
null,
|
|
209
285
|
)
|
|
210
286
|
}
|
|
211
287
|
|
|
@@ -222,6 +298,7 @@ export function classifyGithubInbound(
|
|
|
222
298
|
readUser(discussion.user),
|
|
223
299
|
selfLogin,
|
|
224
300
|
discussion.created_at,
|
|
301
|
+
null,
|
|
225
302
|
)
|
|
226
303
|
}
|
|
227
304
|
|
|
@@ -340,6 +417,7 @@ function buildInbound(
|
|
|
340
417
|
user: GithubUser | null,
|
|
341
418
|
selfLogin: string | null,
|
|
342
419
|
rawTs: unknown,
|
|
420
|
+
reactionTarget: GithubReactionTarget | null,
|
|
343
421
|
): InboundMessage | null {
|
|
344
422
|
if (user === null) return null
|
|
345
423
|
const text = typeof rawText === 'string' ? rawText : ''
|
|
@@ -347,6 +425,7 @@ function buildInbound(
|
|
|
347
425
|
...key,
|
|
348
426
|
text,
|
|
349
427
|
externalMessageId: String(id),
|
|
428
|
+
...(reactionTarget !== null ? { reactionRef: encodeGithubReactionRef(reactionTarget) } : {}),
|
|
350
429
|
authorId: String(user.id),
|
|
351
430
|
authorName: user.login,
|
|
352
431
|
authorIsBot: user.type === 'Bot',
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
buildPermissionGuidance,
|
|
20
20
|
parseListHooksPermissionStatus,
|
|
21
21
|
} from './permission-guidance'
|
|
22
|
+
import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
|
|
22
23
|
import { createTeamMembershipChecker } from './team-membership'
|
|
23
24
|
import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
|
|
24
25
|
|
|
@@ -119,6 +120,16 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
119
120
|
logger,
|
|
120
121
|
fetchImpl,
|
|
121
122
|
})
|
|
123
|
+
const reaction = createGithubReactionCallback({
|
|
124
|
+
token: authToken,
|
|
125
|
+
authType: options.secrets.auth.type,
|
|
126
|
+
fetchImpl,
|
|
127
|
+
})
|
|
128
|
+
const removeReaction = createGithubRemoveReactionCallback({
|
|
129
|
+
token: authToken,
|
|
130
|
+
authType: options.secrets.auth.type,
|
|
131
|
+
fetchImpl,
|
|
132
|
+
})
|
|
122
133
|
const history = createGithubHistoryCallback({
|
|
123
134
|
token: authToken,
|
|
124
135
|
fetchImpl,
|
|
@@ -139,6 +150,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
139
150
|
selfLogin: () => selfLogin,
|
|
140
151
|
authType: () => options.secrets.auth.type,
|
|
141
152
|
isBotInTeam,
|
|
153
|
+
authToken,
|
|
154
|
+
fetchImpl,
|
|
142
155
|
logger,
|
|
143
156
|
route: (message) => {
|
|
144
157
|
rememberWorkspace(message.workspace, message.chat)
|
|
@@ -161,6 +174,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
161
174
|
// Register all callbacks before binding the HTTP listener so the router
|
|
162
175
|
// is fully wired before any webhook can arrive.
|
|
163
176
|
options.router.registerOutbound('github', outbound)
|
|
177
|
+
options.router.registerReaction('github', reaction)
|
|
178
|
+
options.router.registerRemoveReaction('github', removeReaction)
|
|
164
179
|
options.router.registerTyping('github', typing)
|
|
165
180
|
options.router.registerHistory('github', history)
|
|
166
181
|
options.router.registerMembership('github', membership)
|
|
@@ -172,6 +187,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
172
187
|
// Listener failed — roll back all registrations so stop() is a no-op
|
|
173
188
|
// and the manager can report the failure cleanly.
|
|
174
189
|
options.router.unregisterOutbound('github', outbound)
|
|
190
|
+
options.router.unregisterReaction('github', reaction)
|
|
191
|
+
options.router.unregisterRemoveReaction('github', removeReaction)
|
|
175
192
|
options.router.unregisterTyping('github', typing)
|
|
176
193
|
options.router.unregisterHistory('github', history)
|
|
177
194
|
options.router.unregisterMembership('github', membership)
|
|
@@ -292,6 +309,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
292
309
|
if (!started) return
|
|
293
310
|
started = false
|
|
294
311
|
options.router.unregisterOutbound('github', outbound)
|
|
312
|
+
options.router.unregisterReaction('github', reaction)
|
|
313
|
+
options.router.unregisterRemoveReaction('github', removeReaction)
|
|
295
314
|
options.router.unregisterTyping('github', typing)
|
|
296
315
|
options.router.unregisterHistory('github', history)
|
|
297
316
|
options.router.unregisterMembership('github', membership)
|
|
@@ -6,7 +6,12 @@ export type GithubAuthType = 'pat' | 'app'
|
|
|
6
6
|
// permission-failure response. Each value maps to a distinct GitHub App
|
|
7
7
|
// permission family (and, for PATs, a distinct scope), so each surfaces a
|
|
8
8
|
// different remediation message.
|
|
9
|
-
export type OutboundEndpointKind =
|
|
9
|
+
export type OutboundEndpointKind =
|
|
10
|
+
| 'issue-comment'
|
|
11
|
+
| 'pr-review-reply'
|
|
12
|
+
| 'discussion-comment'
|
|
13
|
+
| 'issue-reaction'
|
|
14
|
+
| 'pr-review-comment-reaction'
|
|
10
15
|
|
|
11
16
|
// Parses webhook-register errors of the shape `list hooks failed: <status> <body>`.
|
|
12
17
|
// Returns the status code when it matches the two shapes GitHub emits for
|
|
@@ -127,6 +132,20 @@ const OUTBOUND_PERMISSION_FOR_KIND: Record<
|
|
|
127
132
|
patScope: 'repo',
|
|
128
133
|
patFineGrained: 'Discussions',
|
|
129
134
|
},
|
|
135
|
+
// Reactions on an issue/PR body or an issue comment go through the Issues
|
|
136
|
+
// permission family; reactions on a PR review comment go through Pull requests.
|
|
137
|
+
'issue-reaction': {
|
|
138
|
+
label: 'Issues',
|
|
139
|
+
level: 'Read and write',
|
|
140
|
+
patScope: 'repo (or public_repo for public repos)',
|
|
141
|
+
patFineGrained: 'Issues',
|
|
142
|
+
},
|
|
143
|
+
'pr-review-comment-reaction': {
|
|
144
|
+
label: 'Pull requests',
|
|
145
|
+
level: 'Read and write',
|
|
146
|
+
patScope: 'repo',
|
|
147
|
+
patFineGrained: 'Pull requests',
|
|
148
|
+
},
|
|
130
149
|
}
|
|
131
150
|
|
|
132
151
|
// Decorate an outbound-API failure with the precise github.com permission a
|