first push

This commit is contained in:
a. fox 2023-03-21 00:51:54 -04:00
commit 52133d25e2
16 changed files with 575 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
*.fasl
*.dx32fsl
*.dx64fsl
*.lx32fsl
*.lx64fsl
*.x86f
*~
.#*
bin/

74
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,74 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at dev@computerfox.xyz. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

23
Makefile Normal file
View File

@ -0,0 +1,23 @@
LISPS = ros sbcl clisp cmucl ccl
CMDS = --eval "(ql:quickload :ida-bot)" --eval "(asdf:make :ida-bot)" --eval "(quit)"
ifeq ($(OS),Windows_NT)
LISP := $(foreach lisp,$(LISPS), \
$(shell where $(lisp)) \
$(if $(.SHELLSTATUS),$(strip $(lisp)),))
else
LISP := $(foreach lisp,$(LISPS), \
$(if $(findstring $(lisp),"$(shell which $(lisp) 2>/dev/null)"), $(strip $(lisp)),))
endif
ifeq ($(LISP),)
$(error "No lisps found")
endif
all:
$(LISP) $(CMDS)
cp -r commands bin/
clean:
rm -rf bin/

31
README.markdown Normal file
View File

@ -0,0 +1,31 @@
# owncast-bot
a quick and dirty chat bot for owncast
## Usage
`$ ./ida-bot --help` to print command usage
`$ ./ida-bot -c your.config -p 8080` to run the bot with the specified config and on port 8080
the bot will load all lisp code in the `./commands` folder if it exists. while this is pretty unsafe it allows you (developers) to quickly create commands using the full power of lisp!
the bot comes with a few pre-built commands (check commands folder). feel free to add your own, and contribute some back to the repo if you feel like it :)
right now only chat-initiated commands are supported, but thats planning on changing soon.
## Installation
download a binary release from releases
## Author
* a. fox
## License
BSD 3-Clause

14
commands/count.lisp Normal file
View File

@ -0,0 +1,14 @@
(in-package :ida-bot.extension)
(unless (uiop:file-exists-p "stab.count")
(str:to-file "stab.count" "0"))
(defvar *stab-count* (parse-integer (str:from-file "stab.count")))
(define-command ("stab")
(incf *stab-count*)
(str:to-file "stab.count" (format nil "~A" *stab-count*))
(send-chat (format nil "Ceasar has been stabbed ~A times" *stab-count*)))

4
commands/motd.lisp Normal file
View File

@ -0,0 +1,4 @@
(in-package :ida-bot.extension)
(define-command ("motd")
(send-chat (env :motd)))

5
config.example Normal file
View File

@ -0,0 +1,5 @@
access-token = a cool token
stream-url = https://my.stream.live
motd = Thanks for joining the stream, hope you're having a good time! I try to stream every wednesday, so if youre interested feel free to join!

11
ida-bot-test.asd Normal file
View File

@ -0,0 +1,11 @@
(defsystem "ida-bot-test"
:defsystem-depends-on ("prove-asdf")
:author "a. fox"
:license ""
:depends-on ("ida-bot"
"prove")
:components ((:module "tests"
:components
((:test-file "ida-bot"))))
:description "Test system for ida-bot"
:perform (test-op (op c) (symbol-call :prove-asdf :run-test-system c)))

40
ida-bot.asd Normal file
View File

@ -0,0 +1,40 @@
(defsystem "ida-bot"
:version "0.1.0"
:author "a. fox"
:license ""
:depends-on ("bordeaux-threads"
"clack"
"lack"
"caveman2"
"envy"
"cl-ppcre"
"uiop"
"str"
#+(and Unix SBCL) "woo"
"com.inuoe.jzon"
"alexandria"
;; for @route annotation
"cl-syntax-annot"
"with-user-abort"
"simple-config"
"unix-opts"
"drakma")
:components ((:module "src"
:components
((:file "main" :depends-on ("web" "config" "commands"))
(:file "web" :depends-on ("util" "commands"))
(:file "commands" :depends-on ("util" "owncast"))
(:file "owncast" :depends-on ("util" "config"))
(:file "util")
(:file "config"))))
:description ""
:in-order-to ((test-op (test-op "ida-bot-test")))
:build-operation "program-op"
:build-pathname "bin/ida-bot"
:entry-point "ida-bot::make-start")
#+sb-core-compression
(defmethod asdf:perform ((o asdf:image-op) (c asdf:system))
(uiop:dump-image (asdf:output-file o c) :executable t :compression t))

69
src/commands.lisp Normal file
View File

@ -0,0 +1,69 @@
(defpackage ida-bot.commands
(:use :cl :ida-bot.util)
(:export :define-command
:process-commands))
(in-package :ida-bot.commands)
;; here is where i need to write the command loader
;; this should ideally load a directory with all
;;
(defvar *commands* nil
"list of commands")
(defclass bot-command ()
((priority :accessor command-priority
:initarg :priority)
(type :reader command-type
:initarg :type)
(command :reader command-string
:initarg :command)
(function :accessor command-function
:initarg :function)))
(defmacro define-command ((command &key priority (type :chat)) &body body)
"create a command COMMAND"
(let ((pri (or priority (1+ (length *commands*))))
(cmd (str:concat "!" command)))
;; ensure each command has unique commands
(unless (some #'(lambda (x)
(string= command (command-string x)))
*commands*)
`(prog1
(push (make-instance 'bot-command :command ,cmd
:priority ,pri
:type ,type
:function
(lambda (it)
(let ((type (agetf it "type"))
(data (agetf it "eventData")))
(when (and (check-type-symbol ,type type)
(str:starts-with-p ,cmd (agetf data "body")))
,@body))))
*commands*)
(setf *commands* (sort *commands* #'< :key #'command-priority))))))
(defun process-commands (message)
"process each command based on priority"
(loop :for cmd :in *commands*
:do (funcall (command-function cmd) message)))
(in-package :cl-user)
(defpackage ida-bot.extension
(:use :cl :ida-bot.util :ida-bot.commands
:ida-bot.actions :ida-bot.config)
(:export :load-commands))
(in-package :ida-bot.extension)
(defun load-commands ()
"loads all commands from subdirectory"
(unless (uiop:directory-exists-p "./commands/")
(format t "The command directory doesn't exist. Please create it and fill it with lisp commands"))
(loop :for file :in (uiop:directory-files "./commands/" "*.lisp")
:do (format t "Loading file ~A~%" file)
(load file)))

46
src/config.lisp Normal file
View File

@ -0,0 +1,46 @@
(in-package :cl-user)
(defpackage ida-bot.config
(:use :cl)
(:import-from :envy
:config-env-var
:defconfig)
(:export :env
:*application-root*
:*static-directory*
:appenv
:developmentp
:productionp))
(in-package :ida-bot.config)
(setf (config-env-var) "APP_ENV")
(defparameter *application-root* (asdf:system-source-directory :ida-bot))
(defparameter *static-directory* (merge-pathnames #P"static/" *application-root*))
(defconfig :common
`(:databases ((:maindb :sqlite3 :database-name ":memory:"))))
(defconfig |development|
'())
(defconfig |production|
'())
(defconfig |test|
'())
(defun config (&optional key)
(envy:config #.(package-name *package*) key))
(defun appenv ()
(uiop:getenv (config-env-var #.(package-name *package*))))
(defun developmentp ()
(string= (appenv) "development"))
(defun productionp ()
(string= (appenv) "production"))
(defun env (key &optional default)
(or (config key)
(conf:config key default)))

129
src/main.lisp Normal file
View File

@ -0,0 +1,129 @@
(defpackage ida-bot.app
(:use :cl)
(:import-from :lack.builder
:builder)
(:import-from :ppcre
:scan
:regex-replace)
(:import-from :ida-bot.web
:*web*)
(:import-from :ida-bot.config
:env
:productionp
:*static-directory*)
(:export :get-builder))
(in-package :ida-bot.app)
(defun get-builder ()
(builder
nil
(if (productionp)
nil
:accesslog)
(if (env :error-log)
`(:backtrace
:output ,(env :error-log))
nil)
:session
(if (productionp)
nil
nil)
*web*))
;;
;; main app package
;;
(in-package :cl-user)
(defpackage ida-bot
(:use :cl :with-user-abort)
(:import-from :ida-bot.config
:env)
(:import-from :clack
:clackup)
(:import-from :unix-opts
:define-opts
:get-opts)
(:export :start
:stop))
(in-package :ida-bot)
(define-opts
(:name :help
:description "prints this help text"
:short #\h
:long "help")
(:name :port
:description "sets port"
:short #\p
:long "port"
:arg-parser #'parse-integer
:meta-var "PORT")
(:name :config
:description "specify the config to use"
:short #\c
:long "config"
:arg-parser #'string
:meta-var "FILE")
(:name :prod
:description "enabled production mode"
:long "production")
(:name :version
:description "prints application version"
:long "version"))
(defvar *handler* nil)
(defun start (&rest args &key server port debug &allow-other-keys)
(declare (ignore server port debug))
(when *handler*
(restart-case (error "Server is already running.")
(restart-server ()
:report "Restart the server"
(stop))))
(setf *handler*
(apply #'clackup (ida-bot.app:get-builder) args)))
(defun stop ()
(prog1
(clack:stop *handler*)
(setf *handler* nil)))
(defun make-start ()
"our binary entry point"
(multiple-value-bind (opts args) (get-opts)
(when (getf opts :version)
(format t "ida-bot v~A~&" #.(asdf:component-version (asdf:find-system :ida-bot)))
(uiop:quit 0))
(when (getf opts :help)
(unix-opts:describe
:usage-of "idabot")
(uiop:quit 0))
(when (getf opts :prod)
(setf (uiop:getenv "APP_ENV") "production"))
(if (getf opts :config)
(conf:load-config (getf opts :config) :parse-lists nil)
(progn
(format t "please specify a config file to use")
(uiop:quit 1)))
(ida-bot.extension:load-commands)
(let ((server #+(and Unix SBCL) :woo
#-(and Unix SBCL) :hunchentoot))
(start :port (env :port (or (getf opts :port) 9000)) :server server)
(handler-case
(with-user-abort
(bt:join-thread (find-if (lambda (th)
(search (string-downcase (string server)) (bt:thread-name th)))
(bt:all-threads))))
(user-abort ())
(error (c) (format t "Woops, an unknown error occured:~&~a~&" c)))
(format t "~&Quitting bot.")
(stop))))

42
src/owncast.lisp Normal file
View File

@ -0,0 +1,42 @@
(defpackage :ida-bot.actions
(:use :cl :ida-bot.util)
(:import-from :ida-bot.config :env)
(:import-from :drakma :http-request)
(:import-from :com.inuoe.jzon :stringify)
(:import-from :alexandria :alist-hash-table)
(:export
:send-chat
:moderate-chat
:moderate-user))
(in-package :ida-bot.actions)
(defun send-chat (message)
"sends a chat message to the configured server"
(owncast-request "/api/integrations/chat/send"
`(("body" . ,message))))
(defun moderate-chat (message-id set-visible)
"moderates a chat message with id MESSAGE-ID, setting it visible based on SET-VISIBLE"
(owncast-request "/api/chat/messagevisibility"
`(("visible" . ,set-visible)
("idArray" . (,message-id)))))
(defun moderate-user (user-id set-enabled)
"moderates a user with USER-ID, enabling them based on SET-ENABLED"
(owncast-request "/api/chat/users/setenabled"
`(("userId" . ,user-id)
("enabled" . ,set-enabled))))
(defun owncast-request (api-path payload)
"runs the owncast request"
(http-request (str:concat (env :stream-url) api-path)
:method :post
:content-type "application/json"
:accept "application/json"
:content (stringify (alist-hash-table payload))
:additional-headers `(("Authorization" . ,(str:concat "Bearer " (env :access-token))))))

30
src/util.lisp Normal file
View File

@ -0,0 +1,30 @@
(in-package :cl-user)
(defpackage ida-bot.util
(:use :cl)
(:import-from :bt
:make-thread)
(:export
:agetf
:check-type-symbol
:->))
(in-package ida-bot.util)
(declaim (inline agetf))
(defun agetf (place indicator &optional default)
(or (cdr (assoc indicator place :test #'equal))
default))
(defun check-type-symbol (tsym tstr)
(or (and (eq tsym :chat) (string= tstr "CHAT"))
(and (eq tsym :name-changed) (string= tstr "NAME_CHANGED"))
(and (eq tsym :user-joined) (string= tstr "USER_JOINED"))
(and (eq tsym :stream-started) (string= tstr "STREAM_STARTED"))
(and (eq tsym :stream-stopped) (string= tstr "STREAM_STOPPED"))
(and (eq tsym :visibility-update) (string= tstr "VISIBILITY-UPDATE"))))
(defmacro -> ((&key name) &body body)
(let ((n (or name (string (gensym)))))
`(bt:make-thread #'(lambda () ,@body) :name ,n)))

36
src/web.lisp Normal file
View File

@ -0,0 +1,36 @@
(in-package :cl-user)
(defpackage ida-bot.web
(:use :cl
:caveman2)
(:import-from :ida-bot.util
:agetf)
(:import-from :ida-bot.commands
:process-commands)
(:export :*web*))
(in-package :ida-bot.web)
;; for @route annotation
(syntax:use-syntax :annot)
;;
;; Application
(defclass <web> (<app>) ())
(defvar *web* (make-instance '<web>))
(clear-routing-rules *web*)
;;
;; Routing rules
@route POST "/ingest"
(defun parse-webhooks (&key _parsed)
(process-commands _parsed))
;;
;; Error pages
(defmethod on-exception ((app <web>) (code (eql 404)))
(declare (ignore app))
(merge-pathnames #P"_errors/404.html"
*template-directory*))

12
tests/ida-bot.lisp Normal file
View File

@ -0,0 +1,12 @@
(in-package :cl-user)
(defpackage ida-bot-test
(:use :cl
:ida-bot
:prove))
(in-package :ida-bot-test)
(plan nil)
;; blah blah blah.
(finalize)