From cca03f1ae3289fe84ef2e16c86bf8fc2b3e95cc6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 14:50:49 +0100 Subject: [PATCH 1/6] feat(bots): install bots from outside Hazel, and buttons they can hear A bot that lives in another service (Maple's agent) can now be installed the way a chat platform installs an app, and can put buttons on its messages: - Bot-install grants: a bot's owner registers install redirect URIs; an org admin approves on /oauth/bot-install, Hazel installs the bot and hands the redirect a one-time code (org store, 10 min, single use) that the bot redeems with its own token at POST /api/v1/bot-installs/token. - Embed actions (up to 5 buttons). message.interact sends the bot that posted the message an interaction.create gateway event. - Bot API: POST /api/v1/channels/:id/threads and GET /api/v1/organizations/:id/users. The HTTP API now holds a bot to its declared scopes, as RPC does (bots with none recorded keep member scopes). - Gateway: READY names the bot and its user; message events name their author (one directory read per message, only where a channel has bots). Co-Authored-By: Claude Opus 5.5 --- apps/backend/src/http.ts | 3 +- apps/backend/src/routes/api-v1/bot-auth.ts | 64 ++++ apps/backend/src/routes/api-v1/bots.http.ts | 155 +++++++++ apps/backend/src/routes/api-v1/index.ts | 1 + .../src/routes/api-v1/messages.http.ts | 51 +-- apps/backend/src/routes/object-routes.test.ts | 319 +++++++++++++++--- apps/backend/src/rpc/handlers/bots.ts | 78 +++++ apps/backend/src/rpc/handlers/messages.ts | 62 +++- apps/backend/src/rpc/middleware/auth.test.ts | 1 + apps/backend/src/rpc/routing.ts | 5 + .../src/services/bot-gateway-service.ts | 3 + .../src/worker/objects/call-receivers.ts | 16 +- .../worker/objects/channel/api-messages.ts | 36 +- .../worker/objects/channel/message-events.ts | 2 +- .../src/worker/objects/directory-path.test.ts | 120 +++++++ .../src/worker/objects/directory-wire.ts | 3 + .../objects/effects/bot-gateway-effects.ts | 56 ++- .../src/worker/objects/effects/deliver.ts | 5 +- .../worker/objects/org/bot-install-grants.ts | 155 +++++++++ apps/bot-gateway/src/gateway/auth.ts | 7 +- apps/bot-gateway/src/gateway/handshake.ts | 10 +- apps/bot-gateway/src/gateway/object-live.ts | 2 + apps/bot-gateway/src/gateway/relay.test.ts | 15 +- apps/bot-gateway/src/gateway/relay.ts | 9 +- apps/web-foldkit/src/chat/embeds/index.ts | 51 ++- apps/web-foldkit/src/chat/embeds/types.ts | 1 + apps/web-foldkit/src/chat/message/row.ts | 3 + .../web-foldkit/src/overlay/modal/edit-bot.ts | 78 ++++- .../web-foldkit/src/overlay/modal/requests.ts | 11 +- .../src/page/bot-install/command.ts | 70 ++++ .../web-foldkit/src/page/bot-install/index.ts | 13 + .../src/page/bot-install/message.ts | 21 ++ .../web-foldkit/src/page/bot-install/model.ts | 66 ++++ .../src/page/bot-install/scene.test.ts | 104 ++++++ .../src/page/bot-install/subscription.ts | 55 +++ .../src/page/bot-install/update.ts | 120 +++++++ apps/web-foldkit/src/page/bot-install/view.ts | 168 +++++++++ .../page/chat/channel/messages.scene.test.ts | 50 +++ .../src/page/chat/channel/model.ts | 2 + .../web-foldkit/src/page/chat/channel/page.ts | 2 + .../src/page/chat/channel/write.ts | 25 ++ .../src/page/chat/message-actions.ts | 26 ++ apps/web-foldkit/src/page/chat/row-context.ts | 5 + apps/web-foldkit/src/page/registry.ts | 2 + .../integrations/marketplace/scene.test.ts | 1 + .../page/settings/integrations/shared/bots.ts | 5 + .../integrations/your-apps/scene.test.ts | 1 + .../integrations/your-apps/story.test.ts | 15 +- .../settings/integrations/your-apps/update.ts | 1 + apps/web-foldkit/src/route.test.ts | 18 + apps/web-foldkit/src/route.ts | 21 ++ .../src/test/pages-integrations-fixtures.ts | 1 + .../backend-core/src/repositories/bot-repo.ts | 5 + .../0002_bot_install_oauth/migration.sql | 1 + packages/db/snapshots/directory.json | 17 +- packages/db/snapshots/org.json | 103 +++++- packages/db/src/migrations.generated.ts | 14 +- packages/db/src/node.ts | 29 +- packages/db/src/stores.ts | 1 + packages/db/src/tables/bot-installations.ts | 23 +- packages/db/src/tables/bots.ts | 2 + packages/db/src/tables/messages.ts | 6 + packages/domain/package.json | 3 +- packages/domain/src/bot-gateway.ts | 44 ++- packages/domain/src/bot-install.test.ts | 42 +++ packages/domain/src/bot-install.ts | 52 +++ packages/domain/src/http/api-v1/bots.ts | 125 +++++++ packages/domain/src/http/api-v1/index.ts | 3 + packages/domain/src/http/api.ts | 2 + packages/domain/src/models/bot-model.ts | 2 + .../domain/src/models/message-embed-schema.ts | 31 ++ packages/domain/src/rpc/bots.ts | 80 +++++ packages/domain/src/rpc/messages.ts | 43 +++ 73 files changed, 2597 insertions(+), 145 deletions(-) create mode 100644 apps/backend/src/routes/api-v1/bot-auth.ts create mode 100644 apps/backend/src/routes/api-v1/bots.http.ts create mode 100644 apps/backend/src/worker/objects/org/bot-install-grants.ts create mode 100644 apps/web-foldkit/src/page/bot-install/command.ts create mode 100644 apps/web-foldkit/src/page/bot-install/index.ts create mode 100644 apps/web-foldkit/src/page/bot-install/message.ts create mode 100644 apps/web-foldkit/src/page/bot-install/model.ts create mode 100644 apps/web-foldkit/src/page/bot-install/scene.test.ts create mode 100644 apps/web-foldkit/src/page/bot-install/subscription.ts create mode 100644 apps/web-foldkit/src/page/bot-install/update.ts create mode 100644 apps/web-foldkit/src/page/bot-install/view.ts create mode 100644 packages/db/migrations/directory/0002_bot_install_oauth/migration.sql create mode 100644 packages/domain/src/bot-install.test.ts create mode 100644 packages/domain/src/bot-install.ts create mode 100644 packages/domain/src/http/api-v1/bots.ts 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..ac4b18f85 --- /dev/null +++ b/apps/backend/src/routes/api-v1/bots.http.ts @@ -0,0 +1,155 @@ +/** + * 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 }))), + ) + +const decodeUserIds = Schema.decodeUnknownOption(Schema.Array(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 = Option.getOrElse(decodeUserIds([...new Set(raw)]), () => []) + 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..9a53dc239 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,182 @@ 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 + + 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, + userName: `${world.alice}@example.com`, + }, + }, + }, + ]) + 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() + + // 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) + + const users = yield* json( + yield* request( + `/api/v1/organizations/${world.orgId}/users?ids=${world.alice},${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/messages.ts b/apps/backend/src/rpc/handlers/messages.ts index a33c2c478..da38c0f45 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,56 @@ 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, + userName: + [user.firstName, user.lastName].filter(Boolean).join(" ") || + user.email, + 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..5317ca501 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"), @@ -278,6 +279,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 +311,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..4a2bad465 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,62 @@ 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", "email", "userType") + .where(($) => [$.id.eq(userId)]) + .limit(1), + ) + return Option.map(user, (row) => ({ + id: row.id, + displayName: [row.firstName, row.lastName].filter(Boolean).join(" ") || row.email, + 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/bot-install-grants.ts b/apps/backend/src/worker/objects/org/bot-install-grants.ts new file mode 100644 index 000000000..c20353cd6 --- /dev/null +++ b/apps/backend/src/worker/objects/org/bot-install-grants.ts @@ -0,0 +1,155 @@ +/** + * 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", "email", "avatarUrl", "userType") + .where(($) => [$.id.in_(...payload.ids), $.deletedAt.isNull()]), + ) + return users.map((user) => ({ + id: user.id, + displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email, + 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/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..6fc026151 --- /dev/null +++ b/apps/web-foldkit/src/page/bot-install/scene.test.ts @@ -0,0 +1,104 @@ +// @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("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..fcb9ef2e4 --- /dev/null +++ b/apps/web-foldkit/src/page/bot-install/view.ts @@ -0,0 +1,168 @@ +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), + ], + Failed: ({ reason }) => [ + 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/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..2308e66dd 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -15,7 +15,8 @@ "./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" }, "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.ts b/packages/domain/src/models/message-embed-schema.ts index 1fca2c6b9..33c77a1b8 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,24 @@ 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) + if (action !== undefined) return action.disabled === true ? undefined : 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 * From 0b15c8a513de170a77dba0e90d139a7520952ead Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 14:50:49 +0100 Subject: [PATCH 2/6] fix(bot-sdk): threads for bots, resilient batches, current docs - channel.createThread routes by the message's channel when no organizationId is given: bots have no active org and the SDK never sent one, so every bot's createThread failed to route. - A thread created without an id gets one derived from its message, so a retried or racing create answers with the same thread instead of a second. - The SDK reads each gateway event on its own and skips (and still acknowledges) one it cannot decode. Before, one unknown event failed the whole frame and the batch was redelivered forever. - createThread takes the organization when the bot knows it; its docs no longer call the required channelId unused. - Docs and examples: @hazel-chat/bot-sdk, gateway port 3004. Co-Authored-By: Claude Opus 5.5 --- apps/backend/src/rpc/handlers/channels.ts | 14 +-- apps/backend/src/rpc/routing.ts | 16 ++-- .../src/worker/objects/org-objects.test.ts | 44 ++++++++++ .../content/docs/bot-sdk/getting-started.mdx | 8 +- apps/docs/content/docs/bot-sdk/index.mdx | 2 +- .../docs/bot-sdk/reference/ai-streaming.mdx | 2 +- .../docs/bot-sdk/reference/commands.mdx | 6 +- .../docs/bot-sdk/reference/configuration.mdx | 8 +- .../content/docs/bot-sdk/reference/errors.mdx | 2 +- .../bot-sdk/reference/hazel-bot-client.mdx | 2 +- .../docs/bot-sdk/reference/html-renders.mdx | 2 +- .../content/docs/bot-sdk/reference/index.mdx | 4 +- .../bot-sdk/tutorial/01-project-setup.mdx | 4 +- .../bot-sdk/tutorial/02-handling-messages.mdx | 4 +- .../bot-sdk/tutorial/03-slash-commands.mdx | 8 +- .../tutorial/04-message-operations.mdx | 2 +- .../bot-sdk/tutorial/05-error-handling.mdx | 2 +- .../content/docs/bot-sdk/tutorial/index.mdx | 2 +- libs/bot-sdk/examples/README.md | 10 +-- .../examples/simple-echo-bot/.env.example | 2 +- .../bot-sdk/examples/simple-echo-bot/index.ts | 4 +- .../examples/simple-echo-bot/package.json | 2 +- libs/bot-sdk/src/hazel-bot-sdk.test.ts | 85 +++++++++++++++++++ libs/bot-sdk/src/hazel-bot-sdk.ts | 61 +++++++++++-- packages/domain/package.json | 3 +- packages/domain/src/thread-id.ts | 18 ++++ 26 files changed, 260 insertions(+), 57 deletions(-) create mode 100644 packages/domain/src/thread-id.ts 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/routing.ts b/apps/backend/src/rpc/routing.ts index 5317ca501..f6e31ea3d 100644 --- a/apps/backend/src/rpc/routing.ts +++ b/apps/backend/src/rpc/routing.ts @@ -200,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"), 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/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/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..0670279a3 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,24 @@ 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) => + events.flatMap((event) => { + const decoded = decodeEnvelope(event) + if (Option.isSome(decoded)) return [decoded.value] + Effect.runFork( + Effect.logWarning("Skipping a gateway event this SDK cannot read", { + eventType: eventTypeOf(event), + }), + ) + return [] + }) + const processGatewayBatch = ( envelopes: ReadonlyArray>, ) => @@ -742,11 +783,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 +859,7 @@ export class HazelBotClient extends Context.Service()("HazelBotC case "DISPATCH": { Effect.runPromiseWith(services)( Effect.gen(function* () { - yield* processGatewayBatch(frame.events) + yield* processGatewayBatch(readableEnvelopes(frame.events)) yield* gatewaySessionStore.save( authContext.botId as BotId, frame.nextOffset, @@ -1472,17 +1511,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/domain/package.json b/packages/domain/package.json index 2308e66dd..2eb24fdc7 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -16,7 +16,8 @@ "./sync": "./src/sync/index.ts", "./live": "./src/live/index.ts", "./html-render": "./src/html-render.ts", - "./bot-install": "./src/bot-install.ts" + "./bot-install": "./src/bot-install.ts", + "./thread-id": "./src/thread-id.ts" }, "dependencies": { "@hazel/integrations": "workspace:*", 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)}`, + ) +} From f0ec237de909f26e656f616fc116a59c60b2dd46 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 15:01:40 +0100 Subject: [PATCH 3/6] fix(bots): never show bots a member's email; find enabled buttons past disabled ones - The author name on message events, the users lookup and an interaction's userName fall back to "Unknown", never the email: a bot is not granted members' contact details. - findEmbedAction keeps looking past a disabled button with the same id. - Tests: a mention inside a thread answers with that same thread; the bot can post in a thread it opened. Co-Authored-By: Claude Opus 5.5 --- apps/backend/src/routes/object-routes.test.ts | 34 ++++++++++++++++++- apps/backend/src/rpc/handlers/messages.ts | 3 +- .../objects/effects/bot-gateway-effects.ts | 5 +-- .../worker/objects/org/bot-install-grants.ts | 5 +-- .../src/models/message-embed-schema.test.ts | 20 +++++++++++ .../domain/src/models/message-embed-schema.ts | 6 ++-- 6 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 packages/domain/src/models/message-embed-schema.test.ts diff --git a/apps/backend/src/routes/object-routes.test.ts b/apps/backend/src/routes/object-routes.test.ts index 9a53dc239..460dc1843 100644 --- a/apps/backend/src/routes/object-routes.test.ts +++ b/apps/backend/src/routes/object-routes.test.ts @@ -383,7 +383,8 @@ describe("public API (v1) for bots outside Hazel", () => { channelId: world.channelId, orgId: world.orgId, userId: world.alice, - userName: `${world.alice}@example.com`, + // Alice has no name here, and a bot never gets her email instead. + userName: "Unknown", }, }, }, @@ -397,6 +398,16 @@ describe("public API (v1) for bots outside Hazel", () => { // 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`, { @@ -416,6 +427,27 @@ describe("public API (v1) for bots outside Hazel", () => { ) 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},${botUserId},${uuid()}`, diff --git a/apps/backend/src/rpc/handlers/messages.ts b/apps/backend/src/rpc/handlers/messages.ts index da38c0f45..4ea020de6 100644 --- a/apps/backend/src/rpc/handlers/messages.ts +++ b/apps/backend/src/rpc/handlers/messages.ts @@ -253,9 +253,10 @@ export const MessageRpcLive = MessageRpcs.toLayer( 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(" ") || - user.email, + "Unknown", timestamp: Date.now(), }, }, 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 4a2bad465..31c867122 100644 --- a/apps/backend/src/worker/objects/effects/bot-gateway-effects.ts +++ b/apps/backend/src/worker/objects/effects/bot-gateway-effects.ts @@ -49,13 +49,14 @@ const authorOf = (userId: typeof Message.Schema.Type.authorId) => const directory = yield* Directory const user = yield* directory.db.first( SQLite.from(schema.usersTable) - .select("id", "firstName", "lastName", "email", "userType") + .select("id", "firstName", "lastName", "userType") .where(($) => [$.id.eq(userId)]) .limit(1), ) return Option.map(user, (row) => ({ id: row.id, - displayName: [row.firstName, row.lastName].filter(Boolean).join(" ") || row.email, + // Never the email: a bot is not granted members' contact details. + displayName: [row.firstName, row.lastName].filter(Boolean).join(" ") || "Unknown", isBot: row.userType === "machine", })) }) diff --git a/apps/backend/src/worker/objects/org/bot-install-grants.ts b/apps/backend/src/worker/objects/org/bot-install-grants.ts index c20353cd6..c19242428 100644 --- a/apps/backend/src/worker/objects/org/bot-install-grants.ts +++ b/apps/backend/src/worker/objects/org/bot-install-grants.ts @@ -141,12 +141,13 @@ export const organizationUsers = Effect.fn("OrgObject.botUsers")( if (payload.ids.length === 0) return [] const users = yield* db.run( SQLite.from(schema.usersTable) - .select("id", "firstName", "lastName", "email", "avatarUrl", "userType") + .select("id", "firstName", "lastName", "avatarUrl", "userType") .where(($) => [$.id.in_(...payload.ids), $.deletedAt.isNull()]), ) return users.map((user) => ({ id: user.id, - displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email, + // 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", })) 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 33c77a1b8..c1bd9f6b5 100644 --- a/packages/domain/src/models/message-embed-schema.ts +++ b/packages/domain/src/models/message-embed-schema.ts @@ -166,8 +166,10 @@ export const findEmbedAction = ( actionId: string, ): MessageEmbedAction | undefined => { for (const embed of embeds ?? []) { - const action = embed.actions?.find((candidate) => candidate.id === actionId) - if (action !== undefined) return action.disabled === true ? undefined : action + const action = embed.actions?.find( + (candidate) => candidate.id === actionId && candidate.disabled !== true, + ) + if (action !== undefined) return action } return undefined } From 310d54a81e0b53347d7ebf7fd2e2b91e3dd9604a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 15:05:37 +0100 Subject: [PATCH 4/6] fix(bots): message events name no author for a deleted user Co-Authored-By: Claude Opus 5.5 --- apps/backend/src/worker/objects/effects/bot-gateway-effects.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 31c867122..d5185bdfa 100644 --- a/apps/backend/src/worker/objects/effects/bot-gateway-effects.ts +++ b/apps/backend/src/worker/objects/effects/bot-gateway-effects.ts @@ -50,7 +50,7 @@ const authorOf = (userId: typeof Message.Schema.Type.authorId) => const user = yield* directory.db.first( SQLite.from(schema.usersTable) .select("id", "firstName", "lastName", "userType") - .where(($) => [$.id.eq(userId)]) + .where(($) => [$.id.eq(userId), $.deletedAt.isNull()]) .limit(1), ) return Option.map(user, (row) => ({ From 7a544933f696d4177c5963ed3fade6b1faa03811 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 15:08:40 +0100 Subject: [PATCH 5/6] fix(bots): a malformed id drops only itself; signed-out install links offer sign-in; SDK logs through the bot - GET /api/v1/organizations/:id/users checks each id on its own. - The install page shows "Sign in to continue" when the lookup failed for want of a session. - The SDK logs skipped gateway events inside the dispatch Effect, so the bot's logger sees them. Co-Authored-By: Claude Opus 5.5 --- apps/backend/src/routes/api-v1/bots.http.ts | 5 +-- apps/backend/src/routes/object-routes.test.ts | 2 +- .../src/page/bot-install/scene.test.ts | 14 ++++++++ apps/web-foldkit/src/page/bot-install/view.ts | 35 ++++++++++++------- libs/bot-sdk/src/hazel-bot-sdk.ts | 23 ++++++------ 5 files changed, 53 insertions(+), 26 deletions(-) diff --git a/apps/backend/src/routes/api-v1/bots.http.ts b/apps/backend/src/routes/api-v1/bots.http.ts index ac4b18f85..2cfa38c56 100644 --- a/apps/backend/src/routes/api-v1/bots.http.ts +++ b/apps/backend/src/routes/api-v1/bots.http.ts @@ -44,7 +44,8 @@ const internal = (message: string) => (cause: unknown) => Effect.andThen(Effect.fail(new InternalServerError({ message, cause }))), ) -const decodeUserIds = Schema.decodeUnknownOption(Schema.Array(UserId)) +/** 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( @@ -136,7 +137,7 @@ export const HttpApiV1BotsLive = HttpApiBuilder.group(HazelApi, "api-v1-bots", ( .split(",") .map((id) => id.trim()) .filter((id) => id.length > 0) - const ids = Option.getOrElse(decodeUserIds([...new Set(raw)]), () => []) + const ids = [...new Set(raw)].filter(isUserId) const users = yield* callObject(OrganizationUsersCall, orgTarget(params.organizationId), { botId: bot.id, botUserId: bot.userId, diff --git a/apps/backend/src/routes/object-routes.test.ts b/apps/backend/src/routes/object-routes.test.ts index 460dc1843..a9af36ad7 100644 --- a/apps/backend/src/routes/object-routes.test.ts +++ b/apps/backend/src/routes/object-routes.test.ts @@ -450,7 +450,7 @@ describe("public API (v1) for bots outside Hazel", () => { const users = yield* json( yield* request( - `/api/v1/organizations/${world.orgId}/users?ids=${world.alice},${botUserId},${uuid()}`, + `/api/v1/organizations/${world.orgId}/users?ids=${world.alice},not-a-uuid,${botUserId},${uuid()}`, ), ) expect( diff --git a/apps/web-foldkit/src/page/bot-install/scene.test.ts b/apps/web-foldkit/src/page/bot-install/scene.test.ts index 6fc026151..1fa7bd426 100644 --- a/apps/web-foldkit/src/page/bot-install/scene.test.ts +++ b/apps/web-foldkit/src/page/bot-install/scene.test.ts @@ -85,6 +85,20 @@ describe("bot install page", () => { ) }) + 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", diff --git a/apps/web-foldkit/src/page/bot-install/view.ts b/apps/web-foldkit/src/page/bot-install/view.ts index fcb9ef2e4..f5cf2cc97 100644 --- a/apps/web-foldkit/src/page/bot-install/view.ts +++ b/apps/web-foldkit/src/page/bot-install/view.ts @@ -151,18 +151,29 @@ export const view = Submodel.defineView((model, ), homeLink(h), ], - Failed: ({ reason }) => [ - authHeading(h, "Couldn't load this app", commonMessage(reason).title), - button( - h, - { - intent: "outline", - className: "w-full", - onPress: Message.ClickedRetryRequest(), - }, - ["Try again"], - ), - ], + // 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/libs/bot-sdk/src/hazel-bot-sdk.ts b/libs/bot-sdk/src/hazel-bot-sdk.ts index 0670279a3..6a56514f8 100644 --- a/libs/bot-sdk/src/hazel-bot-sdk.ts +++ b/libs/bot-sdk/src/hazel-bot-sdk.ts @@ -628,16 +628,15 @@ export class HazelBotClient extends Context.Service()("HazelBotC * reconnect and stall the bot for good. */ const readableEnvelopes = (events: ReadonlyArray) => - events.flatMap((event) => { - const decoded = decodeEnvelope(event) - if (Option.isSome(decoded)) return [decoded.value] - Effect.runFork( - Effect.logWarning("Skipping a gateway event this SDK cannot read", { - eventType: eventTypeOf(event), - }), - ) - return [] - }) + 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>, @@ -859,7 +858,9 @@ export class HazelBotClient extends Context.Service()("HazelBotC case "DISPATCH": { Effect.runPromiseWith(services)( Effect.gen(function* () { - yield* processGatewayBatch(readableEnvelopes(frame.events)) + yield* processGatewayBatch( + yield* readableEnvelopes(frame.events), + ) yield* gatewaySessionStore.save( authContext.botId as BotId, frame.nextOffset, From ee1f670e2145cec198ed1c6100487aec22e790bd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 15:10:04 +0100 Subject: [PATCH 6/6] test(bots): a deleted author's message event names no author Co-Authored-By: Claude Opus 5.5 --- apps/backend/src/routes/object-routes.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/backend/src/routes/object-routes.test.ts b/apps/backend/src/routes/object-routes.test.ts index a9af36ad7..97a3d8cee 100644 --- a/apps/backend/src/routes/object-routes.test.ts +++ b/apps/backend/src/routes/object-routes.test.ts @@ -361,6 +361,35 @@ describe("public API (v1) for bots outside Hazel", () => { ]) 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,