@effect-uai/slack 0.17.0 → 0.19.0-dev-20261109183959
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/README.md +2 -2
- package/dist/Slack.d.mts +3 -3
- package/dist/Slack.d.mts.map +1 -1
- package/dist/Slack.mjs +7 -5
- package/dist/Slack.mjs.map +1 -1
- package/dist/{api-BBgliKo7.d.mts → api-CbBW1iF0.d.mts} +2 -2
- package/dist/{api-BBgliKo7.d.mts.map → api-CbBW1iF0.d.mts.map} +1 -1
- package/dist/dist-DHer1cql.mjs +1533 -0
- package/dist/dist-DHer1cql.mjs.map +1 -0
- package/dist/{test.DNmyFkvJ-N3QqMRwZ.mjs → index.C4tKZ0yW-CUBm3Bmz.mjs} +9446 -5818
- package/dist/index.C4tKZ0yW-CUBm3Bmz.mjs.map +1 -0
- package/dist/internal/api.d.mts +1 -1
- package/dist/internal/api.mjs +1 -1
- package/dist/internal/api.mjs.map +1 -1
- package/dist/internal/events.test.mjs +1 -1
- package/dist/internal/socket.d.mts +1 -1
- package/dist/internal/socket.mjs +10 -7
- package/dist/internal/socket.mjs.map +1 -1
- package/dist/internal/socket.test.mjs +271 -6
- package/dist/internal/socket.test.mjs.map +1 -1
- package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-EAsCxrDo.mjs} +2 -1
- package/dist/{socket-DJgdt_ka.d.mts → socket-qV7yc2q7.d.mts} +3 -2
- package/dist/socket-qV7yc2q7.d.mts.map +1 -0
- package/package.json +5 -5
- package/src/Slack.ts +8 -4
- package/src/internal/api.ts +1 -6
- package/src/internal/socket.test.ts +107 -2
- package/src/internal/socket.ts +23 -14
- package/dist/magic-string.es-CUAnjKYC.mjs +0 -1014
- package/dist/magic-string.es-CUAnjKYC.mjs.map +0 -1
- package/dist/socket-DJgdt_ka.d.mts.map +0 -1
- package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-uai/slack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0-dev-20261109183959",
|
|
4
4
|
"description": "Slack provider for @effect-uai/core (Messenger capability over Socket Mode and the Web API).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -44,13 +44,13 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"effect": "4.
|
|
47
|
+
"effect": "4.1.2",
|
|
48
48
|
"typescript": "^7.0.2",
|
|
49
|
-
"@effect-uai/core": "0.
|
|
49
|
+
"@effect-uai/core": "0.19.0-dev-20261109183959"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@effect-uai/core": "
|
|
53
|
-
"effect": ">=4.0.
|
|
52
|
+
"@effect-uai/core": "0.19.0-dev-20261109183959",
|
|
53
|
+
"effect": ">=4.0.1 <5.0.0"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
56
|
"build": "tsdown",
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
),
|
package/src/internal/api.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
+
})
|
package/src/internal/socket.ts
CHANGED
|
@@ -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/
|
|
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<
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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) =>
|
|
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*
|
|
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
|
|
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*
|
|
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) }),
|