typeclaw 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +88 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +370 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +194 -28
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/channels/types.ts +3 -1
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +400 -67
- package/src/cli/model.ts +14 -4
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +134 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +48 -4
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +174 -48
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +165 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +519 -12
- package/src/init/oauth-login.ts +17 -3
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +29 -2
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/permissions.ts +24 -7
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +44 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +16 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +144 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +112 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +35 -16
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +70 -7
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +311 -26
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { resolveSecret, type Secret } from '@/secrets/resolve'
|
|
2
|
+
|
|
3
|
+
import type { GithubAuthStrategy, GithubSelfUser } from './auth'
|
|
4
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
5
|
+
|
|
6
|
+
export class AppAuthStrategy implements GithubAuthStrategy {
|
|
7
|
+
private readonly appId: number
|
|
8
|
+
private readonly privateKeyPem: string
|
|
9
|
+
private readonly installationId: number | null
|
|
10
|
+
private readonly fetchImpl: typeof fetch
|
|
11
|
+
private cachedToken: { value: string; expiresAt: number } | null = null
|
|
12
|
+
private resolvedInstallationId: number | null = null
|
|
13
|
+
private _selfUser: GithubSelfUser | null = null
|
|
14
|
+
|
|
15
|
+
constructor(options: { appId: number; privateKey: Secret; installationId?: number; fetchImpl?: typeof fetch }) {
|
|
16
|
+
const privateKeyPem = resolveSecret(options.privateKey, undefined, process.env)
|
|
17
|
+
if (privateKeyPem === undefined || privateKeyPem.trim() === '') throw new Error('GitHub App private key is missing')
|
|
18
|
+
this.appId = options.appId
|
|
19
|
+
this.privateKeyPem = privateKeyPem
|
|
20
|
+
this.installationId = options.installationId ?? null
|
|
21
|
+
this.fetchImpl = options.fetchImpl ?? fetch
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async token(): Promise<string> {
|
|
25
|
+
if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 5 * 60 * 1000) {
|
|
26
|
+
return this.cachedToken.value
|
|
27
|
+
}
|
|
28
|
+
const jwt = await this.mintJwt()
|
|
29
|
+
const installId = await this.resolveInstallationId(jwt)
|
|
30
|
+
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}/access_tokens`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: githubJsonHeaders(jwt),
|
|
33
|
+
})
|
|
34
|
+
if (!response.ok) throw new Error(`GitHub App token mint failed: ${response.status}`)
|
|
35
|
+
const raw = (await response.json()) as { token?: unknown; expires_at?: unknown }
|
|
36
|
+
if (typeof raw.token !== 'string') throw new Error('GitHub App token response missing token')
|
|
37
|
+
const expiresAt = typeof raw.expires_at === 'string' ? Date.parse(raw.expires_at) : Date.now() + 60 * 60 * 1000
|
|
38
|
+
this.cachedToken = { value: raw.token, expiresAt }
|
|
39
|
+
return raw.token
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async authHeaders(): Promise<HeadersInit> {
|
|
43
|
+
return githubJsonHeaders(await this.token())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async getSelf(): Promise<GithubSelfUser> {
|
|
47
|
+
if (this._selfUser) return this._selfUser
|
|
48
|
+
const jwt = await this.mintJwt()
|
|
49
|
+
const appResponse = await this.fetchImpl(`${GITHUB_API_BASE}/app`, { headers: githubJsonHeaders(jwt) })
|
|
50
|
+
if (!appResponse.ok) throw new Error(`GitHub App preflight failed: ${appResponse.status}`)
|
|
51
|
+
const app = (await appResponse.json()) as { slug?: unknown }
|
|
52
|
+
if (typeof app.slug !== 'string') throw new Error('GitHub /app response missing slug')
|
|
53
|
+
|
|
54
|
+
const botLogin = `${app.slug}[bot]`
|
|
55
|
+
const userResponse = await this.fetchImpl(`${GITHUB_API_BASE}/users/${encodeURIComponent(botLogin)}`, {
|
|
56
|
+
headers: githubJsonHeaders(jwt),
|
|
57
|
+
})
|
|
58
|
+
if (!userResponse.ok) throw new Error(`GitHub bot user lookup failed: ${userResponse.status}`)
|
|
59
|
+
const user = (await userResponse.json()) as { id?: unknown; login?: unknown }
|
|
60
|
+
if (typeof user.id !== 'number' || typeof user.login !== 'string') {
|
|
61
|
+
throw new Error('GitHub bot user response missing id/login')
|
|
62
|
+
}
|
|
63
|
+
this._selfUser = { id: user.id, login: user.login }
|
|
64
|
+
return this._selfUser
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async dispose(): Promise<void> {
|
|
68
|
+
this.cachedToken = null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async mintJwt(): Promise<string> {
|
|
72
|
+
const now = Math.floor(Date.now() / 1000)
|
|
73
|
+
const iat = now - 60
|
|
74
|
+
const exp = iat + 600
|
|
75
|
+
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
|
76
|
+
const payload = base64url(JSON.stringify({ iat, exp, iss: this.appId }))
|
|
77
|
+
const signingInput = `${header}.${payload}`
|
|
78
|
+
const key = await importRsaPrivateKey(this.privateKeyPem)
|
|
79
|
+
const signature = await crypto.subtle.sign(
|
|
80
|
+
{ name: 'RSASSA-PKCS1-v1_5' },
|
|
81
|
+
key,
|
|
82
|
+
new TextEncoder().encode(signingInput),
|
|
83
|
+
)
|
|
84
|
+
return `${signingInput}.${base64url(Buffer.from(signature))}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async resolveInstallationId(jwt: string): Promise<number> {
|
|
88
|
+
if (this.resolvedInstallationId !== null) return this.resolvedInstallationId
|
|
89
|
+
if (this.installationId !== null) {
|
|
90
|
+
this.resolvedInstallationId = this.installationId
|
|
91
|
+
return this.installationId
|
|
92
|
+
}
|
|
93
|
+
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations`, { headers: githubJsonHeaders(jwt) })
|
|
94
|
+
if (!response.ok) throw new Error(`GitHub App installations fetch failed: ${response.status}`)
|
|
95
|
+
const list = (await response.json()) as Array<{ id?: unknown }>
|
|
96
|
+
if (list.length === 0) throw new Error('GitHub App has no installations')
|
|
97
|
+
if (list.length > 1) {
|
|
98
|
+
const ids = list.map((installation) => installation.id).join(', ')
|
|
99
|
+
throw new Error(`GitHub App has multiple installations (${ids}); set installationId in secrets.json`)
|
|
100
|
+
}
|
|
101
|
+
const id = list[0]?.id
|
|
102
|
+
if (typeof id !== 'number') throw new Error('GitHub App installation missing id')
|
|
103
|
+
this.resolvedInstallationId = id
|
|
104
|
+
return id
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function base64url(input: string | Buffer): string {
|
|
109
|
+
const buf = typeof input === 'string' ? Buffer.from(input) : input
|
|
110
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function importRsaPrivateKey(pem: string): Promise<CryptoKey> {
|
|
114
|
+
const b64 = pem
|
|
115
|
+
.replace(/-----BEGIN [^-]+-----/, '')
|
|
116
|
+
.replace(/-----END [^-]+-----/, '')
|
|
117
|
+
.replace(/\s/g, '')
|
|
118
|
+
const der = Buffer.from(b64, 'base64')
|
|
119
|
+
return await crypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'])
|
|
120
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { resolveSecret, type Secret } from '@/secrets/resolve'
|
|
2
|
+
|
|
3
|
+
import type { GithubAuthStrategy, GithubSelfUser } from './auth'
|
|
4
|
+
|
|
5
|
+
export const GITHUB_API_BASE = 'https://api.github.com'
|
|
6
|
+
|
|
7
|
+
export class PatAuthStrategy implements GithubAuthStrategy {
|
|
8
|
+
private readonly _token: string
|
|
9
|
+
private readonly fetchImpl: typeof fetch
|
|
10
|
+
|
|
11
|
+
constructor(options: { token: Secret; fetchImpl?: typeof fetch }) {
|
|
12
|
+
const token = resolveSecret(options.token, undefined, process.env)
|
|
13
|
+
if (token === undefined || token.trim() === '') throw new Error('GitHub PAT token is missing')
|
|
14
|
+
this._token = token
|
|
15
|
+
this.fetchImpl = options.fetchImpl ?? fetch
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async token(): Promise<string> {
|
|
19
|
+
return this._token
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async authHeaders(): Promise<HeadersInit> {
|
|
23
|
+
return githubJsonHeaders(this._token)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async getSelf(): Promise<GithubSelfUser> {
|
|
27
|
+
const response = await this.fetchImpl(`${GITHUB_API_BASE}/user`, { headers: await this.authHeaders() })
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const body = await response.text().catch(() => '')
|
|
30
|
+
throw new Error(`GitHub PAT authentication failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
|
|
31
|
+
}
|
|
32
|
+
const raw = (await response.json()) as { login?: unknown; id?: unknown }
|
|
33
|
+
if (typeof raw.login !== 'string' || typeof raw.id !== 'number') {
|
|
34
|
+
throw new Error('GitHub /user response did not include login/id')
|
|
35
|
+
}
|
|
36
|
+
return { login: raw.login, id: raw.id }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async dispose(): Promise<void> {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function githubJsonHeaders(token: string): HeadersInit {
|
|
43
|
+
return {
|
|
44
|
+
Authorization: `Bearer ${token}`,
|
|
45
|
+
Accept: 'application/vnd.github+json',
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
48
|
+
'User-Agent': 'typeclaw-github-channel',
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { GithubAppAuthBlock, GithubPatAuthBlock } from '@/secrets/schema'
|
|
2
|
+
|
|
3
|
+
import { AppAuthStrategy } from './auth-app'
|
|
4
|
+
import { PatAuthStrategy } from './auth-pat'
|
|
5
|
+
|
|
6
|
+
export type GithubAuthStrategy = {
|
|
7
|
+
token: () => Promise<string>
|
|
8
|
+
authHeaders: () => Promise<HeadersInit>
|
|
9
|
+
getSelf: () => Promise<GithubSelfUser>
|
|
10
|
+
dispose: () => Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type GithubSelfUser = {
|
|
14
|
+
login: string
|
|
15
|
+
id: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildAuthStrategy(options: {
|
|
19
|
+
auth: GithubPatAuthBlock | GithubAppAuthBlock
|
|
20
|
+
fetchImpl?: typeof fetch
|
|
21
|
+
}): GithubAuthStrategy {
|
|
22
|
+
switch (options.auth.type) {
|
|
23
|
+
case 'pat':
|
|
24
|
+
return new PatAuthStrategy({ token: options.auth.token, fetchImpl: options.fetchImpl })
|
|
25
|
+
case 'app':
|
|
26
|
+
return new AppAuthStrategy({
|
|
27
|
+
appId: options.auth.appId,
|
|
28
|
+
privateKey: options.auth.privateKey,
|
|
29
|
+
installationId: options.auth.installationId,
|
|
30
|
+
fetchImpl: options.fetchImpl,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
|
|
2
|
+
|
|
3
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
|
+
import { parseChat, parseRepo } from './outbound'
|
|
5
|
+
|
|
6
|
+
export function createGithubChannelNameResolver(options: {
|
|
7
|
+
token: () => Promise<string>
|
|
8
|
+
fetchImpl?: typeof fetch
|
|
9
|
+
}): ChannelNameResolver {
|
|
10
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
11
|
+
return async (key): Promise<ResolvedChannelNames> => {
|
|
12
|
+
if (key.adapter !== 'github') return {}
|
|
13
|
+
const repo = parseRepo(key.workspace)
|
|
14
|
+
const chat = parseChat(key.chat)
|
|
15
|
+
if (repo === null || chat === null) return {}
|
|
16
|
+
const names: ResolvedChannelNames = { workspaceName: key.workspace }
|
|
17
|
+
if (chat.kind === 'discussion') return names
|
|
18
|
+
const path = chat.kind === 'issue' ? `issues/${chat.number}` : `pulls/${chat.number}`
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/${path}`, {
|
|
21
|
+
headers: githubJsonHeaders(await options.token()),
|
|
22
|
+
})
|
|
23
|
+
if (!response.ok) return names
|
|
24
|
+
const raw = (await response.json()) as { title?: string }
|
|
25
|
+
return raw.title !== undefined ? { ...names, chatName: raw.title } : names
|
|
26
|
+
} catch {
|
|
27
|
+
return names
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type DeliveryDedup = {
|
|
2
|
+
has: (deliveryId: string) => boolean
|
|
3
|
+
add: (deliveryId: string) => void
|
|
4
|
+
size: () => number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createDeliveryDedup(limit = 1000): DeliveryDedup {
|
|
8
|
+
const seen = new Map<string, true>()
|
|
9
|
+
return {
|
|
10
|
+
has(deliveryId: string): boolean {
|
|
11
|
+
return seen.has(deliveryId)
|
|
12
|
+
},
|
|
13
|
+
add(deliveryId: string): void {
|
|
14
|
+
if (seen.has(deliveryId)) seen.delete(deliveryId)
|
|
15
|
+
seen.set(deliveryId, true)
|
|
16
|
+
while (seen.size > limit) {
|
|
17
|
+
const oldest = seen.keys().next().value
|
|
18
|
+
if (oldest === undefined) break
|
|
19
|
+
seen.delete(oldest)
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
size(): number {
|
|
23
|
+
return seen.size
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function githubEventKey(event: string, action: unknown): string {
|
|
2
|
+
return typeof action === 'string' && action.length > 0 ? `${event}.${action}` : event
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function isGithubEventAllowed(allowlist: readonly string[], event: string, action: unknown): boolean {
|
|
6
|
+
const key = githubEventKey(event, action)
|
|
7
|
+
return allowlist.includes(key) || allowlist.includes(event)
|
|
8
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ChannelHistoryMessage, FetchHistoryArgs, FetchHistoryResult, HistoryCallback } from '@/channels/types'
|
|
2
|
+
|
|
3
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
|
+
import { parseChat, parseRepo } from './outbound'
|
|
5
|
+
|
|
6
|
+
export function createGithubHistoryCallback(options: {
|
|
7
|
+
token: () => Promise<string>
|
|
8
|
+
workspaceForChat: (chat: string) => string | null
|
|
9
|
+
fetchImpl?: typeof fetch
|
|
10
|
+
}): HistoryCallback {
|
|
11
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
12
|
+
return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
|
|
13
|
+
const workspace = options.workspaceForChat(args.chat)
|
|
14
|
+
if (workspace === null)
|
|
15
|
+
return { ok: false, error: 'github history unavailable until this chat receives an inbound' }
|
|
16
|
+
const repo = parseRepo(workspace)
|
|
17
|
+
const chat = parseChat(args.chat)
|
|
18
|
+
if (repo === null || chat === null) return { ok: false, error: 'invalid github history target' }
|
|
19
|
+
if (chat.kind === 'discussion') return { ok: false, error: 'github discussion history not supported yet' }
|
|
20
|
+
const endpoint =
|
|
21
|
+
chat.kind === 'pr' && args.thread !== null
|
|
22
|
+
? `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/pulls/${chat.number}/comments`
|
|
23
|
+
: `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/issues/${chat.number}/comments`
|
|
24
|
+
try {
|
|
25
|
+
const cursor = args.cursor !== undefined && args.cursor !== '' ? `&page=${encodeURIComponent(args.cursor)}` : ''
|
|
26
|
+
const response = await fetchImpl(
|
|
27
|
+
`${endpoint}?per_page=${Math.min(Math.max(args.limit, 1), 100)}&direction=desc${cursor}`,
|
|
28
|
+
{
|
|
29
|
+
headers: githubJsonHeaders(await options.token()),
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
if (!response.ok) return { ok: false, error: `GitHub history ${response.status}` }
|
|
33
|
+
const raw = (await response.json()) as GithubComment[]
|
|
34
|
+
const link = response.headers.get('link') ?? ''
|
|
35
|
+
const nextCursor = /[?&]page=(\d+)[^>]*>; rel="next"/.exec(link)?.[1]
|
|
36
|
+
return nextCursor !== undefined
|
|
37
|
+
? { ok: true, messages: raw.map(mapComment), nextCursor }
|
|
38
|
+
: { ok: true, messages: raw.map(mapComment) }
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type GithubComment = {
|
|
46
|
+
id: number
|
|
47
|
+
body?: string
|
|
48
|
+
created_at?: string
|
|
49
|
+
user?: { id?: number; login?: string; type?: string }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mapComment(comment: GithubComment): ChannelHistoryMessage {
|
|
53
|
+
const login = comment.user?.login ?? 'unknown'
|
|
54
|
+
return {
|
|
55
|
+
externalMessageId: String(comment.id),
|
|
56
|
+
authorId: String(comment.user?.id ?? login),
|
|
57
|
+
authorName: login,
|
|
58
|
+
text: comment.body ?? '',
|
|
59
|
+
ts: comment.created_at !== undefined ? Date.parse(comment.created_at) || 0 : 0,
|
|
60
|
+
isBot: comment.user?.type === 'Bot',
|
|
61
|
+
replyToBotMessageId: null,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import type { InboundMessage } from '@/channels/types'
|
|
4
|
+
|
|
5
|
+
import type { DeliveryDedup } from './dedup'
|
|
6
|
+
import { isGithubEventAllowed } from './event-allowlist'
|
|
7
|
+
|
|
8
|
+
export type GithubInboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
|
|
9
|
+
|
|
10
|
+
export type GithubWebhookHandlerOptions = {
|
|
11
|
+
webhookSecret: string
|
|
12
|
+
dedup: DeliveryDedup
|
|
13
|
+
allowlist: () => readonly string[]
|
|
14
|
+
selfId: () => string | null
|
|
15
|
+
selfLogin: () => string | null
|
|
16
|
+
route: (message: InboundMessage) => void
|
|
17
|
+
logger: GithubInboundLogger
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions): (req: Request) => Promise<Response> {
|
|
21
|
+
return async (req: Request): Promise<Response> => {
|
|
22
|
+
if (req.method !== 'POST') return new Response('method not allowed', { status: 405 })
|
|
23
|
+
const body = await req.text()
|
|
24
|
+
const signature = req.headers.get('x-hub-signature-256') ?? ''
|
|
25
|
+
if (!(await verifySignature(body, options.webhookSecret, signature))) {
|
|
26
|
+
options.logger.warn('[github] webhook rejected: bad signature')
|
|
27
|
+
return new Response('bad signature', { status: 401 })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const delivery = req.headers.get('x-github-delivery') ?? ''
|
|
31
|
+
if (delivery !== '' && options.dedup.has(delivery)) {
|
|
32
|
+
options.logger.info(`[github] duplicate delivery ignored id=${delivery}`)
|
|
33
|
+
return ok()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const event = req.headers.get('x-github-event') ?? ''
|
|
37
|
+
const payload = parseJson(body)
|
|
38
|
+
if (payload === null) return ok()
|
|
39
|
+
const action = readString(payload, 'action')
|
|
40
|
+
if (!isGithubEventAllowed(options.allowlist(), event, action)) return ok()
|
|
41
|
+
|
|
42
|
+
const selfId = options.selfId()
|
|
43
|
+
const author = readAuthor(payload)
|
|
44
|
+
if (selfId !== null && author !== null && String(author.id) === selfId) return ok()
|
|
45
|
+
|
|
46
|
+
const classified = classifyGithubInbound(event, payload, options.selfLogin())
|
|
47
|
+
if (classified === null) return ok()
|
|
48
|
+
|
|
49
|
+
if (delivery !== '') options.dedup.add(delivery)
|
|
50
|
+
options.route(classified)
|
|
51
|
+
return ok()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function verifySignature(body: string, secret: string, sigHeader: string): Promise<boolean> {
|
|
56
|
+
const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
|
|
57
|
+
const a = Buffer.from(expected)
|
|
58
|
+
const b = Buffer.from(sigHeader)
|
|
59
|
+
if (a.length !== b.length) return false
|
|
60
|
+
return timingSafeEqual(a, b)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function classifyGithubInbound(
|
|
64
|
+
event: string,
|
|
65
|
+
payload: Record<string, unknown>,
|
|
66
|
+
selfLogin: string | null,
|
|
67
|
+
): InboundMessage | null {
|
|
68
|
+
const repository = readRepository(payload)
|
|
69
|
+
if (repository === null) return null
|
|
70
|
+
const base = {
|
|
71
|
+
adapter: 'github' as const,
|
|
72
|
+
workspace: `${repository.owner}/${repository.name}`,
|
|
73
|
+
isDm: false,
|
|
74
|
+
mentionsOthers: false,
|
|
75
|
+
replyToOtherMessageId: null,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (event === 'issue_comment') {
|
|
79
|
+
const issue = readRecord(payload.issue)
|
|
80
|
+
const comment = readRecord(payload.comment)
|
|
81
|
+
if (issue === null || comment === null) return null
|
|
82
|
+
const number = readNumber(issue, 'number')
|
|
83
|
+
const id = readNumber(comment, 'id')
|
|
84
|
+
if (number === null || id === null) return null
|
|
85
|
+
const isPullRequest = readRecord(issue.pull_request) !== null
|
|
86
|
+
const user = readUser(comment.user)
|
|
87
|
+
return buildInbound(
|
|
88
|
+
{ ...base, chat: `${isPullRequest ? 'pr' : 'issue'}:${number}`, thread: null },
|
|
89
|
+
comment.body,
|
|
90
|
+
id,
|
|
91
|
+
user,
|
|
92
|
+
selfLogin,
|
|
93
|
+
comment.created_at,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (event === 'pull_request_review_comment') {
|
|
98
|
+
const pr = readRecord(payload.pull_request)
|
|
99
|
+
const comment = readRecord(payload.comment)
|
|
100
|
+
if (pr === null || comment === null) return null
|
|
101
|
+
const number = readNumber(pr, 'number')
|
|
102
|
+
const id = readNumber(comment, 'id')
|
|
103
|
+
if (number === null || id === null) return null
|
|
104
|
+
const root = readNumber(comment, 'in_reply_to_id') ?? id
|
|
105
|
+
return buildInbound(
|
|
106
|
+
{ ...base, chat: `pr:${number}`, thread: String(root) },
|
|
107
|
+
comment.body,
|
|
108
|
+
id,
|
|
109
|
+
readUser(comment.user),
|
|
110
|
+
selfLogin,
|
|
111
|
+
comment.created_at,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (event === 'discussion_comment') {
|
|
116
|
+
const discussion = readRecord(payload.discussion)
|
|
117
|
+
const comment = readRecord(payload.comment)
|
|
118
|
+
if (discussion === null || comment === null) return null
|
|
119
|
+
const number = readNumber(discussion, 'number')
|
|
120
|
+
const id = readNumber(comment, 'id')
|
|
121
|
+
if (number === null || id === null) return null
|
|
122
|
+
return buildInbound(
|
|
123
|
+
{ ...base, chat: `discussion:${number}`, thread: null },
|
|
124
|
+
comment.body,
|
|
125
|
+
id,
|
|
126
|
+
readUser(comment.user),
|
|
127
|
+
selfLogin,
|
|
128
|
+
comment.created_at,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (event === 'issues') {
|
|
133
|
+
const issue = readRecord(payload.issue)
|
|
134
|
+
if (issue === null) return null
|
|
135
|
+
const number = readNumber(issue, 'number')
|
|
136
|
+
const id = readNumber(issue, 'id') ?? number
|
|
137
|
+
if (number === null || id === null) return null
|
|
138
|
+
return buildInbound(
|
|
139
|
+
{ ...base, chat: `issue:${number}`, thread: null },
|
|
140
|
+
issue.body,
|
|
141
|
+
id,
|
|
142
|
+
readUser(issue.user),
|
|
143
|
+
selfLogin,
|
|
144
|
+
issue.created_at,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (event === 'pull_request') {
|
|
149
|
+
const pr = readRecord(payload.pull_request)
|
|
150
|
+
if (pr === null) return null
|
|
151
|
+
const number = readNumber(pr, 'number')
|
|
152
|
+
const id = readNumber(pr, 'id') ?? number
|
|
153
|
+
if (number === null || id === null) return null
|
|
154
|
+
return buildInbound(
|
|
155
|
+
{ ...base, chat: `pr:${number}`, thread: null },
|
|
156
|
+
pr.body,
|
|
157
|
+
id,
|
|
158
|
+
readUser(pr.user),
|
|
159
|
+
selfLogin,
|
|
160
|
+
pr.created_at,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (event === 'pull_request_review') {
|
|
165
|
+
const pr = readRecord(payload.pull_request)
|
|
166
|
+
const review = readRecord(payload.review)
|
|
167
|
+
if (pr === null || review === null) return null
|
|
168
|
+
const number = readNumber(pr, 'number')
|
|
169
|
+
const id = readNumber(review, 'id')
|
|
170
|
+
if (number === null || id === null) return null
|
|
171
|
+
return buildInbound(
|
|
172
|
+
{ ...base, chat: `pr:${number}`, thread: null },
|
|
173
|
+
review.body,
|
|
174
|
+
id,
|
|
175
|
+
readUser(review.user),
|
|
176
|
+
selfLogin,
|
|
177
|
+
review.submitted_at,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (event === 'discussion') {
|
|
182
|
+
const discussion = readRecord(payload.discussion)
|
|
183
|
+
if (discussion === null) return null
|
|
184
|
+
const number = readNumber(discussion, 'number')
|
|
185
|
+
const id = readNumber(discussion, 'id') ?? number
|
|
186
|
+
if (number === null || id === null) return null
|
|
187
|
+
return buildInbound(
|
|
188
|
+
{ ...base, chat: `discussion:${number}`, thread: null },
|
|
189
|
+
discussion.body,
|
|
190
|
+
id,
|
|
191
|
+
readUser(discussion.user),
|
|
192
|
+
selfLogin,
|
|
193
|
+
discussion.created_at,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildInbound(
|
|
201
|
+
key: Pick<
|
|
202
|
+
InboundMessage,
|
|
203
|
+
'adapter' | 'workspace' | 'chat' | 'thread' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'
|
|
204
|
+
>,
|
|
205
|
+
rawText: unknown,
|
|
206
|
+
id: number,
|
|
207
|
+
user: GithubUser | null,
|
|
208
|
+
selfLogin: string | null,
|
|
209
|
+
rawTs: unknown,
|
|
210
|
+
): InboundMessage | null {
|
|
211
|
+
if (user === null) return null
|
|
212
|
+
const text = typeof rawText === 'string' ? rawText : ''
|
|
213
|
+
return {
|
|
214
|
+
...key,
|
|
215
|
+
text,
|
|
216
|
+
externalMessageId: String(id),
|
|
217
|
+
authorId: String(user.id),
|
|
218
|
+
authorName: user.login,
|
|
219
|
+
authorIsBot: user.type === 'Bot',
|
|
220
|
+
isBotMention: selfLogin !== null && text.includes(`@${selfLogin}`),
|
|
221
|
+
replyToBotMessageId: null,
|
|
222
|
+
ts: typeof rawTs === 'string' ? Date.parse(rawTs) || 0 : 0,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function readRepository(payload: Record<string, unknown>): { owner: string; name: string } | null {
|
|
227
|
+
const repository = readRecord(payload.repository)
|
|
228
|
+
const owner = readRecord(repository?.owner)
|
|
229
|
+
const ownerLogin = readString(owner, 'login')
|
|
230
|
+
const name = readString(repository, 'name')
|
|
231
|
+
if (ownerLogin === null || name === null) return null
|
|
232
|
+
return { owner: ownerLogin, name }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function readAuthor(payload: Record<string, unknown>): GithubUser | null {
|
|
236
|
+
const candidates = [payload.comment, payload.issue, payload.pull_request, payload.discussion, payload.review]
|
|
237
|
+
for (const candidate of candidates) {
|
|
238
|
+
const user = readUser(readRecord(candidate)?.user)
|
|
239
|
+
if (user !== null) return user
|
|
240
|
+
}
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
type GithubUser = { login: string; id: number; type?: string }
|
|
245
|
+
|
|
246
|
+
function readUser(value: unknown): GithubUser | null {
|
|
247
|
+
const user = readRecord(value)
|
|
248
|
+
const login = readString(user, 'login')
|
|
249
|
+
const id = readNumber(user, 'id')
|
|
250
|
+
if (login === null || id === null) return null
|
|
251
|
+
const type = readString(user, 'type') ?? undefined
|
|
252
|
+
return { login, id, ...(type !== undefined ? { type } : {}) }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseJson(body: string): Record<string, unknown> | null {
|
|
256
|
+
try {
|
|
257
|
+
const parsed = JSON.parse(body) as unknown
|
|
258
|
+
return readRecord(parsed)
|
|
259
|
+
} catch {
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function readRecord(value: unknown): Record<string, unknown> | null {
|
|
265
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
266
|
+
? (value as Record<string, unknown>)
|
|
267
|
+
: null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function readString(obj: Record<string, unknown> | null, key: string): string | null {
|
|
271
|
+
const value = obj?.[key]
|
|
272
|
+
return typeof value === 'string' ? value : null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function readNumber(obj: Record<string, unknown> | null, key: string): number | null {
|
|
276
|
+
const value = obj?.[key]
|
|
277
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function ok(): Response {
|
|
281
|
+
return new Response('ok', { status: 200 })
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function describe(err: unknown): string {
|
|
285
|
+
return err instanceof Error ? err.message : String(err)
|
|
286
|
+
}
|