1 ! Copyright (C) 2023 Doug Coleman.
2 ! See https://factorcode.org/license.txt for BSD license.
3 USING: accessors alien.syntax arrays assocs byte-arrays calendar
4 combinators combinators.short-circuit concurrency.mailboxes
5 continuations destructors formatting hashtables help http
6 http.client http.websockets io io.encodings.string
7 io.encodings.utf8 io.streams.string json kernel math multiline
8 namespaces prettyprint prettyprint.sections random sequences
9 sets splitting strings threads tools.hexdump unicode vocabs
13 CONSTANT: discord-api-url "https://discord.com/api/v10"
14 CONSTANT: discord-bot-gateway "wss://gateway.discord.gg/gateway/bot?v=10&encoding=json"
16 TUPLE: discord-webhook url id token ;
18 TUPLE: discord-bot-config
19 client-id client-secret
20 token application-id guild-id channel-id permissions
21 user-callback obey-names
23 discord-bot mailbox connect-thread ;
26 config in out bot-thread heartbeat-thread
27 send-heartbeat? reconnect? stop?
30 application user session_id resume_gateway_url
33 : <discord-bot> ( in out config -- discord-bot )
43 H{ } clone >>channels ;
45 : add-discord-auth-header ( request -- request )
46 discord-bot-config get token>> "Bot " prepend "Authorization" set-header ;
48 : add-json-header ( request -- request )
49 "application/json" "Content-Type" set-header ;
51 : json-request ( request -- json ) http-request nip utf8 decode json> ;
52 : gwrite ( string -- ) [ write ] with-global ;
53 : gprint ( string -- ) [ print ] with-global ;
54 : gprint-flush ( string -- ) [ print flush ] with-global ;
55 : gflush ( -- ) [ flush ] with-global ;
56 : gbl ( -- ) [ bl ] with-global ;
57 : gnl ( -- ) [ nl ] with-global ;
58 : g. ( object -- ) [ . ] with-global ;
59 : g... ( object -- ) [ ... ] with-global ;
61 : >discord-url ( route -- url ) discord-api-url prepend ;
62 : discord-get-request ( route -- request )
63 >discord-url <get-request> add-discord-auth-header ;
64 : discord-get ( route -- json )
65 discord-get-request json-request ;
66 : discord-post-request ( payload route -- request )
67 >discord-url <post-request> add-discord-auth-header ;
68 : discord-patch-request ( payload route -- request )
69 >discord-url <patch-request> add-discord-auth-header ;
70 : discord-delete-request ( route -- request )
71 >discord-url <delete-request> add-discord-auth-header ;
72 : discord-post ( payload route -- json )
73 discord-post-request json-request ;
74 : discord-post-json ( payload route -- json )
75 [ >json ] dip discord-post-request add-json-header json-request ;
76 : discord-patch-json ( payload route -- json )
77 [ >json ] dip discord-patch-request add-json-header json-request ;
78 : discord-delete-json ( route -- json )
79 discord-delete-request add-json-header json-request ;
81 : bot-guild-join-uri ( discord-bot-config -- uri )
82 [ permissions>> ] [ client-id>> ] [ guild-id>> ] tri
83 "https://discord.com/oauth2/authorize?scope=bot&permissions=%d&client_id=%s&guild_id=%s" sprintf ;
85 : get-discord-user ( user -- json ) "/users/%s" sprintf discord-get ;
86 : get-discord-users-me ( -- json ) "/users/@me" discord-get ;
87 : get-discord-users-guilds ( -- json ) "/users/@me/guilds" discord-get ;
88 : get-discord-users-guild-member ( guild-id -- json ) "/users/@me/guilds/%s/member" sprintf discord-get ;
89 : get-discord-user-connections ( -- json ) "/users/@me/connections" discord-get ;
90 : get-discord-user-application-role-connection ( application-id -- json )
91 "/users/@me/applications/%s/role-connection" sprintf discord-get ;
92 : get-discord-channel ( channel-id -- json ) "/channels/%s" sprintf discord-get ;
93 : get-discord-channel-pins ( channel-id -- json ) "/channels/%s/pins" sprintf discord-get ;
94 : get-discord-channel-messages ( channel-id -- json ) "/channels/%s/messages" sprintf discord-get ;
95 : get-discord-channel-message ( channel-id message-id -- json ) "/channels/%s/messages/%s" sprintf discord-get ;
97 : set-discord-application-commands ( json application-id -- json )
98 "/applications/%s/commands" sprintf discord-post-json ;
99 : set-discord-application-guild-commands ( json application-id guild-id -- json )
100 "/applications/%s/guilds/%s/commands" sprintf discord-post-json ;
102 : delete-discord-application-command ( application-id -- json )
103 "/applications/%s/commands" sprintf discord-delete-json ;
104 : delete-discord-application-guild-command ( application-id -- json )
105 "/applications/%s/commands" sprintf discord-delete-json ;
107 : create-interaction-response ( interaction-id interaction-token -- json )
108 [ H{ { "type" 4 } { "data" "pang" } } clone ] 2dip
109 "/webhooks/%s/%s/messages/callback" sprintf discord-post ;
111 : get-original-interaction-response ( application-id interaction-token -- json )
112 "/webhooks/%s/%s/messages/@original" sprintf discord-get ;
116 : send-message* ( string channel-id -- json )
117 [ "content" associate ] dip "/channels/%s/messages" sprintf discord-post-json ;
118 : send-message ( string channel-id -- ) send-message* drop ;
119 : reply-message ( string -- ) discord-bot get last-message>> "channel_id" of send-message ;
120 : ghosting-payload ( -- string )
122 197 [ { 124 124 124 124 8203 } ] replicate concat
123 1 [ 124 ] replicate "" 3append-as ;
125 : ghost-ping ( message who channel-id -- )
126 [ ghosting-payload glue ] dip send-message ;
128 : get-channel-webhooks ( channel-id -- json ) "/channels/%s/webhooks" sprintf discord-get ;
129 : get-guild-webhooks ( guild-id -- json ) "/guilds/%s/webhooks" sprintf discord-get ;
130 : get-webhook ( webhook-id -- json ) "/webhooks/%s" sprintf discord-get ;
132 : get-guilds-me ( -- json ) "/users/@me/guilds" discord-get ;
133 : get-guild-active-threads ( channel-id -- json ) "/guilds/%s/threads/active" sprintf discord-get ;
134 : get-application-info ( -- json ) "/oauth2/applications/@me" discord-get ;
136 : get-discord-gateway ( -- json ) "/gateway" discord-get ;
137 : get-discord-bot-gateway ( -- json ) "/gateway/bot" discord-get ;
139 : gateway-identify-json ( -- json )
140 \ discord-bot get config>> token>> [[ {
146 "browser": "discord.factor",
147 "device": "discord.factor"
149 "large_threshold": 250,
152 }]] sprintf json> >json ;
154 : jitter-millis ( heartbeat-millis -- millis ) 0 1 uniform-random-float * >integer ;
156 : send-heartbeat ( seq/f -- )
157 json-null or "d" associate H{ { "op" 1 } } assoc-union!
158 >json send-masked-message ;
160 : start-heartbeat-thread ( millis -- )
163 [ jitter-millis sleep f send-heartbeat ]
167 _ sleep discord-bot get
168 [ send-heartbeat?>> ] [ sequence-number>> ] bi
171 output-stream get disposed>>
172 [ f ] [ send-heartbeat t ] if
173 ] [ 2drop f ] recover
177 ] "discord-bot-heartbeat" spawn discord-bot get heartbeat-thread<< ;
188 { REQUEST_MEMBERS 8 }
189 { INVALIDATE_SESSION 9 }
195 AUTOMOD_ACTION AUTOMOD_RULE_CREATE AUTOMOD_RULE_DELETE AUTOMOD_RULE_UPDATE
196 CHANNEL_CREATE CHANNEL_DELETE CHANNEL_PINS_UPDATE CHANNEL_UPDATE
197 GUILD_AVAILABLE GUILD_BAN_ADD GUILD_BAN_REMOVE
198 GUILD_CHANNEL_CREATE GUILD_CHANNEL_DELETE GUILD_CHANNEL_PINS_UPDATE GUILD_CHANNEL_UPDATE
199 GUILD_CREATE GUILD_EMOJIS_UPDATE GUILD_INTEGRATION_UPDATE GUILD_JOIN
200 GUILD_MEMBER_ADD GUILD_MEMBER_REMOVE GUILD_MEMBER_UPDATE GUILD_REMOVE
201 GUILD_ROLE_CREATE GUILD_ROLE_DELETE GUILD_ROLE_UPDATE
202 GUILD_STICKERS_UPDATE GUILD_UNAVAILABLE GUILD_UPDATE
204 INVITE_CREATE INVITE_DELETE
205 MEMBER_BAN MEMBER_JOIN MEMBER_REMOVE MEMBER_UNBAN MEMBER_UPDATE
206 MESSAGE_CREATE MESSAGE_DELETE MESSAGE_EDIT
207 MESSAGE_REACTION_ADD MESSAGE_REACTION_REMOVE MESSAGE_UPDATE
209 RAW_MESSAGE_DELETE RAW_MESSAGE_EDIT
210 REACTION_ADD REACTION_CLEAR REACTION_REMOVE
211 SCHEDULED_EVENT_CREATE SCHEDULED_EVENT_REMOVE SCHEDULED_EVENT_UPDATE
212 SCHEDULED_EVENT_USER_ADD SCHEDULED_EVENT_USER_REMOVE
213 SHARD_CONNECT SHARD_DISCONNECT
214 SHARD_READY SHARD_RESUMED THREAD_CREATE
215 THREAD_DELETE THREAD_JOIN
216 THREAD_MEMBER_JOIN THREAD_MEMBER_REMOVE THREAD_UPDATE
217 VOICE_SERVER_UPDATE VOICE_STATE_UPDATE
218 READY TYPING_START USER_UPDATE WEBHOOKS_UPDATE ;
220 : guild-name ( guild-id -- name ) discord-bot get guilds>> at "name" of ;
221 : channel-name ( guild-id channel-id -- name ) 2array discord-bot get channels>> at "name" of ;
222 : guild-channel-name ( guild-id channel-id -- name )
224 [ drop guild-name "`" dup surround ]
225 [ channel-name "`" dup surround ] 2tri ":" glue ;
227 : handle-channel-message ( json -- )
229 [ "guild_id" of "guild_id:" prepend write bl ]
230 [ "id" of "channel_id:" prepend write bl ]
231 [ [ "guild_id" of ] [ "id" of ] bi guild-channel-name write bl ]
232 [ "name" of "name:`" "`" surround write bl ]
233 [ "rate_limit_per_user" of "rate_limit_per_user:%d" sprintf write bl ]
234 [ "default_auto_archive_duration" of -1 or "default_auto_archive_duration:%d minutes" sprintf write bl ]
235 [ "nsfw" of unparse "nsfw:%s" sprintf write bl ]
236 [ "position" of unparse "position:%s" sprintf write bl ]
237 [ "topic" of json-null>f "topic:`" "`" surround print flush ]
240 : handle-guild-message ( json -- )
242 [ dup "id" of discord-bot get guilds>> set-at ]
244 [ "id" of ] [ "channels" of ] bi
245 discord-bot get channels>> '[ tuck "id" of 2array _ set-at ] with each
249 : my-user-id ( -- id ) discord-bot get user>> "id" of ;
250 : message-from-me? ( json -- ? ) "author" of "id" of my-user-id = ;
251 : message-mentions ( json -- ids ) "mentions" of ;
252 : message-mentions-ids ( json -- ids ) message-mentions [ "id" of ] map ;
253 : message-mentions-me? ( json -- ? ) message-mentions my-user-id '[ "id" of _ = ] any? ;
254 : message-mentions-me-and-not-from-me? ( json -- ? )
255 { [ message-mentions-me? ] [ message-from-me? not ] } 1&& ;
256 : message-channel-id ( json -- ids ) "channel_id" of ;
257 : obey-message? ( json -- ? )
258 "author" of "username" of
259 discord-bot get config>> obey-names>> [ in? ] [ drop f ] if* ;
261 : handle-incoming-message ( guild_id channel_id message_id author content -- )
264 GENERIC: dispatch-message ( json singleton -- )
265 M: object dispatch-message "unhandled: " gwrite name>> gwrite g... ;
266 M: string dispatch-message "unhandled string: " gwrite gwrite g... ;
268 M: READY dispatch-message drop
269 [ discord-bot get ] dip
272 [ "session_id" of >>session_id ]
273 [ "application" of >>application ]
274 [ "resume_gateway_url" of >>resume_gateway_url ]
277 M: AUTOMOD_ACTION dispatch-message 2drop ;
278 M: AUTOMOD_RULE_CREATE dispatch-message 2drop ;
279 M: AUTOMOD_RULE_UPDATE dispatch-message 2drop ;
280 M: AUTOMOD_RULE_DELETE dispatch-message 2drop ;
281 M: CHANNEL_CREATE dispatch-message drop handle-channel-message ;
282 M: CHANNEL_UPDATE dispatch-message drop handle-channel-message ;
283 M: CHANNEL_DELETE dispatch-message drop handle-channel-message ;
284 M: CHANNEL_PINS_UPDATE dispatch-message 2drop ;
285 M: GUILD_CREATE dispatch-message drop handle-guild-message ;
286 M: GUILD_UPDATE dispatch-message drop handle-guild-message ;
287 M: GUILD_EMOJIS_UPDATE dispatch-message 2drop ;
288 M: GUILD_STICKERS_UPDATE dispatch-message 2drop ;
289 M: GUILD_INTEGRATION_UPDATE dispatch-message 2drop ;
290 M: GUILD_CHANNEL_CREATE dispatch-message 2drop ;
291 M: GUILD_CHANNEL_UPDATE dispatch-message 2drop ;
292 M: GUILD_CHANNEL_DELETE dispatch-message 2drop ;
293 M: GUILD_CHANNEL_PINS_UPDATE dispatch-message 2drop ;
294 M: GUILD_JOIN dispatch-message 2drop ;
295 M: GUILD_REMOVE dispatch-message 2drop ;
296 M: GUILD_AVAILABLE dispatch-message 2drop ;
297 M: GUILD_UNAVAILABLE dispatch-message 2drop ;
298 M: GUILD_MEMBER_ADD dispatch-message 2drop ;
299 M: GUILD_MEMBER_REMOVE dispatch-message 2drop ;
300 M: GUILD_MEMBER_UPDATE dispatch-message 2drop ;
301 M: GUILD_BAN_ADD dispatch-message 2drop ;
302 M: GUILD_BAN_REMOVE dispatch-message 2drop ;
303 M: GUILD_ROLE_CREATE dispatch-message 2drop ;
304 M: GUILD_ROLE_UPDATE dispatch-message 2drop ;
305 M: GUILD_ROLE_DELETE dispatch-message 2drop ;
306 M: INTERACTION_CREATE dispatch-message 2drop ;
307 M: INVITE_CREATE dispatch-message 2drop ;
308 M: INVITE_DELETE dispatch-message 2drop ;
309 M: MEMBER_BAN dispatch-message 2drop ;
310 M: MEMBER_UNBAN dispatch-message 2drop ;
311 M: MEMBER_JOIN dispatch-message 2drop ;
312 M: MEMBER_REMOVE dispatch-message 2drop ;
313 M: MEMBER_UPDATE dispatch-message 2drop ;
314 M: PRESENCE_UPDATE dispatch-message 2drop ;
315 M: RAW_MESSAGE_EDIT dispatch-message 2drop ;
316 M: RAW_MESSAGE_DELETE dispatch-message 2drop ;
317 M: REACTION_ADD dispatch-message 2drop ;
318 M: REACTION_REMOVE dispatch-message 2drop ;
319 M: REACTION_CLEAR dispatch-message 2drop ;
320 M: SCHEDULED_EVENT_CREATE dispatch-message 2drop ;
321 M: SCHEDULED_EVENT_REMOVE dispatch-message 2drop ;
322 M: SCHEDULED_EVENT_UPDATE dispatch-message 2drop ;
323 M: SCHEDULED_EVENT_USER_ADD dispatch-message 2drop ;
324 M: SCHEDULED_EVENT_USER_REMOVE dispatch-message 2drop ;
325 M: SHARD_CONNECT dispatch-message 2drop ;
326 M: SHARD_DISCONNECT dispatch-message 2drop ;
327 M: SHARD_READY dispatch-message 2drop ;
328 M: SHARD_RESUMED dispatch-message 2drop ;
329 M: THREAD_CREATE dispatch-message 2drop ;
330 M: THREAD_JOIN dispatch-message 2drop ;
331 M: THREAD_UPDATE dispatch-message 2drop ;
332 M: THREAD_DELETE dispatch-message 2drop ;
333 M: THREAD_MEMBER_JOIN dispatch-message 2drop ;
334 M: THREAD_MEMBER_REMOVE dispatch-message 2drop ;
335 M: USER_UPDATE dispatch-message 2drop ;
336 M: VOICE_STATE_UPDATE dispatch-message 2drop ;
337 M: VOICE_SERVER_UPDATE dispatch-message 2drop ;
338 M: WEBHOOKS_UPDATE dispatch-message 2drop ;
340 M: MESSAGE_CREATE dispatch-message drop
342 "MESSAGE_CREATE" write bl [
344 [ [ "guild_id" of ] [ "channel_id" of ] bi guild-channel-name write bl ]
345 [ "id" of "id:" prepend write bl ]
346 [ "author" of "username" of ":" append write bl ]
347 [ "content" of "`" dup surround print flush ]
351 [ [ "guild_id" of ] [ "channel_id" of ] bi ]
353 [ "author" of "username" of ]
355 } cleave handle-incoming-message
358 M: MESSAGE_UPDATE dispatch-message drop
360 "MESSAGE_UPDATE" write bl {
361 [ [ "guild_id" of ] [ "channel_id" of ] bi guild-channel-name write bl ]
362 [ "id" of "id:" prepend write bl ]
363 [ "author" of "username" of ":" append write bl ]
364 [ "content" of "`" dup surround print flush ]
367 M: MESSAGE_EDIT dispatch-message 2drop ;
368 M: MESSAGE_DELETE dispatch-message drop
370 "MESSAGE_DELETE" write bl {
371 [ [ "guild_id" of ] [ "channel_id" of ] bi guild-channel-name write bl ]
372 [ "id" of "id:" prepend print flush ]
375 M: MESSAGE_REACTION_ADD dispatch-message 2drop ;
376 M: MESSAGE_REACTION_REMOVE dispatch-message 2drop ;
377 M: TYPING_START dispatch-message drop
379 "TYPING_START:" write bl
380 [ [ "guild_id" of ] [ "channel_id" of ] bi guild-channel-name write bl ]
382 "member" of [ "nick" of json-null>f ] [ "user" of "username" of ] bi or
383 " started typing" append print flush
387 : handle-discord-RESUME ( json -- ) drop ;
389 : handle-discord-RECONNECT ( json -- ) drop ;
391 : handle-discord-HELLO ( json -- )
392 "d" of "heartbeat_interval" of start-heartbeat-thread
393 gateway-identify-json send-masked-message ;
395 : handle-discord-HEARTBEAT_ACK ( json -- ) drop ;
397 : parse-discord-op ( json -- )
399 clone now "timestamp" pick set-at discord-bot get
400 [ messages>> push ] [ [ "d" of ] dip last-message<< ] 2bi
402 [ ] [ "s" of discord-bot get sequence-number<< ] [ "op" of ] tri {
404 [ "d" of ] [ "t" of [ "discord" lookup-word ] transmute ] bi
407 discord-bot get config>> user-callback>>
408 [ call( json message-type -- ) ] [ 2drop ] if*
411 { 6 [ handle-discord-RESUME ] }
412 { 7 [ handle-discord-RECONNECT ] }
413 { 10 [ handle-discord-HELLO ] }
414 { 11 [ handle-discord-HEARTBEAT_ACK ] }
415 [ "unknown opcode:" gwrite g. g... gflush ]
418 : stopping-discord-bot ( -- )
419 discord-bot get t >>stop? drop ;
421 DEFER: discord-reconnect
422 : handle-discord-websocket ( obj opcode -- )
423 "opcode: " gwrite dup g. over dup byte-array? [ utf8 decode json> ] when g... gflush
427 "closed with error, code %d" sprintf gprint-flush
429 ] [ "closed with f" gprint-flush ] if*
433 [ utf8 decode json> parse-discord-op ] bi
436 [ [ hexdump. flush ] with-global ] when*
439 drop "close received" gprint-flush
442 [ "ping received" gprint-flush send-heartbeat ] when*
447 : discord-reconnect ( -- )
449 discord-bot-gateway <get-request>
450 add-discord-auth-header
451 [ drop ] do-http-request
455 [ in>> stream>> ] [ out>> stream>> ] bi \ discord-bot-config get
457 [ discord-bot-config get discord-bot<< ] keep
460 discord-bot get [ in>> ] [ out>> ] bi
462 [ handle-discord-websocket discord-bot-config get discord-bot>> stop?>> not ] read-websocket-loop
465 discord-bot-config get mailbox>> "disconnected" swap mailbox-put
466 ] "Discord Bot" spawn >>bot-thread discord-bot-config get discord-bot<<
469 M: discord-bot dispose
474 [ in>> &dispose drop ]
475 [ out>> &dispose drop ]
476 [ f >>in f >>out drop ] tri
479 M: discord-bot-config dispose
480 discord-bot>> dispose ;
482 : discord-connect ( config -- )
484 \ discord-bot-config [
489 discord-bot-config get
490 ! wait here for signal to maybe reconnect
491 [ mailbox>> mailbox-get ] [ discord-bot>> ] bi
492 [ reconnect?>> ] [ stop?>> not ] bi and
494 ] "Discord bot connect loop" spawn discord-bot-config get connect-thread<<
497 : reply-command ( json -- ? )
498 "content" of [ blank? ] trim
499 " " split1 [ [ blank? ] trim ] bi@
502 ":" split1 swap lookup-word dup [
503 [ [ print-topic ] with-string-writer ]
505 ] when "vocab:word not found (maybe it's not loaded)" or
509 all-words swap '[ name>> _ = ] filter
513 [ props>> "declared-effect" of unparse " " glue ] tri
515 [ "no words found" reply-message f ]
516 [ "\n" join reply-message t ] if-empty
521 : reply-echo ( json -- ? )
522 dup message-mentions-me-and-not-from-me?
523 [ "content" of "echobot sez: " prepend reply-message t ]
526 GENERIC: discord-help-bot ( json opcode -- )
528 M: object discord-help-bot 2drop ;
530 M: MESSAGE_CREATE discord-help-bot drop
531 '[ _ { [ reply-command ] [ reply-echo ] } 1|| drop ]
532 [ g... gflush ] recover ;