@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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Data, Duration, Effect, Redacted, Schema } from "effect"
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
|
+
import * as MessengerError from "@effect-uai/core/MessengerError"
|
|
4
|
+
import * as Multipart from "@effect-uai/core/Multipart"
|
|
5
|
+
|
|
6
|
+
export const provider = "discord"
|
|
7
|
+
|
|
8
|
+
/** Tracks the package version; Discord requires a `DiscordBot (url, version)` agent. */
|
|
9
|
+
const userAgent = "DiscordBot (https://effect-uai.betalyra.com, 0.14.0)"
|
|
10
|
+
|
|
11
|
+
export type Config = {
|
|
12
|
+
readonly token: Redacted.Redacted
|
|
13
|
+
/** Defaults to `https://discord.com/api/v10`. Always version-pinned. */
|
|
14
|
+
readonly baseUrl?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"
|
|
18
|
+
|
|
19
|
+
/** JSON body fields of a request. */
|
|
20
|
+
export type Fields = Readonly<Record<string, unknown>>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A REST call that came back non-2xx or never came back at all. `code` is
|
|
24
|
+
* Discord's own JSON error code, `status` the HTTP one; each verb maps this
|
|
25
|
+
* onto its own `MessengerError`.
|
|
26
|
+
*/
|
|
27
|
+
export class ApiFailure extends Data.TaggedError("DiscordApiFailure")<{
|
|
28
|
+
readonly route: string
|
|
29
|
+
readonly status?: number
|
|
30
|
+
readonly code?: number
|
|
31
|
+
readonly message: string
|
|
32
|
+
readonly raw: unknown
|
|
33
|
+
}> {}
|
|
34
|
+
|
|
35
|
+
export type ApiError = ApiFailure | MessengerError.MessengerRateLimited
|
|
36
|
+
|
|
37
|
+
const ErrorBody = Schema.Struct({
|
|
38
|
+
message: Schema.optional(Schema.String),
|
|
39
|
+
code: Schema.optional(Schema.Number),
|
|
40
|
+
retry_after: Schema.optional(Schema.Number),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** The file part of a multipart message. Discord names it `files[n]`. */
|
|
44
|
+
export type Upload = {
|
|
45
|
+
readonly bytes: Uint8Array
|
|
46
|
+
readonly filename: string
|
|
47
|
+
readonly mimeType: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const endpoint = (cfg: Config, path: string): string =>
|
|
51
|
+
`${cfg.baseUrl ?? "https://discord.com/api/v10"}${path}`
|
|
52
|
+
|
|
53
|
+
const constructors: Record<Method, (url: string) => HttpClientRequest.HttpClientRequest> = {
|
|
54
|
+
GET: HttpClientRequest.get,
|
|
55
|
+
POST: HttpClientRequest.post,
|
|
56
|
+
PATCH: HttpClientRequest.patch,
|
|
57
|
+
PUT: HttpClientRequest.put,
|
|
58
|
+
DELETE: HttpClientRequest.delete,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const request = (cfg: Config, method: Method, path: string) =>
|
|
62
|
+
constructors[method](endpoint(cfg, path)).pipe(
|
|
63
|
+
HttpClientRequest.setHeaders({
|
|
64
|
+
Authorization: `Bot ${Redacted.value(cfg.token)}`,
|
|
65
|
+
"User-Agent": userAgent,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const transportFailure = (route: string) => (raw: unknown) =>
|
|
70
|
+
new ApiFailure({ route, message: "transport failure", raw })
|
|
71
|
+
|
|
72
|
+
const rejected = (route: string, status: number, raw: unknown): Effect.Effect<never, ApiError> =>
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const body = yield* Schema.decodeUnknownEffect(ErrorBody)(raw).pipe(
|
|
75
|
+
Effect.orElseSucceed((): typeof ErrorBody.Type => ({})),
|
|
76
|
+
)
|
|
77
|
+
// Discord's `retry_after` is float seconds; only a 429 carries one.
|
|
78
|
+
return yield* status === 429 && body.retry_after !== undefined
|
|
79
|
+
? new MessengerError.MessengerRateLimited({
|
|
80
|
+
provider,
|
|
81
|
+
retryAfter: Duration.seconds(body.retry_after),
|
|
82
|
+
raw,
|
|
83
|
+
})
|
|
84
|
+
: new ApiFailure({
|
|
85
|
+
route,
|
|
86
|
+
status,
|
|
87
|
+
...(body.code !== undefined && { code: body.code }),
|
|
88
|
+
message: body.message ?? `HTTP ${status}`,
|
|
89
|
+
raw,
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const send = (
|
|
94
|
+
route: string,
|
|
95
|
+
httpRequest: HttpClientRequest.HttpClientRequest,
|
|
96
|
+
): Effect.Effect<unknown, ApiError, HttpClient.HttpClient> =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const client = yield* HttpClient.HttpClient
|
|
99
|
+
const response = yield* client
|
|
100
|
+
.execute(httpRequest)
|
|
101
|
+
.pipe(Effect.mapError(transportFailure(route)))
|
|
102
|
+
// 204 is the success shape of `typing`, `react` and every delete: no body
|
|
103
|
+
// to read, and reading one would fail.
|
|
104
|
+
if (response.status === 204) return undefined
|
|
105
|
+
const body = yield* response.json.pipe(Effect.mapError(transportFailure(route)))
|
|
106
|
+
return response.status < 400 ? body : yield* rejected(route, response.status, body)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/** One JSON call against the API base. The response body comes back undecoded. */
|
|
110
|
+
export const call =
|
|
111
|
+
(cfg: Config) =>
|
|
112
|
+
(
|
|
113
|
+
method: Method,
|
|
114
|
+
path: string,
|
|
115
|
+
body?: unknown,
|
|
116
|
+
): Effect.Effect<unknown, ApiError, HttpClient.HttpClient> =>
|
|
117
|
+
send(
|
|
118
|
+
`${method} ${path}`,
|
|
119
|
+
body === undefined
|
|
120
|
+
? request(cfg, method, path)
|
|
121
|
+
: request(cfg, method, path).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
// The `attachments` entry is what binds `files[0]` to the message; without it
|
|
125
|
+
// Discord can drop the part and reject the rest as an empty message.
|
|
126
|
+
const formData = (payload: Fields, file: Upload): FormData => {
|
|
127
|
+
const form = new FormData()
|
|
128
|
+
form.append(
|
|
129
|
+
"payload_json",
|
|
130
|
+
JSON.stringify({ ...payload, attachments: [{ id: 0, filename: file.filename }] }),
|
|
131
|
+
)
|
|
132
|
+
form.append(
|
|
133
|
+
"files[0]",
|
|
134
|
+
new File([file.bytes as Uint8Array<ArrayBuffer>], file.filename, { type: file.mimeType }),
|
|
135
|
+
)
|
|
136
|
+
return form
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One multipart message: the JSON fields as `payload_json` plus a single file. */
|
|
140
|
+
export const upload =
|
|
141
|
+
(cfg: Config) =>
|
|
142
|
+
(
|
|
143
|
+
path: string,
|
|
144
|
+
payload: Fields,
|
|
145
|
+
file: Upload,
|
|
146
|
+
): Effect.Effect<unknown, ApiError, HttpClient.HttpClient> =>
|
|
147
|
+
Effect.gen(function* () {
|
|
148
|
+
const route = `POST ${path}`
|
|
149
|
+
const body = yield* Multipart.bodyMultipart(formData(payload, file)).pipe(
|
|
150
|
+
Effect.mapError(transportFailure(route)),
|
|
151
|
+
)
|
|
152
|
+
return yield* send(route, request(cfg, "POST", path).pipe(body))
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
/** Decode a response body; a shape mismatch is a malformed response, not a defect. */
|
|
156
|
+
export const decoded =
|
|
157
|
+
<A>(route: string, schema: Schema.Decoder<A>) =>
|
|
158
|
+
<E, R>(result: Effect.Effect<unknown, E, R>): Effect.Effect<A, E | ApiFailure, R> =>
|
|
159
|
+
Effect.gen(function* () {
|
|
160
|
+
const raw = yield* result
|
|
161
|
+
return yield* Schema.decodeUnknownEffect(schema)(raw).pipe(
|
|
162
|
+
Effect.mapError(() => new ApiFailure({ route, message: "malformed response", raw })),
|
|
163
|
+
)
|
|
164
|
+
})
|