@autono/pinbox-core 0.16.0 → 0.18.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/dist/connectors/index.d.ts +31 -1
- package/dist/connectors/index.js +2 -102
- package/dist/do.d.ts +19 -1
- package/dist/do.js +39 -6
- package/dist/slack-Dnq9bnXd.js +305 -0
- package/package.json +1 -1
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
import { g as ThreadMessage } from "../schema-BlM4lTLW.js";
|
|
2
2
|
import { a as RemoteStatus, i as RemoteComment, n as ConnectorEvents, r as ConnectorTransport, t as Connector } from "../types-BmfT_m1p.js";
|
|
3
3
|
import { c as PinStore } from "../store-DAB5CEA1.js";
|
|
4
|
+
//#region src/connectors/github-app.d.ts
|
|
5
|
+
/** The slice of fetch this transport uses — injectable without dragging in Bun's extras. */
|
|
6
|
+
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
7
|
+
type GithubAppTransportOptions = {
|
|
8
|
+
/** The App's numeric id (the `iss` claim). */
|
|
9
|
+
appId: string;
|
|
10
|
+
/** PEM, PKCS#1 or PKCS#8 — GitHub downloads PKCS#1. */
|
|
11
|
+
privateKeyPem: string;
|
|
12
|
+
/** The installation on the target repo's owner. */
|
|
13
|
+
installationId: string;
|
|
14
|
+
/** "owner/name". */
|
|
15
|
+
repo: string;
|
|
16
|
+
/** Default https://api.github.com; GHES uses https://<host>/api/v3. */
|
|
17
|
+
apiBase?: string;
|
|
18
|
+
fetchImpl?: FetchLike;
|
|
19
|
+
/** Test seam: epoch ms. */
|
|
20
|
+
now?: () => number;
|
|
21
|
+
};
|
|
22
|
+
/** A transport failure carrying the hint the link route surfaces as E_CONNECTOR. */
|
|
23
|
+
declare class GithubAppError extends Error {
|
|
24
|
+
readonly hint: string | undefined;
|
|
25
|
+
readonly status: number;
|
|
26
|
+
constructor(message: string, status: number, hint?: string);
|
|
27
|
+
}
|
|
28
|
+
declare function createGithubAppTransport(opts: GithubAppTransportOptions): ConnectorTransport;
|
|
29
|
+
/** PKCS#8 PEM in, PKCS#8 PEM out; PKCS#1 PEM (GitHub's download) is wrapped into PKCS#8. */
|
|
30
|
+
declare function toPkcs8Pem(pem: string): string;
|
|
31
|
+
/** PrivateKeyInfo ::= SEQUENCE { version 0, algorithm rsaEncryption, privateKey OCTET STRING }. */
|
|
32
|
+
declare function pkcs1ToPkcs8(pkcs1: Uint8Array): Uint8Array;
|
|
33
|
+
//#endregion
|
|
4
34
|
//#region src/connectors/mirror.d.ts
|
|
5
35
|
declare function createConnectorEvents(store: PinStore, pinId: string): ConnectorEvents;
|
|
6
36
|
/**
|
|
@@ -34,4 +64,4 @@ type SlackConnectorOptions = {
|
|
|
34
64
|
/** Links are `ref: "<channel>/<thread_ts>"`; every thread op derives channel + ts from the ref. */
|
|
35
65
|
declare function createSlackConnector(transport: ConnectorTransport, opts: SlackConnectorOptions): Connector;
|
|
36
66
|
//#endregion
|
|
37
|
-
export { Connector, ConnectorEvents, ConnectorTransport, POLL_OPEN_MS, POLL_RESOLVED_MS, RemoteComment, RemoteStatus, SlackConnectorOptions, SlackTransportOptions, createConnectorEvents, createSlackConnector, createSlackTransport, drainConnectorPolls, outboundCandidates };
|
|
67
|
+
export { Connector, ConnectorEvents, ConnectorTransport, FetchLike, GithubAppError, GithubAppTransportOptions, POLL_OPEN_MS, POLL_RESOLVED_MS, RemoteComment, RemoteStatus, SlackConnectorOptions, SlackTransportOptions, createConnectorEvents, createGithubAppTransport, createSlackConnector, createSlackTransport, drainConnectorPolls, outboundCandidates, pkcs1ToPkcs8, toPkcs8Pem };
|
package/dist/connectors/index.js
CHANGED
|
@@ -1,103 +1,3 @@
|
|
|
1
1
|
import { a as outboundCandidates, i as createConnectorEvents, n as POLL_RESOLVED_MS, r as drainConnectorPolls, t as POLL_OPEN_MS } from "../poll-BrcAuaAz.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
//#region src/connectors/slack.ts
|
|
5
|
-
/**
|
|
6
|
-
* request(op, params) → POST https://slack.com/api/<op> (JSON, bearer botToken).
|
|
7
|
-
* Slack's `{ok:false, error}` becomes a rejection carrying the Slack error string —
|
|
8
|
-
* the route layer surfaces it as 502 E_CONNECTOR.
|
|
9
|
-
*/
|
|
10
|
-
function createSlackTransport(opts) {
|
|
11
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
12
|
-
return { async request(op, params) {
|
|
13
|
-
const res = await fetchImpl(`https://slack.com/api/${op}`, {
|
|
14
|
-
method: "POST",
|
|
15
|
-
headers: {
|
|
16
|
-
authorization: `Bearer ${opts.botToken}`,
|
|
17
|
-
"content-type": "application/json; charset=utf-8"
|
|
18
|
-
},
|
|
19
|
-
body: JSON.stringify(params)
|
|
20
|
-
});
|
|
21
|
-
if (!res.ok) throw new Error(`slack ${op} failed: HTTP ${res.status}`);
|
|
22
|
-
const data = await res.json();
|
|
23
|
-
const envelope = SlackEnvelopeSchema.parse(data);
|
|
24
|
-
if (!envelope.ok) throw new Error(`slack ${op} failed: ${envelope.error ?? "unknown_error"}`);
|
|
25
|
-
return data;
|
|
26
|
-
} };
|
|
27
|
-
}
|
|
28
|
-
/** Links are `ref: "<channel>/<thread_ts>"`; every thread op derives channel + ts from the ref. */
|
|
29
|
-
function createSlackConnector(transport, opts) {
|
|
30
|
-
return {
|
|
31
|
-
name: "slack",
|
|
32
|
-
async createItem(pin, thread) {
|
|
33
|
-
const text = [pinsToMarkdown([pin], "standard"), ...thread.map((m) => `${m.role}: ${m.text}`)].join("\n").trimEnd();
|
|
34
|
-
const posted = PostMessageSchema.parse(await transport.request("chat.postMessage", {
|
|
35
|
-
channel: opts.channel,
|
|
36
|
-
text
|
|
37
|
-
}));
|
|
38
|
-
const channel = posted.channel ?? opts.channel;
|
|
39
|
-
const permalink = PermalinkSchema.parse(await transport.request("chat.getPermalink", {
|
|
40
|
-
channel,
|
|
41
|
-
message_ts: posted.ts
|
|
42
|
-
}));
|
|
43
|
-
return {
|
|
44
|
-
connector: "slack",
|
|
45
|
-
ref: `${channel}/${posted.ts}`,
|
|
46
|
-
url: permalink.permalink
|
|
47
|
-
};
|
|
48
|
-
},
|
|
49
|
-
async postComment(link, message) {
|
|
50
|
-
const { channel, ts } = parseRef(link.ref);
|
|
51
|
-
await transport.request("chat.postMessage", {
|
|
52
|
-
channel,
|
|
53
|
-
thread_ts: ts,
|
|
54
|
-
text: message.text
|
|
55
|
-
});
|
|
56
|
-
},
|
|
57
|
-
async sync(link, events) {
|
|
58
|
-
const { channel, ts } = parseRef(link.ref);
|
|
59
|
-
const replies = RepliesSchema.parse(await transport.request("conversations.replies", {
|
|
60
|
-
channel,
|
|
61
|
-
ts
|
|
62
|
-
}));
|
|
63
|
-
for (const reply of replies.messages) {
|
|
64
|
-
if (reply.ts === ts) continue;
|
|
65
|
-
const atMs = slackTsToMs(reply.ts);
|
|
66
|
-
await events.onRemoteComment(link, {
|
|
67
|
-
origin: `slack:${reply.user}`,
|
|
68
|
-
text: reply.text ?? "",
|
|
69
|
-
at: new Date(atMs).toISOString()
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
async setRemoteStatus() {}
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
const SlackEnvelopeSchema = z.looseObject({
|
|
77
|
-
ok: z.boolean(),
|
|
78
|
-
error: z.string().optional()
|
|
79
|
-
});
|
|
80
|
-
const PostMessageSchema = z.looseObject({
|
|
81
|
-
channel: z.string().optional(),
|
|
82
|
-
ts: z.string()
|
|
83
|
-
});
|
|
84
|
-
const PermalinkSchema = z.looseObject({ permalink: z.string() });
|
|
85
|
-
const RepliesSchema = z.looseObject({ messages: z.array(z.looseObject({
|
|
86
|
-
ts: z.string(),
|
|
87
|
-
user: z.string(),
|
|
88
|
-
text: z.string().optional()
|
|
89
|
-
})) });
|
|
90
|
-
function parseRef(ref) {
|
|
91
|
-
const slash = ref.indexOf("/");
|
|
92
|
-
if (slash <= 0 || slash === ref.length - 1) throw new Error(`slack link ref must be "<channel>/<thread_ts>", got "${ref}"`);
|
|
93
|
-
return {
|
|
94
|
-
channel: ref.slice(0, slash),
|
|
95
|
-
ts: ref.slice(slash + 1)
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
/** Slack ts is "<epoch-seconds>.<suffix>" — epoch milliseconds for the reported `at`. */
|
|
99
|
-
function slackTsToMs(ts) {
|
|
100
|
-
return Number(ts) * 1e3;
|
|
101
|
-
}
|
|
102
|
-
//#endregion
|
|
103
|
-
export { POLL_OPEN_MS, POLL_RESOLVED_MS, createConnectorEvents, createSlackConnector, createSlackTransport, drainConnectorPolls, outboundCandidates };
|
|
2
|
+
import { a as pkcs1ToPkcs8, i as createGithubAppTransport, n as createSlackTransport, o as toPkcs8Pem, r as GithubAppError, t as createSlackConnector } from "../slack-Dnq9bnXd.js";
|
|
3
|
+
export { GithubAppError, POLL_OPEN_MS, POLL_RESOLVED_MS, createConnectorEvents, createGithubAppTransport, createSlackConnector, createSlackTransport, drainConnectorPolls, outboundCandidates, pkcs1ToPkcs8, toPkcs8Pem };
|
package/dist/do.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { a as Link, g as ThreadMessage, m as SessionRef, r as Attachment, s as Pin, t as AppliedEdit } from "./schema-BlM4lTLW.js";
|
|
2
|
+
import { t as Connector } from "./types-BmfT_m1p.js";
|
|
2
3
|
import { r as SessionStore } from "./sessions-BOrd8Yvv.js";
|
|
3
4
|
import { a as LinkStore, c as PinStore, i as DeliveryStore, l as StoredEvent, t as CursorStore } from "./store-DAB5CEA1.js";
|
|
4
5
|
import { Broadcaster } from "./ws.js";
|
|
@@ -34,6 +35,13 @@ type PinboxDoEnv = {
|
|
|
34
35
|
JWT_ISSUER?: string;
|
|
35
36
|
JWT_JWKS_URL?: string;
|
|
36
37
|
JWT_AUDIENCE?: string;
|
|
38
|
+
GITHUB_APP_ID?: string;
|
|
39
|
+
GITHUB_APP_PRIVATE_KEY?: string;
|
|
40
|
+
GITHUB_INSTALLATION_ID?: string;
|
|
41
|
+
GITHUB_REPO?: string;
|
|
42
|
+
GITHUB_API_BASE?: string;
|
|
43
|
+
SLACK_BOT_TOKEN?: string;
|
|
44
|
+
SLACK_CHANNEL?: string;
|
|
37
45
|
MEDIA?: R2Bucket;
|
|
38
46
|
R2_ACCOUNT_ID?: string;
|
|
39
47
|
R2_BUCKET?: string;
|
|
@@ -46,6 +54,12 @@ declare class DoBroadcaster implements Broadcaster {
|
|
|
46
54
|
publish(topic: string, data: string): void;
|
|
47
55
|
subscriberCount(topic: string): number;
|
|
48
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The cloud connector set, from the environment: github when the App quartet is present,
|
|
59
|
+
* slack when its pair is. A partial quartet is ignored rather than half-configured — the
|
|
60
|
+
* link route then answers 502 E_CONNECTOR with the `pinbox doctor` hint, as with no config.
|
|
61
|
+
*/
|
|
62
|
+
declare function buildConnectors(env: PinboxDoEnv): Connector[];
|
|
49
63
|
declare class PinboxHubDO {
|
|
50
64
|
readonly store: DoPinStore;
|
|
51
65
|
readonly broadcaster: DoBroadcaster;
|
|
@@ -56,6 +70,8 @@ declare class PinboxHubDO {
|
|
|
56
70
|
private readonly handler;
|
|
57
71
|
/** Undefined when no adapter is configured — the hub then stores pins and delivers nothing. */
|
|
58
72
|
private readonly router;
|
|
73
|
+
/** Host-injected tracker connectors (github via App token, slack); empty ⇒ link routes 502. */
|
|
74
|
+
private readonly connectors;
|
|
59
75
|
constructor(ctx: DurableObjectState, env: PinboxDoEnv);
|
|
60
76
|
fetch(req: Request): Promise<Response>;
|
|
61
77
|
private intercept;
|
|
@@ -69,6 +85,8 @@ declare class PinboxHubDO {
|
|
|
69
85
|
alarm(): Promise<void>;
|
|
70
86
|
/** Re-arm while work remains. Setting an alarm that already exists is a no-op. */
|
|
71
87
|
private scheduleDrain;
|
|
88
|
+
/** Any delivery row pending, or any linked pin with a poll deadline set. */
|
|
89
|
+
private hasPendingWork;
|
|
72
90
|
private upgrade;
|
|
73
91
|
private protocolError;
|
|
74
92
|
private unauthorized;
|
|
@@ -76,4 +94,4 @@ declare class PinboxHubDO {
|
|
|
76
94
|
private serveMedia;
|
|
77
95
|
}
|
|
78
96
|
//#endregion
|
|
79
|
-
export { DoBroadcaster, PinboxDoEnv, PinboxHubDO };
|
|
97
|
+
export { DoBroadcaster, PinboxDoEnv, PinboxHubDO, buildConnectors };
|
package/dist/do.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { AttachmentSchema, PinInputSchema, PinSchema, SessionRefSchema, ThreadMessageSchema } from "./schema.js";
|
|
2
2
|
import { a as NotFoundError, i as ConflictError, o as newId, t as SessionSchema } from "./sessions-DrCVTMfI.js";
|
|
3
3
|
import { t as MIGRATIONS } from "./store-IR3o5YwY.js";
|
|
4
|
+
import { r as drainConnectorPolls } from "./poll-BrcAuaAz.js";
|
|
4
5
|
import { n as createHubHandler, o as ok, r as err } from "./hub-I67BuX9S.js";
|
|
5
6
|
import { ClientHelloSchema, WS_CLOSE_PROTOCOL, WS_CLOSE_UNAUTHORIZED, WS_TOKEN_SUBPROTOCOL_PREFIX, encodeWsEvent } from "./ws-protocol.js";
|
|
6
7
|
import { n as DeliveryRouter } from "./router-BH74psWn.js";
|
|
7
8
|
import { createWebhookAdapter } from "./delivery/webhook.js";
|
|
9
|
+
import { i as createGithubAppTransport, n as createSlackTransport, t as createSlackConnector } from "./slack-Dnq9bnXd.js";
|
|
10
|
+
import { createGithubConnector } from "./connectors/github.js";
|
|
8
11
|
import { i as verifyJwt, n as verifyNone, r as verifyToken } from "./verify-BDt9d9Np.js";
|
|
9
12
|
//#region src/do-store-registries.ts
|
|
10
13
|
function parseSession(json) {
|
|
@@ -583,6 +586,24 @@ function buildRouter(store, env) {
|
|
|
583
586
|
})]
|
|
584
587
|
});
|
|
585
588
|
}
|
|
589
|
+
/**
|
|
590
|
+
* The cloud connector set, from the environment: github when the App quartet is present,
|
|
591
|
+
* slack when its pair is. A partial quartet is ignored rather than half-configured — the
|
|
592
|
+
* link route then answers 502 E_CONNECTOR with the `pinbox doctor` hint, as with no config.
|
|
593
|
+
*/
|
|
594
|
+
function buildConnectors(env) {
|
|
595
|
+
const out = [];
|
|
596
|
+
const present = (v) => v !== void 0 && v !== "";
|
|
597
|
+
if (present(env.GITHUB_APP_ID) && present(env.GITHUB_APP_PRIVATE_KEY) && present(env.GITHUB_INSTALLATION_ID) && present(env.GITHUB_REPO)) out.push(createGithubConnector(createGithubAppTransport({
|
|
598
|
+
appId: env.GITHUB_APP_ID,
|
|
599
|
+
privateKeyPem: env.GITHUB_APP_PRIVATE_KEY,
|
|
600
|
+
installationId: env.GITHUB_INSTALLATION_ID,
|
|
601
|
+
repo: env.GITHUB_REPO,
|
|
602
|
+
...present(env.GITHUB_API_BASE) ? { apiBase: env.GITHUB_API_BASE } : {}
|
|
603
|
+
})));
|
|
604
|
+
if (present(env.SLACK_BOT_TOKEN) && present(env.SLACK_CHANNEL)) out.push(createSlackConnector(createSlackTransport({ botToken: env.SLACK_BOT_TOKEN }), { channel: env.SLACK_CHANNEL }));
|
|
605
|
+
return out;
|
|
606
|
+
}
|
|
586
607
|
var PinboxHubDO = class {
|
|
587
608
|
store;
|
|
588
609
|
broadcaster;
|
|
@@ -593,6 +614,8 @@ var PinboxHubDO = class {
|
|
|
593
614
|
handler;
|
|
594
615
|
/** Undefined when no adapter is configured — the hub then stores pins and delivers nothing. */
|
|
595
616
|
router;
|
|
617
|
+
/** Host-injected tracker connectors (github via App token, slack); empty ⇒ link routes 502. */
|
|
618
|
+
connectors;
|
|
596
619
|
constructor(ctx, env) {
|
|
597
620
|
this.ctx = ctx;
|
|
598
621
|
this.env = env;
|
|
@@ -600,10 +623,12 @@ var PinboxHubDO = class {
|
|
|
600
623
|
this.topic = `project:${ctx.id.name ?? ctx.id.toString()}`;
|
|
601
624
|
this.broadcaster = new DoBroadcaster(ctx);
|
|
602
625
|
this.strategy = buildStrategy(env);
|
|
626
|
+
this.connectors = buildConnectors(env);
|
|
603
627
|
this.handler = createHubHandler({
|
|
604
628
|
store: this.store,
|
|
605
629
|
token: env.PINBOX_TOKEN ?? "",
|
|
606
|
-
..."verify" in this.strategy ? { verify: this.strategy.verify } : {}
|
|
630
|
+
..."verify" in this.strategy ? { verify: this.strategy.verify } : {},
|
|
631
|
+
...this.connectors.length > 0 ? { connectors: this.connectors } : {}
|
|
607
632
|
});
|
|
608
633
|
this.store.subscribe((event) => this.broadcaster.publish(this.topic, encodeWsEvent(event)));
|
|
609
634
|
this.router = buildRouter(this.store, env);
|
|
@@ -614,6 +639,10 @@ var PinboxHubDO = class {
|
|
|
614
639
|
});
|
|
615
640
|
this.scheduleDrain();
|
|
616
641
|
}
|
|
642
|
+
if (this.connectors.length > 0) {
|
|
643
|
+
this.store.subscribe(() => void this.scheduleDrain());
|
|
644
|
+
this.scheduleDrain();
|
|
645
|
+
}
|
|
617
646
|
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
|
|
618
647
|
}
|
|
619
648
|
async fetch(req) {
|
|
@@ -677,17 +706,21 @@ var PinboxHubDO = class {
|
|
|
677
706
|
* DO has been unloaded and woken again.
|
|
678
707
|
*/
|
|
679
708
|
async alarm() {
|
|
680
|
-
if (this.router
|
|
681
|
-
await this.
|
|
709
|
+
if (this.router !== void 0) await this.router.drainDue();
|
|
710
|
+
if (this.connectors.length > 0) await drainConnectorPolls(this.store, this.connectors);
|
|
682
711
|
await this.scheduleDrain();
|
|
683
712
|
}
|
|
684
713
|
/** Re-arm while work remains. Setting an alarm that already exists is a no-op. */
|
|
685
714
|
async scheduleDrain() {
|
|
686
|
-
if (this.
|
|
687
|
-
if (this.store.deliveries.due(FAR_FUTURE).length === 0) return;
|
|
715
|
+
if (!this.hasPendingWork()) return;
|
|
688
716
|
if (await this.ctx.storage.getAlarm() !== null) return;
|
|
689
717
|
await this.ctx.storage.setAlarm(Date.now() + DRAIN_INTERVAL_MS);
|
|
690
718
|
}
|
|
719
|
+
/** Any delivery row pending, or any linked pin with a poll deadline set. */
|
|
720
|
+
hasPendingWork() {
|
|
721
|
+
if (this.router !== void 0 && this.store.deliveries.due(FAR_FUTURE).length > 0) return true;
|
|
722
|
+
return this.connectors.length > 0 && this.store.pinsDueBefore(FAR_FUTURE).length > 0;
|
|
723
|
+
}
|
|
691
724
|
async upgrade(req, url, verify) {
|
|
692
725
|
let token = url.searchParams.get("token");
|
|
693
726
|
let acceptedProtocol;
|
|
@@ -763,4 +796,4 @@ var PinboxHubDO = class {
|
|
|
763
796
|
}
|
|
764
797
|
};
|
|
765
798
|
//#endregion
|
|
766
|
-
export { DoBroadcaster, PinboxHubDO };
|
|
799
|
+
export { DoBroadcaster, PinboxHubDO, buildConnectors };
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { pinsToMarkdown } from "./markdown.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { SignJWT, importPKCS8 } from "jose";
|
|
4
|
+
//#region src/connectors/github-app.ts
|
|
5
|
+
const API_VERSION = "2022-11-28";
|
|
6
|
+
const USER_AGENT = "pinbox";
|
|
7
|
+
/** Mint a fresh installation token this close to expiry. */
|
|
8
|
+
const REFRESH_MARGIN_MS = 6e4;
|
|
9
|
+
/** App JWTs may live 10 minutes; nine leaves room for clock skew on GitHub's side. */
|
|
10
|
+
const APP_JWT_TTL_S = 540;
|
|
11
|
+
const COMMENTS_PER_PAGE = 100;
|
|
12
|
+
const MAX_COMMENT_PAGES = 10;
|
|
13
|
+
/** Ops that address an existing issue by number. */
|
|
14
|
+
const NUMBERED_OPS = /* @__PURE__ */ new Set([
|
|
15
|
+
"issue.comment",
|
|
16
|
+
"issue.view",
|
|
17
|
+
"issue.close",
|
|
18
|
+
"issue.reopen"
|
|
19
|
+
]);
|
|
20
|
+
/** A transport failure carrying the hint the link route surfaces as E_CONNECTOR. */
|
|
21
|
+
var GithubAppError = class extends Error {
|
|
22
|
+
hint;
|
|
23
|
+
status;
|
|
24
|
+
constructor(message, status, hint) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "GithubAppError";
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.hint = hint;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
function createGithubAppTransport(opts) {
|
|
32
|
+
const fetchImpl = opts.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
33
|
+
const now = opts.now ?? (() => Date.now());
|
|
34
|
+
const api = (opts.apiBase ?? "https://api.github.com").replace(/\/+$/, "");
|
|
35
|
+
const repo = opts.repo.replace(/^\/+|\/+$/g, "");
|
|
36
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new GithubAppError(`GITHUB_REPO must be "owner/name", got "${opts.repo}"`, 0);
|
|
37
|
+
let keyPromise = null;
|
|
38
|
+
let cached = null;
|
|
39
|
+
function key() {
|
|
40
|
+
keyPromise ??= importPKCS8(toPkcs8Pem(opts.privateKeyPem), "RS256");
|
|
41
|
+
return keyPromise;
|
|
42
|
+
}
|
|
43
|
+
async function appJwt() {
|
|
44
|
+
const iat = Math.floor(now() / 1e3) - 60;
|
|
45
|
+
return new SignJWT({}).setProtectedHeader({
|
|
46
|
+
alg: "RS256",
|
|
47
|
+
typ: "JWT"
|
|
48
|
+
}).setIssuer(opts.appId).setIssuedAt(iat).setExpirationTime(iat + 60 + APP_JWT_TTL_S).sign(await key());
|
|
49
|
+
}
|
|
50
|
+
async function installationToken() {
|
|
51
|
+
if (cached !== null && cached.expiresAt - now() > REFRESH_MARGIN_MS) return cached.token;
|
|
52
|
+
const res = await fetchImpl(`${api}/app/installations/${opts.installationId}/access_tokens`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: headers(await appJwt())
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) throw new GithubAppError(`GitHub App token request failed: HTTP ${res.status}`, res.status, res.status === 401 ? "check GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY belong to the same App" : res.status === 404 ? "check GITHUB_INSTALLATION_ID — the App may not be installed on this repo's owner" : void 0);
|
|
57
|
+
const body = await res.json();
|
|
58
|
+
if (typeof body.token !== "string" || typeof body.expires_at !== "string") throw new GithubAppError("GitHub App token response was not { token, expires_at }", 502);
|
|
59
|
+
cached = {
|
|
60
|
+
token: body.token,
|
|
61
|
+
expiresAt: Date.parse(body.expires_at)
|
|
62
|
+
};
|
|
63
|
+
return cached.token;
|
|
64
|
+
}
|
|
65
|
+
async function call(method, path, body) {
|
|
66
|
+
const res = await fetchImpl(`${api}${path}`, {
|
|
67
|
+
method,
|
|
68
|
+
headers: headers(await installationToken(), body !== void 0),
|
|
69
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const detail = await res.text().catch(() => "");
|
|
73
|
+
if (res.status === 401) cached = null;
|
|
74
|
+
throw new GithubAppError(`GitHub ${method} ${path} failed: HTTP ${res.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`, res.status, res.status === 403 || res.status === 404 ? `check the App is installed on ${repo} with Issues: read & write` : void 0);
|
|
75
|
+
}
|
|
76
|
+
return await res.json();
|
|
77
|
+
}
|
|
78
|
+
async function allComments(number) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (let page = 1; page <= MAX_COMMENT_PAGES; page += 1) {
|
|
81
|
+
const batch = await call("GET", `/repos/${repo}/issues/${number}/comments?per_page=${COMMENTS_PER_PAGE}&page=${page}`);
|
|
82
|
+
out.push(...batch);
|
|
83
|
+
if (batch.length < COMMENTS_PER_PAGE) break;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
return { async request(op, params) {
|
|
88
|
+
const number = Number(params["number"]);
|
|
89
|
+
if (NUMBERED_OPS.has(op) && (!Number.isInteger(number) || number <= 0)) throw new GithubAppError(`github ${op} needs a positive issue number`, 0);
|
|
90
|
+
switch (op) {
|
|
91
|
+
case "issue.create": {
|
|
92
|
+
const issue = await call("POST", `/repos/${repo}/issues`, {
|
|
93
|
+
title: params["title"],
|
|
94
|
+
body: params["body"]
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
number: issue.number,
|
|
98
|
+
url: issue.html_url
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case "issue.comment":
|
|
102
|
+
await call("POST", `/repos/${repo}/issues/${number}/comments`, { body: params["body"] });
|
|
103
|
+
return;
|
|
104
|
+
case "issue.view": {
|
|
105
|
+
const issue = await call("GET", `/repos/${repo}/issues/${number}`);
|
|
106
|
+
const comments = await allComments(number);
|
|
107
|
+
return {
|
|
108
|
+
state: issue.state === "closed" ? "closed" : "open",
|
|
109
|
+
comments: comments.map((c) => ({
|
|
110
|
+
author: c.user?.login ?? "ghost",
|
|
111
|
+
body: c.body ?? "",
|
|
112
|
+
createdAt: c.created_at
|
|
113
|
+
}))
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
case "issue.close":
|
|
117
|
+
await call("PATCH", `/repos/${repo}/issues/${number}`, { state: "closed" });
|
|
118
|
+
return;
|
|
119
|
+
case "issue.reopen":
|
|
120
|
+
await call("PATCH", `/repos/${repo}/issues/${number}`, { state: "open" });
|
|
121
|
+
return;
|
|
122
|
+
default: throw new GithubAppError(`unknown github op: ${op}`, 0);
|
|
123
|
+
}
|
|
124
|
+
} };
|
|
125
|
+
}
|
|
126
|
+
function headers(bearer, json = false) {
|
|
127
|
+
return {
|
|
128
|
+
authorization: `Bearer ${bearer}`,
|
|
129
|
+
accept: "application/vnd.github+json",
|
|
130
|
+
"x-github-api-version": API_VERSION,
|
|
131
|
+
"user-agent": USER_AGENT,
|
|
132
|
+
...json ? { "content-type": "application/json" } : {}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** PKCS#8 PEM in, PKCS#8 PEM out; PKCS#1 PEM (GitHub's download) is wrapped into PKCS#8. */
|
|
136
|
+
function toPkcs8Pem(pem) {
|
|
137
|
+
const trimmed = pem.trim();
|
|
138
|
+
if (trimmed.includes("BEGIN PRIVATE KEY")) return trimmed;
|
|
139
|
+
if (!trimmed.includes("BEGIN RSA PRIVATE KEY")) throw new GithubAppError("GITHUB_APP_PRIVATE_KEY is not a PEM private key", 0, "paste the whole file GitHub downloaded, \"-----BEGIN RSA PRIVATE KEY-----\" through the END line");
|
|
140
|
+
return toPem("PRIVATE KEY", pkcs1ToPkcs8(pemBody(trimmed)));
|
|
141
|
+
}
|
|
142
|
+
function pemBody(pem) {
|
|
143
|
+
const b64 = pem.split("\n").filter((line) => !line.startsWith("-----")).join("").replace(/\s+/g, "");
|
|
144
|
+
const bin = atob(b64);
|
|
145
|
+
const out = new Uint8Array(bin.length);
|
|
146
|
+
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function toPem(label, der) {
|
|
150
|
+
let bin = "";
|
|
151
|
+
for (const b of der) bin += String.fromCharCode(b);
|
|
152
|
+
return `-----BEGIN ${label}-----\n${btoa(bin).replace(/(.{64})/g, "$1\n").trimEnd()}\n-----END ${label}-----`;
|
|
153
|
+
}
|
|
154
|
+
/** rsaEncryption AlgorithmIdentifier: SEQUENCE { OID 1.2.840.113549.1.1.1, NULL }. */
|
|
155
|
+
const RSA_ALGORITHM = new Uint8Array([
|
|
156
|
+
48,
|
|
157
|
+
13,
|
|
158
|
+
6,
|
|
159
|
+
9,
|
|
160
|
+
42,
|
|
161
|
+
134,
|
|
162
|
+
72,
|
|
163
|
+
134,
|
|
164
|
+
247,
|
|
165
|
+
13,
|
|
166
|
+
1,
|
|
167
|
+
1,
|
|
168
|
+
1,
|
|
169
|
+
5,
|
|
170
|
+
0
|
|
171
|
+
]);
|
|
172
|
+
const VERSION_ZERO = new Uint8Array([
|
|
173
|
+
2,
|
|
174
|
+
1,
|
|
175
|
+
0
|
|
176
|
+
]);
|
|
177
|
+
/** PrivateKeyInfo ::= SEQUENCE { version 0, algorithm rsaEncryption, privateKey OCTET STRING }. */
|
|
178
|
+
function pkcs1ToPkcs8(pkcs1) {
|
|
179
|
+
return derTlv(48, concat(VERSION_ZERO, RSA_ALGORITHM, derTlv(4, pkcs1)));
|
|
180
|
+
}
|
|
181
|
+
function derTlv(tag, body) {
|
|
182
|
+
const len = body.length;
|
|
183
|
+
let header;
|
|
184
|
+
if (len < 128) header = [tag, len];
|
|
185
|
+
else {
|
|
186
|
+
const bytes = [];
|
|
187
|
+
for (let v = len; v > 0; v = Math.floor(v / 256)) bytes.unshift(v & 255);
|
|
188
|
+
header = [
|
|
189
|
+
tag,
|
|
190
|
+
128 | bytes.length,
|
|
191
|
+
...bytes
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
return concat(new Uint8Array(header), body);
|
|
195
|
+
}
|
|
196
|
+
function concat(...parts) {
|
|
197
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
198
|
+
let at = 0;
|
|
199
|
+
for (const p of parts) {
|
|
200
|
+
out.set(p, at);
|
|
201
|
+
at += p.length;
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/connectors/slack.ts
|
|
207
|
+
/**
|
|
208
|
+
* request(op, params) → POST https://slack.com/api/<op> (JSON, bearer botToken).
|
|
209
|
+
* Slack's `{ok:false, error}` becomes a rejection carrying the Slack error string —
|
|
210
|
+
* the route layer surfaces it as 502 E_CONNECTOR.
|
|
211
|
+
*/
|
|
212
|
+
function createSlackTransport(opts) {
|
|
213
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
214
|
+
return { async request(op, params) {
|
|
215
|
+
const res = await fetchImpl(`https://slack.com/api/${op}`, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: {
|
|
218
|
+
authorization: `Bearer ${opts.botToken}`,
|
|
219
|
+
"content-type": "application/json; charset=utf-8"
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify(params)
|
|
222
|
+
});
|
|
223
|
+
if (!res.ok) throw new Error(`slack ${op} failed: HTTP ${res.status}`);
|
|
224
|
+
const data = await res.json();
|
|
225
|
+
const envelope = SlackEnvelopeSchema.parse(data);
|
|
226
|
+
if (!envelope.ok) throw new Error(`slack ${op} failed: ${envelope.error ?? "unknown_error"}`);
|
|
227
|
+
return data;
|
|
228
|
+
} };
|
|
229
|
+
}
|
|
230
|
+
/** Links are `ref: "<channel>/<thread_ts>"`; every thread op derives channel + ts from the ref. */
|
|
231
|
+
function createSlackConnector(transport, opts) {
|
|
232
|
+
return {
|
|
233
|
+
name: "slack",
|
|
234
|
+
async createItem(pin, thread) {
|
|
235
|
+
const text = [pinsToMarkdown([pin], "standard"), ...thread.map((m) => `${m.role}: ${m.text}`)].join("\n").trimEnd();
|
|
236
|
+
const posted = PostMessageSchema.parse(await transport.request("chat.postMessage", {
|
|
237
|
+
channel: opts.channel,
|
|
238
|
+
text
|
|
239
|
+
}));
|
|
240
|
+
const channel = posted.channel ?? opts.channel;
|
|
241
|
+
const permalink = PermalinkSchema.parse(await transport.request("chat.getPermalink", {
|
|
242
|
+
channel,
|
|
243
|
+
message_ts: posted.ts
|
|
244
|
+
}));
|
|
245
|
+
return {
|
|
246
|
+
connector: "slack",
|
|
247
|
+
ref: `${channel}/${posted.ts}`,
|
|
248
|
+
url: permalink.permalink
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
async postComment(link, message) {
|
|
252
|
+
const { channel, ts } = parseRef(link.ref);
|
|
253
|
+
await transport.request("chat.postMessage", {
|
|
254
|
+
channel,
|
|
255
|
+
thread_ts: ts,
|
|
256
|
+
text: message.text
|
|
257
|
+
});
|
|
258
|
+
},
|
|
259
|
+
async sync(link, events) {
|
|
260
|
+
const { channel, ts } = parseRef(link.ref);
|
|
261
|
+
const replies = RepliesSchema.parse(await transport.request("conversations.replies", {
|
|
262
|
+
channel,
|
|
263
|
+
ts
|
|
264
|
+
}));
|
|
265
|
+
for (const reply of replies.messages) {
|
|
266
|
+
if (reply.ts === ts) continue;
|
|
267
|
+
const atMs = slackTsToMs(reply.ts);
|
|
268
|
+
await events.onRemoteComment(link, {
|
|
269
|
+
origin: `slack:${reply.user}`,
|
|
270
|
+
text: reply.text ?? "",
|
|
271
|
+
at: new Date(atMs).toISOString()
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
async setRemoteStatus() {}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const SlackEnvelopeSchema = z.looseObject({
|
|
279
|
+
ok: z.boolean(),
|
|
280
|
+
error: z.string().optional()
|
|
281
|
+
});
|
|
282
|
+
const PostMessageSchema = z.looseObject({
|
|
283
|
+
channel: z.string().optional(),
|
|
284
|
+
ts: z.string()
|
|
285
|
+
});
|
|
286
|
+
const PermalinkSchema = z.looseObject({ permalink: z.string() });
|
|
287
|
+
const RepliesSchema = z.looseObject({ messages: z.array(z.looseObject({
|
|
288
|
+
ts: z.string(),
|
|
289
|
+
user: z.string(),
|
|
290
|
+
text: z.string().optional()
|
|
291
|
+
})) });
|
|
292
|
+
function parseRef(ref) {
|
|
293
|
+
const slash = ref.indexOf("/");
|
|
294
|
+
if (slash <= 0 || slash === ref.length - 1) throw new Error(`slack link ref must be "<channel>/<thread_ts>", got "${ref}"`);
|
|
295
|
+
return {
|
|
296
|
+
channel: ref.slice(0, slash),
|
|
297
|
+
ts: ref.slice(slash + 1)
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** Slack ts is "<epoch-seconds>.<suffix>" — epoch milliseconds for the reported `at`. */
|
|
301
|
+
function slackTsToMs(ts) {
|
|
302
|
+
return Number(ts) * 1e3;
|
|
303
|
+
}
|
|
304
|
+
//#endregion
|
|
305
|
+
export { pkcs1ToPkcs8 as a, createGithubAppTransport as i, createSlackTransport as n, toPkcs8Pem as o, GithubAppError as r, createSlackConnector as t };
|