| @@ -5,7 +5,7 @@ · 4 unmodified lines |
| 5 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| 6 | | 6 | |
| 7 | import { env } from "cloudflare:workers"; | 7 | import { env } from "cloudflare:workers"; |
| 8 | import { Context, Data, Effect, Layer } from "effect"; | 8 | import { Context, Data, Effect, Layer, Schema } from "effect"; |
| 9 | | 9 | |
| 10 | export class SlackError extends Data.TaggedError("SlackError")<{ | 10 | export class SlackError extends Data.TaggedError("SlackError")<{ |
| 11 | method: string; | 11 | method: string; |
| @@ -40,41 +40,82 @@ const randomSuffix = (): string => { · 28 unmodified lines |
| 40 | return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); | 40 | return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); |
| 41 | }; | 41 | }; |
| 42 | | 42 | |
| 43 | type SlackResponse = { ok: boolean; error?: string } & Record<string, unknown>; | 43 | const SlackErrorResponse = Schema.Struct({ |
| | 44 | ok: Schema.Literal(false), |
| | 45 | error: Schema.optional(Schema.String), |
| | 46 | }); |
| | 47 | |
| | 48 | const SlackChannel = Schema.Struct({ id: Schema.String, name: Schema.String }); |
| | 49 | |
| | 50 | const SlackPostMessageResponse = Schema.Struct({ ok: Schema.Literal(true) }); |
| | 51 | |
| | 52 | const SlackCreateChannelResponse = Schema.Struct({ |
| | 53 | ok: Schema.Literal(true), |
| | 54 | channel: SlackChannel, |
| | 55 | }); |
| | 56 | |
| | 57 | const SlackSharedInviteResponse = Schema.Struct({ |
| | 58 | ok: Schema.Literal(true), |
| | 59 | invite_id: Schema.String, |
| | 60 | url: Schema.String, |
| | 61 | }); |
| 44 | | 62 | |
| 45 | const make = Effect.sync(() => { | 63 | const make = Effect.sync(() => { |
| 46 | const token = env.SLACK_BOT_TOKEN; | 64 | const token = env.SLACK_BOT_TOKEN; |
| 47 | | 65 | |
| 48 | if (!token) { | 66 | if (!token) { |
| 49 | const notConfigured = (method: string) => | 67 | const notConfigured = (method: string) => |
| 50 | Effect.fail( | 68 | Effect.fail(new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" })); |
| 51 | new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" }), | | |
| 52 | ); | | |
| 53 | return { | 69 | return { |
| 54 | createConnectInvite: () => notConfigured("createConnectInvite"), | 70 | createConnectInvite: () => notConfigured("createConnectInvite"), |
| 55 | } satisfies ISlackService; | 71 | } satisfies ISlackService; |
| 56 | } | 72 | } |
| 57 | | 73 | |
| 58 | const call = <A extends SlackResponse>(method: string, body: Record<string, unknown>) => | 74 | const call = <A extends { ok: true }>( |
| 59 | Effect.tryPromise({ | 75 | method: string, |
| 60 | try: async (): Promise<A> => { | 76 | body: Record<string, unknown>, |
| 61 | const res = await fetch(`https://slack.com/api/${method}`, { | 77 | successSchema: Schema.Decoder<A>, |
| 62 | method: "POST", | 78 | ) => |
| 63 | headers: { | 79 | Effect.gen(function* () { |
| 64 | "content-type": "application/json; charset=utf-8", | 80 | const json = yield* Effect.tryPromise({ |
| 65 | authorization: `Bearer ${token}`, | 81 | try: async (): Promise<unknown> => { |
| 66 | }, | 82 | const res = await fetch(`https://slack.com/api/${method}`, { |
| 67 | body: JSON.stringify(body), | 83 | method: "POST", |
| 68 | }); | 84 | headers: { |
| 69 | const json = (await res.json()) as A; | 85 | "content-type": "application/json; charset=utf-8", |
| 70 | if (!json.ok) throw new Error(json.error ?? "unknown_slack_error"); | 86 | authorization: `Bearer ${token}`, |
| 71 | return json; | 87 | }, |
| 72 | }, | 88 | body: JSON.stringify(body), |
| 73 | catch: (cause) => | 89 | }); |
| 74 | new SlackError({ | 90 | return res.json(); |
| | 91 | }, |
| | 92 | catch: () => |
| | 93 | new SlackError({ |
| | 94 | method, |
| | 95 | error: "Failed to read Slack API response", |
| | 96 | }), |
| | 97 | }); |
| | 98 | |
| | 99 | const response = yield* Schema.decodeUnknownEffect( |
| | 100 | Schema.Union([successSchema, SlackErrorResponse]), |
| | 101 | )(json).pipe( |
| | 102 | Effect.mapError( |
| | 103 | () => |
| | 104 | new SlackError({ |
| | 105 | method, |
| | 106 | error: "Unexpected Slack API response", |
| | 107 | }), |
| | 108 | ), |
| | 109 | ); |
| | 110 | |
| | 111 | if (!response.ok) { |
| | 112 | return yield* new SlackError({ |
| 75 | method, | 113 | method, |
| 76 | error: cause instanceof Error ? cause.message : String(cause), | 114 | error: response.error ?? "unknown_slack_error", |
| 77 | }), | 115 | }); |
| | 116 | } |
| | 117 | |
| | 118 | return response; |
| 78 | }).pipe(Effect.withSpan(`slack.${method}`)); | 119 | }).pipe(Effect.withSpan(`slack.${method}`)); |
| 79 | | 120 | |
| 80 | const createConnectInvite: ISlackService["createConnectInvite"] = ({ | 121 | const createConnectInvite: ISlackService["createConnectInvite"] = ({ |
| @@ -87,10 +128,7 @@ const make = Effect.sync(() => { · 6 unmodified lines |
| 87 | // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace. | 128 | // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace. |
| 88 | const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80); | 129 | const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80); |
| 89 | const tryCreate = (n: string) => | 130 | const tryCreate = (n: string) => |
| 90 | call<SlackResponse & { channel: { id: string; name: string } }>( | 131 | call("conversations.create", { name: n, is_private: false }, SlackCreateChannelResponse); |
| 91 | "conversations.create", | | |
| 92 | { name: n, is_private: false }, | | |
| 93 | ); | | |
| 94 | | 132 | |
| 95 | const created = yield* tryCreate(baseName).pipe( | 133 | const created = yield* tryCreate(baseName).pipe( |
| 96 | Effect.catchTag("SlackError", (err) => | 134 | Effect.catchTag("SlackError", (err) => |
| @@ -108,12 +146,16 @@ const make = Effect.sync(() => { · 11 unmodified lines |
| 108 | note ? `Note: ${note}` : null, | 146 | note ? `Note: ${note}` : null, |
| 109 | ].filter(Boolean) as string[]; | 147 | ].filter(Boolean) as string[]; |
| 110 | | 148 | |
| 111 | yield* call<SlackResponse>("chat.postMessage", { | 149 | yield* call( |
| 112 | channel: channel.id, | 150 | "chat.postMessage", |
| 113 | text: helloLines.join("\n"), | 151 | { |
| 114 | }); | 152 | channel: channel.id, |
| | 153 | text: helloLines.join("\n"), |
| | 154 | }, |
| | 155 | SlackPostMessageResponse, |
| | 156 | ); |
| 115 | | 157 | |
| 116 | const invite = yield* call<SlackResponse & { invite_id: string; url: string }>( | 158 | const invite = yield* call( |
| 117 | "conversations.inviteShared", | 159 | "conversations.inviteShared", |
| 118 | { | 160 | { |
| 119 | channel: channel.id, | 161 | channel: channel.id, |
| @@ -122,15 +164,14 @@ const make = Effect.sync(() => { · 2 unmodified lines |
| 122 | // which is what we want for a focused 1:1 support conversation. | 164 | // which is what we want for a focused 1:1 support conversation. |
| 123 | external_limited: true, | 165 | external_limited: true, |
| 124 | }, | 166 | }, |
| | 167 | SlackSharedInviteResponse, |
| 125 | ); | 168 | ); |
| 126 | | 169 | |
| 127 | return { | 170 | return { |
| 128 | channel: { id: channel.id, name: channel.name }, | 171 | channel: { id: channel.id, name: channel.name }, |
| 129 | invite: { invite_id: invite.invite_id, url: invite.url }, | 172 | invite: { invite_id: invite.invite_id, url: invite.url }, |
| 130 | }; | 173 | }; |
| 131 | }).pipe( | 174 | }).pipe(Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } })); |
| 132 | Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }), | | |
| 133 | ); | | |
| 134 | | 175 | |
| 135 | return { createConnectInvite } satisfies ISlackService; | 176 | return { createConnectInvite } satisfies ISlackService; |
| 136 | }); | 177 | }); |