@effect-uai/discord 0.17.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.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/dist/Discord.d.mts +3 -3
  3. package/dist/Discord.d.mts.map +1 -1
  4. package/dist/Discord.mjs +7 -5
  5. package/dist/Discord.mjs.map +1 -1
  6. package/dist/dist-DHer1cql.mjs +1533 -0
  7. package/dist/dist-DHer1cql.mjs.map +1 -0
  8. package/dist/{gateway-C_Ae1HYB.d.mts → gateway-CYyZYm4m.d.mts} +3 -2
  9. package/dist/gateway-CYyZYm4m.d.mts.map +1 -0
  10. package/dist/{test.DNmyFkvJ-N3QqMRwZ.mjs → index.C4tKZ0yW-CUBm3Bmz.mjs} +9446 -5818
  11. package/dist/index.C4tKZ0yW-CUBm3Bmz.mjs.map +1 -0
  12. package/dist/internal/events.test.mjs +1 -1
  13. package/dist/internal/gateway.d.mts +1 -1
  14. package/dist/internal/gateway.mjs +23 -14
  15. package/dist/internal/gateway.mjs.map +1 -1
  16. package/dist/internal/gateway.test.mjs +259 -7
  17. package/dist/internal/gateway.test.mjs.map +1 -1
  18. package/dist/internal/rest.d.mts +1 -1
  19. package/dist/internal/rest.mjs +1 -1
  20. package/dist/internal/rest.mjs.map +1 -1
  21. package/dist/{rest-CZSW-VJK.d.mts → rest-SmIBt9Nh.d.mts} +2 -2
  22. package/dist/{rest-CZSW-VJK.d.mts.map → rest-SmIBt9Nh.d.mts.map} +1 -1
  23. package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-EAsCxrDo.mjs} +2 -1
  24. package/package.json +4 -4
  25. package/src/Discord.ts +7 -4
  26. package/src/internal/gateway.test.ts +87 -1
  27. package/src/internal/gateway.ts +32 -22
  28. package/src/internal/rest.ts +1 -1
  29. package/dist/gateway-C_Ae1HYB.d.mts.map +0 -1
  30. package/dist/magic-string.es-CUAnjKYC.mjs +0 -1014
  31. package/dist/magic-string.es-CUAnjKYC.mjs.map +0 -1
  32. package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs.map +0 -1
@@ -1,5 +1,6 @@
1
1
  //#region \0rolldown/runtime.js
2
2
  var __defProp = Object.defineProperty;
3
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
3
4
  var __exportAll = (all, no_symbols) => {
4
5
  let target = {};
5
6
  for (var name in all) __defProp(target, name, {
@@ -10,4 +11,4 @@ var __exportAll = (all, no_symbols) => {
10
11
  return target;
11
12
  };
12
13
  //#endregion
13
- export { __exportAll as t };
14
+ export { __exportAll as n, __commonJSMin as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/discord",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Discord provider for @effect-uai/core (Messenger capability over the gateway and REST API).",
5
5
  "keywords": [
6
6
  "agents",
@@ -44,13 +44,13 @@
44
44
  "access": "public"
45
45
  },
46
46
  "devDependencies": {
47
- "effect": "4.0.0-rc.111",
47
+ "effect": "4.0.1",
48
48
  "typescript": "^7.0.2",
49
- "@effect-uai/core": "0.17.0"
49
+ "@effect-uai/core": "0.18.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@effect-uai/core": ">=0.15.0 <1",
53
- "effect": ">=4.0.0-rc.111 <5.0.0"
53
+ "effect": ">=4.0.1 <5.0.0"
54
54
  },
55
55
  "scripts": {
56
56
  "build": "tsdown",
package/src/Discord.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,
@@ -13,7 +12,9 @@ import {
13
12
  type Scope,
14
13
  Stream,
15
14
  } from "effect"
16
- import { HttpClient } from "effect/unstable/http"
15
+ import { Base64 } from "effect/encoding"
16
+ import { HttpClient } from "effect/http"
17
+ import * as Socket from "effect/socket/Socket"
17
18
  import type { MediaSource } from "@effect-uai/core/Media"
18
19
  import {
19
20
  CurrentConversation,
@@ -208,11 +209,13 @@ export const make = (
208
209
 
209
210
  // -- inbound -----------------------------------------------------------
210
211
 
212
+ // The gateway takes its token in the identify payload, not a header, so
213
+ // every runtime's global `WebSocket` is enough.
211
214
  const session = yield* Gateway.connect({
212
215
  token: cfg.token,
213
216
  intents: cfg.intents ?? defaultIntents,
214
217
  url,
215
- })
218
+ }).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal))
216
219
  const toEvents = Events.toEvents(session.bot)
217
220
  const inbox = yield* Queue.unbounded<InboundEvent, MessengerError.MessengerError | Cause.Done>()
218
221
 
@@ -280,7 +283,7 @@ export const make = (
280
283
  Match.tag("url", ({ url }) => sendUrl(at, fields, body, url)),
281
284
  Match.tag("bytes", ({ bytes, mimeType }) => send(bytes, mimeType)),
282
285
  Match.tag("base64", ({ base64, mimeType }) =>
283
- Effect.fromResult(Encoding.decodeBase64(base64)).pipe(
286
+ Effect.fromResult(Base64.decode(base64)).pipe(
284
287
  Effect.mapError(invalidMedia),
285
288
  Effect.flatMap((bytes) => send(bytes, mimeType)),
286
289
  ),
@@ -1,5 +1,9 @@
1
+ import { it as effect } from "@effect/vitest"
2
+ import { Effect, Layer, Queue, Redacted } from "effect"
3
+ import * as Socket from "effect/socket/Socket"
4
+ import * as FakeWebSocket from "@effect-uai/core/testing/FakeWebSocket"
1
5
  import { describe, expect, it } from "vitest"
2
- import { classifyClose, closeReason } from "./gateway.js"
6
+ import { classifyClose, closeReason, connect } from "./gateway.js"
3
7
 
4
8
  describe("classifyClose", () => {
5
9
  it("never retries a close reconnecting cannot fix", () => {
@@ -25,3 +29,85 @@ describe("closeReason", () => {
25
29
  expect(closeReason(1006, "connection reset")).toBe("connection reset")
26
30
  })
27
31
  })
32
+
33
+ const cfg = { token: Redacted.make("token"), intents: 0, url: "wss://gateway.test" }
34
+
35
+ // Long enough that no heartbeat lands mid-test.
36
+ const HELLO = { op: 10, d: { heartbeat_interval: 3_600_000 } }
37
+
38
+ const READY = {
39
+ op: 0,
40
+ t: "READY",
41
+ s: 1,
42
+ d: {
43
+ user: { id: "1", username: "bot" },
44
+ session_id: "session",
45
+ resume_gateway_url: "wss://resume.test",
46
+ },
47
+ }
48
+
49
+ // Identify gets READY, resume gets RESUMED.
50
+ const answers: Record<number, ReadonlyArray<unknown>> = {
51
+ 2: [READY],
52
+ 6: [{ op: 0, t: "RESUMED", s: 2 }],
53
+ }
54
+
55
+ const gateway = FakeWebSocket.make({
56
+ greeting: [HELLO],
57
+ reply: (frame) => Effect.succeed(answers[(frame as { op: number }).op] ?? []),
58
+ })
59
+
60
+ /** One fake per connection, in dial order; the last one takes any extra dials. */
61
+ const dialing = (servers: ReadonlyArray<FakeWebSocket.FakeWebSocketServer>) => {
62
+ const urls: Array<string> = []
63
+ const layer = Layer.succeed(Socket.WebSocketConstructor)((url, protocols) => {
64
+ const server = servers[Math.min(urls.length, servers.length - 1)]!
65
+ urls.push(url)
66
+ return server.connect(url, protocols)
67
+ })
68
+ return { urls, layer }
69
+ }
70
+
71
+ describe("gateway close handling", () => {
72
+ effect.live.each([1000, 4000])("resumes on the resume host after a %i close", (code) =>
73
+ Effect.gen(function* () {
74
+ const first = yield* gateway
75
+ const second = yield* gateway
76
+ const { urls, layer } = dialing([first, second])
77
+ const session = yield* connect(cfg).pipe(Effect.provide(layer))
78
+ expect(session.bot.username).toBe("bot")
79
+
80
+ yield* first.close(code)
81
+ yield* second.awaitSent(1)
82
+
83
+ expect(urls[1]).toMatch(/^wss:\/\/resume\.test\//)
84
+ expect(yield* second.sent).toEqual([
85
+ { op: 6, d: { token: "token", session_id: "session", seq: 1 } },
86
+ ])
87
+ }),
88
+ )
89
+
90
+ effect.live("fails the dispatches on a fatal close after READY", () =>
91
+ Effect.gen(function* () {
92
+ const server = yield* gateway
93
+ const session = yield* connect(cfg).pipe(Effect.provide(dialing([server]).layer))
94
+
95
+ yield* server.close(4014)
96
+ const error = yield* Queue.take(session.dispatches).pipe(Effect.flip)
97
+
98
+ expect(error).toMatchObject({ _tag: "MessengerTransportClosed", raw: { code: 4014 } })
99
+ }),
100
+ )
101
+
102
+ effect.live("fails the connect on a fatal close before READY", () =>
103
+ Effect.gen(function* () {
104
+ const server = yield* FakeWebSocket.make({ greeting: [HELLO] })
105
+ yield* server.awaitSent(1).pipe(Effect.andThen(server.close(4004)), Effect.forkScoped)
106
+
107
+ const error = yield* connect(cfg).pipe(Effect.provide(dialing([server]).layer), Effect.flip)
108
+
109
+ expect(error._tag).toBe("MessengerConnectFailed")
110
+ expect(error.reason).toContain("authentication failed")
111
+ }),
112
+ )
113
+ })
@@ -11,9 +11,11 @@ import {
11
11
  Schedule,
12
12
  Schema,
13
13
  type Scope,
14
+ Stream,
14
15
  } from "effect"
15
- import * as Socket from "effect/unstable/socket/Socket"
16
+ import * as Socket from "effect/socket/Socket"
16
17
  import * as MessengerError from "@effect-uai/core/MessengerError"
18
+ import { isCleanClose } from "@effect-uai/core/WebSocketSession"
17
19
  import * as Events from "./events.js"
18
20
  import { provider } from "./rest.js"
19
21
 
@@ -140,6 +142,14 @@ const socketReason = (error: Socket.SocketError): string =>
140
142
  ? (error.reason.closeReason ?? error.reason.message)
141
143
  : error.reason.message
142
144
 
145
+ // Effect's `Socket` fails on every close. A clean one is only ever our own
146
+ // teardown; anything else carries the close code the classifier reads.
147
+ const endedBy = (error: Socket.SocketError): Ended => {
148
+ if (isCleanClose(error)) return { action: "resume", code: 1000, reason: "closed" }
149
+ const code = socketCode(error)
150
+ return { action: classifyClose(code), code, reason: closeReason(code, socketReason(error)) }
151
+ }
152
+
143
153
  // Discord asks for a random 1-5 s pause before identifying on a fresh session.
144
154
  const freshSessionDelay = Effect.sync(() => Duration.millis(1000 + Math.random() * 4000))
145
155
 
@@ -159,7 +169,11 @@ const identity = { os: "effect-uai", browser: "effect-uai", device: "effect-uai"
159
169
  */
160
170
  export const connect = (
161
171
  cfg: Config,
162
- ): Effect.Effect<Session, MessengerError.MessengerConnectFailed, Scope.Scope> =>
172
+ ): Effect.Effect<
173
+ Session,
174
+ MessengerError.MessengerConnectFailed,
175
+ Scope.Scope | Socket.WebSocketConstructor
176
+ > =>
163
177
  Effect.gen(function* () {
164
178
  const dispatches = yield* Queue.unbounded<
165
179
  Incoming,
@@ -168,6 +182,8 @@ export const connect = (
168
182
  const ready = yield* Deferred.make<Events.BotIdentity, MessengerError.MessengerConnectFailed>()
169
183
  const resume = yield* Ref.make(Option.none<Resume>())
170
184
  const attempts = yield* Ref.make(0)
185
+ // Outlives a connection: a resume replays from the last sequence seen.
186
+ const seq = yield* Ref.make(Option.none<number>())
171
187
 
172
188
  // -- one connection ----------------------------------------------------
173
189
 
@@ -176,16 +192,16 @@ export const connect = (
176
192
  // A resumable session has its own host; a fresh one starts at the URL
177
193
  // `GET /gateway/bot` gave us.
178
194
  const host = Option.match(from, { onNone: () => cfg.url, onSome: (r: Resume) => r.url })
179
- const socket = yield* Socket.makeWebSocket(`${host}/?v=10&encoding=json`, {
180
- // Effect treats every close as an error by default; the standard clean
181
- // codes are not, and Discord's 4xxx ones are what drives `classifyClose`.
182
- closeCodeIsError: (code: number) => code !== 1000 && code !== 1001 && code !== 1005,
183
- // The gateway takes its token in the identify payload, not a header,
184
- // so every runtime's global `WebSocket` is enough.
185
- }).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal))
186
- const write = yield* socket.writer
195
+ const socket = yield* Socket.makeWebSocket(`${host}/?v=10&encoding=json`)
196
+ const writer = yield* socket.writer
197
+ // The writer waits for the next connection rather than failing, so a
198
+ // beat or handshake after this one's reader ended is dropped here.
199
+ const closed = yield* Ref.make(false)
200
+ const write = (chunk: string | Socket.CloseEvent) =>
201
+ Effect.flatMap(Ref.get(closed), (isClosed) =>
202
+ isClosed ? Effect.void : writer.write(chunk),
203
+ )
187
204
  const send = (payload: unknown) => write(JSON.stringify(payload))
188
- const seq = yield* Ref.make(Option.none<number>())
189
205
  const acked = yield* Ref.make(true)
190
206
  // Set by op 9 with `d: false`, the one case the close code cannot express.
191
207
  const requested = yield* Ref.make(Option.none<CloseAction>())
@@ -301,18 +317,12 @@ export const connect = (
301
317
  )
302
318
  }).pipe(Effect.ignore)
303
319
 
304
- // A clean 1000 is only ever our own teardown; anything else carries the
305
- // close code the classifier reads.
306
- const ended = yield* socket.runString(handle).pipe(
320
+ const ended = yield* Stream.fromPull(Socket.readerString(socket)).pipe(
321
+ Stream.scoped,
322
+ Stream.runForEach(handle),
323
+ Effect.ensuring(Ref.set(closed, true)),
307
324
  Effect.as<Ended>({ action: "resume", code: 1000, reason: "closed" }),
308
- Effect.catch((error: Socket.SocketError) => {
309
- const code = socketCode(error)
310
- return Effect.succeed<Ended>({
311
- action: classifyClose(code),
312
- code,
313
- reason: closeReason(code, socketReason(error)),
314
- })
315
- }),
325
+ Effect.catch((error: Socket.SocketError) => Effect.succeed(endedBy(error))),
316
326
  )
317
327
  const override = yield* Ref.get(requested)
318
328
  return Option.match(override, {
@@ -1,5 +1,5 @@
1
1
  import { Data, Duration, Effect, Redacted, Schema } from "effect"
2
- import { HttpClient, HttpClientRequest } from "effect/unstable/http"
2
+ import { HttpClient, HttpClientRequest } from "effect/http"
3
3
  import * as MessengerError from "@effect-uai/core/MessengerError"
4
4
  import * as Multipart from "@effect-uai/core/Multipart"
5
5
 
@@ -1 +0,0 @@
1
- {"version":3,"file":"gateway-C_Ae1HYB.d.mts","names":[],"sources":["../src/internal/gateway.ts"],"mappings":";;;;cAmCa,OAAK,OAAA;;;;;;;;KAKN,eAAe,MAAM;;cAGpB,aAAW,OAAA;gBAAX,OAAA;;;KAoBD;;;;;cAmBC,gBAAa,iBAAmB;;cAchC,cAAW,cAAgB;KAY5B;WACD,OAAO,SAAS;WAChB;;WAEA;;;KAIC;WACD,UAAU;WACV;;KAGC;WACD,KAAK;;;;;WAKL,YAAY,MAAM,MAAM,UAAU,eAAe,iBAAiB,MAAM;;;;;;;;;;cAgCtE,UAAO,KACb,WACJ,OAAO,OAAO,SAAS,eAAe,wBAAwB,MAAM"}