@effect-uai/slack 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Slack.ts CHANGED
@@ -3,7 +3,6 @@ import {
3
3
  type Cause,
4
4
  Context,
5
5
  Effect,
6
- Encoding,
7
6
  Layer,
8
7
  Match,
9
8
  Option,
@@ -12,7 +11,9 @@ import {
12
11
  type Scope,
13
12
  Stream,
14
13
  } from "effect"
15
- import { HttpClient } from "effect/unstable/http"
14
+ import { Base64 } from "effect/encoding"
15
+ import { HttpClient } from "effect/http"
16
+ import * as Socket from "effect/socket/Socket"
16
17
  import type { MediaSource } from "@effect-uai/core/Media"
17
18
  import {
18
19
  CurrentConversation,
@@ -225,7 +226,10 @@ export const make = (
225
226
  ),
226
227
  )
227
228
 
228
- const session = yield* SocketMode.connect({ open })
229
+ // The ticket is in the URL, so every runtime's global `WebSocket` is enough.
230
+ const session = yield* SocketMode.connect({ open }).pipe(
231
+ Effect.provide(Socket.layerWebSocketConstructorGlobal),
232
+ )
229
233
  const toEvents = Events.toEvents({ bot, replyIn: cfg.replyIn ?? "thread" })
230
234
  const inbox = yield* Queue.unbounded<InboundEvent, MessengerError.MessengerError | Cause.Done>()
231
235
 
@@ -298,7 +302,7 @@ export const make = (
298
302
  Match.tag("url", ({ url }) => sendUrl(at, msg, body, url)),
299
303
  Match.tag("bytes", ({ bytes, mimeType }) => sendBytes(at, msg, body, bytes, mimeType)),
300
304
  Match.tag("base64", ({ base64, mimeType }) =>
301
- Effect.fromResult(Encoding.decodeBase64(base64)).pipe(
305
+ Effect.fromResult(Base64.decode(base64)).pipe(
302
306
  Effect.mapError(invalidMedia),
303
307
  Effect.flatMap((bytes) => sendBytes(at, msg, body, bytes, mimeType)),
304
308
  ),
@@ -1,10 +1,5 @@
1
1
  import { Data, Duration, Effect, Option, Redacted, Schema } from "effect"
2
- import {
3
- Headers,
4
- HttpClient,
5
- HttpClientRequest,
6
- type HttpClientResponse,
7
- } from "effect/unstable/http"
2
+ import { Headers, HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"
8
3
  import * as MessengerError from "@effect-uai/core/MessengerError"
9
4
 
10
5
  export const provider = "slack"
@@ -1,6 +1,32 @@
1
- import { Option } from "effect"
1
+ import { it as effect } from "@effect/vitest"
2
+ import { Effect, Exit, Layer, Option, Queue, Ref } from "effect"
3
+ import * as Socket from "effect/socket/Socket"
2
4
  import { describe, expect, it } from "vitest"
3
- import { classifyDisconnect, remember } from "./socket.js"
5
+ import * as MessengerError from "@effect-uai/core/MessengerError"
6
+ import * as FakeWebSocket from "@effect-uai/core/testing/FakeWebSocket"
7
+ import { classifyDisconnect, connect, remember } from "./socket.js"
8
+
9
+ const command = (envelopeId: string) => ({
10
+ type: "slash_commands",
11
+ envelope_id: envelopeId,
12
+ payload: { channel_id: "C1", user_id: "U1", command: "/ping" },
13
+ })
14
+
15
+ /** Each connection dials its own single-use URL, so each gets its own fake server. */
16
+ const slack = (connections: number) =>
17
+ Effect.gen(function* () {
18
+ const servers = yield* Effect.forEach(Array.from({ length: connections }), () =>
19
+ FakeWebSocket.make({ greeting: [{ type: "hello" }] }),
20
+ )
21
+ const opened = yield* Ref.make(0)
22
+ const open = Ref.modify(opened, (n): [string, number] => [`wss://slack.test/${n}`, n + 1])
23
+ const dial: Socket.WebSocketConstructor["Service"] = (url, protocols) =>
24
+ servers[Number(url.split("/").pop())]!.connect(url, protocols)
25
+ const session = yield* connect({ open }).pipe(
26
+ Effect.provide(Layer.succeed(Socket.WebSocketConstructor, dial)),
27
+ )
28
+ return { servers, opened: Ref.get(opened), session }
29
+ })
4
30
 
5
31
  describe("classifyDisconnect", () => {
6
32
  it("ends only on the reason Slack says not to retry", () => {
@@ -23,3 +49,82 @@ describe("remember", () => {
23
49
  expect(Option.getOrThrow(remember(full, "c", 2))).toEqual(["b", "c"])
24
50
  })
25
51
  })
52
+
53
+ describe("connect", () => {
54
+ effect.live("acknowledges an envelope before handing it on", () =>
55
+ Effect.gen(function* () {
56
+ const { servers, session } = yield* slack(1)
57
+ yield* servers[0]!.push(command("e1"))
58
+
59
+ const incoming = yield* Queue.take(session.envelopes)
60
+ expect(incoming.envelope.envelope_id).toBe("e1")
61
+ yield* servers[0]!.awaitSent(1)
62
+ expect(yield* servers[0]!.sent).toEqual([{ envelope_id: "e1" }])
63
+ }).pipe(Effect.scoped),
64
+ )
65
+
66
+ effect.live("fails to connect when no URL can be had before hello", () =>
67
+ Effect.gen(function* () {
68
+ const exit = yield* connect({
69
+ open: Effect.fail(
70
+ new MessengerError.MessengerConnectFailed({
71
+ provider: "slack",
72
+ reason: "invalid_auth",
73
+ raw: undefined,
74
+ }),
75
+ ),
76
+ }).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal), Effect.exit)
77
+
78
+ expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("invalid_auth")
79
+ }).pipe(Effect.scoped),
80
+ )
81
+
82
+ // Clean or dirty, a close Slack did not announce is a drop, and every drop reconnects.
83
+ for (const code of [1000, 1006]) {
84
+ effect.live(`reconnects on a fresh URL after a ${code} close`, () =>
85
+ Effect.gen(function* () {
86
+ const { servers, opened, session } = yield* slack(2)
87
+ yield* servers[0]!.close(code)
88
+ yield* servers[1]!.greeted
89
+ yield* servers[1]!.push(command("e2"))
90
+
91
+ const incoming = yield* Queue.take(session.envelopes)
92
+ expect(incoming.envelope.envelope_id).toBe("e2")
93
+ expect(yield* opened).toBe(2)
94
+ yield* servers[1]!.awaitSent(1)
95
+ expect(yield* servers[1]!.sent).toEqual([{ envelope_id: "e2" }])
96
+ }).pipe(Effect.scoped),
97
+ )
98
+ }
99
+
100
+ effect.live("closes and reconnects on a refresh disconnect", () =>
101
+ Effect.gen(function* () {
102
+ const { servers, opened, session } = yield* slack(2)
103
+ yield* servers[0]!.push({ type: "disconnect", reason: "refresh_requested" })
104
+ yield* servers[1]!.greeted
105
+ yield* servers[1]!.push(command("e3"))
106
+
107
+ const incoming = yield* Queue.take(session.envelopes)
108
+ expect(incoming.envelope.envelope_id).toBe("e3")
109
+ expect(yield* opened).toBe(2)
110
+ }).pipe(Effect.scoped),
111
+ )
112
+
113
+ effect.live("ends envelopes on a disconnect Slack says not to retry", () =>
114
+ Effect.gen(function* () {
115
+ const { servers, opened, session } = yield* slack(1)
116
+ yield* servers[0]!.push({ type: "disconnect", reason: "link_disabled" })
117
+
118
+ const exit = yield* Effect.exit(Queue.take(session.envelopes))
119
+ expect(exit).toEqual(
120
+ Exit.fail(
121
+ new MessengerError.MessengerTransportClosed({
122
+ provider: "slack",
123
+ reason: "link_disabled",
124
+ }),
125
+ ),
126
+ )
127
+ expect(yield* opened).toBe(1)
128
+ }).pipe(Effect.scoped),
129
+ )
130
+ })
@@ -10,9 +10,11 @@ import {
10
10
  Ref,
11
11
  Schema,
12
12
  type Scope,
13
+ Stream,
13
14
  } from "effect"
14
- import * as Socket from "effect/unstable/socket/Socket"
15
+ import * as Socket from "effect/socket/Socket"
15
16
  import * as MessengerError from "@effect-uai/core/MessengerError"
17
+ import { isCleanClose } from "@effect-uai/core/WebSocketSession"
16
18
  import * as Events from "./events.js"
17
19
  import { provider } from "./api.js"
18
20
 
@@ -108,7 +110,11 @@ const backoff = (attempt: number): Duration.Duration =>
108
110
  */
109
111
  export const connect = (
110
112
  cfg: Config,
111
- ): Effect.Effect<Session, MessengerError.MessengerConnectFailed, Scope.Scope> =>
113
+ ): Effect.Effect<
114
+ Session,
115
+ MessengerError.MessengerConnectFailed,
116
+ Scope.Scope | Socket.WebSocketConstructor
117
+ > =>
112
118
  Effect.gen(function* () {
113
119
  const envelopes = yield* Queue.unbounded<Incoming, MessengerError.MessengerError | Cause.Done>()
114
120
  const ready = yield* Deferred.make<void, MessengerError.MessengerConnectFailed>()
@@ -119,20 +125,19 @@ export const connect = (
119
125
 
120
126
  const once = Effect.gen(function* () {
121
127
  const url = yield* cfg.open
122
- const socket = yield* Socket.makeWebSocket(url, {
123
- // Effect treats every close as an error by default; the standard clean
124
- // codes are not, and Slack's close codes say nothing a reconnect
125
- // cannot fix, so a `disconnect` frame is the only fatal signal.
126
- closeCodeIsError: (code: number) => code !== 1000 && code !== 1001 && code !== 1005,
127
- // The ticket is in the URL, so every runtime's global `WebSocket` is enough.
128
- }).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal))
129
- const write = yield* socket.writer
128
+ const socket = yield* Socket.makeWebSocket(url)
129
+ const { write } = yield* socket.writer
130
+ // The writer waits for a reconnect that never comes on this URL, so
131
+ // nothing is written once this connection's reader has ended.
132
+ const closed = yield* Ref.make(false)
133
+ const send = (chunk: string | Socket.CloseEvent) =>
134
+ Effect.flatMap(Ref.get(closed), (isClosed) => (isClosed ? Effect.void : write(chunk)))
130
135
  // Set by a `disconnect` frame, the one end a close code cannot express.
131
136
  const requested = yield* Ref.make(Option.none<Ended>())
132
137
 
133
138
  // Acknowledged before the envelope is offered, well inside Slack's three
134
139
  // second deadline, so the platform is never waiting on the recipe.
135
- const acknowledge = (envelopeId: string) => write(JSON.stringify({ envelope_id: envelopeId }))
140
+ const acknowledge = (envelopeId: string) => send(JSON.stringify({ envelope_id: envelopeId }))
136
141
 
137
142
  // A redelivery is acknowledged like any other envelope and then dropped,
138
143
  // so Slack stops retrying it and the recipe never sees it twice.
@@ -152,7 +157,7 @@ export const connect = (
152
157
  Effect.gen(function* () {
153
158
  const action = classifyDisconnect(reason)
154
159
  yield* Ref.set(requested, Option.some<Ended>({ action, reason: reason ?? "disconnect" }))
155
- yield* write(new Socket.CloseEvent(1000, reason ?? "disconnect"))
160
+ yield* send(new Socket.CloseEvent(1000, reason ?? "disconnect"))
156
161
  })
157
162
 
158
163
  const handle = (text: string) =>
@@ -170,9 +175,13 @@ export const connect = (
170
175
  )
171
176
  }).pipe(Effect.ignore)
172
177
 
173
- // A clean 1000 is only ever our own teardown or a disconnect we asked
178
+ // A clean close is only ever our own teardown or a disconnect we asked
174
179
  // for; anything else is a drop, and every drop reconnects.
175
- const ended = yield* socket.runString(handle).pipe(
180
+ const ended = yield* Stream.fromPull(Socket.readerString(socket)).pipe(
181
+ Stream.scoped,
182
+ Stream.catchIf(isCleanClose, () => Stream.empty),
183
+ Stream.runForEach(handle),
184
+ Effect.ensuring(Ref.set(closed, true)),
176
185
  Effect.as<Ended>({ action: "reconnect", reason: "closed" }),
177
186
  Effect.catch((error: Socket.SocketError) =>
178
187
  Effect.succeed<Ended>({ action: "reconnect", reason: reasonOf(error) }),