typeclaw 0.37.3 → 0.37.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -46
- package/package.json +1 -1
- package/src/agent/compaction.ts +24 -15
- package/src/agent/doctor.ts +6 -1
- package/src/agent/session-origin.ts +101 -173
- package/src/agent/subagents.ts +146 -14
- package/src/agent/system-prompt.ts +46 -48
- package/src/agent/todo/scope.ts +4 -2
- package/src/agent/tools/channel-reply.ts +7 -9
- package/src/bundled-plugins/memory/index.ts +33 -33
- package/src/bundled-plugins/memory/load-memory.ts +92 -35
- package/src/bundled-plugins/memory/slug.ts +19 -0
- package/src/bundled-plugins/memory/turn-dedup.ts +32 -29
- package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
- package/src/bundled-plugins/tool-result-cap/README.md +7 -7
- package/src/bundled-plugins/tool-result-cap/index.ts +1 -1
- package/src/channels/adapters/discord-bot.ts +11 -4
- package/src/channels/adapters/github/inbound.ts +68 -43
- package/src/channels/adapters/github/index.ts +57 -9
- package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
- package/src/channels/adapters/kakaotalk.ts +5 -1
- package/src/channels/adapters/mention-hints.ts +75 -0
- package/src/channels/adapters/slack-bot.ts +8 -2
- package/src/channels/continuation-willingness.ts +216 -68
- package/src/channels/router.ts +149 -15
- package/src/cli/dreams.ts +2 -2
- package/src/cli/init.ts +41 -7
- package/src/cli/inspect.ts +2 -2
- package/src/cli/logs.ts +2 -2
- package/src/cli/qr.ts +4 -3
- package/src/cli/require-agent-dir.ts +31 -0
- package/src/cli/shell.ts +2 -2
- package/src/cli/stop.ts +2 -2
- package/src/cli/tui.ts +20 -6
- package/src/cli/ui.ts +8 -4
- package/src/container/shared.ts +18 -0
- package/src/container/start.ts +1 -1
- package/src/doctor/checks.ts +145 -2
- package/src/hostd/client.ts +48 -52
- package/src/hostd/daemon.ts +82 -39
- package/src/hostd/paths.ts +22 -2
- package/src/hostd/spawn.ts +7 -0
- package/src/hostd/tailscale.ts +12 -1
- package/src/init/index.ts +35 -8
- package/src/init/kakaotalk-auth.ts +2 -2
- package/src/init/packagejson.ts +2 -2
- package/src/init/run-bun-install.ts +71 -37
- package/src/inspect/transcript-view.ts +15 -2
- package/src/plugin/loader.ts +7 -4
- package/src/portbroker/hostd-client.ts +32 -6
- package/src/sandbox/session-tmp.ts +6 -1
- package/src/secrets/export-claude-credentials-file.ts +2 -2
- package/src/shared/index.ts +4 -0
- package/src/shared/platform.ts +11 -0
- package/src/shared/wsl.ts +139 -0
- package/src/tui/index.ts +26 -8
- package/src/tui/terminal-guard.ts +139 -0
- package/typeclaw.schema.json +2 -2
|
@@ -61,56 +61,81 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const delivery = req.headers.get('x-github-delivery') ?? ''
|
|
64
|
-
if (delivery !== '' && options.dedup.has(delivery)) {
|
|
65
|
-
options.logger.info(`[github] duplicate delivery ignored id=${delivery}`)
|
|
66
|
-
return ok()
|
|
67
|
-
}
|
|
68
|
-
|
|
69
64
|
const event = req.headers.get('x-github-event') ?? ''
|
|
70
65
|
const payload = parseJson(body)
|
|
71
66
|
if (payload === null) return ok()
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const author = readAuthor(event, payload)
|
|
78
|
-
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
79
|
-
maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
|
|
80
|
-
options.logger.info(
|
|
81
|
-
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
82
|
-
)
|
|
83
|
-
return ok()
|
|
84
|
-
}
|
|
67
|
+
// The HTTP request is only transport; event handling is the shared core.
|
|
68
|
+
await processVerifiedGithubDelivery(options, { event, delivery, payload })
|
|
69
|
+
return ok()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
85
72
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
73
|
+
// Post-verification core shared by the live HTTP handler AND the missed-delivery
|
|
74
|
+
// recovery sweep (recover-failed-deliveries.ts), which pulls lost payloads from
|
|
75
|
+
// GitHub's authenticated deliveries API and re-injects them with no HTTP request
|
|
76
|
+
// or signature. Routing both through this one function is the load-bearing
|
|
77
|
+
// invariant: recovery must never become a second, divergent event pipeline.
|
|
78
|
+
// `delivery` is the `X-GitHub-Delivery` GUID (stable across redeliveries, equal
|
|
79
|
+
// to a delivery's `guid`) used as the dedup key, so a recovered event and its
|
|
80
|
+
// later/duplicate live delivery collapse to one route. HMAC is the caller's job:
|
|
81
|
+
// the live handler verifies the body; the sweep trusts the authenticated API.
|
|
82
|
+
export async function processVerifiedGithubDelivery(
|
|
83
|
+
options: GithubWebhookHandlerOptions,
|
|
84
|
+
input: { event: string; delivery: string; payload: Record<string, unknown> },
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const { event, delivery, payload } = input
|
|
87
|
+
if (delivery !== '') {
|
|
88
|
+
if (options.dedup.has(delivery)) {
|
|
89
|
+
options.logger.info(`[github] duplicate delivery ignored id=${delivery}`)
|
|
90
|
+
return
|
|
98
91
|
}
|
|
92
|
+
// Reserve the delivery id synchronously, BEFORE the awaits below, so a live
|
|
93
|
+
// webhook and the recovery sweep can never both clear the dedup gate for the
|
|
94
|
+
// same event and route it twice. JS is single-threaded: nothing else runs
|
|
95
|
+
// between this has-check and add, so the reservation is atomic. The awaits
|
|
96
|
+
// and classify that follow are all throw-safe, so reserving early cannot
|
|
97
|
+
// strand a routable event.
|
|
98
|
+
options.dedup.add(delivery)
|
|
99
|
+
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return ok()
|
|
101
|
+
const action = readString(payload, 'action')
|
|
102
|
+
if (!isGithubEventAllowed(options.allowlist(), event, action)) return
|
|
103
|
+
|
|
104
|
+
const selfId = options.selfId()
|
|
105
|
+
const selfLogin = options.selfLogin()
|
|
106
|
+
const author = readAuthor(event, payload)
|
|
107
|
+
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
108
|
+
maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
|
|
109
|
+
options.logger.info(
|
|
110
|
+
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
// A push to an open PR (`synchronize`) is not a message to react to — it is
|
|
116
|
+
// a trigger to re-evaluate the bot's own outstanding review obligations on
|
|
117
|
+
// this PR: unresolved review threads it authored AND a sticky
|
|
118
|
+
// CHANGES_REQUESTED block (which leaves no threads when filed as a top-level
|
|
119
|
+
// verdict — the black hole this path closes). Both need an API round-trip,
|
|
120
|
+
// so it runs OFF the ACK path (like the decoy-reviewer drop) and only wakes a
|
|
121
|
+
// session when an obligation is outstanding. Returning here also keeps
|
|
122
|
+
// synchronize out of the generic awareness-only fallthrough below.
|
|
123
|
+
if (event === 'pull_request' && action === 'synchronize') {
|
|
124
|
+
scheduleReviewFollowup({ payload, selfLogin, options })
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const teamIsBotMember = await resolveTeamMembership(event, payload, options)
|
|
129
|
+
const reviewCommentParent = await resolveReviewCommentParent(event, payload, selfId, selfLogin, options)
|
|
130
|
+
const classified = classifyGithubInbound(event, payload, selfLogin, {
|
|
131
|
+
teamIsBotMember,
|
|
132
|
+
authType: options.authType?.() ?? 'pat',
|
|
133
|
+
reviewOn: options.reviewOn?.() ?? 'review_requested',
|
|
134
|
+
...(reviewCommentParent !== null ? { reviewCommentParent } : {}),
|
|
135
|
+
})
|
|
136
|
+
if (classified === null) return
|
|
137
|
+
|
|
138
|
+
options.route(withApprovalPolicy(classified, options.allowApprove?.() ?? true))
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
export const PR_APPROVAL_DISABLED_NOTE =
|
|
@@ -11,7 +11,7 @@ import { createDeliveryDedup } from './dedup'
|
|
|
11
11
|
import { findPermissionGaps } from './event-permissions'
|
|
12
12
|
import { createGithubFetchAttachmentCallback } from './fetch-attachment'
|
|
13
13
|
import { createGithubHistoryCallback } from './history'
|
|
14
|
-
import { createGithubWebhookHandler } from './inbound'
|
|
14
|
+
import { createGithubWebhookHandler, processVerifiedGithubDelivery, type GithubWebhookHandlerOptions } from './inbound'
|
|
15
15
|
import { applyManagedPath, buildManagedPath, resolveAgentId } from './managed-path'
|
|
16
16
|
import { createGithubMembershipResolver } from './membership'
|
|
17
17
|
import { createGithubOutboundCallback } from './outbound'
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from './permission-guidance'
|
|
23
23
|
import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
|
|
24
24
|
import { reconcileOpenPrs } from './reconcile-open-prs'
|
|
25
|
+
import { createRecoveredGuidLog, recoverFailedGithubDeliveries } from './recover-failed-deliveries'
|
|
25
26
|
import { createGithubReviewStateResolver } from './review-state'
|
|
26
27
|
import { createGithubReviewThreadResolver } from './review-thread-resolver'
|
|
27
28
|
import { createTeamMembershipChecker } from './team-membership'
|
|
@@ -67,6 +68,10 @@ export type GithubAdapterOptions = {
|
|
|
67
68
|
// Test-only: replaces `setInterval` so tests can control when the
|
|
68
69
|
// background refresh fires without waiting on real wall-clock time.
|
|
69
70
|
setInterval?: (handler: () => void, ms: number) => { clear: () => void }
|
|
71
|
+
// How often to sweep each managed hook's GitHub delivery log for events whose
|
|
72
|
+
// inbound delivery failed (and that GitHub never redelivered), re-injecting
|
|
73
|
+
// them through the live event path. Zero disables the sweep. Default: 5 min.
|
|
74
|
+
deliveryRecoveryIntervalMs?: number
|
|
70
75
|
// Write-side of the GithubTokenBridge. On App-auth start the adapter
|
|
71
76
|
// registers a per-repo minter here so plugin hooks can resolve a token for
|
|
72
77
|
// ad-hoc `gh` commands; it unregisters on stop and on start rollback. PAT
|
|
@@ -89,6 +94,12 @@ const consoleLogger: GithubAdapterLogger = {
|
|
|
89
94
|
|
|
90
95
|
const DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS = 2_000
|
|
91
96
|
const DEFAULT_TOKEN_REFRESH_INTERVAL_MS = 30 * 60 * 1000
|
|
97
|
+
const DEFAULT_DELIVERY_RECOVERY_INTERVAL_MS = 5 * 60 * 1000
|
|
98
|
+
// GitHub retains the delivery log for 3 days; sweep a little under that so a
|
|
99
|
+
// failed delivery is always still listable on the next interval.
|
|
100
|
+
const DELIVERY_RECOVERY_LOOKBACK_MS = 70 * 60 * 60 * 1000
|
|
101
|
+
// Bounds an LLM-session storm if a bad tunnel window drops a large burst.
|
|
102
|
+
const MAX_RECOVERED_PER_SWEEP = 50
|
|
92
103
|
|
|
93
104
|
export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
|
|
94
105
|
const logger = options.logger ?? consoleLogger
|
|
@@ -105,8 +116,15 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
105
116
|
let started = false
|
|
106
117
|
let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
|
|
107
118
|
let tokenRefreshTimer: { clear: () => void } | null = null
|
|
119
|
+
let deliveryRecoveryTimer: { clear: () => void } | null = null
|
|
108
120
|
let unregisterTokenBridge: (() => void) | null = null
|
|
109
121
|
const workspaceByChat = new Map<string, string>()
|
|
122
|
+
const setIntervalFn =
|
|
123
|
+
options.setInterval ??
|
|
124
|
+
((handler: () => void, ms: number) => {
|
|
125
|
+
const timer = setInterval(handler, ms)
|
|
126
|
+
return { clear: () => clearInterval(timer) }
|
|
127
|
+
})
|
|
110
128
|
|
|
111
129
|
const rememberWorkspace = (workspace: string, chat: string): void => {
|
|
112
130
|
workspaceByChat.set(chat, workspace)
|
|
@@ -174,7 +192,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
174
192
|
logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
175
193
|
})
|
|
176
194
|
}
|
|
177
|
-
const
|
|
195
|
+
const handlerOptions: GithubWebhookHandlerOptions = {
|
|
178
196
|
webhookSecret,
|
|
179
197
|
dedup,
|
|
180
198
|
allowlist: () => options.configRef().eventAllowlist,
|
|
@@ -188,7 +206,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
188
206
|
fetchImpl,
|
|
189
207
|
logger,
|
|
190
208
|
route: routeInbound,
|
|
191
|
-
}
|
|
209
|
+
}
|
|
210
|
+
const handler = createGithubWebhookHandler(handlerOptions)
|
|
192
211
|
|
|
193
212
|
return {
|
|
194
213
|
async start(): Promise<void> {
|
|
@@ -251,12 +270,6 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
251
270
|
)
|
|
252
271
|
})
|
|
253
272
|
}
|
|
254
|
-
const setIntervalFn =
|
|
255
|
-
options.setInterval ??
|
|
256
|
-
((handler: () => void, ms: number) => {
|
|
257
|
-
const timer = setInterval(handler, ms)
|
|
258
|
-
return { clear: () => clearInterval(timer) }
|
|
259
|
-
})
|
|
260
273
|
tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
|
|
261
274
|
}
|
|
262
275
|
} else {
|
|
@@ -355,10 +368,45 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
355
368
|
logger.warn(`[github] reconcile pass failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
356
369
|
})
|
|
357
370
|
}
|
|
371
|
+
// Periodically recover inbound deliveries that failed at the tunnel and
|
|
372
|
+
// were never redelivered (the cloudflare-quick 502 loss). Registered only
|
|
373
|
+
// when we manage hooks to query, and driven by the same injectable timer
|
|
374
|
+
// as the token refresh. The first sweep fires after one interval — NOT
|
|
375
|
+
// inside start() — so start() stays free of surprise API traffic; the
|
|
376
|
+
// reconcile pass above already covers the review-needed case immediately.
|
|
377
|
+
const deliveryRecoveryIntervalMs = options.deliveryRecoveryIntervalMs ?? DEFAULT_DELIVERY_RECOVERY_INTERVAL_MS
|
|
378
|
+
if (managedHooks.length > 0 && deliveryRecoveryIntervalMs > 0) {
|
|
379
|
+
// Created once and captured by `sweep`, so recovery idempotency persists
|
|
380
|
+
// across ticks even when the shared live dedup evicts the guid.
|
|
381
|
+
const recoveredLog = createRecoveredGuidLog(DELIVERY_RECOVERY_LOOKBACK_MS)
|
|
382
|
+
const sweep = () => {
|
|
383
|
+
recoverFailedGithubDeliveries({
|
|
384
|
+
hooks: managedHooks,
|
|
385
|
+
token: (repoSlug: string) => auth.token({ repoSlug }),
|
|
386
|
+
process: (input) => processVerifiedGithubDelivery(handlerOptions, input),
|
|
387
|
+
alreadySeen: (guid: string) => dedup.has(guid),
|
|
388
|
+
recoveredLog,
|
|
389
|
+
lookbackMs: DELIVERY_RECOVERY_LOOKBACK_MS,
|
|
390
|
+
maxPerSweep: MAX_RECOVERED_PER_SWEEP,
|
|
391
|
+
logger,
|
|
392
|
+
fetchImpl,
|
|
393
|
+
}).catch((err: unknown) => {
|
|
394
|
+
logger.warn(`[github] delivery recovery sweep failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
deliveryRecoveryTimer = setIntervalFn(sweep, deliveryRecoveryIntervalMs)
|
|
398
|
+
}
|
|
358
399
|
},
|
|
359
400
|
async stop(): Promise<void> {
|
|
360
401
|
if (!started) return
|
|
361
402
|
started = false
|
|
403
|
+
// Stop the recovery sweep first: its async work outlives the synchronous
|
|
404
|
+
// unregister calls below, and a tick landing mid-teardown would query a
|
|
405
|
+
// hook we're about to deregister and could route during shutdown.
|
|
406
|
+
if (deliveryRecoveryTimer !== null) {
|
|
407
|
+
deliveryRecoveryTimer.clear()
|
|
408
|
+
deliveryRecoveryTimer = null
|
|
409
|
+
}
|
|
362
410
|
options.router.unregisterOutbound('github', outbound)
|
|
363
411
|
options.router.unregisterReaction('github', reaction)
|
|
364
412
|
options.router.unregisterRemoveReaction('github', removeReaction)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
2
|
+
|
|
3
|
+
// Recovers webhook events whose delivery to our ingress FAILED and that GitHub
|
|
4
|
+
// never successfully redelivered. The production failure mode is inbound-only: a
|
|
5
|
+
// cloudflare-quick tunnel drops ~half its deliveries with 502 "failed to connect
|
|
6
|
+
// to host", and GitHub does not auto-redeliver issue_comment events — so a
|
|
7
|
+
// `@bot review please` comment is lost with no log entry and no reply.
|
|
8
|
+
//
|
|
9
|
+
// This sweep is OUTBOUND-only, so it never touches the broken inbound leg: it
|
|
10
|
+
// lists each managed hook's delivery log, finds events with no successful
|
|
11
|
+
// delivery, fetches the original payload from GitHub's authenticated deliveries
|
|
12
|
+
// API, and feeds it through the SAME processVerifiedGithubDelivery core a live
|
|
13
|
+
// webhook uses (passed in as `process`). It is a floor, not the primary path —
|
|
14
|
+
// webhooks remain low-latency when delivery works; reconcile-open-prs.ts is the
|
|
15
|
+
// sibling floor for review-state drift.
|
|
16
|
+
|
|
17
|
+
export type ManagedHook = { repo: string; hookId: number }
|
|
18
|
+
|
|
19
|
+
// Recovery-owned idempotency keyed by delivery GUID, retained for the FULL
|
|
20
|
+
// lookback window. The shared live dedup cannot serve this alone: it is a
|
|
21
|
+
// fixed 1000-entry LRU, so during a 70h lookback across many repos it can evict
|
|
22
|
+
// a GUID we already recovered, after which the still-listed failed delivery
|
|
23
|
+
// would be re-fetched and re-routed. This TTL log holds only RECOVERED GUIDs
|
|
24
|
+
// (failed-then-recovered deliveries, a small set), expiring each exactly when it
|
|
25
|
+
// also falls out of the scan window — so a recovered delivery is routed once
|
|
26
|
+
// regardless of live-dedup churn. (The shared dedup still guards the live-vs-
|
|
27
|
+
// sweep concurrency race; this guards cross-sweep durability.)
|
|
28
|
+
export type RecoveredGuidLog = { has: (guid: string) => boolean; record: (guid: string) => void }
|
|
29
|
+
|
|
30
|
+
export function createRecoveredGuidLog(ttlMs: number, now: () => number = Date.now): RecoveredGuidLog {
|
|
31
|
+
const expiresAt = new Map<string, number>()
|
|
32
|
+
return {
|
|
33
|
+
has(guid: string): boolean {
|
|
34
|
+
const expiry = expiresAt.get(guid)
|
|
35
|
+
if (expiry === undefined) return false
|
|
36
|
+
if (expiry <= now()) {
|
|
37
|
+
expiresAt.delete(guid)
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
return true
|
|
41
|
+
},
|
|
42
|
+
record(guid: string): void {
|
|
43
|
+
const t = now()
|
|
44
|
+
for (const [g, expiry] of expiresAt) if (expiry <= t) expiresAt.delete(g)
|
|
45
|
+
expiresAt.set(guid, t + ttlMs)
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type RecoverFailedDeliveriesOptions = {
|
|
51
|
+
hooks: readonly ManagedHook[]
|
|
52
|
+
token: (repoSlug: string) => Promise<string>
|
|
53
|
+
// The shared processVerifiedGithubDelivery, bound to the adapter's handler
|
|
54
|
+
// options. `delivery` is the GUID; the core dedups, filters by allowlist,
|
|
55
|
+
// drops self-authored, and routes exactly as the live path does.
|
|
56
|
+
process: (input: { event: string; delivery: string; payload: Record<string, unknown> }) => Promise<void>
|
|
57
|
+
// Fast-path skip backed by the LIVE delivery dedup (shared with the webhook
|
|
58
|
+
// handler): a guid here was just routed live (or reserved by `process` on
|
|
59
|
+
// entry), so skip it. Best-effort only — it is a 1000-entry LRU and may evict
|
|
60
|
+
// within the lookback window, which is exactly why `recoveredLog` exists.
|
|
61
|
+
alreadySeen: (guid: string) => boolean
|
|
62
|
+
// Durable recovery idempotency for the whole lookback window (see
|
|
63
|
+
// createRecoveredGuidLog). Caller-owned so it persists across sweeps.
|
|
64
|
+
recoveredLog: RecoveredGuidLog
|
|
65
|
+
lookbackMs: number
|
|
66
|
+
maxPerSweep: number
|
|
67
|
+
logger: { info: (m: string) => void; warn: (m: string) => void }
|
|
68
|
+
now?: () => number
|
|
69
|
+
fetchImpl?: typeof fetch
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type RecoverOutcome = { recovered: number; scanned: number }
|
|
73
|
+
|
|
74
|
+
export async function recoverFailedGithubDeliveries(options: RecoverFailedDeliveriesOptions): Promise<RecoverOutcome> {
|
|
75
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
76
|
+
const now = options.now ?? Date.now
|
|
77
|
+
const cutoff = now() - options.lookbackMs
|
|
78
|
+
let recovered = 0
|
|
79
|
+
let scanned = 0
|
|
80
|
+
|
|
81
|
+
for (const hook of options.hooks) {
|
|
82
|
+
// maxPerSweep is a GLOBAL budget across all hooks (an LLM-session storm
|
|
83
|
+
// guard), so pass the remaining budget into each hook rather than letting
|
|
84
|
+
// every hook recover up to the full cap independently.
|
|
85
|
+
const remaining = options.maxPerSweep - recovered
|
|
86
|
+
if (remaining <= 0) break
|
|
87
|
+
const target = parseRepo(hook.repo)
|
|
88
|
+
if (target === null) {
|
|
89
|
+
options.logger.warn(`[github] recovery skipped malformed repo slug "${hook.repo}"`)
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const result = await recoverHook(hook, target, cutoff, options, remaining, fetchImpl)
|
|
94
|
+
scanned += result.scanned
|
|
95
|
+
recovered += result.recovered
|
|
96
|
+
if (result.recovered > 0) {
|
|
97
|
+
options.logger.info(`[github] recovered ${result.recovered} missed delivery(s) on ${hook.repo}`)
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Per-hook isolation: one repo's token/list/detail failure must not abort
|
|
101
|
+
// the others. The next interval retries this hook.
|
|
102
|
+
options.logger.warn(`[github] delivery recovery failed for ${hook.repo}: ${describe(err)}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { recovered, scanned }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function recoverHook(
|
|
109
|
+
hook: ManagedHook,
|
|
110
|
+
target: RepoTarget,
|
|
111
|
+
cutoff: number,
|
|
112
|
+
options: RecoverFailedDeliveriesOptions,
|
|
113
|
+
budget: number,
|
|
114
|
+
fetchImpl: typeof fetch,
|
|
115
|
+
): Promise<RecoverOutcome> {
|
|
116
|
+
const token = await options.token(hook.repo)
|
|
117
|
+
const deliveries = await listRecentDeliveries(fetchImpl, token, target, hook.hookId, cutoff)
|
|
118
|
+
|
|
119
|
+
// Any 2xx/3xx delivery for a guid means the event got through (e.g. GitHub
|
|
120
|
+
// auto-redelivered, or a manual redeliver succeeded). Never recover those.
|
|
121
|
+
const succeededGuids = new Set<string>()
|
|
122
|
+
for (const d of deliveries) {
|
|
123
|
+
if (isSuccess(d.statusCode)) succeededGuids.add(d.guid)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let recovered = 0
|
|
127
|
+
let scanned = 0
|
|
128
|
+
const handledThisSweep = new Set<string>()
|
|
129
|
+
for (const delivery of deliveries) {
|
|
130
|
+
if (recovered >= budget) break
|
|
131
|
+
if (isSuccess(delivery.statusCode)) continue
|
|
132
|
+
scanned += 1
|
|
133
|
+
const guid = delivery.guid
|
|
134
|
+
if (guid === '') continue
|
|
135
|
+
if (
|
|
136
|
+
succeededGuids.has(guid) ||
|
|
137
|
+
handledThisSweep.has(guid) ||
|
|
138
|
+
options.alreadySeen(guid) ||
|
|
139
|
+
options.recoveredLog.has(guid)
|
|
140
|
+
) {
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
handledThisSweep.add(guid)
|
|
144
|
+
|
|
145
|
+
const payload = await fetchDeliveryPayload(fetchImpl, token, target, hook.hookId, delivery.id)
|
|
146
|
+
if (payload === null) continue
|
|
147
|
+
await options.process({ event: delivery.event, delivery: guid, payload })
|
|
148
|
+
// Record AFTER process resolves: an unexpected throw leaves the guid
|
|
149
|
+
// unrecorded so the next sweep retries it. A no-op classify still records
|
|
150
|
+
// (process returned), so a non-routable failed delivery is not refetched.
|
|
151
|
+
options.recoveredLog.record(guid)
|
|
152
|
+
recovered += 1
|
|
153
|
+
}
|
|
154
|
+
return { recovered, scanned }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type RepoTarget = { owner: string; repo: string }
|
|
158
|
+
|
|
159
|
+
type DeliverySummary = { id: number; guid: string; event: string; statusCode: number }
|
|
160
|
+
|
|
161
|
+
async function listRecentDeliveries(
|
|
162
|
+
fetchImpl: typeof fetch,
|
|
163
|
+
token: string,
|
|
164
|
+
target: RepoTarget,
|
|
165
|
+
hookId: number,
|
|
166
|
+
cutoff: number,
|
|
167
|
+
): Promise<DeliverySummary[]> {
|
|
168
|
+
const summaries: DeliverySummary[] = []
|
|
169
|
+
let url: string | null =
|
|
170
|
+
`${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/hooks/${hookId}/deliveries?per_page=100`
|
|
171
|
+
while (url !== null) {
|
|
172
|
+
const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
const body = await response.text().catch(() => '')
|
|
175
|
+
throw new Error(`GitHub deliveries ${response.status}${body !== '' ? `: ${body}` : ''}`)
|
|
176
|
+
}
|
|
177
|
+
const page = (await response.json().catch(() => null)) as DeliveryRow[] | null
|
|
178
|
+
if (page === null) throw new Error('GitHub deliveries returned non-JSON')
|
|
179
|
+
// Deliveries are newest-first; once a page's oldest entry predates the
|
|
180
|
+
// lookback cutoff we can stop paginating instead of walking the full log.
|
|
181
|
+
let reachedCutoff = false
|
|
182
|
+
for (const row of page) {
|
|
183
|
+
const parsed = parseDeliveryRow(row)
|
|
184
|
+
if (parsed === null) continue
|
|
185
|
+
if (parsed.deliveredAt !== null && parsed.deliveredAt < cutoff) {
|
|
186
|
+
reachedCutoff = true
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
summaries.push(parsed.summary)
|
|
190
|
+
}
|
|
191
|
+
if (reachedCutoff) break
|
|
192
|
+
url = nextLink(response.headers.get('link'))
|
|
193
|
+
}
|
|
194
|
+
return summaries
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function fetchDeliveryPayload(
|
|
198
|
+
fetchImpl: typeof fetch,
|
|
199
|
+
token: string,
|
|
200
|
+
target: RepoTarget,
|
|
201
|
+
hookId: number,
|
|
202
|
+
deliveryId: number,
|
|
203
|
+
): Promise<Record<string, unknown> | null> {
|
|
204
|
+
const response = await fetchImpl(
|
|
205
|
+
`${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/hooks/${hookId}/deliveries/${deliveryId}`,
|
|
206
|
+
{ headers: githubJsonHeaders(token) },
|
|
207
|
+
)
|
|
208
|
+
if (!response.ok) return null
|
|
209
|
+
const raw = (await response.json().catch(() => null)) as { request?: { payload?: unknown } } | null
|
|
210
|
+
return coercePayload(raw?.request?.payload)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function coercePayload(value: unknown): Record<string, unknown> | null {
|
|
214
|
+
if (typeof value === 'string') {
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(value) as unknown
|
|
217
|
+
return isRecord(parsed) ? parsed : null
|
|
218
|
+
} catch {
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return isRecord(value) ? value : null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// GitHub records a non-delivery (connection refused / DNS / tunnel down) as
|
|
226
|
+
// status_code 0, and HTTP failures as 4xx/5xx. Treat 2xx and 3xx as success.
|
|
227
|
+
function isSuccess(statusCode: number): boolean {
|
|
228
|
+
return statusCode >= 200 && statusCode < 400
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
type DeliveryRow = {
|
|
232
|
+
id?: unknown
|
|
233
|
+
guid?: unknown
|
|
234
|
+
event?: unknown
|
|
235
|
+
status_code?: unknown
|
|
236
|
+
delivered_at?: unknown
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseDeliveryRow(row: DeliveryRow): { summary: DeliverySummary; deliveredAt: number | null } | null {
|
|
240
|
+
const id = typeof row.id === 'number' ? row.id : null
|
|
241
|
+
const guid = typeof row.guid === 'string' ? row.guid : null
|
|
242
|
+
const event = typeof row.event === 'string' ? row.event : null
|
|
243
|
+
const statusCode = typeof row.status_code === 'number' ? row.status_code : null
|
|
244
|
+
if (id === null || guid === null || event === null || statusCode === null) return null
|
|
245
|
+
const deliveredAt = typeof row.delivered_at === 'string' ? Date.parse(row.delivered_at) || null : null
|
|
246
|
+
return { summary: { id, guid, event, statusCode }, deliveredAt }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function parseRepo(slug: string): RepoTarget | null {
|
|
250
|
+
const [owner, repo, ...rest] = slug.trim().split('/')
|
|
251
|
+
if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
|
|
252
|
+
return { owner, repo }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function nextLink(linkHeader: string | null): string | null {
|
|
256
|
+
if (linkHeader === null) return null
|
|
257
|
+
for (const part of linkHeader.split(',')) {
|
|
258
|
+
const m = /<([^>]+)>;\s*rel="next"/.exec(part)
|
|
259
|
+
if (m !== null) return m[1] ?? null
|
|
260
|
+
}
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
265
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function describe(err: unknown): string {
|
|
269
|
+
return err instanceof Error ? err.message : String(err)
|
|
270
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
KakaoCredentialManager,
|
|
3
5
|
KakaoTalkClient as RealKakaoTalkClient,
|
|
@@ -203,7 +205,9 @@ export function createOutboundCallback(deps: {
|
|
|
203
205
|
try {
|
|
204
206
|
items = await Promise.all(
|
|
205
207
|
attachments.map(async (a) => {
|
|
206
|
-
|
|
208
|
+
// basename (not split('/')) so a native-Windows host's backslash
|
|
209
|
+
// attachment paths still yield just the filename (issue #899).
|
|
210
|
+
const filename = a.filename ?? (basename(a.path) || 'file')
|
|
207
211
|
const data = await readFile(a.path)
|
|
208
212
|
return { data, filename }
|
|
209
213
|
}),
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { AdapterId } from '../schema'
|
|
2
|
+
|
|
3
|
+
export type DiscordMentionUser = { id: string; username?: string; global_name?: string | null }
|
|
4
|
+
|
|
5
|
+
export type MentionHintOptions = { botUserId?: string | null }
|
|
6
|
+
|
|
7
|
+
// Slack encodes user mentions as `<@U…>`/`<@W…>`, optionally with a native
|
|
8
|
+
// `|label` fallback suffix. We capture the id and the whole token so the bare
|
|
9
|
+
// `<@id>` can be reconstructed (dropping any legacy label) and a resolved hint
|
|
10
|
+
// appended after it.
|
|
11
|
+
const SLACK_MENTION_PATTERN = /<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g
|
|
12
|
+
|
|
13
|
+
// Discord uses `<@id>` and the nickname form `<@!id>`; the `!` is optional and
|
|
14
|
+
// irrelevant to the target user, so it is captured but discarded on rewrite.
|
|
15
|
+
const DISCORD_MENTION_PATTERN = /<@!?(\d+)>/g
|
|
16
|
+
|
|
17
|
+
// User ids the agent addressed by @-mention in an outbound message, so the
|
|
18
|
+
// router can grant them sticky credit (their reply answers us without a
|
|
19
|
+
// re-mention). Only Slack (`<@U…>`) and Discord (`<@id>`) encode mentions as
|
|
20
|
+
// raw id tokens that map 1:1 to an inbound `authorId`; Telegram (`@username`),
|
|
21
|
+
// GitHub (`@login`), LINE and KakaoTalk have no token→authorId mapping here, so
|
|
22
|
+
// they return [] by design and the caller falls back to existing rules.
|
|
23
|
+
export function extractMentionedUserIds(adapter: AdapterId, text: string): string[] {
|
|
24
|
+
const pattern =
|
|
25
|
+
adapter === 'slack-bot' ? SLACK_MENTION_PATTERN : adapter === 'discord-bot' ? DISCORD_MENTION_PATTERN : null
|
|
26
|
+
if (pattern === null) return []
|
|
27
|
+
const ids = new Set<string>()
|
|
28
|
+
for (const match of text.matchAll(pattern)) ids.add(match[1]!)
|
|
29
|
+
return Array.from(ids)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function addSlackMentionHints(
|
|
33
|
+
text: string,
|
|
34
|
+
resolveUserName: (id: string) => Promise<string>,
|
|
35
|
+
options: MentionHintOptions = {},
|
|
36
|
+
): Promise<string> {
|
|
37
|
+
const ids = new Set<string>()
|
|
38
|
+
for (const match of text.matchAll(SLACK_MENTION_PATTERN)) ids.add(match[1]!)
|
|
39
|
+
if (ids.size === 0) return text
|
|
40
|
+
|
|
41
|
+
const hints = new Map<string, string>()
|
|
42
|
+
await Promise.all(
|
|
43
|
+
Array.from(ids).map(async (id) => {
|
|
44
|
+
const hint = resolveHint(id, await resolveUserName(id), options.botUserId)
|
|
45
|
+
if (hint !== null) hints.set(id, hint)
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return text.replace(SLACK_MENTION_PATTERN, (_token, id: string) => renderToken(id, hints.get(id)))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function addDiscordMentionHints(
|
|
53
|
+
text: string,
|
|
54
|
+
usersById: Map<string, DiscordMentionUser>,
|
|
55
|
+
options: MentionHintOptions = {},
|
|
56
|
+
): string {
|
|
57
|
+
return text.replace(DISCORD_MENTION_PATTERN, (token, id: string) => {
|
|
58
|
+
const user = usersById.get(id)
|
|
59
|
+
const name = user === undefined ? id : (user.global_name ?? user.username ?? id)
|
|
60
|
+
const hint = resolveHint(id, name, options.botUserId)
|
|
61
|
+
return hint === null ? token : `${token} (${hint})`
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveHint(id: string, resolvedName: string, botUserId: string | null | undefined): string | null {
|
|
66
|
+
if (id === botUserId) return 'you'
|
|
67
|
+
// The resolver echoes the id back when it cannot find a name; a bare id is
|
|
68
|
+
// not a useful hint, so leave the token unannotated in that case.
|
|
69
|
+
if (resolvedName === id || resolvedName === '') return null
|
|
70
|
+
return resolvedName
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderToken(id: string, hint: string | undefined): string {
|
|
74
|
+
return hint === undefined ? `<@${id}>` : `<@${id}> (${hint})`
|
|
75
|
+
}
|
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
} from '@/channels/types'
|
|
33
33
|
import { chunkMarkdown } from '@/markdown'
|
|
34
34
|
|
|
35
|
+
import { addSlackMentionHints } from './mention-hints'
|
|
35
36
|
import { createSlackAuthorResolver, type SlackAuthorResolver } from './slack-bot-author-resolver'
|
|
36
37
|
import { createSlackChannelResolver } from './slack-bot-channel-resolver'
|
|
37
38
|
import {
|
|
@@ -703,11 +704,14 @@ export function createSlackHistoryCallback(deps: {
|
|
|
703
704
|
// users.info entry. The resolver caches/coalesces, so repeated authors
|
|
704
705
|
// cost one lookup each.
|
|
705
706
|
if (authorResolver !== undefined) {
|
|
707
|
+
const resolver = authorResolver
|
|
708
|
+
const botId = botUserIdRef()
|
|
706
709
|
await Promise.all(
|
|
707
710
|
mapped.map(async (message, index) => {
|
|
711
|
+
message.text = await addSlackMentionHints(message.text, resolver.resolve, { botUserId: botId })
|
|
708
712
|
const userId = rawMessages[index]?.user
|
|
709
713
|
if (userId === undefined || userId === '') return
|
|
710
|
-
message.authorName = await
|
|
714
|
+
message.authorName = await resolver.resolve(userId)
|
|
711
715
|
}),
|
|
712
716
|
)
|
|
713
717
|
}
|
|
@@ -1133,9 +1137,10 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1133
1137
|
}
|
|
1134
1138
|
|
|
1135
1139
|
dedupe.mark(event)
|
|
1140
|
+
const hintedText = await addSlackMentionHints(verdict.payload.text, authorResolver.resolve, { botUserId })
|
|
1136
1141
|
const slackAttachments = Array.isArray(event.attachments) ? event.attachments : undefined
|
|
1137
1142
|
const referenceResult = await enrichSlackReferenceContext({
|
|
1138
|
-
text:
|
|
1143
|
+
text: hintedText,
|
|
1139
1144
|
channelId: event.channel,
|
|
1140
1145
|
messageTs: event.ts,
|
|
1141
1146
|
...(slackAttachments !== undefined ? { attachments: slackAttachments } : {}),
|
|
@@ -1143,6 +1148,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1143
1148
|
})
|
|
1144
1149
|
const enriched = {
|
|
1145
1150
|
...verdict.payload,
|
|
1151
|
+
text: hintedText,
|
|
1146
1152
|
authorName: resolvedUserName,
|
|
1147
1153
|
...(referenceResult.referenceContext !== undefined
|
|
1148
1154
|
? { referenceContext: referenceResult.referenceContext }
|