205 lines
7.9 KiB
Racket
205 lines
7.9 KiB
Racket
#lang racket
|
|
|
|
(require racket/async-channel)
|
|
(require struct-update)
|
|
(require json)
|
|
(require net/url)
|
|
(require net/uri-codec)
|
|
(require net/rfc6455)
|
|
(require web-server/http/request-structs)
|
|
(require uuid)
|
|
(require html-parsing)
|
|
|
|
;; Bot configuration
|
|
(struct app-config [instance token char-limit] #:transparent)
|
|
(define-struct-updaters app-config)
|
|
|
|
;; This is dumb but I'm not good enough with the Racket
|
|
(define (config-from-file filename)
|
|
(define (parse-config config)
|
|
(match config
|
|
[(cons #:instance (cons x r))
|
|
(app-config-instance-set (parse-config r) x)]
|
|
[(cons #:token (cons x r))
|
|
(app-config-token-set (parse-config r) x)]
|
|
[(cons #:char-limit (cons x r))
|
|
(app-config-char-limit-set (parse-config r) x)]
|
|
[_ (app-config '() '() 500)]))
|
|
(parse-config (with-input-from-file filename read)))
|
|
|
|
;; Represents the current masto config
|
|
(define current-config (make-parameter '()))
|
|
|
|
;; Utils
|
|
(define (make-api-url path #:ws? [ws? #f] #:query [query '()])
|
|
(url (if ws? "wss" "https")
|
|
#f (app-config-instance (current-config))
|
|
#f #t
|
|
`(,(path/param "api" '())
|
|
,(path/param "v1" '())
|
|
,@(map (lambda (x) (path/param x '())) path))
|
|
query #f))
|
|
|
|
(define (make-header k v)
|
|
(string-append k ": " v))
|
|
|
|
(define (get-authorization)
|
|
(string-append "Bearer " (app-config-token (current-config))))
|
|
|
|
;; Creates a toot
|
|
(define (make-toot status #:cw [cw #f] #:reply-to [reply-to #f]
|
|
#:sensitive [sensitive #f] #:language [lang #f]
|
|
#:visibility [visibility #f])
|
|
(for/fold ([out '()])
|
|
([name '(status in_reply_to_id sensitive spoiler_text language visibility)]
|
|
[value (list status reply-to sensitive cw lang visibility)])
|
|
(cond [(false? value) out]
|
|
[else (cons (cons name value) out)])))
|
|
|
|
;; Sends a toot
|
|
(define (send-toot toot)
|
|
(define idem-key (uuid-string))
|
|
(define-values (status headers rsp)
|
|
(http-sendrecv/url (make-api-url '("statuses"))
|
|
#:method "POST"
|
|
#:headers (list
|
|
(make-header "Idempotency-Key" idem-key)
|
|
(make-header "Authorization" (get-authorization))
|
|
(make-header "Content-Type" "application/x-www-form-urlencoded"))
|
|
#:data (alist->form-urlencoded toot)))
|
|
(string->jsexpr (port->string rsp)))
|
|
|
|
(define (verify-creds)
|
|
(define-values (status headers rsp)
|
|
(http-sendrecv/url (make-api-url '("accounts" "verify_credentials"))
|
|
#:headers (list (make-header "Authorization" (get-authorization)))))
|
|
(string->jsexpr (port->string rsp)))
|
|
|
|
(define (fetch-toot toot-id)
|
|
(define-values (status headers rsp)
|
|
(http-sendrecv/url (make-api-url (list "statuses" toot-id))
|
|
#:headers (list (make-header "Authorization" (get-authorization)))))
|
|
(string->jsexpr (port->string rsp)))
|
|
|
|
;; Stream incoming toots
|
|
(define (stream-user chan)
|
|
(define query (list (cons 'access_token (app-config-token (current-config)))
|
|
(cons 'stream "user")))
|
|
(define ws (ws-connect (make-api-url '("streaming") #:ws? #t #:query query)))
|
|
(define (stream-forever chan)
|
|
(let ([msg (ws-recv ws)])
|
|
(cond [(equal? msg eof) (void)]
|
|
[else
|
|
(define msg-data (string->jsexpr msg))
|
|
(async-channel-put chan (list (hash-ref msg-data 'event)
|
|
(hash-ref msg-data 'payload)))
|
|
(stream-forever chan)])))
|
|
(stream-forever chan)
|
|
(ws-close! ws))
|
|
|
|
(define (bot-run)
|
|
(define my-username (hash-ref (verify-creds) 'acct))
|
|
(define (handle-mention status)
|
|
(define status-error (hash-ref status 'error #f))
|
|
(cond
|
|
[(string? status-error) (void)]
|
|
[else
|
|
(define status-reply-to-id (hash-ref status 'in_reply_to_id))
|
|
(define status-account (hash-ref status 'account))
|
|
(define status-username (hash-ref status-account 'acct))
|
|
(cond
|
|
[(string? status-reply-to-id)
|
|
(define parent (fetch-toot status-reply-to-id))
|
|
(define parent-error (hash-ref parent 'error #f))
|
|
(if (string? parent-error)
|
|
(void)
|
|
(do-uwu-reply parent (string-append "@" status-username)))]
|
|
[else (do-uwu-reply status #f)])]))
|
|
(define (do-uwu-reply status orig-username)
|
|
;(displayln status)
|
|
(define status-text (hash-ref status 'content))
|
|
(define status-id (hash-ref status 'id))
|
|
(define status-sensitive (hash-ref status 'sensitive))
|
|
(define status-cw (hash-ref status 'spoiler_text))
|
|
(define status-lang (hash-ref status 'language))
|
|
(define status-vis (hash-ref status 'visibility))
|
|
(define status-account (hash-ref status 'account))
|
|
(define status-username (string-append "@" (hash-ref status-account 'acct)))
|
|
(define status-bio (hash-ref status-account 'note))
|
|
(define cleaned-text
|
|
(maybe-prepend status-username
|
|
(maybe-prepend orig-username
|
|
(string-trim
|
|
(string-replace (xexp-strip-tags (html->xexp status-text))
|
|
(string-append "@" my-username) "")))))
|
|
;; priv => unlisted basically
|
|
(define mapped-vis (match status-vis
|
|
["public" "public"]
|
|
["direct" "direct"]
|
|
[_ "unlisted"]))
|
|
(cond [(string-contains? status-bio "nobot") (void)]
|
|
[else
|
|
(define new-content (string-chunk (uwu cleaned-text)
|
|
(app-config-char-limit (current-config))))
|
|
(for/fold ([reply-to status-id])
|
|
([toot new-content])
|
|
(hash-ref (send-toot (make-toot toot #:cw status-cw
|
|
#:reply-to reply-to
|
|
#:sensitive status-sensitive
|
|
#:language status-lang
|
|
#:visibility mapped-vis))
|
|
'id))]))
|
|
|
|
(define (handle-notification payload)
|
|
(match (hash-ref payload 'type)
|
|
["mention" (handle-mention (hash-ref payload 'status))]
|
|
[_ (void)]))
|
|
(define (do-stream chan)
|
|
(define next (async-channel-get chan))
|
|
(match next
|
|
[(list "notification" payload)
|
|
(handle-notification (string->jsexpr payload))]
|
|
[_ (void)])
|
|
(do-stream chan))
|
|
(define chan (make-async-channel))
|
|
(define recv-thread (thread (lambda () (stream-user chan))))
|
|
(do-stream chan))
|
|
|
|
(define (uwu text)
|
|
(define replacements (list (list #px"[rl]" "w")
|
|
(list #px"[RL]" "W")
|
|
(list #px"([nNmM])o" "\\1yo")
|
|
(list #px"([nNmM])O" "\\1YO")))
|
|
(define (uwu-word text)
|
|
(if (and (not (zero? (string-length text))) (char=? #\@ (string-ref text 0)))
|
|
text
|
|
(for/fold ([t text])
|
|
([r replacements])
|
|
(regexp-replace* (first r) t (second r)))))
|
|
(string-append (string-join (map uwu-word (string-split text))) " uwu"))
|
|
|
|
(define (string-chunk str n)
|
|
(cond [(<= (string-length str) n) (list str)]
|
|
[else (cons (substring str 0 n) (string-chunk (substring str n) n))]))
|
|
|
|
(define (xexp-strip-tags x)
|
|
(define (xexp-content x)
|
|
(cond
|
|
[(string? x) x]
|
|
[(and (list? x) (equal? (first x) '@)) ""]
|
|
[(and (list? x) (not (equal? (first x) '@))) (map xexp-content (rest x))]
|
|
[else ""]))
|
|
(string-join (flatten (xexp-content x)) ""))
|
|
|
|
(define (maybe-prepend pre text)
|
|
(cond
|
|
[(not (string? pre)) text]
|
|
[(string-contains? text pre) text]
|
|
[(string-append pre " " text)]))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(define bot-config (config-from-file ".config.uwubot"))
|
|
(parameterize ([current-config bot-config])
|
|
(displayln "Running uwubot")
|
|
(bot-run)) |