@ithousecall/chat 0.0.0-stage → 0.2.1

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -2
  3. package/dist/attachments.d.ts +32 -0
  4. package/dist/attachments.js +159 -0
  5. package/dist/chat/adapter.d.ts +77 -0
  6. package/dist/chat/adapter.js +23 -0
  7. package/dist/chat/console.d.ts +21 -0
  8. package/dist/chat/console.js +105 -0
  9. package/dist/chat/download.d.ts +13 -0
  10. package/dist/chat/download.js +61 -0
  11. package/dist/chat/googlechat.d.ts +54 -0
  12. package/dist/chat/googlechat.js +329 -0
  13. package/dist/chat/http.d.ts +9 -0
  14. package/dist/chat/http.js +21 -0
  15. package/dist/chat/markdown.d.ts +4 -0
  16. package/dist/chat/markdown.js +28 -0
  17. package/dist/chat/slack.d.ts +55 -0
  18. package/dist/chat/slack.js +197 -0
  19. package/dist/chat/teams.d.ts +75 -0
  20. package/dist/chat/teams.js +202 -0
  21. package/dist/config.d.ts +79 -0
  22. package/dist/config.js +155 -0
  23. package/dist/docs/chunk.d.ts +7 -0
  24. package/dist/docs/chunk.js +70 -0
  25. package/dist/docs/confluence.d.ts +12 -0
  26. package/dist/docs/confluence.js +68 -0
  27. package/dist/docs/drive.d.ts +20 -0
  28. package/dist/docs/drive.js +85 -0
  29. package/dist/docs/html.d.ts +2 -0
  30. package/dist/docs/html.js +13 -0
  31. package/dist/docs/index.d.ts +31 -0
  32. package/dist/docs/index.js +146 -0
  33. package/dist/docs/local.d.ts +3 -0
  34. package/dist/docs/local.js +39 -0
  35. package/dist/docs/source.d.ts +14 -0
  36. package/dist/docs/source.js +1 -0
  37. package/dist/docs/sync.d.ts +13 -0
  38. package/dist/docs/sync.js +68 -0
  39. package/dist/docs/tools.d.ts +3 -0
  40. package/dist/docs/tools.js +59 -0
  41. package/dist/extensions/approvals.d.ts +26 -0
  42. package/dist/extensions/approvals.js +144 -0
  43. package/dist/extensions/mcp.d.ts +10 -0
  44. package/dist/extensions/mcp.js +127 -0
  45. package/dist/extensions/tool.d.ts +14 -0
  46. package/dist/extensions/tool.js +8 -0
  47. package/dist/format.d.ts +7 -0
  48. package/dist/format.js +36 -0
  49. package/dist/harness.d.ts +4 -0
  50. package/dist/harness.js +14 -0
  51. package/dist/index.d.ts +7 -0
  52. package/dist/index.js +5 -0
  53. package/dist/lock.d.ts +2 -0
  54. package/dist/lock.js +18 -0
  55. package/dist/model.d.ts +14 -0
  56. package/dist/model.js +87 -0
  57. package/dist/prompt.d.ts +2 -0
  58. package/dist/prompt.js +15 -0
  59. package/dist/start.d.ts +13 -0
  60. package/dist/start.js +148 -0
  61. package/dist/threads.d.ts +19 -0
  62. package/dist/threads.js +73 -0
  63. package/dist/turn.d.ts +12 -0
  64. package/dist/turn.js +206 -0
  65. package/package.json +62 -4
@@ -0,0 +1,329 @@
1
+ import { chat } from "@googleapis/chat";
2
+ import { GoogleAuth, OAuth2Client } from "google-auth-library";
3
+ import { z } from "zod";
4
+ import { escapeHtml } from "../format.js";
5
+ import { approvalArgs, decisionText, messageRef, } from "./adapter.js";
6
+ import { fetchCapped } from "./download.js";
7
+ import { toGoogleChat } from "./markdown.js";
8
+ /** The thread ID of a DM's top level: one conversation per DM, answered without a thread. */
9
+ export const DM_TOP_LEVEL = "dm";
10
+ const User = z.object({ name: z.string(), displayName: z.string().optional(), type: z.string().optional() });
11
+ const Space = z.object({
12
+ name: z.string(),
13
+ type: z.string().optional(),
14
+ spaceType: z.string().optional(),
15
+ singleUserBotDm: z.boolean().optional(),
16
+ });
17
+ /** Uploaded files have an `attachmentDataRef`; Drive files have none and are not downloaded. */
18
+ const ChatAttachment = z.object({
19
+ contentName: z.string().optional(),
20
+ contentType: z.string().optional(),
21
+ attachmentDataRef: z.object({ resourceName: z.string().optional() }).optional(),
22
+ });
23
+ const Message = z.object({
24
+ name: z.string(),
25
+ attachment: z.array(ChatAttachment).optional(),
26
+ text: z.string().optional(),
27
+ argumentText: z.string().optional(),
28
+ createTime: z.string(),
29
+ sender: User,
30
+ thread: z.object({ name: z.string() }).optional(),
31
+ space: Space.optional(),
32
+ annotations: z.array(z.object({ type: z.string(), userMention: z.object({ user: User }).optional() })).optional(),
33
+ });
34
+ const Parameters = z.object({ approvalId: z.string(), decision: z.enum(["approve", "deny"]) });
35
+ /** Chat API apps receive `type` + `message`/`common`; Workspace add-on Chat apps receive `chat` + `commonEventObject`. */
36
+ const Event = z.object({
37
+ type: z.string().optional(),
38
+ space: Space.optional(),
39
+ message: Message.optional(),
40
+ user: User.optional(),
41
+ common: z.object({ parameters: z.record(z.string(), z.string()).optional() }).optional(),
42
+ commonEventObject: z.object({ parameters: z.record(z.string(), z.string()).optional() }).optional(),
43
+ chat: z
44
+ .object({
45
+ user: User.optional(),
46
+ messagePayload: z.object({ space: Space, message: Message }).optional(),
47
+ buttonClickedPayload: z.object({ space: Space, message: Message }).optional(),
48
+ })
49
+ .optional(),
50
+ });
51
+ function chatUser(user) {
52
+ return user.displayName === undefined ? { id: user.name } : { id: user.name, name: user.displayName };
53
+ }
54
+ function isDirectSpace(space) {
55
+ return space.type === "DM" || space.spaceType === "DIRECT_MESSAGE" || space.singleUserBotDm === true;
56
+ }
57
+ function normalizeGoogleChatMessage(message, space) {
58
+ if (message.sender.type === "BOT")
59
+ return undefined;
60
+ const isDirect = isDirectSpace(space);
61
+ return {
62
+ platform: "googlechat",
63
+ spaceId: space.name,
64
+ threadId: isDirect ? DM_TOP_LEVEL : (message.thread?.name ?? message.name),
65
+ messageId: message.name,
66
+ user: chatUser(message.sender),
67
+ text: (message.argumentText ?? message.text ?? "").trim(),
68
+ attachments: (message.attachment ?? []).map((attachment) => ({
69
+ name: attachment.contentName ?? "file",
70
+ mimeType: attachment.contentType ?? "application/octet-stream",
71
+ source: attachment.attachmentDataRef?.resourceName ?? "",
72
+ })),
73
+ isDirect,
74
+ mentionsBot: (message.annotations ?? []).some((annotation) => annotation.type === "USER_MENTION" && annotation.userMention?.user.type === "BOT"),
75
+ cursor: message.createTime,
76
+ };
77
+ }
78
+ function decision(parameters, user) {
79
+ const parsed = Parameters.safeParse(parameters);
80
+ if (!parsed.success || user === undefined)
81
+ return undefined;
82
+ return { approvalId: parsed.data.approvalId, decision: parsed.data.decision, user: chatUser(user) };
83
+ }
84
+ export function normalizeGoogleChatEvent(body) {
85
+ const parsed = Event.safeParse(body);
86
+ if (!parsed.success)
87
+ return { kind: "ignored" };
88
+ const event = parsed.data;
89
+ const addOn = event.chat;
90
+ let message;
91
+ let approval;
92
+ if (addOn?.messagePayload !== undefined) {
93
+ message = normalizeGoogleChatMessage(addOn.messagePayload.message, addOn.messagePayload.space);
94
+ }
95
+ else if (addOn?.buttonClickedPayload !== undefined) {
96
+ approval = decision(event.commonEventObject?.parameters, addOn.user);
97
+ }
98
+ else if (event.type === "MESSAGE" && event.message !== undefined) {
99
+ const space = event.space ?? event.message.space;
100
+ message = space === undefined ? undefined : normalizeGoogleChatMessage(event.message, space);
101
+ }
102
+ else if (event.type === "CARD_CLICKED") {
103
+ approval = decision(event.common?.parameters, event.user);
104
+ }
105
+ if (message !== undefined)
106
+ return { kind: "message", message };
107
+ if (approval !== undefined)
108
+ return { kind: "approval", decision: approval, addOn: addOn !== undefined };
109
+ return { kind: "ignored" };
110
+ }
111
+ const CHAT_ISSUER = "chat@system.gserviceaccount.com";
112
+ const CHAT_CERTS = `https://www.googleapis.com/service_accounts/v1/metadata/x509/${CHAT_ISSUER}`;
113
+ const GOOGLE_CERTS = "https://www.googleapis.com/oauth2/v1/certs";
114
+ const GOOGLE_ISSUERS = ["accounts.google.com", "https://accounts.google.com"];
115
+ const CERTS_TTL_MS = 60 * 60_000;
116
+ function cachedCertificates() {
117
+ const cache = new Map();
118
+ return (url) => {
119
+ const hit = cache.get(url);
120
+ if (hit !== undefined && Date.now() - hit.at < CERTS_TTL_MS)
121
+ return hit.certs;
122
+ const certs = fetch(url).then(async (response) => {
123
+ if (!response.ok)
124
+ throw new Error(`${url} answered ${response.status}`);
125
+ return (await response.json());
126
+ });
127
+ certs.catch(() => cache.delete(url));
128
+ cache.set(url, { at: Date.now(), certs });
129
+ return certs;
130
+ };
131
+ }
132
+ /**
133
+ * Accepts only this project's requests: a Chat API app with the "Project Number" authentication audience gets a JWT
134
+ * from Chat for the project number; a Workspace add-on Chat app gets a Google ID token for the endpoint URL from the
135
+ * project's own add-on service agent.
136
+ */
137
+ export function googleTokenVerifier(projectNumber, endpointUrl, certificates = cachedCertificates()) {
138
+ const client = new OAuth2Client();
139
+ const addOnAgent = `service-${projectNumber}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`;
140
+ return async (authorization) => {
141
+ const token = /^Bearer (.+)$/.exec(authorization ?? "")?.[1];
142
+ if (token === undefined)
143
+ return false;
144
+ try {
145
+ await client.verifySignedJwtWithCertsAsync(token, await certificates(CHAT_CERTS), projectNumber, [CHAT_ISSUER]);
146
+ return true;
147
+ }
148
+ catch { }
149
+ try {
150
+ const ticket = await client.verifySignedJwtWithCertsAsync(token, await certificates(GOOGLE_CERTS), endpointUrl, GOOGLE_ISSUERS);
151
+ const payload = ticket.getPayload();
152
+ return payload?.email_verified === true && payload.email === addOnAgent;
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ };
158
+ }
159
+ /** The synchronous answer to a click: a decided card becomes its outcome, any other click gets a reply. */
160
+ function clickResponse(feedback, addOn) {
161
+ const message = feedback.counted ? { text: feedback.text, cardsV2: [] } : { text: feedback.text };
162
+ if (addOn) {
163
+ const action = feedback.counted ? "updateMessageAction" : "createMessageAction";
164
+ return { hostAppDataAction: { chatDataAction: { [action]: { message } } } };
165
+ }
166
+ return { actionResponse: { type: feedback.counted ? "UPDATE_MESSAGE" : "NEW_MESSAGE" }, ...message };
167
+ }
168
+ /**
169
+ * Listing messages needs chat.app.messages.readonly, which an admin must grant, so it has its own client and posting
170
+ * keeps working without it.
171
+ */
172
+ export function googleChatApi(keyFile) {
173
+ const auth = (scope) => new GoogleAuth({ keyFile, scopes: [`https://www.googleapis.com/auth/${scope}`] });
174
+ const botAuth = auth("chat.bot");
175
+ const bot = chat({ version: "v1", auth: botAuth });
176
+ const reader = chat({ version: "v1", auth: auth("chat.app.messages.readonly") });
177
+ return {
178
+ create: async (parent, requestBody, threaded) => {
179
+ const { data } = await bot.spaces.messages.create({
180
+ parent,
181
+ requestBody,
182
+ ...(threaded ? { messageReplyOption: "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" } : {}),
183
+ });
184
+ if (!data.name)
185
+ throw new Error("Google Chat returned no message name");
186
+ return data.name;
187
+ },
188
+ patch: async (name, requestBody, updateMask) => {
189
+ await bot.spaces.messages.patch({ name, updateMask, requestBody });
190
+ },
191
+ remove: async (name) => {
192
+ await bot.spaces.messages.delete({ name });
193
+ },
194
+ list: async (parent, filter) => (await reader.spaces.messages.list({ parent, filter, orderBy: "createTime asc", pageSize: 100 })).data.messages ??
195
+ [],
196
+ download: async (resourceName, signal) => {
197
+ const segments = resourceName.split("/");
198
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
199
+ throw new Error(`not an attachment resource name: ${resourceName}`);
200
+ }
201
+ const token = await botAuth.getAccessToken();
202
+ if (token === null || token === undefined)
203
+ throw new Error("Google gave no access token");
204
+ const path = segments.map(encodeURIComponent).join("/");
205
+ return fetchCapped(`https://chat.googleapis.com/v1/media/${path}?alt=media`, {
206
+ headers: { authorization: `Bearer ${token}` },
207
+ signal,
208
+ });
209
+ },
210
+ };
211
+ }
212
+ function approvalCard(prompt, endpointUrl) {
213
+ const button = (text, choice) => ({
214
+ text,
215
+ onClick: {
216
+ action: {
217
+ function: endpointUrl,
218
+ parameters: [
219
+ { key: "approvalId", value: prompt.approvalId },
220
+ { key: "decision", value: choice },
221
+ ],
222
+ },
223
+ },
224
+ });
225
+ return {
226
+ cardId: `approval-${prompt.approvalId}`,
227
+ card: {
228
+ header: { title: "Approval needed", subtitle: prompt.tool },
229
+ sections: [
230
+ {
231
+ widgets: [
232
+ { textParagraph: { text: escapeHtml(prompt.summary) } },
233
+ { textParagraph: { text: escapeHtml(approvalArgs(prompt)) } },
234
+ {
235
+ textParagraph: {
236
+ text: `Only ${escapeHtml(prompt.requester.name ?? prompt.requester.id)} can approve or deny.`,
237
+ },
238
+ },
239
+ { buttonList: { buttons: [button("Approve", "approve"), button("Deny", "deny")] } },
240
+ ],
241
+ },
242
+ ],
243
+ },
244
+ };
245
+ }
246
+ /** Google Chat as an HTTP endpoint app: Google posts events to `endpointUrl`; replies go out through the Chat API. */
247
+ export class GoogleChatAdapter {
248
+ platform = "googlechat";
249
+ maxMessageLength = 4000;
250
+ #http;
251
+ #endpointUrl;
252
+ #api;
253
+ #verify;
254
+ #historyUnavailable = false;
255
+ constructor(http, endpointUrl, api, verify) {
256
+ this.#http = http;
257
+ this.#endpointUrl = endpointUrl;
258
+ this.#api = api;
259
+ this.#verify = verify;
260
+ }
261
+ async start(handlers) {
262
+ this.#http.post(new URL(this.#endpointUrl).pathname, async (c) => {
263
+ if (!(await this.#verify(c.req.header("authorization"))))
264
+ return c.json({ error: "unauthorized" }, 401);
265
+ const event = normalizeGoogleChatEvent(await c.req.json().catch(() => undefined));
266
+ switch (event.kind) {
267
+ case "message":
268
+ void handlers.onMessage(event.message);
269
+ return c.json({});
270
+ case "approval":
271
+ return c.json(clickResponse(await handlers.onApproval(event.decision), event.addOn));
272
+ case "ignored":
273
+ return c.json({});
274
+ default:
275
+ throw new Error(`unknown Google Chat event ${JSON.stringify(event)}`);
276
+ }
277
+ });
278
+ }
279
+ async stop() { }
280
+ async post(thread, markdown) {
281
+ const threaded = thread.threadId !== DM_TOP_LEVEL;
282
+ const message = { text: toGoogleChat(markdown), ...(threaded ? { thread: { name: thread.threadId } } : {}) };
283
+ return messageRef(thread, await this.#api.create(thread.spaceId, message, threaded));
284
+ }
285
+ async update(ref, markdown) {
286
+ await this.#api.patch(ref.messageId, { text: toGoogleChat(markdown) }, "text");
287
+ }
288
+ async remove(ref) {
289
+ await this.#api.remove(ref.messageId);
290
+ }
291
+ async postApproval(thread, prompt) {
292
+ const threaded = thread.threadId !== DM_TOP_LEVEL;
293
+ const message = {
294
+ text: `Approval needed: ${prompt.summary}`,
295
+ cardsV2: [approvalCard(prompt, this.#endpointUrl)],
296
+ ...(threaded ? { thread: { name: thread.threadId } } : {}),
297
+ };
298
+ return messageRef(thread, await this.#api.create(thread.spaceId, message, threaded));
299
+ }
300
+ async resolveApprovalMessage(ref, prompt, outcome, by) {
301
+ await this.#api.patch(ref.messageId, { text: decisionText(prompt, outcome, by), cardsV2: [] }, "text,cardsV2");
302
+ }
303
+ async download(attachment, signal) {
304
+ if (attachment.source === "")
305
+ throw new Error("Google Drive files are not downloaded");
306
+ return this.#api.download(attachment.source, signal);
307
+ }
308
+ async fetchThread(thread, after) {
309
+ if (this.#historyUnavailable)
310
+ return [];
311
+ const filters = [
312
+ ...(thread.threadId === DM_TOP_LEVEL ? [] : [`thread.name = ${thread.threadId}`]),
313
+ ...(after === undefined ? [] : [`createTime > "${after}"`]),
314
+ ];
315
+ try {
316
+ const listed = await this.#api.list(thread.spaceId, filters.join(" AND "));
317
+ const space = { name: thread.spaceId, ...(thread.threadId === DM_TOP_LEVEL ? { type: "DM" } : {}) };
318
+ return listed.flatMap((item) => {
319
+ const message = Message.safeParse(item);
320
+ return message.success ? (normalizeGoogleChatMessage(message.data, space) ?? []) : [];
321
+ });
322
+ }
323
+ catch (error) {
324
+ this.#historyUnavailable = true;
325
+ console.error("[googlechat] cannot read thread history; continuing without it", error);
326
+ return [];
327
+ }
328
+ }
329
+ }
@@ -0,0 +1,9 @@
1
+ import { Hono } from "hono";
2
+ /** The one public port that Teams (`/api/messages`) and Google Chat (`/googlechat`) share. */
3
+ export declare class HttpServer {
4
+ #private;
5
+ readonly app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
6
+ constructor();
7
+ start(port: number): Promise<void>;
8
+ stop(): Promise<void>;
9
+ }
@@ -0,0 +1,21 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { Hono } from "hono";
3
+ /** The one public port that Teams (`/api/messages`) and Google Chat (`/googlechat`) share. */
4
+ export class HttpServer {
5
+ app = new Hono();
6
+ #server;
7
+ constructor() {
8
+ this.app.get("/healthz", (c) => c.text("ok"));
9
+ }
10
+ async start(port) {
11
+ await new Promise((resolve) => {
12
+ this.#server = serve({ fetch: this.app.fetch, port }, () => resolve());
13
+ });
14
+ }
15
+ async stop() {
16
+ const server = this.#server;
17
+ if (server === undefined)
18
+ return;
19
+ await new Promise((resolve) => server.close(() => resolve()));
20
+ }
21
+ }
@@ -0,0 +1,4 @@
1
+ /** Model Markdown to Slack mrkdwn. */
2
+ export declare function toSlack(markdown: string): string;
3
+ /** Model Markdown to Google Chat text formatting, which uses the same markers as Slack without HTML escaping. */
4
+ export declare function toGoogleChat(markdown: string): string;
@@ -0,0 +1,28 @@
1
+ import { escapeHtml } from "../format.js";
2
+ const BOLD = "\u0001";
3
+ /** Code spans and fenced blocks pass through untouched; everything else goes through `transform`. */
4
+ function outsideCode(markdown, transform) {
5
+ return markdown
6
+ .split(/(```[\s\S]*?```|`[^`\n]+`)/)
7
+ .map((part, index) => (index % 2 === 1 ? part : transform(part)))
8
+ .join("");
9
+ }
10
+ function convert(text, escape) {
11
+ const escaped = escape ? escapeHtml(text) : text;
12
+ return escaped
13
+ .replaceAll(/^#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, `${BOLD}$1${BOLD}`)
14
+ .replaceAll(/\*\*(.+?)\*\*|__(.+?)__/g, (_match, star, underscore) => `${BOLD}${star ?? underscore}${BOLD}`)
15
+ .replaceAll(/(?<![*\w])\*(?![\s*])(.+?)(?<![\s*])\*(?![*\w])/g, "_$1_")
16
+ .replaceAll(/^([ \t]*)[-*+][ \t]+/gm, "$1• ")
17
+ .replaceAll(/~~(.+?)~~/g, "~$1~")
18
+ .replaceAll(/!?\[([^\]]+)\]\(([^)\s]+)\)/g, "<$2|$1>")
19
+ .replaceAll(BOLD, "*");
20
+ }
21
+ /** Model Markdown to Slack mrkdwn. */
22
+ export function toSlack(markdown) {
23
+ return outsideCode(markdown, (text) => convert(text, true));
24
+ }
25
+ /** Model Markdown to Google Chat text formatting, which uses the same markers as Slack without HTML escaping. */
26
+ export function toGoogleChat(markdown) {
27
+ return outsideCode(markdown, (text) => convert(text, false));
28
+ }
@@ -0,0 +1,55 @@
1
+ import { type AdapterHandlers, type ApprovalDecision, type ApprovalOutcome, type ApprovalPrompt, type Attachment, type ChatAdapter, type ChatUser, type InboundMessage, type MessageRef, type ThreadRef } from "./adapter.ts";
2
+ /** The fields housecall reads from Slack message and app_mention events and from history results. */
3
+ type SlackMessage = {
4
+ ts?: string | undefined;
5
+ thread_ts?: string | undefined;
6
+ user?: string | undefined;
7
+ bot_id?: string | undefined;
8
+ subtype?: string | undefined;
9
+ text?: string | undefined;
10
+ files?: readonly SlackFile[] | undefined;
11
+ };
12
+ /** The fields housecall reads from a Slack file object. */
13
+ type SlackFile = {
14
+ name?: string | null | undefined;
15
+ title?: string | null | undefined;
16
+ mimetype?: string | undefined;
17
+ size?: number | undefined;
18
+ url_private_download?: string | undefined;
19
+ };
20
+ /** The fields housecall reads from a block_actions payload. */
21
+ type SlackAction = {
22
+ user: {
23
+ id: string;
24
+ };
25
+ actions: {
26
+ action_id?: string;
27
+ value?: string;
28
+ }[];
29
+ channel?: {
30
+ id: string;
31
+ } | undefined;
32
+ message?: {
33
+ ts: string;
34
+ thread_ts?: string | undefined;
35
+ } | undefined;
36
+ };
37
+ export declare function normalizeSlackMessage(message: SlackMessage, channel: string, isDirect: boolean, botUserId: string): InboundMessage | undefined;
38
+ export declare function normalizeSlackAction(action: SlackAction): ApprovalDecision | undefined;
39
+ /** Slack over Socket Mode: no public URL. Every message gets its own thread; DMs need no mention. */
40
+ export declare class SlackAdapter implements ChatAdapter {
41
+ #private;
42
+ readonly platform = "slack";
43
+ readonly maxMessageLength = 3900;
44
+ constructor(botToken: string, appToken: string);
45
+ start(handlers: AdapterHandlers): Promise<void>;
46
+ stop(): Promise<void>;
47
+ post(thread: ThreadRef, markdown: string): Promise<MessageRef>;
48
+ update(ref: MessageRef, markdown: string): Promise<void>;
49
+ remove(ref: MessageRef): Promise<void>;
50
+ postApproval(thread: ThreadRef, prompt: ApprovalPrompt): Promise<MessageRef>;
51
+ resolveApprovalMessage(ref: MessageRef, prompt: ApprovalPrompt, outcome: ApprovalOutcome, by?: ChatUser): Promise<void>;
52
+ download(attachment: Attachment, signal: AbortSignal): Promise<Uint8Array>;
53
+ fetchThread(thread: ThreadRef, after: string | undefined): Promise<InboundMessage[]>;
54
+ }
55
+ export {};
@@ -0,0 +1,197 @@
1
+ import { App, LogLevel } from "@slack/bolt";
2
+ import { escapeHtml } from "../format.js";
3
+ import { approvalArgs, decisionText, messageRef, } from "./adapter.js";
4
+ import { fetchCapped } from "./download.js";
5
+ import { toSlack } from "./markdown.js";
6
+ const APPROVE = "housecall_approve";
7
+ const DENY = "housecall_deny";
8
+ const FILES_HOST = "files.slack.com";
9
+ /** Slack Connect files without access and deleted files carry no download URL and are left out. */
10
+ function slackAttachments(files) {
11
+ return files.flatMap((file) => {
12
+ const url = file.url_private_download;
13
+ if (url === undefined || !URL.canParse(url) || new URL(url).host !== FILES_HOST)
14
+ return [];
15
+ return [
16
+ {
17
+ name: file.name ?? file.title ?? "file",
18
+ mimeType: file.mimetype ?? "application/octet-stream",
19
+ ...(file.size === undefined ? {} : { size: file.size }),
20
+ source: url,
21
+ },
22
+ ];
23
+ });
24
+ }
25
+ function slackUser(id) {
26
+ return { id, name: `<@${id}>` };
27
+ }
28
+ export function normalizeSlackMessage(message, channel, isDirect, botUserId) {
29
+ const { ts, user } = message;
30
+ const human = message.subtype === undefined || message.subtype === "file_share" || message.subtype === "thread_broadcast";
31
+ if (!human || message.bot_id !== undefined || ts === undefined || user === undefined || user === botUserId) {
32
+ return undefined;
33
+ }
34
+ const raw = message.text ?? "";
35
+ const mention = `<@${botUserId}>`;
36
+ return {
37
+ platform: "slack",
38
+ spaceId: channel,
39
+ threadId: message.thread_ts ?? ts,
40
+ messageId: ts,
41
+ user: slackUser(user),
42
+ text: raw.replaceAll(mention, "").trim(),
43
+ attachments: slackAttachments(message.files ?? []),
44
+ isDirect,
45
+ mentionsBot: raw.includes(mention),
46
+ cursor: ts,
47
+ };
48
+ }
49
+ export function normalizeSlackAction(action) {
50
+ const clicked = action.actions[0];
51
+ if (clicked?.value === undefined)
52
+ return undefined;
53
+ const decision = clicked.action_id === APPROVE ? "approve" : clicked.action_id === DENY ? "deny" : undefined;
54
+ return decision === undefined ? undefined : { approvalId: clicked.value, decision, user: slackUser(action.user.id) };
55
+ }
56
+ function approvalBlocks(prompt) {
57
+ return [
58
+ { type: "section", text: { type: "mrkdwn", text: `*Approval needed:* ${escapeHtml(prompt.summary)}` } },
59
+ {
60
+ type: "context",
61
+ elements: [
62
+ { type: "mrkdwn", text: `\`${prompt.tool}\` ${escapeHtml(approvalArgs(prompt))}` },
63
+ { type: "mrkdwn", text: `Only <@${prompt.requester.id}> can approve or deny.` },
64
+ ],
65
+ },
66
+ {
67
+ type: "actions",
68
+ elements: [
69
+ {
70
+ type: "button",
71
+ action_id: APPROVE,
72
+ style: "primary",
73
+ text: { type: "plain_text", text: "Approve" },
74
+ value: prompt.approvalId,
75
+ },
76
+ {
77
+ type: "button",
78
+ action_id: DENY,
79
+ style: "danger",
80
+ text: { type: "plain_text", text: "Deny" },
81
+ value: prompt.approvalId,
82
+ },
83
+ ],
84
+ },
85
+ ];
86
+ }
87
+ const isDirectChannel = (channel) => channel.startsWith("D");
88
+ /** Slack over Socket Mode: no public URL. Every message gets its own thread; DMs need no mention. */
89
+ export class SlackAdapter {
90
+ platform = "slack";
91
+ maxMessageLength = 3900;
92
+ #app;
93
+ #botToken;
94
+ #botUserId = "";
95
+ constructor(botToken, appToken) {
96
+ this.#botToken = botToken;
97
+ this.#app = new App({
98
+ token: botToken,
99
+ appToken,
100
+ socketMode: true,
101
+ logLevel: LogLevel.WARN,
102
+ deferInitialization: true,
103
+ });
104
+ }
105
+ async start(handlers) {
106
+ await this.#app.init();
107
+ const auth = await this.#app.client.auth.test();
108
+ this.#botUserId = auth.user_id ?? "";
109
+ const handle = async (message) => {
110
+ if (message !== undefined)
111
+ await handlers.onMessage(message);
112
+ };
113
+ this.#app.event("app_mention", async ({ event }) => {
114
+ await handle(normalizeSlackMessage(event, event.channel, false, this.#botUserId));
115
+ });
116
+ this.#app.message(async ({ message }) => {
117
+ if (message.channel_type !== "im")
118
+ return;
119
+ await handle(normalizeSlackMessage(message, message.channel, true, this.#botUserId));
120
+ });
121
+ this.#app.action({ type: "block_actions" }, async ({ ack, body }) => {
122
+ await ack();
123
+ const action = body;
124
+ const decision = normalizeSlackAction(action);
125
+ if (decision === undefined)
126
+ return;
127
+ const feedback = await handlers.onApproval(decision);
128
+ if (feedback.counted || action.channel === undefined)
129
+ return;
130
+ const threadTs = action.message?.thread_ts ?? action.message?.ts;
131
+ await this.#app.client.chat.postEphemeral({
132
+ channel: action.channel.id,
133
+ user: decision.user.id,
134
+ text: feedback.text,
135
+ ...(threadTs === undefined ? {} : { thread_ts: threadTs }),
136
+ });
137
+ });
138
+ await this.#app.start();
139
+ }
140
+ async stop() {
141
+ await this.#app.stop();
142
+ }
143
+ async post(thread, markdown) {
144
+ const posted = await this.#app.client.chat.postMessage({
145
+ channel: thread.spaceId,
146
+ text: toSlack(markdown),
147
+ thread_ts: thread.threadId,
148
+ });
149
+ if (posted.ts === undefined)
150
+ throw new Error("Slack returned no ts for a posted message");
151
+ return messageRef(thread, posted.ts);
152
+ }
153
+ async update(ref, markdown) {
154
+ await this.#app.client.chat.update({ channel: ref.spaceId, ts: ref.messageId, text: toSlack(markdown) });
155
+ }
156
+ async remove(ref) {
157
+ await this.#app.client.chat.delete({ channel: ref.spaceId, ts: ref.messageId });
158
+ }
159
+ async postApproval(thread, prompt) {
160
+ const posted = await this.#app.client.chat.postMessage({
161
+ channel: thread.spaceId,
162
+ text: `Approval needed: ${prompt.summary}`,
163
+ blocks: approvalBlocks(prompt),
164
+ thread_ts: thread.threadId,
165
+ });
166
+ if (posted.ts === undefined)
167
+ throw new Error("Slack returned no ts for an approval prompt");
168
+ return messageRef(thread, posted.ts);
169
+ }
170
+ async resolveApprovalMessage(ref, prompt, outcome, by) {
171
+ const text = decisionText({ ...prompt, summary: escapeHtml(prompt.summary) }, outcome, by);
172
+ await this.#app.client.chat.update({
173
+ channel: ref.spaceId,
174
+ ts: ref.messageId,
175
+ text,
176
+ blocks: [{ type: "section", text: { type: "mrkdwn", text } }],
177
+ });
178
+ }
179
+ async download(attachment, signal) {
180
+ if (new URL(attachment.source).host !== FILES_HOST)
181
+ throw new Error(`not a Slack file URL: ${attachment.source}`);
182
+ return fetchCapped(attachment.source, { headers: { authorization: `Bearer ${this.#botToken}` }, signal });
183
+ }
184
+ async fetchThread(thread, after) {
185
+ const isDirect = isDirectChannel(thread.spaceId);
186
+ const page = await this.#app.client.conversations.replies({
187
+ channel: thread.spaceId,
188
+ ts: thread.threadId,
189
+ limit: 200,
190
+ ...(after === undefined ? {} : { oldest: after }),
191
+ });
192
+ return (page.messages ?? [])
193
+ .flatMap((message) => normalizeSlackMessage(message, thread.spaceId, isDirect, this.#botUserId) ?? [])
194
+ .filter((message) => after === undefined || message.cursor > after)
195
+ .sort((a, b) => a.cursor.localeCompare(b.cursor));
196
+ }
197
+ }