typeclaw 0.23.0 → 0.24.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 +91 -22
- package/src/agent/plugin-tools.ts +38 -2
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/subagent-completion-reminder.ts +3 -1
- package/src/agent/subagents.ts +44 -1
- package/src/agent/system-prompt.ts +4 -0
- package/src/agent/todo/continuation-policy.ts +242 -0
- package/src/agent/todo/continuation-state.ts +87 -0
- package/src/agent/todo/continuation-wiring.ts +113 -0
- package/src/agent/todo/continuation.ts +71 -0
- package/src/agent/todo/scope.ts +77 -0
- package/src/agent/todo/store.ts +98 -0
- package/src/agent/tool-not-found-nudge.ts +119 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/bundled-plugins/backup/runner.ts +1 -1
- package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +25 -3
- package/src/channels/adapters/github/inbound.ts +161 -10
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/kakaotalk-classify.ts +67 -6
- package/src/channels/adapters/slack-bot-classify.ts +9 -1
- package/src/channels/adapters/slack-bot-reference.ts +129 -0
- package/src/channels/adapters/slack-bot.ts +67 -8
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +445 -22
- package/src/channels/schema.ts +20 -4
- package/src/channels/types.ts +68 -0
- package/src/cli/inspect-controller.ts +7 -0
- package/src/cli/inspect.ts +2 -1
- package/src/commands/index.ts +9 -0
- package/src/init/gitignore.ts +5 -2
- package/src/inspect/index.ts +22 -0
- package/src/run/index.ts +60 -5
- package/src/sandbox/build.ts +10 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/policy.ts +10 -0
- package/src/sandbox/writable-zones.ts +78 -0
- package/src/server/index.ts +118 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
- package/typeclaw.schema.json +10 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
2
|
|
|
3
|
+
import type { GithubReviewOn } from '@/channels/schema'
|
|
3
4
|
import type { InboundMessage } from '@/channels/types'
|
|
4
5
|
|
|
5
6
|
import type { GithubAuthContext } from './auth'
|
|
@@ -23,6 +24,14 @@ export type GithubWebhookHandlerOptions = {
|
|
|
23
24
|
// an appended operator-policy note telling the agent not to submit an APPROVE
|
|
24
25
|
// review; the github skill keys off that note to downgrade approve→COMMENT.
|
|
25
26
|
allowApprove?: () => boolean
|
|
27
|
+
// Which pull_request action triggers an agent code review. Defaults to
|
|
28
|
+
// 'review_requested' when omitted, preserving the request-driven behavior.
|
|
29
|
+
// 'opened' additionally wakes the bot to review every PR the moment it opens;
|
|
30
|
+
// 'off' suppresses the dedicated review-trigger synthesis entirely (an
|
|
31
|
+
// explicit review_requested no longer wakes a session). Orthogonal to the
|
|
32
|
+
// eventAllowlist (the outer "process this webhook?" gate) — this is the inner
|
|
33
|
+
// "does an admitted pull_request event become a review-trigger inbound?" gate.
|
|
34
|
+
reviewOn?: () => GithubReviewOn
|
|
26
35
|
route: (message: InboundMessage) => void
|
|
27
36
|
logger: GithubInboundLogger
|
|
28
37
|
// Optional: resolves whether the bot is a member of the given team. When
|
|
@@ -75,6 +84,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
75
84
|
const classified = classifyGithubInbound(event, payload, selfLogin, {
|
|
76
85
|
teamIsBotMember,
|
|
77
86
|
authType: options.authType?.() ?? 'pat',
|
|
87
|
+
reviewOn: options.reviewOn?.() ?? 'review_requested',
|
|
78
88
|
})
|
|
79
89
|
if (classified === null) return ok()
|
|
80
90
|
|
|
@@ -173,7 +183,7 @@ export function classifyGithubInbound(
|
|
|
173
183
|
event: string,
|
|
174
184
|
payload: Record<string, unknown>,
|
|
175
185
|
selfLogin: string | null,
|
|
176
|
-
options?: { teamIsBotMember?: boolean; authType?: 'pat' | 'app' },
|
|
186
|
+
options?: { teamIsBotMember?: boolean; authType?: 'pat' | 'app'; reviewOn?: GithubReviewOn },
|
|
177
187
|
): InboundMessage | null {
|
|
178
188
|
const repository = readRepository(payload)
|
|
179
189
|
if (repository === null) return null
|
|
@@ -248,14 +258,22 @@ export function classifyGithubInbound(
|
|
|
248
258
|
const number = readNumber(issue, 'number')
|
|
249
259
|
const id = readNumber(issue, 'id') ?? number
|
|
250
260
|
if (number === null || id === null) return null
|
|
261
|
+
const action = readString(payload, 'action')
|
|
262
|
+
const opener = readUser(issue.user)
|
|
263
|
+
const hasBody = readString(issue, 'body')?.trim() ? true : false
|
|
264
|
+
const text =
|
|
265
|
+
action === 'opened'
|
|
266
|
+
? bodyOrOpenedTitle(issue.body, opener, 'issue', number, readString(issue, 'title'))
|
|
267
|
+
: issue.body
|
|
251
268
|
return buildInbound(
|
|
252
269
|
{ ...base, chat: `issue:${number}`, thread: null },
|
|
253
|
-
|
|
270
|
+
text,
|
|
254
271
|
id,
|
|
255
|
-
|
|
272
|
+
opener,
|
|
256
273
|
selfLogin,
|
|
257
274
|
issue.created_at,
|
|
258
275
|
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
276
|
+
action === 'opened' && !hasBody,
|
|
259
277
|
)
|
|
260
278
|
}
|
|
261
279
|
|
|
@@ -266,7 +284,12 @@ export function classifyGithubInbound(
|
|
|
266
284
|
const id = readNumber(pr, 'id') ?? number
|
|
267
285
|
if (number === null || id === null) return null
|
|
268
286
|
const action = readString(payload, 'action')
|
|
287
|
+
const reviewOn = options?.reviewOn ?? 'review_requested'
|
|
269
288
|
if (action === 'review_requested' || action === 'review_request_removed') {
|
|
289
|
+
// `off` disables the dedicated review trigger: these two actions exist
|
|
290
|
+
// only to drive review-request behavior here, so under `off` they wake no
|
|
291
|
+
// session rather than falling through to awareness-only context.
|
|
292
|
+
if (reviewOn === 'off') return null
|
|
270
293
|
return classifyReviewRequest({
|
|
271
294
|
action,
|
|
272
295
|
payload,
|
|
@@ -278,14 +301,30 @@ export function classifyGithubInbound(
|
|
|
278
301
|
teamIsBotMember: options?.teamIsBotMember,
|
|
279
302
|
})
|
|
280
303
|
}
|
|
304
|
+
if (action === 'opened' && reviewOn === 'opened') {
|
|
305
|
+
const trigger = classifyOpenedReviewTrigger({
|
|
306
|
+
payload,
|
|
307
|
+
pr,
|
|
308
|
+
number,
|
|
309
|
+
base,
|
|
310
|
+
selfLogin,
|
|
311
|
+
authType: options?.authType ?? 'pat',
|
|
312
|
+
})
|
|
313
|
+
if (trigger !== null) return trigger
|
|
314
|
+
}
|
|
315
|
+
const opener = readUser(pr.user)
|
|
316
|
+
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
|
|
281
319
|
return buildInbound(
|
|
282
320
|
{ ...base, chat: `pr:${number}`, thread: null },
|
|
283
|
-
|
|
321
|
+
prText,
|
|
284
322
|
id,
|
|
285
|
-
|
|
323
|
+
opener,
|
|
286
324
|
selfLogin,
|
|
287
325
|
pr.created_at,
|
|
288
326
|
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
327
|
+
action === 'opened' && !hasBody,
|
|
289
328
|
)
|
|
290
329
|
}
|
|
291
330
|
|
|
@@ -296,14 +335,23 @@ export function classifyGithubInbound(
|
|
|
296
335
|
const number = readNumber(pr, 'number')
|
|
297
336
|
const id = readNumber(review, 'id')
|
|
298
337
|
if (number === null || id === null) return null
|
|
338
|
+
const reviewer = readUser(review.user)
|
|
339
|
+
const body = readString(review, 'body')
|
|
340
|
+
const hasBody = body !== null && body.trim() !== ''
|
|
341
|
+
const text = hasBody
|
|
342
|
+
? body
|
|
343
|
+
: reviewer !== null
|
|
344
|
+
? synthesizeReviewStateText(reviewer.login, number, readString(pr, 'title'), readString(review, 'state'))
|
|
345
|
+
: ''
|
|
299
346
|
return buildInbound(
|
|
300
347
|
{ ...base, chat: `pr:${number}`, thread: null },
|
|
301
|
-
|
|
348
|
+
text,
|
|
302
349
|
id,
|
|
303
|
-
|
|
350
|
+
reviewer,
|
|
304
351
|
selfLogin,
|
|
305
352
|
review.submitted_at,
|
|
306
353
|
null,
|
|
354
|
+
!hasBody,
|
|
307
355
|
)
|
|
308
356
|
}
|
|
309
357
|
|
|
@@ -313,14 +361,22 @@ export function classifyGithubInbound(
|
|
|
313
361
|
const number = readNumber(discussion, 'number')
|
|
314
362
|
const id = readNumber(discussion, 'id') ?? number
|
|
315
363
|
if (number === null || id === null) return null
|
|
364
|
+
const action = readString(payload, 'action')
|
|
365
|
+
const opener = readUser(discussion.user)
|
|
366
|
+
const hasBody = readString(discussion, 'body')?.trim() ? true : false
|
|
367
|
+
const text =
|
|
368
|
+
action === 'created'
|
|
369
|
+
? bodyOrOpenedTitle(discussion.body, opener, 'discussion', number, readString(discussion, 'title'))
|
|
370
|
+
: discussion.body
|
|
316
371
|
return buildInbound(
|
|
317
372
|
{ ...base, chat: `discussion:${number}`, thread: null },
|
|
318
|
-
|
|
373
|
+
text,
|
|
319
374
|
id,
|
|
320
|
-
|
|
375
|
+
opener,
|
|
321
376
|
selfLogin,
|
|
322
377
|
discussion.created_at,
|
|
323
378
|
null,
|
|
379
|
+
action === 'created' && !hasBody,
|
|
324
380
|
)
|
|
325
381
|
}
|
|
326
382
|
|
|
@@ -418,6 +474,53 @@ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null
|
|
|
418
474
|
}
|
|
419
475
|
}
|
|
420
476
|
|
|
477
|
+
type OpenedReviewTriggerInput = {
|
|
478
|
+
payload: Record<string, unknown>
|
|
479
|
+
pr: Record<string, unknown>
|
|
480
|
+
number: number
|
|
481
|
+
base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
|
|
482
|
+
selfLogin: string | null
|
|
483
|
+
authType: 'pat' | 'app'
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function classifyOpenedReviewTrigger(input: OpenedReviewTriggerInput): InboundMessage | null {
|
|
487
|
+
const { payload, pr, number, base, selfLogin, authType } = input
|
|
488
|
+
if (selfLogin === null) return null
|
|
489
|
+
const sender = readUser(payload.sender) ?? readUser(pr.user)
|
|
490
|
+
if (sender === null) return null
|
|
491
|
+
// Defensive self-loop guard mirroring classifyReviewRequest: the handler-level
|
|
492
|
+
// self-author drop already discards bot-opened PRs, but the decoy account is a
|
|
493
|
+
// distinct login, so a decoy-opened PR would otherwise wake a self-review.
|
|
494
|
+
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
|
|
495
|
+
if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
|
|
496
|
+
|
|
497
|
+
const title = readString(pr, 'title') ?? `#${number}`
|
|
498
|
+
const head = readString(readRecord(pr.head), 'ref')
|
|
499
|
+
const baseRef = readString(readRecord(pr.base), 'ref')
|
|
500
|
+
const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
|
|
501
|
+
const text =
|
|
502
|
+
`@${sender.login} opened PR #${number}: "${title}".${branchSegment}` +
|
|
503
|
+
' Please review the changes line-by-line and post your feedback.'
|
|
504
|
+
|
|
505
|
+
const updatedAt = readString(pr, 'updated_at') ?? ''
|
|
506
|
+
const prId = readNumber(pr, 'id') ?? number
|
|
507
|
+
const externalMessageId = `pr-${prId}-opened-${updatedAt}`
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
...base,
|
|
511
|
+
chat: `pr:${number}`,
|
|
512
|
+
thread: null,
|
|
513
|
+
text,
|
|
514
|
+
externalMessageId,
|
|
515
|
+
authorId: String(sender.id),
|
|
516
|
+
authorName: sender.login,
|
|
517
|
+
authorIsBot: sender.type === 'Bot',
|
|
518
|
+
isBotMention: true,
|
|
519
|
+
replyToBotMessageId: null,
|
|
520
|
+
ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
421
524
|
export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
|
|
422
525
|
|
|
423
526
|
export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
|
|
@@ -440,9 +543,21 @@ function buildInbound(
|
|
|
440
543
|
selfLogin: string | null,
|
|
441
544
|
rawTs: unknown,
|
|
442
545
|
reactionTarget: GithubReactionTarget | null,
|
|
546
|
+
synthesizedAwareness = false,
|
|
443
547
|
): InboundMessage | null {
|
|
444
548
|
if (user === null) return null
|
|
445
549
|
const text = typeof rawText === 'string' ? rawText : ''
|
|
550
|
+
// A body-less inbound reaches engagement as contentless text; in a solo-human
|
|
551
|
+
// channel the fallback engages on it and the agent replies with a generic
|
|
552
|
+
// greeting. The other adapters drop empty text at their classifier — this is
|
|
553
|
+
// the matching guard. Events whose empty body still carries signal (review
|
|
554
|
+
// state, opened-PR/issue title) synthesize non-empty text upstream and so
|
|
555
|
+
// never reach this drop.
|
|
556
|
+
if (text.trim() === '') return null
|
|
557
|
+
// Synthesized awareness lines carry an `@author` prefix describing who acted;
|
|
558
|
+
// that handle is the author, never a third-party mention of the bot, so the
|
|
559
|
+
// body-text mention heuristic must not fire on it.
|
|
560
|
+
const isBotMention = !synthesizedAwareness && selfLogin !== null && text.includes(`@${selfLogin}`)
|
|
446
561
|
return {
|
|
447
562
|
...key,
|
|
448
563
|
text,
|
|
@@ -451,12 +566,48 @@ function buildInbound(
|
|
|
451
566
|
authorId: String(user.id),
|
|
452
567
|
authorName: user.login,
|
|
453
568
|
authorIsBot: user.type === 'Bot',
|
|
454
|
-
isBotMention
|
|
569
|
+
isBotMention,
|
|
455
570
|
replyToBotMessageId: null,
|
|
456
571
|
ts: typeof rawTs === 'string' ? Date.parse(rawTs) || 0 : 0,
|
|
457
572
|
}
|
|
458
573
|
}
|
|
459
574
|
|
|
575
|
+
function bodyOrOpenedTitle(
|
|
576
|
+
rawBody: unknown,
|
|
577
|
+
opener: GithubUser | null,
|
|
578
|
+
kind: 'issue' | 'PR' | 'discussion',
|
|
579
|
+
number: number,
|
|
580
|
+
title: string | null,
|
|
581
|
+
): string {
|
|
582
|
+
const body = typeof rawBody === 'string' ? rawBody : ''
|
|
583
|
+
if (body.trim() !== '' || opener === null) return body
|
|
584
|
+
const label = title !== null && title.trim() !== '' ? `: "${title}"` : ''
|
|
585
|
+
return `@${opener.login} opened ${kind} #${number}${label}.`
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Neutral phrasing per review state — must never imply a review was requested
|
|
589
|
+
// or that action is needed; a COMMENTED review in particular must not read as
|
|
590
|
+
// "please review", which is the review-request path's wording.
|
|
591
|
+
function synthesizeReviewStateText(
|
|
592
|
+
reviewer: string,
|
|
593
|
+
number: number,
|
|
594
|
+
title: string | null,
|
|
595
|
+
state: string | null,
|
|
596
|
+
): string {
|
|
597
|
+
const label = title !== null && title.trim() !== '' ? `: "${title}"` : ''
|
|
598
|
+
// GitHub's pull_request_review webhook can send the state in either case
|
|
599
|
+
// depending on the source (webhook payload vs REST), so normalize before
|
|
600
|
+
// matching — an unmatched state would silently fall back to the neutral verb.
|
|
601
|
+
const normalized = state?.toLowerCase() ?? null
|
|
602
|
+
const verb =
|
|
603
|
+
normalized === 'approved'
|
|
604
|
+
? 'approved'
|
|
605
|
+
: normalized === 'changes_requested'
|
|
606
|
+
? 'requested changes on'
|
|
607
|
+
: 'submitted a review on'
|
|
608
|
+
return `@${reviewer} ${verb} PR #${number}${label}.`
|
|
609
|
+
}
|
|
610
|
+
|
|
460
611
|
async function resolveTeamMembership(
|
|
461
612
|
event: string,
|
|
462
613
|
payload: Record<string, unknown>,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
parseListHooksPermissionStatus,
|
|
22
22
|
} from './permission-guidance'
|
|
23
23
|
import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
|
|
24
|
+
import { createGithubReviewThreadResolver } from './review-thread-resolver'
|
|
24
25
|
import { createTeamMembershipChecker } from './team-membership'
|
|
25
26
|
import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
|
|
26
27
|
|
|
@@ -137,6 +138,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
137
138
|
workspaceForChat: (chat) => workspaceByChat.get(chat) ?? null,
|
|
138
139
|
})
|
|
139
140
|
const membership = createGithubMembershipResolver({ token: authToken, fetchImpl })
|
|
141
|
+
const reviewThreadResolver = createGithubReviewThreadResolver({
|
|
142
|
+
token: authToken,
|
|
143
|
+
selfLogin: () => selfLogin,
|
|
144
|
+
fetchImpl,
|
|
145
|
+
})
|
|
140
146
|
const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
|
|
141
147
|
// GitHub addresses by `@login`, not the numeric id, so `username` carries
|
|
142
148
|
// the login the model should type; the id is kept for completeness.
|
|
@@ -155,6 +161,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
155
161
|
selfLogin: () => selfLogin,
|
|
156
162
|
authType: () => options.secrets.auth.type,
|
|
157
163
|
allowApprove: () => options.configRef().review.approve,
|
|
164
|
+
reviewOn: () => options.configRef().review.on,
|
|
158
165
|
isBotInTeam,
|
|
159
166
|
authToken,
|
|
160
167
|
fetchImpl,
|
|
@@ -187,6 +194,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
187
194
|
options.router.registerMembership('github', membership)
|
|
188
195
|
options.router.registerChannelNameResolver('github', channelNameResolver)
|
|
189
196
|
options.router.registerSelfIdentity('github', selfIdentityResolver)
|
|
197
|
+
options.router.registerReviewThreadResolver('github', reviewThreadResolver)
|
|
190
198
|
options.router.registerFetchAttachment('github', fetchAttachment)
|
|
191
199
|
try {
|
|
192
200
|
server = (options.httpListenImpl ?? listenWithBun)(options.configRef().webhookPort, handler)
|
|
@@ -201,6 +209,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
201
209
|
options.router.unregisterMembership('github', membership)
|
|
202
210
|
options.router.unregisterChannelNameResolver('github', channelNameResolver)
|
|
203
211
|
options.router.unregisterSelfIdentity('github', selfIdentityResolver)
|
|
212
|
+
options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
|
|
204
213
|
options.router.unregisterFetchAttachment('github', fetchAttachment)
|
|
205
214
|
await auth.dispose()
|
|
206
215
|
delete process.env.GH_TOKEN
|
|
@@ -324,6 +333,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
324
333
|
options.router.unregisterMembership('github', membership)
|
|
325
334
|
options.router.unregisterChannelNameResolver('github', channelNameResolver)
|
|
326
335
|
options.router.unregisterSelfIdentity('github', selfIdentityResolver)
|
|
336
|
+
options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
|
|
327
337
|
options.router.unregisterFetchAttachment('github', fetchAttachment)
|
|
328
338
|
await server?.stop()
|
|
329
339
|
// Detach hooks AFTER closing the listener so any in-flight deliveries
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { ReviewThreadResolveRequest, ReviewThreadResolveResult, ReviewThreadResolver } from '@/channels/types'
|
|
2
|
+
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
4
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
5
|
+
|
|
6
|
+
const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
|
|
7
|
+
|
|
8
|
+
// One page of review threads. `first: 100` is the GraphQL max; a busy PR can
|
|
9
|
+
// carry more, so the resolver paginates until it matches the root comment id
|
|
10
|
+
// or exhausts the pages — stopping early on a 404-equivalent (thread absent)
|
|
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}}}}}}}}`
|
|
13
|
+
|
|
14
|
+
const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
|
|
15
|
+
|
|
16
|
+
type ReviewThreadNode = {
|
|
17
|
+
id: string
|
|
18
|
+
isResolved: boolean
|
|
19
|
+
rootCommentId: number | null
|
|
20
|
+
rootAuthorLogin: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ThreadLookup =
|
|
24
|
+
| { kind: 'found'; thread: ReviewThreadNode }
|
|
25
|
+
| { kind: 'absent' }
|
|
26
|
+
| { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
|
|
27
|
+
|
|
28
|
+
export function createGithubReviewThreadResolver(deps: {
|
|
29
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
30
|
+
selfLogin: () => string | null
|
|
31
|
+
fetchImpl?: typeof fetch
|
|
32
|
+
}): ReviewThreadResolver {
|
|
33
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
34
|
+
return async (req): Promise<ReviewThreadResolveResult> => {
|
|
35
|
+
if (req.adapter !== 'github') {
|
|
36
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
37
|
+
}
|
|
38
|
+
const target = parseTarget(req)
|
|
39
|
+
if (target === null) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: `unparseable github review-thread target (chat=${req.chat}, root=${req.rootCommentId})`,
|
|
43
|
+
code: 'transient',
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const selfLogin = deps.selfLogin()
|
|
47
|
+
if (selfLogin === null) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: 'github self-identity not resolved; cannot verify thread authorship',
|
|
51
|
+
code: 'transient',
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
|
|
56
|
+
const lookup = await findThread(fetchImpl, token, target)
|
|
57
|
+
if (lookup.kind === 'error') return lookup.result
|
|
58
|
+
if (lookup.kind === 'absent') {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `no review thread rooted at comment ${target.rootCommentId} on ${req.chat}`,
|
|
62
|
+
code: 'no-match',
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const thread = lookup.thread
|
|
67
|
+
// The load-bearing guard: only the bot may resolve the bot's own thread.
|
|
68
|
+
// Resolving a human reviewer's thread would erase their open question.
|
|
69
|
+
if (thread.rootAuthorLogin !== selfLogin) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
|
|
73
|
+
code: 'not-author',
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (thread.isResolved) return { ok: true, alreadyResolved: true }
|
|
77
|
+
|
|
78
|
+
return await runResolveMutation(fetchImpl, token, thread.id)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
|
|
83
|
+
|
|
84
|
+
function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
|
|
85
|
+
const [owner, repo, ...rest] = req.workspace.split('/')
|
|
86
|
+
if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
|
|
87
|
+
const prMatch = /^pr:(\d+)$/.exec(req.chat)
|
|
88
|
+
if (prMatch === null) return null
|
|
89
|
+
const prNumber = parseDecimalId(prMatch[1])
|
|
90
|
+
const rootCommentId = parseDecimalId(req.rootCommentId)
|
|
91
|
+
if (prNumber === null || rootCommentId === null) return null
|
|
92
|
+
return { owner, repo, prNumber, rootCommentId }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Strict decimal-id parse: `Number()` would coerce '' -> 0, '1e2' -> 100, and
|
|
96
|
+
// silently round ids past 2^53 (GitHub comment ids are large), any of which
|
|
97
|
+
// could match the WRONG thread. Demand a plain run of digits and a safe
|
|
98
|
+
// integer, so a malformed or oversized id fails closed (no resolution) rather
|
|
99
|
+
// than resolving a collided thread.
|
|
100
|
+
function parseDecimalId(value: string | undefined): number | null {
|
|
101
|
+
if (value === undefined || !/^\d+$/.test(value)) return null
|
|
102
|
+
const n = Number(value)
|
|
103
|
+
return Number.isSafeInteger(n) ? n : null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function findThread(fetchImpl: typeof fetch, token: string, target: ResolveTarget): Promise<ThreadLookup> {
|
|
107
|
+
let after: string | null = null
|
|
108
|
+
for (;;) {
|
|
109
|
+
let response: Response
|
|
110
|
+
try {
|
|
111
|
+
response = await fetchImpl(GRAPHQL_ENDPOINT, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: githubJsonHeaders(token),
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
query: THREADS_QUERY,
|
|
116
|
+
variables: { owner: target.owner, name: target.repo, number: target.prNumber, after },
|
|
117
|
+
}),
|
|
118
|
+
})
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
kind: 'error',
|
|
122
|
+
result: { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' },
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const parsed = await parseThreadsPage(response)
|
|
126
|
+
if (parsed.kind === 'error') return { kind: 'error', result: parsed.result }
|
|
127
|
+
|
|
128
|
+
for (const node of parsed.nodes) {
|
|
129
|
+
if (node.rootCommentId === target.rootCommentId) return { kind: 'found', thread: node }
|
|
130
|
+
}
|
|
131
|
+
if (!parsed.hasNextPage || parsed.endCursor === null) return { kind: 'absent' }
|
|
132
|
+
after = parsed.endCursor
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type ThreadsPage =
|
|
137
|
+
| { kind: 'ok'; nodes: ReviewThreadNode[]; hasNextPage: boolean; endCursor: string | null }
|
|
138
|
+
| { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
|
|
139
|
+
|
|
140
|
+
async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const text = await response.text().catch(() => '')
|
|
143
|
+
return {
|
|
144
|
+
kind: 'error',
|
|
145
|
+
result: {
|
|
146
|
+
ok: false,
|
|
147
|
+
error: `GitHub GraphQL ${response.status}${text !== '' ? `: ${text}` : ''}`,
|
|
148
|
+
code: classifyStatus(response.status),
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const body = (await response.json().catch(() => null)) as GraphqlThreadsResponse | null
|
|
153
|
+
if (body === null)
|
|
154
|
+
return { kind: 'error', result: { ok: false, error: 'GitHub GraphQL returned non-JSON', code: 'transient' } }
|
|
155
|
+
if (body.errors !== undefined && body.errors.length > 0) {
|
|
156
|
+
return {
|
|
157
|
+
kind: 'error',
|
|
158
|
+
result: {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: `GitHub GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`,
|
|
161
|
+
code: 'transient',
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const connection = body.data?.repository?.pullRequest?.reviewThreads
|
|
166
|
+
if (connection === undefined)
|
|
167
|
+
return {
|
|
168
|
+
kind: 'error',
|
|
169
|
+
result: { ok: false, error: 'GitHub GraphQL response missing reviewThreads', code: 'transient' },
|
|
170
|
+
}
|
|
171
|
+
const nodes = connection.nodes.map((n) => {
|
|
172
|
+
const root = n.comments.nodes[0]
|
|
173
|
+
return {
|
|
174
|
+
id: n.id,
|
|
175
|
+
isResolved: n.isResolved,
|
|
176
|
+
rootCommentId: root?.databaseId ?? null,
|
|
177
|
+
rootAuthorLogin: root?.author?.login ?? null,
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runResolveMutation(
|
|
184
|
+
fetchImpl: typeof fetch,
|
|
185
|
+
token: string,
|
|
186
|
+
threadId: string,
|
|
187
|
+
): Promise<ReviewThreadResolveResult> {
|
|
188
|
+
let response: Response
|
|
189
|
+
try {
|
|
190
|
+
response = await fetchImpl(GRAPHQL_ENDPOINT, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: githubJsonHeaders(token),
|
|
193
|
+
body: JSON.stringify({ query: RESOLVE_MUTATION, variables: { threadId } }),
|
|
194
|
+
})
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
|
|
197
|
+
}
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
const text = await response.text().catch(() => '')
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
error: `GitHub GraphQL ${response.status}${text !== '' ? `: ${text}` : ''}`,
|
|
203
|
+
code: classifyStatus(response.status),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const body = (await response.json().catch(() => null)) as GraphqlResolveResponse | null
|
|
207
|
+
if (body === null) return { ok: false, error: 'GitHub GraphQL returned non-JSON', code: 'transient' }
|
|
208
|
+
if (body.errors !== undefined && body.errors.length > 0) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
error: `GitHub GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`,
|
|
212
|
+
code: 'transient',
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (body.data?.resolveReviewThread?.thread?.isResolved === true) return { ok: true }
|
|
216
|
+
return { ok: false, error: 'resolveReviewThread mutation did not report isResolved', code: 'transient' }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function classifyStatus(status: number): 'permission-denied' | 'not-found' | 'transient' {
|
|
220
|
+
if (status === 401 || status === 403) return 'permission-denied'
|
|
221
|
+
if (status === 404) return 'not-found'
|
|
222
|
+
return 'transient'
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type GraphqlThreadsResponse = {
|
|
226
|
+
data?: {
|
|
227
|
+
repository?: {
|
|
228
|
+
pullRequest?: {
|
|
229
|
+
reviewThreads?: {
|
|
230
|
+
pageInfo: { hasNextPage: boolean; endCursor: string | null }
|
|
231
|
+
nodes: Array<{
|
|
232
|
+
id: string
|
|
233
|
+
isResolved: boolean
|
|
234
|
+
comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
|
|
235
|
+
}>
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
errors?: Array<{ message: string }>
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
type GraphqlResolveResponse = {
|
|
244
|
+
data?: { resolveReviewThread?: { thread?: { id: string; isResolved: boolean } } }
|
|
245
|
+
errors?: Array<{ message: string }>
|
|
246
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { KAKAO_MESSAGE_TYPE, type KakaoTalkPushMessageEvent } from 'agent-messenger/kakaotalk'
|
|
2
2
|
|
|
3
3
|
import { matchesAnyAlias } from '@/channels/engagement'
|
|
4
4
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
5
|
-
import type { InboundAttachment, InboundMessage } from '@/channels/types'
|
|
5
|
+
import type { InboundAttachment, InboundMessage, InboundReferenceContext } from '@/channels/types'
|
|
6
6
|
|
|
7
7
|
export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect' | 'bot_message'
|
|
8
8
|
|
|
@@ -49,7 +49,9 @@ export function classifyInbound(
|
|
|
49
49
|
return { kind: 'drop', reason: 'bot_message' }
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const
|
|
52
|
+
const rawText = event.message ?? ''
|
|
53
|
+
const replyContext = parseReplyContext(event, context.selfUserId)
|
|
54
|
+
const text = rawText
|
|
53
55
|
if (text === '') return { kind: 'drop', reason: 'empty_text' }
|
|
54
56
|
|
|
55
57
|
const chatInfo = context.lookupChat(event.chat_id)
|
|
@@ -64,7 +66,7 @@ export function classifyInbound(
|
|
|
64
66
|
// mention (see engagement.ts: alias is unconditional and ranks alongside
|
|
65
67
|
// explicit triggers). Without aliases configured, only `reply` and `dm`
|
|
66
68
|
// triggers can fire on KakaoTalk.
|
|
67
|
-
const aliasMatched = matchesAnyAlias(
|
|
69
|
+
const aliasMatched = matchesAnyAlias(rawText, context.selfAliases ?? [])
|
|
68
70
|
|
|
69
71
|
return {
|
|
70
72
|
kind: 'route',
|
|
@@ -74,6 +76,7 @@ export function classifyInbound(
|
|
|
74
76
|
chat: event.chat_id,
|
|
75
77
|
thread: null,
|
|
76
78
|
text,
|
|
79
|
+
...referenceContextPayload(replyContext),
|
|
77
80
|
...(context.attachments !== undefined && context.attachments.length > 0
|
|
78
81
|
? { attachments: context.attachments }
|
|
79
82
|
: {}),
|
|
@@ -82,9 +85,9 @@ export function classifyInbound(
|
|
|
82
85
|
authorName: event.author_name ?? String(event.author_id),
|
|
83
86
|
authorIsBot: false,
|
|
84
87
|
isBotMention: aliasMatched,
|
|
85
|
-
replyToBotMessageId: null,
|
|
88
|
+
replyToBotMessageId: replyContext?.target === 'bot' ? replyContext.logId : null,
|
|
86
89
|
mentionsOthers: false,
|
|
87
|
-
replyToOtherMessageId: null,
|
|
90
|
+
replyToOtherMessageId: replyContext?.target === 'other' ? replyContext.logId : null,
|
|
88
91
|
isDm: chatInfo.isDm,
|
|
89
92
|
// SDK delivers `sent_at` in Unix seconds (LOCO `sendAt`); contract
|
|
90
93
|
// wants ms (see `src/channels/types.ts`). Without `* 1000`, ms-based
|
|
@@ -93,3 +96,61 @@ export function classifyInbound(
|
|
|
93
96
|
},
|
|
94
97
|
}
|
|
95
98
|
}
|
|
99
|
+
|
|
100
|
+
type ParsedReplyContext = {
|
|
101
|
+
logId: string
|
|
102
|
+
authorId: string
|
|
103
|
+
authorName: string
|
|
104
|
+
quotedText: string | null
|
|
105
|
+
target: 'bot' | 'other'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseReplyContext(event: KakaoTalkPushMessageEvent, selfUserId: string): ParsedReplyContext | null {
|
|
109
|
+
if (event.message_type !== KAKAO_MESSAGE_TYPE.REPLY) return null
|
|
110
|
+
if (event.attachment === null) return null
|
|
111
|
+
|
|
112
|
+
const logId = stringField(event.attachment, 'src_logId')
|
|
113
|
+
const sourceUserId = numericField(event.attachment, 'src_userId')
|
|
114
|
+
if (logId === null || sourceUserId === null) return null
|
|
115
|
+
|
|
116
|
+
const sourceAuthorId = String(sourceUserId)
|
|
117
|
+
const quotedText = stringField(event.attachment, 'src_message')
|
|
118
|
+
return {
|
|
119
|
+
logId,
|
|
120
|
+
authorId: sourceAuthorId,
|
|
121
|
+
// classifyInbound is synchronous and has no cheap author resolver access;
|
|
122
|
+
// fall back to Kakao's stable source user id rather than fetching.
|
|
123
|
+
authorName: sourceAuthorId,
|
|
124
|
+
quotedText,
|
|
125
|
+
target: sourceAuthorId === selfUserId ? 'bot' : 'other',
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function referenceContextPayload(
|
|
130
|
+
replyContext: ParsedReplyContext | null,
|
|
131
|
+
): { referenceContext: InboundReferenceContext } | Record<string, never> {
|
|
132
|
+
if (replyContext === null || replyContext.quotedText === null || replyContext.quotedText.trim() === '') return {}
|
|
133
|
+
return {
|
|
134
|
+
referenceContext: {
|
|
135
|
+
kind: 'reply',
|
|
136
|
+
sources: [
|
|
137
|
+
{
|
|
138
|
+
adapter: 'kakaotalk',
|
|
139
|
+
authorId: replyContext.authorId,
|
|
140
|
+
authorName: replyContext.authorName,
|
|
141
|
+
text: replyContext.quotedText,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stringField(record: Record<string, unknown>, key: string): string | null {
|
|
149
|
+
const value = record[key]
|
|
150
|
+
return typeof value === 'string' && value.length > 0 ? value : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function numericField(record: Record<string, unknown>, key: string): number | null {
|
|
154
|
+
const value = record[key]
|
|
155
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
156
|
+
}
|