@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.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/Discord.d.mts +55 -0
- package/dist/Discord.d.mts.map +1 -0
- package/dist/Discord.mjs +196 -0
- package/dist/Discord.mjs.map +1 -0
- package/dist/events-Du1u-kVV.d.mts +172 -0
- package/dist/events-Du1u-kVV.d.mts.map +1 -0
- package/dist/gateway-C_Ae1HYB.d.mts +57 -0
- package/dist/gateway-C_Ae1HYB.d.mts.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/internal/events.d.mts +2 -0
- package/dist/internal/events.mjs +109 -0
- package/dist/internal/events.mjs.map +1 -0
- package/dist/internal/events.test.d.mts +1 -0
- package/dist/internal/events.test.mjs +130 -0
- package/dist/internal/events.test.mjs.map +1 -0
- package/dist/internal/gateway.d.mts +2 -0
- package/dist/internal/gateway.mjs +216 -0
- package/dist/internal/gateway.mjs.map +1 -0
- package/dist/internal/gateway.test.d.mts +1 -0
- package/dist/internal/gateway.test.mjs +40 -0
- package/dist/internal/gateway.test.mjs.map +1 -0
- package/dist/internal/rest.d.mts +2 -0
- package/dist/internal/rest.mjs +89 -0
- package/dist/internal/rest.mjs.map +1 -0
- package/dist/magic-string.es-CUAnjKYC.mjs +1014 -0
- package/dist/magic-string.es-CUAnjKYC.mjs.map +1 -0
- package/dist/rest-CZSW-VJK.d.mts +44 -0
- package/dist/rest-CZSW-VJK.d.mts.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs +13658 -0
- package/dist/test.DNmyFkvJ-N3QqMRwZ.mjs.map +1 -0
- package/package.json +59 -0
- package/src/Discord.ts +393 -0
- package/src/index.ts +1 -0
- package/src/internal/events.test.ts +89 -0
- package/src/internal/events.ts +197 -0
- package/src/internal/gateway.test.ts +27 -0
- package/src/internal/gateway.ts +368 -0
- 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
|
+
})
|