@effect-uai/discord 0.14.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/Discord.d.mts +55 -0
  4. package/dist/Discord.d.mts.map +1 -0
  5. package/dist/Discord.mjs +196 -0
  6. package/dist/Discord.mjs.map +1 -0
  7. package/dist/events-Du1u-kVV.d.mts +172 -0
  8. package/dist/events-Du1u-kVV.d.mts.map +1 -0
  9. package/dist/gateway-C_Ae1HYB.d.mts +57 -0
  10. package/dist/gateway-C_Ae1HYB.d.mts.map +1 -0
  11. package/dist/index.d.mts +2 -0
  12. package/dist/index.mjs +2 -0
  13. package/dist/internal/events.d.mts +2 -0
  14. package/dist/internal/events.mjs +109 -0
  15. package/dist/internal/events.mjs.map +1 -0
  16. package/dist/internal/events.test.d.mts +1 -0
  17. package/dist/internal/events.test.mjs +130 -0
  18. package/dist/internal/events.test.mjs.map +1 -0
  19. package/dist/internal/gateway.d.mts +2 -0
  20. package/dist/internal/gateway.mjs +216 -0
  21. package/dist/internal/gateway.mjs.map +1 -0
  22. package/dist/internal/gateway.test.d.mts +1 -0
  23. package/dist/internal/gateway.test.mjs +40 -0
  24. package/dist/internal/gateway.test.mjs.map +1 -0
  25. package/dist/internal/rest.d.mts +2 -0
  26. package/dist/internal/rest.mjs +89 -0
  27. package/dist/internal/rest.mjs.map +1 -0
  28. package/dist/magic-string.es-CUAnjKYC.mjs +1014 -0
  29. package/dist/magic-string.es-CUAnjKYC.mjs.map +1 -0
  30. package/dist/rest-CZSW-VJK.d.mts +44 -0
  31. package/dist/rest-CZSW-VJK.d.mts.map +1 -0
  32. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  33. package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs +13658 -0
  34. package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs.map +1 -0
  35. package/package.json +59 -0
  36. package/src/Discord.ts +393 -0
  37. package/src/index.ts +1 -0
  38. package/src/internal/events.test.ts +89 -0
  39. package/src/internal/events.ts +197 -0
  40. package/src/internal/gateway.test.ts +27 -0
  41. package/src/internal/gateway.ts +368 -0
  42. package/src/internal/rest.ts +164 -0
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@effect-uai/discord",
3
+ "version": "0.14.0",
4
+ "description": "Discord provider for @effect-uai/core (Messenger capability over the gateway and REST API).",
5
+ "keywords": [
6
+ "agents",
7
+ "ai",
8
+ "bot",
9
+ "discord",
10
+ "effect",
11
+ "messenger"
12
+ ],
13
+ "homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/providers/discord",
14
+ "bugs": {
15
+ "url": "https://github.com/betalyra/effect-uai/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Betalyra",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/betalyra/effect-uai",
22
+ "directory": "packages/providers/discord"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "type": "module",
31
+ "main": "./dist/index.mjs",
32
+ "types": "./dist/index.d.mts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.mts",
36
+ "import": "./dist/index.mjs"
37
+ },
38
+ "./Discord": {
39
+ "types": "./dist/Discord.d.mts",
40
+ "import": "./dist/Discord.mjs"
41
+ }
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "devDependencies": {
47
+ "effect": "4.0.0-rc.111",
48
+ "typescript": "^7.0.2",
49
+ "@effect-uai/core": "0.14.0"
50
+ },
51
+ "peerDependencies": {
52
+ "@effect-uai/core": ">=0.15.0 <1",
53
+ "effect": ">=4.0.0-rc.111 <5.0.0"
54
+ },
55
+ "scripts": {
56
+ "build": "tsdown",
57
+ "typecheck": "tsc --noEmit"
58
+ }
59
+ }
package/src/Discord.ts ADDED
@@ -0,0 +1,393 @@
1
+ import {
2
+ Array as Arr,
3
+ type Cause,
4
+ Context,
5
+ Effect,
6
+ Encoding,
7
+ Layer,
8
+ Match,
9
+ Option,
10
+ Queue,
11
+ Schedule,
12
+ Schema,
13
+ type Scope,
14
+ Stream,
15
+ } from "effect"
16
+ import { HttpClient } from "effect/unstable/http"
17
+ import type { MediaSource } from "@effect-uai/core/Media"
18
+ import {
19
+ CurrentConversation,
20
+ type ConversationRef,
21
+ type InboundEvent,
22
+ MessageId,
23
+ Messenger,
24
+ type MessengerLimits,
25
+ type MessengerService,
26
+ type Outbound,
27
+ type OutboundBody,
28
+ } from "@effect-uai/core/Messenger"
29
+ import {
30
+ type StreamViaEditsOptions,
31
+ splitForLimit,
32
+ streamViaEdits,
33
+ } from "@effect-uai/core/MessengerAdapter"
34
+ import * as MessengerError from "@effect-uai/core/MessengerError"
35
+ import * as Events from "./internal/events.js"
36
+ import * as Gateway from "./internal/gateway.js"
37
+ import * as Rest from "./internal/rest.js"
38
+
39
+ export type { BotIdentity } from "./internal/events.js"
40
+ export { classifyClose, type CloseAction } from "./internal/gateway.js"
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Public types
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** Gateway intent bits. `MessageContent` is the one Discord gates behind review. */
47
+ export const Intents = {
48
+ Guilds: 1 << 0,
49
+ GuildMessages: 1 << 9,
50
+ GuildMessageReactions: 1 << 10,
51
+ DirectMessages: 1 << 12,
52
+ DirectMessageReactions: 1 << 13,
53
+ MessageContent: 1 << 15,
54
+ } as const
55
+
56
+ /**
57
+ * What a mention-or-DM bot needs. Even without the privileged
58
+ * `MessageContent` intent, `content` arrives for DMs and for messages that
59
+ * mention the bot, which is exactly the set `addressed` covers.
60
+ */
61
+ export const defaultIntents: number =
62
+ Intents.Guilds |
63
+ Intents.GuildMessages |
64
+ Intents.GuildMessageReactions |
65
+ Intents.DirectMessages |
66
+ Intents.DirectMessageReactions
67
+
68
+ export type Config = Rest.Config & {
69
+ /** Gateway intents mask. Defaults to {@link defaultIntents}. */
70
+ readonly intents?: number
71
+ /** Tuning for `stream`, which is post-then-edit on Discord. */
72
+ readonly stream?: StreamViaEditsOptions
73
+ }
74
+
75
+ export type DiscordService = MessengerService & {
76
+ /** From `READY` at layer build: the identity behind `addressed`. */
77
+ readonly bot: Events.BotIdentity
78
+ }
79
+
80
+ /**
81
+ * Provider-typed tag. Yield this for the bot identity; yield the generic
82
+ * `Messenger` tag for provider-portable code. Both are registered by {@link layer}.
83
+ */
84
+ export class Discord extends Context.Service<Discord, DiscordService>()(
85
+ "@betalyra/effect-uai/providers/discord/Discord",
86
+ ) {}
87
+
88
+ export const limits: MessengerLimits = { maxText: 2000, maxCaption: 2000 }
89
+
90
+ // Under the observed five-edits-per-five-seconds-per-channel bucket.
91
+ const defaultStream: StreamViaEditsOptions = { every: "1200 millis" }
92
+
93
+ // A deferred update: the interaction is answered, nothing changes on screen.
94
+ const DEFERRED_UPDATE = 6
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Wire results
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const Me = Schema.Struct({ id: Schema.String, username: Schema.String })
101
+ const Sent = Schema.Struct({ id: Schema.String })
102
+ const RawCall = Schema.Struct({
103
+ method: Schema.Literals(["GET", "POST", "PATCH", "PUT", "DELETE"]),
104
+ path: Schema.String,
105
+ body: Schema.optional(Schema.Unknown),
106
+ })
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Errors
110
+ // ---------------------------------------------------------------------------
111
+
112
+ const isApiFailure = (e: Rest.ApiError): e is Rest.ApiFailure => e._tag === "DiscordApiFailure"
113
+
114
+ const describes = (needle: string) => (e: Rest.ApiError) =>
115
+ isApiFailure(e) && e.message.includes(needle)
116
+
117
+ const reasonOf = (e: Rest.ApiError): string => (isApiFailure(e) ? e.message : "rate limited")
118
+
119
+ /** An API rejection becomes the verb's `MessengerRequestFailed`; typed errors pass through. */
120
+ const requestFailed =
121
+ (operation: MessengerError.MessengerOperation) =>
122
+ (e: Rest.ApiError | MessengerError.MessengerError): MessengerError.MessengerError =>
123
+ Match.value(e).pipe(
124
+ Match.tag(
125
+ "DiscordApiFailure",
126
+ (failure) =>
127
+ new MessengerError.MessengerRequestFailed({
128
+ provider: Rest.provider,
129
+ operation,
130
+ reason: failure.message,
131
+ raw: failure.raw,
132
+ }),
133
+ ),
134
+ Match.orElse((typed) => typed),
135
+ )
136
+
137
+ const unsupported = (capability: string, reason: string) =>
138
+ new MessengerError.MessengerUnsupported({ provider: Rest.provider, capability, reason })
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Outbound params
142
+ // ---------------------------------------------------------------------------
143
+
144
+ type Fields = Rest.Fields
145
+
146
+ type MediaFields = Extract<OutboundBody, { _tag: "Media" }>
147
+
148
+ // Model output must never be able to ping anyone, so nothing is ever parsed.
149
+ const noMentions: Fields = { allowed_mentions: { parse: [] } }
150
+
151
+ const replyFields = (msg: Outbound): Fields =>
152
+ msg.replyTo === undefined
153
+ ? {}
154
+ : { message_reference: { message_id: msg.replyTo, fail_if_not_exists: false } }
155
+
156
+ const messages = (at: ConversationRef): string => `/channels/${at.channel}/messages`
157
+
158
+ const captionField = (caption: string | undefined): Fields =>
159
+ caption === undefined ? {} : { content: caption }
160
+
161
+ const defaultFilename = (media: MediaSource): string =>
162
+ `file.${media.mimeType?.split("/")[1] ?? "bin"}`
163
+
164
+ const invalidMedia = (raw: unknown) =>
165
+ new MessengerError.MessengerRequestFailed({
166
+ provider: Rest.provider,
167
+ operation: "post",
168
+ reason: "invalid base64 media",
169
+ raw,
170
+ })
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Service
174
+ // ---------------------------------------------------------------------------
175
+
176
+ export const make = (
177
+ cfg: Config,
178
+ ): Effect.Effect<
179
+ DiscordService,
180
+ MessengerError.MessengerConnectFailed,
181
+ HttpClient.HttpClient | Scope.Scope
182
+ > =>
183
+ Effect.gen(function* () {
184
+ // Captured once so the verbs need no `HttpClient` of their own.
185
+ const client = yield* HttpClient.HttpClient
186
+ const withClient = Effect.provideService(HttpClient.HttpClient, client)
187
+ const call = (method: Rest.Method, path: string, body?: unknown) =>
188
+ withClient(Rest.call(cfg)(method, path, body))
189
+ const upload = (path: string, payload: Fields, file: Rest.Upload) =>
190
+ withClient(Rest.upload(cfg)(path, payload, file))
191
+
192
+ const connectFailed = (e: Rest.ApiError) =>
193
+ new MessengerError.MessengerConnectFailed({
194
+ provider: Rest.provider,
195
+ reason: reasonOf(e),
196
+ raw: isApiFailure(e) ? e.raw : e,
197
+ })
198
+
199
+ // A bad token fails here rather than as a gateway close a moment later.
200
+ yield* call("GET", "/users/@me").pipe(
201
+ Rest.decoded("GET /users/@me", Me),
202
+ Effect.mapError(connectFailed),
203
+ )
204
+ const { url } = yield* call("GET", "/gateway/bot").pipe(
205
+ Rest.decoded("GET /gateway/bot", Gateway.GatewayInfo),
206
+ Effect.mapError(connectFailed),
207
+ )
208
+
209
+ // -- inbound -----------------------------------------------------------
210
+
211
+ const session = yield* Gateway.connect({
212
+ token: cfg.token,
213
+ intents: cfg.intents ?? defaultIntents,
214
+ url,
215
+ })
216
+ const toEvents = Events.toEvents(session.bot)
217
+ const inbox = yield* Queue.unbounded<InboundEvent, MessengerError.MessengerError | Cause.Done>()
218
+
219
+ // Discord drops a component interaction that goes unanswered for three
220
+ // seconds, so it is acknowledged in the reader fiber rather than by the
221
+ // recipe, the twin of Telegram's `answerCallbackQuery`.
222
+ const acknowledge = (dispatch: Events.Dispatch) =>
223
+ Match.value(dispatch).pipe(
224
+ Match.when({ t: "INTERACTION_CREATE" }, ({ d }) =>
225
+ d.type === Events.MESSAGE_COMPONENT
226
+ ? call("POST", `/interactions/${d.id}/${d.token}/callback`, {
227
+ type: DEFERRED_UPDATE,
228
+ }).pipe(Effect.ignore)
229
+ : Effect.void,
230
+ ),
231
+ Match.orElse(() => Effect.void),
232
+ )
233
+
234
+ yield* Stream.fromQueue(session.dispatches).pipe(
235
+ Stream.runForEach(({ dispatch, raw }) =>
236
+ acknowledge(dispatch).pipe(Effect.andThen(Queue.offerAll(inbox, toEvents(dispatch, raw)))),
237
+ ),
238
+ Effect.catch((closed) => Queue.fail(inbox, closed)),
239
+ Effect.ensuring(Queue.end(inbox)),
240
+ Effect.forkScoped,
241
+ )
242
+
243
+ // -- outbound ----------------------------------------------------------
244
+
245
+ const sendRaw = (payload: unknown) =>
246
+ Effect.gen(function* () {
247
+ const { method, path, body } = yield* Schema.decodeUnknownEffect(RawCall)(payload).pipe(
248
+ Effect.mapError(
249
+ () =>
250
+ new MessengerError.MessengerRequestFailed({
251
+ provider: Rest.provider,
252
+ operation: "post",
253
+ reason: "raw payload must be { method, path, body? }",
254
+ raw: payload,
255
+ }),
256
+ ),
257
+ )
258
+ return yield* call(method, path, body)
259
+ })
260
+
261
+ // Discord has no attachment-by-URL, so a URL goes as content and Discord
262
+ // unfurls it. Not as an embed: embeds need the Embed Links permission and
263
+ // are silently stripped without it, which Discord then rejects as an
264
+ // empty message.
265
+ const sendUrl = (at: ConversationRef, fields: Fields, body: MediaFields, url: string) =>
266
+ call("POST", messages(at), {
267
+ ...fields,
268
+ content: Arr.getSomes([Option.fromNullishOr(body.caption), Option.some(url)]).join("\n"),
269
+ })
270
+
271
+ const sendMedia = (at: ConversationRef, msg: Outbound, body: MediaFields) => {
272
+ const fields = { ...noMentions, ...replyFields(msg) }
273
+ const send = (bytes: Uint8Array, mimeType: string) =>
274
+ upload(
275
+ messages(at),
276
+ { ...fields, ...captionField(body.caption) },
277
+ { bytes, filename: body.filename ?? defaultFilename(body.media), mimeType },
278
+ )
279
+ return Match.value(body.media).pipe(
280
+ Match.tag("url", ({ url }) => sendUrl(at, fields, body, url)),
281
+ Match.tag("bytes", ({ bytes, mimeType }) => send(bytes, mimeType)),
282
+ Match.tag("base64", ({ base64, mimeType }) =>
283
+ Effect.fromResult(Encoding.decodeBase64(base64)).pipe(
284
+ Effect.mapError(invalidMedia),
285
+ Effect.flatMap((bytes) => send(bytes, mimeType)),
286
+ ),
287
+ ),
288
+ Match.exhaustive,
289
+ )
290
+ }
291
+
292
+ // The reply lands on the first chunk; the id is the last one's.
293
+ const sendChunks = (at: ConversationRef, msg: Outbound, text: string) =>
294
+ Effect.gen(function* () {
295
+ const [first, ...rest] = splitForLimit(text, limits.maxText)
296
+ const send = (chunk: string, extra: Fields) =>
297
+ call("POST", messages(at), { ...noMentions, ...extra, content: chunk }).pipe(
298
+ Rest.decoded("POST message", Sent),
299
+ )
300
+ const head = yield* send(first, replyFields(msg))
301
+ const tail = yield* Effect.forEach(rest, (chunk) => send(chunk, {}))
302
+ return Option.getOrElse(Arr.last(tail), () => head)
303
+ })
304
+
305
+ const post: MessengerService["post"] = (msg) =>
306
+ Effect.gen(function* () {
307
+ const at = yield* CurrentConversation
308
+ const sent = yield* Match.value(msg.body).pipe(
309
+ Match.tag("Text", ({ text }) => sendChunks(at, msg, text)),
310
+ Match.tag("Media", (body) =>
311
+ sendMedia(at, msg, body).pipe(Rest.decoded("POST media", Sent)),
312
+ ),
313
+ Match.tag("Raw", ({ payload }) => sendRaw(payload).pipe(Rest.decoded("raw", Sent))),
314
+ Match.exhaustive,
315
+ )
316
+ return MessageId(sent.id)
317
+ }).pipe(Effect.mapError(requestFailed("post")))
318
+
319
+ const edit: MessengerService["edit"] = (ref, next) =>
320
+ Match.value(next.body).pipe(
321
+ Match.tag("Text", ({ text }) =>
322
+ call("PATCH", `${messages(ref.conversation)}/${ref.id}`, {
323
+ ...noMentions,
324
+ content: text,
325
+ }).pipe(Effect.asVoid, Effect.mapError(requestFailed("edit"))),
326
+ ),
327
+ Match.tag("Raw", ({ payload }) =>
328
+ sendRaw(payload).pipe(Effect.asVoid, Effect.mapError(requestFailed("edit"))),
329
+ ),
330
+ Match.tag("Media", () =>
331
+ Effect.fail(unsupported("media edits", "send a new message instead")),
332
+ ),
333
+ Match.exhaustive,
334
+ )
335
+
336
+ // Unicode emoji go URL-encoded and custom ones as `name:id`, which is the
337
+ // same spelling a `Reaction` event delivers.
338
+ const react: MessengerService["react"] = (ref, emoji) =>
339
+ call(
340
+ "PUT",
341
+ `${messages(ref.conversation)}/${ref.id}/reactions/${encodeURIComponent(emoji)}/@me`,
342
+ ).pipe(
343
+ Effect.asVoid,
344
+ Effect.catchIf(describes("Unknown Emoji"), () =>
345
+ Effect.fail(unsupported("reaction", `${emoji} is not an emoji Discord knows`)),
346
+ ),
347
+ Effect.mapError(requestFailed("react")),
348
+ )
349
+
350
+ // The indicator lasts ten seconds, so it is re-sent every eight until the
351
+ // scope closes. The first send is awaited so a bad channel fails here.
352
+ const typing: MessengerService["typing"] = Effect.gen(function* () {
353
+ const at = yield* CurrentConversation
354
+ const once = call("POST", `/channels/${at.channel}/typing`)
355
+ yield* once.pipe(Effect.mapError(requestFailed("typing")))
356
+ yield* once.pipe(
357
+ Effect.ignore,
358
+ Effect.schedule(Schedule.spaced("8 seconds")),
359
+ Effect.forkScoped,
360
+ )
361
+ })
362
+
363
+ return {
364
+ bot: session.bot,
365
+ events: Stream.fromQueue(inbox),
366
+ post,
367
+ edit,
368
+ react,
369
+ typing,
370
+ stream: streamViaEdits({ post, edit, limits }, { ...defaultStream, ...cfg.stream }),
371
+ limits,
372
+ }
373
+ })
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Layer
377
+ // ---------------------------------------------------------------------------
378
+
379
+ /**
380
+ * One gateway session, registered under both the `Discord` and `Messenger`
381
+ * tags. Building the layer identifies and waits for `READY`, so a bad token
382
+ * or an intent the portal has not granted fails here; the connection lives
383
+ * until the scope closes and `events` ends with it. Run one instance per
384
+ * bot token.
385
+ */
386
+ export const layer = (
387
+ cfg: Config,
388
+ ): Layer.Layer<Discord | Messenger, MessengerError.MessengerConnectFailed, HttpClient.HttpClient> =>
389
+ Layer.effectContext(
390
+ Effect.map(make(cfg), (service) =>
391
+ Context.make(Discord, service).pipe(Context.add(Messenger, service)),
392
+ ),
393
+ )
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * as Discord from "./Discord.js"
@@ -0,0 +1,89 @@
1
+ import { Schema } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { type BotIdentity, Dispatch, toEvents } from "./events.js"
4
+
5
+ const bot: BotIdentity = { id: "42", username: "HelperBot" }
6
+ const events = toEvents(bot)
7
+
8
+ const alice = { id: "7", username: "alice" }
9
+
10
+ /** One dispatch through the real decoder, as the gateway hands it over. */
11
+ const dispatch = (t: string, d: Record<string, unknown>) =>
12
+ events(Schema.decodeUnknownSync(Dispatch)({ t, d }), d)[0]
13
+
14
+ const message = (fields: Record<string, unknown>) =>
15
+ dispatch("MESSAGE_CREATE", {
16
+ id: "10",
17
+ channel_id: "555",
18
+ guild_id: "100",
19
+ author: alice,
20
+ ...fields,
21
+ })
22
+
23
+ describe("addressed", () => {
24
+ it("is a DM, a mention, or a reply to the bot, and nothing else", () => {
25
+ expect(message({ guild_id: undefined, content: "hi" })).toMatchObject({ addressed: true })
26
+ expect(message({ content: "<@42> ping", mentions: [{ id: "42" }] })).toMatchObject({
27
+ addressed: true,
28
+ })
29
+ expect(
30
+ message({ content: "and?", referenced_message: { id: "9", author: { id: "42" } } }),
31
+ ).toMatchObject({ addressed: true, replyTo: "9" })
32
+ expect(
33
+ message({ content: "and?", referenced_message: { id: "9", author: alice } }),
34
+ ).toMatchObject({ addressed: false, replyTo: "9" })
35
+ // `@everyone` and role pings never land in `mentions`.
36
+ expect(message({ content: "@everyone lunch?", mention_everyone: true })).toMatchObject({
37
+ addressed: false,
38
+ })
39
+ })
40
+
41
+ it("strips both spellings of the bot's own mention and leaves other users'", () => {
42
+ expect(
43
+ message({ content: "<@!42> ask <@7> too", mentions: [{ id: "42" }, alice] }),
44
+ ).toMatchObject({ text: "ask <@7> too" })
45
+ })
46
+
47
+ it("drops messages another bot wrote, so two bots cannot answer each other", () => {
48
+ expect(message({ content: "hello", author: { id: "99", bot: true } })).toBeUndefined()
49
+ })
50
+ })
51
+
52
+ describe("reactions", () => {
53
+ const add = (emoji: Record<string, unknown>, user_id = "7") =>
54
+ dispatch("MESSAGE_REACTION_ADD", { user_id, channel_id: "555", message_id: "10", emoji })
55
+
56
+ it("names unicode by itself and custom emoji as `name:id`", () => {
57
+ expect(add({ id: null, name: "👀" })).toMatchObject({ _tag: "Reaction", emoji: "👀" })
58
+ expect(add({ id: "8", name: "blobcat" })).toMatchObject({ emoji: "blobcat:8" })
59
+ })
60
+
61
+ it("drops the bot's own reactions", () => {
62
+ expect(add({ id: null, name: "👀" }, "42")).toBeUndefined()
63
+ })
64
+ })
65
+
66
+ describe("interactions", () => {
67
+ const interaction = (type: number, data: Record<string, unknown>) =>
68
+ dispatch("INTERACTION_CREATE", {
69
+ id: "1",
70
+ token: "tok",
71
+ type,
72
+ channel_id: "555",
73
+ member: { user: alice },
74
+ data,
75
+ })
76
+
77
+ it("maps a component press to an Action, joining a select's values", () => {
78
+ expect(interaction(3, { custom_id: "approve:12", values: ["a", "b"] })).toMatchObject({
79
+ _tag: "Action",
80
+ actionId: "approve:12",
81
+ value: "a,b",
82
+ author: "7",
83
+ })
84
+ })
85
+
86
+ it("ignores interaction types that need a response of their own", () => {
87
+ expect(interaction(2, { name: "search" })).toBeUndefined()
88
+ })
89
+ })