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