Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions packages/chat-platform/src/connectors/hazel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ declared once, in `api.ts`.

A half whose names are unset is skipped rather than fatal, as for every connector.

1. **Create the bot** with the scopes `messages:read`, `messages:write` and `channels:write` (the
last one opens threads), mark it **public**, and make it **mentionable**.
1. **Create the bot** with the scopes `messages:read`, `messages:write`, `channels:write` (opens
threads) and `channels:read` (lists the channels an alert can go to), mark it **public**, and
make it **mentionable**.
A bot created before alerts came through it lacks `channels:read`: add it on each instance's
bot (US and EU), or its channel list answers `auth` and no Hazel channel can be picked.
2. **Register the install redirect URIs** on the bot (Edit Application → Install redirect URIs):
`https://api.maple.dev/oauth/chat/hazel/callback`, plus the equivalent origin of any other
stage that shares this bot. Hazel compares them exactly.
Expand All @@ -40,7 +43,10 @@ its own bot, with its own redirect URI.
2. **Mention it.** `@Maple why is checkout slow?` in any channel. The bot opens a thread on the
message (`POST /api/v1/channels/{id}/threads`) and edits one message in it as the answer streams.
A follow-up inside that thread needs no mention.
3. **Decide what it proposes.** A write the agent wants to make is an embed with two buttons. A
3. **Send it alerts.** A Hazel channel is an alert destination like any other chat channel. The
picker lists what `GET /api/v1/organizations/{id}/bot-channels` answers: every public channel and the
private ones the bot was added to. Delivery posts as the bot.
4. **Decide what it proposes.** A write the agent wants to make is an embed with two buttons. A
press reaches the bot as an `interaction.create` gateway event, and the card is then edited with
what came of it, without the buttons.

Expand Down Expand Up @@ -75,7 +81,5 @@ pays for them; the host decides whether an unaddressed message is still a turn.

- **No private replies.** A whisper is a message in the conversation that mentions the person.
- **No typing indicator** for a bot; the placeholder message says it is working.
- **No alert destinations** through this connector: alerts reach Hazel through Maple's separate
Hazel webhook integration.
- **No uninstall event.** Removing the bot in Hazel does not unlink the workspace in Maple; answers
fail with `auth` until it is unlinked there.
3 changes: 2 additions & 1 deletion packages/chat-platform/src/connectors/hazel/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { HAZEL_CONNECTOR_ID } from "./id"
export const hazelManifest: ChatConnectorManifest = {
id: HAZEL_CONNECTOR_ID,
name: "Hazel",
description: "Add the Maple bot to a Hazel workspace and link it to your Maple organization.",
description:
"Add the Maple bot to a Hazel workspace: ask it questions in threads and post alerts to Hazel channels.",
icon: {
viewBox: "0 0 266 219",
paths: [
Expand Down
47 changes: 47 additions & 0 deletions packages/chat-platform/src/connectors/hazel/outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,4 +293,51 @@ describe("hazel transport", () => {
})
}).pipe(Effect.provide(http.layer))
})

it.effect("lists the channels the bot can post in as alert destinations", () => {
const http = stub([
[
"/organizations/",
{
status: 200,
body: JSON.stringify({
data: [
{ id: CHANNEL, name: "incidents", private: true },
{ id: THREAD, name: "general", private: false },
],
}),
},
],
])
return Effect.gen(function* () {
const transport = yield* hazelOutbound.transport
expect(yield* transport.destinations(ORG)).toEqual([
{ id: CHANNEL, name: "incidents", private: true },
{ id: THREAD, name: "general", private: false },
])
expect(http.seen[0]?.url).toBe(`https://api.hazel.sh/api/v1/organizations/${ORG}/bot-channels`)
expect(http.seen[0]?.headers.authorization).toBe("Bearer hzl_bot_token")
}).pipe(Effect.provide(http.layer))
})

it.effect("fails the listing as the bot's grant when Hazel refuses it", () => {
const http = stub([["/organizations/", { status: 403, body: "" }]])
return Effect.gen(function* () {
const transport = yield* hazelOutbound.transport
const refused = yield* Effect.flip(transport.destinations(ORG))
expect(refused).toMatchObject({ operation: "destinations", reason: "auth", status: 403 })
}).pipe(Effect.provide(http.layer))
})

it.effect("fails the listing when Hazel answers with something other than channels", () => {
const http = stub([["/organizations/", { status: 200, body: JSON.stringify({ data: [{ id: 1 }] }) }]])
return Effect.gen(function* () {
const transport = yield* hazelOutbound.transport
const unreadable = yield* Effect.flip(transport.destinations(ORG))
expect(unreadable).toMatchObject({
operation: "destinations",
message: "Hazel answered with no list of channels",
})
}).pipe(Effect.provide(http.layer))
})
})
24 changes: 20 additions & 4 deletions packages/chat-platform/src/connectors/hazel/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* The API (`@hazel/domain/http` `api-v1-messages`, `api-v1-bots`): create is `POST /messages`, edit
* is `PATCH /messages/{id}` naming its channel, history is `GET /messages?channel_id=` newest first
* with Stripe-style cursors, a thread is `POST /channels/{id}/threads` (idempotent: a message's
* thread is answered again), and the people behind ids are `GET /organizations/{id}/users?ids=`.
* thread is answered again), the people behind ids are `GET /organizations/{id}/users?ids=`, and
* the channels the bot can post in are `GET /organizations/{id}/bot-channels`.
* A thread IS a channel, so once a turn answers in one every call addresses the thread's id.
*/
import { ChatConversationKey } from "@maple/primitives"
Expand Down Expand Up @@ -84,6 +85,11 @@ const Users = Schema.Struct({
),
})

/** The public channels, and the private ones the bot was added to, already sorted by name. */
const Channels = Schema.Struct({
data: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String, private: Schema.Boolean })),
})

/** Who an id is, as far as the model's context names them. */
interface Person {
readonly displayName: string
Expand Down Expand Up @@ -353,10 +359,20 @@ export const hazelOutbound: ChatOutbound<HttpClient.HttpClient | ConnectorCreden
}),

/**
* None: alerts reach Hazel through Maple's Hazel webhook integration, which provisions a
* channel webhook per destination. The bot API lists no channels for a bot to pick from.
* What Hazel says the bot may post in: every public channel of the workspace and the
* private ones it was added to. Threads and direct messages are never listed.
*/
destinations: () => Effect.succeed([]),
destinations: Effect.fn("Hazel.destinations")(function* (workspaceId: string) {
const response = yield* send(
"destinations",
"/organizations/{organization_id}/bot-channels",
HttpClientRequest.get(
`${API_BASE}/organizations/${encodeURIComponent(workspaceId)}/bot-channels`,
),
)
const page = yield* read("destinations", response, Channels, "no list of channels")
return page.data
}),

/**
* A mention is answered in a thread on it, so a channel keeps reading as a channel. On
Expand Down
Loading