]> gitweb.factorcode.org Git - factor.git/commitdiff
discord: add basic discord bot support
authorDoug Coleman <doug.coleman@gmail.com>
Tue, 28 Mar 2023 04:06:25 +0000 (23:06 -0500)
committerDoug Coleman <doug.coleman@gmail.com>
Tue, 28 Mar 2023 04:32:17 +0000 (23:32 -0500)
- go to discord developer portal https://discord.com/developers/applications
- make a discord application
- add a bot to that app
-- enable PRESENCE INTENT, SERVER MEMBERS INTENT, MESSAGE CONTENT INTENT (as desired)

! For websockets
t{ discord-bot-config
    { token "...from discord developer portal..." }
    { application-id "10885523740" } ! from developer portal
    { guild-id "1357300335" }        ! from "developer mode" in discord client, right click the server and "copy id"
    { channel-id "1089175837623" }   ! from "developer mode", right click channel and "copy id"
    { permissions 2048 }             ! intentions (2048 is send messages)
}
discord-connect

! For REST api
T{ discord-bot-config
    { client-id "bot id from dev portal" }
    { client-secret "from dev portal" }
}

basis/http/websockets/authors.txt [new file with mode: 0644]
basis/http/websockets/websockets.factor [new file with mode: 0644]
extra/discord/authors.txt [new file with mode: 0644]
extra/discord/discord.factor [new file with mode: 0644]

diff --git a/basis/http/websockets/authors.txt b/basis/http/websockets/authors.txt
new file mode 100644 (file)
index 0000000..7c1b2f2
--- /dev/null
@@ -0,0 +1 @@
+Doug Coleman
diff --git a/basis/http/websockets/websockets.factor b/basis/http/websockets/websockets.factor
new file mode 100644 (file)
index 0000000..c3c05e7
--- /dev/null
@@ -0,0 +1,134 @@
+! Copyright (C) 2023 Doug Coleman.
+! See https://factorcode.org/license.txt for BSD license.
+USING: accessors alien.syntax assocs base64 combinators
+crypto.xor endian http io io.encodings.string io.encodings.utf8
+kernel math math.bitwise multiline random sequences strings ;
+IN: http.websockets
+
+CONSTANT: websocket-version "13"
+
+: random-websocket-key ( -- base64 )
+    16 random-bytes >base64 >string ;
+
+: add-websocket-upgrade-headers ( request -- request )
+    "connection" over header>> delete-at
+    websocket-version "Sec-WebSocket-Version" set-header
+    random-websocket-key "Sec-WebSocket-Key" set-header
+    ! websocket-extensions "Sec-WebSocket-Extensions" set-header
+    ! websocket-protocol "Sec-WebSocket-Protocol" set-header
+    "Upgrade" "Connection" set-header
+    "websocket" "Upgrade" set-header
+    "no-cache" "Pragma" set-header
+    "no-cache" "Cache-Control" set-header
+    "permessage-deflate; client_max_window_bits" "Sec-WebSocket-Extensions" set-header
+    dup url>> host>> "Host" set-header ;
+
+CONSTANT: websocket-opcode-continue-frame 0
+CONSTANT: websocket-opcode-text-frame 1
+CONSTANT: websocket-opcode-binary-frame 2
+CONSTANT: websocket-opcode-connection-close-frame 8
+CONSTANT: websocket-opcode-ping-frame 9
+CONSTANT: websocket-opcode-pong-frame 0xa
+
+ENUM: WEBSOCKET-CLOSE
+{ WEBSOCKET-CLOSE-NORMAL 1000 }
+{ WEBSOCKET-CLOSE-GOING-AWAY 1001 }
+{ WEBSOCKET-CLOSE-PROTOCOL-ERROR 1002 }
+{ WEBSOCKET-CLOSE-UNSUPPORTED-DATA 1003 }
+{ WEBSOCKET-CLOSE-RESERVED 1004 }
+{ WEBSOCKET-CLOSE-NO-STATUS-RECEIVED 1005 }
+{ WEBSOCKET-CLOSE-ABNORMAL-CLOSURE 1006 }
+{ WEBSOCKET-CLOSE-INVALID-FRAME-PAYLOAD-DATA 1007 }
+{ WEBSOCKET-CLOSE-PRIVACY-VIOLATION 1008 }
+{ WEBSOCKET-CLOSE-MESSAGE-TOO-BIG 1009 }
+{ WEBSOCKET-CLOSE-MANDATORY-EXT 1010 }
+{ WEBSOCKET-CLOSE-INTERNAL-SERVER-ERRO 1011 }
+{ WEBSOCKET-CLOSE-TLS-HANDSHAKE 1015 } ;
+
+: get-read-payload-length ( -- length masked? )
+    read1 [
+        {
+            { [ dup 125 <= ] [ ] }
+            { [ dup 126 = ] [ drop 2 read be> ] }
+            { [ dup 127 = ] [ drop 8 read be> ] }
+        } cond
+    ] [
+        0x80 mask?
+    ] bi ;
+
+: get-write-payload-length ( bytes -- length-byte length-bytes/f )
+    length {
+        { [ dup 125 <= ] [ f ] }
+        { [ dup 0xffff <= ] [ [ drop 126 ] [ 2 >be ] bi ] }
+        [ [ drop 127 ] [ 8 >be ] bi ]
+    } cond ;
+
+! : send-websocket-fragmented ( bytes opcode -- ) 0b10000000 bitor
+
+: send-websocket-bytes ( bytes mask? opcode final? -- )
+    0b10000000 0b0 ? bitor write1
+    [
+        [
+            get-write-payload-length [ 0x80 bitor ] dip
+            [ write1 ] [ [ write ] when* ] bi*
+        ] [
+            4 random-bytes
+            [ write drop ]
+            [ xor-crypt [ write ] when* ] 2bi
+        ] bi
+    ] [
+        [ get-write-payload-length [ write1 ] [ [ write ] when* ] bi* ]
+        [ [ write ] when* ] bi
+    ] if flush ;
+
+: send-websocket-text ( bytes mask? opcode fin? -- )
+    [ utf8 encode ] 3dip send-websocket-bytes ;
+
+: read-payload ( -- payload )
+    get-read-payload-length [ [ 4 read ] dip read xor-crypt ] [ read ] if ;
+
+: send-pong ( payload -- )
+    t 0xa t send-websocket-bytes ;
+
+: send-masked-message ( payload -- )
+    t 0x1 t send-websocket-text ;
+
+: send-unmasked-message ( payload -- )
+    f 0x2 t send-websocket-text ;
+
+ERROR: unimplemented-opcode opcode message ;
+
+: read-websocket ( -- obj opcode loop? )
+    read1 [
+        ! [ 0x80 mask? drop ] [ 7 clear-bit ] bi
+        7 clear-bit
+        [
+            {
+                { f [ "disconnected" f ] }
+                { 0 [ 0 "continuation frame" unimplemented-opcode t ] }
+                { 1 [ read-payload t ] }
+                { 2 [ read-payload utf8 decode t ] }
+                { 8 [ read-payload be> f ] }
+                { 9 [ read-payload [ send-pong ] keep t ] }
+                { 0xa [ read-payload t ] }
+                [ "fall-through" unimplemented-opcode ]
+            } case
+        ] keep swap
+    ] [
+        f f f
+    ] if* ;
+
+: read-websocket-loop ( quot: ( obj opcode -- loop? ) -- )
+    '[ read-websocket _ dip and ] loop ; inline
+
+![[
+: handle-websocket ( obj opcode -- loop? )
+    {
+        { f [ [ "closed with error, code %d" sprintf . flush ] with-global f ] }
+        { 1 [ [ [ hexdump. flush ] with-global ] when* t ] }
+        { 2 [ [ [ hexdump. flush ] with-global ] when* t ] }
+        { 8 [ [ "close received, code: %d" sprintf print flush ] with-global t ] }
+        { 9 [ [ [ "ping received" print hexdump. flush ] with-global ] when* t ] }
+        [ 2drop t ]
+    } case ;
+]]
\ No newline at end of file
diff --git a/extra/discord/authors.txt b/extra/discord/authors.txt
new file mode 100644 (file)
index 0000000..7c1b2f2
--- /dev/null
@@ -0,0 +1 @@
+Doug Coleman
diff --git a/extra/discord/discord.factor b/extra/discord/discord.factor
new file mode 100644 (file)
index 0000000..be761b9
--- /dev/null
@@ -0,0 +1,281 @@
+! Copyright (C) 2023 Doug Coleman.
+! See https://factorcode.org/license.txt for BSD license.
+USING: accessors alien.syntax assocs calendar combinators
+formatting hashtables http http.client http.client.private
+http.websockets io io.encodings.string io.encodings.utf8 json
+kernel math multiline namespaces prettyprint random sequences
+threads tools.hexdump ;
+IN: discord
+
+CONSTANT: discord-api-url "https://discord.com/api/v10"
+CONSTANT: discord-bot-gateway  "https://gateway.discord.gg/gateway/bot?v=10&encoding=json"
+
+TUPLE: discord-webhook url id token ;
+
+TUPLE: discord-bot-config
+    client-id client-secret
+    token application-id guild-id channel-id permissions ;
+
+TUPLE: discord-bot
+    config in out ui-stdout bot-thread heartbeat-thread
+    send-heartbeat? messages sequence-number
+    name application guilds user session_id resume_gateway_url ;
+
+: <discord-bot> ( in out config -- discord-bot )
+    discord-bot new
+        swap >>config
+        swap >>out
+        swap >>in
+        t >>send-heartbeat?
+        V{ } clone >>messages ;
+
+: add-discord-auth-header ( request -- request )
+    discord-bot-config get token>> "Bot " prepend "Authorization" set-header ;
+
+: add-json-header ( request -- request )
+    "application/json" "Content-Type" set-header ;
+
+: json-request ( request -- json ) http-request nip utf8 decode json> ;
+
+: >discord-url ( route -- url ) discord-api-url prepend ;
+: discord-get-request ( route -- request )
+    >discord-url <get-request> add-discord-auth-header ;
+: discord-get ( route -- json )
+    discord-get-request json-request ;
+: discord-post-request ( payload route -- request )
+    >discord-url <post-request> add-discord-auth-header ;
+: discord-post ( payload route -- json )
+    discord-post-request json-request ;
+: discord-post-json ( payload route -- json )
+    [ >json ] dip discord-post-request add-json-header json-request ;
+
+: bot-guild-join-uri ( discord-bot-config -- uri )
+    [ permissions>> ] [ client-id>> ] [ guild-id>> ] tri
+    "https://discord.com/oauth2/authorize?scope=bot&permissions=%d&client_id=%s&guild_id=%s" sprintf ;
+
+: gateway-identify-json ( -- json )
+    \ discord-bot get config>> token>> [[ {
+        "op": 2,
+        "d": {
+            "token": "%s",
+            "properties": {
+                "os": "darwin",
+                "browser": "discord.factor",
+                "device": "discord.factor"
+            },
+            "large_threshold": 250,
+            "intents": 3276541
+        }
+    }]] sprintf json> >json ;
+
+: jitter-millis ( heartbeat-millis -- millis ) 0 1 uniform-random-float * >integer ;
+
+: send-heartbeat ( seq/f -- )
+    json-null or "d" associate H{ { "op" 1 } } assoc-union!
+    >json send-masked-message ;
+
+: start-heartbeat-thread ( millis -- )
+    '[
+        _
+        [ jitter-millis sleep f send-heartbeat ]
+        [
+            milliseconds
+            '[
+                _ sleep discord-bot get
+                [ send-heartbeat?>> ] [ sequence-number>> ] bi
+                '[ _ send-heartbeat t ] [ f ] if
+            ] loop
+        ] bi
+    ] "discord-bot-heartbeat" spawn discord-bot get heartbeat-thread<< ;
+
+ENUM: discord-opcode
+    { DISPATCH           0 }
+    { HEARTBEAT          1 }
+    { IDENTIFY           2 }
+    { PRESENCE           3 }
+    { VOICE_STATE        4 }
+    { VOICE_PING         5 }
+    { RESUME             6 }
+    { RECONNECT          7 }
+    { REQUEST_MEMBERS    8 }
+    { INVALIDATE_SESSION 9 }
+    { HELLO              10 }
+    { HEARTBEAT_ACK      11 }
+    { GUILD_SYNC         12 } ;
+
+: handle-discord-DISPATCH ( json -- )
+    dup "t" of {
+        { "AUTOMOD_ACTION" [ drop ] }
+        { "AUTOMOD_RULE_CREATE" [ drop ] }
+        { "AUTOMOD_RULE_UPDATE" [ drop ] }
+        { "AUTOMOD_RULE_DELETE" [ drop ] }
+        
+        { "CHANNEL_CREATE" [ drop ] }
+        { "CHANNEL_UPDATE" [ drop ] }
+        { "CHANNEL_DELETE" [ drop ] }
+        { "CHANNEL_PINS_UPDATE" [ drop ] }
+
+        { "GUILD_CREATE" [ drop ] }
+        { "GUILD_UPDATE" [ drop ] }
+        { "GUILD_EMOJIS_UPDATE" [ drop ] }
+        { "GUILD_STICKERS_UPDATE" [ drop ] }
+        { "GUILD_INTEGRATION_UPDATE" [ drop ] }
+        { "GUILD_CHANNEL_CREATE" [ drop ] }
+        { "GUILD_CHANNEL_UPDATE" [ drop ] }
+        { "GUILD_CHANNEL_DELETE" [ drop ] }
+        { "GUILD_CHANNEL_PINS_UPDATE" [ drop ] }
+        { "GUILD_JOIN" [ drop ] }
+        { "GUILD_REMOVE" [ drop ] }
+        { "GUILD_AVAILABLE" [ drop ] }
+        { "GUILD_UNAVAILABLE" [ drop ] }
+        { "GUILD_MEMBER_ADD" [ drop ] }
+        { "GUILD_MEMBER_REMOVE" [ drop ] }
+        { "GUILD_MEMBER_UPDATE" [ drop ] }
+        { "GUILD_BAN_ADD" [ drop ] }
+        { "GUILD_BAN_REMOVE" [ drop ] }
+        { "GUILD_ROLE_CREATE" [ drop ] }
+        { "GUILD_ROLE_UPDATE" [ drop ] }
+        { "GUILD_ROLE_DELETE" [ drop ] }
+
+        { "INVITE_CREATE" [ drop ] }
+        { "INVITE_DELETE" [ drop ] }
+
+        { "READY" [
+            discord-bot get swap
+            {
+                [ "user" of >>user ]
+                [ "session_id" of >>session_id ]
+                [ "application" of >>application ]
+                [ "guilds" of >>guilds ]
+                [ "resume_gateway_url" of >>resume_gateway_url ]
+            } cleave drop
+        ] }
+
+        { "MESSAGE_CREATE" [ drop ] }
+        { "MESSAGE_UPDATE" [ drop ] }
+        { "MESSAGE_EDIT" [ drop ] }
+        { "MESSAGE_DELETE" [ drop ] }
+
+        { "MESSAGE_REACTION_ADD" [ drop ] }
+        { "MESSAGE_REACTION_REMOVE" [ drop ] }
+
+        { "MEMBER_BAN" [ drop ] }
+        { "MEMBER_UNBAN" [ drop ] }
+        { "MEMBER_JOIN" [ drop ] }
+        { "MEMBER_REMOVE" [ drop ] }
+        { "MEMBER_UPDATE" [ drop ] }
+
+        { "PRESENCE_UPDATE" [ drop ] }
+
+        { "RAW_MESSAGE_EDIT" [ drop ] }
+        { "RAW_MESSAGE_DELETE" [ drop ] }
+
+        { "REACTION_ADD" [ drop ] }
+        { "REACTION_REMOVE" [ drop ] }
+        { "REACTION_CLEAR" [ drop ] }
+
+        { "SCHEDULED_EVENT_CREATE" [ drop ] }
+        { "SCHEDULED_EVENT_REMOVE" [ drop ] }
+        { "SCHEDULED_EVENT_UPDATE" [ drop ] }
+        { "SCHEDULED_EVENT_USER_ADD" [ drop ] }
+        { "SCHEDULED_EVENT_USER_REMOVE" [ drop ] }
+
+        { "SHARD_CONNECT" [ drop ] }
+        { "SHARD_DISCONNECT" [ drop ] }
+        { "SHARD_READY" [ drop ] }
+        { "SHARD_RESUMED" [ drop ] }
+
+        { "THREAD_CREATE" [ drop ] }
+        { "THREAD_JOIN" [ drop ] }
+        { "THREAD_UPDATE" [ drop ] }
+        { "THREAD_DELETE" [ drop ] }
+
+        { "THREAD_MEMBER_JOIN" [ drop ] }
+        { "THREAD_MEMBER_REMOVE" [ drop ] }
+
+        { "TYPING_START" [ drop ] }
+
+        { "USER_UPDATE" [ drop ] }
+        { "VOICE_STATE_UPDATE" [ drop ] }
+        { "VOICE_SERVER_UPDATE" [ drop ] }
+        { "WEBHOOKS_UPDATE" [ drop ] }        
+        [ 2drop ]
+    } case ;
+
+: handle-discord-RESUME ( json -- ) drop ;
+
+: handle-discord-RECONNECT ( json -- ) drop ;
+
+: handle-discord-HELLO ( json -- )
+    "d" of "heartbeat_interval" of start-heartbeat-thread
+    gateway-identify-json send-masked-message ;
+
+: handle-discord-HEARTBEAT_ACK ( json -- ) drop ;
+
+: parse-discord-op ( json -- )
+    [ clone now "timestamp" pick set-at discord-bot get messages>> push ] keep
+    [ ] [ "s" of discord-bot get sequence-number<< ] [ "op" of ] tri {
+        { 0 [ handle-discord-DISPATCH ] }
+        { 6 [ handle-discord-RESUME ] }
+        { 7 [ handle-discord-RECONNECT ] }
+        { 10 [ handle-discord-HELLO ] }
+        { 11 [ handle-discord-HEARTBEAT_ACK ] }
+        [ 2drop ]
+    } case ;
+
+: handle-discord-websocket ( obj opcode -- loop? )
+    {
+        { f [ [ "closed with error, code %d" sprintf . flush ] with-global f ] }
+        { 1 [
+            [ [ hexdump. flush ] with-global ]
+            [ utf8 decode json> parse-discord-op ] bi
+            t
+        ] }
+        { 2 [ [ [ hexdump. flush ] with-global ] when* t ] }
+        { 8 [ [ drop "close received" print flush ] with-global t ] }
+        { 9 [ [ [ "ping received" print flush ] with-global send-heartbeat ] when* t ] }
+        [ 2drop t ]
+    } case ;
+
+: get-discord-user ( user -- json ) "/users/%s" sprintf discord-get ;
+: get-discord-users-me ( -- json ) "/users/@me" discord-get ;
+: get-discord-users-guilds ( -- json ) "/users/@me/guilds" discord-get ;
+: get-discord-users-guild-member ( guild-id -- json ) "/users/@me/guilds/%s/member" sprintf discord-get ;
+: get-discord-user-connections ( -- json ) "/users/@me/connections" discord-get ;
+: get-discord-user-application-role-connection ( application-id -- json )
+    "/users/@me/applications/%s/role-connection" sprintf discord-get ;
+: get-discord-channel ( channel-id -- json ) "/channels/%s" sprintf discord-get ;
+: get-discord-channel-pins ( channel-id -- json ) "/channels/%s/pins" sprintf discord-get ;
+: get-discord-channel-messages ( channel-id -- json ) "/channels/%s/messages" sprintf discord-get ;
+: get-discord-channel-message ( channel-id message-id -- json ) "/channels/%s/messages/%s" sprintf discord-get ;
+: send-discord-message ( hashtable channel-id -- json ) "/channels/%s/messages" sprintf discord-post-json ;
+
+: get-channel-webhooks ( channel-id -- json ) "/channels/%s/webhooks" sprintf discord-get ;
+: get-guild-webhooks ( guild-id -- json ) "/guilds/%s/webhooks" sprintf discord-get ;
+: get-webhook ( webhook-id -- json ) "/webhooks/%s" sprintf discord-get ;
+
+: get-guilds-me ( -- json ) "/users/@me/guilds" discord-get ;
+: get-guild-active-threads ( channel-id -- json ) "/guilds/%s/threads/active" sprintf discord-get ;
+: get-application-info ( -- json ) "/oauth2/applications/@me" discord-get ;
+
+: get-discord-gateway ( -- json ) "/gateway" discord-get ;
+: get-discord-bot-gateway ( -- json ) "/gateway/bot" discord-get ;
+
+: discord-connect ( config -- discord-bot )
+    \ discord-bot-config [
+        discord-bot-gateway <get-request>
+        add-websocket-upgrade-headers
+        add-discord-auth-header
+        [ drop ] do-http-request
+        [ in>> stream>> ] [ out>> stream>> ] bi
+        \ discord-bot-config get <discord-bot>
+        dup '[
+            _ \ discord-bot [
+                discord-bot get [ in>> ] [ out>> ] bi
+                [
+                    [ handle-discord-websocket ] read-websocket-loop
+                ] with-streams
+            ] with-variable
+        ] "Discord Bot" spawn
+        >>bot-thread
+    ] with-variable ;