dfx 0.42.1 → 0.42.3
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/Cache/driver.js.map +1 -1
- package/Cache/memory.js.map +1 -1
- package/Cache/memoryTTL.js.map +1 -1
- package/Cache/prelude.js.map +1 -1
- package/Cache.js.map +1 -1
- package/DiscordConfig.js.map +1 -1
- package/DiscordGateway/DiscordWS.js +1 -1
- package/DiscordGateway/DiscordWS.js.map +1 -1
- package/DiscordGateway/Shard/heartbeats.js.map +1 -1
- package/DiscordGateway/Shard/identify.js.map +1 -1
- package/DiscordGateway/Shard/invalidSession.js.map +1 -1
- package/DiscordGateway/Shard/sendEvents.js.map +1 -1
- package/DiscordGateway/Shard/utils.js.map +1 -1
- package/DiscordGateway/Shard.js.map +1 -1
- package/DiscordGateway/ShardStore.js.map +1 -1
- package/DiscordGateway/Sharder.js.map +1 -1
- package/DiscordGateway/WS.d.ts +3 -3
- package/DiscordGateway/WS.js +25 -18
- package/DiscordGateway/WS.js.map +1 -1
- package/DiscordGateway.js.map +1 -1
- package/DiscordREST/types.js.map +1 -1
- package/DiscordREST/utils.js.map +1 -1
- package/DiscordREST.js.map +1 -1
- package/Helpers/flags.js.map +1 -1
- package/Helpers/intents.js.map +1 -1
- package/Helpers/interactions.js.map +1 -1
- package/Helpers/members.js.map +1 -1
- package/Helpers/permissions.js.map +1 -1
- package/Helpers/ui.js.map +1 -1
- package/Interactions/context.js.map +1 -1
- package/Interactions/definitions.js.map +1 -1
- package/Interactions/gateway.js.map +1 -1
- package/Interactions/handlers.js.map +1 -1
- package/Interactions/index.js.map +1 -1
- package/Interactions/utils.js.map +1 -1
- package/Interactions/webhook.js.map +1 -1
- package/Log.js.map +1 -1
- package/RateLimit/memory.js.map +1 -1
- package/RateLimit/utils.js.map +1 -1
- package/RateLimit.js.map +1 -1
- package/_common.js.map +1 -1
- package/gateway.js.map +1 -1
- package/global.js.map +1 -1
- package/index.js.map +1 -1
- package/package.json +50 -52
- package/src/Cache/driver.ts +31 -0
- package/src/Cache/memory.ts +76 -0
- package/src/Cache/memoryTTL.ts +201 -0
- package/src/Cache/prelude.ts +215 -0
- package/src/Cache.ts +140 -0
- package/src/DiscordConfig.ts +48 -0
- package/src/DiscordGateway/DiscordWS.ts +74 -0
- package/src/DiscordGateway/Shard/heartbeats.ts +42 -0
- package/src/DiscordGateway/Shard/identify.ts +52 -0
- package/src/DiscordGateway/Shard/invalidSession.ts +10 -0
- package/src/DiscordGateway/Shard/sendEvents.ts +37 -0
- package/src/DiscordGateway/Shard/utils.ts +14 -0
- package/src/DiscordGateway/Shard.ts +152 -0
- package/src/DiscordGateway/ShardStore.ts +33 -0
- package/src/DiscordGateway/Sharder.ts +102 -0
- package/src/DiscordGateway/WS.ts +122 -0
- package/src/DiscordGateway.ts +43 -0
- package/src/DiscordREST/types.ts +13 -0
- package/src/DiscordREST/utils.ts +33 -0
- package/src/DiscordREST.ts +203 -0
- package/src/Helpers/flags.ts +68 -0
- package/src/Helpers/intents.ts +34 -0
- package/src/Helpers/interactions.ts +229 -0
- package/src/Helpers/members.ts +14 -0
- package/src/Helpers/permissions.ts +140 -0
- package/src/Helpers/ui.ts +103 -0
- package/src/Interactions/context.ts +132 -0
- package/src/Interactions/definitions.ts +309 -0
- package/src/Interactions/gateway.ts +71 -0
- package/src/Interactions/handlers.ts +130 -0
- package/src/Interactions/index.ts +108 -0
- package/src/Interactions/utils.ts +81 -0
- package/src/Interactions/webhook.ts +110 -0
- package/src/Log.ts +17 -0
- package/src/RateLimit/memory.ts +57 -0
- package/src/RateLimit/utils.ts +27 -0
- package/src/RateLimit.ts +69 -0
- package/src/_common.ts +43 -0
- package/src/gateway.ts +38 -0
- package/src/global.ts +45 -0
- package/src/index.ts +20 -0
- package/src/package.json +52 -0
- package/src/types.ts +6368 -0
- package/src/utils/effect.ts +0 -0
- package/src/utils/hub.ts +47 -0
- package/src/utils/json.d.ts +1 -0
- package/src/utils/tsplus.ts +10 -0
- package/src/webhooks.ts +41 -0
- package/tsconfig.json +23 -0
- package/tsplus.config.json +8 -0
- package/types.js.map +1 -1
- package/utils/effect.js.map +1 -1
- package/utils/hub.js.map +1 -1
- package/utils/tsplus.js.map +1 -1
- package/webhooks.js.map +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { millis } from "@effect/data/Duration"
|
|
2
|
+
import { DiscordConfig } from "dfx/DiscordConfig"
|
|
3
|
+
import { DiscordREST } from "dfx/DiscordREST"
|
|
4
|
+
import { LiveRateLimiter, RateLimiter } from "../RateLimit.js"
|
|
5
|
+
import { LiveShard, Shard } from "./Shard.js"
|
|
6
|
+
import { ShardStore } from "./ShardStore.js"
|
|
7
|
+
import { WebSocketCloseError, WebSocketError } from "./WS.js"
|
|
8
|
+
|
|
9
|
+
const make = Do($ => {
|
|
10
|
+
const store = $(ShardStore)
|
|
11
|
+
const rest = $(DiscordREST)
|
|
12
|
+
const { gateway: config } = $(DiscordConfig)
|
|
13
|
+
const limiter = $(RateLimiter)
|
|
14
|
+
const shard = $(Shard)
|
|
15
|
+
|
|
16
|
+
const takeConfig = (totalCount: number) =>
|
|
17
|
+
Do($ => {
|
|
18
|
+
const currentCount = $(Ref.make(0))
|
|
19
|
+
|
|
20
|
+
const claimId = (sharderCount: number): Effect<never, never, number> =>
|
|
21
|
+
store
|
|
22
|
+
.claimId({
|
|
23
|
+
totalCount,
|
|
24
|
+
sharderCount,
|
|
25
|
+
})
|
|
26
|
+
.flatMap(a =>
|
|
27
|
+
a.match(
|
|
28
|
+
() => claimId(sharderCount).delay(Duration.minutes(3)),
|
|
29
|
+
id => Effect.succeed(id),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return currentCount
|
|
34
|
+
.getAndUpdate(_ => _ + 1)
|
|
35
|
+
.flatMap(claimId)
|
|
36
|
+
.map(id => ({ id, totalCount } as const))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const gateway = $(
|
|
40
|
+
rest
|
|
41
|
+
.getGatewayBot()
|
|
42
|
+
.flatMap(r => r.json)
|
|
43
|
+
.catchAll(() =>
|
|
44
|
+
Effect.succeed<Discord.GetGatewayBotResponse>({
|
|
45
|
+
url: "wss://gateway.discord.gg/",
|
|
46
|
+
shards: 1,
|
|
47
|
+
session_start_limit: {
|
|
48
|
+
total: 0,
|
|
49
|
+
remaining: 0,
|
|
50
|
+
reset_after: 0,
|
|
51
|
+
max_concurrency: 1,
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const run = (hub: Hub<Discord.GatewayPayload<Discord.ReceiveEvent>>) =>
|
|
58
|
+
Do($ => {
|
|
59
|
+
const deferred = $(
|
|
60
|
+
Deferred.make<WebSocketError | WebSocketCloseError, never>(),
|
|
61
|
+
)
|
|
62
|
+
const take = $(takeConfig(config.shardCount ?? gateway.shards))
|
|
63
|
+
|
|
64
|
+
const spawner = take
|
|
65
|
+
.map(config => ({
|
|
66
|
+
...config,
|
|
67
|
+
url: gateway.url,
|
|
68
|
+
concurrency: gateway.session_start_limit.max_concurrency,
|
|
69
|
+
}))
|
|
70
|
+
.tap(({ id, concurrency }) =>
|
|
71
|
+
limiter.maybeWait(
|
|
72
|
+
`dfx.sharder.${id % concurrency}`,
|
|
73
|
+
millis(config.identifyRateLimit[0]),
|
|
74
|
+
config.identifyRateLimit[1],
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
.flatMap(c => shard.connect([c.id, c.totalCount], hub))
|
|
78
|
+
.flatMap(
|
|
79
|
+
shard => shard.run.catchAllCause(_ => deferred.failCause(_)).fork,
|
|
80
|
+
).forever
|
|
81
|
+
|
|
82
|
+
const spawners = Chunk.range(
|
|
83
|
+
1,
|
|
84
|
+
gateway.session_start_limit.max_concurrency,
|
|
85
|
+
).map(() => spawner)
|
|
86
|
+
|
|
87
|
+
return $(
|
|
88
|
+
Effect.allParDiscard(spawners).zipParLeft(deferred.await) as Effect<
|
|
89
|
+
never,
|
|
90
|
+
WebSocketError | WebSocketCloseError,
|
|
91
|
+
never
|
|
92
|
+
>,
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return { run } as const
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
export interface Sharder extends Effect.Success<typeof make> {}
|
|
100
|
+
export const Sharder = Tag<Sharder>()
|
|
101
|
+
export const LiveSharder =
|
|
102
|
+
(LiveRateLimiter + LiveShard) >> make.toLayer(Sharder)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Log } from "dfx/Log"
|
|
2
|
+
import WebSocket from "isomorphic-ws"
|
|
3
|
+
|
|
4
|
+
export const Reconnect = Symbol()
|
|
5
|
+
export type Reconnect = typeof Reconnect
|
|
6
|
+
export type Message = string | Buffer | ArrayBuffer | Reconnect
|
|
7
|
+
|
|
8
|
+
export class WebSocketError {
|
|
9
|
+
readonly _tag = "WebSocketError"
|
|
10
|
+
constructor(
|
|
11
|
+
readonly reason: "open-timeout" | "error",
|
|
12
|
+
readonly error?: unknown,
|
|
13
|
+
) {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class WebSocketCloseError {
|
|
17
|
+
readonly _tag = "WebSocketCloseError"
|
|
18
|
+
constructor(readonly code: number, readonly reason: string) {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isReconnect = (
|
|
22
|
+
e: WebSocketError | WebSocketCloseError,
|
|
23
|
+
): e is WebSocketCloseError =>
|
|
24
|
+
e._tag === "WebSocketCloseError" && e.code === 1012
|
|
25
|
+
|
|
26
|
+
const socket = (urlRef: Ref<string>) =>
|
|
27
|
+
urlRef.get
|
|
28
|
+
.map(_ => new WebSocket(_) as any as globalThis.WebSocket)
|
|
29
|
+
.acquireRelease(ws =>
|
|
30
|
+
Effect.sync(() => {
|
|
31
|
+
;(ws as any).removeAllListeners?.()
|
|
32
|
+
ws.close()
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const offer = (
|
|
37
|
+
ws: globalThis.WebSocket,
|
|
38
|
+
queue: Enqueue<WebSocket.Data>,
|
|
39
|
+
log: Log,
|
|
40
|
+
) =>
|
|
41
|
+
Effect.async<never, WebSocketError | WebSocketCloseError, never>(resume => {
|
|
42
|
+
ws.addEventListener("message", message => {
|
|
43
|
+
queue.offer(message.data).zipLeft(log.debug("WS", "offer", message.data))
|
|
44
|
+
.runFork
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
ws.addEventListener("error", cause => {
|
|
48
|
+
resume(Effect.fail(new WebSocketError("error", cause)))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
ws.addEventListener("close", e => {
|
|
52
|
+
resume(Effect.fail(new WebSocketCloseError(e.code, e.reason)))
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const waitForOpen = (ws: globalThis.WebSocket, timeout: Duration) =>
|
|
57
|
+
Effect.suspend(() => {
|
|
58
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
59
|
+
return Effect.unit()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Effect.async<never, never, void>(resume => {
|
|
63
|
+
ws.addEventListener("open", () => resume(Effect.unit()), {
|
|
64
|
+
once: true,
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
}).timeoutFail(() => new WebSocketError("open-timeout"), timeout)
|
|
68
|
+
|
|
69
|
+
const send = (
|
|
70
|
+
ws: globalThis.WebSocket,
|
|
71
|
+
take: Effect<never, never, Message>,
|
|
72
|
+
log: Log,
|
|
73
|
+
openTimeout: Duration,
|
|
74
|
+
) => {
|
|
75
|
+
const loop = take
|
|
76
|
+
.tap(data => log.debug("WS", "send", data))
|
|
77
|
+
.tap((data): Effect<never, WebSocketCloseError, void> => {
|
|
78
|
+
if (data === Reconnect) {
|
|
79
|
+
return Effect.failSync(() => {
|
|
80
|
+
ws.close(1012, "reconnecting")
|
|
81
|
+
return new WebSocketCloseError(1012, "reconnecting")
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Effect.sync(() => {
|
|
86
|
+
ws.send(data)
|
|
87
|
+
})
|
|
88
|
+
}).forever
|
|
89
|
+
|
|
90
|
+
return waitForOpen(ws, openTimeout).zipRight(loop)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const make = Do($ => {
|
|
94
|
+
const log = $(Log)
|
|
95
|
+
|
|
96
|
+
const connect = (
|
|
97
|
+
url: Ref<string>,
|
|
98
|
+
takeOutbound: Effect<never, never, Message>,
|
|
99
|
+
onReconnect = Effect.unit(),
|
|
100
|
+
openTimeout = Duration.seconds(3),
|
|
101
|
+
) =>
|
|
102
|
+
Do($ => {
|
|
103
|
+
const queue = $(Queue.unbounded<WebSocket.Data>())
|
|
104
|
+
|
|
105
|
+
const run = socket(url)
|
|
106
|
+
.flatMap(ws =>
|
|
107
|
+
offer(ws, queue, log).zipParLeft(
|
|
108
|
+
send(ws, takeOutbound, log, openTimeout),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
.tapError(_ => (isReconnect(_) ? onReconnect : Effect.unit()))
|
|
112
|
+
.retryWhile(isReconnect).scoped
|
|
113
|
+
|
|
114
|
+
return { run, take: queue.take() } as const
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return { connect } as const
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
export interface WS extends Effect.Success<typeof make> {}
|
|
121
|
+
export const WS = Tag<WS>()
|
|
122
|
+
export const LiveWS = make.toLayer(WS)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { LiveSharder, Sharder } from "./DiscordGateway/Sharder.js"
|
|
2
|
+
|
|
3
|
+
const fromDispatchFactory =
|
|
4
|
+
<R, E>(source: Stream<R, E, Discord.GatewayPayload<Discord.ReceiveEvent>>) =>
|
|
5
|
+
<K extends keyof Discord.ReceiveEvents>(
|
|
6
|
+
event: K,
|
|
7
|
+
): Stream<R, E, Discord.ReceiveEvents[K]> =>
|
|
8
|
+
source.filter(p => p.t === event).map(p => p.d! as any)
|
|
9
|
+
|
|
10
|
+
const handleDispatchFactory =
|
|
11
|
+
(hub: Hub<Discord.GatewayPayload<Discord.ReceiveEvent>>) =>
|
|
12
|
+
<K extends keyof Discord.ReceiveEvents, R, E, A>(
|
|
13
|
+
event: K,
|
|
14
|
+
handle: (event: Discord.ReceiveEvents[K]) => Effect<R, E, A>,
|
|
15
|
+
): Effect<R, E, never> =>
|
|
16
|
+
hub.subscribeForEachPar(_ => {
|
|
17
|
+
if (_.t === event) {
|
|
18
|
+
return handle(_.d as any)
|
|
19
|
+
}
|
|
20
|
+
return Effect.unit()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const make = Do($ => {
|
|
24
|
+
const sharder = $(Sharder)
|
|
25
|
+
const hub = $(Hub.unbounded<Discord.GatewayPayload<Discord.ReceiveEvent>>())
|
|
26
|
+
|
|
27
|
+
const dispatch = Stream.fromHub(hub)
|
|
28
|
+
const fromDispatch = fromDispatchFactory(dispatch)
|
|
29
|
+
const handleDispatch = handleDispatchFactory(hub)
|
|
30
|
+
|
|
31
|
+
const run = sharder.run(hub)
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
run,
|
|
35
|
+
dispatch,
|
|
36
|
+
fromDispatch,
|
|
37
|
+
handleDispatch,
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export interface DiscordGateway extends Effect.Success<typeof make> {}
|
|
42
|
+
export const DiscordGateway = Tag<DiscordGateway>()
|
|
43
|
+
export const LiveDiscordGateway = LiveSharder >> make.toLayer(DiscordGateway)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as Http from "@effect-http/client"
|
|
2
|
+
import { DiscordRESTError } from "dfx/DiscordREST"
|
|
3
|
+
import { Effect } from "dfx/_common"
|
|
4
|
+
|
|
5
|
+
export interface ResponseWithData<A> extends Http.response.Response {
|
|
6
|
+
readonly json: Effect<never, Http.ResponseDecodeError, A>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type RestResponse<T> = Effect<
|
|
10
|
+
never,
|
|
11
|
+
DiscordRESTError,
|
|
12
|
+
ResponseWithData<T>
|
|
13
|
+
>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const majorResources = ["channels", "guilds", "webhooks"] as const
|
|
2
|
+
|
|
3
|
+
export const routeFromConfig = (path: string, method: string) => {
|
|
4
|
+
// Only keep major ID's
|
|
5
|
+
const routeURL = path
|
|
6
|
+
.split("?")[0]
|
|
7
|
+
.replace(/\/([A-Za-z]+)\/(\d{16,21}|@me)/g, (match, resource) =>
|
|
8
|
+
majorResources.includes(resource) ? match : `/${resource}`,
|
|
9
|
+
)
|
|
10
|
+
// Strip reactions
|
|
11
|
+
.replace(/\/reactions\/(.*)/, "/reactions")
|
|
12
|
+
|
|
13
|
+
return `${method}-${routeURL}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const numberHeader = (headers: Headers) => (key: string) =>
|
|
17
|
+
Maybe.fromNullable(headers.get(key))
|
|
18
|
+
.map(parseFloat)
|
|
19
|
+
.filter(n => !isNaN(n))
|
|
20
|
+
|
|
21
|
+
export const retryAfter = (headers: Headers) =>
|
|
22
|
+
numberHeader(headers)("x-ratelimit-reset-after")
|
|
23
|
+
.orElse(() => numberHeader(headers)("retry-after"))
|
|
24
|
+
.map(Duration.seconds)
|
|
25
|
+
|
|
26
|
+
export const rateLimitFromHeaders = (headers: Headers) =>
|
|
27
|
+
Maybe.struct({
|
|
28
|
+
bucket: Maybe.fromNullable(headers.get("x-ratelimit-bucket")),
|
|
29
|
+
retryAfter: retryAfter(headers),
|
|
30
|
+
limit: numberHeader(headers)("x-ratelimit-limit"),
|
|
31
|
+
remaining: numberHeader(headers)("x-ratelimit-remaining"),
|
|
32
|
+
})
|
|
33
|
+
export type RateLimitDetails = ReturnType<typeof rateLimitFromHeaders>
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import * as Http from "@effect-http/client"
|
|
2
|
+
import { millis } from "@effect/data/Duration"
|
|
3
|
+
import { DiscordConfig } from "./DiscordConfig.js"
|
|
4
|
+
import { ResponseWithData, RestResponse } from "./DiscordREST/types.js"
|
|
5
|
+
import {
|
|
6
|
+
rateLimitFromHeaders,
|
|
7
|
+
retryAfter,
|
|
8
|
+
routeFromConfig,
|
|
9
|
+
} from "./DiscordREST/utils.js"
|
|
10
|
+
import { Log } from "./Log.js"
|
|
11
|
+
import {
|
|
12
|
+
BucketDetails,
|
|
13
|
+
LiveRateLimiter,
|
|
14
|
+
RateLimitStore,
|
|
15
|
+
RateLimiter,
|
|
16
|
+
} from "./RateLimit.js"
|
|
17
|
+
import Pkg from "./package.json" assert { type: "json" }
|
|
18
|
+
|
|
19
|
+
export class DiscordRESTError {
|
|
20
|
+
readonly _tag = "DiscordRESTError"
|
|
21
|
+
constructor(readonly error: Http.HttpClientError) {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const make = Do($ => {
|
|
25
|
+
const { token, rest } = $(DiscordConfig)
|
|
26
|
+
|
|
27
|
+
const http = $(Http.HttpRequestExecutor)
|
|
28
|
+
const log = $(Log)
|
|
29
|
+
const store = $(RateLimitStore)
|
|
30
|
+
const { maybeWait } = $(RateLimiter)
|
|
31
|
+
|
|
32
|
+
const globalRateLimit = maybeWait(
|
|
33
|
+
"dfx.rest.global",
|
|
34
|
+
rest.globalRateLimit.window,
|
|
35
|
+
rest.globalRateLimit.limit,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// Invalid route handling (40x)
|
|
39
|
+
const badRoutesRef = $(Ref.make(HashSet.empty<string>()))
|
|
40
|
+
const addBadRoute = (route: string) =>
|
|
41
|
+
Effect.allParDiscard([
|
|
42
|
+
log.info("DiscordREST", "addBadRoute", route),
|
|
43
|
+
badRoutesRef.update(s => s.add(route)),
|
|
44
|
+
store.incrementCounter(
|
|
45
|
+
"dfx.rest.invalid",
|
|
46
|
+
Duration.minutes(10).millis,
|
|
47
|
+
10000,
|
|
48
|
+
),
|
|
49
|
+
])
|
|
50
|
+
const isBadRoute = (route: string) => badRoutesRef.get.map(s => s.has(route))
|
|
51
|
+
const removeBadRoute = (route: string) =>
|
|
52
|
+
badRoutesRef.update(s => s.remove(route))
|
|
53
|
+
|
|
54
|
+
const invalidRateLimit = (route: string) =>
|
|
55
|
+
isBadRoute(route).tap(invalid =>
|
|
56
|
+
invalid
|
|
57
|
+
? maybeWait("dfx.rest.invalid", Duration.minutes(10), 10000)
|
|
58
|
+
: Effect.unit(),
|
|
59
|
+
).asUnit
|
|
60
|
+
|
|
61
|
+
// Request rate limiting
|
|
62
|
+
const requestRateLimit = (path: string, request: Http.Request) =>
|
|
63
|
+
Do($ => {
|
|
64
|
+
const route = routeFromConfig(path, request.method)
|
|
65
|
+
const maybeBucket = $(store.getBucketForRoute(route))
|
|
66
|
+
const bucket = maybeBucket.getOrElse(
|
|
67
|
+
(): BucketDetails => ({
|
|
68
|
+
key: `?.${route}`,
|
|
69
|
+
resetAfter: 5000,
|
|
70
|
+
limit: 1,
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
const resetAfter = millis(bucket.resetAfter)
|
|
74
|
+
|
|
75
|
+
$(invalidRateLimit(route))
|
|
76
|
+
$(maybeWait(`dfx.rest.${bucket.key}`, resetAfter, bucket.limit))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Update rate limit buckets
|
|
80
|
+
const updateBuckets = (request: Http.Request, response: Http.Response) =>
|
|
81
|
+
Do($ => {
|
|
82
|
+
const route = routeFromConfig(request.url, request.method)
|
|
83
|
+
const { bucket, retryAfter, limit, remaining } = $(
|
|
84
|
+
rateLimitFromHeaders(response.headers),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const effectsToRun = [
|
|
88
|
+
removeBadRoute(route),
|
|
89
|
+
store.putBucketRoute(route, bucket),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
const hasBucket = $(store.hasBucket(bucket))
|
|
93
|
+
if (!hasBucket || limit - 1 === remaining) {
|
|
94
|
+
effectsToRun.push(
|
|
95
|
+
store.removeCounter(`dfx.rest.?.${route}`),
|
|
96
|
+
store.putBucket({
|
|
97
|
+
key: bucket,
|
|
98
|
+
resetAfter: retryAfter.millis,
|
|
99
|
+
limit: !hasBucket && remaining > 0 ? remaining : limit,
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
$(Effect.allParDiscard(effectsToRun))
|
|
105
|
+
}).ignore
|
|
106
|
+
|
|
107
|
+
const httpExecutor = http.execute.filterStatusOk
|
|
108
|
+
.contramap(_ =>
|
|
109
|
+
_.updateUrl(_ => `${rest.baseUrl}${_}`).setHeaders({
|
|
110
|
+
Authorization: `Bot ${token.value}`,
|
|
111
|
+
"User-Agent": `DiscordBot (https://github.com/tim-smart/dfx, ${Pkg.version})`,
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
.catchAll(_ => Effect.fail(new DiscordRESTError(_)))
|
|
115
|
+
|
|
116
|
+
const executor = <A = unknown>(
|
|
117
|
+
request: Http.Request,
|
|
118
|
+
): Effect<never, DiscordRESTError, ResponseWithData<A>> =>
|
|
119
|
+
Do($ => {
|
|
120
|
+
$(requestRateLimit(request.url, request))
|
|
121
|
+
$(globalRateLimit)
|
|
122
|
+
|
|
123
|
+
const response = $(httpExecutor(request))
|
|
124
|
+
|
|
125
|
+
$(updateBuckets(request, response))
|
|
126
|
+
|
|
127
|
+
return response as ResponseWithData<A>
|
|
128
|
+
}).catchTag("DiscordRESTError", e => {
|
|
129
|
+
if (e.error._tag !== "StatusCodeError") {
|
|
130
|
+
return Effect.fail(e)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const response = e.error.response
|
|
134
|
+
|
|
135
|
+
switch (e.error.status) {
|
|
136
|
+
case 403:
|
|
137
|
+
return Do($ => {
|
|
138
|
+
$(
|
|
139
|
+
Effect.allParDiscard([
|
|
140
|
+
log.info("DiscordREST", "403", request.url),
|
|
141
|
+
addBadRoute(routeFromConfig(request.url, request.method)),
|
|
142
|
+
updateBuckets(request, response),
|
|
143
|
+
]),
|
|
144
|
+
)
|
|
145
|
+
return $(Effect.fail(e))
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
case 429:
|
|
149
|
+
return Do($ => {
|
|
150
|
+
$(
|
|
151
|
+
Effect.allParDiscard([
|
|
152
|
+
log.info("DiscordREST", "429", request.url),
|
|
153
|
+
addBadRoute(routeFromConfig(request.url, request.method)),
|
|
154
|
+
updateBuckets(request, response),
|
|
155
|
+
Effect.sleep(
|
|
156
|
+
retryAfter(response.headers).getOrElse(() =>
|
|
157
|
+
Duration.seconds(5),
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
]),
|
|
161
|
+
)
|
|
162
|
+
return $(executor<A>(request))
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return Effect.fail(e)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const routes = Discord.createRoutes<Partial<Http.MakeOptions>>(
|
|
170
|
+
<R, P>({
|
|
171
|
+
method,
|
|
172
|
+
url,
|
|
173
|
+
params,
|
|
174
|
+
options = {},
|
|
175
|
+
}: Discord.Route<P, Partial<Http.MakeOptions>>): RestResponse<R> => {
|
|
176
|
+
const hasBody = method !== "GET" && method !== "DELETE"
|
|
177
|
+
let request = Http.make(method as any)(url, options)
|
|
178
|
+
|
|
179
|
+
if (!hasBody) {
|
|
180
|
+
if (params) {
|
|
181
|
+
request = request.appendParams(params as any)
|
|
182
|
+
}
|
|
183
|
+
} else if (
|
|
184
|
+
params &&
|
|
185
|
+
request.body._tag === "Some" &&
|
|
186
|
+
request.body.value._tag === "FormDataBody"
|
|
187
|
+
) {
|
|
188
|
+
request.body.value.value.append("payload_json", JSON.stringify(params))
|
|
189
|
+
} else if (params) {
|
|
190
|
+
request = request.jsonBody(params)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return executor(request)
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return { executor, ...routes }
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
export interface DiscordREST extends Effect.Success<typeof make> {}
|
|
201
|
+
export const DiscordREST = Tag<DiscordREST>()
|
|
202
|
+
export const LiveDiscordREST =
|
|
203
|
+
LiveRateLimiter >> Layer.effect(DiscordREST, make)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type Flags<T extends number | bigint> = Record<string, T>
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns all the flags OR'ed together.
|
|
5
|
+
*/
|
|
6
|
+
export function all(flags: Flags<number>): number
|
|
7
|
+
export function all(flags: Flags<bigint>): bigint
|
|
8
|
+
export function all(flags: Flags<any>): any {
|
|
9
|
+
return Object.values(flags).reduce((acc, flag) => acc | flag)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns a function that converts a bitfield to a list of flag names.
|
|
14
|
+
*/
|
|
15
|
+
export function toList<T extends Flags<number>>(
|
|
16
|
+
flags: T,
|
|
17
|
+
): (bitfield: number) => (keyof T)[]
|
|
18
|
+
export function toList<T extends Flags<bigint>>(
|
|
19
|
+
flags: T,
|
|
20
|
+
): (bitfield: bigint) => (keyof T)[]
|
|
21
|
+
export function toList<T extends Flags<any>>(
|
|
22
|
+
flags: T,
|
|
23
|
+
): (bitfield: any) => (keyof T)[] {
|
|
24
|
+
const entries = Object.entries(flags)
|
|
25
|
+
return val =>
|
|
26
|
+
entries.reduce(
|
|
27
|
+
(acc, [key, flag]) => ((val & flag) === flag ? [...acc, key] : acc),
|
|
28
|
+
[] as (keyof T)[],
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns a function that converts a list of flags names to a bigint bitfield.
|
|
34
|
+
*/
|
|
35
|
+
export const fromListBigint =
|
|
36
|
+
<T extends Flags<bigint>>(flags: T) =>
|
|
37
|
+
(list: (keyof T)[]) =>
|
|
38
|
+
list.reduce((acc, key) => acc | flags[key], BigInt(0))
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns a function that converts a list of flags names to a bitfield.
|
|
42
|
+
*/
|
|
43
|
+
export const fromList =
|
|
44
|
+
<T extends Flags<number>>(flags: T) =>
|
|
45
|
+
(list: (keyof T)[]) =>
|
|
46
|
+
list.reduce((acc, key) => acc | flags[key], 0)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Checks if a bigint bitfield contains and a flag value.
|
|
50
|
+
*/
|
|
51
|
+
export const hasBigInt = (flag: bigint | string) => {
|
|
52
|
+
const flagBigInt = BigInt(flag)
|
|
53
|
+
return (bits: bigint | string) => {
|
|
54
|
+
const bitsBigInt = BigInt(bits)
|
|
55
|
+
return (bitsBigInt & flagBigInt) === flagBigInt
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Checks if a bitfield contains and a flag value.
|
|
61
|
+
*/
|
|
62
|
+
export const has = (flag: number | string) => {
|
|
63
|
+
const flagNumber = +flag
|
|
64
|
+
return (bits: number | string) => {
|
|
65
|
+
const bitsNumber = +bits
|
|
66
|
+
return (bitsNumber & flagNumber) === flagNumber
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as Flags from "dfx/Helpers/flags"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* All the intents
|
|
5
|
+
*/
|
|
6
|
+
export const ALL = Flags.all(Discord.GatewayIntents)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Privileged intents
|
|
10
|
+
*/
|
|
11
|
+
export const PRIVILEGED =
|
|
12
|
+
Discord.GatewayIntents.GUILD_PRESENCES |
|
|
13
|
+
Discord.GatewayIntents.GUILD_MEMBERS |
|
|
14
|
+
Discord.GatewayIntents.MESSAGE_CONTENT
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Un-privileged intents
|
|
18
|
+
*/
|
|
19
|
+
export const UNPRIVILEGED = ALL ^ PRIVILEGED
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Function that converts a intents bitfield value to a list of intent names.
|
|
23
|
+
*/
|
|
24
|
+
export const toList = Flags.toList(Discord.GatewayIntents)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Function that converts a list of intent names to a bitfield value.
|
|
28
|
+
*/
|
|
29
|
+
export const fromList = Flags.fromList(Discord.GatewayIntents)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if an intent flag exists in the permissions.
|
|
33
|
+
*/
|
|
34
|
+
export const has = Flags.has
|