diff --git a/apps/backend/src/http.ts b/apps/backend/src/http.ts index 9e146201b..420a8d36e 100644 --- a/apps/backend/src/http.ts +++ b/apps/backend/src/http.ts @@ -1,7 +1,7 @@ import { Layer } from "effect" import { HttpApiBuilder } from "effect/http-api" import { HazelApi } from "./api" -import { HttpApiV1IntegrationsLive, HttpMessagesApiLive } from "./routes/api-v1" +import { HttpApiV1BotsLive, HttpApiV1IntegrationsLive, HttpMessagesApiLive } from "./routes/api-v1" import { HttpKlipyLive } from "./routes/klipy.http" import { HttpBotCommandsLive } from "./routes/bot-commands.http" import { HttpIncomingWebhookLive } from "./routes/incoming-webhooks.http" @@ -18,6 +18,7 @@ export const HttpApiRoutes = HttpApiBuilder.layer(HazelApi).pipe( Layer.provide(HttpRootLive), Layer.provide(HttpMessagesApiLive), Layer.provide(HttpApiV1IntegrationsLive), + Layer.provide(HttpApiV1BotsLive), Layer.provide(HttpBotCommandsLive), Layer.provide(HttpIntegrationLive), Layer.provide(HttpT3CodeLive), diff --git a/apps/backend/src/routes/api-v1/bot-auth.ts b/apps/backend/src/routes/api-v1/bot-auth.ts new file mode 100644 index 000000000..d421f79ae --- /dev/null +++ b/apps/backend/src/routes/api-v1/bot-auth.ts @@ -0,0 +1,64 @@ +/** + * The public bot API's authentication: `Authorization: Bearer hzl_bot_…`, matched by its SHA-256 + * against the directory's `bots`. A bot acts as its machine user. + */ +import { HttpServerRequest } from "effect/http" +import { BotRepo } from "@hazel/backend-core" +import { CurrentUser, UnauthorizedError } from "@hazel/domain" +import { ApiScope } from "@hazel/domain/scopes" +import type { UserId } from "@hazel/schema" +import { Effect, Option, Schema } from "effect" + +/** Hash a token using SHA-256 (Web Crypto API). */ +export async function hashToken(token: string): Promise { + const data = new TextEncoder().encode(token) + const hashBuffer = await crypto.subtle.digest("SHA-256", data) + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +/** Authenticate the bot from its Bearer token. */ +export const authenticateBotFromToken = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return yield* new UnauthorizedError({ + message: "Missing or invalid API token", + detail: "Authorization header must be 'Bearer '", + }) + } + const tokenHash = yield* Effect.promise(() => hashToken(authHeader.slice(7))) + const bot = yield* (yield* BotRepo).findByTokenHash(tokenHash) + if (Option.isNone(bot)) { + return yield* new UnauthorizedError({ + message: "Invalid API token", + detail: "No bot found with this token", + }) + } + return bot.value +}) + +const isApiScope = Schema.is(ApiScope) + +/** + * Bots act as their user, held to the scopes they declared — as on the RPC path + * (`rpc/middleware/auth.ts`), so a bot can do the same things over either. A bot row with no + * scopes recorded keeps a member's, as this API always gave it. + */ +export const botPrincipal = (bot: { + readonly userId: UserId + readonly name: string + readonly scopes: ReadonlyArray | null +}) => ({ + user: new CurrentUser.Schema({ + id: bot.userId, + role: "member", + email: `bot-${bot.name}@hazel.bot`, + isOnboarded: true, + timezone: null, + organizationId: null, + settings: null, + }), + botScopes: bot.scopes === null ? null : bot.scopes.filter(isApiScope), +}) diff --git a/apps/backend/src/routes/api-v1/bots.http.ts b/apps/backend/src/routes/api-v1/bots.http.ts new file mode 100644 index 000000000..2cfa38c56 --- /dev/null +++ b/apps/backend/src/routes/api-v1/bots.http.ts @@ -0,0 +1,156 @@ +/** + * The public bot API for bots that live outside Hazel (`@hazel/domain/http` `api-v1-bots`): + * redeeming an install grant in the `OrgObject` its code names, opening a thread through + * `channel.createThread` in the channel's org, and the org's user lookup. Each runs as the bot + * authenticated by its token (`./bot-auth.ts`). + */ +import { HttpApiBuilder } from "effect/http-api" +import { + InternalServerError, + InvalidChannelRequestError, + MessageNotFoundError, + NestedThreadError, + UnauthorizedError, +} from "@hazel/domain" +import { + BotInstallGrantInvalidError, + BotInstallRedeemedResponse, + ChannelNotFoundError, + ApiV1UsersResponse, + ThreadResponse, +} from "@hazel/domain/http" +import { type ChannelResponse, CreateThreadRequest } from "@hazel/domain/rpc" +import { UserId } from "@hazel/schema" +import { Effect, Option, Schema } from "effect" +import { HazelApi } from "../../api" +import { Directory } from "../../services/directory" +import { checkMessageRateLimit } from "../../services/rate-limit-helpers" +import { callObject } from "../../worker/objects/calls" +import { forwardRpc } from "../../worker/objects/forward" +import { channelTarget, orgTarget } from "../../worker/objects/kinds" +import { MessageThreadCall } from "../../worker/objects/channel/api-messages" +import { + hashInstallCode, + ORGANIZATION_USERS_MAX_IDS, + organizationOfInstallCode, + OrganizationUsersCall, + RedeemBotInstallGrantCall, +} from "../../worker/objects/org/bot-install-grants" +import { providePrincipal } from "../../worker/objects/principal" +import { authenticateBotFromToken, botPrincipal } from "./bot-auth" + +const internal = (message: string) => (cause: unknown) => + Effect.logError(message, { cause }).pipe( + Effect.andThen(Effect.fail(new InternalServerError({ message, cause }))), + ) + +/** Each id on its own: a malformed one is left out like an unknown one, not the whole lookup. */ +const isUserId = Schema.is(UserId) + +export const HttpApiV1BotsLive = HttpApiBuilder.group(HazelApi, "api-v1-bots", (handlers) => + Effect.succeed( + handlers + .handle("redeemBotInstall", ({ payload }) => + Effect.gen(function* () { + const bot = yield* authenticateBotFromToken + const organizationId = organizationOfInstallCode(payload.code) + if (Option.isNone(organizationId)) { + return yield* new BotInstallGrantInvalidError({ + message: "The install code is invalid, expired or already used", + }) + } + const redeemed = yield* callObject( + RedeemBotInstallGrantCall, + orgTarget(organizationId.value), + { + botId: bot.id, + codeHash: yield* hashInstallCode(payload.code), + redirectUri: payload.redirectUri, + }, + ) + return new BotInstallRedeemedResponse(redeemed) + }).pipe( + Effect.catchTags({ + DatabaseError: internal("Database error while redeeming a bot install"), + ObjectCallError: internal("The organization is unavailable"), + }), + Effect.withSpan("ApiV1.redeemBotInstall"), + ), + ) + .handle("createThread", ({ params, payload }) => + Effect.gen(function* () { + const bot = yield* authenticateBotFromToken + yield* checkMessageRateLimit(bot.userId) + const index = yield* (yield* Directory).channel(params.channelId) + if (Option.isNone(index) || index.value.deletedAt !== null) { + return yield* new ChannelNotFoundError({ channelId: params.channelId }) + } + const organizationId = index.value.organizationId + const host = channelTarget(index.value.hostChannelId ?? params.channelId) + const existing = yield* callObject(MessageThreadCall, host, { + userId: bot.userId, + messageId: payload.messageId, + }) + if (existing !== null) { + return new ThreadResponse({ + data: { id: existing, parentChannelId: params.channelId, organizationId }, + }) + } + // No id: `channel.createThread` derives one from the message, so racing calls open one. + const response = yield* forwardRpc< + ChannelResponse, + | MessageNotFoundError + | NestedThreadError + | InvalidChannelRequestError + | UnauthorizedError + >( + orgTarget(organizationId), + "channel.createThread", + new CreateThreadRequest({ + messageId: payload.messageId, + channelId: params.channelId, + organizationId, + }), + ).pipe(providePrincipal(botPrincipal(bot))) + return new ThreadResponse({ + data: { id: response.data.id, parentChannelId: params.channelId, organizationId }, + }) + }).pipe( + Effect.catchTags({ + DatabaseError: internal("Database error while creating a thread"), + ObjectCallError: internal("The channel is unavailable"), + // The message is not in this channel, or is itself in a thread. + MessageNotFoundError: () => + Effect.fail(new ChannelNotFoundError({ channelId: params.channelId })), + NestedThreadError: () => + Effect.fail(new ChannelNotFoundError({ channelId: params.channelId })), + InvalidChannelRequestError: () => + Effect.fail(new ChannelNotFoundError({ channelId: params.channelId })), + }), + Effect.withSpan("ApiV1.createThread"), + ), + ) + .handle("listOrganizationUsers", ({ params, query }) => + Effect.gen(function* () { + const bot = yield* authenticateBotFromToken + const raw = query.ids + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0) + const ids = [...new Set(raw)].filter(isUserId) + const users = yield* callObject(OrganizationUsersCall, orgTarget(params.organizationId), { + botId: bot.id, + botUserId: bot.userId, + ids: ids.slice(0, ORGANIZATION_USERS_MAX_IDS), + }) + return new ApiV1UsersResponse({ data: users }) + }).pipe( + Effect.catchTags({ + DatabaseError: internal("Database error while listing users"), + ObjectCallError: internal("The organization is unavailable"), + }), + Effect.withSpan("ApiV1.listOrganizationUsers"), + ), + ), + ), +) diff --git a/apps/backend/src/routes/api-v1/index.ts b/apps/backend/src/routes/api-v1/index.ts index f7073d307..256ddb499 100644 --- a/apps/backend/src/routes/api-v1/index.ts +++ b/apps/backend/src/routes/api-v1/index.ts @@ -1,2 +1,3 @@ export { HttpMessagesApiLive } from "./messages.http" export { HttpApiV1IntegrationsLive } from "./integrations.http" +export { HttpApiV1BotsLive } from "./bots.http" diff --git a/apps/backend/src/routes/api-v1/messages.http.ts b/apps/backend/src/routes/api-v1/messages.http.ts index de866ebfd..c8d2cccf7 100644 --- a/apps/backend/src/routes/api-v1/messages.http.ts +++ b/apps/backend/src/routes/api-v1/messages.http.ts @@ -7,9 +7,7 @@ * routed. */ import { HttpApiBuilder } from "effect/http-api" -import { HttpServerRequest } from "effect/http" -import { BotRepo } from "@hazel/backend-core" -import { CurrentUser, InternalServerError, MessageNotFoundError, UnauthorizedError } from "@hazel/domain" +import { InternalServerError, MessageNotFoundError } from "@hazel/domain" import { ChannelNotFoundError, DeleteMessageResponse, @@ -23,9 +21,10 @@ import { } from "@hazel/domain/http" import type { MessageResponse as RpcMessageResponse } from "@hazel/domain/rpc" import type { MessageReaction } from "@hazel/domain/models" -import type { ChannelId, MessageId, TransactionId, UserId } from "@hazel/schema" +import type { ChannelId, MessageId, TransactionId } from "@hazel/schema" import { Effect, Option } from "effect" import { HazelApi } from "../../api" +import { authenticateBotFromToken, botPrincipal } from "./bot-auth" import { Directory } from "../../services/directory" import { HtmlRenders } from "../../services/html-renders" import { checkMessageRateLimit } from "../../services/rate-limit-helpers" @@ -36,50 +35,6 @@ import { forwardRpc } from "../../worker/objects/forward" import { channelTarget } from "../../worker/objects/kinds" import { providePrincipal } from "../../worker/objects/principal" -/** Hash a token using SHA-256 (Web Crypto API). */ -async function hashToken(token: string): Promise { - const data = new TextEncoder().encode(token) - const hashBuffer = await crypto.subtle.digest("SHA-256", data) - return Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join("") -} - -/** Authenticate the bot from its Bearer token. */ -const authenticateBotFromToken = Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - const authHeader = request.headers.authorization - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return yield* new UnauthorizedError({ - message: "Missing or invalid API token", - detail: "Authorization header must be 'Bearer '", - }) - } - const tokenHash = yield* Effect.promise(() => hashToken(authHeader.slice(7))) - const bot = yield* (yield* BotRepo).findByTokenHash(tokenHash) - if (Option.isNone(bot)) { - return yield* new UnauthorizedError({ - message: "Invalid API token", - detail: "No bot found with this token", - }) - } - return bot.value -}) - -/** Bots act as their user, with a member's scopes (as the API always did). */ -const botPrincipal = (bot: { readonly userId: UserId; readonly name: string }) => ({ - user: new CurrentUser.Schema({ - id: bot.userId, - role: "member", - email: `bot-${bot.name}@hazel.bot`, - isOnboarded: true, - timezone: null, - organizationId: null, - settings: null, - }), - botScopes: null, -}) - /** The `ChannelObject` holding `channelId`'s messages (a Connect mount's host). */ const hostOf = (channelId: ChannelId) => Effect.gen(function* () { diff --git a/apps/backend/src/routes/object-routes.test.ts b/apps/backend/src/routes/object-routes.test.ts index 36f17dc87..97a3d8cee 100644 --- a/apps/backend/src/routes/object-routes.test.ts +++ b/apps/backend/src/routes/object-routes.test.ts @@ -7,7 +7,8 @@ import { createHash } from "node:crypto" import { NodeHttpPlatform, NodeServices } from "@effect/platform-node" import { BotRepo } from "@hazel/backend-core" import { Database, SQLite, schema } from "@hazel/db" -import { MessagesApiGroup } from "@hazel/domain/http" +import { CurrentUser } from "@hazel/domain" +import { BotsApiGroup, MessagesApiGroup } from "@hazel/domain/http" import type { BotId, ChannelId, ChannelWebhookId, OrganizationId, UserId } from "@hazel/schema" import { Cause, Context, Effect, Exit, Layer, Option, type Scope } from "effect" import { Etag, HttpRouter } from "effect/http" @@ -20,6 +21,9 @@ import { channelTarget, orgTarget } from "../worker/objects/kinds" import { ConnectIntegrationCall } from "../worker/objects/org/integrations" import { ObjectStubs } from "../worker/objects/protocol" import { makeTestObjects, type TestObject, type TestObjects } from "../worker/objects/testing" +import { decodeExit, encodePayload, type HazelRpcTag, rpcFor } from "../rpc/dispatch" +import { encodeInvokePrincipal } from "../worker/objects/principal" +import { HttpApiV1BotsLive } from "./api-v1/bots.http" import { HttpMessagesApiLive } from "./api-v1/messages.http" import { deliverWebhook } from "./incoming-webhooks.http" import { dispatchGitHubEvent } from "./webhooks.http" @@ -112,6 +116,93 @@ const messagesIn = (object: TestObject) => const failureOf = (exit: Exit.Exit) => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined +/** The public bot API over `world`'s objects, authenticated as the bot holding `token`. */ +const botHttp = (world: World, token: string) => + Effect.gen(function* () { + const directory = Context.get(world.objects.directory, Directory) + const routes = Layer.mergeAll(HttpMessagesApiLive, HttpApiV1BotsLive).pipe( + Layer.provideMerge(BotRepo.layer), + Layer.provideMerge(Layer.succeed(Database.Database, directory.db)), + Layer.provideMerge(Layer.succeedContext(world.objects.directory)), + Layer.provideMerge(Layer.succeed(ObjectStubs, world.objects.stubs)), + Layer.provideMerge(RateLimiterAllowAll), + ) + const app = HttpApiBuilder.layer( + HttpApi.make("HazelApp").add(MessagesApiGroup).add(BotsApiGroup), + ).pipe( + Layer.provideMerge(routes), + Layer.provideMerge(HttpRouter.layer), + Layer.provideMerge(Etag.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpPlatform.layer), + ) + const { handler, dispose } = HttpRouter.toWebHandler(app as never, { disableLogger: true }) + yield* Effect.addFinalizer(() => Effect.promise(() => dispose())) + const request = (path: string, init: RequestInit = {}) => + Effect.promise(() => + handler( + new Request(`http://localhost${path}`, { + ...init, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + }), + Context.empty() as Context.Context, + ), + ) + const json = (response: Response) => + Effect.promise(async () => { + if (response.status !== 200) { + throw new Error(`${response.status} ${response.url}: ${await response.text()}`) + } + return (await response.json()) as any + }) + return { request, json } + }) + +/** A directory bot row for `botId`, authenticated by `token`. */ +const insertDirectoryBot = ( + world: World, + bot: { botId: BotId; botUserId: UserId; token: string; scopes?: ReadonlyArray }, +) => + Context.get(world.objects.directory, Directory) + .db.run( + SQLite.insertInto(schema.botsTable).values({ + id: bot.botId, + userId: bot.botUserId, + createdBy: world.alice, + name: "Helper", + apiTokenHash: sha256(bot.token), + scopes: bot.scopes === undefined ? null : [...bot.scopes], + }), + ) + .pipe(Effect.orDie) + +/** Run `tag` on `object` as `userId`, the way the api Worker forwards it. */ +const invokeAs = (object: TestObject, objectId: string, tag: HazelRpcTag, payload: unknown, userId: UserId) => + Effect.gen(function* () { + const rpc = yield* rpcFor(tag) + const response = yield* object.invoke({ + objectId, + tag, + payload: yield* encodePayload(rpc, payload), + principal: yield* encodeInvokePrincipal({ + user: new CurrentUser.Schema({ + id: userId, + organizationId: null, + role: "member", + email: `${userId}@example.com`, + isOnboarded: true, + timezone: null, + settings: null, + }), + botScopes: null, + }), + }) + return yield* decodeExit(rpc, structuredClone(response).exit) + }).pipe(Effect.orDie) + describe("public API (v1) messages", () => { it("creates a bot's message in the ChannelObject, with its bot gateway event", async () => { const botId = uuid() as BotId @@ -121,54 +212,8 @@ describe("public API (v1) messages", () => { await run( Effect.gen(function* () { const world = yield* makeWorld([botUserId], [{ botId, botUserId }]) - const directory = Context.get(world.objects.directory, Directory) - yield* directory.db - .run( - SQLite.insertInto(schema.botsTable).values({ - id: botId, - userId: botUserId, - createdBy: world.alice, - name: "Helper", - apiTokenHash: sha256(token), - }), - ) - .pipe(Effect.orDie) - const routes = HttpMessagesApiLive.pipe( - Layer.provideMerge(BotRepo.layer), - Layer.provideMerge(Layer.succeed(Database.Database, directory.db)), - Layer.provideMerge(Layer.succeedContext(world.objects.directory)), - Layer.provideMerge(Layer.succeed(ObjectStubs, world.objects.stubs)), - Layer.provideMerge(RateLimiterAllowAll), - ) - const app = HttpApiBuilder.layer(HttpApi.make("HazelApp").add(MessagesApiGroup)).pipe( - Layer.provideMerge(routes), - Layer.provideMerge(HttpRouter.layer), - Layer.provideMerge(Etag.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpPlatform.layer), - ) - const { handler, dispose } = HttpRouter.toWebHandler(app as never, { disableLogger: true }) - yield* Effect.addFinalizer(() => Effect.promise(() => dispose())) - const request = (path: string, init: RequestInit = {}) => - Effect.promise(() => - handler( - new Request(`http://localhost${path}`, { - ...init, - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json", - }, - }), - Context.empty() as Context.Context, - ), - ) - const json = (response: Response) => - Effect.promise(async () => { - if (response.status !== 200) { - throw new Error(`${response.status} ${response.url}: ${await response.text()}`) - } - return (await response.json()) as any - }) + yield* insertDirectoryBot(world, { botId, botUserId, token }) + const { request, json } = yield* botHttp(world, token) const body = yield* json( yield* request("/api/v1/messages", { @@ -212,6 +257,243 @@ describe("public API (v1) messages", () => { }) }) +describe("public API (v1) for bots outside Hazel", () => { + it("holds a bot to the scopes it declared, as RPC does", () => + run( + Effect.gen(function* () { + const botId = uuid() as BotId + const botUserId = uuid() as UserId + const token = `hzl_bot_${uuid()}` + const world = yield* makeWorld([botUserId], [{ botId, botUserId }]) + yield* insertDirectoryBot(world, { botId, botUserId, token, scopes: ["messages:read"] }) + const { request } = yield* botHttp(world, token) + const refused = yield* request("/api/v1/messages", { + method: "POST", + body: JSON.stringify({ + channelId: world.channelId, + content: "not allowed", + replyToMessageId: null, + threadChannelId: null, + embeds: null, + }), + }) + expect(refused.status).toBe(401) + expect(messagesIn(world.channel)).toEqual([]) + const listed = yield* request(`/api/v1/messages?channel_id=${world.channelId}`) + expect(listed.status).toBe(200) + }), + )) + + it("a button press reaches the bot that posted it; threads and people by id", () => + run( + Effect.gen(function* () { + const botId = uuid() as BotId + const botUserId = uuid() as UserId + const otherBotId = uuid() as BotId + const otherBotUserId = uuid() as UserId + const token = `hzl_bot_${uuid()}` + const world = yield* makeWorld( + [botUserId, otherBotUserId], + [ + { botId, botUserId }, + { botId: otherBotId, botUserId: otherBotUserId }, + ], + ) + for (const [userId, userType, name] of [ + [world.alice, "user", "Alice"], + [botUserId, "machine", "Maple"], + ] as const) { + world.org.storage.db + .prepare( + `INSERT INTO users (id, externalId, email, firstName, lastName, userType) VALUES (?, ?, ?, ?, '', ?)`, + ) + .run(userId, `ext-${userId}`, `${userId}@example.com`, name, userType) + } + yield* insertDirectoryBot(world, { + botId, + botUserId, + token, + scopes: ["messages:read", "messages:write", "channels:write"], + }) + yield* Context.get(world.objects.directory, Directory) + .db.run( + SQLite.insertInto(schema.usersTable).values({ + id: botUserId, + externalId: `bot_${botUserId}`, + email: `${botUserId}@bot.hazel.sh`, + firstName: "Maple", + lastName: "", + userType: "machine", + }), + ) + .pipe(Effect.orDie) + const { request, json } = yield* botHttp(world, token) + + const posted = yield* json( + yield* request("/api/v1/messages", { + method: "POST", + body: JSON.stringify({ + channelId: world.channelId, + content: "Restart checkout?", + replyToMessageId: null, + threadChannelId: null, + embeds: [ + { + description: "Maple wants to restart checkout", + actions: [ + { id: "approve:call_1", label: "Approve", style: "primary" }, + { id: "deny:call_1", label: "Deny", disabled: true }, + ], + }, + ], + }), + }), + ) + const messageId = posted.data.id + // Message events name their author, read from the directory. + const created = { + eventType: "message.create", + author: { id: botUserId, displayName: "Maple", isBot: true }, + } + expect(world.objects.published.map((entry) => entry.envelope)).toMatchObject([ + created, + created, + ]) + world.objects.published.length = 0 + + // A deleted author is named nowhere: the event goes out without one. + const directory = Context.get(world.objects.directory, Directory) + const setDeleted = (deletedAt: Date | null) => + directory.db + .run( + SQLite.update(schema.usersTable) + .set({ deletedAt }) + .where(($) => [$.id.eq(botUserId)]), + ) + .pipe(Effect.orDie) + yield* setDeleted(new Date()) + yield* json( + yield* request("/api/v1/messages", { + method: "POST", + body: JSON.stringify({ + channelId: world.channelId, + content: "from a deleted author", + replyToMessageId: null, + threadChannelId: null, + embeds: null, + }), + }), + ) + const anonymous = world.objects.published.map((entry) => entry.envelope) + expect(anonymous).toHaveLength(2) + expect(anonymous.every((envelope) => !("author" in envelope))).toBe(true) + yield* setDeleted(null) + world.objects.published.length = 0 + + const press = (actionId: string, userId: UserId = world.alice) => + invokeAs( + world.channel, + world.channelId, + "message.interact", + { channelId: world.channelId, messageId, actionId }, + userId, + ) + const pressed = yield* press("approve:call_1") + expect(Exit.isSuccess(pressed)).toBe(true) + // Only the bot that posted the message hears about it. + expect(world.objects.published).toMatchObject([ + { + botId, + envelope: { + eventType: "interaction.create", + payload: { + actionId: "approve:call_1", + messageId, + channelId: world.channelId, + orgId: world.orgId, + userId: world.alice, + // Alice has no name here, and a bot never gets her email instead. + userName: "Unknown", + }, + }, + }, + ]) + expect((failureOf(yield* press("deny:call_1")) as { _tag: string })._tag).toBe( + "MessageActionUnavailableError", + ) + expect((failureOf(yield* press("nope")) as { _tag: string })._tag).toBe( + "MessageActionUnavailableError", + ) + // Someone who cannot read the channel cannot press its buttons. + expect(failureOf(yield* press("approve:call_1", uuid() as UserId))).toBeDefined() + + // The org's access rows for the channel, which a thread copies (this world sets the + // channel's viewers by hand rather than through the org). + for (const userId of [world.alice, botUserId, otherBotUserId]) { + world.org.storage.db + .prepare( + `INSERT INTO channel_access (userId, channelId, organizationId) VALUES (?, ?, ?)`, + ) + .run(userId, world.channelId, world.orgId) + } + + // A thread on the bot's own message; asking again answers with the same thread. + const thread = yield* json( + yield* request(`/api/v1/channels/${world.channelId}/threads`, { + method: "POST", + body: JSON.stringify({ messageId }), + }), + ) + expect(thread.data).toMatchObject({ + parentChannelId: world.channelId, + organizationId: world.orgId, + }) + const again = yield* json( + yield* request(`/api/v1/channels/${world.channelId}/threads`, { + method: "POST", + body: JSON.stringify({ messageId }), + }), + ) + expect(again.data.id).toBe(thread.data.id) + + // A mention inside the thread answers with that same thread, not a thread on a thread. + const inThread = yield* json( + yield* request("/api/v1/messages", { + method: "POST", + body: JSON.stringify({ + channelId: thread.data.id, + content: "follow-up", + replyToMessageId: null, + threadChannelId: null, + embeds: null, + }), + }), + ) + const sameThread = yield* json( + yield* request(`/api/v1/channels/${thread.data.id}/threads`, { + method: "POST", + body: JSON.stringify({ messageId: inThread.data.id }), + }), + ) + expect(sameThread.data.id).toBe(thread.data.id) + + const users = yield* json( + yield* request( + `/api/v1/organizations/${world.orgId}/users?ids=${world.alice},not-a-uuid,${botUserId},${uuid()}`, + ), + ) + expect( + [...users.data].sort((a: { displayName: string }, b: { displayName: string }) => + a.displayName.localeCompare(b.displayName), + ), + ).toMatchObject([ + { id: world.alice, displayName: "Alice", isBot: false }, + { id: botUserId, displayName: "Maple", isBot: true }, + ]) + }), + )) +}) + describe("incoming webhooks", () => { const seedWebhook = (world: World, options: { readonly enabled: boolean }) => Effect.gen(function* () { diff --git a/apps/backend/src/rpc/handlers/bots.ts b/apps/backend/src/rpc/handlers/bots.ts index 61bdbc005..59791f32d 100644 --- a/apps/backend/src/rpc/handlers/bots.ts +++ b/apps/backend/src/rpc/handlers/bots.ts @@ -13,9 +13,13 @@ import { BotCommandRepo, BotRepo, UserRepo } from "@hazel/backend-core" import { Database, SQLite, schema } from "@hazel/db" import { ActiveOrganizationRequiredError, CurrentUser, withRemapDbErrors } from "@hazel/domain" +import { isRegisteredInstallRedirect } from "@hazel/domain/bot-install" import { BotAlreadyInstalledError, BotCommandListResponse, + BotInstallAuthorizedResponse, + BotInstallRedirectMismatchError, + BotInstallRequestResponse, BotCreatedResponse, BotListResponse, BotNotFoundError, @@ -41,6 +45,7 @@ import { DirectoryEffects } from "../../worker/objects/effects/directory-effects import { enqueueEffect } from "../../worker/objects/effects/registry" import { fanOut, transactionIdOf } from "../../worker/objects/fanout" import { orgTarget } from "../../worker/objects/kinds" +import { mintInstallGrant } from "../../worker/objects/org/bot-install-grants" import { installBotHere, uninstallBotHere } from "../../worker/objects/org/bot-installs" import { toUserProjection, upsertUserProjection } from "../../worker/objects/org/projections" import { projectUserToOrgs } from "./users" @@ -183,6 +188,7 @@ export const BotRpcLive = BotRpcs.toLayer( installCount: 1, mentionable: false, allowedIntegrations: null, + installRedirectUris: null, createdAt: now, updatedAt: now, deletedAt: null, @@ -245,6 +251,7 @@ export const BotRpcLive = BotRpcs.toLayer( webhookUrl: payload.webhookUrl, scopes: payload.scopes ? [...payload.scopes] : undefined, isPublic: payload.isPublic, + installRedirectUris: payload.installRedirectUris, }) // The machine user's name follows the bot's. if (payload.name) { @@ -399,6 +406,77 @@ export const BotRpcLive = BotRpcs.toLayer( .pipe(withRemapDbErrors("BotInstallation", "delete")) }), + "bot.getInstallRequest": ({ botId, redirectUri }) => + Effect.gen(function* () { + const botRepo = yield* BotRepo + const userRepo = yield* UserRepo + const bot = yield* botRepo.findById(botId) + if (Option.isNone(bot)) return yield* new BotNotFoundError({ botId }) + if (!isRegisteredInstallRedirect(bot.value.installRedirectUris, redirectUri)) { + return yield* new BotInstallRedirectMismatchError({ botId }) + } + const [botUser, creator] = yield* Effect.all( + [userRepo.findById(bot.value.userId), userRepo.findById(bot.value.createdBy)], + { concurrency: 2 }, + ) + const creatorName = Option.match(creator, { + onNone: () => "Unknown", + onSome: ({ firstName, lastName, email }) => + firstName && lastName ? `${firstName} ${lastName}` : email, + }) + return new BotInstallRequestResponse({ + id: bot.value.id, + name: bot.value.name, + description: bot.value.description, + avatarUrl: Option.match(botUser, { + onNone: () => null, + onSome: (user) => user.avatarUrl ?? null, + }), + creatorName, + }) + }).pipe(withRemapDbErrors("Bot", "select")), + + // The consent page's approval: install (unless it already is) and mint the grant together. + "bot.authorizeInstall": ({ botId, organizationId: hint, redirectUri }) => + Effect.gen(function* () { + const currentUser = yield* CurrentUser.Context + const organizationId = yield* activeOrganization(hint) + yield* botPolicy.canInstall(organizationId) + const found = yield* readDirectoryBot(botId) + if (Option.isNone(found)) return yield* new BotNotFoundError({ botId }) + if (!isRegisteredInstallRedirect(found.value.bot.installRedirectUris, redirectUri)) { + return yield* new BotInstallRedirectMismatchError({ botId }) + } + return yield* db.transaction( + Effect.gen(function* () { + const installed = yield* db.first( + SQLite.from(schema.botInstallationsTable) + .select("id") + .where(($) => [$.botId.eq(botId), $.organizationId.eq(organizationId)]) + .limit(1), + ) + if (Option.isNone(installed)) { + yield* installBotHere({ + bot: found.value.bot, + commands: found.value.commands, + organizationId, + installedBy: currentUser.id, + }) + } + const code = yield* mintInstallGrant({ + organizationId, + botId, + redirectUri, + installedBy: currentUser.id, + }) + return new BotInstallAuthorizedResponse({ + code, + transactionId: yield* generateTransactionId(), + }) + }), + ) + }).pipe(withRemapDbErrors("BotInstallation", "create")), + "bot.updateAvatar": ({ id, avatarUrl }) => Effect.gen(function* () { yield* botPolicy.canUpdate(id) diff --git a/apps/backend/src/rpc/handlers/channels.ts b/apps/backend/src/rpc/handlers/channels.ts index 59754ff76..3f18c0fd0 100644 --- a/apps/backend/src/rpc/handlers/channels.ts +++ b/apps/backend/src/rpc/handlers/channels.ts @@ -1,3 +1,4 @@ +import { derivedThreadChannelId } from "@hazel/domain/thread-id" import { ChannelMemberRepo, ChannelRepo, OrganizationMemberRepo, UserRepo } from "@hazel/backend-core" import { Database, SQLite, schema } from "@hazel/db" import { @@ -289,9 +290,12 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( yield* channelPolicy.canCreate(organizationId) - // A retried create (same client-chosen id) returns the thread it made. - if (id !== undefined) { - const existing = yield* channelRepo.findById(id) + // A retried create (same id) returns the thread it made. Without an id from the + // caller the id follows from the message, so retries and racing bots agree too. + const threadId = + id ?? (yield* Effect.promise(() => derivedThreadChannelId(messageId))) + { + const existing = yield* channelRepo.findById(threadId) if (Option.isSome(existing)) { if ( existing.value.type !== "thread" || @@ -306,12 +310,12 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( transactionId: yield* generateTransactionId(), }) } - yield* requireUnusedChannelId(id) + yield* requireUnusedChannelId(threadId) } const createdChannel = yield* channelRepo .insert({ - ...(id === undefined ? {} : { id }), + id: threadId, name: "Thread", icon: null, type: "thread" as const, diff --git a/apps/backend/src/rpc/handlers/messages.ts b/apps/backend/src/rpc/handlers/messages.ts index a33c2c478..4ea020de6 100644 --- a/apps/backend/src/rpc/handlers/messages.ts +++ b/apps/backend/src/rpc/handlers/messages.ts @@ -1,7 +1,14 @@ import { AttachmentRepo, MessageRepo } from "@hazel/backend-core" import { Database, SQLite, schema } from "@hazel/db" import { CurrentUser, MessageNotFoundError, withRemapDbErrors } from "@hazel/domain" -import { HtmlRenderUrlResponse, MessageResponse, MessageRpcs } from "@hazel/domain/rpc" +import { MessageEmbed } from "@hazel/domain/models" +import { + HtmlRenderUrlResponse, + MessageActionUnavailableError, + MessageInteractionResponse, + MessageResponse, + MessageRpcs, +} from "@hazel/domain/rpc" import type { ChannelId, HtmlRenderId, UserId } from "@hazel/schema" import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" @@ -11,7 +18,8 @@ import { ChannelViewers } from "../../services/channel-viewers" import { HtmlRenders, htmlRenderIdsOf } from "../../services/html-renders" import { checkMessageRateLimit } from "../../services/rate-limit-helpers" import { SqlClient } from "effect/sql/SqlClient" -import { emitMessageEvent } from "../../worker/objects/channel/message-events" +import { channelOrganization, emitMessageEvent } from "../../worker/objects/channel/message-events" +import { BotGatewayEffects } from "../../worker/objects/effects/bot-gateway-effects" import { enqueueEffect } from "../../worker/objects/effects/registry" import { StorageEffects } from "../../worker/objects/effects/storage-effects" import { searchMessages } from "../../worker/objects/org/message-search" @@ -206,6 +214,57 @@ export const MessageRpcLive = MessageRpcs.toLayer( return new HtmlRenderUrlResponse({ url, expiresAt }) }).pipe(withRemapDbErrors("Message", "select")), + // A button on a bot's message: only the bot that posted it hears about the press. + "message.interact": ({ channelId, messageId, actionId }) => + Effect.gen(function* () { + const user = yield* CurrentUser.Context + yield* messagePolicy.canRead(channelId) + const message = yield* messageRepo.findById(messageId) + if ( + Option.isNone(message) || + message.value.channelId !== channelId || + message.value.deletedAt !== null + ) { + return yield* new MessageNotFoundError({ messageId }) + } + const unavailable = new MessageActionUnavailableError({ messageId, actionId }) + if (MessageEmbed.findEmbedAction(message.value.embeds, actionId) === undefined) + return yield* unavailable + const authorId = message.value.authorId + const bot = yield* db.first( + SQLite.from(schema.channelBotsTable) + .select("botId") + .where(($) => [$.botUserId.eq(authorId)]) + .limit(1), + ) + const organizationId = yield* channelOrganization + if (Option.isNone(bot) || Option.isNone(organizationId)) return yield* unavailable + const interactionId = crypto.randomUUID() + yield* db.transaction( + enqueueEffect( + BotGatewayEffects.interaction, + { kind: "bot-gateway" }, + { + botId: bot.value.botId, + interaction: { + interactionId, + actionId, + messageId, + channelId, + orgId: organizationId.value, + userId: user.id, + // Never the email: the bot is not granted members' contact details. + userName: + [user.firstName, user.lastName].filter(Boolean).join(" ") || + "Unknown", + timestamp: Date.now(), + }, + }, + ), + ) + return new MessageInteractionResponse({ interactionId }) + }).pipe(withRemapDbErrors("Message", "select")), + // Runs in the org's `OrgObject` (`message-search.ts`), not a channel's. "message.search": (payload) => searchMessages(payload).pipe(Effect.provideService(SqlClient, sql)), diff --git a/apps/backend/src/rpc/middleware/auth.test.ts b/apps/backend/src/rpc/middleware/auth.test.ts index c8c14cc86..067b253e1 100644 --- a/apps/backend/src/rpc/middleware/auth.test.ts +++ b/apps/backend/src/rpc/middleware/auth.test.ts @@ -107,6 +107,7 @@ const BOT_RECORD: BotRecord = { isPublic: false, installCount: 0, allowedIntegrations: null, + installRedirectUris: null, mentionable: true, createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), diff --git a/apps/backend/src/rpc/routing.ts b/apps/backend/src/rpc/routing.ts index 490fedbae..f6e31ea3d 100644 --- a/apps/backend/src/rpc/routing.ts +++ b/apps/backend/src/rpc/routing.ts @@ -159,6 +159,7 @@ export const routes: RouteTable = { "message.update": channelHint("message.update"), "message.delete": channelHint("message.delete"), "message.htmlRenderUrl": byChannel, + "message.interact": byChannel, "messageReaction.toggle": byChannel, "messageReaction.create": byChannel, "messageReaction.update": channelHint("messageReaction.update"), @@ -199,12 +200,18 @@ export const routes: RouteTable = { "channel.update": (payload) => orgOfChannel("channel.update", payload.id), "channel.delete": (payload) => orgOfChannel("channel.delete", payload.id), "channel.createDm": byOrg, + // Bots have no active org and older SDKs never sent one: the message's channel names it. "channel.createThread": (payload) => - payload.organizationId === undefined - ? Effect.fail( - new RoutingError({ method: "channel.createThread", message: "needs `organizationId`" }), - ) - : org(payload.organizationId), + payload.organizationId !== undefined + ? org(payload.organizationId) + : payload.channelId !== undefined + ? orgOfChannel("channel.createThread", payload.channelId) + : Effect.fail( + new RoutingError({ + method: "channel.createThread", + message: "needs `organizationId` or `channelId`", + }), + ), "channelMember.create": byOrgOfChannel("channelMember.create"), "channelMember.update": orgHint("channelMember.update"), "channelMember.delete": orgHint("channelMember.delete"), @@ -278,6 +285,8 @@ export const routes: RouteTable = { "bot.install": activeOrg, "bot.uninstall": activeOrg, "bot.installById": activeOrg, + "bot.getInstallRequest": directory, + "bot.authorizeInstall": activeOrg, "bot.updateAvatar": directory, // projects the bot user to its orgs // Invitation rows are the directory's; the admin check needs the org's members. "invitation.create": byOrg, @@ -308,12 +317,14 @@ export const rateLimits: Partial< "message.create": (current) => checkMessageRateLimit(current.id), "message.update": (current) => checkMessageRateLimit(current.id), "message.delete": (current) => checkMessageRateLimit(current.id), + "message.interact": (current) => checkMessageRateLimit(current.id), "bot.create": (current) => checkBotOperationRateLimit(current.id), "bot.update": (current) => checkBotUpdateRateLimit(current.id), "bot.regenerateToken": (current) => checkBotOperationRateLimit(current.id), "bot.install": (current) => checkBotOperationRateLimit(current.id), "bot.uninstall": (current) => checkBotOperationRateLimit(current.id), "bot.installById": (current) => checkBotOperationRateLimit(current.id), + "bot.authorizeInstall": (current) => checkBotOperationRateLimit(current.id), } /** Where `tag` runs for `payload` (decoded) and `user`. */ diff --git a/apps/backend/src/services/bot-gateway-service.ts b/apps/backend/src/services/bot-gateway-service.ts index 92b7c46e9..3c972b5c6 100644 --- a/apps/backend/src/services/bot-gateway-service.ts +++ b/apps/backend/src/services/bot-gateway-service.ts @@ -2,6 +2,7 @@ import { BotInstallationRepo, ChannelRepo } from "@hazel/backend-core" import { type BotGatewayCommandInvokePayload, type BotGatewayEnvelope, + type BotGatewayMessageAuthor, createBotGatewayPartitionKey, } from "@hazel/domain" import type { Channel, ChannelMember, Message } from "@hazel/domain/models" @@ -22,6 +23,7 @@ export const messageEventEnvelope = ( organizationId: OrganizationId, eventType: "message.create" | "message.update" | "message.delete", message: Schema.Schema.Type, + author?: BotGatewayMessageAuthor, ): BotGatewayEnvelope => { const eventTimestamp = message.updatedAt ? toEpochMs(message.updatedAt) @@ -36,6 +38,7 @@ export const messageEventEnvelope = ( idempotencyKey: `${eventType}:${message.id}:${eventTimestamp}`, eventType, payload: message, + ...(author === undefined ? {} : { author }), } } diff --git a/apps/backend/src/worker/objects/call-receivers.ts b/apps/backend/src/worker/objects/call-receivers.ts index 7f49ff711..4dd7d4e39 100644 --- a/apps/backend/src/worker/objects/call-receivers.ts +++ b/apps/backend/src/worker/objects/call-receivers.ts @@ -17,9 +17,15 @@ import type { IntegrationTokenService } from "../../services/integration-token-s import type { IntegrationBotService } from "../../services/integrations/integration-bot-service" import type { WebhookBotService } from "../../services/webhook-bot-service" import { type AnyObjectCall, type CallRequest, type CallResponse, exitCodec, payloadCodec } from "./calls" -import { listMessages, ListMessagesCall } from "./channel/api-messages" +import { listMessages, ListMessagesCall, messageThread, MessageThreadCall } from "./channel/api-messages" import { liveAccess, LiveAccessCall } from "./channel/live-access" import type { ObjectKind } from "./kinds" +import { + organizationUsers, + OrganizationUsersCall, + redeemBotInstallGrant, + RedeemBotInstallGrantCall, +} from "./org/bot-install-grants" import { isOrgAdmin, IsOrgAdminCall, @@ -107,8 +113,14 @@ export const callReceivers: Record> = receiver(OrganizationProfileCall, () => organizationProfile()), receiver(ListChannelsCall, listChannels), receiver(IsOrgAdminCall, isOrgAdmin), + receiver(RedeemBotInstallGrantCall, redeemBotInstallGrant), + receiver(OrganizationUsersCall, organizationUsers), + ]), + channel: byName([ + receiver(ListMessagesCall, listMessages), + receiver(MessageThreadCall, messageThread), + receiver(LiveAccessCall, liveAccess), ]), - channel: byName([receiver(ListMessagesCall, listMessages), receiver(LiveAccessCall, liveAccess)]), user: byName([]), } diff --git a/apps/backend/src/worker/objects/channel/api-messages.ts b/apps/backend/src/worker/objects/channel/api-messages.ts index cf40f1c11..3baf68c97 100644 --- a/apps/backend/src/worker/objects/channel/api-messages.ts +++ b/apps/backend/src/worker/objects/channel/api-messages.ts @@ -5,7 +5,7 @@ */ import { MessageRepo } from "@hazel/backend-core" import { Database, SQLite, schema } from "@hazel/db" -import { UnauthorizedError } from "@hazel/domain" +import { MessageNotFoundError, UnauthorizedError } from "@hazel/domain" import { InvalidPaginationError } from "@hazel/domain/http" import { Message } from "@hazel/domain/models" import { ChannelId, MessageId, UserId } from "@hazel/schema" @@ -78,3 +78,37 @@ export const listMessages = Effect.fn("ChannelObject.listMessages")( }, Effect.catchTag("DatabaseError", (error) => Effect.die(error)), ) + +export const MessageThreadCall = defineCall("channel.messageThread", "channel", { + payload: Schema.Struct({ userId: UserId, messageId: MessageId }), + success: Schema.NullOr(ChannelId), + error: Schema.Union([UnauthorizedError, MessageNotFoundError]), +}) + +/** The thread a message already has, for a viewer of its channel (`POST /api/v1/channels/:id/threads`). */ +export const messageThread = Effect.fn("ChannelObject.messageThread")( + function* (payload: typeof MessageThreadCall.payload.Type) { + const db = yield* Database.Database + const viewer = yield* db.first( + SQLite.from(schema.channelViewersTable) + .select("userId") + .where(($) => [$.userId.eq(payload.userId)]) + .limit(1), + ) + if (Option.isNone(viewer)) { + return yield* new UnauthorizedError({ + message: "You can't read this channel", + detail: `messageId=${payload.messageId}`, + }) + } + const message = yield* db.first( + SQLite.from(schema.messagesTable) + .select("threadChannelId") + .where(($) => [$.id.eq(payload.messageId), $.deletedAt.isNull()]) + .limit(1), + ) + if (Option.isNone(message)) return yield* new MessageNotFoundError({ messageId: payload.messageId }) + return message.value.threadChannelId + }, + Effect.catchTag("DatabaseError", (error) => Effect.die(error)), +) diff --git a/apps/backend/src/worker/objects/channel/message-events.ts b/apps/backend/src/worker/objects/channel/message-events.ts index a80970e65..f0b268a6f 100644 --- a/apps/backend/src/worker/objects/channel/message-events.ts +++ b/apps/backend/src/worker/objects/channel/message-events.ts @@ -45,7 +45,7 @@ export type MessageEventHook = ( export const THREAD_NAMING_THRESHOLD = 3 /** The channel's organization, from the last `channel.setViewers`. */ -const channelOrganization = Effect.gen(function* () { +export const channelOrganization = Effect.gen(function* () { const db = yield* Database.Database const rows = yield* db.query(Database.sql`SELECT value FROM _object_meta WHERE key = 'organizationId'`) return Option.map(Option.fromNullishOr(rows[0]?.value as string | undefined), (id) => diff --git a/apps/backend/src/worker/objects/directory-path.test.ts b/apps/backend/src/worker/objects/directory-path.test.ts index 69d39b8cf..bdd071401 100644 --- a/apps/backend/src/worker/objects/directory-path.test.ts +++ b/apps/backend/src/worker/objects/directory-path.test.ts @@ -13,7 +13,10 @@ import { Hex } from "effect/encoding" import { describe, expect, it } from "vitest" import { decodeExit, encodePayload, type HazelRpcTag, rpcFor } from "../../rpc/dispatch" import { Directory } from "../../services/directory" +import { callObject } from "./calls" import { channelTarget, orgTarget, userTarget } from "./kinds" +import { hashInstallCode, OrganizationUsersCall, RedeemBotInstallGrantCall } from "./org/bot-install-grants" +import { ObjectStubs } from "./protocol" import { encodeInvokePrincipal } from "./principal" import { makeTestObjects, type TestObject, type TestObjects } from "./testing" @@ -288,6 +291,123 @@ describe("directory path", () => { }), )) + it("bot install grants: a registered redirect, an admin's approval, one redemption by that bot", () => + run( + Effect.gen(function* () { + const objects = yield* makeTestObjects + const home = yield* seed(objects) + const away = yield* seed(objects) + const worker = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(ObjectStubs, objects.stubs), + Effect.provideContext(objects.directory), + Effect.exit, + ) + const redirectUri = "https://maple.example/oauth/chat/hazel/callback" + const created = ok<{ data: { id: string; userId: string } }>( + yield* call( + home.org, + home.seed.orgId, + "bot.create", + { name: "Maple", scopes: ["messages:read"], organizationId: home.seed.orgId }, + home.seed.owner, + ), + ) + const botId = created.data.id + const request = (uri: string) => + objects.invokeDirectory( + "bot.getInstallRequest", + { botId, redirectUri: uri }, + principalOf(away.seed.owner), + ) + expect(errorTag(yield* request(redirectUri))).toBe("BotInstallRedirectMismatchError") + + ok( + yield* objects.invokeDirectory( + "bot.update", + { id: botId, installRedirectUris: [redirectUri] }, + principalOf(home.seed.owner), + ), + ) + expect(ok<{ name: string }>(yield* request(redirectUri)).name).toBe("Maple") + expect(errorTag(yield* request(`${redirectUri}?x=1`))).toBe("BotInstallRedirectMismatchError") + + const authorize = (user: UserId, uri = redirectUri) => + call( + away.org, + away.seed.orgId, + "bot.authorizeInstall", + { botId, organizationId: away.seed.orgId, redirectUri: uri }, + user, + ) + expect(errorTag(yield* authorize(away.seed.member))).toBe("UnauthorizedError") + expect(errorTag(yield* authorize(away.seed.owner, "https://evil.example/cb"))).toBe( + "BotInstallRedirectMismatchError", + ) + const { code } = ok<{ code: string }>(yield* authorize(away.seed.owner)) + expect(code.startsWith(`${away.seed.orgId}.`)).toBe(true) + expect(away.org.storage.db.prepare(`SELECT botId FROM bot_installations`).all()).toEqual([ + { botId }, + ]) + // Approving again while installed mints another grant rather than failing. + const second = ok<{ code: string }>(yield* authorize(away.seed.owner)).code + expect(second).not.toBe(code) + + const redeem = (input: { code: string; botId?: string; redirectUri?: string }) => + Effect.flatMap(hashInstallCode(input.code), (codeHash) => + worker( + callObject(RedeemBotInstallGrantCall, orgTarget(away.seed.orgId), { + botId: (input.botId ?? botId) as never, + codeHash, + redirectUri: input.redirectUri ?? redirectUri, + }), + ), + ) + expect(errorTag(yield* redeem({ code, botId: uuid() }))).toBe("BotInstallGrantInvalidError") + expect(errorTag(yield* redeem({ code, redirectUri: `${redirectUri}/x` }))).toBe( + "BotInstallGrantInvalidError", + ) + expect(ok(yield* redeem({ code }))).toEqual({ + organizationId: away.seed.orgId, + organizationName: "Acme", + installedBy: away.seed.owner, + }) + expect(errorTag(yield* redeem({ code }))).toBe("BotInstallGrantInvalidError") + + // An expired grant is refused. + away.org.storage.db + .prepare(`UPDATE bot_install_grants SET expiresAt = '2000-01-01 00:00:00.000'`) + .run() + expect(errorTag(yield* redeem({ code: second }))).toBe("BotInstallGrantInvalidError") + + // The bot reads the org's people by id; another org it is not in refuses. + const users = ok>( + yield* worker( + callObject(OrganizationUsersCall, orgTarget(away.seed.orgId), { + botId: botId as never, + botUserId: created.data.userId as UserId, + ids: [away.seed.owner, created.data.userId as UserId, uuid() as UserId], + }), + ), + ) + expect(users.map((user) => [user.id, user.isBot]).sort()).toEqual( + [ + [away.seed.owner, false], + [created.data.userId, true], + ].sort(), + ) + const stranger = yield* seed(objects) + const refused = yield* worker( + callObject(OrganizationUsersCall, orgTarget(stranger.seed.orgId), { + botId: botId as never, + botUserId: created.data.userId as UserId, + ids: [stranger.seed.owner], + }), + ) + expect(errorTag(refused)).toBe("BotNotInstalledError") + }), + )) + it("invitation.accept joins the org and #general, and only once", () => run( Effect.gen(function* () { diff --git a/apps/backend/src/worker/objects/directory-wire.ts b/apps/backend/src/worker/objects/directory-wire.ts index a279c3819..91c62b95e 100644 --- a/apps/backend/src/worker/objects/directory-wire.ts +++ b/apps/backend/src/worker/objects/directory-wire.ts @@ -38,6 +38,8 @@ export const BotWire = Schema.Struct({ installCount: Schema.Number, mentionable: Schema.Boolean, allowedIntegrations: Schema.NullOr(Schema.Array(IntegrationConnection.IntegrationProvider)), + /** Optional: deliveries enqueued before the column existed omit it. */ + installRedirectUris: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), createdAt: Timestamp, updatedAt: Timestamp, deletedAt: Schema.NullOr(Timestamp), @@ -86,6 +88,7 @@ export const fromBotWire = (bot: BotWire): schema.NewBot => ({ ...bot, scopes: bot.scopes === null ? null : [...bot.scopes], allowedIntegrations: bot.allowedIntegrations === null ? null : [...bot.allowedIntegrations], + installRedirectUris: bot.installRedirectUris ? [...bot.installRedirectUris] : null, createdAt: new Date(bot.createdAt), updatedAt: new Date(bot.updatedAt), deletedAt: toDate(bot.deletedAt), diff --git a/apps/backend/src/worker/objects/effects/bot-gateway-effects.ts b/apps/backend/src/worker/objects/effects/bot-gateway-effects.ts index cf8133348..d5185bdfa 100644 --- a/apps/backend/src/worker/objects/effects/bot-gateway-effects.ts +++ b/apps/backend/src/worker/objects/effects/bot-gateway-effects.ts @@ -4,11 +4,13 @@ * bot's `BotGateway` object (`bindBotGateways`). Replaces the global message outbox for these. */ import { Database, SQLite, schema } from "@hazel/db" +import { BotGatewayInteractionCreatePayload, createBotGatewayPartitionKey } from "@hazel/domain" import { Message } from "@hazel/domain/models" -import { OrganizationId } from "@hazel/schema" -import { Effect, Schema } from "effect" +import { BotId, OrganizationId } from "@hazel/schema" +import { Effect, Option, Schema } from "effect" import { messageEventEnvelope } from "../../../services/bot-gateway-service" import { BotGatewayTransport } from "../../../services/bot-gateway-transport" +import { Directory } from "../../../services/directory" import { ChannelEventPayload, ChannelMemberEventPayload, @@ -23,7 +25,14 @@ export const MessageEventPayload = Schema.Struct({ message: Message.Schema, }) +/** A button press on `botId`'s message (`message.interact`), for that bot alone. */ +export const InteractionEventPayload = Schema.Struct({ + botId: BotId, + interaction: BotGatewayInteractionCreatePayload, +}) + export const BotGatewayEffects = { + interaction: defineEffect("botGateway.interaction", "bot-gateway", InteractionEventPayload), messageEvent: defineEffect("botGateway.messageEvent", "bot-gateway", MessageEventPayload), /** From an `OrgObject`: to the org's installed bots (`../org/bot-events.ts`). */ channelEvent: defineEffect("botGateway.channelEvent", "bot-gateway", ChannelEventPayload), @@ -34,23 +43,63 @@ export const BotGatewayEffects = { ), } +/** The message's author from the directory, read once for every bot the event goes to. */ +const authorOf = (userId: typeof Message.Schema.Type.authorId) => + Effect.gen(function* () { + const directory = yield* Directory + const user = yield* directory.db.first( + SQLite.from(schema.usersTable) + .select("id", "firstName", "lastName", "userType") + .where(($) => [$.id.eq(userId), $.deletedAt.isNull()]) + .limit(1), + ) + return Option.map(user, (row) => ({ + id: row.id, + // Never the email: a bot is not granted members' contact details. + displayName: [row.firstName, row.lastName].filter(Boolean).join(" ") || "Unknown", + isBot: row.userType === "machine", + })) + }) + export const botGatewayReceivers = { "botGateway.messageEvent": (payload) => Effect.gen(function* () { const db = yield* Database.Database const transport = yield* BotGatewayTransport const bots = yield* db.run(SQLite.from(schema.channelBotsTable).select("botId")) + if (bots.length === 0) return + const author = Option.getOrUndefined(yield* authorOf(payload.message.authorId)) yield* Effect.forEach( bots, (bot) => transport.append( bot.botId, - messageEventEnvelope(payload.organizationId, payload.eventType, payload.message), + messageEventEnvelope( + payload.organizationId, + payload.eventType, + payload.message, + author, + ), ), { concurrency: 8, discard: true }, ) yield* Effect.annotateCurrentSpan({ "bot_gateway.bots": bots.length }) }), + "botGateway.interaction": ({ botId, interaction }) => + Effect.flatMap(BotGatewayTransport, (transport) => + transport.append(botId, { + schemaVersion: 1, + deliveryId: crypto.randomUUID(), + partitionKey: createBotGatewayPartitionKey({ + organizationId: interaction.orgId, + channelId: interaction.channelId, + }), + occurredAt: interaction.timestamp, + idempotencyKey: `interaction.create:${interaction.interactionId}`, + eventType: "interaction.create", + payload: interaction, + }), + ), "botGateway.channelEvent": publishChannelEvent, "botGateway.channelMemberEvent": publishChannelMemberEvent, -} satisfies EffectReceivers +} satisfies EffectReceivers diff --git a/apps/backend/src/worker/objects/effects/deliver.ts b/apps/backend/src/worker/objects/effects/deliver.ts index aec7ccc6c..1c3d8bfaa 100644 --- a/apps/backend/src/worker/objects/effects/deliver.ts +++ b/apps/backend/src/worker/objects/effects/deliver.ts @@ -56,7 +56,10 @@ export const objectReceivers: Record(DirectoryEffects, directoryReceivers) -const botGatewaySide = side(BotGatewayEffects, botGatewayReceivers) +const botGatewaySide = side( + BotGatewayEffects, + botGatewayReceivers, +) const aiSide = side( AiEffects, aiReceivers, diff --git a/apps/backend/src/worker/objects/org-objects.test.ts b/apps/backend/src/worker/objects/org-objects.test.ts index b44a2dabd..452e294e4 100644 --- a/apps/backend/src/worker/objects/org-objects.test.ts +++ b/apps/backend/src/worker/objects/org-objects.test.ts @@ -10,7 +10,9 @@ import type { ChannelId, OrganizationId, UserId } from "@hazel/schema" import { Cause, DateTime, Effect, Exit, Fiber, Option, type Scope } from "effect" import { afterEach, describe, expect, it, vi } from "vitest" import { CreateDmChannelRequest, CreateThreadRequest } from "@hazel/domain/rpc" +import { derivedThreadChannelId } from "@hazel/domain/thread-id" import { decodeExit, encodePayload, type HazelRpcTag, rpcFor } from "../../rpc/dispatch" +import { resolveTarget } from "../../rpc/routing" import { Directory } from "../../services/directory" import { channelTarget, orgTarget, userTarget } from "./kinds" import { encodeInvokePrincipal } from "./principal" @@ -547,6 +549,48 @@ describe("OrgObject: channels", () => { expect( ok(yield* call(org, orgId, "channel.createThread", payload, world.owner)).data.id, ).toBe(threadId) + + // Without an id (bots, older SDKs) the thread's follows from the message: asking twice + // opens one thread, and the router finds the org from the channel. + const second = ok( + yield* call( + parent, + world.general, + "message.create", + { + channelId: world.general, + content: "another", + embeds: null, + replyToMessageId: null, + threadChannelId: null, + authorId: member, + deletedAt: null, + }, + member, + ), + ) + const unnamed = new CreateThreadRequest({ + messageId: second.data.id, + channelId: world.general, + }) + const target = yield* resolveTarget( + "channel.createThread", + unnamed, + new CurrentUser.Schema({ + id: world.owner, + organizationId: null, + role: "member", + email: "owner@example.com", + isOnboarded: true, + timezone: null, + settings: null, + }), + ).pipe(Effect.provideContext(objects.directory)) + expect(target).toEqual(orgTarget(orgId)) + const derived = yield* Effect.promise(() => derivedThreadChannelId(second.data.id)) + const first = ok(yield* call(org, orgId, "channel.createThread", unnamed, world.owner)) + const again = ok(yield* call(org, orgId, "channel.createThread", unnamed, world.owner)) + expect([first.data.id, again.data.id]).toEqual([derived, derived]) }), )) diff --git a/apps/backend/src/worker/objects/org/bot-install-grants.ts b/apps/backend/src/worker/objects/org/bot-install-grants.ts new file mode 100644 index 000000000..c19242428 --- /dev/null +++ b/apps/backend/src/worker/objects/org/bot-install-grants.ts @@ -0,0 +1,156 @@ +/** + * Bot-install grants in the `OrgObject` (`@hazel/domain/bot-install`): `bot.authorizeInstall` + * mints one beside the installation, and the bot redeems it with its token through the api + * Worker (`routes/api-v1/bots.http.ts`), which routes by the org id the code starts with. The + * org's own store holds the grant, so a code works the moment the consent page hands it out. + * + * Also the public bot API's user lookup (`GET /api/v1/organizations/:id/users`), read from the + * org's `users` projection. + */ +import { Database, SQLite, schema } from "@hazel/db" +import { BOT_INSTALL_GRANT_TTL_MS } from "@hazel/domain/bot-install" +import { ApiV1User, BotInstallGrantInvalidError, BotNotInstalledError } from "@hazel/domain/http" +import { BotId, OrganizationId, UserId } from "@hazel/schema" +import { Effect, Option, Schema } from "effect" +import { Hex } from "effect/encoding" +import { defineCall } from "../calls" +import { requireOrganizationId } from "./integrations" + +const CODE_SEPARATOR = "." + +const sha256 = (value: string) => + Effect.promise(async () => { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)) + return Hex.encode(new Uint8Array(digest)) + }) + +/** The grant's code is `.`: the api Worker routes on the org id, the object checks the rest. */ +export const organizationOfInstallCode = (code: string): Option.Option => { + const index = code.indexOf(CODE_SEPARATOR) + if (index <= 0) return Option.none() + return Schema.decodeUnknownOption(OrganizationId)(code.slice(0, index)) +} + +export const hashInstallCode = sha256 + +/** Mint a grant for `botId`, redeemable once at `redirectUri` within the TTL. Answers the code. */ +export const mintInstallGrant = Effect.fn("OrgObject.mintInstallGrant")(function* (input: { + readonly organizationId: OrganizationId + readonly botId: BotId + readonly redirectUri: string + readonly installedBy: UserId +}) { + const db = yield* Database.Database + const secret = Hex.encode(crypto.getRandomValues(new Uint8Array(32))) + const code = `${input.organizationId}${CODE_SEPARATOR}${secret}` + yield* db.run( + SQLite.insertInto(schema.botInstallGrantsTable).values({ + codeHash: yield* sha256(code), + botId: input.botId, + redirectUri: input.redirectUri, + installedBy: input.installedBy, + expiresAt: new Date(Date.now() + BOT_INSTALL_GRANT_TTL_MS), + }), + ) + return code +}) + +export const RedeemBotInstallGrantCall = defineCall("org.redeemBotInstallGrant", "org", { + payload: Schema.Struct({ botId: BotId, codeHash: Schema.String, redirectUri: Schema.String }), + success: Schema.Struct({ + organizationId: OrganizationId, + organizationName: Schema.String, + installedBy: UserId, + }), + error: BotInstallGrantInvalidError, +}) + +/** + * Redeem a grant: one conditional update, so a code is spent once however many requests race + * for it, and only by the bot and redirect URI it was issued to while it is still fresh. One + * failure for every reason, so the answer says nothing about which codes exist. + */ +export const redeemBotInstallGrant = Effect.fn("OrgObject.redeemBotInstallGrant")( + function* (payload: typeof RedeemBotInstallGrantCall.payload.Type) { + const organizationId = yield* requireOrganizationId + const db = yield* Database.Database + const now = new Date() + const [grant] = yield* db.run( + SQLite.update(schema.botInstallGrantsTable) + .set({ redeemedAt: now }) + .where(($) => [ + $.codeHash.eq(payload.codeHash), + $.botId.eq(payload.botId), + $.redirectUri.eq(payload.redirectUri), + $.redeemedAt.isNull(), + $.expiresAt.gt(now), + ]) + .returning(), + ) + const invalid = new BotInstallGrantInvalidError({ + message: "The install code is invalid, expired or already used", + }) + if (grant === undefined) return yield* invalid + // Uninstalled between approving and redeeming: there is nothing to link. + const installation = yield* db.first( + SQLite.from(schema.botInstallationsTable) + .select("id") + .where(($) => [$.botId.eq(payload.botId), $.organizationId.eq(organizationId)]) + .limit(1), + ) + if (Option.isNone(installation)) return yield* invalid + const organization = yield* db.first( + SQLite.from(schema.organizationsTable) + .select("name") + .where(($) => [$.id.eq(organizationId), $.deletedAt.isNull()]) + .limit(1), + ) + if (Option.isNone(organization)) return yield* invalid + yield* Effect.annotateCurrentSpan({ "bot.id": payload.botId }) + return { organizationId, organizationName: organization.value.name, installedBy: grant.installedBy } + }, + Effect.catchTag("DatabaseError", (error) => Effect.die(error)), +) + +/** At most this many ids per lookup. */ +export const ORGANIZATION_USERS_MAX_IDS = 100 + +export const OrganizationUsersCall = defineCall("org.botUsers", "org", { + payload: Schema.Struct({ botId: BotId, botUserId: UserId, ids: Schema.Array(UserId) }), + success: Schema.Array(ApiV1User), + error: BotNotInstalledError, +}) + +/** The org's people (and bots) among `ids`, for a bot that is a member here. */ +export const organizationUsers = Effect.fn("OrgObject.botUsers")( + function* (payload: typeof OrganizationUsersCall.payload.Type) { + const organizationId = yield* requireOrganizationId + const db = yield* Database.Database + const membership = yield* db.first( + SQLite.from(schema.organizationMembersTable) + .select("id") + .where(($) => [ + $.organizationId.eq(organizationId), + $.userId.eq(payload.botUserId), + $.deletedAt.isNull(), + ]) + .limit(1), + ) + if (Option.isNone(membership)) + return yield* new BotNotInstalledError({ botId: payload.botId, orgId: organizationId }) + if (payload.ids.length === 0) return [] + const users = yield* db.run( + SQLite.from(schema.usersTable) + .select("id", "firstName", "lastName", "avatarUrl", "userType") + .where(($) => [$.id.in_(...payload.ids), $.deletedAt.isNull()]), + ) + return users.map((user) => ({ + id: user.id, + // Never the email: a bot is not granted members' contact details. + displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || "Unknown", + avatarUrl: user.avatarUrl ?? null, + isBot: user.userType === "machine", + })) + }, + Effect.catchTag("DatabaseError", (error) => Effect.die(error)), +) diff --git a/apps/bot-gateway/src/gateway/auth.ts b/apps/bot-gateway/src/gateway/auth.ts index a7ebf0c20..ab8a0a155 100644 --- a/apps/bot-gateway/src/gateway/auth.ts +++ b/apps/bot-gateway/src/gateway/auth.ts @@ -21,7 +21,12 @@ export const authenticateBotToken = (token: string, directory: D1Database): Effe const bot = yield* repo.findByTokenHash(tokenHash) return Option.match(bot, { onNone: (): AuthResult => ({ _tag: "Rejected", reason: "Invalid bot token" }), - onSome: (found): AuthResult => ({ _tag: "Authenticated", botId: found.id, botName: found.name }), + onSome: (found): AuthResult => ({ + _tag: "Authenticated", + botId: found.id, + botUserId: found.userId, + botName: found.name, + }), }) }).pipe( Effect.catchTag("DatabaseError", (error) => diff --git a/apps/bot-gateway/src/gateway/handshake.ts b/apps/bot-gateway/src/gateway/handshake.ts index 3cfedcb66..8efdedfcd 100644 --- a/apps/bot-gateway/src/gateway/handshake.ts +++ b/apps/bot-gateway/src/gateway/handshake.ts @@ -9,6 +9,7 @@ import type { SessionRequest } from "./log.ts" export const HANDSHAKE_HEADERS = { botId: "x-hazel-bot-id", + botUserId: "x-hazel-bot-user-id", botName: "x-hazel-bot-name", op: "x-hazel-gateway-op", sessionId: "x-hazel-gateway-session-id", @@ -17,12 +18,15 @@ export const HANDSHAKE_HEADERS = { export interface Handshake { readonly botId: string + /** The bot's user, for READY. Optional: a Worker deployed before it sends none. */ + readonly botUserId?: string | undefined readonly botName: string readonly request: SessionRequest } export const encodeHandshake = (handshake: Handshake): Record => ({ [HANDSHAKE_HEADERS.botId]: handshake.botId, + ...(handshake.botUserId === undefined ? {} : { [HANDSHAKE_HEADERS.botUserId]: handshake.botUserId }), // Header values must be ByteStrings; bot names are free text. [HANDSHAKE_HEADERS.botName]: encodeURIComponent(handshake.botName), [HANDSHAKE_HEADERS.op]: handshake.request.op, @@ -48,9 +52,11 @@ export const decodeHandshake = ( const botName = safeDecode(headers[HANDSHAKE_HEADERS.botName] ?? "") const op = headers[HANDSHAKE_HEADERS.op] const resumeOffset = safeDecode(headers[HANDSHAKE_HEADERS.resumeOffset] ?? "") + const botUserId = headers[HANDSHAKE_HEADERS.botUserId] || undefined if (!botId || botName === undefined || resumeOffset === undefined) return undefined - if (op === "IDENTIFY") return { botId, botName, request: { op, resumeOffset } } + if (op === "IDENTIFY") return { botId, botUserId, botName, request: { op, resumeOffset } } const sessionId = headers[HANDSHAKE_HEADERS.sessionId] - if (op === "RESUME" && sessionId) return { botId, botName, request: { op, sessionId, resumeOffset } } + if (op === "RESUME" && sessionId) + return { botId, botUserId, botName, request: { op, sessionId, resumeOffset } } return undefined } diff --git a/apps/bot-gateway/src/gateway/object-live.ts b/apps/bot-gateway/src/gateway/object-live.ts index 2b9564178..52adb3687 100644 --- a/apps/bot-gateway/src/gateway/object-live.ts +++ b/apps/bot-gateway/src/gateway/object-live.ts @@ -215,6 +215,8 @@ export const activateBotGateway = Effect.gen(function* () { sessionId: session.sessionId, resumed: sessionRequest.op === "RESUME", resumeOffset: sessionRequest.resumeOffset, + botId: handshake.botId, + ...(handshake.botUserId === undefined ? {} : { botUserId: handshake.botUserId }), } satisfies typeof BotGatewayReadyFrame.Type) deliver(rawSocket, session, now) yield* scheduleAlarm diff --git a/apps/bot-gateway/src/gateway/relay.test.ts b/apps/bot-gateway/src/gateway/relay.test.ts index d44d4f993..bc7beb228 100644 --- a/apps/bot-gateway/src/gateway/relay.test.ts +++ b/apps/bot-gateway/src/gateway/relay.test.ts @@ -61,7 +61,12 @@ const fakeClock = () => { const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) -const BOT = { _tag: "Authenticated", botId: "00000000-0000-4000-8000-000000000111", botName: "echo" } as const +const BOT = { + _tag: "Authenticated", + botId: "00000000-0000-4000-8000-000000000111", + botUserId: "00000000-0000-4000-8000-000000000222", + botName: "echo", +} as const const setup = (options?: { auth?: (token: string) => Promise @@ -113,7 +118,12 @@ describe("gateway relay", () => { expect(tokens).toEqual(["hzl_bot_x"]) expect(handshakes).toEqual([ - { botId: BOT.botId, botName: "echo", request: { op: "IDENTIFY", resumeOffset: "-1" } }, + { + botId: BOT.botId, + botUserId: BOT.botUserId, + botName: "echo", + request: { op: "IDENTIFY", resumeOffset: "-1" }, + }, ]) expect(relay.phase()).toBe("relaying") @@ -261,6 +271,7 @@ describe("handshake headers", () => { } const resume: Handshake = { botId: BOT.botId, + botUserId: BOT.botUserId, botName: "echo", request: { op: "RESUME", sessionId: "s1", resumeOffset: "0000000000000003" }, } diff --git a/apps/bot-gateway/src/gateway/relay.ts b/apps/bot-gateway/src/gateway/relay.ts index eca16199b..46060dcfc 100644 --- a/apps/bot-gateway/src/gateway/relay.ts +++ b/apps/bot-gateway/src/gateway/relay.ts @@ -29,7 +29,12 @@ export interface RelaySocket { } export type AuthResult = - | { readonly _tag: "Authenticated"; readonly botId: string; readonly botName: string } + | { + readonly _tag: "Authenticated" + readonly botId: string + readonly botUserId: string + readonly botName: string + } | { readonly _tag: "Rejected"; readonly reason: string } | { readonly _tag: "Unavailable"; readonly reason: string } @@ -174,7 +179,7 @@ export const startRelay = (options: RelayOptions): RelayHandle => { } const result = await options - .connect({ botId: auth.botId, botName: auth.botName, request }) + .connect({ botId: auth.botId, botUserId: auth.botUserId, botName: auth.botName, request }) .catch((cause): UpstreamResult => ({ _tag: "Unavailable", reason: String(cause) })) if (isClosed()) { if (result._tag === "Connected") closeSocket(result.socket, 1000, "client_closed") diff --git a/apps/docs/content/docs/bot-sdk/getting-started.mdx b/apps/docs/content/docs/bot-sdk/getting-started.mdx index 7f224793a..20beb51ed 100644 --- a/apps/docs/content/docs/bot-sdk/getting-started.mdx +++ b/apps/docs/content/docs/bot-sdk/getting-started.mdx @@ -15,13 +15,13 @@ This guide walks you through installing the Bot SDK and creating a simple bot th ## Installation ```bash -bun add @hazel/bot-sdk effect +bun add @hazel-chat/bot-sdk effect ``` Or with npm: ```bash -npm install @hazel/bot-sdk effect +npm install @hazel-chat/bot-sdk effect ``` ## Environment Setup @@ -37,7 +37,7 @@ For local development, you may also need: ```bash # Optional: Override for local development BACKEND_URL=http://localhost:3003 -GATEWAY_URL=http://localhost:3034 +GATEWAY_URL=http://localhost:3004 ``` ## Your First Bot @@ -46,7 +46,7 @@ Create an `index.ts` file: ```typescript import { Effect } from "effect" -import { runHazelBot } from "@hazel/bot-sdk" +import { runHazelBot } from "@hazel-chat/bot-sdk" runHazelBot({ setup: (bot) => diff --git a/apps/docs/content/docs/bot-sdk/index.mdx b/apps/docs/content/docs/bot-sdk/index.mdx index a98318f8b..aa268147e 100644 --- a/apps/docs/content/docs/bot-sdk/index.mdx +++ b/apps/docs/content/docs/bot-sdk/index.mdx @@ -19,7 +19,7 @@ The Hazel Bot SDK lets you build bots that respond to messages, handle slash com ```typescript import { Effect, Schema } from "effect" -import { Command, CommandGroup, runHazelBot } from "@hazel/bot-sdk" +import { Command, CommandGroup, runHazelBot } from "@hazel-chat/bot-sdk" // Define a slash command with typed arguments const GreetCommand = Command.make("greet", { diff --git a/apps/docs/content/docs/bot-sdk/reference/ai-streaming.mdx b/apps/docs/content/docs/bot-sdk/reference/ai-streaming.mdx index bba9fc5ca..ff5375038 100644 --- a/apps/docs/content/docs/bot-sdk/reference/ai-streaming.mdx +++ b/apps/docs/content/docs/bot-sdk/reference/ai-streaming.mdx @@ -19,7 +19,7 @@ AI streaming enables your bot to: ```typescript import { Effect } from "effect" -import { runHazelBot } from "@hazel/bot-sdk" +import { runHazelBot } from "@hazel-chat/bot-sdk" runHazelBot({ setup: (bot) => diff --git a/apps/docs/content/docs/bot-sdk/reference/commands.mdx b/apps/docs/content/docs/bot-sdk/reference/commands.mdx index 1455f58db..8bcdb27b8 100644 --- a/apps/docs/content/docs/bot-sdk/reference/commands.mdx +++ b/apps/docs/content/docs/bot-sdk/reference/commands.mdx @@ -12,7 +12,7 @@ Create a command definition. ```typescript import { Schema } from "effect" -import { Command } from "@hazel/bot-sdk" +import { Command } from "@hazel-chat/bot-sdk" const GreetCommand = Command.make("greet", { description: "Greet someone", @@ -62,7 +62,7 @@ const MyCommand = Command.make("example", { Group multiple commands together. ```typescript -import { Command, CommandGroup } from "@hazel/bot-sdk" +import { Command, CommandGroup } from "@hazel-chat/bot-sdk" const AddCommand = Command.make("task-add", { description: "Add a task", @@ -184,7 +184,7 @@ Complete example with multiple commands: ```typescript import { Effect, Schema } from "effect" -import { Command, CommandGroup, runHazelBot } from "@hazel/bot-sdk" +import { Command, CommandGroup, runHazelBot } from "@hazel-chat/bot-sdk" const EchoCommand = Command.make("echo", { description: "Echo text back", diff --git a/apps/docs/content/docs/bot-sdk/reference/configuration.mdx b/apps/docs/content/docs/bot-sdk/reference/configuration.mdx index e76aca425..3280236bd 100644 --- a/apps/docs/content/docs/bot-sdk/reference/configuration.mdx +++ b/apps/docs/content/docs/bot-sdk/reference/configuration.mdx @@ -21,7 +21,7 @@ gateway URL for inbound websocket delivery. ```bash BOT_TOKEN=your-bot-token-here BACKEND_URL=http://localhost:3003 -GATEWAY_URL=http://localhost:3034 +GATEWAY_URL=http://localhost:3004 ``` ## `runHazelBot` options @@ -52,7 +52,7 @@ runHazelBot({ }), config: { backendUrl: "http://localhost:3003", - gatewayUrl: "http://localhost:3034", + gatewayUrl: "http://localhost:3004", }, serviceName: "my-task-bot", }) @@ -86,7 +86,7 @@ interface HazelBotConfig { const runtime = createHazelBot({ botToken: process.env.BOT_TOKEN!, backendUrl: "http://localhost:3003", - gatewayUrl: "http://localhost:3034", + gatewayUrl: "http://localhost:3004", commands: myCommands, maxConcurrentPartitions: 8, }) @@ -108,5 +108,5 @@ For local development, point the bot at your local backend and gateway: ```bash BOT_TOKEN=dev-bot-token BACKEND_URL=http://localhost:3003 -GATEWAY_URL=http://localhost:3034 +GATEWAY_URL=http://localhost:3004 ``` diff --git a/apps/docs/content/docs/bot-sdk/reference/errors.mdx b/apps/docs/content/docs/bot-sdk/reference/errors.mdx index 4976a129e..d3f691ab5 100644 --- a/apps/docs/content/docs/bot-sdk/reference/errors.mdx +++ b/apps/docs/content/docs/bot-sdk/reference/errors.mdx @@ -149,7 +149,7 @@ yield * For retry classification, use the exported helpers: ```typescript -import { getErrorTag, retryPolicyForTag } from "@hazel/bot-sdk" +import { getErrorTag, retryPolicyForTag } from "@hazel-chat/bot-sdk" const tag = getErrorTag(error) if (tag && retryPolicyForTag(tag) === "transient") { diff --git a/apps/docs/content/docs/bot-sdk/reference/hazel-bot-client.mdx b/apps/docs/content/docs/bot-sdk/reference/hazel-bot-client.mdx index d533de83d..35f31cb6d 100644 --- a/apps/docs/content/docs/bot-sdk/reference/hazel-bot-client.mdx +++ b/apps/docs/content/docs/bot-sdk/reference/hazel-bot-client.mdx @@ -10,7 +10,7 @@ icon: Bot ```typescript import { Effect } from "effect" -import { HazelBotClient } from "@hazel/bot-sdk" +import { HazelBotClient } from "@hazel-chat/bot-sdk" const program = Effect.gen(function* () { const bot = yield* HazelBotClient diff --git a/apps/docs/content/docs/bot-sdk/reference/html-renders.mdx b/apps/docs/content/docs/bot-sdk/reference/html-renders.mdx index 39b2edda3..37b3d50c3 100644 --- a/apps/docs/content/docs/bot-sdk/reference/html-renders.mdx +++ b/apps/docs/content/docs/bot-sdk/reference/html-renders.mdx @@ -8,7 +8,7 @@ A bot can attach a self-contained HTML page to a message. Hazel shows it inline, ```typescript import { Effect } from "effect" -import { Command, CommandGroup, runHazelBot } from "@hazel/bot-sdk" +import { Command, CommandGroup, runHazelBot } from "@hazel-chat/bot-sdk" const DeploysCommand = Command.make("deploys", { description: "Show this week's deploys" }) diff --git a/apps/docs/content/docs/bot-sdk/reference/index.mdx b/apps/docs/content/docs/bot-sdk/reference/index.mdx index b5ea41552..54751a311 100644 --- a/apps/docs/content/docs/bot-sdk/reference/index.mdx +++ b/apps/docs/content/docs/bot-sdk/reference/index.mdx @@ -21,7 +21,7 @@ The recommended way to create a bot. Handles configuration, signal handling, and ```typescript import { Effect } from "effect" -import { runHazelBot } from "@hazel/bot-sdk" +import { runHazelBot } from "@hazel-chat/bot-sdk" runHazelBot({ commands, // Optional: CommandGroup @@ -39,7 +39,7 @@ For advanced use cases where you need full control over the runtime: ```typescript import { Effect } from "effect" -import { createHazelBot, HazelBotClient } from "@hazel/bot-sdk" +import { createHazelBot, HazelBotClient } from "@hazel-chat/bot-sdk" const runtime = createHazelBot({ botToken: process.env.BOT_TOKEN!, diff --git a/apps/docs/content/docs/bot-sdk/tutorial/01-project-setup.mdx b/apps/docs/content/docs/bot-sdk/tutorial/01-project-setup.mdx index 87020c372..816b98cb6 100644 --- a/apps/docs/content/docs/bot-sdk/tutorial/01-project-setup.mdx +++ b/apps/docs/content/docs/bot-sdk/tutorial/01-project-setup.mdx @@ -21,7 +21,7 @@ bun init -y Install the Bot SDK and Effect: ```bash -bun add @hazel/bot-sdk effect +bun add @hazel-chat/bot-sdk effect ``` ## Configure TypeScript @@ -62,7 +62,7 @@ Create `index.ts`: ```typescript import { Effect } from "effect" -import { runHazelBot } from "@hazel/bot-sdk" +import { runHazelBot } from "@hazel-chat/bot-sdk" runHazelBot({ setup: (bot) => diff --git a/apps/docs/content/docs/bot-sdk/tutorial/02-handling-messages.mdx b/apps/docs/content/docs/bot-sdk/tutorial/02-handling-messages.mdx index 7b0007a20..bf1d6043f 100644 --- a/apps/docs/content/docs/bot-sdk/tutorial/02-handling-messages.mdx +++ b/apps/docs/content/docs/bot-sdk/tutorial/02-handling-messages.mdx @@ -12,7 +12,7 @@ Update `index.ts` to add a message handler: ```typescript import { Effect } from "effect" -import { runHazelBot } from "@hazel/bot-sdk" +import { runHazelBot } from "@hazel-chat/bot-sdk" runHazelBot({ setup: (bot) => @@ -96,7 +96,7 @@ For our Task Bot, let's react to any message mentioning "task": ```typescript import { Effect } from "effect" -import { runHazelBot } from "@hazel/bot-sdk" +import { runHazelBot } from "@hazel-chat/bot-sdk" runHazelBot({ setup: (bot) => diff --git a/apps/docs/content/docs/bot-sdk/tutorial/03-slash-commands.mdx b/apps/docs/content/docs/bot-sdk/tutorial/03-slash-commands.mdx index 3ed3ea329..2e1d1d006 100644 --- a/apps/docs/content/docs/bot-sdk/tutorial/03-slash-commands.mdx +++ b/apps/docs/content/docs/bot-sdk/tutorial/03-slash-commands.mdx @@ -12,7 +12,7 @@ Use `Command.make` to define a command: ```typescript import { Schema } from "effect" -import { Command } from "@hazel/bot-sdk" +import { Command } from "@hazel-chat/bot-sdk" const AddTaskCommand = Command.make("task-add", { description: "Add a new task", @@ -30,7 +30,7 @@ The `args` field uses Effect Schema to define typed arguments. The SDK validates Group related commands with `CommandGroup.make`: ```typescript -import { Command, CommandGroup } from "@hazel/bot-sdk" +import { Command, CommandGroup } from "@hazel-chat/bot-sdk" import { Schema } from "effect" const AddTaskCommand = Command.make("task-add", { @@ -67,7 +67,7 @@ Use `bot.onCommand` to handle commands. The context (`ctx`) is fully typed: ```typescript import { Effect, Schema } from "effect" -import { Command, CommandGroup, runHazelBot } from "@hazel/bot-sdk" +import { Command, CommandGroup, runHazelBot } from "@hazel-chat/bot-sdk" // In-memory task storage const tasks: string[] = [] @@ -155,7 +155,7 @@ Here's what our Task Bot looks like so far: ```typescript import { Effect, Schema } from "effect" -import { Command, CommandGroup, runHazelBot } from "@hazel/bot-sdk" +import { Command, CommandGroup, runHazelBot } from "@hazel-chat/bot-sdk" const tasks: string[] = [] diff --git a/apps/docs/content/docs/bot-sdk/tutorial/04-message-operations.mdx b/apps/docs/content/docs/bot-sdk/tutorial/04-message-operations.mdx index d409c10b8..1939c18e3 100644 --- a/apps/docs/content/docs/bot-sdk/tutorial/04-message-operations.mdx +++ b/apps/docs/content/docs/bot-sdk/tutorial/04-message-operations.mdx @@ -93,7 +93,7 @@ Here's our Task Bot with all message operations: ```typescript import { Effect, Schema } from "effect" -import { Command, CommandGroup, runHazelBot } from "@hazel/bot-sdk" +import { Command, CommandGroup, runHazelBot } from "@hazel-chat/bot-sdk" const tasks: string[] = [] diff --git a/apps/docs/content/docs/bot-sdk/tutorial/05-error-handling.mdx b/apps/docs/content/docs/bot-sdk/tutorial/05-error-handling.mdx index 2e1c573bf..8846ec98f 100644 --- a/apps/docs/content/docs/bot-sdk/tutorial/05-error-handling.mdx +++ b/apps/docs/content/docs/bot-sdk/tutorial/05-error-handling.mdx @@ -118,7 +118,7 @@ Here's the final Task Bot with proper error handling: ```typescript import { Effect, Schema } from "effect" -import { Command, CommandGroup, runHazelBot } from "@hazel/bot-sdk" +import { Command, CommandGroup, runHazelBot } from "@hazel-chat/bot-sdk" const tasks: string[] = [] diff --git a/apps/docs/content/docs/bot-sdk/tutorial/index.mdx b/apps/docs/content/docs/bot-sdk/tutorial/index.mdx index 3e8fc1456..cf553fd59 100644 --- a/apps/docs/content/docs/bot-sdk/tutorial/index.mdx +++ b/apps/docs/content/docs/bot-sdk/tutorial/index.mdx @@ -55,7 +55,7 @@ Here's what the completed Task Bot looks like: ```typescript import { Effect, Schema } from "effect" -import { Command, CommandGroup, runHazelBot } from "@hazel/bot-sdk" +import { Command, CommandGroup, runHazelBot } from "@hazel-chat/bot-sdk" // In-memory task storage (use a database in production!) const tasks: string[] = [] diff --git a/apps/web-foldkit/src/chat/embeds/index.ts b/apps/web-foldkit/src/chat/embeds/index.ts index 76ee2b2cd..000476b51 100644 --- a/apps/web-foldkit/src/chat/embeds/index.ts +++ b/apps/web-foldkit/src/chat/embeds/index.ts @@ -1,6 +1,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { embedContainerStyles } from "~/components/embeds/embed.styles" import { cn } from "~/lib/utils" +import { button } from "../../ui/button" import { embedAuthorView, embedBodyView, @@ -10,7 +11,7 @@ import { embedThumbnailView, } from "./embed-sections" import { type HtmlRenderContext, htmlRenderView } from "./html-render" -import type { EmbedType } from "./types" +import type { EmbedAction, EmbedType } from "./types" /** Port of `components/chat/message-embeds.tsx` (`MessageEmbeds` + `MessageEmbedCard`). */ @@ -28,7 +29,8 @@ const hasCardContent = (embed: EmbedType): boolean => embed.footer || embed.image || embed.thumbnail || - (embed.fields && embed.fields.length > 0) + (embed.fields && embed.fields.length > 0) || + (embed.actions && embed.actions.length > 0) ) /** `Embed` root: wrapped in a block `` when the embed has a url. */ @@ -63,6 +65,37 @@ const embedRootView = ( : content } +const actionIntent = (style: EmbedAction["style"]) => + style === "primary" ? "primary" : style === "danger" ? "danger" : "outline" + +/** The embed's buttons; inert where nothing handles a press (previews outside the message list). */ +const embedActionsView = ( + h: HtmlBuilder, + actions: ReadonlyArray, + onAction: ((actionId: string) => M) | undefined, +): Html => + h.div( + [h.Class("flex flex-wrap gap-2 px-3 pt-1 pb-3")], + actions.map((action) => + h.keyed("span")( + action.id, + [], + [ + button( + h, + { + intent: actionIntent(action.style), + size: "sm", + isDisabled: action.disabled === true || onAction === undefined, + ...(onAction === undefined ? {} : { onPress: onAction(action.id) }), + }, + [action.label], + ), + ], + ), + ), + ) + /** The HTML render of an embed, or nothing without a render context (it renders only in the message list). */ const embedHtmlView = (h: HtmlBuilder, embed: EmbedType, html: HtmlRenderContext | undefined) => embed.html === undefined || html === undefined ? h.empty : htmlRenderView(h, embed.html, html) @@ -71,14 +104,20 @@ export const messageEmbedCardView = ( h: HtmlBuilder, embed: EmbedType, html?: HtmlRenderContext, + onAction?: (actionId: string) => M, ): Html => { if (!hasCardContent(embed)) return embedHtmlView(h, embed, html) const accentColor = embed.color ? `#${embed.color.toString(16).padStart(6, "0")}` : undefined const hasHtml = embed.html !== undefined && html !== undefined - // The frame takes the column's width, and an `` around it would swallow its controls. + const hasActions = embed.actions !== undefined && embed.actions.length > 0 + // The frame takes the column's width, and an `` around it would swallow its controls (so would buttons). return embedRootView( h, - { accentColor, url: hasHtml ? undefined : embed.url, className: hasHtml ? "max-w-none" : undefined }, + { + accentColor, + url: hasHtml || hasActions ? undefined : embed.url, + className: hasHtml ? "max-w-none" : undefined, + }, [ embed.author ? embedAuthorView(h, { @@ -113,6 +152,7 @@ export const messageEmbedCardView = ( timestamp: embed.timestamp ? new Date(embed.timestamp) : undefined, }) : h.empty, + hasActions ? embedActionsView(h, embed.actions ?? [], onAction) : h.empty, ], ) } @@ -123,6 +163,7 @@ export const messageEmbedsView = ( embeds: ReadonlyArray | null, live: Html = h.empty, html?: HtmlRenderContext, + onAction?: (actionId: string) => M, ): Html => embeds?.length ? h.div( @@ -133,7 +174,7 @@ export const messageEmbedsView = ( (embed) => hasCardContent(embed) || (embed.html !== undefined && html !== undefined), ) - .map((embed) => messageEmbedCardView(h, embed, html)), + .map((embed) => messageEmbedCardView(h, embed, html, onAction)), live, ], ) diff --git a/apps/web-foldkit/src/chat/embeds/types.ts b/apps/web-foldkit/src/chat/embeds/types.ts index cc9e2e6c6..057105d0e 100644 --- a/apps/web-foldkit/src/chat/embeds/types.ts +++ b/apps/web-foldkit/src/chat/embeds/types.ts @@ -4,3 +4,4 @@ import type { Message } from "@hazel/domain/models" export type EmbedType = NonNullable[number] export type EmbedBadge = NonNullable export type EmbedField = NonNullable[number] +export type EmbedAction = NonNullable[number] diff --git a/apps/web-foldkit/src/chat/message/row.ts b/apps/web-foldkit/src/chat/message/row.ts index 412425226..d2d4f3c38 100644 --- a/apps/web-foldkit/src/chat/message/row.ts +++ b/apps/web-foldkit/src/chat/message/row.ts @@ -38,6 +38,8 @@ export interface RowContext { readonly avatar: (row: MessageRow) => Html /** An HTML render frame's mount, load, height and controls. */ readonly toHtmlRenderMessage: (message: HtmlRender.Message) => M + /** A button on a bot's message (`MessageEmbed.actions`). */ + readonly toPressEmbedAction: (channelId: ChannelId, messageId: MessageId, actionId: string) => M } /** `DateDivider`: the line is hidden while the divider is pinned to the top. */ @@ -130,6 +132,7 @@ const contentView = (h: HtmlBuilder, row: MessageRow, context: RowContext< rows: row.htmlRenders, toMessage: context.toHtmlRenderMessage, }, + (actionId) => context.toPressEmbedAction(message.channelId, message.id, actionId), ), ] } diff --git a/apps/web-foldkit/src/overlay/modal/edit-bot.ts b/apps/web-foldkit/src/overlay/modal/edit-bot.ts index 68e09e70e..570013f4d 100644 --- a/apps/web-foldkit/src/overlay/modal/edit-bot.ts +++ b/apps/web-foldkit/src/overlay/modal/edit-bot.ts @@ -1,3 +1,4 @@ +import { BOT_INSTALL_MAX_REDIRECT_URIS, isValidInstallRedirectUri } from "@hazel/domain/bot-install" import { ApiScope } from "@hazel/domain/scopes" import { BotId } from "@hazel/schema" import { Effect, Schema } from "effect" @@ -7,6 +8,7 @@ import { modifyFields } from "foldkit/struct" import { BOT_SCOPES } from "~/lib/bot-scopes" import { HazelRpc } from "../../rpc" import { button } from "../../ui/button" +import { textField } from "../../ui/text-field" import { dialogBody, dialogDescription, dialogFooter, dialogHeader } from "../../ui/dialog" import { closed, completed, ModalOutMessage } from "../out-message" import { ToastRequest } from "../toasts" @@ -39,15 +41,37 @@ const Model = Schema.Struct({ botName: Schema.String, avatarUrl: Schema.NullOr(Schema.String), ...BotFormFields, + /** The install link's redirect URIs, one per line as typed. */ + redirectUris: Schema.String, + redirectUrisError: Schema.NullOr(Schema.String), }) type Model = typeof Model.Type +/** The typed lines, trimmed and without blanks or repeats. */ +const redirectUrisOf = (value: string): ReadonlyArray => [ + ...new Set( + value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), +] + +const validateRedirectUris = (value: string): string | null => { + const uris = redirectUrisOf(value) + if (uris.length > BOT_INSTALL_MAX_REDIRECT_URIS) + return `At most ${BOT_INSTALL_MAX_REDIRECT_URIS} redirect URIs` + const invalid = uris.find((uri) => !isValidInstallRedirectUri(uri)) + return invalid === undefined ? null : `${invalid} is not an https URL` +} + const Message = defineMessageUnion({ GotFrameMessage: { message: FrameMessage }, ChangedName: { value: Schema.String }, ChangedDescription: { value: Schema.String }, ToggledScope: { scope: ApiScope }, ToggledPublic: { isSelected: Schema.Boolean }, + ChangedRedirectUris: { value: Schema.String }, ClickedCancel: {}, SubmittedForm: {}, SucceededUpdateBot: { name: Schema.String }, @@ -75,6 +99,7 @@ const UpdateBot = Command.define("UpdateBot", { description: Schema.NullOr(Schema.String), scopes: Schema.Array(ApiScope), isPublic: Schema.Boolean, + installRedirectUris: Schema.NullOr(Schema.Array(Schema.String)), }, messages: [Message.SucceededUpdateBot, Message.FailedUpdateBot], execute: (payload) => @@ -84,7 +109,9 @@ const UpdateBot = Command.define("UpdateBot", { return Message.SucceededUpdateBot({ name: payload.name }) }).pipe( Effect.catchCause((cause) => - Effect.succeed(Message.FailedUpdateBot({ toast: failureToast(cause, "friendly", errorOverrides) })), + Effect.succeed( + Message.FailedUpdateBot({ toast: failureToast(cause, "friendly", errorOverrides) }), + ), ), ), }) @@ -92,8 +119,10 @@ const UpdateBot = Command.define("UpdateBot", { type Return = ModalReturn const submitted = (model: Model): Return => { - const { model: next, isReady } = submittedBotForm(model) - if (!isReady) return { model: next } + const { model: form, isReady } = submittedBotForm(model) + const next = modifyFields(form, { redirectUrisError: () => validateRedirectUris(form.redirectUris) }) + if (!isReady || next.redirectUrisError !== null) return { model: next } + const redirectUris = redirectUrisOf(next.redirectUris) return { model: modifyFields(next, { isSubmitting: () => true }), commands: [ @@ -103,6 +132,7 @@ const submitted = (model: Model): Return => { description: next.description || null, scopes: next.scopes, isPublic: next.isPublic, + installRedirectUris: redirectUris.length === 0 ? null : redirectUris, }), ], } @@ -110,11 +140,18 @@ const submitted = (model: Model): Return => { const update = (model: Model, message: Message): Return => Message.match(message, { - GotFrameMessage: ({ message }) => (isCloseRequest(message) ? { model, outMessage: closed } : { model }), + GotFrameMessage: ({ message }) => + isCloseRequest(message) ? { model, outMessage: closed } : { model }, ChangedName: ({ value }) => ({ model: changedName(model, value) }), ChangedDescription: ({ value }) => ({ model: changedDescription(model, value) }), ToggledScope: ({ scope }) => ({ model: toggledScope(model, scope) }), ToggledPublic: ({ isSelected }) => ({ model: toggledPublic(model, isSelected) }), + ChangedRedirectUris: ({ value }) => ({ + model: modifyFields(model, { + redirectUris: () => value, + redirectUrisError: () => validateRedirectUris(value), + }), + }), ClickedCancel: () => ({ model, outMessage: closed }), SubmittedForm: () => submitted(model), SucceededUpdateBot: ({ name }) => ({ @@ -157,6 +194,31 @@ const view = Submodel.defineView((model, _input onToggleScope: (scope) => Message.ToggledScope({ scope }), onPublic: (isSelected) => Message.ToggledPublic({ isSelected }), }), + textField( + h, + { + id: `${ID}-redirect-uris`, + value: model.redirectUris, + onInput: (value) => Message.ChangedRedirectUris({ value }), + }, + (field) => [ + field.label(["Install redirect URIs"]), + field.description([ + "Where a service may send an admin back after they install this app from its install link (/oauth/bot-install). One https URL per line.", + ]), + field.textarea({ + placeholder: "https://example.com/hazel/callback", + attributes: [ + h.Rows(2), + h.Attribute( + "aria-invalid", + model.redirectUrisError === null ? "false" : "true", + ), + ], + }), + field.fieldError([model.redirectUrisError ?? ""]), + ], + ), ], "flex flex-col gap-6", ), @@ -164,7 +226,11 @@ const view = Submodel.defineView((model, _input button(h, { intent: "outline", onPress: Message.ClickedCancel() }, ["Cancel"]), button( h, - { intent: "primary", isDisabled: isBotSubmitDisabled(model), attributes: [h.Type("submit")] }, + { + intent: "primary", + isDisabled: isBotSubmitDisabled(model) || model.redirectUrisError !== null, + attributes: [h.Type("submit")], + }, [model.isSubmitting ? "Saving..." : "Save Changes"], ), ]), @@ -199,6 +265,8 @@ export const modal = defineModal( scopes: knownScopes(request.scopes), isPublic: request.isPublic, }), + redirectUris: request.installRedirectUris.join("\n"), + redirectUrisError: null, }, }), update, diff --git a/apps/web-foldkit/src/overlay/modal/requests.ts b/apps/web-foldkit/src/overlay/modal/requests.ts index b0c4ce7f3..284b175f5 100644 --- a/apps/web-foldkit/src/overlay/modal/requests.ts +++ b/apps/web-foldkit/src/overlay/modal/requests.ts @@ -12,7 +12,10 @@ export const JoinChannel = Schema.TaggedStruct("JoinChannel", {}) export const EmailInvite = Schema.TaggedStruct("EmailInvite", {}) export const CreateOrganization = Schema.TaggedStruct("CreateOrganization", {}) export const CreateSection = Schema.TaggedStruct("CreateSection", {}) -export const DeleteChannel = Schema.TaggedStruct("DeleteChannel", { channelId: ChannelId, channelName: Schema.String }) +export const DeleteChannel = Schema.TaggedStruct("DeleteChannel", { + channelId: ChannelId, + channelName: Schema.String, +}) export const Feedback = Schema.TaggedStruct("Feedback", {}) export const SetStatus = Schema.TaggedStruct("SetStatus", {}) export const RenameChannel = Schema.TaggedStruct("RenameChannel", { channelId: ChannelId }) @@ -37,11 +40,15 @@ export const EditBot = Schema.TaggedStruct("EditBot", { isPublic: Schema.Boolean, scopes: Schema.Array(Schema.String), allowedIntegrations: Schema.Array(Schema.String), + installRedirectUris: Schema.Array(Schema.String), /** Resolved like `BotAvatar` (`resolveBotAvatarUrl`): the machine user's avatar or null. */ avatarUrl: Schema.NullOr(Schema.String), }) export const InstallBotById = Schema.TaggedStruct("InstallBotById", {}) -export const RegenerateBotToken = Schema.TaggedStruct("RegenerateBotToken", { botId: BotId, botName: Schema.String }) +export const RegenerateBotToken = Schema.TaggedStruct("RegenerateBotToken", { + botId: BotId, + botName: Schema.String, +}) export const DeleteBot = Schema.TaggedStruct("DeleteBot", { botId: BotId, botName: Schema.String }) export const DeleteMessage = Schema.TaggedStruct("DeleteMessage", { messageId: MessageId }) diff --git a/apps/web-foldkit/src/page/bot-install/command.ts b/apps/web-foldkit/src/page/bot-install/command.ts new file mode 100644 index 000000000..34c1e04c3 --- /dev/null +++ b/apps/web-foldkit/src/page/bot-install/command.ts @@ -0,0 +1,70 @@ +import { installRedirectUrl } from "@hazel/domain/bot-install" +import { BotId, OrganizationId } from "@hazel/schema" +import { Effect, Schema } from "effect" +import { Command } from "foldkit" +import { load } from "foldkit/navigation" +import { catchCommon } from "../../data/actions" +import { signInHref } from "../../route" +import { HazelRpc } from "../../rpc" +import { Message } from "./message" + +export const FetchInstallRequest = Command.define("FetchInstallRequest", { + args: { botId: BotId, redirectUri: Schema.String }, + messages: [Message.SucceededFetchRequest, Message.FoundInvalidRequest, Message.FailedFetchRequest], + execute: ({ botId, redirectUri }) => + HazelRpc.use((client) => client("bot.getInstallRequest", { botId, redirectUri })).pipe( + Effect.map((bot) => + Message.SucceededFetchRequest({ + bot: { + id: bot.id, + name: bot.name, + description: bot.description, + avatarUrl: bot.avatarUrl, + creatorName: bot.creatorName, + }, + }), + ), + Effect.catchTags({ + BotNotFoundError: () => Effect.succeed(Message.FoundInvalidRequest()), + BotInstallRedirectMismatchError: () => Effect.succeed(Message.FoundInvalidRequest()), + }), + catchCommon((reason) => Message.FailedFetchRequest({ reason })), + ), +}) + +/** Install, then hand the grant to the bot's redirect URI. */ +export const ApproveInstall = Command.define("ApproveInstall", { + args: { botId: BotId, organizationId: OrganizationId, redirectUri: Schema.String, state: Schema.String }, + messages: [Message.LeftPage, Message.FailedApprove], + execute: ({ botId, organizationId, redirectUri, state }) => + HazelRpc.use((client) => client("bot.authorizeInstall", { botId, organizationId, redirectUri })).pipe( + Effect.flatMap(({ code }) => load(installRedirectUrl(redirectUri, { code, state }))), + Effect.as(Message.LeftPage()), + Effect.catchTags({ + UnauthorizedError: () => Effect.succeed(Message.FailedApprove({ reason: "NotAdmin" })), + BotNotFoundError: () => Effect.succeed(Message.FailedApprove({ reason: "Invalid" })), + BotInstallRedirectMismatchError: () => + Effect.succeed(Message.FailedApprove({ reason: "Invalid" })), + ActiveOrganizationRequiredError: () => + Effect.succeed(Message.FailedApprove({ reason: "Invalid" })), + }), + catchCommon((reason) => Message.FailedApprove({ reason })), + ), +}) + +/** Tell the bot the install was declined, the way OAuth does (`error=access_denied`). */ +export const DeclineInstall = Command.define("DeclineInstall", { + args: { redirectUri: Schema.String, state: Schema.String }, + messages: [Message.LeftPage], + execute: ({ redirectUri, state }) => + load(installRedirectUrl(redirectUri, { error: "access_denied", state })).pipe( + Effect.as(Message.LeftPage()), + ), +}) + +/** Sign in, then come back to this link. */ +export const RedirectToBotInstallSignIn = Command.define("RedirectToBotInstallSignIn", { + args: { returnTo: Schema.String }, + messages: [Message.LeftPage], + execute: ({ returnTo }) => load(signInHref(returnTo)).pipe(Effect.as(Message.LeftPage())), +}) diff --git a/apps/web-foldkit/src/page/bot-install/index.ts b/apps/web-foldkit/src/page/bot-install/index.ts new file mode 100644 index 000000000..be7151efb --- /dev/null +++ b/apps/web-foldkit/src/page/bot-install/index.ts @@ -0,0 +1,13 @@ +import { definePage } from "../contract" +import { Message } from "./message" +import { Model } from "./model" +import { subscriptions } from "./subscription" +import { init, update } from "./update" +import { view } from "./view" + +/** `/oauth/bot-install`: a bot's install link, where an admin picks a workspace and approves. */ +export const page = definePage( + "BotInstall", + { Model, Message }, + { routes: ["BotInstall"], init, update, view, subscriptions }, +) diff --git a/apps/web-foldkit/src/page/bot-install/message.ts b/apps/web-foldkit/src/page/bot-install/message.ts new file mode 100644 index 000000000..a14d4db2d --- /dev/null +++ b/apps/web-foldkit/src/page/bot-install/message.ts @@ -0,0 +1,21 @@ +import { OrganizationId } from "@hazel/schema" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { CommonReason } from "~/lib/error-messages" +import { ApproveFailure, BotSummary, InstallTarget } from "./model" + +export const Message = defineMessageUnion({ + SucceededFetchRequest: { bot: BotSummary }, + FoundInvalidRequest: {}, + FailedFetchRequest: { reason: CommonReason }, + ClickedRetryRequest: {}, + UpdatedTargets: { targets: Schema.Array(InstallTarget) }, + SelectedOrganization: { organizationId: OrganizationId }, + ClickedSignIn: {}, + ClickedApprove: {}, + ClickedCancel: {}, + FailedApprove: { reason: ApproveFailure }, + /** The browser is loading the redirect or sign-in. */ + LeftPage: {}, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/bot-install/model.ts b/apps/web-foldkit/src/page/bot-install/model.ts new file mode 100644 index 000000000..5fe990d5f --- /dev/null +++ b/apps/web-foldkit/src/page/bot-install/model.ts @@ -0,0 +1,66 @@ +import { BotId, OrganizationId } from "@hazel/schema" +import { Schema } from "effect" +import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" + +/** What the consent page shows about the bot asking to be installed. */ +export const BotSummary = Schema.Struct({ + id: BotId, + name: Schema.String, + description: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + creatorName: Schema.String, +}) +export type BotSummary = typeof BotSummary.Type + +/** An organization the signed-in user could install into (they administer it). */ +export const InstallTarget = Schema.Struct({ + id: OrganizationId, + name: Schema.String, + logoUrl: Schema.NullOr(Schema.String), +}) +export type InstallTarget = typeof InstallTarget.Type + +/** The link's own parameters, once they check out. */ +export const Request = Schema.Struct({ + botId: BotId, + redirectUri: Schema.String, + state: Schema.String, +}) +export type Request = typeof Request.Type + +/** + * `bot.getInstallRequest`. `Invalid` is a link that cannot be honoured: parameters missing, an + * unknown bot, or a redirect the bot never registered. It never sends the browser anywhere. + */ +export const Lookup = defineTaggedUnion({ + Loading: {}, + Loaded: { bot: BotSummary }, + Invalid: {}, + Failed: { reason: CommonReason }, +}) +export type Lookup = typeof Lookup.Type + +export const ApproveFailure = Schema.Union([Schema.Literals(["NotAdmin", "Invalid"]), CommonReason]) +export type ApproveFailure = typeof ApproveFailure.Type + +/** Approving (or cancelling) stays in flight until the browser leaves for the redirect. */ +export const Approval = defineTaggedUnion({ + Idle: {}, + Approving: {}, + Leaving: {}, + Failed: { reason: ApproveFailure }, +}) +export type Approval = typeof Approval.Type + +export const Model = Schema.Struct({ + /** This link, to come back to after signing in. */ + returnTo: Schema.String, + request: Schema.NullOr(Request), + lookup: Lookup, + /** `null` until the membership query answers. */ + targets: Schema.NullOr(Schema.Array(InstallTarget)), + selectedOrganizationId: Schema.NullOr(OrganizationId), + approval: Approval, +}) +export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/bot-install/scene.test.ts b/apps/web-foldkit/src/page/bot-install/scene.test.ts new file mode 100644 index 000000000..1fa7bd426 --- /dev/null +++ b/apps/web-foldkit/src/page/bot-install/scene.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import type { BotId, OrganizationId } from "@hazel/schema" +import { Option } from "effect" +import * as Scene from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { makeShared, pageScene } from "../../test/pages-fixtures" +import { ApproveInstall, DeclineInstall, RedirectToBotInstallSignIn } from "./command" +import { Message } from "./message" +import { Approval, Lookup, type Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +/** A bot's install link: pick a workspace you administer and approve, or cancel back to the app. */ + +const botId = "0b5a1f7e-3c55-4d0e-9a51-0b7e6b2f0c11" as BotId +const acme = "7c9e6679-7425-40de-944b-e07fc1f90ae7" as OrganizationId +const beta = "5f0c2b8e-1d4a-4b6c-8e9f-2a3b4c5d6e7f" as OrganizationId +const redirectUri = "https://api.maple.dev/oauth/chat/hazel/callback" + +const loaded = (overrides: Partial = {}): Model => ({ + returnTo: "/oauth/bot-install?bot_id=x", + request: { botId, redirectUri, state: "nonce" }, + lookup: Lookup.Loaded({ + bot: { id: botId, name: "Maple", description: null, avatarUrl: null, creatorName: "Maple Labs" }, + }), + targets: [ + { id: acme, name: "Acme", logoUrl: null }, + { id: beta, name: "Beta", logoUrl: null }, + ], + selectedOrganizationId: acme, + approval: Approval.Idle(), + ...overrides, +}) +const signedOut = makeShared({ auth: "SignedOut", currentUser: null, organization: null, member: null }) + +describe("bot install page", () => { + test("the admin picks a workspace and installs; a failure says why", () => { + Scene.scene( + pageScene(update, view, makeShared()), + Scene.given(loaded()), + Scene.expect(Scene.role("heading", { name: "Install Maple" })).toExist(), + Scene.click(Scene.role("radio", { name: "Beta" })), + Scene.click(Scene.role("button", { name: "Install" })), + Scene.Command.expectExact( + ApproveInstall({ botId, organizationId: beta, redirectUri, state: "nonce" }), + ), + Scene.expect(Scene.role("button", { name: "Installing..." })).toBeDisabled(), + Scene.Command.resolve(ApproveInstall, Message.FailedApprove({ reason: "NotAdmin" })), + Scene.expect(Scene.role("alert")).toHaveText( + "Only an owner or admin can install apps in this workspace", + ), + ) + }) + + test("cancel tells the app it was declined", () => { + Scene.scene( + pageScene(update, view, makeShared()), + Scene.given(loaded()), + Scene.click(Scene.role("button", { name: "Cancel" })), + Scene.Command.expectExact(DeclineInstall({ redirectUri, state: "nonce" })), + Scene.Command.resolve(DeclineInstall, Message.LeftPage()), + ) + }) + + test("without a workspace to administer there is nothing to install into", () => { + Scene.scene( + pageScene(update, view, makeShared()), + Scene.given(loaded({ targets: [], selectedOrganizationId: null })), + Scene.expect(Scene.role("alert")).toHaveText( + "You need to be an owner or admin of a workspace to install apps.", + ), + Scene.expect(Scene.role("button", { name: "Install" })).toBeDisabled(), + ) + }) + + test("signed out, it sends the visitor to sign in and back", () => { + Scene.scene( + pageScene(update, view, signedOut), + Scene.given(loaded()), + Scene.click(Scene.role("button", { name: "Sign in to continue" })), + Scene.Command.expectExact( + RedirectToBotInstallSignIn({ returnTo: "/oauth/bot-install?bot_id=x" }), + ), + Scene.Command.resolve(RedirectToBotInstallSignIn, Message.LeftPage()), + ) + }) + + test("signed out, a lookup that needs a session offers sign-in instead of a dead retry", () => { + Scene.scene( + pageScene(update, view, signedOut), + Scene.given(loaded({ lookup: Lookup.Failed({ reason: "SignedOut" }) })), + Scene.expect(Scene.role("heading", { name: "Sign in to install this app" })).toExist(), + Scene.expect(Scene.role("button", { name: "Try again" })).toBeAbsent(), + Scene.click(Scene.role("button", { name: "Sign in to continue" })), + Scene.Command.expectExact( + RedirectToBotInstallSignIn({ returnTo: "/oauth/bot-install?bot_id=x" }), + ), + Scene.Command.resolve(RedirectToBotInstallSignIn, Message.LeftPage()), + ) + }) + + test("a link missing its parameters, or one the server refuses, never redirects", () => { + const started = init({ + _tag: "BotInstall", + botId: Option.some("not-a-uuid"), + redirectUri: Option.some(redirectUri), + state: Option.some("nonce"), + }) + expect(started.commands ?? []).toEqual([]) + Scene.scene( + pageScene(update, view, makeShared()), + Scene.given(started.model), + Scene.expect(Scene.role("heading", { name: "This install link isn't valid" })).toExist(), + ) + const refused = update(loaded({ lookup: Lookup.Invalid() }), Message.ClickedCancel()) + expect(refused.commands ?? []).toEqual([]) + }) +}) diff --git a/apps/web-foldkit/src/page/bot-install/subscription.ts b/apps/web-foldkit/src/page/bot-install/subscription.ts new file mode 100644 index 000000000..4784df289 --- /dev/null +++ b/apps/web-foldkit/src/page/bot-install/subscription.ts @@ -0,0 +1,55 @@ +import { UserId } from "@hazel/schema" +import { eq } from "@tanstack/db" +import { Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import { organizationCollection, organizationMemberCollection } from "~/db/collections" +import { liveQueryStream } from "../../data/live-query" +import type { PageSubscriptionInput } from "../contract" +import { Message } from "./message" +import type { InstallTarget, Model } from "./model" + +interface TargetRow { + readonly member: { readonly role: "owner" | "admin" | "member" } + readonly org: { + readonly id: InstallTarget["id"] + readonly name: string + readonly logoUrl?: string | null + } +} + +/** The organizations the user administers: only an owner or admin can install (`BotPolicy.canInstall`). */ +export const subscriptions = Subscription.make, Message>()((entry) => ({ + targets: entry( + { userId: Schema.NullOr(UserId) }, + { + modelToDependencies: ({ shared }) => ({ userId: shared.currentUser?.id ?? null }), + dependenciesToStream: ({ userId }) => + userId === null + ? Stream.empty + : liveQueryStream( + (q) => + q + .from({ member: organizationMemberCollection }) + .innerJoin({ org: organizationCollection }, ({ member, org }) => + eq(member.organizationId, org.id), + ) + .where(({ member }) => eq(member.userId, userId)) + .orderBy(({ member }) => member.joinedAt, "asc") + .select(({ member, org }) => ({ member, org })), + (rows) => + Message.UpdatedTargets({ + targets: rows + .filter( + ({ member }) => + member.role === "owner" || member.role === "admin", + ) + .map(({ org }) => ({ + id: org.id, + name: org.name, + logoUrl: org.logoUrl ?? null, + })), + }), + ), + }, + ), +})) diff --git a/apps/web-foldkit/src/page/bot-install/update.ts b/apps/web-foldkit/src/page/bot-install/update.ts new file mode 100644 index 000000000..5fc93c442 --- /dev/null +++ b/apps/web-foldkit/src/page/bot-install/update.ts @@ -0,0 +1,120 @@ +import { BotId } from "@hazel/schema" +import { Option, Schema } from "effect" +import { modifyFields } from "foldkit/struct" +import { hrefOf, type RouteOf } from "../../route" +import type { PageReturn } from "../contract" +import { ApproveInstall, DeclineInstall, FetchInstallRequest, RedirectToBotInstallSignIn } from "./command" +import { Message } from "./message" +import { Approval, Lookup, type Model, type Request } from "./model" + +type Return = PageReturn + +const decodeBotId = Schema.decodeUnknownOption(BotId) + +/** The link's parameters; `null` when one is missing or malformed. */ +const requestOf = (route: RouteOf<"BotInstall">): Request | null => { + const botId = Option.flatMap(route.botId, decodeBotId) + if (Option.isNone(botId) || Option.isNone(route.redirectUri) || Option.isNone(route.state)) return null + return { botId: botId.value, redirectUri: route.redirectUri.value, state: route.state.value } +} + +export const init = (route: RouteOf<"BotInstall">): Return => { + const request = requestOf(route) + const base = { + returnTo: hrefOf(route), + request, + targets: null, + selectedOrganizationId: null, + approval: Approval.Idle(), + } + return request === null + ? { model: { ...base, lookup: Lookup.Invalid() } } + : { + model: { ...base, lookup: Lookup.Loading() }, + commands: [FetchInstallRequest({ botId: request.botId, redirectUri: request.redirectUri })], + } +} + +const isBusy = (model: Model) => model.approval._tag === "Approving" || model.approval._tag === "Leaving" + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + SucceededFetchRequest: ({ bot }) => ({ + model: modifyFields(model, { lookup: () => Lookup.Loaded({ bot }) }), + }), + FoundInvalidRequest: () => ({ model: modifyFields(model, { lookup: () => Lookup.Invalid() }) }), + FailedFetchRequest: ({ reason }) => ({ + model: modifyFields(model, { lookup: () => Lookup.Failed({ reason }) }), + }), + ClickedRetryRequest: () => + model.lookup._tag !== "Failed" || model.request === null + ? { model } + : { + model: modifyFields(model, { lookup: () => Lookup.Loading() }), + commands: [ + FetchInstallRequest({ + botId: model.request.botId, + redirectUri: model.request.redirectUri, + }), + ], + }, + UpdatedTargets: ({ targets }) => { + const [first] = targets + // Keep the pick while it is still a target; otherwise the first one. + const selected = + model.selectedOrganizationId !== null && + targets.some((target) => target.id === model.selectedOrganizationId) + ? model.selectedOrganizationId + : (first?.id ?? null) + return { + model: modifyFields(model, { + targets: () => targets, + selectedOrganizationId: () => selected, + }), + } + }, + SelectedOrganization: ({ organizationId }) => + isBusy(model) + ? { model } + : { + model: modifyFields(model, { + selectedOrganizationId: () => organizationId, + approval: () => Approval.Idle(), + }), + }, + ClickedSignIn: () => ({ + model: modifyFields(model, { approval: () => Approval.Leaving() }), + commands: [RedirectToBotInstallSignIn({ returnTo: model.returnTo })], + }), + ClickedApprove: () => + isBusy(model) || model.request === null || model.selectedOrganizationId === null + ? { model } + : { + model: modifyFields(model, { approval: () => Approval.Approving() }), + commands: [ + ApproveInstall({ + botId: model.request.botId, + organizationId: model.selectedOrganizationId, + redirectUri: model.request.redirectUri, + state: model.request.state, + }), + ], + }, + // Only once the request checked out: an unverified redirect is never followed. + ClickedCancel: () => + isBusy(model) || model.request === null || model.lookup._tag !== "Loaded" + ? { model } + : { + model: modifyFields(model, { approval: () => Approval.Leaving() }), + commands: [ + DeclineInstall({ + redirectUri: model.request.redirectUri, + state: model.request.state, + }), + ], + }, + FailedApprove: ({ reason }) => ({ + model: modifyFields(model, { approval: () => Approval.Failed({ reason }) }), + }), + LeftPage: () => ({ model }), + }) diff --git a/apps/web-foldkit/src/page/bot-install/view.ts b/apps/web-foldkit/src/page/bot-install/view.ts new file mode 100644 index 000000000..f5cf2cc97 --- /dev/null +++ b/apps/web-foldkit/src/page/bot-install/view.ts @@ -0,0 +1,179 @@ +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { commonMessage, failureMessage, type UserErrorMessage } from "~/lib/error-messages" +import { AppRoute, hrefOf } from "../../route" +import { avatar } from "../../ui/avatar" +import { button } from "../../ui/button" +import { loader } from "../../ui/loader" +import { authError, authFrame, authHeading } from "../auth/layout" +import { isSessionLoading, type PageViewInputs, type Shared } from "../contract" +import { initialsOf } from "../initials" +import { Message } from "./message" +import { type ApproveFailure, type BotSummary, type InstallTarget, Lookup, type Model } from "./model" + +const homeLink = (h: HtmlBuilder): Html => + h.a( + [h.Class("text-fg text-sm underline underline-offset-2"), h.Href(hrefOf(AppRoute.Root()))], + ["Go to Hazel"], + ) + +const approveFailureMessage = (reason: ApproveFailure): UserErrorMessage => + failureMessage(reason, { + NotAdmin: { title: "Only an owner or admin can install apps in this workspace" }, + Invalid: { title: "This install link is no longer valid", description: "Start again from the app." }, + }) + +const targetOption = (h: HtmlBuilder, target: InstallTarget, isSelected: boolean): Html => + h.keyed("button")( + target.id, + [ + h.Type("button"), + h.Role("radio"), + h.AriaChecked(isSelected), + h.AriaLabel(target.name), + h.OnClick(Message.SelectedOrganization({ organizationId: target.id })), + h.Class( + `flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${ + isSelected ? "border-primary bg-primary/5" : "border-border hover:bg-secondary/50" + }`, + ), + ], + [ + avatar(h, { src: target.logoUrl, initials: initialsOf(target.name), className: "size-8" }), + h.span([h.Class("flex-1 truncate font-medium text-fg text-sm")], [target.name]), + ], + ) + +const targetsView = (h: HtmlBuilder, model: Model): ReadonlyArray => { + if (model.targets === null) return [loader(h, { className: "size-5" })] + if (model.targets.length === 0) + return [ + h.p( + [h.Class("text-muted-fg text-sm"), h.Role("alert")], + ["You need to be an owner or admin of a workspace to install apps."], + ), + ] + return [ + h.p([h.Class("mb-2 font-medium text-fg text-sm")], ["Install in"]), + h.div( + [h.Class("flex flex-col gap-2"), h.Role("radiogroup"), h.AriaLabel("Workspace")], + model.targets.map((target) => + targetOption(h, target, target.id === model.selectedOrganizationId), + ), + ), + ] +} + +const actions = (h: HtmlBuilder, model: Model, shared: Shared): ReadonlyArray => { + const isLeaving = model.approval._tag === "Leaving" + if (shared.currentUser === null) + return [ + button( + h, + { + intent: "primary", + className: "w-full", + isDisabled: isLeaving, + onPress: Message.ClickedSignIn(), + }, + ["Sign in to continue"], + ), + ] + const isApproving = model.approval._tag === "Approving" + return [ + h.div( + [h.Class("mt-6 flex flex-col gap-2")], + [ + button( + h, + { + intent: "primary", + className: "w-full", + isDisabled: isApproving || isLeaving || model.selectedOrganizationId === null, + onPress: Message.ClickedApprove(), + }, + isApproving ? [loader(h, { className: "size-4" }), "Installing..."] : ["Install"], + ), + button( + h, + { + intent: "plain", + className: "w-full", + isDisabled: isApproving || isLeaving, + onPress: Message.ClickedCancel(), + }, + ["Cancel"], + ), + ], + ), + ] +} + +const request = ( + h: HtmlBuilder, + model: Model, + bot: BotSummary, + shared: Shared, +): ReadonlyArray => [ + avatar(h, { src: bot.avatarUrl, initials: initialsOf(bot.name), size: "xl", className: "mb-6" }), + authHeading( + h, + `Install ${bot.name}`, + bot.description ?? `${bot.name} by ${bot.creatorName} wants to join a workspace.`, + ), + h.p( + [h.Class("mb-6 text-muted-fg text-xs")], + [ + `${bot.name} will join the workspace as a member: it can read and post in its public channels, and in the private channels and threads it is added to. Built by ${bot.creatorName}.`, + ], + ), + ...(shared.currentUser === null ? [] : targetsView(h, model)), + ...actions(h, model, shared), + ...(model.approval._tag === "Failed" + ? [authError(h, approveFailureMessage(model.approval.reason)) ?? h.empty] + : []), +] + +export const view = Submodel.defineView((model, { shared }, h) => + authFrame( + h, + shared.nowMs, + isSessionLoading(shared) + ? [loader(h, { className: "size-6" })] + : Lookup.match(model.lookup, { + Loading: () => [loader(h, { className: "size-6" })], + Loaded: ({ bot }) => request(h, model, bot, shared), + Invalid: () => [ + authHeading( + h, + "This install link isn't valid", + "It may be incomplete, or the app it names doesn't accept installs from here. Start again from the app.", + ), + homeLink(h), + ], + // Looking the app up needs a session: a signed-out visitor signs in and comes back. + Failed: ({ reason }) => + reason === "SignedOut" && shared.currentUser === null + ? [ + authHeading( + h, + "Sign in to install this app", + "You'll come back here afterwards.", + ), + ...actions(h, model, shared), + ] + : [ + authHeading(h, "Couldn't load this app", commonMessage(reason).title), + button( + h, + { + intent: "outline", + className: "w-full", + onPress: Message.ClickedRetryRequest(), + }, + ["Try again"], + ), + ], + }), + ), +) diff --git a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts index 625953dff..b2c30fdba 100644 --- a/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts +++ b/apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts @@ -31,6 +31,7 @@ import { DownloadImage, GenerateThreadChannelId, JoinChannel, + PressEmbedAction, ToggleReaction, } from "../message-actions" import { @@ -59,6 +60,55 @@ describe("reading", () => { }) }) +describe("bot buttons", () => { + test("a button on a bot's message tells the bot; a stale one says so", () => { + const base = loadedModel() + const withButtons = { + ...base, + messages: base.messages.map((message) => + message.id === graceMessageId + ? { + ...message, + hasEmbeds: true, + embeds: [ + { + description: "Restart checkout?", + actions: [ + { id: "approve:call_1", label: "Approve", style: "primary" as const }, + { id: "deny:call_1", label: "Deny", disabled: true }, + ], + }, + ], + } + : message, + ), + } + Scene.scene( + channelScene, + Scene.given(derivedModel(withButtons)), + mountedChannel(), + Scene.expect(Scene.role("button", { name: "Deny" })).toBeDisabled(), + Scene.click(Scene.role("button", { name: "Approve" })), + Scene.Command.expectExact( + PressEmbedAction({ channelId, messageId: graceMessageId, actionId: "approve:call_1" }), + ), + Scene.Command.resolve( + PressEmbedAction, + ActionMessage.FailedPressEmbedAction({ reason: "ActionUnavailable" }), + ), + Scene.expectOutMessage( + PageOutMessage.RequestedToast({ + toast: { + intent: "error", + title: "That button no longer works", + description: "The app may have already handled it.", + }, + }), + ), + ) + }) +}) + describe("join banner", () => { test("a non-member's Join #channel joins the channel", () => { const base = loadedModel() diff --git a/apps/web-foldkit/src/page/chat/channel/model.ts b/apps/web-foldkit/src/page/chat/channel/model.ts index f0dc57e6c..8116c4b25 100644 --- a/apps/web-foldkit/src/page/chat/channel/model.ts +++ b/apps/web-foldkit/src/page/chat/channel/model.ts @@ -162,6 +162,8 @@ export const Message = defineMessageUnion({ GotActionMessage: { message: ActionMessage }, UpdatedThreadMember: { threadChannelId: ChannelId, memberId: Schema.NullOr(ChannelMemberId) }, ClickedThreadPreview: { threadChannelId: ChannelId, messageId: MessageId }, + /** A button on a bot's message (`MessageEmbed.actions`). */ + ClickedEmbedAction: { channelId: ChannelId, messageId: MessageId, actionId: Schema.String }, ClickedCloseThread: {}, ClickedGenerateThreadName: {}, ClickedRenameThread: {}, diff --git a/apps/web-foldkit/src/page/chat/channel/page.ts b/apps/web-foldkit/src/page/chat/channel/page.ts index a56382a29..db644456b 100644 --- a/apps/web-foldkit/src/page/chat/channel/page.ts +++ b/apps/web-foldkit/src/page/chat/channel/page.ts @@ -443,6 +443,8 @@ export const update = (model: Model, message: Message, shared: Shared | null = n : { model }, ClickedThreadPreview: ({ threadChannelId, messageId }) => Write.openThread(model, threadChannelId, messageId, "Created"), + ClickedEmbedAction: ({ channelId, messageId, actionId }) => + Write.pressEmbedAction(model, { channelId, messageId, actionId }), ClickedCloseThread: () => Write.closeThread(model), ClickedGenerateThreadName: () => Write.generateThreadName(model), ClickedRenameThread: () => diff --git a/apps/web-foldkit/src/page/chat/channel/write.ts b/apps/web-foldkit/src/page/chat/channel/write.ts index fa19a15c9..4a744e862 100644 --- a/apps/web-foldkit/src/page/chat/channel/write.ts +++ b/apps/web-foldkit/src/page/chat/channel/write.ts @@ -25,6 +25,7 @@ import { GenerateThreadName, OpenUrl, PinMessage, + PressEmbedAction, ToggleReaction, UnpinMessage, } from "../message-actions" @@ -383,9 +384,33 @@ const messageNotFoundToast = (reason: MessageActionFailure): ToastRequest => const withToast = (model: Model, request: ToastRequest | null): PageReturn => request === null ? { model } : { model, outMessage: toast(request) } +/** A button on a bot's message: `message.interact`, reported only if it fails. */ +export const pressEmbedAction = ( + model: Model, + press: { readonly channelId: ChannelId; readonly messageId: MessageId; readonly actionId: string }, +): PageReturn => ({ model, commands: actionCommands([PressEmbedAction(press)]) }) + export const handleActionMessage = (model: Model, message: ActionMessage): PageReturn => ActionMessage.match(message, { SucceededToggleReaction: () => ({ model }), + // The bot answers by editing its message; nothing to show until it does. + SucceededPressEmbedAction: () => ({ model }), + FailedPressEmbedAction: ({ reason }) => + withToast( + model, + errorToastOf( + failureMessage(reason, { + MessageNotFound: { + title: "Message not found", + description: "The message no longer exists", + }, + ActionUnavailable: { + title: "That button no longer works", + description: "The app may have already handled it.", + }, + }), + ), + ), FailedToggleReaction: ({ reason }) => withToast(model, messageNotFoundToast(reason)), SucceededPinMessage: () => withToast(model, successToast("Message pinned")), FailedPinMessage: ({ reason }) => withToast(model, messageNotFoundToast(reason)), diff --git a/apps/web-foldkit/src/page/chat/message-actions.ts b/apps/web-foldkit/src/page/chat/message-actions.ts index b57f89fb6..f024d7503 100644 --- a/apps/web-foldkit/src/page/chat/message-actions.ts +++ b/apps/web-foldkit/src/page/chat/message-actions.ts @@ -60,6 +60,13 @@ export type JoinChannelFailure = typeof JoinChannelFailure.Type export const MessageActionFailure = Schema.Union([Schema.Literals(["MessageNotFound"]), CommonReason]) export type MessageActionFailure = typeof MessageActionFailure.Type +/** What can go wrong pressing a button on a bot's message, beyond the shared reasons. */ +export const PressEmbedActionFailure = Schema.Union([ + Schema.Literals(["MessageNotFound", "ActionUnavailable"]), + CommonReason, +]) +export type PressEmbedActionFailure = typeof PressEmbedActionFailure.Type + /** What can go wrong unpinning, beyond the shared reasons. */ export const UnpinMessageFailure = Schema.Union([Schema.Literals(["PinNotFound"]), CommonReason]) export type UnpinMessageFailure = typeof UnpinMessageFailure.Type @@ -84,6 +91,8 @@ export const ActionMessage = defineMessageUnion({ FailedGenerateThreadName: { threadChannelId: ChannelId, reason: GenerateThreadNameFailure }, SucceededJoinChannel: {}, FailedJoinChannel: { reason: JoinChannelFailure }, + SucceededPressEmbedAction: {}, + FailedPressEmbedAction: { reason: PressEmbedActionFailure }, }) export type ActionMessage = typeof ActionMessage.Type /** `ActionMessage` under the Submodel name, so a parent's Got wrapper imports it as `Message`. */ @@ -106,6 +115,23 @@ export const ToggleReaction = Command.define("ToggleReaction", { ), }) +/** A button on a bot's message (`MessageEmbed.actions`): the bot hears about it and answers itself. */ +export const PressEmbedAction = Command.define("PressEmbedAction", { + args: { channelId: ChannelId, messageId: MessageId, actionId: Schema.String }, + messages: [ActionMessage.SucceededPressEmbedAction, ActionMessage.FailedPressEmbedAction], + execute: (args) => + HazelRpc.use((client) => client("message.interact", args)).pipe( + Effect.as(ActionMessage.SucceededPressEmbedAction()), + Effect.catchTags({ + MessageNotFoundError: () => + Effect.succeed(ActionMessage.FailedPressEmbedAction({ reason: "MessageNotFound" })), + MessageActionUnavailableError: () => + Effect.succeed(ActionMessage.FailedPressEmbedAction({ reason: "ActionUnavailable" })), + }), + catchCommon((reason) => ActionMessage.FailedPressEmbedAction({ reason })), + ), +}) + export const PinMessage = Command.define("PinMessage", { args: { messageId: MessageId, channelId: ChannelId, userId: UserId }, messages: [ActionMessage.SucceededPinMessage, ActionMessage.FailedPinMessage], diff --git a/apps/web-foldkit/src/page/chat/row-context.ts b/apps/web-foldkit/src/page/chat/row-context.ts index ee7a31eb1..e1e060180 100644 --- a/apps/web-foldkit/src/page/chat/row-context.ts +++ b/apps/web-foldkit/src/page/chat/row-context.ts @@ -58,6 +58,9 @@ const toOpenThread = (threadChannelId: ChannelId, messageId: MessageId) => const toHtmlRenderMessage = (message: HtmlRender.Message) => Message.GotHtmlRenderMessage({ message }) +const toPressEmbedAction = (channelId: ChannelId, messageId: MessageId, actionId: string) => + Message.ClickedEmbedAction({ channelId, messageId, actionId }) + const toContextMenuMessage = (message: Menu.Message) => toOverlays(Overlays.Message.GotContextMenuMessage({ message })) @@ -129,6 +132,7 @@ const buildIdleContext = ( toReact, toOpenThread, toHtmlRenderMessage, + toPressEmbedAction, contextMenu: (_row, render) => render([], h.empty), avatar: (row: MessageRow) => popoverTrigger(h, { @@ -163,6 +167,7 @@ const buildOwnerContext = (h: H, model: Model, nowMs: number): RowContext contextMenu === null || contextMenu.messageId !== row.message.id ? render([], h.empty) diff --git a/apps/web-foldkit/src/page/registry.ts b/apps/web-foldkit/src/page/registry.ts index 2da2225fe..4824007df 100644 --- a/apps/web-foldkit/src/page/registry.ts +++ b/apps/web-foldkit/src/page/registry.ts @@ -5,6 +5,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import type { Resources } from "../rpc" import type { AppRoute } from "../route" import * as Auth from "./auth" +import * as BotInstall from "./bot-install" import * as ChannelSettingsConnect from "./channel-settings/connect" import * as ChannelSettingsIntegrations from "./channel-settings/integrations" import * as ChannelSettingsOverview from "./channel-settings/overview" @@ -72,6 +73,7 @@ export const pages = [ SelectOrganization.page, Join.page, Invite.page, + BotInstall.page, ChannelSettingsRedirect.page, ChannelSettingsOverview.page, ChannelSettingsConnect.page, diff --git a/apps/web-foldkit/src/page/settings/integrations/marketplace/scene.test.ts b/apps/web-foldkit/src/page/settings/integrations/marketplace/scene.test.ts index 2d2b3827e..00d0c7cf9 100644 --- a/apps/web-foldkit/src/page/settings/integrations/marketplace/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/marketplace/scene.test.ts @@ -42,6 +42,7 @@ const deployBot = { isPublic: true, scopes: ["messages:read"], allowedIntegrations: [], + installRedirectUris: [], avatarUrl: null, installCount: 1200, creatorName: "Ada Lovelace", diff --git a/apps/web-foldkit/src/page/settings/integrations/shared/bots.ts b/apps/web-foldkit/src/page/settings/integrations/shared/bots.ts index 225ddfc49..5c5ec3976 100644 --- a/apps/web-foldkit/src/page/settings/integrations/shared/bots.ts +++ b/apps/web-foldkit/src/page/settings/integrations/shared/bots.ts @@ -13,6 +13,8 @@ export const Bot = Schema.Struct({ isPublic: Schema.Boolean, scopes: Schema.Array(Schema.String), allowedIntegrations: Schema.Array(Schema.String), + /** Where its install link may send a grant (`@hazel/domain/bot-install`). */ + installRedirectUris: Schema.Array(Schema.String), avatarUrl: Schema.NullOr(Schema.String), installCount: Schema.Number, }) @@ -38,6 +40,8 @@ export interface BotRow { readonly isPublic: boolean readonly scopes: ReadonlyArray | null readonly allowedIntegrations: ReadonlyArray | null + /** Absent on a copy from an org that has not migrated yet. */ + readonly installRedirectUris?: ReadonlyArray | null readonly installCount: number readonly user: UserRow } @@ -53,6 +57,7 @@ export const toBot = (row: BotRow): Bot => ({ isPublic: row.isPublic, scopes: row.scopes ?? [], allowedIntegrations: row.allowedIntegrations ?? [], + installRedirectUris: row.installRedirectUris ?? [], avatarUrl: resolveBotAvatarUrl(row), installCount: row.installCount, }) diff --git a/apps/web-foldkit/src/page/settings/integrations/your-apps/scene.test.ts b/apps/web-foldkit/src/page/settings/integrations/your-apps/scene.test.ts index 50553eec1..2ad21195e 100644 --- a/apps/web-foldkit/src/page/settings/integrations/your-apps/scene.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/your-apps/scene.test.ts @@ -52,6 +52,7 @@ describe("your apps", () => { isPublic: bot.isPublic, scopes: bot.scopes, allowedIntegrations: bot.allowedIntegrations, + installRedirectUris: bot.installRedirectUris, avatarUrl: bot.avatarUrl, }, }), diff --git a/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts b/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts index 0146e168d..57748c165 100644 --- a/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts +++ b/apps/web-foldkit/src/page/settings/integrations/your-apps/story.test.ts @@ -17,6 +17,7 @@ const bot = (n: number, name: string): Bot => ({ isPublic: false, scopes: [], allowedIntegrations: [], + installRedirectUris: [], avatarUrl: null, installCount: 0, }) @@ -54,7 +55,12 @@ describe("your apps menu actions", () => { story( update, given(withOpenMenu), - message(Message.GotMenuMessage({ botId: deploy.id, message: Menu.Message.ClickedItem({ key: "regenerate", entries: BOT_MENU_ENTRIES }) })), + message( + Message.GotMenuMessage({ + botId: deploy.id, + message: Menu.Message.ClickedItem({ key: "regenerate", entries: BOT_MENU_ENTRIES }), + }), + ), expectOutMessage( PageOutMessage.RequestedModal({ modal: { _tag: "RegenerateBotToken", botId: deploy.id, botName: "Deploy Bot" }, @@ -68,7 +74,12 @@ describe("your apps menu actions", () => { story( update, given(withOpenMenu), - message(Message.GotMenuMessage({ botId: triage.id, message: Menu.Message.ClickedItem({ key: "delete", entries: BOT_MENU_ENTRIES }) })), + message( + Message.GotMenuMessage({ + botId: triage.id, + message: Menu.Message.ClickedItem({ key: "delete", entries: BOT_MENU_ENTRIES }), + }), + ), expectNoOutMessage(), model((current) => expect(current).toEqual(withOpenMenu)), ) diff --git a/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts b/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts index 4a8ef8fa8..4477ff4b5 100644 --- a/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts +++ b/apps/web-foldkit/src/page/settings/integrations/your-apps/update.ts @@ -34,6 +34,7 @@ const modalFor = (bot: Bot, key: string): ModalRequest | null => isPublic: bot.isPublic, scopes: bot.scopes, allowedIntegrations: bot.allowedIntegrations, + installRedirectUris: bot.installRedirectUris, avatarUrl: bot.avatarUrl, } : key === "regenerate" diff --git a/apps/web-foldkit/src/route.test.ts b/apps/web-foldkit/src/route.test.ts index 81cf3b54d..1156ed654 100644 --- a/apps/web-foldkit/src/route.test.ts +++ b/apps/web-foldkit/src/route.test.ts @@ -220,6 +220,24 @@ describe("hrefOf", () => { ], Join: [[AppRoute.Join({ slug: "hazel" }), "/join/hazel"]], Invite: [[AppRoute.Invite({ token: "abc_DEF-123" }), "/invite/abc_DEF-123"]], + BotInstall: [ + [ + AppRoute.BotInstall({ + botId: Option.some(orgId), + redirectUri: Option.some("https://maple.dev/cb?x=1"), + state: Option.some("s t"), + }), + `/oauth/bot-install?bot_id=${orgId}&redirect_uri=https%3A%2F%2Fmaple.dev%2Fcb%3Fx%3D1&state=s+t`, + ], + [ + AppRoute.BotInstall({ + botId: Option.none(), + redirectUri: Option.none(), + state: Option.none(), + }), + "/oauth/bot-install", + ], + ], AuthDesktop: [[AppRoute.AuthDesktop({ attempt: Option.some("a1") }), "/auth/desktop?attempt=a1"]], Onboarding: [ [AppRoute.Onboarding({ orgId: Option.none(), step: none }), "/onboarding"], diff --git a/apps/web-foldkit/src/route.ts b/apps/web-foldkit/src/route.ts index 55dfba834..b09e8ea71 100644 --- a/apps/web-foldkit/src/route.ts +++ b/apps/web-foldkit/src/route.ts @@ -18,6 +18,12 @@ export const AppRoute = defineRouteUnion({ Join: { slug: Schema.String }, /** The link in an invitation email. */ Invite: { token: Schema.String }, + /** A bot's install link (`@hazel/domain/bot-install`): `?bot_id=&redirect_uri=&state=`. */ + BotInstall: { + botId: Schema.Option(Schema.String), + redirectUri: Schema.Option(Schema.String), + state: Schema.Option(Schema.String), + }, /** `?orgId=&step=` (`validateSearch` in `onboarding/index.tsx`). */ Onboarding: { orgId: Schema.Option(OrganizationId), step: Schema.Option(Schema.String) }, OnboardingSetupOrganization: { orgId: Schema.Option(OrganizationId) }, @@ -83,6 +89,12 @@ const RegisterQuery = Schema.Struct({ }) const ContinueQuery = Schema.Struct({ provider: Schema.OptionFromOptional(Schema.String) }) const DesktopQuery = Schema.Struct({ attempt: Schema.OptionFromOptional(Schema.String) }) +/** Decoded by the page: a malformed link is a page state, not a 404. */ +const BotInstallQuery = Schema.Struct({ + botId: Schema.OptionFromOptional(Schema.String), + redirectUri: Schema.OptionFromOptional(Schema.String), + state: Schema.OptionFromOptional(Schema.String), +}).pipe(Schema.encodeKeys({ botId: "bot_id", redirectUri: "redirect_uri" })) const OrgIdQuery = Schema.Struct({ orgId: Schema.OptionFromOptional(OrganizationId) }) const OnboardingQuery = Schema.Struct({ orgId: Schema.OptionFromOptional(OrganizationId), @@ -136,6 +148,12 @@ const routers = { ), Join: pipe(literal("join"), slash(string("slug")), Route.mapTo(AppRoute.Join)), Invite: pipe(literal("invite"), slash(string("token")), Route.mapTo(AppRoute.Invite)), + BotInstall: pipe( + literal("oauth"), + slash(literal("bot-install")), + query(BotInstallQuery), + Route.mapTo(AppRoute.BotInstall), + ), Onboarding: pipe(literal("onboarding"), query(OnboardingQuery), Route.mapTo(AppRoute.Onboarding)), OnboardingSetupOrganization: pipe( literal("onboarding"), @@ -237,6 +255,7 @@ export const urlToAppRoute = Route.parseUrlWithFallback( routers.AuthDesktop, routers.Join, routers.Invite, + routers.BotInstall, routers.Onboarding, routers.OnboardingSetupOrganization, routers.SelectOrganization, @@ -308,6 +327,7 @@ export const isPublicRoute = AppRoute.isAnyOf([ "AuthDesktop", "Join", "Invite", + "BotInstall", "NotFound", ]) @@ -398,6 +418,7 @@ export const titleOf = (route: AppRoute): string => { SignUp: () => "Sign up", Join: () => "Join", Invite: () => "Invitation", + BotInstall: () => "Install an app", Onboarding: () => "Welcome", OnboardingSetupOrganization: () => "Welcome", SelectOrganization: () => "Select a workspace", diff --git a/apps/web-foldkit/src/test/pages-integrations-fixtures.ts b/apps/web-foldkit/src/test/pages-integrations-fixtures.ts index 758f71ee2..a0619ff77 100644 --- a/apps/web-foldkit/src/test/pages-integrations-fixtures.ts +++ b/apps/web-foldkit/src/test/pages-integrations-fixtures.ts @@ -39,6 +39,7 @@ export const bot: Bot = { isPublic: true, scopes: ["messages:write"], allowedIntegrations: [], + installRedirectUris: [], avatarUrl: null, installCount: 12, } diff --git a/libs/bot-sdk/examples/README.md b/libs/bot-sdk/examples/README.md index 0e7936a89..2acbdbf4d 100644 --- a/libs/bot-sdk/examples/README.md +++ b/libs/bot-sdk/examples/README.md @@ -1,6 +1,6 @@ -# @hazel/bot-sdk Examples +# @hazel-chat/bot-sdk Examples -This directory contains example bots demonstrating how to use the `@hazel/bot-sdk` package. +This directory contains example bots demonstrating how to use the `@hazel-chat/bot-sdk` package. ## Available Examples @@ -33,7 +33,7 @@ Before running any example, you need: 1. **Bot Token**: Create a bot in your organization and get its authentication token 2. **Organization ID**: The ID of the organization where your bot will operate -3. **Gateway URL**: The URL of your bot gateway service (defaults to `http://localhost:3034`) +3. **Gateway URL**: The URL of your bot gateway service (defaults to `http://localhost:3004`) ### Running an Example @@ -54,7 +54,7 @@ Before running any example, you need: ``` BOT_TOKEN=your_bot_token_here BACKEND_URL=http://localhost:3003 - GATEWAY_URL=http://localhost:3034 + GATEWAY_URL=http://localhost:3004 ``` 4. **Install dependencies** (if not already installed from the workspace root): @@ -183,4 +183,4 @@ Found a bug or have an idea for a new example? Please open an issue or submit a ## License -These examples are part of the @hazel/bot-sdk package and follow the same license. +These examples are part of the @hazel-chat/bot-sdk package and follow the same license. diff --git a/libs/bot-sdk/examples/simple-echo-bot/.env.example b/libs/bot-sdk/examples/simple-echo-bot/.env.example index 22252e7d9..3a5303af5 100644 --- a/libs/bot-sdk/examples/simple-echo-bot/.env.example +++ b/libs/bot-sdk/examples/simple-echo-bot/.env.example @@ -8,5 +8,5 @@ BACKEND_URL=http://localhost:3003 # Gateway URL (optional, defaults to https://bot-gateway.hazel.sh in production) # Inbound bot events are delivered over websocket from the gateway service -GATEWAY_URL=http://localhost:3034 +GATEWAY_URL=http://localhost:3004 diff --git a/libs/bot-sdk/examples/simple-echo-bot/index.ts b/libs/bot-sdk/examples/simple-echo-bot/index.ts index 5b26e889a..cfa49b400 100644 --- a/libs/bot-sdk/examples/simple-echo-bot/index.ts +++ b/libs/bot-sdk/examples/simple-echo-bot/index.ts @@ -3,7 +3,7 @@ /** * Simple Echo Bot Example * - * This example demonstrates the basic usage of the @hazel/bot-sdk. + * This example demonstrates the basic usage of the @hazel-chat/bot-sdk. * It listens for new messages and responds with an echo. * * Features demonstrated: @@ -64,7 +64,7 @@ const runtime = createHazelBot({ botToken, // For local development, override the URLs: backendUrl: process.env.BACKEND_URL ?? "http://localhost:3003", - gatewayUrl: process.env.GATEWAY_URL ?? "http://localhost:3034", + gatewayUrl: process.env.GATEWAY_URL ?? "http://localhost:3004", // Pass the typesafe command group commands, }) diff --git a/libs/bot-sdk/examples/simple-echo-bot/package.json b/libs/bot-sdk/examples/simple-echo-bot/package.json index 318d61957..226eb455a 100644 --- a/libs/bot-sdk/examples/simple-echo-bot/package.json +++ b/libs/bot-sdk/examples/simple-echo-bot/package.json @@ -2,7 +2,7 @@ "name": "simple-echo-bot-example", "version": "1.0.0", "private": true, - "description": "Simple echo bot example using @hazel/bot-sdk", + "description": "Simple echo bot example using @hazel-chat/bot-sdk", "type": "module", "scripts": { "start": "bun run index.ts", diff --git a/libs/bot-sdk/src/hazel-bot-sdk.test.ts b/libs/bot-sdk/src/hazel-bot-sdk.test.ts index 03470852b..645cc7e95 100644 --- a/libs/bot-sdk/src/hazel-bot-sdk.test.ts +++ b/libs/bot-sdk/src/hazel-bot-sdk.test.ts @@ -261,6 +261,91 @@ describe("HazelBotClient durable gateway", () => { }), )) + it("skips an event it cannot read and still handles and acknowledges the rest of the batch", () => + Effect.runPromise( + Effect.gen(function* () { + const handledArgsRef = yield* Ref.make>([]) + const savedOffsetsRef = yield* Ref.make>([]) + const sentFramesRef = yield* Ref.make>([]) + const originalWebSocket = globalThis.WebSocket + + server.use( + http.post(`${BACKEND_URL}/bot-commands/sync`, async () => + HttpResponse.json({ syncedCount: 1 }), + ), + http.patch(`${BACKEND_URL}/bot-commands/settings`, async () => + HttpResponse.json({ success: true }), + ), + ) + + globalThis.WebSocket = FakeWebSocket as any + FakeWebSocket.onCreate = (socket) => { + socket.emitServerFrame({ + op: "HELLO", + heartbeatIntervalMs: 60_000, + }) + } + FakeWebSocket.onSend = (socket, frame) => { + Ref.update(sentFramesRef, (frames) => [...frames, frame.op]).pipe(Effect.runSync) + if (frame.op === "IDENTIFY") { + socket.emitServerFrame({ + op: "READY", + sessionId: "session-1", + resumed: false, + resumeOffset: "now", + }) + socket.emitServerFrame({ + op: "DISPATCH", + sessionId: "session-1", + events: [ + { ...commandEnvelope, eventType: "something.added.later", payload: {} }, + { ...commandEnvelope, payload: { nope: true } }, + commandEnvelope, + ], + nextOffset: "1", + }) + } + if (frame.op === "ACK") { + socket.close() + } + } + + const TestLayer = makeHazelBotLayer({ + commands: CommandGroup.make(EchoCommand), + sessionStore: { + load: () => Effect.succeed(null), + save: (_botId, offset) => + Ref.update(savedOffsetsRef, (offsets) => [...offsets, offset]).pipe( + Effect.asVoid, + ), + }, + }) + + yield* Effect.gen(function* () { + const bot = yield* HazelBotClient + yield* bot.onCommand(EchoCommand, (ctx) => + Ref.update(handledArgsRef, (handled) => [...handled, ctx.args.text]).pipe( + Effect.asVoid, + ), + ) + yield* bot.start + yield* Effect.sleep(Duration.millis(100)) + + expect(yield* Ref.get(handledArgsRef)).toEqual(["hello"]) + expect(yield* Ref.get(savedOffsetsRef)).toEqual(["1"]) + expect(yield* Ref.get(sentFramesRef)).toContain("ACK") + }).pipe( + Effect.scoped, + Effect.provide(TestLayer), + Effect.ensuring( + Effect.sync(() => { + globalThis.WebSocket = originalWebSocket + }), + ), + ) + }), + )) + it("does not advance the offset when command handling fails", () => Effect.runPromise( Effect.gen(function* () { diff --git a/libs/bot-sdk/src/hazel-bot-sdk.ts b/libs/bot-sdk/src/hazel-bot-sdk.ts index c8cbdaca7..6a56514f8 100644 --- a/libs/bot-sdk/src/hazel-bot-sdk.ts +++ b/libs/bot-sdk/src/hazel-bot-sdk.ts @@ -23,8 +23,12 @@ import { BotGatewayEnvelope, BotGatewayHeartbeatFrame, BotGatewayHelloFrame, + BotGatewayReadyFrame, BotGatewayResumeFrame, - BotGatewayServerFrame, + BotGatewayDispatchFrame, + BotGatewayHeartbeatAckFrame, + BotGatewayReconnectFrame, + BotGatewayInvalidSessionFrame, isTemporaryActorServiceError, } from "@hazel/domain" import type { IntegrationConnection } from "@hazel/domain/models" @@ -109,6 +113,25 @@ const GATEWAY_SESSION_ID_STATE_KEY = "gateway_session_id" * Internal configuration context for HazelBotClient * Contains commands to sync and backend URL for HTTP calls */ + +/** + * `BotGatewayServerFrame` with a DISPATCH batch's events left undecoded, so each envelope is read + * on its own (`readableEnvelopes`) rather than the whole frame failing over one. + */ +const LenientServerFrame = Schema.Union([ + BotGatewayHelloFrame, + BotGatewayReadyFrame, + Schema.Struct({ ...BotGatewayDispatchFrame.fields, events: Schema.Array(Schema.Unknown) }), + BotGatewayHeartbeatAckFrame, + BotGatewayReconnectFrame, + BotGatewayInvalidSessionFrame, +]) +const decodeServerFrame = Schema.decodeUnknownSync(LenientServerFrame) +const decodeEnvelope = Schema.decodeUnknownOption(BotGatewayEnvelope) +const decodeEventType = Schema.decodeUnknownOption(Schema.Struct({ eventType: Schema.String })) +const eventTypeOf = (event: unknown): string => + Option.match(decodeEventType(event), { onNone: () => "unknown", onSome: (decoded) => decoded.eventType }) + export interface HazelBotRuntimeConfig = CommandGroup> { readonly backendUrl: string readonly gatewayUrl: string @@ -598,6 +621,23 @@ export class HazelBotClient extends Context.Service()("HazelBotC } }) + /** + * The batch's envelopes this SDK can read. One it cannot — an event type added to Hazel after + * this version, a field it does not know how to decode — is logged and skipped, and the batch + * is still acknowledged: failing the whole frame would redeliver the same batch on every + * reconnect and stall the bot for good. + */ + const readableEnvelopes = (events: ReadonlyArray) => + Effect.forEach(events, (event) => + Option.match(decodeEnvelope(event), { + onSome: (envelope) => Effect.succeed([envelope]), + onNone: () => + Effect.logWarning("Skipping a gateway event this SDK cannot read", { + eventType: eventTypeOf(event), + }).pipe(Effect.as([])), + }), + ).pipe(Effect.map((batches) => batches.flat())) + const processGatewayBatch = ( envelopes: ReadonlyArray>, ) => @@ -742,11 +782,9 @@ export class HazelBotClient extends Context.Service()("HazelBotC socket.addEventListener("message", (event) => { const payload = normalizeSocketPayload(event.data) - let frame: Schema.Schema.Type + let frame: Schema.Schema.Type try { - frame = Schema.decodeUnknownSync(BotGatewayServerFrame)( - JSON.parse(payload), - ) + frame = decodeServerFrame(JSON.parse(payload)) } catch (error) { socket.close(1011, "invalid_gateway_frame") finish( @@ -820,7 +858,9 @@ export class HazelBotClient extends Context.Service()("HazelBotC case "DISPATCH": { Effect.runPromiseWith(services)( Effect.gen(function* () { - yield* processGatewayBatch(frame.events) + yield* processGatewayBatch( + yield* readableEnvelopes(frame.events), + ) yield* gatewaySessionStore.save( authContext.botId as BotId, frame.nextOffset, @@ -1472,17 +1512,23 @@ export class HazelBotClient extends Context.Service()("HazelBotC /** * Ensure a thread exists on a message and return it. * + * Idempotent: asking again for the same message answers with the same thread. + * * @param messageId - The message to create a thread on - * @param channelId - Deprecated/unused. Kept for backwards compatibility. + * @param channelId - The message's channel (the thread's parent). Required: the thread + * is linked to the message in that channel. + * @param organizationId - The channel's organization, when the bot knows it (a message + * event's `partitionKey` names it). Optional: Hazel resolves it from the channel. * @returns The thread channel data (use `.id` as the thread's ChannelId) * * @throws MessageNotFoundError if the message doesn't exist */ - createThread: (messageId: MessageId, channelId: ChannelId) => + createThread: (messageId: MessageId, channelId: ChannelId, organizationId?: OrganizationId) => rpc["channel.createThread"]({ messageId, // The message lives in the parent's ChannelObject; the handler needs it. channelId, + ...(organizationId === undefined ? {} : { organizationId }), }).pipe( Effect.timeout(Duration.seconds(15)), Effect.tapCause((cause) => diff --git a/packages/backend-core/src/repositories/bot-repo.ts b/packages/backend-core/src/repositories/bot-repo.ts index cdc7da13a..9945e4023 100644 --- a/packages/backend-core/src/repositories/bot-repo.ts +++ b/packages/backend-core/src/repositories/bot-repo.ts @@ -100,6 +100,11 @@ export class BotRepo extends Context.Service()("BotRepo", { ...(data.mentionable !== undefined && { mentionable: data.mentionable, }), + ...(data.installRedirectUris !== undefined && { + installRedirectUris: data.installRedirectUris + ? [...data.installRedirectUris] + : null, + }), updatedAt: new Date(), }) .where(($) => [$.id.eq(data.id), $.deletedAt.isNull()]) diff --git a/packages/db/migrations/directory/0002_bot_install_oauth/migration.sql b/packages/db/migrations/directory/0002_bot_install_oauth/migration.sql new file mode 100644 index 000000000..cf18fa65b --- /dev/null +++ b/packages/db/migrations/directory/0002_bot_install_oauth/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "bots" ADD COLUMN "installRedirectUris" text; diff --git a/packages/db/snapshots/directory.json b/packages/db/snapshots/directory.json index baba7516f..94d272884 100644 --- a/packages/db/snapshots/directory.json +++ b/packages/db/snapshots/directory.json @@ -1,5 +1,5 @@ { - "migration": "0001_initial", + "migration": "0002_bot_install_oauth", "entities": [ { "kind": "table", @@ -1168,9 +1168,18 @@ { "kind": "column", "table": "bots", - "name": "createdAt", + "name": "installRedirectUris", "position": 13, "type": "text", + "notNull": false, + "default": null + }, + { + "kind": "column", + "table": "bots", + "name": "createdAt", + "position": 14, + "type": "text", "notNull": true, "default": "(strftime('%Y-%m-%d %H:%M:%f', 'now'))" }, @@ -1178,7 +1187,7 @@ "kind": "column", "table": "bots", "name": "updatedAt", - "position": 14, + "position": 15, "type": "text", "notNull": true, "default": "(strftime('%Y-%m-%d %H:%M:%f', 'now'))" @@ -1187,7 +1196,7 @@ "kind": "column", "table": "bots", "name": "deletedAt", - "position": 15, + "position": 16, "type": "text", "notNull": false, "default": null diff --git a/packages/db/snapshots/org.json b/packages/db/snapshots/org.json index 2fdc5a9e6..8648f78f7 100644 --- a/packages/db/snapshots/org.json +++ b/packages/db/snapshots/org.json @@ -1,5 +1,5 @@ { - "migration": "0003_notification_levels", + "migration": "0004_bot_install_oauth", "entities": [ { "kind": "table", @@ -8,6 +8,13 @@ "columns": ["id"] } }, + { + "kind": "table", + "name": "bot_install_grants", + "primaryKey": { + "columns": ["id"] + } + }, { "kind": "table", "name": "bot_installations", @@ -250,6 +257,78 @@ "notNull": true, "default": "(strftime('%Y-%m-%d %H:%M:%f', 'now'))" }, + { + "kind": "column", + "table": "bot_install_grants", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": "(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))), 2) || '-' || substr('89ab', 1 + (abs(random()) % 4), 1) || substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))))" + }, + { + "kind": "column", + "table": "bot_install_grants", + "name": "codeHash", + "position": 1, + "type": "text", + "notNull": true, + "default": null + }, + { + "kind": "column", + "table": "bot_install_grants", + "name": "botId", + "position": 2, + "type": "text", + "notNull": true, + "default": null + }, + { + "kind": "column", + "table": "bot_install_grants", + "name": "redirectUri", + "position": 3, + "type": "text", + "notNull": true, + "default": null + }, + { + "kind": "column", + "table": "bot_install_grants", + "name": "installedBy", + "position": 4, + "type": "text", + "notNull": true, + "default": null + }, + { + "kind": "column", + "table": "bot_install_grants", + "name": "expiresAt", + "position": 5, + "type": "text", + "notNull": true, + "default": null + }, + { + "kind": "column", + "table": "bot_install_grants", + "name": "redeemedAt", + "position": 6, + "type": "text", + "notNull": false, + "default": null + }, + { + "kind": "column", + "table": "bot_install_grants", + "name": "createdAt", + "position": 7, + "type": "text", + "notNull": true, + "default": "(strftime('%Y-%m-%d %H:%M:%f', 'now'))" + }, { "kind": "column", "table": "bot_installations", @@ -415,9 +494,18 @@ { "kind": "column", "table": "bots", - "name": "createdAt", + "name": "installRedirectUris", "position": 13, "type": "text", + "notNull": false, + "default": null + }, + { + "kind": "column", + "table": "bots", + "name": "createdAt", + "position": 14, + "type": "text", "notNull": true, "default": "(strftime('%Y-%m-%d %H:%M:%f', 'now'))" }, @@ -425,7 +513,7 @@ "kind": "column", "table": "bots", "name": "updatedAt", - "position": 14, + "position": 15, "type": "text", "notNull": true, "default": "(strftime('%Y-%m-%d %H:%M:%f', 'now'))" @@ -434,7 +522,7 @@ "kind": "column", "table": "bots", "name": "deletedAt", - "position": 15, + "position": 16, "type": "text", "notNull": false, "default": null @@ -2568,6 +2656,13 @@ "unique": true, "columns": ["\"botId\"", "\"name\""] }, + { + "kind": "index", + "table": "bot_install_grants", + "name": "bot_install_grants_code_hash_unique", + "unique": true, + "columns": ["\"codeHash\""] + }, { "kind": "index", "table": "bot_installations", diff --git a/packages/db/src/migrations.generated.ts b/packages/db/src/migrations.generated.ts index 0a8d26f7f..b7f381101 100644 --- a/packages/db/src/migrations.generated.ts +++ b/packages/db/src/migrations.generated.ts @@ -79,6 +79,10 @@ export const generatedMigrations: Record + Effect.gen(function* () { + const statements: Array = [] + for (const statement of migration.statements) { + const match = ADD_COLUMN.exec(statement) + if (match !== null) { + const columns = yield* sql<{ + readonly name: string + }>`SELECT "name" FROM pragma_table_info(${match[1]!})` + if (columns.some((column) => column.name === match[2])) continue + } + statements.push(statement) + } + return { ...migration, statements } + }) + /** Applies every store's migrations to one database. */ const migrateAll = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient for (const store of storeNames) { - const { failed } = yield* applyMigrations(sql, store) + const migrations = yield* Effect.forEach(storeMigrations[store], (migration) => + withoutAppliedColumns(sql, migration), + ) + const { failed } = yield* applyMigrations(sql, store, migrations) // Local databases have no reason to tolerate one. if (failed.length > 0) return yield* Effect.die(failed[0]!.error) } diff --git a/packages/db/src/stores.ts b/packages/db/src/stores.ts index 5ebceb18a..175f9131c 100644 --- a/packages/db/src/stores.ts +++ b/packages/db/src/stores.ts @@ -43,6 +43,7 @@ export const orgTables = [ schema.channelAccessTable, schema.customEmojisTable, schema.botInstallationsTable, + schema.botInstallGrantsTable, schema.integrationConnectionsTable, schema.integrationTokensTable, schema.integrationRequestsTable, diff --git a/packages/db/src/tables/bot-installations.ts b/packages/db/src/tables/bot-installations.ts index 60c6a609d..c1573c944 100644 --- a/packages/db/src/tables/bot-installations.ts +++ b/packages/db/src/tables/bot-installations.ts @@ -1,6 +1,6 @@ import { BotId, BotInstallationId, OrganizationId, UserId } from "@hazel/schema" import * as SQLite from "@maple-dev/effect-orm/sqlite" -import { randomUuid, timestamptz, uuid } from "./columns" +import { randomUuid, timestamptz, uuid, varchar } from "./columns" export const botInstallationsTable = SQLite.table("bot_installations", { columns: { @@ -19,3 +19,24 @@ export const botInstallationsTable = SQLite.table("bot_installations", { }) export type BotInstallation = SQLite.SelectRowOf export type NewBotInstallation = SQLite.InsertRowOf + +/** + * A one-time install grant (`bot.authorizeInstall`): what the consent page hands the bot's + * redirect URI as `code`, redeemed once by the bot with its token (`POST /api/v1/bot-installs/token`). + * Only the code's hash is kept. Never synced. + */ +export const botInstallGrantsTable = SQLite.table("bot_install_grants", { + columns: { + id: SQLite.column(SQLite.text, { defaultExpr: randomUuid }), + codeHash: varchar(64), + botId: uuid(BotId), + redirectUri: SQLite.text, + installedBy: uuid(UserId), + expiresAt: timestamptz, + redeemedAt: SQLite.nullable(timestamptz), + createdAt: SQLite.column(timestamptz, { defaultExpr: SQLite.timestampNow }), + }, + primaryKey: ["id"], + indexes: [SQLite.uniqueIndex("bot_install_grants_code_hash_unique", ["codeHash"])], +}) +export type BotInstallGrant = SQLite.SelectRowOf diff --git a/packages/db/src/tables/bots.ts b/packages/db/src/tables/bots.ts index fbbb08526..c7749a90d 100644 --- a/packages/db/src/tables/bots.ts +++ b/packages/db/src/tables/bots.ts @@ -20,6 +20,8 @@ export const botsTable = SQLite.table("bots", { installCount: SQLite.column(SQLite.integer, { default: 0 }), mentionable: SQLite.column(SQLite.boolean, { default: false }), allowedIntegrations: SQLite.column(SQLite.nullable(jsonb()), { default: [] }), + /** Where the bot-install consent page may send a grant (`/oauth/bot-install`); exact matches only. */ + installRedirectUris: SQLite.nullable(jsonb()), createdAt: SQLite.column(timestamptz, { defaultExpr: SQLite.timestampNow }), updatedAt: SQLite.column(timestamptz, { defaultExpr: SQLite.timestampNow }), deletedAt: SQLite.nullable(timestamptz), diff --git a/packages/db/src/tables/messages.ts b/packages/db/src/tables/messages.ts index 9d20d4fd4..faa4a535a 100644 --- a/packages/db/src/tables/messages.ts +++ b/packages/db/src/tables/messages.ts @@ -69,6 +69,12 @@ export interface MessageEmbed { badge?: MessageEmbedBadge // Status badge (e.g., "Deployed", "Failed") liveState?: MessageEmbedLiveState // Live state for real-time updates html?: { renderId: HtmlRenderId; title: string; height: number } // Sandboxed HTML page (`@hazel/domain/html-render`) + actions?: Array<{ + id: string + label: string + style?: "primary" | "secondary" | "danger" + disabled?: boolean + }> // Buttons (`message.interact`) } export const messageReactionsTable = SQLite.table("message_reactions", { diff --git a/packages/domain/package.json b/packages/domain/package.json index 408aa415c..2eb24fdc7 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -15,7 +15,9 @@ "./scopes": "./src/scopes/index.ts", "./sync": "./src/sync/index.ts", "./live": "./src/live/index.ts", - "./html-render": "./src/html-render.ts" + "./html-render": "./src/html-render.ts", + "./bot-install": "./src/bot-install.ts", + "./thread-id": "./src/thread-id.ts" }, "dependencies": { "@hazel/integrations": "workspace:*", diff --git a/packages/domain/src/bot-gateway.ts b/packages/domain/src/bot-gateway.ts index 110eda08c..1f2d29f75 100644 --- a/packages/domain/src/bot-gateway.ts +++ b/packages/domain/src/bot-gateway.ts @@ -1,6 +1,6 @@ import { type Effect, Schema } from "effect" import { Channel, ChannelMember, Message } from "./models" -import { BotId, ChannelId, OrganizationId, UserId } from "@hazel/schema" +import { BotId, ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema" export const BotGatewayDeliveryId = Schema.String @@ -14,6 +14,35 @@ export const BotGatewayCommandInvokePayload = Schema.Struct({ }) export type BotGatewayCommandInvokePayload = Schema.Schema.Type +/** + * Someone pressed a button on one of the bot's messages (`MessageEmbed.actions`, RPC + * `message.interact`). Sent only to the bot that posted the message. `channelId` is where the + * message is: a thread's own channel when it is in one. + */ +export const BotGatewayInteractionCreatePayload = Schema.Struct({ + interactionId: Schema.String, + actionId: Schema.String, + messageId: MessageId, + channelId: ChannelId, + orgId: OrganizationId, + userId: UserId, + /** Who pressed it, as Hazel shows them. */ + userName: Schema.String, + timestamp: Schema.Number, +}) +export type BotGatewayInteractionCreatePayload = Schema.Schema.Type + +/** + * Who wrote a message event's message, as the directory has them: the name a bot shows for them + * and whether they are a bot. Absent where the author could not be read (a deleted user). + */ +export const BotGatewayMessageAuthor = Schema.Struct({ + id: UserId, + displayName: Schema.String, + isBot: Schema.Boolean, +}) +export type BotGatewayMessageAuthor = Schema.Schema.Type + const BaseGatewayEnvelope = { schemaVersion: Schema.Literal(1), deliveryId: BotGatewayDeliveryId, @@ -28,22 +57,31 @@ export const BotGatewayCommandInvokeEnvelope = Schema.Struct({ payload: BotGatewayCommandInvokePayload, }) +export const BotGatewayInteractionCreateEnvelope = Schema.Struct({ + ...BaseGatewayEnvelope, + eventType: Schema.Literal("interaction.create"), + payload: BotGatewayInteractionCreatePayload, +}) + export const BotGatewayMessageCreateEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("message.create"), payload: Message.Schema, + author: Schema.optionalKey(BotGatewayMessageAuthor), }) export const BotGatewayMessageUpdateEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("message.update"), payload: Message.Schema, + author: Schema.optionalKey(BotGatewayMessageAuthor), }) export const BotGatewayMessageDeleteEnvelope = Schema.Struct({ ...BaseGatewayEnvelope, eventType: Schema.Literal("message.delete"), payload: Message.Schema, + author: Schema.optionalKey(BotGatewayMessageAuthor), }) export const BotGatewayChannelCreateEnvelope = Schema.Struct({ @@ -78,6 +116,7 @@ export const BotGatewayChannelMemberRemoveEnvelope = Schema.Struct({ export const BotGatewayEnvelope = Schema.Union([ BotGatewayCommandInvokeEnvelope, + BotGatewayInteractionCreateEnvelope, BotGatewayMessageCreateEnvelope, BotGatewayMessageUpdateEnvelope, BotGatewayMessageDeleteEnvelope, @@ -147,6 +186,9 @@ export const BotGatewayReadyFrame = Schema.Struct({ sessionId: Schema.String, resumed: Schema.Boolean, resumeOffset: BotGatewayOffset, + /** The bot this session is for, and its user: what a mention of it (`@[userId:…]`) names. */ + botId: Schema.optionalKey(Schema.String), + botUserId: Schema.optionalKey(Schema.String), }) export const BotGatewayDispatchFrame = Schema.Struct({ diff --git a/packages/domain/src/bot-install.test.ts b/packages/domain/src/bot-install.test.ts new file mode 100644 index 000000000..ab051f0a1 --- /dev/null +++ b/packages/domain/src/bot-install.test.ts @@ -0,0 +1,42 @@ +import { Schema } from "effect" +import { describe, expect, it } from "vitest" +import { + InstallRedirectUris, + installRedirectUrl, + isRegisteredInstallRedirect, + isValidInstallRedirectUri, +} from "./bot-install" + +describe("bot install redirect URIs", () => { + it("accepts https, and http only on loopback", () => { + expect(isValidInstallRedirectUri("https://api.maple.dev/oauth/chat/hazel/callback")).toBe(true) + expect(isValidInstallRedirectUri("http://localhost:3472/callback")).toBe(true) + expect(isValidInstallRedirectUri("http://127.0.0.1/callback")).toBe(true) + expect(isValidInstallRedirectUri("http://maple.dev/callback")).toBe(false) + expect(isValidInstallRedirectUri("javascript:alert(1)")).toBe(false) + expect(isValidInstallRedirectUri("https://maple.dev/cb#frag")).toBe(false) + expect(isValidInstallRedirectUri("https://user:pass@maple.dev/cb")).toBe(false) + expect(isValidInstallRedirectUri("/relative")).toBe(false) + }) + + it("decodes a list of at most ten", () => { + const decode = Schema.decodeUnknownOption(InstallRedirectUris) + expect(decode(["https://a.example/cb"])._tag).toBe("Some") + expect(decode(["http://a.example/cb"])._tag).toBe("None") + expect(decode(Array.from({ length: 11 }, (_, i) => `https://a.example/${i}`))._tag).toBe("None") + }) + + it("matches registered URIs exactly", () => { + const registered = ["https://a.example/cb"] + expect(isRegisteredInstallRedirect(registered, "https://a.example/cb")).toBe(true) + expect(isRegisteredInstallRedirect(registered, "https://a.example/cb/")).toBe(false) + expect(isRegisteredInstallRedirect(registered, "https://a.example/cb?x=1")).toBe(false) + expect(isRegisteredInstallRedirect(null, "https://a.example/cb")).toBe(false) + }) + + it("adds the grant to the redirect's query, keeping what it had", () => { + expect(installRedirectUrl("https://a.example/cb?tenant=eu", { code: "c", state: "s&t" })).toBe( + "https://a.example/cb?tenant=eu&code=c&state=s%26t", + ) + }) +}) diff --git a/packages/domain/src/bot-install.ts b/packages/domain/src/bot-install.ts new file mode 100644 index 000000000..51f14ee1a --- /dev/null +++ b/packages/domain/src/bot-install.ts @@ -0,0 +1,52 @@ +/** + * Installing a bot from outside Hazel, the way a chat platform installs an app: the bot's service + * sends an org admin to `/oauth/bot-install?bot_id=&redirect_uri=&state=`, the admin picks an + * organization and approves, Hazel installs the bot there and sends the browser back to + * `redirect_uri?code=&state=`, and the service redeems the code with its own bot token + * (`POST /api/v1/bot-installs/token`) for the organization it was installed in. + * + * The organization comes from the redeemed code, never from the redirect: a code is bound to the + * bot, the redirect URI and the org at approval, is single-use and lives ten minutes. The redirect + * URI must be one the bot's owner registered (`bots.installRedirectUris`), compared exactly. + */ +import { Schema } from "effect" + +/** How long a grant can wait to be redeemed. */ +export const BOT_INSTALL_GRANT_TTL_MS = 10 * 60 * 1_000 + +export const BOT_INSTALL_MAX_REDIRECT_URIS = 10 + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]) + +/** + * A redirect URI a bot may register: absolute `https:` (or `http:` on loopback, for local + * development), with no fragment and no credentials. + */ +export const isValidInstallRedirectUri = (value: string): boolean => { + if (value.length > 2048 || !URL.canParse(value)) return false + const url = new URL(value) + if (url.hash !== "" || url.username !== "" || url.password !== "") return false + if (url.protocol === "https:") return true + return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname) +} + +export const InstallRedirectUri = Schema.String.check( + Schema.makeFilter((value: string) => isValidInstallRedirectUri(value) || "Expected an https URL"), +) + +export const InstallRedirectUris = Schema.Array(InstallRedirectUri).check( + Schema.isMaxLength(BOT_INSTALL_MAX_REDIRECT_URIS), +) + +/** Where the consent page sends the browser: the registered URI with `params` added to its query. */ +export const installRedirectUrl = (redirectUri: string, params: Readonly>): string => { + const url = new URL(redirectUri) + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value) + return url.toString() +} + +/** `true` when `redirectUri` is one of the bot's registered URIs. */ +export const isRegisteredInstallRedirect = ( + registered: ReadonlyArray | null, + redirectUri: string, +): boolean => registered !== null && registered.includes(redirectUri) diff --git a/packages/domain/src/http/api-v1/bots.ts b/packages/domain/src/http/api-v1/bots.ts new file mode 100644 index 000000000..4303025e0 --- /dev/null +++ b/packages/domain/src/http/api-v1/bots.ts @@ -0,0 +1,125 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" +import { Schema } from "effect" +import { ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema" +import { InternalServerError, UnauthorizedError } from "../../errors" +import { RateLimitExceededError } from "../../rate-limit-errors" +import { BotNotInstalledError } from "../bot-commands" +import { ChannelNotFoundError } from "./messages" + +/** + * Public API v1 for bots that live outside Hazel (a service holding a bot token): redeeming an + * install grant (`@hazel/domain/bot-install`), opening a thread on a message, and reading the + * people in an organization the bot is installed in. Auth: `Authorization: Bearer hzl_bot_…`. + */ + +// ============ MODELS ============ + +export class RedeemBotInstallRequest extends Schema.Class("RedeemBotInstallRequest")( + { + /** The `code` the consent page added to the redirect URI. */ + code: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256)), + /** The redirect URI the install was approved for; it must match exactly. */ + redirectUri: Schema.String.check(Schema.isMaxLength(2048)), + }, +) {} + +export class BotInstallRedeemedResponse extends Schema.Class( + "BotInstallRedeemedResponse", +)({ + organizationId: OrganizationId, + organizationName: Schema.String, + /** Who approved the install. */ + installedBy: UserId, +}) {} + +/** The code is unknown, already redeemed, expired, or was issued to another bot or redirect URI. */ +export class BotInstallGrantInvalidError extends Schema.TaggedError()( + "BotInstallGrantInvalidError", + { + message: Schema.String, + }, + { httpApiStatus: 400 }, +) {} + +export class ApiV1CreateThreadRequest extends Schema.Class( + "ApiV1CreateThreadRequest", +)({ + /** The message the thread hangs off; it must be in the path's channel. */ + messageId: MessageId, +}) {} + +export class ThreadResponse extends Schema.Class("ThreadResponse")({ + data: Schema.Struct({ + /** The thread's own channel id: post to it to reply in the thread. */ + id: ChannelId, + parentChannelId: ChannelId, + organizationId: OrganizationId, + }), +}) {} + +export class ApiV1UsersQuery extends Schema.Class("ApiV1UsersQuery")({ + /** Comma-separated user ids, at most 100. */ + ids: Schema.String, +}) {} + +export const ApiV1User = Schema.Struct({ + id: UserId, + displayName: Schema.String, + avatarUrl: Schema.NullOr(Schema.String), + isBot: Schema.Boolean, +}) +export type ApiV1User = typeof ApiV1User.Type + +export class ApiV1UsersResponse extends Schema.Class("ApiV1UsersResponse")({ + /** The ids that are people (or bots) in the organization; unknown ids are left out. */ + data: Schema.Array(ApiV1User), +}) {} + +// ============ API GROUP ============ + +export class BotsApiGroup extends HttpApiGroup.make("api-v1-bots") + .add( + HttpApiEndpoint.post("redeemBotInstall", "/bot-installs/token", { + payload: RedeemBotInstallRequest, + success: BotInstallRedeemedResponse, + error: [UnauthorizedError, BotInstallGrantInvalidError, InternalServerError], + }).annotateMerge( + OpenApi.annotations({ + title: "Redeem Bot Install", + description: + "Exchange the one-time `code` from the bot-install consent page (`/oauth/bot-install`) for the organization the bot was installed in. Single-use, valid for ten minutes, and only for the bot and redirect URI it was issued to.", + summary: "Redeem bot install", + }), + ), + ) + .add( + HttpApiEndpoint.post("createThread", "/channels/:channelId/threads", { + params: { channelId: ChannelId }, + payload: ApiV1CreateThreadRequest, + success: ThreadResponse, + error: [UnauthorizedError, ChannelNotFoundError, RateLimitExceededError, InternalServerError], + }).annotateMerge( + OpenApi.annotations({ + title: "Create Thread", + description: + "Open a thread on a message, or answer with the one it already has. A thread is a channel: post to its id to reply in it.", + summary: "Create thread", + }), + ), + ) + .add( + HttpApiEndpoint.get("listOrganizationUsers", "/organizations/:organizationId/users", { + params: { organizationId: OrganizationId }, + query: ApiV1UsersQuery, + success: ApiV1UsersResponse, + error: [UnauthorizedError, BotNotInstalledError, InternalServerError], + }).annotateMerge( + OpenApi.annotations({ + title: "List Organization Users", + description: + "Look up people (and bots) in an organization the bot is installed in by id, e.g. the authors of the messages it reads.", + summary: "List organization users", + }), + ), + ) + .prefix("/api/v1") {} diff --git a/packages/domain/src/http/api-v1/index.ts b/packages/domain/src/http/api-v1/index.ts index 229970e79..b63ceb3be 100644 --- a/packages/domain/src/http/api-v1/index.ts +++ b/packages/domain/src/http/api-v1/index.ts @@ -4,3 +4,6 @@ export * as Messages from "./messages" // Public API v1 - Integration provisioning (orgs/channels/channel-webhooks) export * from "./integrations" export * as ApiV1Integrations from "./integrations" +// Public API v1 - Bots that live outside Hazel (install grants, threads, users) +export * from "./bots" +export * as ApiV1Bots from "./bots" diff --git a/packages/domain/src/http/api.ts b/packages/domain/src/http/api.ts index a600a8dd9..6a07acbe5 100644 --- a/packages/domain/src/http/api.ts +++ b/packages/domain/src/http/api.ts @@ -1,6 +1,7 @@ import { HttpApi, OpenApi } from "effect/http-api" import { IntegrationsApiGroup as ApiV1IntegrationsApiGroup } from "./api-v1/integrations" import { MessagesApiGroup } from "./api-v1/messages" +import { BotsApiGroup as ApiV1BotsApiGroup } from "./api-v1/bots" import { BotCommandsApiGroup } from "./bot-commands" import { IncomingWebhookGroup } from "./incoming-webhooks" import { IntegrationCommandGroup } from "./integration-commands" @@ -16,6 +17,7 @@ import { WebhookGroup } from "./webhooks" export class HazelApi extends HttpApi.make("HazelApp") .add(MessagesApiGroup) .add(ApiV1IntegrationsApiGroup) + .add(ApiV1BotsApiGroup) .add(BotCommandsApiGroup) .add(PresencePublicGroup) .add(RootGroup) diff --git a/packages/domain/src/models/bot-model.ts b/packages/domain/src/models/bot-model.ts index ffe0f8516..4cec1014a 100644 --- a/packages/domain/src/models/bot-model.ts +++ b/packages/domain/src/models/bot-model.ts @@ -20,6 +20,8 @@ class Model extends M.Class("Bot")({ allowedIntegrations: S.NullOr(S.Array(IntegrationProvider)), // Whether this bot can be @mentioned in messages mentionable: S.Boolean, + // Where the bot-install consent page (`/oauth/bot-install`) may send an install grant + installRedirectUris: S.NullOr(S.Array(S.String)), ...baseFields, }) {} diff --git a/packages/domain/src/models/message-embed-schema.test.ts b/packages/domain/src/models/message-embed-schema.test.ts new file mode 100644 index 000000000..4dbf98895 --- /dev/null +++ b/packages/domain/src/models/message-embed-schema.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest" +import { findEmbedAction } from "./message-embed-schema" + +describe("findEmbedAction", () => { + it("finds an enabled button on any embed, past a disabled one with the same id", () => { + const embeds = [ + { actions: [{ id: "approve:1", label: "Approve", disabled: true }] }, + { description: "no buttons" }, + { actions: [{ id: "approve:1", label: "Approve" }] }, + ] + expect(findEmbedAction(embeds, "approve:1")).toEqual({ id: "approve:1", label: "Approve" }) + }) + + it("finds nothing for a disabled or unknown button", () => { + const embeds = [{ actions: [{ id: "deny:1", label: "Deny", disabled: true }] }] + expect(findEmbedAction(embeds, "deny:1")).toBeUndefined() + expect(findEmbedAction(embeds, "nope")).toBeUndefined() + expect(findEmbedAction(null, "deny:1")).toBeUndefined() + }) +}) diff --git a/packages/domain/src/models/message-embed-schema.ts b/packages/domain/src/models/message-embed-schema.ts index 1fca2c6b9..c1bd9f6b5 100644 --- a/packages/domain/src/models/message-embed-schema.ts +++ b/packages/domain/src/models/message-embed-schema.ts @@ -121,6 +121,22 @@ export const MessageEmbedHtml = Schema.Struct({ }) export type MessageEmbedHtml = Schema.Schema.Type +/** + * A button on a bot's embed. Clicking it sends the bot that posted the message an + * `interaction.create` event carrying `id` (`message.interact`); nothing else happens until the bot + * answers, typically by editing the message. `id` is the bot's own: up to 100 characters it gets + * back verbatim. + */ +export const MessageEmbedAction = Schema.Struct({ + id: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)), + label: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(80)), + style: Schema.optional(Schema.Literals(["primary", "secondary", "danger"])), + disabled: Schema.optional(Schema.Boolean), +}) +export type MessageEmbedAction = Schema.Schema.Type + +export const MESSAGE_EMBED_MAX_ACTIONS = 5 + // Full embed schema (Discord-style) export const MessageEmbed = Schema.Struct({ title: Schema.optional(Schema.String.check(Schema.isMaxLength(256))), @@ -138,9 +154,26 @@ export const MessageEmbed = Schema.Struct({ badge: Schema.optional(MessageEmbedBadge), // Status badge (e.g., "Deployed", "Failed") liveState: Schema.optional(MessageEmbedLiveState), // Live state for real-time updates html: Schema.optional(MessageEmbedHtml), + actions: Schema.optional( + Schema.Array(MessageEmbedAction).check(Schema.isMaxLength(MESSAGE_EMBED_MAX_ACTIONS)), + ), }) export type MessageEmbed = Schema.Schema.Type +/** The enabled action `actionId` on any of `embeds`, if there is one. */ +export const findEmbedAction = ( + embeds: ReadonlyArray | null | undefined, + actionId: string, +): MessageEmbedAction | undefined => { + for (const embed of embeds ?? []) { + const action = embed.actions?.find( + (candidate) => candidate.id === actionId && candidate.disabled !== true, + ) + if (action !== undefined) return action + } + return undefined +} + // Array of embeds for a message export const MessageEmbeds = Schema.Array(MessageEmbed) export type MessageEmbeds = Schema.Schema.Type diff --git a/packages/domain/src/rpc/bots.ts b/packages/domain/src/rpc/bots.ts index 291e64c62..649ea12ae 100644 --- a/packages/domain/src/rpc/bots.ts +++ b/packages/domain/src/rpc/bots.ts @@ -9,6 +9,7 @@ import { AuthMiddleware } from "./middleware" import { RouteOrganizationId } from "./routing-fields" import { RequiredScopes } from "../scopes/required-scopes" import { ApiScope } from "../scopes/api-scope" +import { InstallRedirectUris } from "../bot-install" /** * Response schema for bot operations. @@ -76,6 +77,33 @@ export class BotAlreadyInstalledError extends Schema.TaggedError()( + "BotInstallRedirectMismatchError", + { + botId: BotId, + }, +) {} + +/** What the bot-install consent page shows about the bot asking to be installed. */ +export class BotInstallRequestResponse extends Schema.Class( + "BotInstallRequestResponse", +)({ + id: BotId, + name: Schema.String, + description: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + creatorName: Schema.String, +}) {} + +/** An approved install: the one-time `code` the consent page hands the bot's redirect URI. */ +export class BotInstallAuthorizedResponse extends Schema.Class( + "BotInstallAuthorizedResponse", +)({ + code: Schema.String, + transactionId: TransactionId, +}) {} + /** * Bot RPC Group * @@ -185,6 +213,7 @@ export class BotRpcs extends RpcGroup.make( webhookUrl: Schema.optional(Schema.NullOr(Schema.String)), scopes: Schema.optional(Schema.Array(ApiScope)), isPublic: Schema.optional(Schema.Boolean), + installRedirectUris: Schema.optional(Schema.NullOr(InstallRedirectUris)), }), success: BotResponse, error: Schema.Union([ @@ -373,6 +402,57 @@ export class BotRpcs extends RpcGroup.make( .annotate(RequiredScopes, ["bots:write"]) .middleware(AuthMiddleware), + /** + * bot.getInstallRequest + * + * The bot-install consent page's lookup (`/oauth/bot-install`, see `@hazel/domain/bot-install`): + * the bot asking to be installed, once its redirect URI checks out. + * + * @throws BotNotFoundError if the bot doesn't exist + * @throws BotInstallRedirectMismatchError if the redirect URI is not registered on the bot + */ + Rpc.make("bot.getInstallRequest", { + payload: Schema.Struct({ botId: BotId, redirectUri: Schema.String }), + success: BotInstallRequestResponse, + error: Schema.Union([ + BotNotFoundError, + BotInstallRedirectMismatchError, + UnauthorizedError, + InternalServerError, + ]), + }) + .annotate(RequiredScopes, ["bots:read"]) + .middleware(AuthMiddleware), + + /** + * bot.authorizeInstall + * + * Approve a bot-install request: installs the bot in the organization (when it is not already) + * and mints the one-time code the bot redeems with its token for that organization. + * + * @throws BotNotFoundError if the bot doesn't exist + * @throws BotInstallRedirectMismatchError if the redirect URI is not registered on the bot + * @throws UnauthorizedError if user is not org admin + */ + Rpc.make("bot.authorizeInstall", { + payload: Schema.Struct({ + botId: BotId, + organizationId: RouteOrganizationId, + redirectUri: Schema.String, + }), + success: BotInstallAuthorizedResponse, + error: Schema.Union([ + ActiveOrganizationRequiredError, + BotNotFoundError, + BotInstallRedirectMismatchError, + UnauthorizedError, + InternalServerError, + RateLimitExceededError, + ]), + }) + .annotate(RequiredScopes, ["bots:write"]) + .middleware(AuthMiddleware), + /** * bot.updateAvatar * diff --git a/packages/domain/src/rpc/messages.ts b/packages/domain/src/rpc/messages.ts index 2fd519201..604bafc36 100644 --- a/packages/domain/src/rpc/messages.ts +++ b/packages/domain/src/rpc/messages.ts @@ -22,6 +22,22 @@ export class MessageResponse extends Schema.Class("MessageRespo transactionId: TransactionId, }) {} +export class MessageInteractionResponse extends Schema.Class( + "MessageInteractionResponse", +)({ + /** The id the bot's `interaction.create` event carries. */ + interactionId: Schema.String, +}) {} + +/** The message has no enabled action with that id, or was not posted by a bot. */ +export class MessageActionUnavailableError extends Schema.TaggedError()( + "MessageActionUnavailableError", + { + messageId: MessageId, + actionId: Schema.String, + }, +) {} + export class HtmlRenderUrlResponse extends Schema.Class("HtmlRenderUrlResponse")({ url: Schema.String, /** Epoch milliseconds. */ @@ -198,6 +214,33 @@ export class MessageRpcs extends RpcGroup.make( .annotate(RequiredScopes, ["messages:read"]) .middleware(AuthMiddleware), + /** + * MessageInteract + * + * Press a button on a bot's message (`MessageEmbed.actions`): the bot that posted it gets an + * `interaction.create` gateway event. For a viewer of the channel. + * + * @throws MessageNotFoundError if the message is gone + * @throws MessageActionUnavailableError if it has no enabled action with that id + */ + Rpc.make("message.interact", { + payload: Schema.Struct({ + channelId: ChannelId, + messageId: MessageId, + actionId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)), + }), + success: MessageInteractionResponse, + error: Schema.Union([ + MessageNotFoundError, + MessageActionUnavailableError, + UnauthorizedError, + InternalServerError, + RateLimitExceededError, + ]), + }) + .annotate(RequiredScopes, ["messages:read"]) + .middleware(AuthMiddleware), + /** * MessageSearch * diff --git a/packages/domain/src/thread-id.ts b/packages/domain/src/thread-id.ts new file mode 100644 index 000000000..5666f2c00 --- /dev/null +++ b/packages/domain/src/thread-id.ts @@ -0,0 +1,18 @@ +import { ChannelId, type MessageId } from "@hazel/schema" + +/** + * The id a thread on `messageId` gets when its creator names none (`channel.createThread`): derived + * from the message, so a retried or racing create answers with the same thread instead of opening + * another. The first 16 bytes of a SHA-256, shaped as an RFC 4122 v4 UUID. + */ +export const derivedThreadChannelId = async (messageId: MessageId): Promise => { + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`hazel.thread:${messageId}`)), + ) + digest[6] = (digest[6]! & 0x0f) | 0x40 + digest[8] = (digest[8]! & 0x3f) | 0x80 + const hex = Array.from(digest.slice(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("") + return ChannelId.make( + `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`, + ) +}