├── .editorconfig ├── .gitignore ├── .gitmodules ├── .luacheckrc ├── Dockerfile ├── LICENSE ├── README.md ├── config.fnl ├── extern ├── fennel ├── fennel.lua └── fennelview.fnl ├── fennel_preamble.lua ├── install-dependencies.sh ├── launch.sh ├── main.lua ├── otouto ├── autils.lua ├── bot.lua ├── plugins │ ├── admin │ │ ├── add_admin.lua │ │ ├── add_group.lua │ │ ├── add_mod.lua │ │ ├── antibot.lua │ │ ├── antihammer_whitelist.lua │ │ ├── antilink.lua │ │ ├── antisquig.lua │ │ ├── antisquigpp.lua │ │ ├── antisticker.lua │ │ ├── autopromoter.lua │ │ ├── ban.lua │ │ ├── ban_remover.lua │ │ ├── deadmin.lua │ │ ├── delete_join_messages.lua │ │ ├── delete_left_messages.lua │ │ ├── demod.lua │ │ ├── files_only.lua │ │ ├── filter.lua │ │ ├── filterer.lua │ │ ├── fix_perms.lua │ │ ├── flags.lua │ │ ├── get_description.lua │ │ ├── get_link.lua │ │ ├── hammer.lua │ │ ├── interactive_flags.lua │ │ ├── kick.lua │ │ ├── kickme.lua │ │ ├── list_admins.lua │ │ ├── list_flags.fnl │ │ ├── list_groups.lua │ │ ├── list_mods.lua │ │ ├── list_rules.lua │ │ ├── mute.lua │ │ ├── regen_link.lua │ │ ├── remove_group.lua │ │ ├── set_description.lua │ │ ├── set_governor.lua │ │ ├── set_rules.lua │ │ ├── temp_ban.lua │ │ ├── unhammer.lua │ │ └── unrestrict.lua │ ├── core │ │ ├── about.fnl │ │ ├── control.lua │ │ ├── delete_messages.fnl │ │ ├── disable_plugins.fnl │ │ ├── end_forwards.fnl │ │ ├── group_info.fnl │ │ ├── group_whitelist.fnl │ │ ├── help.lua │ │ ├── luarun.fnl │ │ ├── paged_lists.lua │ │ ├── ping.fnl │ │ ├── user_blacklist.fnl │ │ ├── user_info.fnl │ │ └── user_lists.fnl │ └── user │ │ ├── apod.fnl │ │ ├── bible.fnl │ │ ├── calc.fnl │ │ ├── cat_fact.fnl │ │ ├── cats.fnl │ │ ├── commit.fnl │ │ ├── currency.lua │ │ ├── data │ │ ├── eight_ball.fnl │ │ └── slap.fnl │ │ ├── dice.fnl │ │ ├── dilbert.fnl │ │ ├── dump.lua │ │ ├── echo.fnl │ │ ├── eight_ball.fnl │ │ ├── full_width.lua │ │ ├── google_translate.lua │ │ ├── greetings.fnl │ │ ├── hex_color.lua │ │ ├── lastfm.lua │ │ ├── maybe.fnl │ │ ├── nickname.fnl │ │ ├── reactions.fnl │ │ ├── regex.fnl │ │ ├── reminders.fnl │ │ ├── shout.lua │ │ ├── slap.fnl │ │ ├── urban_dictionary.lua │ │ ├── user_lookup.fnl │ │ ├── whoami.fnl │ │ ├── wikipedia.fnl │ │ └── xkcd.fnl ├── rot13.fnl └── utilities.lua └── shell_preamble.fnl /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.lua] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 4 11 | 12 | [*.fnl] 13 | charset = utf-8 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # https://github.com/topkecleon/otouto-plugins-topkecleon 2 | otouto/plugins/otouto-plugins-topkecleon 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "extern/anise"] 2 | path = extern/anise 3 | url = https://github.com/bb010g/anise.git 4 | [submodule "otouto/bindings"] 5 | path = extern/bindings 6 | url = https://github.com/topkecleon/bindings 7 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | exclude_files = { "lume.lua" } 2 | ignore = { "_.*" } 3 | self = false 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Set OTOUTO_BOT_API_KEY to your telegram bot api key 2 | # Set OTOUTO_ADMIN_ID to your telegram id 3 | # Example: docker run -e OTOUTO_BOT_API_KEY="apikeyhere" -e OTOUTO_ADMIN_ID="idhere" jacobamason/otouto 4 | FROM alpine:3.7 5 | 6 | RUN apk --no-cache add --virtual build-deps \ 7 | curl gcc libc-dev pcre-dev libressl-dev && \ 8 | apk --no-cache add lua5.3 lua5.3-dev luarocks5.3 && \ 9 | luarocks-5.3 install dkjson && \ 10 | luarocks-5.3 install lpeg && \ 11 | luarocks-5.3 install lrexlib-pcre && \ 12 | luarocks-5.3 install luasec && \ 13 | luarocks-5.3 install luasocket && \ 14 | luarocks-5.3 install multipart-post && \ 15 | luarocks-5.3 install serpent && \ 16 | apk del build-deps 17 | 18 | COPY . /otouto 19 | WORKDIR /otouto 20 | CMD ./launch.sh 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | WIP, refer to the (outdated) readme in the main branch for now. -------------------------------------------------------------------------------- /config.fnl: -------------------------------------------------------------------------------- 1 | (require-macros :anise.macros) 2 | (require* anise) 3 | ; For details on configuration values, see README.md#configuration. 4 | { 5 | 6 | ; Your authorization token from the botfather. (string, put quotes) 7 | :bot_api_key (os.getenv "OTOUTO_BOT_API_KEY") 8 | ; Your Telegram ID (number). 9 | :admin (math.floor (os.getenv "OTOUTO_ADMIN_ID")) 10 | ; Two-letter language code. 11 | ; Fetches it from the system if available, or defaults to English. 12 | :lang (let [lang (os.getenv "LANG")] 13 | (and-or lang (: lang :sub 1 2) "en")) 14 | ; The channel, group, or user to send error reports to. 15 | ; If this is not set, errors will be printed to the console. 16 | :log_chat (or (os.getenv "OTOUTO_LOG_ID") nil) 17 | ; The symbol that starts a command. Usually noted as "/" in documentation. 18 | :cmd_pat "/" 19 | ; The filename of the database. If left nil, defaults to $username.json. 20 | :database_name (os.getenv "OTOUTO_JSON_FILE") 21 | ; The block of text returned by /start and /about.. 22 | :about_text 23 | "I am otouto, the plugin-wielding, multipurpose Telegram bot.\ 24 | \ 25 | Send /help to get started." 26 | 27 | ;; Third-party API keys 28 | ; The Cat API (thecatapi.com) (optional for cats) 29 | :cat_api_key nil 30 | ; Biblia (bibliaapi.com) (mandatory for bible) 31 | :biblia_api_key nil 32 | ; NASA APOD (api.nasa.gov) (optional for apod) 33 | :nasa_api_key "DEMO_KEY" 34 | ; Google (mandatory for google_translate) 35 | :google_api_key nil 36 | ; last.fm (mandatory for lastfm) 37 | :lastfm_api_key nil 38 | 39 | :paged_lists { 40 | :page_length 8 41 | :list_duration 900 42 | :private_lists false 43 | } 44 | 45 | ; some dumb stuff 46 | :user_lists { 47 | ; Lists are sorted alphabetically. Set to true to sort backward. 48 | :reverse_sort false 49 | } 50 | 51 | :user_info { 52 | ; If set to true, user info will only be collected in administrated groups. 53 | :admin_only false 54 | } 55 | 56 | ; Generic error messages. 57 | :errors { 58 | :generic "An unexpected error occurred." 59 | :connection "Connection error." 60 | :results "No results found." 61 | :argument "Invalid argument." 62 | :syntax "Invalid syntax." 63 | :specify_targets "Specify a target or targets by reply, username, or ID." 64 | :specify_target "Specify a target by reply, username, or ID." 65 | } 66 | 67 | :administration { 68 | ; Conversation, group, or channel for kick/ban notifications. 69 | ; Defaults to config.log_chat if left empty. 70 | :log_chat nil 71 | ; link or username 72 | :log_chat_username nil 73 | ; First strike warnings will be deleted after this, in seconds. 74 | :warning_expiration 30 75 | ; Default flag settings. 76 | :flags { 77 | :antibot true 78 | :antilink true 79 | } 80 | } 81 | 82 | :reactions { 83 | :shrug "¯\\_(ツ)_/¯" 84 | :lenny "( ͡° ͜ʖ ͡°)" 85 | :flip "(╯°□°)╯︵ ┻━┻" 86 | :look "ಠ_ಠ" 87 | :shots "SHOTS FIRED" 88 | :facepalm "(-‸ლ)" 89 | } 90 | 91 | :greetings { 92 | "Hello, #NAME." { 93 | "hello" 94 | "hey" 95 | "hi" 96 | "good morning" 97 | "good day" 98 | "good afternoon" 99 | "good evening" 100 | } 101 | "Goodbye, #NAME." { 102 | "good%-?bye" 103 | "bye" 104 | "later" 105 | "see ya" 106 | "good night" 107 | } 108 | "Welcome back, #NAME." { 109 | "i'm home" 110 | "i'm back" 111 | } 112 | "You're welcome, #NAME." { 113 | "thanks" 114 | "thank you" 115 | } 116 | } 117 | 118 | ; To enable a plugin, add its name to the list. 119 | :plugins (let 120 | [ 121 | core-critical [ 122 | :core.control 123 | :core.luarun 124 | :core.user_info 125 | :core.group_whitelist 126 | :core.group_info 127 | ] 128 | admin-critical [ 129 | :admin.flags 130 | :admin.ban_remover 131 | :admin.autopromoter 132 | ] 133 | admin-filters [ 134 | :admin.antibot 135 | :admin.antilink 136 | :admin.antisquigpp 137 | :admin.antisquig 138 | :admin.antisticker 139 | :admin.delete_left_messages 140 | :admin.delete_join_messages 141 | :admin.filterer 142 | :admin.files_only 143 | ] 144 | core [ 145 | :core.end_forwards 146 | :core.user_blacklist 147 | :core.about 148 | :core.delete_messages 149 | :core.disable_plugins 150 | :core.help 151 | :core.paged_lists 152 | :core.ping 153 | :core.user_lists 154 | ] 155 | admin [ 156 | :admin.add_admin 157 | :admin.add_group 158 | :admin.add_mod 159 | :admin.antihammer_whitelist 160 | :admin.ban 161 | :admin.deadmin 162 | :admin.demod 163 | :admin.filter 164 | :admin.fix_perms 165 | :admin.get_description 166 | :admin.get_link 167 | :admin.hammer 168 | :admin.interactive_flags 169 | :admin.kick 170 | :admin.kickme 171 | :admin.list_admins 172 | :admin.list_flags 173 | :admin.list_groups 174 | :admin.list_mods 175 | :admin.list_rules 176 | :admin.mute 177 | :admin.regen_link 178 | :admin.remove_group 179 | :admin.set_description 180 | :admin.set_governor 181 | :admin.set_rules 182 | :admin.temp_ban 183 | :admin.unhammer 184 | :admin.unrestrict 185 | ] 186 | user [ 187 | :user.apod 188 | ;:user.bible 189 | :user.calc 190 | :user.cat_fact 191 | :user.cats 192 | :user.commit 193 | :user.currency 194 | :user.dice 195 | :user.dilbert 196 | :user.echo 197 | :user.eight_ball 198 | :user.full_width 199 | ;:user.google_translate 200 | :user.greetings 201 | :user.hex_color 202 | ;:user.lastfm 203 | :user.maybe 204 | :user.nickname 205 | :user.regex 206 | :user.reminders 207 | :user.slap 208 | :user.shout 209 | :user.urban_dictionary 210 | :user.user_lookup 211 | :user.whoami 212 | :user.wikipedia 213 | :user.xkcd 214 | :user.reactions 215 | ] 216 | ] 217 | 218 | (anise.concat 219 | core-critical 220 | ;admin-critical 221 | ;admin-filters 222 | core 223 | ;admin 224 | user 225 | )) 226 | 227 | } 228 | -------------------------------------------------------------------------------- /extern/fennel: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lua 2 | 3 | local fennel_dir = arg[0]:match("(.-)[^\\/]+$") 4 | package.path = fennel_dir .. "?.lua;" .. package.path 5 | local fennel = require('fennel') 6 | 7 | local help = [[ 8 | Usage: fennel [FLAG] [FILE] 9 | 10 | --repl : Launch an interactive repl session 11 | --compile FILES : Compile files and write their Lua to stdout 12 | --help : Display this text 13 | 14 | When not given a flag, runs the file given as the first argument.]] 15 | 16 | local options = { 17 | sourcemap = true 18 | } 19 | 20 | local function dosafe(filename, opts, arg1) 21 | local ok, val = xpcall(function() 22 | return fennel.dofile(filename, opts, arg1) 23 | end, fennel.traceback) 24 | if not ok then 25 | print(val) 26 | os.exit(1) 27 | end 28 | return val 29 | end 30 | 31 | local compileOptHandlers = { 32 | ['--indent'] = function () 33 | options.indent = table.remove(arg, 3) 34 | if options.indent == "false" then options.indent = false end 35 | table.remove(arg, 2) 36 | end, 37 | ['--sourcemap'] = function () 38 | options.sourcemap = table.remove(arg, 3) 39 | if options.sourcemap == "false" then options.sourcemap = false end 40 | table.remove(arg, 2) 41 | end, 42 | } 43 | 44 | if arg[1] == "--repl" or #arg == 0 then 45 | local ppok, pp = pcall(fennel.dofile, fennel_dir .. "fennelview.fnl", options) 46 | if ppok then 47 | options.pp = pp 48 | end 49 | local initFilename = (os.getenv("HOME") or "") .. "/.fennelrc" 50 | local init = io.open(initFilename, "rb") 51 | if init then 52 | init:close() 53 | -- pass in options so fennerlrc can make changes to it 54 | dosafe(initFilename, options, options) 55 | end 56 | print("Welcome to fennel!") 57 | fennel.repl(options) 58 | elseif arg[1] == "--compile" then 59 | -- Handle options 60 | while compileOptHandlers[arg[2]] do 61 | compileOptHandlers[arg[2]]() 62 | end 63 | for i = 2, #arg do 64 | local f = assert(io.open(arg[i], "rb")) 65 | options.filename=arg[i] 66 | local ok, val = xpcall(function() 67 | return fennel.compileString(f:read("*all"), options) 68 | end, fennel.traceback) 69 | print(val) 70 | if not ok then os.exit(1) end 71 | f:close() 72 | end 73 | elseif #arg >= 1 and arg[1] ~= "--help" then 74 | local filename = table.remove(arg, 1) -- let the script have remaining args 75 | dosafe(filename) 76 | else 77 | print(help) 78 | end 79 | -------------------------------------------------------------------------------- /extern/fennelview.fnl: -------------------------------------------------------------------------------- 1 | ;; A pretty-printer that outputs tables in Fennel syntax. 2 | ;; Loosely based on inspect.lua: http://github.com/kikito/inspect.lua 3 | 4 | (local quote (fn [str] (.. '"' (: str :gsub '"' '\\"') '"'))) 5 | 6 | (local short-control-char-escapes 7 | {"\a" "\\a" "\b" "\\b" "\f" "\\f" "\n" "\\n" 8 | "\r" "\\r" "\t" "\\t" "\v" "\\v"}) 9 | 10 | (local long-control-char-esapes 11 | (let [long {}] 12 | (for [i 0 31] 13 | (let [ch (string.char i)] 14 | (when (not (. short-control-char-escapes ch)) 15 | (tset short-control-char-escapes ch (.. "\\" i)) 16 | (tset long ch (: "\\%03d" :format i))))) 17 | long)) 18 | 19 | (local escape (fn [str] 20 | (let [str (: str :gsub "\\" "\\\\") 21 | str (: str :gsub "(%c)%f[0-9]" long-control-char-esapes)] 22 | (: str :gsub "%c" short-control-char-escapes)))) 23 | 24 | (local sequence-key? (fn [k len] 25 | (and (= (type k) "number") 26 | (<= 1 k) 27 | (<= k len) 28 | (= (math.floor k) k)))) 29 | 30 | (local type-order {:number 1 :boolean 2 :string 3 :table 4 31 | :function 5 :userdata 6 :thread 7}) 32 | 33 | (local sort-keys (fn [a b] 34 | (let [ta (type a) tb (type b)] 35 | (if (and (= ta tb) (~= ta "boolean") 36 | (or (= ta "string") (= ta "number"))) 37 | (< a b) 38 | (let [dta (. type-order a) 39 | dtb (. type-order b)] 40 | (if (and dta dtb) 41 | (< dta dtb) 42 | dta true 43 | dtb false 44 | :else (< ta tb))))))) 45 | 46 | (local get-sequence-length 47 | (fn [t] 48 | (var len 1) 49 | (each [i (ipairs t)] (set len i)) 50 | len)) 51 | 52 | (local get-nonsequential-keys 53 | (fn [t] 54 | (let [keys {} 55 | sequence-length (get-sequence-length t)] 56 | (each [k (pairs t)] 57 | (when (not (sequence-key? k sequence-length)) 58 | (table.insert keys k))) 59 | (table.sort keys sort-keys) 60 | (values keys sequence-length)))) 61 | 62 | (local count-table-appearances 63 | (fn recur [t appearances] 64 | (if (= (type t) "table") 65 | (when (not (. appearances t)) 66 | (tset appearances t 1) 67 | (each [k v (pairs t)] 68 | (recur k appearances) 69 | (recur v appearances))) 70 | (when (and t (= t t)) ; no nans please 71 | (tset appearances t (+ (or (. appearances t) 0) 1)))) 72 | appearances)) 73 | 74 | 75 | 76 | (var put-value nil) ; mutual recursion going on; defined below 77 | 78 | (local puts (fn [self ...] 79 | (each [_ v (ipairs [...])] 80 | (table.insert self.buffer v)))) 81 | 82 | (local tabify (fn [self] (puts self "\n" (: self.indent :rep self.level)))) 83 | 84 | (local already-visited? (fn [self v] (~= (. self.ids v) nil))) 85 | 86 | (local get-id (fn [self v] 87 | (var id (. self.ids v)) 88 | (when (not id) 89 | (let [tv (type v)] 90 | (set id (+ (or (. self.max-ids tv) 0) 1)) 91 | (tset self.max-ids tv id) 92 | (tset self.ids v id))) 93 | (tostring id))) 94 | 95 | (local put-sequential-table (fn [self t length] 96 | (puts self "[") 97 | (set self.level (+ self.level 1)) 98 | (for [i 1 length] 99 | (puts self " ") 100 | (put-value self (. t i))) 101 | (set self.level (- self.level 1)) 102 | (puts self " ]"))) 103 | 104 | (local put-key (fn [self k] 105 | ;; TODO: :,bTrVyiC=z is not valid; comma cuts all off 106 | (if (and (= (type k) "string") 107 | (: k :find "^[%w?\\^_`!#$%&*+-./@~:|<=>]+$")) 108 | (puts self ":" k) 109 | (put-value self k)))) 110 | 111 | (local put-kv-table (fn [self t] 112 | (puts self "{") 113 | (set self.level (+ self.level 1)) 114 | (each [k v (pairs t)] 115 | (tabify self) 116 | (put-key self k) 117 | (puts self " ") 118 | (put-value self v)) 119 | (set self.level (- self.level 1)) 120 | (tabify self) 121 | (puts self "}"))) 122 | 123 | (local put-table (fn [self t] 124 | (if (already-visited? self t) 125 | (puts self "#") 126 | (>= self.level self.depth) 127 | (puts self "{...}") 128 | :else 129 | (let [(non-seq-keys length) (get-nonsequential-keys t) 130 | id (get-id self t)] 131 | (if (> (. self.appearances t) 1) 132 | (puts self "#<" id ">") 133 | (and (= (# non-seq-keys) 0) (= (# t) 0)) 134 | (puts self "{}") 135 | (= (# non-seq-keys) 0) 136 | (put-sequential-table self t length) 137 | :else 138 | (put-kv-table self t)))))) 139 | 140 | (set put-value (fn [self v] 141 | (let [tv (type v)] 142 | (if (= tv "string") 143 | (puts self (quote (escape v))) 144 | (or (= tv "number") (= tv "boolean") (= tv "nil")) 145 | (puts self (tostring v)) 146 | (= tv "table") 147 | (put-table self v) 148 | :else 149 | (puts self "#<" tv " " (get-id self v) ">"))))) 150 | 151 | 152 | 153 | (fn [root options] 154 | (let [options (or options {}) 155 | inspector {:appearances (count-table-appearances root {}) 156 | :depth (or options.depth 128) 157 | :level 0 :buffer {} :ids {} :max-ids {} 158 | :indent (or options.indent " ")}] 159 | (put-value inspector root) 160 | (table.concat inspector.buffer))) 161 | -------------------------------------------------------------------------------- /fennel_preamble.lua: -------------------------------------------------------------------------------- 1 | package.path = './extern/?.lua;./extern/?/init.lua;' .. package.path 2 | local fennel = require('fennel') 3 | fennel.path = './extern/?.fnl;./extern/?/init.fnl;' .. fennel.path 4 | table.insert(package.searchers, fennel.searcher) 5 | -------------------------------------------------------------------------------- /install-dependencies.sh: -------------------------------------------------------------------------------- 1 | # Install Lua, Luarocks, and otouto dependencies. Works in Ubuntu, maybe Debian. 2 | # Installs Lua 5.3 if Ubuntu 16.04. Otherwise, 5.2. 3 | 4 | #!/bin/sh 5 | 6 | rocklist="dkjson lpeg lrexlib-pcre luasec luasocket multipart-post serpent" 7 | if [ $(lsb_release -r | cut -f 2) == "16.04" ]; then 8 | luaver="5.3" 9 | else 10 | luaver="5.2" 11 | rocklist="$rocklist luautf8" 12 | fi 13 | 14 | echo "This script is intended for Ubuntu. It may work in Debian." 15 | echo "This script will request root privileges to install the following packages:" 16 | echo "lua$luaver liblua$luaver-dev fortune-mod fortunes git libc6 libpcre3-dev libssl-dev make unzip" 17 | echo "It will also request root privileges to install Luarocks to /usr/local/" 18 | echo "along with the following rocks:" 19 | echo $rocklist 20 | echo "Press enter to continue. Use Ctrl-C to exit." 21 | read 22 | 23 | sudo apt-get update 24 | sudo apt-get install -y lua$luaver liblua$luaver-dev fortune-mod fortunes git libc6 libpcre3-dev libssl-dev make unzip 25 | git clone http://github.com/keplerproject/luarocks 26 | cd luarocks 27 | ./configure --lua-version=$luaver --versioned-rocks-dir --lua-suffix=$luaver 28 | make build 29 | sudo make install 30 | for rock in $rocklist; do 31 | sudo luarocks-$luaver install $rock 32 | done 33 | sudo -k 34 | cd .. 35 | 36 | echo "Finished. Use ./launch to start otouto." 37 | echo "Be sure to set your bot token in config.lua." 38 | -------------------------------------------------------------------------------- /launch.sh: -------------------------------------------------------------------------------- 1 | # Run otouto in Lua 5.3, if available. 2 | # (Specifying lua5.3 because "lua" is not linked to it in Ubuntu 16.04.) 3 | # Otherwise, use any generic installed Lua. 4 | # If none, give an error and a friendly suggestion. 5 | # If Lua was found, restart otouto five seconds after halting each time. 6 | 7 | #!/bin/sh 8 | 9 | # Ubuntu 16.04 seems to not link "lua" to lua5.3. 10 | if type lua5.3 >/dev/null 2>/dev/null; then 11 | while true; do 12 | lua5.3 main.lua 13 | echo "otouto has stopped. ^C to exit." 14 | sleep 5s 15 | done 16 | elif type lua >/dev/null 2>/dev/null; then 17 | while true; do 18 | lua main.lua 19 | echo "otouto has stopped. ^C to exit." 20 | sleep 5s 21 | done 22 | else 23 | echo "Lua not found." 24 | echo "If you're on Ubuntu, try running ./install-dependencies.sh." 25 | fi 26 | -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | if package.path:find('%./%?%.lua') and not package.path:find('%./%?/init%.lua') then 2 | package.path = package.path .. ';./?/init.lua' 3 | end 4 | dofile('fennel_preamble.lua') 5 | -- Fennel loaded 6 | 7 | local bot = require('otouto.bot') 8 | 9 | local instance = setmetatable({ 10 | config = require('config') 11 | }, {__index = bot}) 12 | 13 | return instance:run() 14 | -------------------------------------------------------------------------------- /otouto/plugins/admin/add_admin.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | add_admin.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(bot) 13 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 14 | :t('admin', true):t('add_?admin', true).table 15 | self.privilege = 5 16 | self.command = 'admin' 17 | self.doc = 'Promotes a user or users to administrator(s).' 18 | self.targeting = true 19 | end 20 | 21 | function P:action(bot, msg, _group, _user) 22 | local targets, output = autils.targets(bot, msg, {unknown_ids_err = true}) 23 | for target, _ in pairs(targets) do 24 | local user = utilities.user(bot, target) 25 | if user:rank(bot, msg.chat.id) > 3 then 26 | table.insert(output, user:name() .. ' is already an administrator.') 27 | else 28 | user.data.administrator = true 29 | table.insert(output, user:name() .. ' is now an administrator.') 30 | end 31 | end 32 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 33 | end 34 | 35 | P.list = { 36 | name = 'admins', 37 | title = 'Global Administrators', 38 | type = 'userdata', 39 | key = 'administrator' 40 | } 41 | 42 | return P 43 | -------------------------------------------------------------------------------- /otouto/plugins/admin/add_group.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | add_group.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local anise = require('anise') 8 | 9 | local bindings = require('extern.bindings') 10 | local utilities = require('otouto.utilities') 11 | 12 | local P = {} 13 | 14 | function P:init(bot) 15 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 16 | :t('add_?group').table 17 | self.command = 'addgroup' 18 | self.doc = 'Adds the current supergroup to the administrative system.' 19 | 20 | self.privilege = 4 21 | end 22 | 23 | function P:action(bot, msg, group) 24 | local output 25 | 26 | if msg.chat.type ~= 'supergroup' then 27 | output = 'Administrated groups must be supergroups.' 28 | 29 | else 30 | local perms, group_owner 31 | local _, res = bindings.getChatAdministrators{ chat_id = msg.chat.id } 32 | for _, administrator in pairs(res.result) do 33 | if administrator.user.id == bot.info.id then 34 | perms = administrator 35 | elseif administrator.status == 'creator' then 36 | group_owner = administrator.user.id 37 | end 38 | end 39 | 40 | if not perms or not ( 41 | perms.can_change_info and perms.can_delete_messages and 42 | perms.can_restrict_members and perms.can_promote_members and 43 | perms.can_invite_users 44 | ) then 45 | output = 46 | 'I must have permission to change group info, delete messages,' 47 | .. ' and add, ban, and promote members.' 48 | elseif group.data.admin then 49 | output = 'I am already administrating this group.' 50 | else 51 | -- This shouldn't fail; we have already checked permissions above. 52 | local _, lres = bindings.exportChatInviteLink{chat_id = msg.chat.id} 53 | group.data.admin = { 54 | link = lres.result, 55 | governor = group_owner or msg.from.id, 56 | owner = group_owner, 57 | rules = {}, 58 | filter = {}, 59 | antihammer = {}, 60 | strikes = {}, 61 | moderators = {}, 62 | bans = {}, 63 | flags = anise.clone(bot.config.administration.flags) 64 | } 65 | 66 | bindings.setChatDescription{ 67 | chat_id = msg.chat.id, 68 | description = 'Welcome! Please review the rules and other group info with ' 69 | .. bot.config.cmd_pat .. 'description@' .. 70 | bot.info.username .. '.' 71 | } 72 | 73 | output = 'I am now administrating this group.' 74 | end 75 | end 76 | 77 | utilities.send_reply(msg, output) 78 | end 79 | 80 | P.list = { 81 | name = 'administrated', 82 | title = 'Administrated Groups', 83 | type = 'groupdata', 84 | key = 'admin', 85 | sudo = true 86 | } 87 | 88 | return P 89 | -------------------------------------------------------------------------------- /otouto/plugins/admin/add_mod.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | add_mod.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(bot) 13 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 14 | :t('mod', true):t('add_?mod', true):t('op', true).table 15 | self.command = 'mod' 16 | self.doc = 'Promotes a user to a moderator.' 17 | self.privilege = 3 18 | self.administration = true 19 | self.targeting = true 20 | end 21 | 22 | function P:action(bot, msg, group) 23 | local targets, output = autils.targets(bot, msg, {unknown_ids_err = true}) 24 | for target, _ in pairs(targets) do 25 | local name = utilities.lookup_name(bot, target) 26 | local rank = autils.rank(bot, target, msg.chat.id) 27 | 28 | if rank > 2 then 29 | autils.promote_admin(msg.chat.id, target, true) 30 | table.insert(output, name ..' is greater than a moderator.') 31 | else 32 | autils.promote_admin(msg.chat.id, target) 33 | local admin = group.data.admin 34 | if admin.moderators[target] then 35 | table.insert(output, name .. ' is already a moderator.') 36 | else 37 | admin.moderators[target] = true 38 | admin.bans[target] = nil 39 | table.insert(output, name .. ' is now a moderator.') 40 | end 41 | end 42 | end 43 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 44 | end 45 | 46 | P.list = { 47 | name = 'mods', 48 | title = 'Moderators', 49 | type = 'admin', 50 | key = 'moderators' 51 | } 52 | 53 | return P 54 | -------------------------------------------------------------------------------- /otouto/plugins/admin/antibot.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | antibot.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local bindings = require('extern.bindings') 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(bot) 13 | local flags_plugin = bot.named_plugins['admin.flags'] 14 | assert(flags_plugin, self.name .. ' requires flags') 15 | self.flag = 'antibot' 16 | self.flag_desc = 'Only moderators may add bots.' 17 | flags_plugin.flags[self.flag] = self.flag_desc 18 | self.triggers = { '^$' } 19 | self.administration = true 20 | end 21 | 22 | function P:action(bot, msg, group, user) 23 | if 24 | group.data.admin.flags[self.flag] 25 | and msg.new_chat_member 26 | and msg.new_chat_member.is_bot 27 | and user:rank(bot, msg.chat.id) < 2 28 | then 29 | if bindings.kickChatMember{ 30 | chat_id = msg.chat.id, 31 | user_id = msg.new_chat_member.id 32 | } then 33 | autils.log(bot, { 34 | chat_id = msg.chat.id, 35 | target = msg.new_chat_member.id, 36 | action = 'Bot removed', 37 | source = self.flag, 38 | reason = self.flag_desc 39 | }) 40 | end 41 | else 42 | return 'continue' 43 | end 44 | end 45 | 46 | return P 47 | -------------------------------------------------------------------------------- /otouto/plugins/admin/antihammer_whitelist.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | antihammer_whitelist.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(bot) 13 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 14 | :t('antihammer', true).table 15 | self.command = 'antihammer' 16 | self.doc = "Returns a list of users who are protected from global bans in \z 17 | this group, or toggle the status of a target or targets.." 18 | self.privilege = 3 19 | self.administration = true 20 | self.targeting = true 21 | end 22 | 23 | function P:action(bot, msg, group) 24 | local targets, output = autils.targets(bot, msg) 25 | local admin = group.data.admin 26 | 27 | if #targets > 0 or #output > 0 then 28 | for target, _ in pairs(targets) do 29 | local name = utilities.lookup_name(bot, target) 30 | if admin.antihammer[target] then 31 | admin.antihammer[target] = nil 32 | table.insert(output, name .. 33 | ' has been removed from the antihammer whitelist.') 34 | else 35 | admin.antihammer[target] = true 36 | table.insert(output, name .. 37 | ' has been added to the antihammer whitelist.') 38 | end 39 | end 40 | 41 | elseif next(admin.antihammer) then 42 | table.insert(output, 'Antihammered users:') 43 | table.insert(output, '• ' .. 44 | table.concat(utilities.list_names(bot, admin.antihammer), '\n• ')) 45 | 46 | else 47 | table.insert(output, 'There are no antihammer-whitelisted users.') 48 | end 49 | 50 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 51 | end 52 | 53 | return P 54 | -------------------------------------------------------------------------------- /otouto/plugins/admin/antilink.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | antilink.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local anise = require('anise') 8 | 9 | local bindings = require('extern.bindings') 10 | local autils = require('otouto.autils') 11 | 12 | local P = {} 13 | 14 | function P:init(bot) 15 | local flags_plugin = bot.named_plugins['admin.flags'] 16 | assert(flags_plugin, self.name .. ' requires flags') 17 | self.flag = 'antilink' 18 | self.flag_desc = 'Posting links to other groups is not allowed.' 19 | flags_plugin.flags[self.flag] = self.flag_desc 20 | 21 | self.help_word = 'antilink' 22 | self.doc = "\z 23 | antilink checks links and usernames posted by non-moderators. If a message \z 24 | references a group or channel outside the realm, an automoderation strike is \z 25 | issued (see /help automoderation) and the user's global antilink counter is \z 26 | incremented. When his counter reaches 3, he is globally banned and \z 27 | immediately removed from all groups where antilink was triggered. \ 28 | antilink can be enabled with /flag antilink (see /help flags)." 29 | 30 | -- Build the antilink patterns. Additional future domains can be added to 31 | -- this list to keep it up to date. 32 | self.patterns = {} 33 | for _, domain in pairs{ 34 | 'telegram.me', 35 | 'telegram.dog', 36 | 'tlgrm.me', 37 | 't.me', 38 | 'telega.one' 39 | } do 40 | local s = '' 41 | -- We build the pattern character by character from the domains. 42 | -- May become an issue when emoji TLDs become mainstream. ;) 43 | for char in domain:gmatch('.') do 44 | if char:match('%l') then 45 | s = s .. '[' .. char:upper() .. char .. ']' 46 | -- all characters which must be escaped 47 | elseif char:match('[%%%.%^%$%+%-%*%?]') then 48 | s = s .. '%' .. char 49 | else 50 | s = s .. char 51 | end 52 | end 53 | table.insert(self.patterns, s) 54 | end 55 | self.triggers = anise.clone(self.patterns) 56 | table.insert(self.triggers, '@[%w_]+') 57 | 58 | -- Infractions are stored, and users are globally banned after three within 59 | -- one day of each other. 60 | if not bot.database.userdata.antilink then 61 | bot.database.userdata.antilink = {} 62 | end 63 | 64 | self.administration = true 65 | end 66 | 67 | function P:action(bot, msg, group, user) 68 | local admin = group.data.admin 69 | if not admin.flags[self.flag] then return 'continue' end 70 | if user:rank(bot, msg.chat.id) > 1 then return 'continue' end 71 | if msg.forward_from and ( 72 | (msg.forward_from.id == bot.info.id) or 73 | (msg.forward_from.id == bot.config.log_chat) or 74 | (msg.forward_from.id == bot.config.administration.log_chat) 75 | ) then 76 | return 'continue' 77 | end 78 | if self:check(bot, msg) then 79 | local store = user.data.antilink 80 | if not store then 81 | store = { 82 | count = 0, 83 | groups = {}, 84 | } 85 | user.data.antilink = store 86 | end 87 | store.count = store.count + 1 88 | store.groups[tostring(msg.chat.id)] = true 89 | 90 | bot:do_later(self.name, os.time() + 86400, msg.from.id) 91 | 92 | if store.count == 3 then 93 | user.data.hammered = true 94 | bindings.deleteMessage{ chat_id = msg.chat.id, 95 | message_id = msg.message_id } 96 | autils.log(bot, { 97 | chat_id = not admin.flags.private and msg.chat.id, 98 | target = msg.from.id, 99 | action = 'Globally banned', 100 | source = self.flag, 101 | reason = self.flag_desc 102 | }) 103 | for chat_id_str, _ in pairs(store.groups) do 104 | bindings.kickChatMember{ 105 | chat_id = chat_id_str, 106 | user_id = msg.from.id 107 | } 108 | end 109 | user.data.antilink = nil 110 | else 111 | autils.strike(bot, msg, self.flag) 112 | end 113 | else 114 | return 'continue' 115 | end 116 | end 117 | 118 | function P:later(bot, user_id) 119 | local store = bot.database.userdata.antilink[tostring(user_id)] 120 | if store then 121 | if store.count > 1 then 122 | store.count = store.count - 1 123 | else 124 | bot.database.userdata.antilink[tostring(user_id)] = nil 125 | end 126 | end 127 | end 128 | 129 | P.edit_action = P.action 130 | 131 | -- Links can come from the message text or from entities, and can be joinchat 132 | -- links (t.me/joinchat/abcdefgh), username links (t.me/abcdefgh), or usernames 133 | -- (@abcdefgh). 134 | function P:check(bot, msg) 135 | for _, pattern in pairs(self.patterns) do 136 | 137 | -- Iterate through links in the message, and determine if they refer to 138 | -- external groups. 139 | for link in msg.text:gmatch(pattern..'%g*') do 140 | if self:parse_and_detect(bot, link, pattern) then 141 | return true 142 | end 143 | end 144 | 145 | -- Iterate through the messages's entities, if any, and determine if 146 | -- they're links to external groups. 147 | if msg.entities then 148 | for _, entity in ipairs(msg.entities) do 149 | if entity.url and self:parse_and_detect(bot, entity.url, pattern) then 150 | return true 151 | end 152 | end 153 | end 154 | end 155 | 156 | -- Iterate through all usernames in the message text, and determine if they 157 | -- are external group links. 158 | for username in msg.text:gmatch('@([%w_]+)') do 159 | if 160 | not (msg.forward_from_chat and username == msg.forward_from_chat.username) 161 | and self:is_username_external(bot, username) 162 | then 163 | return true 164 | end 165 | end 166 | end 167 | 168 | -- This function takes a link or username (parsed from a message or found in an 169 | -- entity) and returns true if that link or username refers to a supergroup 170 | -- outside of the realm. 171 | function P:parse_and_detect(bot, link, pattern) 172 | local code = link:match(pattern .. -- /joinchat/ABC-def_123 173 | '/[Jj][Oo][Ii][Nn][Cc][Hh][Aa][Tt]/([%w_%-]+)') 174 | local username = link:match(pattern .. '/([%w_]+)') 175 | if (code and self:is_code_external(bot, code)) or 176 | (username and self:is_username_external(bot, username)) 177 | then 178 | return true 179 | end 180 | end 181 | 182 | -- This function determines whether or not a given joinchat "code" refers to 183 | -- a group outside the realm (true/false) 184 | function P:is_code_external(bot, code) 185 | -- Prepare the code to be used as a pattern by escaping any hyphens. 186 | -- Also, add an anchor. 187 | local pattern = '/' .. code:gsub('%-', '%%-') .. '$' 188 | -- Iterate through groups and return false if the joinchat code belongs to 189 | -- any one of them. 190 | for _, group in pairs(bot.database.groupdata.admin) do 191 | if group.link:match(pattern) then 192 | return false 193 | end 194 | end 195 | return true 196 | end 197 | 198 | -- This function determines whether or not a username refers to a supergroup 199 | -- outside the realm (true/false). 200 | function P:is_username_external(bot, username) 201 | local suc, res = bindings.getChat{chat_id = '@' .. username} 202 | -- If the username is an external supergroup or channel, return true. 203 | if suc and (res.result.type=='supergroup' or res.result.type=='channel') and 204 | not bot.database.groupdata.admin[tostring(res.result.id)] then 205 | return true 206 | end 207 | return false 208 | end 209 | 210 | return P 211 | -------------------------------------------------------------------------------- /otouto/plugins/admin/antisquig.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | antisquig.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(bot) 13 | local flags_plugin = bot.named_plugins['admin.flags'] 14 | assert(flags_plugin, self.name .. ' requires flags') 15 | self.flag = 'antisquig' 16 | flags_plugin.flags[self.flag] = 17 | 'Arabic script is not allowed in messages.' 18 | self.triggers = { 19 | utilities.char.arabic, 20 | utilities.char.rtl_override, 21 | utilities.char.rtl_mark 22 | } 23 | self.administration = true 24 | end 25 | 26 | function P:action(bot, msg, group, user) 27 | if not group.data.admin.flags[self.flag] then return 'continue' end 28 | if user:rank(bot, msg.chat.id) > 1 then return 'continue' end 29 | if msg.forward_from and ( 30 | msg.forward_from.id == bot.info.id or 31 | msg.forward_from.id == bot.config.log_chat or 32 | msg.forward_from.id == bot.config.administration.log_chat 33 | ) then 34 | return 'continue' 35 | end 36 | 37 | autils.strike(bot, msg, self.flag) 38 | end 39 | 40 | P.edit_action = P.action 41 | 42 | return P 43 | -------------------------------------------------------------------------------- /otouto/plugins/admin/antisquigpp.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | antisquigpp.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | local autils = require('otouto.autils') 9 | local bindings = require('extern.bindings') 10 | 11 | local P = {} 12 | 13 | function P:init(bot) 14 | local flags_plugin = bot.named_plugins['admin.flags'] 15 | assert(flags_plugin, self.name .. ' requires flags') 16 | self.flag_desc = 'Arabic script is not allowed in names.' 17 | self.flag = 'antisquigpp' 18 | flags_plugin.flags[self.flag] = self.flag_desc 19 | self.triggers = {''} 20 | self.administration = true 21 | end 22 | 23 | function P:action(bot, msg, group, user) 24 | if not group.data.admin.flags[self.flag] then return 'continue' end 25 | if user:rank(bot, msg.chat.id) > 1 then return 'continue' end 26 | local name = utilities.build_name(user.data.info.first_name, user.data.info.last_name) 27 | if name:match(utilities.char.arabic) or 28 | name:match(utilities.char.rtl_override) or 29 | name:match(utilities.char.rtl_mark) 30 | then 31 | bindings.deleteMessage{ 32 | chat_id = msg.chat.id, 33 | message_id = msg.message_id 34 | } 35 | 36 | local success, result = bindings.kickChatMember{ 37 | chat_id = msg.chat.id, 38 | user_id = msg.from.id 39 | } 40 | 41 | autils.log(bot, { 42 | source = self.flag, 43 | reason = self.flag_desc, 44 | target = msg.from.id, 45 | chat_id = msg.chat.id, 46 | action = success and 'Kicked' or result.description 47 | }) 48 | else 49 | return 'continue' 50 | end 51 | end 52 | 53 | return P 54 | -------------------------------------------------------------------------------- /otouto/plugins/admin/antisticker.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | nostickers.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local bindings = require('extern.bindings') 8 | local utilities = require('otouto.utilities') 9 | local autils = require('otouto.autils') 10 | 11 | local P = {} 12 | 13 | function P:init(bot) 14 | local flags_plugin = bot.named_plugins['admin.flags'] 15 | assert(flags_plugin, self.name .. ' requires flags') 16 | self.flag = 'antisticker' 17 | self.flag_desc = 'Stickers are filtered.' 18 | flags_plugin.flags[self.flag] = self.flag_desc 19 | self.triggers = {'^$'} 20 | self.administration = true 21 | end 22 | 23 | function P:action(bot, msg, group) 24 | local admin = group.data.admin 25 | if admin.flags[self.flag] and msg.sticker then 26 | bindings.deleteMessage{ 27 | message_id = msg.message_id, 28 | chat_id = msg.chat.id 29 | } 30 | 31 | if msg.date >= (admin.last_antisticker_msg or -3600) + 3600 then -- 1h 32 | local success, result = 33 | utilities.send_message(msg.chat.id, 'Stickers are filtered.') 34 | if success then 35 | bot:do_later('core.delete_messages', os.time() + 5, { 36 | chat_id = msg.chat.id, 37 | message_id = result.result.message_id 38 | }) 39 | admin.last_antisticker_msg = result.result.date 40 | end 41 | end 42 | 43 | autils.log(bot, { 44 | chat_id = msg.chat.id, 45 | target = msg.from.id, 46 | action = 'Sticker deleted', 47 | source = self.flag, 48 | reason = self.flag_desc 49 | }) 50 | else 51 | return 'continue' 52 | end 53 | end 54 | 55 | return P 56 | -------------------------------------------------------------------------------- /otouto/plugins/admin/autopromoter.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | autopromoter.lua 3 | Promotes mods & admins when they join a group. 4 | Copyright 2018 topkecleon 5 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | ]]-- 7 | 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(_bot) 13 | self.triggers = {'^$'} 14 | self.administration = true 15 | end 16 | 17 | function P:action(bot, msg, _group, _user) 18 | if msg.new_chat_member then 19 | local rank = autils.rank(bot, msg.new_chat_member.id, msg.chat.id) 20 | if rank > 1 then 21 | autils.promote_admin(msg.chat.id, msg.new_chat_member.id, rank > 2) 22 | end 23 | end 24 | return 'continue' 25 | end 26 | 27 | return P 28 | -------------------------------------------------------------------------------- /otouto/plugins/admin/ban.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | ban.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local anise = require('anise') 8 | local bindings = require('extern.bindings') 9 | local utilities = require('otouto.utilities') 10 | local autils = require('otouto.autils') 11 | 12 | local P = {} 13 | 14 | function P:init(bot) 15 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 16 | :t('ban', true).table 17 | self.command = 'ban' 18 | self.doc = "Bans a user or users from the group. Targets can be unbanned \z 19 | with /unban. A reason can be given on a new line. Example:\ 20 | /ban @examplus 5551234\ 21 | Bad jokes." 22 | 23 | self.privilege = 2 24 | self.administration = true 25 | self.targeting = true 26 | end 27 | 28 | function P:action(bot, msg, group) 29 | local admin = group.data.admin 30 | local targets, output, reason = autils.targets(bot, msg) 31 | local banned_users = anise.set() 32 | 33 | for target, _ in pairs(targets) do 34 | local name = utilities.lookup_name(bot, target) 35 | if autils.rank(bot, target, msg.chat.id) >= 2 then 36 | table.insert(output, name .. ' is too privileged to be banned.') 37 | elseif admin.bans[target] then 38 | table.insert(output, name .. ' is already banned.') 39 | else 40 | bindings.kickChatMember{ 41 | chat_id = msg.chat.id, 42 | user_id = target 43 | } 44 | admin.bans[target] = reason or true 45 | banned_users:add(target, reason or true) 46 | table.insert(output, name .. ' has been banned.') 47 | end 48 | end 49 | 50 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 51 | if #banned_users > 0 then 52 | autils.log(bot, { 53 | chat_id = msg.chat.id, 54 | targets = banned_users, 55 | action = 'Banned', 56 | source_user = msg.from, 57 | reason = reason 58 | }) 59 | end 60 | end 61 | 62 | P.list = { 63 | name = 'banned', 64 | title = 'Banned Users', 65 | type = 'admin', 66 | key = 'bans' 67 | } 68 | 69 | return P 70 | -------------------------------------------------------------------------------- /otouto/plugins/admin/ban_remover.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | ban_remover.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local bindings = require('extern.bindings') 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(_bot) 13 | self.triggers = {''} 14 | self.administration = true 15 | end 16 | 17 | function P:action(bot, msg, _group, user) 18 | if user:rank(bot, msg.chat.id) == 0 then 19 | bindings.kickChatMember{ 20 | chat_id = msg.chat.id, 21 | user_id = msg.from.id 22 | } 23 | bindings.deleteMessage{ 24 | chat_id = msg.chat.id, 25 | message_id = msg.message_id 26 | } 27 | autils.log(bot, { 28 | chat_id = msg.chat.id, 29 | target = msg.from.id, 30 | action = 'Kicked and message deleted', 31 | source = self.name, 32 | reason = 'User is banned.' 33 | }) 34 | if msg.new_chat_member then 35 | bindings.kickChatMember { 36 | chat_id = msg.chat.id, 37 | user_id = msg.new_chat_member.id 38 | } 39 | end 40 | elseif msg.new_chat_member then 41 | if autils.rank(bot, msg.new_chat_member.id, msg.chat.id) == 0 then 42 | bindings.kickChatMember{ 43 | chat_id = msg.chat.id, 44 | user_id = msg.new_chat_member.id 45 | } 46 | bindings.deleteMessage{ 47 | chat_id = msg.chat.id, 48 | message_id = msg.message_id 49 | } 50 | autils.log(bot, { 51 | chat_id = msg.chat.id, 52 | target = msg.new_chat_member.id, 53 | action = 'Kicked', 54 | source = self.name, 55 | reason = 'User is banned.' 56 | }) 57 | else 58 | return 'continue' 59 | end 60 | else 61 | return 'continue' 62 | end 63 | end 64 | 65 | return P 66 | -------------------------------------------------------------------------------- /otouto/plugins/admin/deadmin.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | deadmin.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(bot) 13 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 14 | :t('deadmin', true).table 15 | self.privilege = 5 16 | self.command = 'deadmin' 17 | self.doc = 'Demotes an administrator or administrators.' 18 | self.targeting = true 19 | end 20 | 21 | function P:action(bot, msg, _group, _user) 22 | local targets, output = autils.targets(bot, msg) 23 | for target, _ in pairs(targets) do 24 | local user = utilities.user(bot, target) 25 | if user.data.administrator then 26 | user.data.administrator = nil 27 | for chat_id, _ in pairs(bot.database.groupdata.admin) do 28 | if user:rank(bot, chat_id) < 2 then 29 | autils.demote_admin(chat_id, target) 30 | end 31 | end 32 | table.insert(output, user:name() .. ' is no longer an administrator.') 33 | else 34 | table.insert(output, user:name() .. ' is not an administrator.') 35 | end 36 | end 37 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 38 | end 39 | 40 | return P 41 | -------------------------------------------------------------------------------- /otouto/plugins/admin/delete_join_messages.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | delete_join_messages.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local bindings = require('extern.bindings') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | local flags_plugin = bot.named_plugins['admin.flags'] 13 | assert(flags_plugin, self.name .. ' requires flags') 14 | self.flag = 'delete_join_messages' 15 | flags_plugin.flags[self.flag] = 16 | 'Deletes new_chat_member messages. These deletions are not logged.' 17 | self.triggers = { '^$' } 18 | self.administration = true 19 | end 20 | 21 | function P:action(_bot, msg, group, _user) 22 | if not group.data.admin.flags[self.flag] then return 'continue' end 23 | if msg.new_chat_members and msg.from.id == msg.new_chat_members[1].id then 24 | bindings.deleteMessage{ 25 | chat_id = msg.chat.id, 26 | message_id = msg.message_id 27 | } 28 | else 29 | return 'continue' 30 | end 31 | end 32 | 33 | return P 34 | -------------------------------------------------------------------------------- /otouto/plugins/admin/delete_left_messages.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | delete_left_messages.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local bindings = require('extern.bindings') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | local flags_plugin = bot.named_plugins['admin.flags'] 13 | assert(flags_plugin, self.name .. ' requires flags') 14 | self.flag = 'delete_left_messages' 15 | flags_plugin.flags[self.flag] = 16 | 'Deletes left_chat_member messages. These deletions are not logged.' 17 | self.triggers = { '^$' } 18 | self.administration = true 19 | end 20 | 21 | function P:action(_bot, msg, group, _user) 22 | if not group.data.admin.flags[self.flag] then return 'continue' end 23 | if msg.left_chat_member and msg.from.id == msg.left_chat_member.id then 24 | bindings.deleteMessage{ 25 | chat_id = msg.chat.id, 26 | message_id = msg.message_id 27 | } 28 | else 29 | return 'continue' 30 | end 31 | end 32 | 33 | return P 34 | -------------------------------------------------------------------------------- /otouto/plugins/admin/demod.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | demod.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(bot) 13 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 14 | :t('demod', true).table 15 | self.command = 'demod' 16 | self.doc = 'Demotes a moderator.' 17 | self.privilege = 3 18 | self.administration = true 19 | self.targeting = true 20 | end 21 | 22 | function P:action(bot, msg, group) 23 | local targets, output = autils.targets(bot, msg) 24 | for target, _ in pairs(targets) do 25 | local name = utilities.lookup_name(bot, target) 26 | local admin = group.data.admin 27 | if autils.rank(bot, target, msg.chat.id) < 3 then 28 | autils.demote_admin(msg.chat.id, target) 29 | end 30 | if admin.moderators[target] then 31 | admin.moderators[target] = nil 32 | table.insert(output, name .. ' is no longer a moderator.') 33 | else 34 | table.insert(output, name .. ' is not a moderator.') 35 | end 36 | end 37 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 38 | end 39 | 40 | return P 41 | -------------------------------------------------------------------------------- /otouto/plugins/admin/files_only.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | filesonly.lua 3 | A flag to delete photos, videos, gifs, etc, and reupload them as files. 4 | Copyright 2018 topkecleon 5 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | ]]-- 7 | 8 | local bindings = require('extern.bindings') 9 | local utilities = require('otouto.utilities') 10 | 11 | local P = {} 12 | 13 | function P:init(bot) 14 | local flags_plugin = bot.named_plugins['admin.flags'] 15 | assert(flags_plugin, self.name .. ' requires flags') 16 | self.flag = 'files_only' 17 | self.flag_desc = 'Deletes photos and videos and reuploads them as files.' 18 | flags_plugin.flags[self.flag] = self.flag_desc 19 | self.triggers = {''} 20 | self.administration = true 21 | end 22 | 23 | function P:action(bot, msg, group) 24 | if not group.data.admin.flags[self.flag] then 25 | return 'continue' 26 | end 27 | 28 | local file_id 29 | if msg.photo then 30 | file_id = msg.photo[#msg.photo].file_id 31 | elseif msg.video_note then 32 | file_id = msg.video_note.file_id 33 | end 34 | 35 | if file_id then 36 | local success, result = bindings.getFile{file_id = file_id} 37 | if success then 38 | local filename = utilities.download_file( 39 | 'https://api.telegram.org/file/bot' .. bot.config.bot_api_key 40 | .. '/' .. result.result.file_path, 41 | '/tmp/' .. os.time() .. result.result.file_path:match('%..-$') 42 | ) 43 | local caption = 'Media from ' .. utilities.print_name(msg.from) 44 | if msg.caption then 45 | caption = caption .. ':\n' .. msg.caption 46 | end 47 | if bindings.sendDocument( 48 | {chat_id = msg.chat.id, caption = caption}, 49 | {document = filename} 50 | ) then 51 | bindings.deleteMessage{ 52 | chat_id = msg.chat.id, 53 | message_id = msg.message_id 54 | } 55 | end 56 | os.execute('rm ' .. filename) 57 | return 58 | end 59 | end 60 | 61 | return 'continue' 62 | end 63 | 64 | return P 65 | -------------------------------------------------------------------------------- /otouto/plugins/admin/filter.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | filter.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 13 | :t('filter', true).table 14 | self.command = 'filter [term]' 15 | self.doc = "Adds or removes a filter, or lists all filters. Messages containing filtered terms are deleted. \z 16 | Filters use Lua patterns." 17 | self.privilege = 3 18 | self.administration = true 19 | end 20 | 21 | function P:action(_bot, msg, group, _user) 22 | local admin = group.data.admin 23 | local input = utilities.input(msg.text_lower) 24 | local output 25 | if input then 26 | local idx 27 | for i = 1, #admin.filter do 28 | if admin.filter[i] == input then 29 | idx = i 30 | break 31 | end 32 | end 33 | if idx then 34 | table.remove(admin.filter, idx) 35 | output = 'That term has been removed from the filter.' 36 | else 37 | table.insert(admin.filter, input) 38 | output = 'That term has been added to the filter.' 39 | end 40 | elseif #admin.filter == 0 then 41 | output = 'There are currently no filtered terms.' 42 | else 43 | output = 'Filtered terms:\n• ' .. 44 | utilities.html_escape(table.concat(admin.filter, '\n• ')) 45 | end 46 | utilities.send_reply(msg, output, 'html') 47 | end 48 | 49 | return P 50 | -------------------------------------------------------------------------------- /otouto/plugins/admin/filterer.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | filterer.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local bindings = require('extern.bindings') 8 | local autils = require('otouto.autils') 9 | local utilities = require('otouto.utilities') 10 | local rot13 = require('otouto.rot13') 11 | 12 | local P = {} 13 | 14 | function P:init(_bot) 15 | self.triggers = {''} 16 | self.administration = true 17 | end 18 | 19 | function P:action(bot, msg, group, user) 20 | if user:rank(bot, msg.chat.id) > 1 then return 'continue' end 21 | if msg.forward_from and ( 22 | (msg.forward_from.id == bot.info.id) or 23 | (msg.forward_from.id == bot.config.log_chat) or 24 | (msg.forward_from.id == bot.config.administration.log_chat) 25 | ) then 26 | return 'continue' 27 | end 28 | 29 | local admin = group.data.admin 30 | for i = 1, #admin.filter do 31 | if msg.text_lower:match(admin.filter[i]) then 32 | bindings.deleteMessage{ 33 | message_id = msg.message_id, 34 | chat_id = msg.chat.id 35 | } 36 | 37 | if msg.date >= (admin.last_filter_msg or -3600) + 3600 then -- 1h 38 | local success, result = utilities.send_message(msg.chat.id, 39 | 'Deleted a filtered term.') 40 | if success then 41 | bot:do_later('core.delete_messages', os.time() + 5, { 42 | chat_id = msg.chat.id, 43 | message_id = result.result.message_id 44 | }) 45 | admin.last_filter_msg = result.result.date 46 | end 47 | end 48 | 49 | autils.log(bot, { 50 | chat_id = msg.chat.id, 51 | target = msg.from.id, 52 | action = 'Message deleted', 53 | source = self.name, 54 | reason = 'ROT13: ' .. 55 | utilities.html_escape(rot13.cipher(admin.filter[i])) 56 | }) 57 | return 58 | end 59 | end 60 | 61 | return 'continue' 62 | end 63 | 64 | P.edit_action = P.action 65 | 66 | return P 67 | -------------------------------------------------------------------------------- /otouto/plugins/admin/fix_perms.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | fix_perms.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(bot) 13 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 14 | :t('fix_?perms', true).table 15 | self.command = 'fixperms' 16 | self.doc = 'Fixes local permissions for the user or specified target.' 17 | self.privilege = 2 18 | self.targeting = true 19 | self.administration = true 20 | end 21 | 22 | function P:action(bot, msg) 23 | local targets, output = autils.targets(bot, msg, {self_targeting = true}) 24 | for target, _ in pairs(targets) do 25 | local rank = autils.rank(bot, target, msg.chat.id) 26 | local name = utilities.lookup_name(bot, target) 27 | local suc, res 28 | if rank >= 3 then 29 | suc, res = autils.promote_admin(msg.chat.id, target, true) 30 | elseif rank == 2 then 31 | suc, res = autils.promote_admin(msg.chat.id, target) 32 | else 33 | suc, res = autils.demote_admin(msg.chat.id, target) 34 | end 35 | if suc then 36 | table.insert(output, 37 | 'Permissions have been corrected for ' .. name .. '.') 38 | else 39 | table.insert(output, 'Error correcting permissions for ' .. 40 | name .. ': ' .. res.description) 41 | end 42 | end 43 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 44 | end 45 | 46 | return P 47 | -------------------------------------------------------------------------------- /otouto/plugins/admin/flags.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | flags.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local anise = require('anise') 8 | local utilities = require('otouto.utilities') 9 | 10 | local P = {} 11 | 12 | P.flags = { 13 | private = 'Removes the link from the public group list and suppresses logs.' 14 | } 15 | 16 | function P:init(bot) 17 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 18 | :t('flags?', true).table 19 | self.command = 'flags ' 20 | self.help_word = 'flags?' 21 | self.privilege = 3 22 | self.administration = true 23 | self.doc = "Returns a list of flags, or toggles the specified flag. \ 24 | Flags are administrative policies at the disposal of the governor. Most \z 25 | provide optional automoderation (see /help antilink). The private flag \z 26 | removes a group's link from the public list and makes it only available to \z 27 | moderators and greater." 28 | if anise.dict_len(bot.config.administration.flags) > 0 then 29 | self.doc = self.doc .. "\nThe following flags are enabled by default:" 30 | for flag, _ in pairs(bot.config.administration.flags) do 31 | self.doc = self.doc .. '\n• ' .. flag 32 | end 33 | end 34 | end 35 | 36 | function P:action(_bot, msg, group) 37 | local admin = group.data.admin 38 | local input = utilities.input_from_msg(msg) 39 | local output 40 | 41 | if not input then 42 | output = self:list_flags(admin.flags) 43 | .. '\n\nSpecify a flag or flags to toggle.' 44 | else 45 | input = input:lower() 46 | local out = {} 47 | for flagname in input:gmatch('%g+') do 48 | local escaped = utilities.html_escape(flagname) 49 | if self.flags[flagname] then 50 | if admin.flags[flagname] then 51 | admin.flags[flagname] = nil 52 | table.insert(out, 'Flag disabled: ' .. escaped .. '.') 53 | else 54 | admin.flags[flagname] = true 55 | table.insert(out, 'Flag enabled: ' .. escaped .. '.') 56 | end 57 | else 58 | table.insert(out, 'Not a valid flag name: ' .. escaped .. '.') 59 | end 60 | end 61 | output = table.concat(out, '\n') 62 | end 63 | 64 | utilities.send_reply(msg, output, 'html') 65 | end 66 | 67 | -- List flags under Enabled and Disabled. 68 | function P:list_flags(local_flags) 69 | local disabled_flags = {} 70 | for flag in pairs(self.flags) do 71 | if not local_flags[flag] then 72 | disabled_flags[flag] = true 73 | end 74 | end 75 | return string.format( 76 | 'Enabled flags:\n• %s\nDisabled flags:\n• %s', 77 | table.concat(self:flag_list(local_flags), '\n• '), 78 | table.concat(self:flag_list(disabled_flags), '\n• ') 79 | ) 80 | end 81 | 82 | -- List flags. 83 | function P:flag_list(local_flags) 84 | local t = {} 85 | for flag in pairs(local_flags) do 86 | table.insert(t, flag .. ': ' .. self.flags[flag]) 87 | end 88 | return t 89 | end 90 | 91 | -- Decrement a user's strikes in a group. 92 | function P:later(bot, params) 93 | -- Check if group is still administrated, otherwise this will break. 94 | if bot.database.groupdata.admin[tostring(params.chat_id)] then 95 | local store = bot.database.groupdata.admin[tostring(params.chat_id)].strikes 96 | local uis = tostring(params.user_id) 97 | if store[uis] then 98 | if store[uis] > 1 then 99 | store[uis] = store[uis] - 1 100 | else 101 | store[uis] = nil 102 | end 103 | end 104 | end 105 | end 106 | 107 | return P 108 | -------------------------------------------------------------------------------- /otouto/plugins/admin/get_description.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | get_description.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 13 | :t('description', true):t('desc', true).table 14 | self.command = 'description [chat]' 15 | end 16 | 17 | function P:action(bot, msg, group) 18 | local input = utilities.input(msg.text_lower) 19 | local chat_id 20 | if input then 21 | for id_str, chat in pairs(bot.database.groupdata.admin) do 22 | if not chat.flags.private and bot.database.groupdata.info[id_str] 23 | .title:lower():find(input, 1, true) then 24 | chat_id = id_str 25 | break 26 | end 27 | end 28 | if not chat_id then 29 | utilities.send_reply(msg, 'Group not found.') 30 | return 31 | end 32 | elseif group and group.data.admin then 33 | chat_id = msg.chat.id 34 | else 35 | utilities.send_reply(msg, 'Specify a group.') 36 | return 37 | end 38 | 39 | local description = self.desc(bot, chat_id) 40 | 41 | if msg.chat.id == msg.from.id then 42 | utilities.send_reply(msg, description, 'html') 43 | else 44 | if utilities.send_message(msg.from.id, description, true, nil, 'html') then 45 | utilities.send_reply(msg, 'I have sent you the requested information in a private message.') 46 | else 47 | utilities.send_reply(msg, description, 'html') 48 | end 49 | end 50 | end 51 | 52 | function P.desc(bot, chat_id) 53 | local admin = bot.database.groupdata.admin[tostring(chat_id)] 54 | local output = {} 55 | 56 | -- Group title 57 | table.insert(output, utilities.lookup_name(bot, chat_id)) 58 | 59 | -- Description 60 | table.insert(output, admin.description) 61 | 62 | -- Rules 63 | if #admin.rules > 0 then 64 | table.insert(output, 'Rules:\n' .. table.concat( 65 | bot.named_plugins['admin.list_rules'].rule_list(admin.rules), '\n')) 66 | end 67 | 68 | -- Flags 69 | if next(admin.flags) ~= nil then 70 | table.insert(output, 'Flags:\n• ' .. table.concat( 71 | bot.named_plugins['admin.flags']:flag_list(admin.flags), '\n• ')) 72 | end 73 | 74 | -- Governor 75 | table.insert(output, 'Governor: ' .. 76 | utilities.lookup_name(bot, admin.governor)) 77 | 78 | -- Moderators 79 | if next(admin.moderators) ~= nil then 80 | table.insert(output, 'Moderators:\n• ' .. table.concat( 81 | utilities.list_names(bot, admin.moderators), '\n• ')) 82 | end 83 | 84 | return table.concat(output, '\n\n') 85 | end 86 | 87 | return P 88 | -------------------------------------------------------------------------------- /otouto/plugins/admin/get_link.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | get_link.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 13 | :t('link'):t('get_?link').table 14 | self.command = 'link' 15 | self.doc = "Returns the group link. If the group is private, \z 16 | only moderators may use this command and responses will be sent in private." 17 | self.administration = true 18 | end 19 | 20 | function P:action(bot, msg, group, user) 21 | local admin = group.data.admin 22 | local output 23 | local link = string.format( 24 | '%s', 25 | admin.link, 26 | utilities.html_escape(msg.chat.title) 27 | ) 28 | 29 | -- Links to private groups are mods+ and are only PM'd. 30 | if admin.flags.private then 31 | if user:rank(bot, msg.chat.id) > 1 then 32 | if utilities.send_message(msg.from.id, link, true, nil, 'html') then 33 | output = 'I have sent you the requested information in a private message.' 34 | else 35 | output = "This group is private. The link must be received privately. \z 36 | Please message me privately and re-run the command." 37 | end 38 | else 39 | output = 'This group is private. Only moderators may retrieve its link.' 40 | end 41 | else 42 | output = link 43 | end 44 | 45 | utilities.send_message(msg.chat.id, output, true, msg.message_id, 'html') 46 | end 47 | 48 | return P 49 | -------------------------------------------------------------------------------- /otouto/plugins/admin/hammer.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | hammer.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local anise = require('anise') 8 | local bindings = require('extern.bindings') 9 | local utilities = require('otouto.utilities') 10 | local autils = require('otouto.autils') 11 | 12 | local P = {} 13 | 14 | function P:init(bot) 15 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 16 | :t('hammer', true).table 17 | self.command = 'hammer' 18 | self.doc = "Globally bans a user or users. Targets can be unbanned with \z 19 | /unhammer. A reason can be given on a new line. Example:\ 20 | /hammer @examplus 5556789\ 21 | Really bad jokes." 22 | self.privilege = 4 23 | self.targeting = true 24 | end 25 | 26 | function P:action(bot, msg, group) 27 | local targets, output, reason = autils.targets(bot, msg) 28 | local hammered_users = anise.set() 29 | 30 | for target, _ in pairs(targets) do 31 | local name = utilities.lookup_name(bot, target) 32 | 33 | if autils.rank(bot, target, msg.chat.id) >= 4 then 34 | table.insert(output, name .. ' is an administrator.') 35 | elseif bot.database.userdata.hammered[target] then 36 | table.insert(output, name .. ' is already globally banned.') 37 | else 38 | if group then bindings.kickChatMember{ 39 | chat_id = msg.chat.id, 40 | user_id = target 41 | } end 42 | bot.database.userdata.hammered[target] = reason or true 43 | table.insert(output, name .. ' has been globally banned.') 44 | hammered_users:add(target, reason or true) 45 | end 46 | end 47 | 48 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 49 | if #hammered_users > 0 then 50 | autils.log(bot, { 51 | -- Do not send the chat ID from PMs or private groups. 52 | chat_id = group and group.data.admin 53 | and not group.data.admin.flags.private and msg.chat.id, 54 | targets = hammered_users, 55 | action = 'Globally banned', 56 | source_user = msg.from, 57 | reason = reason 58 | }) 59 | end 60 | end 61 | 62 | P.list = { 63 | name = 'hammered', 64 | title = 'Globally Banned Users', 65 | type = 'userdata', 66 | key = 'hammered' 67 | } 68 | 69 | return P 70 | -------------------------------------------------------------------------------- /otouto/plugins/admin/interactive_flags.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | interactive_flags.lua 3 | Toggle administrative flags interactively! 4 | 5 | Copyright 2019 topkecleon 6 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | ]]-- 8 | 9 | local bindings = require('extern.bindings') 10 | local utilities = require('otouto.utilities') 11 | 12 | local P = {} 13 | 14 | function P:init(bot) 15 | assert(bot.named_plugins['admin.flags'], 16 | self.name .. ' requires admin.flags!') 17 | 18 | self.command = 'flagint' 19 | self.doc = 'Enable or disable administrative flags.' 20 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 21 | :t('flags?int').table 22 | 23 | self.administration = true 24 | self.privilege = 3 25 | 26 | self.flags_plugin = bot.named_plugins['admin.flags'] 27 | end 28 | 29 | function P:action(_, msg, group) 30 | local message = self:build_message(group) 31 | message.chat_id = msg.chat.id 32 | bindings.sendMessage(message) 33 | end 34 | 35 | function P:callback_action(bot, query) 36 | local group = utilities.group(bot, query.message.chat.id) 37 | local flag_name = utilities.get_word(query.data, 2) 38 | -- toggle flag 39 | if group.data.admin.flags[flag_name] then 40 | group.data.admin.flags[flag_name] = nil 41 | else 42 | group.data.admin.flags[flag_name] = true 43 | end 44 | -- build message update 45 | local message = self:build_message(group) 46 | message.chat_id = query.message.chat.id 47 | message.message_id = query.message.message_id 48 | bindings.editMessageText(message) 49 | end 50 | 51 | function P:build_message(group) 52 | local keyboard = utilities.keyboard('inline_keyboard'):row() 53 | local i = 0 54 | for flag_name in pairs(self.flags_plugin.flags) do 55 | local symbol = '❌ ' 56 | if group.data.admin.flags[flag_name] then 57 | symbol = '✅ ' 58 | end 59 | keyboard:button(symbol .. flag_name, 'callback_data', self.name .. ' ' .. flag_name) 60 | i = i + 1 61 | if i % 3 == 0 then 62 | keyboard:row() 63 | end 64 | end 65 | 66 | return { 67 | reply_markup = keyboard:serialize(), 68 | text = self.flags_plugin:list_flags(group.data.admin.flags), 69 | parse_mode = 'html' 70 | } 71 | end 72 | 73 | return P 74 | -------------------------------------------------------------------------------- /otouto/plugins/admin/kick.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | kick.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local anise = require('anise') 8 | local bindings = require('extern.bindings') 9 | local utilities = require('otouto.utilities') 10 | local autils = require('otouto.autils') 11 | 12 | local P = {} 13 | 14 | function P:init(bot) 15 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 16 | :t('kick', true).table 17 | self.command = 'kick' 18 | self.doc = "Removes a user or users from the group. A reason can be given \z 19 | on a new line. Example:\ 20 | /kick @examplus 5554321\ 21 | Bad jokes." 22 | self.privilege = 2 23 | self.administration = true 24 | self.targeting = true 25 | self.duration = true 26 | end 27 | 28 | function P:action(bot, msg, _group, _user) 29 | local targets, output, reason = autils.targets(bot, msg) 30 | local kicked_users = anise.set() 31 | 32 | for target, _ in pairs(targets) do 33 | local name = utilities.lookup_name(bot, target) 34 | if autils.rank(bot, target, msg.chat.id) >= 2 then 35 | table.insert(output, name .. ' is too privileged to be kicked.') 36 | else 37 | -- It isn't documented, but unbanChatMember also kicks. 38 | -- Thanks, Durov. 39 | local success, result = bindings.unbanChatMember{ 40 | chat_id = msg.chat.id, 41 | user_id = target 42 | } 43 | if success then 44 | table.insert(output, name .. ' has been kicked.') 45 | kicked_users:add(target, reason or true) 46 | else 47 | table.insert(output, 'Error kicking ' .. name .. ': ' .. 48 | result.description) 49 | end 50 | end 51 | end 52 | 53 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 54 | if #kicked_users > 0 then 55 | autils.log(bot, { 56 | chat_id = msg.chat.id, 57 | targets = kicked_users, 58 | action = 'Kicked', 59 | source_user = msg.from, 60 | reason = reason 61 | }) 62 | end 63 | end 64 | 65 | return P 66 | -------------------------------------------------------------------------------- /otouto/plugins/admin/kickme.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | kickme.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local bindings = require('extern.bindings') 8 | local utilities = require('otouto.utilities') 9 | local autils = require('otouto.autils') 10 | 11 | local P = {} 12 | 13 | function P:init(bot) 14 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 15 | :t('kickme').table 16 | self.command = 'kickme' 17 | self.doc = 'Removes the user from the group.' 18 | self.administration = true 19 | end 20 | 21 | function P:action(bot, msg) 22 | bindings.deleteMessage{ 23 | chat_id = msg.chat.id, 24 | message_id = msg.message_id 25 | } 26 | if bindings.unbanChatMember{ 27 | chat_id = msg.chat.id, 28 | user_id = msg.from.id 29 | } then 30 | autils.log(bot, { 31 | chat_id = msg.chat.id, 32 | target = msg.from.id, 33 | action = 'Kicked', 34 | source = self.name 35 | }) 36 | end 37 | end 38 | 39 | return P 40 | -------------------------------------------------------------------------------- /otouto/plugins/admin/list_admins.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | list_admins.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 13 | :t('admins'):t('list_?admins').table 14 | self.command = 'admins' 15 | self.doc = 'Returns a list of global administrators.' 16 | self.privilege = 2 17 | end 18 | 19 | function P:action(bot, msg, _group, _user) 20 | local admin_list = 21 | utilities.list_names(bot, bot.database.userdata.administrator) 22 | table.insert(admin_list, 1, 'Global administrators:') 23 | utilities.send_reply(msg, table.concat(admin_list, '\n• '), 'html') 24 | end 25 | 26 | return P 27 | -------------------------------------------------------------------------------- /otouto/plugins/admin/list_flags.fnl: -------------------------------------------------------------------------------- 1 | ; list_flags.fnl 2 | ; Copyright 2018 topkecleon 3 | ; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 4 | 5 | (require-macros :anise.macros) 6 | (require* otouto.utilities) 7 | 8 | { 9 | :init (fn [self bot] 10 | (set self.command "listflags") 11 | (set self.doc "Returns a list of enabled and disabled flags. Governors and\z 12 | administrators can use /flags to configure them.") 13 | (set self.triggers (utilities.make_triggers bot [] "list_?flags")) 14 | (set self.administration true) 15 | (values)) 16 | 17 | :action (fn [_self bot msg group] 18 | (utilities.send_reply msg 19 | (: (. bot.named_plugins :admin.flags) :list_flags group.data.admin.flags) 20 | :html)) 21 | } 22 | -------------------------------------------------------------------------------- /otouto/plugins/admin/list_groups.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | list_groups.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | local bindings = require('extern.bindings') 9 | local plists 10 | 11 | local P = {} 12 | 13 | function P:init(bot) 14 | plists = bot.named_plugins['core.paged_lists'] 15 | assert(plists, self.name .. ' requires core.paged_lists.') 16 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 17 | :t('groups?', true):t('list_?groups', true).table 18 | self.command = 'groups [query]' 19 | self.doc = "/groups [query]\ 20 | Returns a list of all public, administrated groups, or the results of a query." 21 | end 22 | 23 | function P:action(bot, msg) 24 | local input = utilities.input_from_msg(msg) 25 | input = input and input:lower() 26 | 27 | -- Output will be a list of results, a list of all groups, or an explanation 28 | -- that there are no (listed) groups. 29 | local titles_and_links = {} 30 | for id_str, chat in pairs(bot.database.groupdata.admin) do 31 | if not chat.flags.private then 32 | table.insert(titles_and_links, 33 | bot.database.groupdata.info[id_str].title .. chat.link) 34 | end 35 | end 36 | 37 | local listed_groups = {} 38 | local results = {} 39 | table.sort(titles_and_links) 40 | for _, s in ipairs(titles_and_links) do 41 | local a, b = s:match('^(.+)(https://.-)$') 42 | local fmtd = string.format('%s', b, utilities.html_escape(a)) 43 | table.insert(listed_groups, fmtd) 44 | if input and a:lower():find(input, 1, true) then 45 | table.insert(results, fmtd) 46 | end 47 | end 48 | 49 | local output 50 | if input then 51 | if #results == 0 then 52 | output = bot.config.errors.results 53 | else 54 | plists:send(bot, msg, results, 'Group Results', msg.chat.id) 55 | end 56 | elseif #listed_groups == 0 then 57 | output = 'There are no listed groups.' 58 | else 59 | local success, result = plists:send(bot, msg, listed_groups, 'Groups') 60 | if success then 61 | if result.result.chat.id ~= msg.chat.id then 62 | output = 'I have sent you the requested info privately.' 63 | else 64 | bindings.deleteMessage{ 65 | chat_id = msg.chat.id, 66 | message_id = msg.message_id 67 | } 68 | end 69 | else 70 | output = 'Please message me privately first.' 72 | end 73 | end 74 | if output then utilities.send_reply(msg, output, 'html') end 75 | end 76 | 77 | return P 78 | -------------------------------------------------------------------------------- /otouto/plugins/admin/list_mods.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | list_mods.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 13 | :t('mods'):t('ops'):t('list_?mods').table 14 | self.command = 'mods' 15 | self.doc = 'Returns a list of group moderators.' 16 | self.administration = true 17 | end 18 | 19 | function P:action(bot, msg, group) 20 | local admin = group.data.admin 21 | local mod_list = utilities.list_names(bot, admin.moderators) 22 | table.insert(mod_list, 1, '\n\nModerators:') 23 | local output = 'Governor: ' .. utilities.lookup_name(bot, 24 | admin.governor) .. table.concat(mod_list, '\n• ') 25 | utilities.send_reply(msg, output, 'html') 26 | end 27 | 28 | return P 29 | -------------------------------------------------------------------------------- /otouto/plugins/admin/list_rules.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | list_rules.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 13 | :t('rules?', true):t('list_?rules').table 14 | self.command = 'rules [i]' 15 | self.doc = 'Returns the list of rules, or the specified rule.' 16 | self.administration = true 17 | end 18 | 19 | function P:action(_bot, msg, group) 20 | local admin = group.data.admin 21 | local input = tonumber(utilities.get_word(msg.text, 2)) 22 | local output 23 | if #admin.rules == 0 then 24 | output = 'No rules have been set for this group.' 25 | elseif input and admin.rules[input] then 26 | output = self.rule_list(admin.rules)[input] 27 | else 28 | output = 'Rules for ' ..utilities.html_escape(msg.chat.title).. ':\n' 29 | .. table.concat(self.rule_list(admin.rules), '\n') 30 | end 31 | utilities.send_reply(msg.reply_to_message or msg, output, 'html') 32 | end 33 | 34 | function P.rule_list(rules) 35 | local t = {} 36 | for i, rule in ipairs(rules) do 37 | table.insert(t, '' .. i .. '. ' .. rule) 38 | end 39 | return t 40 | end 41 | 42 | return P 43 | -------------------------------------------------------------------------------- /otouto/plugins/admin/mute.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | mute.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local anise = require('anise') 8 | local bindings = require('extern.bindings') 9 | local utilities = require('otouto.utilities') 10 | local autils = require('otouto.autils') 11 | 12 | local P = {} 13 | 14 | function P:init(bot) 15 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 16 | :t('mute', true).table 17 | self.command = 'mute' 18 | self.doc = "Mute a user or users indefinitely or for the time specified. \z 19 | A duration in the tiem format (see /help tiem) can be given before the reason. \ 20 | Examples:\ 21 | /mute @foo @bar 8675309\ 22 | 2h30m No cursing on my Christian server.\ 23 | \ 24 | [in reply] /mute 240" 25 | self.privilege = 2 26 | self.administration = true 27 | self.targeting = true 28 | self.duration = true 29 | end 30 | 31 | function P:action(bot, msg, _group, _user) 32 | local targets, output, reason, duration = 33 | autils.targets(bot, msg, {get_duration = true}) 34 | local muted_users = anise.set() 35 | 36 | -- Durations shorter than 30 seconds and longer than a leap year are 37 | -- interpreted as "forever" by the bot API. 38 | if duration and (duration > 366*24*60*60 or duration < 60) then 39 | duration = nil 40 | table.insert(output, 41 | 'Durations must be longer than a minute and shorter than a year.') 42 | end 43 | 44 | local out_str, log_str 45 | if duration then 46 | out_str = ' has been muted for ' .. 47 | utilities.tiem.print(duration) .. '.' 48 | log_str = 'Muted for ' .. utilities.tiem.print(duration) 49 | else 50 | out_str = ' has been muted.' 51 | log_str = 'Muted' 52 | end 53 | 54 | for target, _ in pairs(targets) do 55 | local name = utilities.lookup_name(bot, target) 56 | 57 | if autils.rank(bot, target, msg.chat.id) >= 2 then 58 | table.insert(output,name .. ' is too privileged to be muted.') 59 | else 60 | local success, result = bindings.restrictChatMember{ 61 | chat_id = msg.chat.id, 62 | user_id = target, 63 | until_date = duration and os.time() + duration, 64 | can_send_messages = false 65 | } 66 | if success then 67 | table.insert(output, name .. out_str) 68 | muted_users:add(target, reason or true) 69 | else 70 | table.insert(output, result.description .. ' (' ..target.. ')') 71 | end 72 | end 73 | end 74 | 75 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 76 | if #muted_users > 0 then 77 | autils.log(bot, { 78 | chat_id = msg.chat.id, 79 | targets = muted_users, 80 | action = log_str, 81 | source_user = msg.from, 82 | reason = reason 83 | }) 84 | end 85 | end 86 | 87 | return P 88 | -------------------------------------------------------------------------------- /otouto/plugins/admin/regen_link.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | regen_link.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local bindings = require('extern.bindings') 8 | local utilities = require('otouto.utilities') 9 | 10 | local P = {} 11 | 12 | function P:init(bot) 13 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 14 | :t('regen_?link').table 15 | self.command = 'regenlink' 16 | self.doc = 'Regenerates the group link.' 17 | self.administration = true 18 | self.privilege = 2 19 | end 20 | 21 | function P:action(_bot, msg, group) 22 | local success, result = bindings.exportChatInviteLink{chat_id = msg.chat.id} 23 | if success then 24 | group.data.admin.link = result.result 25 | utilities.send_reply(msg, 'The link has been regenerated.') 26 | else 27 | utilities.send_reply(msg, result.description) 28 | end 29 | end 30 | 31 | return P 32 | -------------------------------------------------------------------------------- /otouto/plugins/admin/remove_group.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | remove_group.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 13 | :t('remove_?group', true):t('rem_?group').table 14 | self.command = 'removegroup [chat ID]' 15 | self.doc = "/removegroup [chat ID]\ 16 | Removes the current or specified group from the administrative system." 17 | self.privilege = 4 18 | end 19 | 20 | function P:action(bot, msg, group) 21 | local input = utilities.get_word(msg.text, 2) 22 | local output 23 | 24 | if input then 25 | local id = tostring(tonumber(input)) 26 | if id then 27 | local admin = bot.database.groupdata.admin 28 | if admin[id] then 29 | output = 'I am no longer administrating ' .. admin[id].name .. '.' 30 | admin[id] = nil 31 | elseif admin['-100'..id] then 32 | output = 'I am no longer administrating ' .. admin['-100'..id].name .. '.' 33 | admin['-100'..id] = nil 34 | else 35 | output = 'Group not found (' .. id .. ').' 36 | end 37 | else 38 | output = 'Input must be a group ID.' 39 | end 40 | 41 | elseif group and group.data.admin then 42 | output = 'I am no longer administrating ' .. msg.chat.title .. '.' 43 | group.data.admin = nil 44 | else 45 | output = 'Run in an administrated group or pass one\'s ID.' 46 | end 47 | 48 | utilities.send_reply(msg, output) 49 | 50 | end 51 | 52 | return P 53 | -------------------------------------------------------------------------------- /otouto/plugins/admin/set_description.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | set_description.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 13 | :t('set_?description', true):t('set_?desc', true).table 14 | self.command = 'setdesc ' 15 | self.doc = 'Set a group description. Passing "--" will delete the current one.' 16 | self.privilege = 3 17 | self.administration = true 18 | end 19 | 20 | function P:action(_bot, msg, group) 21 | local admin = group.data.admin 22 | local input = utilities.input_from_msg(msg) 23 | if not input then 24 | if admin.description then 25 | utilities.send_reply(msg, 'Current description:\n' .. 26 | admin.description, 'html') 27 | else 28 | utilities.send_reply(msg, 'This group has no description.') 29 | end 30 | elseif input == '--' or input == utilities.char.em_dash then 31 | admin.description = nil 32 | utilities.send_reply(msg, 'The group description has been cleared.') 33 | else 34 | admin.description = input 35 | utilities.send_reply(msg, 'Description updated:\n' .. 36 | admin.description, 'html') 37 | end 38 | end 39 | 40 | return P 41 | -------------------------------------------------------------------------------- /otouto/plugins/admin/set_governor.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | set_governor.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | local autils = require('otouto.autils') 9 | 10 | local P = {} 11 | 12 | function P:init(bot) 13 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 14 | :t('governor', true):t('gov', true).table 15 | self.command = 'governor ' 16 | self.doc = 'Set the group\'s governor.' 17 | self.privilege = 3 18 | self.administration = true 19 | end 20 | 21 | function P:action(bot, msg, group) 22 | local targets, errors = autils.targets(bot, msg, {unknown_ids_err = true}) 23 | local output 24 | 25 | if #targets == 1 then 26 | local target = targets:next() 27 | local admin = group.data.admin 28 | local name = utilities.lookup_name(bot, target) 29 | autils.promote_admin(msg.chat.id, target, true) 30 | 31 | if target == admin.governor then 32 | output = name .. ' is already governor.' 33 | 34 | else 35 | -- Demote the old governor if he's not an admin. 36 | if autils.rank(bot, admin.governor, msg.chat.id) < 4 then 37 | autils.demote_admin(msg.chat.id, admin.governor) 38 | end 39 | 40 | admin.moderators[target] = nil 41 | admin.bans[target] = nil 42 | admin.governor = target 43 | output = name .. ' is now governor.' 44 | end 45 | 46 | elseif #targets == 0 then 47 | output = bot.config.errors.specify_target 48 | 49 | else -- multiple targets 50 | output = 'Please only specify one new governor.' 51 | end 52 | 53 | utilities.send_reply(msg, output .. table.concat(errors, '\n'), 'html') 54 | end 55 | 56 | return P 57 | -------------------------------------------------------------------------------- /otouto/plugins/admin/set_rules.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | set_rules.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local utilities = require('otouto.utilities') 8 | 9 | local P = {} 10 | 11 | function P:init(bot) 12 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 13 | :t('set_?rules?', true):t('change_?rules?', true) 14 | :t('add_?rules?', true):t('del_?rules?', true).table 15 | self.command = 'setrules ' 16 | -- luacheck: push no max string line length 17 | self.doc = [[ 18 | change [i] 19 | Changes an existing rule or rules, starting at $i. If $i is unspecified, all rules will be overwritten. 20 | Alias: /changerule 21 | 22 | add [i] 23 | Adds a new rule or rules, inserted starting at $i. If $i is unspecified, the new rule or rules will be added to the end of the list. 24 | Alias: /addrule 25 | 26 | del [i] 27 | Deletes a rule. If $i is unspecified, all rules will be deleted. 28 | Alias: /delrule 29 | 30 | Examples: 31 | • Set all the rules for a group. 32 | /changerules 33 | First rule. 34 | Second rule. 35 | ... 36 | 37 | • Change rule 4. 38 | /changerule 4 39 | Changed fourth rule. 40 | [Changed fifth rule.] 41 | 42 | • Add a rule or rules between 2 and 3. 43 | /addrule 3 44 | New third rule. 45 | [New fourth rule.] 46 | ]] 47 | -- luacheck: pop 48 | 49 | self.privilege = 3 50 | self.administration = true 51 | end 52 | 53 | function P:action(bot, msg, group) 54 | local subc, idx 55 | 56 | -- "/changerule ..." -> "/setrules change ..." 57 | local c = '^' .. bot.config.cmd_pat 58 | if msg.text_lower:match(c..'change_?rule') then 59 | subc = 'change' 60 | idx = msg.text_lower:match(c..'change_?rules?%s+(%d+)') 61 | 62 | elseif msg.text_lower:match(c..'add_?rule') then 63 | subc = 'add' 64 | idx = msg.text_lower:match(c..'add_?rules?%s+(%d+)') 65 | 66 | elseif msg.text_lower:match(c..'del_?rule') then 67 | subc = 'del' 68 | idx = msg.text_lower:match(c..'del_?rules?%s+(%d+)') 69 | else 70 | subc, idx = msg.text_lower:match(c..'set_?rules?%s+(%a+)%s*(%d*)') 71 | end 72 | 73 | local nrules = msg.text:match('^.-\n+(.+)$') or msg.reply_to_message and 74 | msg.reply_to_message.text 75 | local new_rules = {} 76 | if nrules then 77 | for s in string.gmatch(nrules..'\n', '(.-)\n') do 78 | table.insert(new_rules, s) 79 | end 80 | end 81 | 82 | local output 83 | if self.subcommands[subc] then 84 | output = self.subcommands[subc](self, group, new_rules, tonumber(idx)) 85 | else 86 | output = 'Invalid subcommand. See /help setrules.' 87 | end 88 | 89 | utilities.send_reply(msg, output, 'html') 90 | end 91 | 92 | P.subcommands = { 93 | change = function (super, group, new_rules, idx) 94 | local admin = group.data.admin 95 | if #new_rules == 0 then 96 | return 'Please specify the new rule or rules.' 97 | 98 | elseif not idx then -- /setrules 99 | admin.rules = new_rules 100 | local output = 'Rules for ' .. utilities.html_escape( 101 | group.data.info.title) .. ':' 102 | for i, rule in ipairs(admin.rules) do 103 | output = output .. '\n' .. i .. '. ' .. rule 104 | end 105 | return output 106 | 107 | elseif idx < 1 then 108 | return 'Invalid index.' 109 | 110 | elseif idx > #admin.rules then 111 | return super.subcommands.add(_, group, new_rules, idx) 112 | 113 | else -- /changerule i 114 | local output = '' 115 | for i = 1, #new_rules do 116 | admin.rules[idx+i-1] = new_rules[i] 117 | output = output .. '\n' .. idx+i-1 .. '. ' .. new_rules[i] 118 | end 119 | return output 120 | end 121 | end, 122 | 123 | add = function (_super, group, new_rules, idx) 124 | local admin = group.data.admin 125 | if #new_rules == 0 then 126 | return 'Please specify the new rule or rules.' 127 | 128 | elseif not idx or idx > #admin.rules then -- /addrule 129 | local output = '' 130 | for i = 1, #new_rules do 131 | table.insert(admin.rules, new_rules[i]) 132 | output = output .. '\n' .. #admin.rules .. '. ' .. new_rules[i] 133 | end 134 | return output 135 | 136 | elseif idx < 1 then 137 | return 'Invalid index.' 138 | 139 | else -- /addrule i 140 | local output = '' 141 | for i = 1, #new_rules do 142 | table.insert(admin.rules, idx+i-1, new_rules[i]) 143 | output = output .. '\n' .. idx+i-1 .. '. ' .. new_rules[i] 144 | end 145 | return output 146 | end 147 | end, 148 | 149 | del = function (_super, group, _new_rules, idx) 150 | local admin = group.data.admin 151 | if not idx then -- /setrules -- 152 | admin.rules = {} 153 | return 'The rules have been deleted.' 154 | 155 | elseif idx > #admin.rules or idx < 0 then 156 | return 'Invalid index.' 157 | 158 | else -- /changerule i -- 159 | table.remove(admin.rules, idx) 160 | return 'Rule ' .. idx .. ' has been deleted.' 161 | end 162 | end, 163 | } 164 | 165 | return P 166 | -------------------------------------------------------------------------------- /otouto/plugins/admin/temp_ban.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | temp_ban.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local anise = require('anise') 8 | local bindings = require('extern.bindings') 9 | local utilities = require('otouto.utilities') 10 | local autils = require('otouto.autils') 11 | 12 | local P = {} 13 | 14 | function P:init(bot) 15 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 16 | :t('temp_?ban', true).table 17 | self.command = 'tempban' 18 | self.doc = "Bans a user or users. A duration must be given on a new line \z 19 | (or after the command in a reply). A reason can be given after that. The \z 20 | duration must be an interval in the tiem format (see /help tiem). Example:\ 21 | /tempban @examplus 5554321\ 22 | 3d12h Bad jokes." 23 | self.privilege = 2 24 | self.administration = true 25 | self.targeting = true 26 | self.duration = true 27 | end 28 | 29 | function P:action(bot, msg, _group, _user) 30 | local targets, output, reason, duration = 31 | autils.targets(bot, msg, {get_duration = true}) 32 | local banned_users = anise.set() 33 | 34 | if not duration or duration > 366*24*60*60 or duration < 60 then 35 | table.insert(output, 36 | 'Durations must be longer than a minute and shorter than a year.') 37 | else 38 | for target, _ in pairs(targets) do 39 | local name = utilities.lookup_name(bot, target) 40 | if autils.rank(bot, target, msg.chat.id) > 2 then 41 | table.insert(output, name .. ' is too privileged to be banned.') 42 | else 43 | local success, result = bindings.kickChatMember{ 44 | chat_id = msg.chat.id, 45 | user_id = target, 46 | until_date = duration + os.time() 47 | } 48 | if success then 49 | table.insert(output, name .. ' has been banned for ' .. 50 | utilities.tiem.print(duration) .. '.') 51 | banned_users:add(target, reason or true) 52 | else 53 | table.insert(output, 'Error banning ' .. name .. ': ' .. 54 | result.description) 55 | end 56 | end 57 | end 58 | end 59 | 60 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 61 | if #banned_users > 0 then 62 | autils.log(bot, { 63 | chat_id = msg.chat.id, 64 | targets = banned_users, 65 | action = 'Banned for '..utilities.tiem.print(duration), 66 | source_user = msg.from, 67 | reason = reason 68 | }) 69 | end 70 | end 71 | 72 | return P 73 | -------------------------------------------------------------------------------- /otouto/plugins/admin/unhammer.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | unhammer.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local anise = require('anise') 8 | local utilities = require('otouto.utilities') 9 | local autils = require('otouto.autils') 10 | 11 | local P = {} 12 | 13 | function P:init(bot) 14 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 15 | :t('unhammer', true).table 16 | self.command = 'unhammer' 17 | self.privilege = 4 18 | self.targeting = true 19 | end 20 | 21 | function P:action(bot, msg, group) 22 | local targets, output, reason = autils.targets(bot, msg) 23 | local unhammered_users = anise.set() 24 | 25 | for target, _ in pairs(targets) do 26 | local user = utilities.user(bot, target) 27 | -- Reset the global antilink counter. 28 | user.data.antilink = nil 29 | if user.data.hammered then 30 | user.data.hammered = nil 31 | unhammered_users:add(target, reason or true) 32 | table.insert(output, user:name() .. ' is no longer globally banned.') 33 | else 34 | table.insert(output, user:name() .. ' is not globally banned.') 35 | end 36 | end 37 | 38 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 39 | if #unhammered_users > 0 then 40 | autils.log(bot, { 41 | -- Do not send the chat ID from PMs or private groups. 42 | chat_id = group and group.data.admin 43 | and not group.data.admin.flags.private and msg.chat.id, 44 | targets = unhammered_users, 45 | action = "Unhammered", 46 | source_user = msg.from, 47 | reason = reason 48 | }) 49 | end 50 | end 51 | 52 | return P 53 | -------------------------------------------------------------------------------- /otouto/plugins/admin/unrestrict.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | unrestrict.lua 3 | Copyright 2018 topkecleon 4 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 5 | ]]-- 6 | 7 | local bindings = require('extern.bindings') 8 | local utilities = require('otouto.utilities') 9 | local autils = require('otouto.autils') 10 | 11 | local P = {} 12 | 13 | function P:init(bot) 14 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 15 | :t('unrestrict', true):t('unmute', true):t('unban', true).table 16 | self.command = 'unrestrict' 17 | self.doc = "Unban a target or targets. Each target is removed from the \z 18 | group's ban list and unrestricted on the Telegram level. Globally banned \z 19 | users will still be unable to access the group (see /help antihammer). \z 20 | \nAliases: /unmute, /unban." 21 | self.doc = self.doc:gsub('/', bot.config.cmd_pat) 22 | self.privilege = 2 23 | self.administration = true 24 | self.targeting = true 25 | end 26 | 27 | function P:action(bot, msg, group) 28 | local targets, output = autils.targets(bot, msg) 29 | for target, _ in pairs(targets) do 30 | local name = utilities.lookup_name(bot, target) 31 | bindings.restrictChatMember{ 32 | chat_id = msg.chat.id, 33 | user_id = target, 34 | can_send_other_messages = true, 35 | can_add_web_page_previews = true 36 | } 37 | local admin = group.data.admin 38 | admin.strikes[target] = nil 39 | if admin.bans[target] then 40 | admin.bans[target] = nil 41 | table.insert(output, name .. 42 | ' has been unbanned and unrestricted.') 43 | else 44 | table.insert(output, name .. ' has been unrestricted.') 45 | end 46 | if bot.database.userdata.hammered[target] then 47 | table.insert(output, name .. 48 | (' is globally banned. See %shelp antihammer.') 49 | :format(bot.config.cmd_pat)) 50 | end 51 | end 52 | utilities.send_reply(msg, table.concat(output, '\n'), 'html') 53 | end 54 | 55 | return P 56 | -------------------------------------------------------------------------------- /otouto/plugins/core/about.fnl: -------------------------------------------------------------------------------- 1 | ;; about.fnl 2 | ;; Returns owner-configured information related to the bot and a link to the 3 | ;; source code. 4 | 5 | ;; Copyright 2018 topkecleon 6 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | 8 | (require-macros :anise.macros) 9 | (require* otouto.utilities) 10 | 11 | { 12 | :init (fn [self bot] 13 | (set self.command "about") 14 | (set self.doc "Returns information about the bot") 15 | (set self.text (f-str "{bot.config.about_text}\z 16 | \nBased on otouto v{bot.version} by topkecleon.")) 17 | (set self.triggers (utilities.make_triggers bot [] :about :start)) 18 | nil) 19 | 20 | :action (fn [self, bot, msg] 21 | (utilities.send_message msg.chat.id self.text true nil :html) 22 | (values)) 23 | } 24 | -------------------------------------------------------------------------------- /otouto/plugins/core/control.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | control.lua 3 | Provides various commands to manage the bot. 4 | 5 | /reload [-config] 6 | Reloads the bot, optionally without reloading config. 7 | 8 | /halt 9 | Safely stops the bot. 10 | 11 | /do 12 | Runs multiple, newline-separated commands as if they were individual 13 | messages. 14 | 15 | Copyright 2016 topkecleon 16 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 17 | ]]-- 18 | 19 | local anise = require('anise') 20 | 21 | local utilities = require('otouto.utilities') 22 | 23 | local control = {} 24 | 25 | function control:init(bot) 26 | local cmd_pat = bot.config.cmd_pat 27 | self.cmd_pat = cmd_pat 28 | self.triggers = utilities.triggers(bot.info.username, cmd_pat, 29 | {'^'..cmd_pat..'do'}):t('hotswap', true):t('reload', true):t('halt').table 30 | 31 | bot.database.control = bot.database.control or {} 32 | -- Ability to send the bot owner a message after a successful restart. 33 | if bot.database.control.on_start then 34 | utilities.send_message(bot.database.control.on_start.id, bot.database.control.on_start.text) 35 | bot.database.control.on_start = nil 36 | end 37 | end 38 | 39 | function control:action(bot, msg) 40 | 41 | if msg.from.id ~= bot.config.admin then 42 | return 43 | end 44 | 45 | if msg.date < os.time() - 2 then return end 46 | 47 | local cmd_pat = self.cmd_pat 48 | if msg.text_lower:match('^'..cmd_pat..'hotswap') then 49 | local errs = {} 50 | local init = false 51 | for modname in anise.split_str_iter(utilities.input(msg.text)) do 52 | if modname == '!' then 53 | init = true 54 | else 55 | local mod, err = anise.hotswap(modname) 56 | if err ~= nil then 57 | table.insert(errs, err) 58 | end 59 | if init then 60 | mod:init(bot) 61 | end 62 | end 63 | end 64 | local reply = "Modules reloaded!" 65 | if #errs ~= 0 then 66 | reply = reply .. '\nErrors:\n' .. table.concat(errs, '\n') 67 | end 68 | utilities.send_reply(msg, reply) 69 | elseif msg.text_lower:match('^'..cmd_pat..'reload') then 70 | for pac, _ in pairs(package.loaded) do 71 | if pac:match('^otouto%.plugins%.') then 72 | package.loaded[pac] = nil 73 | end 74 | end 75 | package.loaded['extern.bindings'] = nil 76 | package.loaded['otouto.utilities'] = nil 77 | package.loaded['anise'] = nil 78 | package.loaded['otouto.autils'] = nil 79 | if not msg.text_lower:match('%-config') then 80 | package.loaded['config'] = nil 81 | bot.config = require('config') 82 | end 83 | bot.database.control.on_start = { 84 | id = msg.chat.id, 85 | text = 'Bot reloaded!' 86 | } 87 | bot:init() 88 | elseif msg.text_lower:match('^'..cmd_pat..'halt') then 89 | bot.is_started = false 90 | utilities.send_reply(msg, 'Stopping bot!') 91 | bot.database.control.on_start = { 92 | id = msg.chat.id, 93 | text = 'Bot started!' 94 | } 95 | elseif msg.text_lower:match('^'..cmd_pat..'do') then 96 | local input = msg.text_lower:match('^'..cmd_pat..'do\n(.+)') 97 | if not input then 98 | utilities.send_reply(msg, 'usage: ```\n'..cmd_pat..'do\n'..cmd_pat..'command \n...\n```', true) 99 | return 100 | end 101 | for command in (input..'\n'):gmatch('(.-)\n+') do 102 | command = anise.trim(command) 103 | msg.text = command 104 | bot:on_message(msg) 105 | end 106 | end 107 | 108 | end 109 | 110 | return control 111 | -------------------------------------------------------------------------------- /otouto/plugins/core/delete_messages.fnl: -------------------------------------------------------------------------------- 1 | ;; delete_messages.fnl 2 | ;; Provides a "later" job to delete messages. 3 | 4 | (local bindings (require "extern.bindings")) 5 | 6 | { 7 | :later (fn [_self _bot param] 8 | (bindings.deleteMessage { 9 | :chat_id param.chat_id 10 | :message_id param.message_id})) 11 | } 12 | 13 | ;; (: bot :do_later :core.delete_messages when { 14 | ;; :chat_id msg.chat.id 15 | ;; :message_id msg.message_id}) 16 | -------------------------------------------------------------------------------- /otouto/plugins/core/disable_plugins.fnl: -------------------------------------------------------------------------------- 1 | ;; disable_plugins.fnl 2 | ;; This plugin manages the list of disabled plugins for a group. Put this 3 | ;; anywhere in the plugin ordering. 4 | 5 | ;; Copyright 2017 bb010g 6 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | 8 | (require-macros :anise.macros) 9 | (require* anise 10 | extern.bindings 11 | otouto.utilities) 12 | 13 | { 14 | :init (fn [self bot] 15 | (set self.command "(disable|enable) …") 16 | (set self.doc "Sets whether plugins are enabled or disabled in a group. You must have ban \z 17 | permissions to use this.\n\n\z 18 | If no plugins are provided, currently disabled plugins are listed.") 19 | (set self.triggers (utilities.make_triggers bot [] [:disable true] [:enable true])) 20 | (values)) 21 | 22 | :get_disabled (fn [disabled chat_str create] 23 | (var chat_disabled (. disabled chat_str)) 24 | (when (= chat_disabled nil) 25 | (set chat_disabled {}) 26 | (when create (tset disabled chat_str chat_disabled))) 27 | chat_disabled) 28 | 29 | :blacklist { 30 | :core.about true 31 | :core.control true 32 | :core.delete_messages true 33 | :core.disable_plugins true 34 | :core.end_forwards true 35 | :core.group_info true 36 | :core.group_whitelist true 37 | :core.luarun true 38 | :core.paged_lists true 39 | :core.user_blacklist true 40 | :core.user_info true 41 | :core.user_lists true 42 | } 43 | 44 | :toggle (fn [self named_plugins chat_disabled enable pnames] 45 | (let [blacklist self.blacklist 46 | disabled {} 47 | enabled {} 48 | not_found {} 49 | blacklisted {}] 50 | (each [_ pname (pairs pnames)] 51 | (if (not (. named_plugins pname)) 52 | (table.insert not_found pname) 53 | (. blacklist pname) 54 | (table.insert blacklisted pname) 55 | (and enable (. chat_disabled pname)) 56 | (do (tset chat_disabled pname nil) 57 | (table.insert enabled pname)) 58 | (and (not enable) (not (. chat_disabled pname))) 59 | (do (tset chat_disabled pname true) 60 | (table.insert disabled pname)) 61 | ; else 62 | nil)) 63 | (values disabled enabled not_found blacklisted))) 64 | 65 | :action (fn [self bot msg] 66 | (local chat_id msg.chat.id) 67 | (local chat_str (tostring chat_id)) 68 | (local input (utilities.input_from_msg msg)) 69 | (local disabled_plugins bot.database.disabled_plugins) 70 | (if (not input) 71 | (let [chat_disabled (self.get_disabled disabled_plugins chat_str false) 72 | disabled (anise.keys chat_disabled)] 73 | (if (not (. disabled 1)) 74 | (do (utilities.send_message chat_id "All plugins are enabled.") nil) 75 | (let [output (.. "Disabled plugins:\n• " (table.concat disabled "\n• "))] 76 | (utilities.send_message chat_id output true nil :html) 77 | nil))) 78 | (let [chat_disabled (self.get_disabled disabled_plugins chat_str true) 79 | (cm_success chat_member) (bindings.getChatMember {:chat_id chat_id :user_id msg.from.id}) 80 | chat_member (and cm_success chat_member.result)] 81 | (if 82 | (not cm_success) 83 | (do (utilities.send_reply msg "Couldn't fetch permissions.") nil) 84 | (not (or chat_member.can_restrict_members (= chat_member.status :creator))) 85 | (do (utilities.send_reply msg "You need ban permissions.") nil) 86 | ; else 87 | (let [enable 88 | (and-or (: msg.text_lower :match (f-str "^{bot.config.cmd_pat}enable")) true false) 89 | pnames (anise.split_str input) 90 | (disabled enabled not_found blacklisted) 91 | (: self :toggle bot.named_plugins chat_disabled enable pnames) 92 | output {} 93 | sep ", "] 94 | (when (= (next chat_disabled) nil) 95 | (tset disabled_plugins chat_str nil)) 96 | (table.insert output 97 | (if (. blacklisted 1) 98 | (.. "Blacklisted: " (table.concat blacklisted sep)) 99 | (. disabled 1) 100 | (.. "Disabled: " (table.concat disabled sep)) 101 | (. enabled 1) 102 | (.. "Enabled: " (table.concat enabled sep)) 103 | (. not_found 1) 104 | (.. "Not found: " (table.concat not_found sep)) 105 | ; else 106 | "Nothing changed.")) 107 | (utilities.send_reply msg (table.concat output "\n") :html) 108 | nil))))) 109 | } 110 | -------------------------------------------------------------------------------- /otouto/plugins/core/end_forwards.fnl: -------------------------------------------------------------------------------- 1 | ;; end_forwards.fnl 2 | ;; This plugin keeps forwarded messages from hitting any plugin after it in the 3 | ;; load order. Just put this wherever, close to the top. The only plugins which 4 | ;; need to see forwarded messages are usually administration-related. 5 | 6 | ;; Copyright 2016 topkecleon 7 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 8 | 9 | { :triggers [""] :action (fn [_ _ msg] (and (not msg.forward_from) :continue)) } 10 | -------------------------------------------------------------------------------- /otouto/plugins/core/group_info.fnl: -------------------------------------------------------------------------------- 1 | ; group_info.fnl 2 | ; Stores group info in database.groupdata.info. 3 | ; Also logs changes of the names of administrated groups. 4 | 5 | ; Copyright 2018 topkecleon 6 | ; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | 8 | (require-macros :anise.macros) 9 | (require* otouto.autils) 10 | 11 | { 12 | :triggers [""] 13 | :error false 14 | 15 | :init (fn [_ bot] (tset? bot.database.groupdata :info {})) 16 | 17 | :action (fn [_ bot msg group] 18 | (when group (if (and group.data.admin 19 | (not group.data.admin.flags.private) 20 | group.data.info 21 | (~= msg.chat.title group.data.info.title)) 22 | (autils.log bot { 23 | :chat_id msg.chat.id 24 | :action "Title changed" 25 | :reason msg.chat.title 26 | :source_user (if msg.new_chat_title msg.from)})) 27 | (set group.data.info msg.chat)) 28 | :continue) 29 | 30 | :list { 31 | :name :groups 32 | :title "Known Groups" 33 | :type :groupdata 34 | :key :info 35 | :sudo true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /otouto/plugins/core/group_whitelist.fnl: -------------------------------------------------------------------------------- 1 | ; group_whitelist.fnl 2 | ; Basic whitelisting for groups. The group is whitelisted when the bot is added 3 | ; by one of its administrators. The group is unwhitelisted when the bot is 4 | ; removed. The bot will leave non-whitelisted groups. 5 | 6 | ; Copyright 2018 topkecleon 7 | ; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 8 | 9 | (require-macros :anise.macros) 10 | (require* extern.bindings) 11 | 12 | { 13 | :init (fn [_ bot] (set? bot.database.groupdata.whitelisted {})) 14 | :triggers [""] 15 | :error false 16 | 17 | :action (fn [_ bot msg group user] 18 | (if (not group) :continue 19 | ;else 20 | (do (if (and msg.left_chat_member (= msg.left_chat_member.id bot.info.id)) 21 | (set group.data.whitelisted nil) 22 | (and msg.new_chat_member 23 | (= msg.new_chat_member.id bot.info.id) 24 | (> (: user :rank bot) 3)) 25 | (set group.data.whitelisted true)) 26 | (if (or group.data.whitelisted group.data.admin) 27 | :continue 28 | (bindings.leaveChat {:chat_id msg.chat.id}))))) 29 | 30 | :list { 31 | :name :whitelist 32 | :title "Whitelisted Groups" 33 | :type :groupdata 34 | :key :whitelisted 35 | :sudo true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /otouto/plugins/core/help.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | help.lua 3 | Returns a list of commands, or command-specific help. 4 | 5 | Copyright 2016 topkecleon 6 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | ]]-- 8 | 9 | local utilities = require('otouto.utilities') 10 | local autils = require('otouto.autils') 11 | 12 | local help = {} 13 | 14 | function help:init(bot) 15 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 16 | :t('help', true):t('h', true).table 17 | self.command = 'help [command]' 18 | self.doc = 'Returns usage information for a given command.' 19 | 20 | self.glossaries = {} 21 | for name, glossary in pairs({ 22 | autils = autils and autils.glossary, 23 | }) do 24 | if glossary then self.glossaries[name] = glossary end 25 | end 26 | 27 | self.commandlist = {} 28 | self:generate_text(bot.config.cmd_pat) 29 | 30 | self.proposition = 'Please message me privately for a list of commands.' 32 | end 33 | 34 | function help:action(bot, msg) 35 | local input = utilities.input(msg.text_lower) 36 | if input then 37 | input = input:lower():gsub('^' .. bot.config.cmd_pat, '') 38 | for _, plugin in ipairs(bot.plugins) do 39 | if plugin.help_word and input:match(plugin.help_word) then 40 | utilities.send_plugin_help(msg.chat.id, nil, bot.config.cmd_pat, plugin) 41 | return 42 | end 43 | end 44 | -- If there are no plugin matches, check the glossaries. 45 | for _glossary_name, glossary in pairs(self.glossaries) do 46 | for name, entry in pairs(glossary) do 47 | if input:match(name) then 48 | utilities.send_help_for(msg.chat.id, nil, name, entry) 49 | return 50 | end 51 | end 52 | end 53 | utilities.send_reply(msg, 'Sorry, there is no help for that command.') 54 | 55 | elseif not utilities.send_message(msg.from.id, self.text, true, nil, 'html') then 56 | -- Attempt to send the help message via PM. 57 | -- If msg is from a group, tell the group whether the PM was successful. 58 | utilities.send_reply(msg, self.proposition, 'html') 59 | 60 | elseif msg.chat.type ~= 'private' then 61 | utilities.send_reply(msg, 62 | 'I have sent you the requested information in a private message.') 63 | end 64 | end 65 | 66 | function help:on_plugins_load(bot, plugins) 67 | for _, plugin in pairs(plugins) do 68 | if plugin.command then 69 | local s = plugin.command 70 | if plugin.targeting then 71 | s = s .. '*' 72 | if plugin.duration then 73 | s = s .. '†' 74 | end 75 | end 76 | table.insert(self.commandlist, {plugin.name, s}) 77 | if plugin.doc and not plugin.help_word then 78 | plugin.help_word = '^' .. utilities.get_word(plugin.command, 1) 79 | .. '$' 80 | end 81 | end 82 | end 83 | table.sort(self.commandlist, function (a, b) return a[2] < b[2] end) 84 | self:generate_text(bot.config.cmd_pat) 85 | end 86 | 87 | function help:on_plugins_unload(bot, plugins) 88 | for _, plugin in pairs(plugins) do 89 | for i, pair in ipairs(self.commandlist) do 90 | if pair[1] == plugin.name then 91 | table.remove(self.commandlist, i) 92 | break 93 | end 94 | end 95 | end 96 | self:generate_text(bot.config.cmd_pat) 97 | end 98 | 99 | function help:generate_text(cmd_pat) 100 | local comlist = '\n' 101 | for _, pair in ipairs(self.commandlist) do 102 | comlist = comlist .. '• ' .. cmd_pat .. pair[2] .. '\n' 103 | end 104 | comlist = comlist .. 105 | "Arguments: [optional]\ 106 | * Targets may be specified via reply, username, mention, or ID. \z 107 | In a reply command, a reason can be given after the command. Otherwise, it must be on a new line.\ 108 | † A duration may be specified before the reason, in seconds or in the format 5d12h30m15s." 109 | self.text = 'Available commands:' .. utilities.html_escape(comlist) 110 | end 111 | 112 | return help 113 | -------------------------------------------------------------------------------- /otouto/plugins/core/luarun.fnl: -------------------------------------------------------------------------------- 1 | ;; luarun.lua 2 | ;; Allows the bot owner to run arbitrary Lua or Fennel code inside the bot instance. 3 | ;; "/return" is an alias for "/lua return". 4 | 5 | ;; Copyright 2016 topkecleon 6 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | 8 | (require-macros :anise.macros) 9 | (require* otouto.utilities 10 | fennel 11 | fennelview 12 | serpent 13 | socket.url) 14 | 15 | { 16 | :init (fn [self bot] 17 | (set self.triggers (utilities.make_triggers bot [] [:lua true] [:fnl true] [:return true])) 18 | (set self.err_msg (fn [x] (.. "Error:\n" (tostring x)))) 19 | (values)) 20 | 21 | :fennel_preamble "\z 22 | (require-macros :anise.macros)\ 23 | (require* anise\ 24 | otouto.autils\ 25 | extern.bindings\ 26 | otouto.utilities\ 27 | fennel\ 28 | fennelview\ 29 | serpent\ 30 | socket.http\ 31 | socket.url\ 32 | ssl.https\ 33 | (rename dkjson json))" 34 | 35 | :lua_preamble "\z 36 | local anise = require('anise')\ 37 | local autils = require('otouto.autils')\ 38 | local bindings = require('extern.bindings')\ 39 | local utilities = require('otouto.utilities')\ 40 | local fennel = require('fennel')\ 41 | local fennelview = require('fennelview')\ 42 | local serpent = require('serpent')\ 43 | local http = require('socket.http')\ 44 | local url = require('socket.url')\ 45 | local https = require('ssl.https')\ 46 | local json = require('dkjson')" 47 | 48 | :action (fn [self bot msg group user] 49 | (if (~= msg.from.id bot.config.admin) 50 | :continue 51 | (let [input (utilities.input msg.text)] 52 | (if (not input) 53 | (do (utilities.send_reply msg "Please enter a string to load.") nil) 54 | (let [mode (if (: msg.text_lower :match (f-str "^{bot.config.cmd_pat}fnl")) 55 | :fennel 56 | (: msg.text_lower :match (f-str "^{bot.config.cmd_pat}return")) 57 | :lua_expr 58 | ; else 59 | :lua) 60 | code 61 | (if (= mode :fennel) 62 | (fennel.compileString 63 | (f-str "{self.fennel_preamble}\n(fn [bot msg group user] {input})")) 64 | ; Lua 65 | (let [input (if (= mode :lua_expr) (f-str "return {input}") input)] 66 | (f-str "{self.lua_preamble}\nreturn function (bot, msg, group, user)\n{input}\nend"))) 67 | (output err) (load code) 68 | text (if err 69 | (utilities.html_escape err) 70 | (: self :format_output mode err 71 | (xpcall (output) self.err_msg bot msg group user)))] 72 | (utilities.send_reply msg (f-str "{text}") :html) 73 | nil))))) 74 | 75 | :format_value (fn [mode val depth] 76 | (if (= mode :fennel) 77 | (fennelview val {:depth depth}) 78 | (or (= mode :lua) (= mode :lua_expr)) 79 | (serpent.block val {:comment false :depth depth}) 80 | ; else 81 | "Unknown format mode.")) 82 | 83 | :format_output (fn [self mode ...] 84 | (local len (- (select :# ...) 2)) 85 | (local max_length 4000) 86 | (if (= len 0) 87 | "Done!" 88 | ; else 89 | (do 90 | (var depth 5) 91 | (var text nil) 92 | (while (or (not text) (and (> (string.len text) 4000) (> depth 0))) 93 | (set text {}) 94 | (for [i 1 len] 95 | (tset text i (utilities.html_escape 96 | (self.format_value mode (select (+ i 2) ...) depth)))) 97 | (set text (table.concat text "\n")) 98 | (set depth (- depth 1))) 99 | (if (> (string.len text) 4000) 100 | "Output is too large to print." 101 | text)))) 102 | } 103 | -------------------------------------------------------------------------------- /otouto/plugins/core/paged_lists.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | paged_lists.lua 3 | Support for inline buttons and expiration for paged lists. 4 | 5 | This has become such a convoluted piece of code. Here's a quick rundown. 6 | P:send stores a list object and generates a page (via P:page) and sends it, 7 | schedules it for deletion (P:later), and returns its results. 8 | P:action is for local admins (with can_edit_info perm) to configure page 9 | length, list duration, and whether (most) lists are sent in private. 10 | P:callback_action is to handle keyboard events on paged lists, such as 11 | scrolling (generating a new page with P:page) and deleting the keyboard. 12 | 13 | Copyright 2018 topkecleon 14 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 15 | ]] 16 | 17 | local bindings = require('extern.bindings') 18 | local utilities = require('otouto.utilities') 19 | local anise = require('extern.anise') 20 | 21 | local P = {} 22 | 23 | function P:init(bot) 24 | self.kb = {} 25 | self.kb.default = utilities.keyboard('inline_keyboard'):row() 26 | :button('◀', 'callback_data', self.name .. ' prev') 27 | :button('▶', 'callback_data', self.name .. ' next') 28 | :button('🗑', 'callback_data', self.name .. ' del'):serialize() 29 | 30 | self.kb.private = utilities.keyboard('inline_keyboard'):row() 31 | :button('◀', 'callback_data', self.name .. ' prev') 32 | :button('▶', 'callback_data', self.name .. ' next'):serialize() 33 | 34 | bot.database.paged_lists = bot.database.paged_lists or {} 35 | bot.database.groupdata.plists = bot.database.groupdata.plists or {} 36 | bot.database.userdata.plists = bot.database.userdata.plists or {} 37 | self.db = bot.database.paged_lists 38 | 39 | self.default = bot.config.paged_lists 40 | 41 | -- somewhat consistent message width, kinda gross, really sorry 42 | self.blank = string.rep(utilities.char.braille_space, 39) 43 | 44 | -- P.action will let local admins with can_change_info configure the length 45 | -- of pages, duration of lists, and whether or not lists will be sent 46 | -- publicly or privately. 47 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 48 | :t('listconf', true):t('plists', true).table 49 | self.command = 'listconf ' 50 | self.doc = ("Change a setting for paged lists in the group. You must have \z 51 | permission to edit group info to use this command. \n\n\z 52 | Settings (defaults parenthesized): \n\z 53 | • length (%s) - The number of items per page. \n\z 54 | • duration (%s) - The period of time after a list is created \z 55 | that its keyboard should expire. This may be a number of \z 56 | seconds or an interval in the tiem format (see /help tiem). \n\z 57 | • private (%s) - Whether (most) lists should be sent \z 58 | in private rather than to the group. This can be set to \z 59 | true or false. \n\n\z 60 | If no setting is given, this text is returned. If a setting is named \z 61 | without a value, its current value will be returned.") 62 | :format( 63 | self.default.page_length, 64 | utilities.tiem.print(self.default.list_duration), 65 | tostring(self.default.private_lists) 66 | ) 67 | end 68 | 69 | -- Send a page, store the list, and schedule it for expiration. 70 | -- title and chat_id are optional. chat_id defaults to msg.chat.id unless 71 | -- private_lists is set. 72 | function P:send(bot, msg, array, title, chat_id) 73 | local plists = 74 | bot.database.groupdata.plists[tostring(chat_id or msg.chat.id)] or {} 75 | 76 | if not chat_id then 77 | -- private_lists can be true, false, or nil. 78 | -- True or false overrides the global default, nil does not. 79 | if 80 | plists.private_lists == true 81 | or plists.private_lists == nil and self.default.private_lists 82 | then 83 | chat_id = msg.from.id 84 | plists = {} 85 | else 86 | chat_id = msg.chat.id 87 | end 88 | end 89 | 90 | local list = { 91 | array = array, 92 | title = title, 93 | chat_id = chat_id, 94 | owner = msg.from, 95 | page = 1, 96 | page_length = plists.page_length or self.default.page_length 97 | } 98 | list.page_count = math.ceil(#list.array / list.page_length) 99 | if list.page_count <= 1 then 100 | list.kb = nil 101 | elseif list.owner.id == list.chat_id then 102 | list.kb = 'private' 103 | else 104 | list.kb = 'default' 105 | end 106 | 107 | local success, result = bindings.sendMessage{ 108 | chat_id = list.chat_id, 109 | text = self:page(list), 110 | parse_mode = 'html', 111 | disable_web_page_preview = true, 112 | reply_markup = self.kb[list.kb] 113 | } 114 | 115 | if success then 116 | list.message_id = result.result.message_id 117 | self.db[tostring(list.message_id)] = list 118 | bot:do_later( 119 | self.name, 120 | os.time() + (plists.list_duration or self.default.list_duration), 121 | list.message_id 122 | ) 123 | end 124 | return success, result 125 | end 126 | 127 | -- Generate a page. 128 | function P:page(list) 129 | local output = {} 130 | if list.title then 131 | table.insert( 132 | output, 133 | '' .. utilities.html_escape(list.title) .. '' 134 | ) 135 | end 136 | 137 | local last = list.page * list.page_length 138 | if #list.array == 0 then 139 | table.insert(output, 'This list is empty!') 140 | else 141 | table.insert( 142 | output, 143 | '• ' .. table.concat( 144 | anise.move(list.array, last - list.page_length + 1, last, 1, {}), 145 | '\n• ' 146 | ) 147 | ) 148 | end 149 | 150 | if list.page_count > 1 then 151 | table.insert(output, self.blank) 152 | table.insert( 153 | output, 154 | string.format( 155 | 'Page %d of %d | %d total', 156 | list.page, 157 | list.page_count, 158 | #list.array 159 | ) 160 | ) 161 | end 162 | 163 | return table.concat(output, '\n') 164 | end 165 | 166 | -- For P.action 167 | P.conf = { 168 | length = function(plists, num) 169 | if not num then 170 | return plists.page_length 171 | elseif tonumber(num) then 172 | num = math.floor(math.abs(tonumber(num))) 173 | if num < 1 or num > 40 then 174 | return 'The range for page length is 1-40.' 175 | else 176 | plists.page_length = num 177 | return 'Page length is now ' .. num .. '.' 178 | end 179 | else 180 | return 'Page length must be a number.' 181 | end 182 | end, 183 | 184 | duration = function(plists, dur) 185 | if not dur then 186 | return utilities.tiem.print(plists.list_duration) 187 | elseif utilities.tiem.deformat(dur) then 188 | local interval = utilities.tiem.deformat(dur) 189 | if interval < 60 or interval > 86400 then 190 | return 'The range for list duration is one minute through one day.' 191 | else 192 | plists.list_duration = interval 193 | return 'The list duration is now ' .. 194 | utilities.tiem.print(interval) .. '.' 195 | end 196 | else 197 | return 'The list duration must be an interval (see /help tiem).' 198 | end 199 | end, 200 | 201 | private = function(plists, bool) 202 | if not bool then 203 | bool = tostring(not plists.private_lists) 204 | end 205 | 206 | if bool:lower() == 'true' then 207 | plists.private_lists = true 208 | return 'Most lists will now be sent privately.' 209 | elseif bool:lower() == 'false' then 210 | plists.private_lists = false 211 | return 'Lists will no longer be sent privately.' 212 | else 213 | return 'This setting is true/false.' 214 | end 215 | end 216 | } 217 | 218 | -- For local admins to configure paged lists in the group. 219 | function P:action(bot, msg, group, user) 220 | local _, result = bindings.getChatMember{ 221 | chat_id = msg.chat.id, 222 | user_id = msg.from.id 223 | } 224 | if result.result.can_change_info or result.result.status == 'creator' then 225 | local plists 226 | if group and group.data.plists then 227 | plists = group.data.plists 228 | elseif user and user.data.plists then 229 | plists = user.data.plists 230 | else 231 | plists = anise.clone(self.default) 232 | end 233 | 234 | local setting = utilities.get_word(msg.text:lower(), 2) 235 | setting = setting and setting:lower() 236 | if setting and self.conf[setting] then 237 | if group then 238 | group.data.plists = plists 239 | else 240 | user.data.plists = plists 241 | end 242 | 243 | local value = utilities.get_word(msg.text:lower(), 3) 244 | utilities.send_reply(msg, self.conf[setting](plists, value)) 245 | else 246 | local output = utilities.plugin_help(bot.config.cmd_pat, self) .. 247 | ("\n\nCurrent settings: \n\z 248 | • length - %s \n\z 249 | • duration - %s \n\z 250 | • private - %s"):format( 251 | plists.page_length, 252 | utilities.tiem.print(plists.list_duration), 253 | tostring(plists.private_lists) 254 | ) 255 | utilities.send_reply(msg, output, 'html') 256 | end 257 | else 258 | utilities.send_reply(msg, 'You need permission to edit group info.') 259 | end 260 | end 261 | 262 | -- For the inline keyboard buttons on lists. 263 | function P:callback_action(_, query) 264 | local list = self.db[tostring(query.message.message_id)] 265 | if not list then -- Remove the keyboard from a list we're not storing. 266 | bindings.editMessageReplyMarkup{ 267 | chat_id = query.message.chat.id, 268 | message_id = query.message.message_id 269 | } 270 | elseif query.from.id ~= list.owner.id then 271 | bindings.answerCallbackQuery{ 272 | callback_query_id = query.id, 273 | text = 'Only ' .. list.owner.first_name .. ' may use this keyboard.' 274 | } 275 | else 276 | local command = utilities.get_word(query.data, 2) 277 | if command == 'del' then 278 | bindings.deleteMessage{ 279 | chat_id = list.chat_id, 280 | message_id = list.message_id 281 | } 282 | self.db[tostring(query.message.message_id)] = nil 283 | 284 | elseif command == 'next' then 285 | if list.page == list.page_count then 286 | list.page = 1 287 | else 288 | list.page = list.page + 1 289 | end 290 | 291 | elseif command == 'prev' then 292 | if list.page == 1 then 293 | list.page = list.page_count 294 | else 295 | list.page = list.page - 1 296 | end 297 | end 298 | bindings.editMessageText{ 299 | chat_id = list.chat_id, 300 | message_id = list.message_id, 301 | text = self:page(list), 302 | parse_mode = 'html', 303 | disable_web_page_preview = true, 304 | reply_markup = self.kb[list.kb] 305 | } 306 | end 307 | end 308 | 309 | -- For the expiration of lists. 310 | function P:later(_, list_id) 311 | local list = self.db[tostring(list_id)] 312 | if list then 313 | if list.page_count ~= 1 then 314 | bindings.editMessageReplyMarkup{ 315 | chat_id = list.chat_id, 316 | message_id = list.message_id 317 | } 318 | end 319 | self.db[tostring(list_id)] = nil 320 | end 321 | end 322 | 323 | return P 324 | -------------------------------------------------------------------------------- /otouto/plugins/core/ping.fnl: -------------------------------------------------------------------------------- 1 | ;; ping.fnl 2 | ;; Sends a response, then updates it with the time it took to send. 3 | 4 | ;; Copyright 2018 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* socket 9 | extern.bindings 10 | otouto.utilities) 11 | 12 | { 13 | :init (fn [self bot] 14 | (set self.command "ping") 15 | (set self.doc "Pong!\z 16 | \nUpdates the message with the time used, in seconds, to send the response.") 17 | (set self.triggers (utilities.make_triggers bot [] :ping :marco :annyong)) 18 | (values)) 19 | 20 | :action (fn [self bot msg] 21 | (local a (socket.gettime)) 22 | (local answer (if (: msg.text_lower :match :marco) 23 | "Polo!" 24 | (: msg.text_lower :match :annyong) 25 | "Annyong." 26 | ; else 27 | "Pong!")) 28 | (local (success message) (utilities.send_reply msg answer)) 29 | (local b (string.format "%.3f" (- (socket.gettime) a))) 30 | (if success (bindings.editMessageText { 31 | :chat_id msg.chat.id 32 | :message_id message.result.message_id 33 | :text (f-str "{answer}\n`{b}`") 34 | :parse_mode :Markdown 35 | }))) 36 | } 37 | -------------------------------------------------------------------------------- /otouto/plugins/core/user_blacklist.fnl: -------------------------------------------------------------------------------- 1 | ;; blacklist.lua 2 | ;; Allows the bot owner to block individuals from using the bot. 3 | 4 | ;; Load this before any plugin you want to block blacklisted users from. 5 | 6 | ;; Copyright 2016 topkecleon 7 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 8 | 9 | (require-macros :anise.macros) 10 | (require* otouto.utilities 11 | otouto.autils) 12 | 13 | { 14 | :init (fn [self bot] 15 | (set self.triggers [""]) 16 | ;(set self.error false) 17 | (tset? bot.database.userdata :blacklisted {})) 18 | 19 | :blacklist (fn [userdata] 20 | (if userdata.blacklisted 21 | " is already blacklisted." 22 | (do 23 | (set userdata.blacklisted true) 24 | " has been blacklisted."))) 25 | 26 | :unblacklist (fn [userdata] 27 | (if (not userdata.blacklisted) 28 | " is not blacklisted." 29 | (do 30 | (set userdata.blacklisted nil) 31 | " has been unblacklisted."))) 32 | 33 | :action (fn [self bot msg _ user] 34 | (if ; non-owner is blacklisted 35 | (and user.data.blacklisted (~= msg.from.id bot.config.admin)) 36 | nil 37 | ; else 38 | (let [act (if (: msg.text :match (f-str "^{bot.config.cmd_pat}blacklist")) 39 | self.blacklist 40 | (: msg.text :match (f-str "^{bot.config.cmd_pat}unblacklist")) 41 | self.unblacklist 42 | ; else 43 | nil)] 44 | (if (not (and act (= msg.from.id bot.config.admin))) 45 | :continue 46 | (let [(targets errors) (autils.targets bot msg)] 47 | (each [target (pairs targets)] 48 | (table.insert errors 49 | (let [user (utilities.user bot target)] 50 | (.. (: user :name) (act user.data))))) 51 | (utilities.send_reply msg (table.concat errors "\n") :html)))))) 52 | 53 | :list { 54 | :name :blacklist 55 | :title "Blacklisted Users" 56 | :type :userdata 57 | :key :blacklisted 58 | :sudo true 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /otouto/plugins/core/user_info.fnl: -------------------------------------------------------------------------------- 1 | ;; user_info.fnl 2 | ;; Stores usernames, IDs, and display names for users seen by the bot. 3 | ;; Worthless on its own but prerequisite for some plugins. 4 | 5 | ;; config.user_info.admin_only - set to true to only store user info from 6 | ;; administrated groups. 7 | 8 | ;; Copyright 2018 topkecleon 9 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 10 | 11 | { 12 | :init (fn [self bot] 13 | (set self.triggers [""]) 14 | (set self.errors false) 15 | (local users (or bot.database.userdata.info {})) 16 | (set bot.database.userdata.info users) 17 | (tset users (tostring bot.info.id) bot.info) 18 | (set self.administration (or bot.config.user_info.admin_only nil)) 19 | (values)) 20 | 21 | :action (fn [self bot msg] 22 | (local users bot.database.userdata.info) 23 | (tset users (tostring msg.from.id) msg.from) 24 | (if msg.entities 25 | (each [_ entity (ipairs msg.entities)] 26 | (if entity.user 27 | (tset users (tostring entity.user.id) entity.user)))) 28 | (if 29 | msg.reply_to_message 30 | (tset users (tostring msg.reply_to_message.from.id) msg.reply_to_message.from) 31 | msg.forward_from 32 | (tset users (tostring msg.forward_from.id) msg.forward_from) 33 | msg.new_chat_member 34 | (tset users (tostring msg.new_chat_member.id) msg.new_chat_member) 35 | msg.left_chat_member 36 | (tset users (tostring msg.left_chat_member.id) msg.left_chat_member) 37 | ; else 38 | nil) 39 | :continue) 40 | 41 | :list { 42 | :name :users 43 | :title "Known Users" 44 | :type :userdata 45 | :key :info 46 | :sudo true 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /otouto/plugins/core/user_lists.fnl: -------------------------------------------------------------------------------- 1 | ; user_lists.fnl 2 | ; Paged lists of formatted names. 3 | 4 | (local utils (require :otouto.utilities)) 5 | (local anise (require :extern.anise)) 6 | 7 | (local load_and_unload (fn [self _ plugins] 8 | (set self.lists {}) 9 | (set self.cats {:main {} :sudo {} :admin {}}) 10 | (each [_ plugin (pairs plugins)] 11 | (when plugin.list 12 | (tset self.lists plugin.list.name plugin.list) 13 | (table.insert (if 14 | plugin.list.sudo self.cats.sudo 15 | (= plugin.list.type :admin) self.cats.admin 16 | self.cats.main) 17 | plugin.list.name))))) 18 | 19 | { 20 | :init (fn [self bot] 21 | (assert (. bot.named_plugins :core.paged_lists) 22 | (.. self.name " requires core.paged_lists")) 23 | 24 | (when bot.config.user_lists.reverse_sort (set self.gt (fn [a b] (> a b)))) 25 | 26 | (set self.triggers (utils.make_triggers bot {} [:lists? true])) 27 | (set self.command "list ") 28 | (set self.doc "Returns a paged list of users in the specified category.")) 29 | 30 | :action (fn [self bot msg group] 31 | (let [key (utils.get_word msg.text_lower 2) linfo (. self.lists key)] 32 | (if (not linfo) 33 | (utils.send_reply msg (: self :listcats bot msg) :html) 34 | (and (= linfo.type :admin) (or (not group) (not group.data.admin))) 35 | (utils.send_reply msg "This list is available in administrated groups.") 36 | (and linfo.sudo (~= msg.from.id bot.config.admin)) 37 | (utils.send_reply msg "You must be the bot owner to see this list.") 38 | ; else 39 | (let [arr (anise.sort (utils.list_names bot (. (if 40 | (= linfo.type :userdata) bot.database.userdata 41 | (= linfo.type :groupdata) bot.database.groupdata 42 | (= linfo.type :admin) group.data.admin) 43 | linfo.key)) 44 | self.gt) 45 | (success result) (: (. bot.named_plugins :core.paged_lists) 46 | :send bot msg arr linfo.title)] 47 | (if success 48 | (when (~= result.result.chat.id msg.chat.id) 49 | (utils.send_reply msg "List sent privately.")) 50 | ; else 51 | (utils.send_reply msg (.. 52 | "Please message me privately first.") :html)))))) 54 | 55 | :listcats (fn [self bot msg] 56 | (.. "Available Lists\n• " (table.concat (anise.pushcat {} 57 | self.cats.main 58 | (if (= bot.config.admin msg.from.id) self.cats.sudo) 59 | (if (. bot.database.groupdata.admin (tostring msg.chat.id)) self.cats.admin)) 60 | "\n• "))) 61 | 62 | :on_plugins_load load_and_unload 63 | :on_plugins_unload load_and_unload 64 | } 65 | -------------------------------------------------------------------------------- /otouto/plugins/user/apod.fnl: -------------------------------------------------------------------------------- 1 | ;; apod.fnl 2 | ;; Returns the NASA astronomy picture of the day, along with related text. 3 | 4 | ;; Credit to @HeitorPB. 5 | ;; Copyright 2018 topkecleon 6 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | 8 | (require-macros :anise.macros) 9 | (require* (rename dkjson json) 10 | otouto.utilities 11 | socket.url 12 | ssl.https) 13 | 14 | { 15 | :init (fn [self bot] 16 | (set self.url (.. "https://api.nasa.gov/planetary/apod?api_key=" 17 | (or bot.config.nasa_api_key "DEMO_KEY"))) 18 | 19 | (set self.command "apod [YYYY-MM-DD]") 20 | (set self.doc "Returns the latest Astronomy Picture of the Day from \z 21 | NASA.") 22 | (set self.triggers (utilities.make_triggers bot [] [:apod true])) 23 | (values)) 24 | 25 | :action (fn [self bot msg] 26 | (local input (utilities.input msg.text)) 27 | (local is_date (and input (: input :match "^%d+%-%d+%-%d+$"))) 28 | (local url (.. self.url (and-or is_date (.. "&date=" (url.escape input)) ""))) 29 | 30 | (local (jstr code) (https.request url)) 31 | (if (~= code 200) 32 | (do (utilities.send_reply msg bot.config.errors.connection) nil) 33 | (let [data (json.decode jstr)] 34 | (if data.error 35 | (do (utilities.send_reply msg bot.config.errors.results) nil) 36 | (let [output (f-str "{} ({})\n{}" 37 | (utilities.html_escape data.title) 38 | (utilities.html_escape (or data.hdurl data.url)) 39 | (or is_date (os.date "%F")) 40 | (utilities.html_escape data.explanation))] 41 | (utilities.send_message msg.chat.id output false nil :html) 42 | nil))))) 43 | } 44 | -------------------------------------------------------------------------------- /otouto/plugins/user/bible.fnl: -------------------------------------------------------------------------------- 1 | ;; bible.fnl 2 | ;; Returns Bible verses. 3 | 4 | ;; Copyright 2018 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* otouto.utilities 9 | socket.http 10 | socket.url) 11 | 12 | { 13 | :init (fn [self bot] 14 | (if (not bot.config.biblia_api_key) 15 | (io.write "Missing config value: biblia_api_key.\n\z 16 | \tuser.bible will not work without a key from http://bibliaapi.com.\n") 17 | ;else 18 | (do (set self.url "http://api.biblia.com/v1") 19 | (set self.command "bible ") 20 | (set self.doc "Returns a verse from the American Standard Version of the Bible,\z 21 | or a deuterocanonical/apocryphal verse from the King James Version.\z 22 | Results from Biblia.") 23 | (set self.triggers (utilities.make_triggers bot [] [:bible true] [:b true])))) 24 | (values)) 25 | 26 | :biblia_content (fn [self key bible passage] 27 | (http.request 28 | (f-str "{self.url}/bible/content/{bible}.txt?key={key}&passage={}" 29 | (url.escape passage)))) 30 | 31 | :action (fn [self bot msg] 32 | (local input (utilities.input_from_msg msg)) 33 | (if (not input) 34 | (do (utilities.send_plugin_help msg.chat.id msg.message_id bot.config.cmd_pat self) nil) 35 | (let [key bot.config.biblia_api_key] 36 | (var (output res) (: self :biblia_content key "ASV" input)) 37 | (when (or (not output) (~= res 200) (= (: output :len) 0)) 38 | (set (output res) (: self :biblia_content key "KJVAPOC" input))) 39 | (when (or (not output) (~= res 200) (= (: output :len) 0)) 40 | (set output bot.config.errors.results)) 41 | (when (> (: output :len) 4000) 42 | (set output "The text is too long to post here. Try being more specific.")) 43 | (utilities.send_reply msg output) 44 | nil))) 45 | } 46 | 47 | -------------------------------------------------------------------------------- /otouto/plugins/user/calc.fnl: -------------------------------------------------------------------------------- 1 | ;; calc.fnl 2 | ;; Runs mathematical expressions through the mathjs.org API. 3 | 4 | ;; Copyright 2018 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* otouto.utilities 9 | socket.url 10 | ssl.https) 11 | 12 | { 13 | :init (fn [self bot] 14 | (set self.url "https://api.mathjs.org/v4/?expr=") 15 | 16 | (set self.command "calc ") 17 | (set self.doc "Returns solutions to mathematical expressions and \z 18 | conversions between common units. Results provided by mathjs.org.") 19 | (set self.triggers (utilities.make_triggers bot [] [:calc true])) 20 | (values)) 21 | 22 | :action (fn [self bot msg] 23 | (local input (utilities.input_from_msg msg)) 24 | (if (not input) 25 | (do (utilities.send_plugin_help msg.chat.id msg.message_id bot.config.cmd_pat self) nil) 26 | (let [(data res) (.. (https.request (.. self.url (url.escape input)))) 27 | output (and-or data 28 | (f-str "{}" (utilities.html_escape data)) 29 | bot.config.errors.connection)] 30 | (utilities.send_reply msg output :html) 31 | nil))) 32 | } 33 | -------------------------------------------------------------------------------- /otouto/plugins/user/cat_fact.fnl: -------------------------------------------------------------------------------- 1 | ;; cat_fact.fnl 2 | ;; Returns cat facts. 3 | 4 | ;; Based on a plugin by matthewhesketh. 5 | ;; Copyright 2018 topkecleon 6 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | 8 | (require-macros :anise.macros) 9 | (require* (rename dkjson json) 10 | otouto.utilities 11 | ssl.https) 12 | 13 | { 14 | :init (fn [self bot] 15 | (set self.url "https://catfact.ninja/fact") 16 | 17 | (set self.command "catfact") 18 | (set self.doc "Returns a cat fact from catfact.ninja.") 19 | (set self.triggers (utilities.make_triggers bot [] ["cat_?fact"])) 20 | (values)) 21 | 22 | :action (fn [self bot msg] 23 | (local (jstr code) (https.request self.url)) 24 | (if (~= code 200) 25 | (do (utilities.send_reply msg bot.config.errors.connection) nil) 26 | (let [data (json.decode jstr) 27 | output (f-str "Cat Fact\n{}" (utilities.html_escape data.fact))] 28 | (utilities.send_message msg.chat.id output true nil :html) 29 | nil))) 30 | } 31 | -------------------------------------------------------------------------------- /otouto/plugins/user/cats.fnl: -------------------------------------------------------------------------------- 1 | ;; cats.fnl 2 | ;; Returns photos of cats from thecatapi.com. 3 | 4 | ;; Copyright 2016 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* socket.http 9 | extern.bindings 10 | otouto.utilities) 11 | 12 | { :init 13 | (fn [self bot] 14 | (when (not bot.config.cat_api_key) 15 | (io.write "Missing config value: cat_api_key.\n\z 16 | \tuser.cats will be enabled, but there are more features with a key.\n")) 17 | (set self.url 18 | (.. "http://thecatapi.com/api/images/get?format=html&type=jpg" 19 | (and-or bot.config.cat_api_key 20 | (.. "&api_key=" bot.config.cat_api_key) 21 | ""))) 22 | 23 | (set self.command "cat") 24 | (set self.doc "Returns a cat!") 25 | (set self.triggers (utilities.make_triggers bot [] :cat)) 26 | (values)) 27 | 28 | :action 29 | (fn [self bot msg] 30 | (local (str res) (http.request self.url)) 31 | (if (~= res 200) 32 | (do (utilities.send_reply msg bot.config.errors.connection) nil) 33 | (do (bindings.sendPhoto {:chat_id msg.chat.id 34 | :photo (: str :match "")}) 35 | nil)))} 36 | 37 | -------------------------------------------------------------------------------- /otouto/plugins/user/commit.fnl: -------------------------------------------------------------------------------- 1 | ;; commit.fnl 2 | ;; Returns a commit message from whatthecommit.com. 3 | 4 | ;; Copyright 2019 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (local http (require :socket.http)) 8 | (local utilities (require :otouto.utilities)) 9 | (local bindings (require :extern.bindings)) 10 | 11 | { 12 | :init (fn [self bot] 13 | (set self.triggers (utilities.make_triggers bot [] :commit)) 14 | (set self.command :commit) 15 | (set self.doc "Returns a commit message from whatthecommit.com.") 16 | (set self.url "http://whatthecommit.com/index.txt") 17 | (set self.alt "add more cowbell") 18 | ) 19 | 20 | :action (fn [self _ msg] 21 | (bindings.sendMessage { 22 | :chat_id msg.chat.id 23 | :text (.. "" (or (http.request self.url) self.alt) "") 24 | :parse_mode :html 25 | }) 26 | ) 27 | } -------------------------------------------------------------------------------- /otouto/plugins/user/currency.lua: -------------------------------------------------------------------------------- 1 | local https = require('ssl.https') 2 | local json = require('dkjson') 3 | 4 | local utilities = require('otouto.utilities') 5 | local anise = require('extern.anise') 6 | 7 | local p = {} 8 | 9 | function p:init(bot) 10 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 11 | :t('cash', true):t('currency', true).table 12 | self.command = 'cash [amount] to ' 13 | self.doc = 'Example: ' .. bot.config.cmd_pat .. [[cash 5 BTC to USD 14 | Returns currency exchange rates and calculations. 15 | Source: Exchange Rates API 16 | Alias: ]] .. bot.config.cmd_pat .. 'currency' 17 | self.url = "https://api.exchangeratesapi.io/latest" 18 | self.out_str = '%s %s = %s %s\nRate: %s\n%s\nExchange Rates API' 19 | self.unsupported_str = 'Unsupported currency: ' 20 | bot.database[self.name] = bot.database[self.name] or {} 21 | self.db = bot.database[self.name] 22 | self:later(bot) 23 | end 24 | 25 | function p:get_rate(from, to) 26 | if from == self.db.base then 27 | return self.db.rates[to] 28 | elseif to == self.db.base then 29 | return 1 / self.db.rates[from] 30 | else 31 | return (1 / self.db.rates[from]) * self.db.rates[to] 32 | end 33 | end 34 | 35 | function p:action(bot, msg) 36 | if not next(self.db) then 37 | self:update(bot) 38 | if not next(self.db) then 39 | error('No conversion rates available.') 40 | end 41 | end 42 | 43 | local output 44 | local input = msg.text:upper() 45 | local from_cur, to_cur = input:match('(%a%a%a) TO (%a%a%a)') 46 | if from_cur and to_cur then 47 | local from_val = tonumber( 48 | input:match('([%d%.]+) ' .. from_cur .. ' TO ' .. to_cur) 49 | ) or 1 50 | if not (self.db.rates[from_cur] or self.db.base == from_cur) then 51 | output = self.unsupported_str .. from_cur 52 | elseif not (self.db.rates[to_cur] or self.db.base == to_cur) then 53 | output = self.unsupported_str .. to_cur 54 | else 55 | local rate = self:get_rate(from_cur, to_cur) 56 | local to_val = from_val * rate 57 | output = string.format(self.out_str, 58 | from_val, 59 | from_cur, 60 | string.format("%.2f", from_val * rate), 61 | to_cur, 62 | string.format("%.4f", rate), 63 | self.db.date 64 | ) 65 | end 66 | else 67 | output = self.doc .. 68 | '\n\nThe following currencies can be converted:\n' .. 69 | table.concat(anise.keys(self.db.rates), ' ') .. '' 70 | end 71 | utilities.send_reply(msg, output, 'html') 72 | end 73 | 74 | -- Update the rates every hour. The API updates every hour. 75 | function p:later(bot) 76 | self:update(bot) 77 | bot:do_later(self.name, os.time() + (60 * 60)) 78 | end 79 | 80 | function p:update(bot) 81 | local jstr, code = https.request(self.url) 82 | if code == 200 then 83 | bot.database[self.name] = json.decode(jstr) 84 | self.db = bot.database[self.name] 85 | end 86 | end 87 | 88 | return p 89 | -------------------------------------------------------------------------------- /otouto/plugins/user/data/eight_ball.fnl: -------------------------------------------------------------------------------- 1 | { :yesno 2 | ['Yes.' 3 | 'No.' 4 | 'Absolutely.' 5 | 'In your dreams.'] 6 | 7 | :answers 8 | ["It is certain." 9 | "It is decidedly so." 10 | "Without a doubt." 11 | "Yes, definitely." 12 | "You may rely on it." 13 | "As I see it, yes." 14 | "Most likely." 15 | "Outlook: good." 16 | "Yes." 17 | "Signs point to yes." 18 | "Reply hazy try again." 19 | "Ask again later." 20 | "Better not tell you now." 21 | "Cannot predict now." 22 | "Concentrate and ask again." 23 | "Don't count on it." 24 | "My reply is no." 25 | "My sources say no." 26 | "Outlook: not so good." 27 | "Very doubtful."]} 28 | 29 | -------------------------------------------------------------------------------- /otouto/plugins/user/data/slap.fnl: -------------------------------------------------------------------------------- 1 | [ 2 | "VICTIM was shot by VICTOR." 3 | "VICTIM was pricked to death." 4 | "VICTIM walked into a cactus while trying to escape VICTOR." 5 | "VICTIM drowned." 6 | "VICTIM drowned whilst trying to escape VICTOR." 7 | "VICTIM blew up." 8 | "VICTIM was blown up by VICTOR." 9 | "VICTIM hit the ground too hard." 10 | "VICTIM fell from a high place." 11 | "VICTIM fell off a ladder." 12 | "VICTIM fell into a patch of cacti." 13 | "VICTIM was doomed to fall by VICTOR." 14 | "VICTIM was blown from a high place by VICTOR." 15 | "VICTIM was squashed by a falling anvil." 16 | "VICTIM went up in flames." 17 | "VICTIM burned to death." 18 | "VICTIM was burnt to a crisp whilst fighting VICTOR." 19 | "VICTIM walked into a fire whilst fighting VICTOR." 20 | "VICTIM tried to swim in lava." 21 | "VICTIM tried to swim in lava while trying to escape VICTOR." 22 | "VICTIM was struck by lightning." 23 | "VICTIM was slain by VICTOR." 24 | "VICTIM got finished off by VICTOR." 25 | "VICTIM was killed by magic." 26 | "VICTIM was killed by VICTOR using magic." 27 | "VICTIM starved to death." 28 | "VICTIM suffocated in a wall." 29 | "VICTIM fell out of the world." 30 | "VICTIM was knocked into the void by VICTOR." 31 | "VICTIM withered away." 32 | "VICTIM was pummeled by VICTOR." 33 | "VICTIM was fragged by VICTOR." 34 | "VICTIM was desynchronized." 35 | "VICTIM was wasted." 36 | "VICTIM was busted." 37 | "VICTIM's bones are scraped clean by the desolate wind." 38 | "VICTIM has died of dysentery." 39 | "VICTIM fainted." 40 | "VICTIM is out of usable Pokemon! VICTIM whited out!" 41 | "VICTIM is out of usable Pokemon! VICTIM blacked out!" 42 | "VICTIM whited out!" 43 | "VICTIM blacked out!" 44 | "VICTIM says goodbye to this cruel world." 45 | "VICTIM got rekt." 46 | "VICTIM was sawn in half by VICTOR." 47 | "VICTIM died. I blame VICTOR." 48 | "VICTIM was axe-murdered by VICTOR." 49 | "VICTIM's melon was split by VICTOR." 50 | "VICTIM was sliced and diced by VICTOR." 51 | "VICTIM was split from crotch to sternum by VICTOR." 52 | "VICTIM's death put another notch in VICTOR's axe." 53 | "VICTIM died impossibly!" 54 | "VICTIM died from VICTOR's mysterious tropical disease." 55 | "VICTIM escaped infection by dying." 56 | "VICTIM played hot-potato with a grenade." 57 | "VICTIM was knifed by VICTOR." 58 | "VICTIM fell on his sword." 59 | "VICTIM ate a grenade." 60 | "VICTIM practiced being VICTOR's clay pigeon." 61 | "VICTIM is what's for dinner!" 62 | "VICTIM was terminated by VICTOR." 63 | "VICTIM was shot before being thrown out of a plane." 64 | "VICTIM was not invincible." 65 | "VICTIM has encountered an error." 66 | "VICTIM died and reincarnated as a goat." 67 | "VICTOR threw VICTIM off a building." 68 | "VICTIM is sleeping with the fishes." 69 | "VICTIM got a premature burial." 70 | "VICTOR replaced all of VICTIM's music with Nickelback." 71 | "VICTOR spammed VICTIM's email." 72 | "VICTOR made VICTIM a knuckle sandwich." 73 | "VICTOR slapped VICTIM with pure nothing." 74 | "VICTOR hit VICTIM with a small, interstellar spaceship." 75 | "VICTIM was quickscoped by VICTOR." 76 | "VICTOR put VICTIM in check-mate." 77 | "VICTOR RSA-encrypted VICTIM and deleted the private key." 78 | "VICTOR put VICTIM in the friendzone." 79 | "VICTOR slaps VICTIM with a DMCA takedown request!" 80 | "VICTIM became a corpse blanket for VICTOR." 81 | "Death is when the monsters get you. Death comes for VICTIM." 82 | "Cowards die many times before their death. VICTIM never tasted death but once." 83 | "VICTIM died of hospital gangrene." 84 | "VICTIM got a house call from Doctor VICTOR." 85 | "VICTOR beheaded VICTIM." 86 | "VICTIM got stoned...by an angry mob." 87 | "VICTOR sued the pants off VICTIM." 88 | "VICTIM was impeached." 89 | "VICTIM was one-hit KO'd by VICTOR." 90 | "VICTOR sent VICTIM to /dev/null." 91 | "VICTOR sent VICTIM down the memory hole." 92 | "VICTIM was a mistake." 93 | "\"VICTIM was a mistake.\" - VICTOR" 94 | "VICTOR checkmated VICTIM in two moves." 95 | "VICTIM was made redundant." 96 | "VICTIM was assimilated." 97 | "VICTIM is with Harambe now." 98 | ] 99 | -------------------------------------------------------------------------------- /otouto/plugins/user/dice.fnl: -------------------------------------------------------------------------------- 1 | ;; dice.fnl 2 | ;; Returns a set of random numbers. Accepts D&D notation. 3 | 4 | ;; Copyright 2018 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* otouto.utilities) 9 | 10 | { 11 | :init (fn [self bot] 12 | (set self.command "roll ") 13 | (set self.doc 14 | "Returns a set of dice rolls, where n is the number of rolls and r is the \z 15 | range. If only a range is given, returns only one roll.") 16 | (set self.triggers (utilities.make_triggers bot [] [:roll true])) 17 | (values)) 18 | 19 | :action (fn [self bot msg] 20 | (local input (utilities.input msg.text_lower)) 21 | (if (not input) 22 | (do (utilities.send_plugin_help msg.chat.id msg.message_id bot.config.cmd_pat self) nil) 23 | (do 24 | (var (count range) (: input :match "([%d]+)d([%d]+)")) 25 | (when (not count) 26 | (set count 1) 27 | (set range (: input :match "d?([%d]+)$"))) 28 | (if (not range) 29 | (do (utilities.send_message msg.chat.id self.doc true msg.message_id :html) nil) 30 | (let [count (tonumber count) 31 | range (tonumber range)] 32 | (if (< range 2) 33 | (do (utilities.send_reply msg "The minimum range is 2.") nil) 34 | (or (> range 1000) (> count 1000)) 35 | (do (utilities.send_reply msg "The maximum range and count are 1000.") nil) 36 | ; else 37 | (let [output (f-str "{count}d{range}\n")] 38 | (var output output) 39 | (for [_ 1 count] 40 | (set output (f-str "{output}{}\t" (math.random range)))) 41 | (set output (f-str "{output}")) 42 | (utilities.send_message msg.chat.id output true msg.message_id :html) 43 | nil))))))) 44 | } 45 | 46 | -------------------------------------------------------------------------------- /otouto/plugins/user/dilbert.fnl: -------------------------------------------------------------------------------- 1 | ; dilbert.fnl 2 | ; Copyright 2018 topkecleon 3 | ; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 4 | 5 | (require-macros :anise.macros) 6 | (require* ssl.https 7 | socket.url 8 | extern.bindings 9 | otouto.utilities) 10 | 11 | { 12 | :init (fn [self bot] 13 | (set self.command "dilbert [date]") 14 | (set self.doc (.. bot.config.cmd_pat "dilbert [YYYY-MM-DD] \ 15 | Returns the latest Dilbert strip or that of a given date.")) 16 | (set self.triggers (utilities.make_triggers bot [] [:dilbert true]))) 17 | 18 | :action (fn [self bot msg] (let [input (utilities.get_word msg.text 2)] 19 | (local (res code) 20 | (https.request (.. "https://dilbert.com/strip/" (url.escape (or 21 | (and input (: input :match "^%d%d%d%d%-%d%d%-%d%d$")) 22 | (os.date "%F")))))) 23 | (if (~= code 200) 24 | (do (utilities.send_reply msg bot.config.errors.connection) nil) 25 | ;else 26 | (bindings.sendPhoto { 27 | :chat_id msg.chat.id 28 | :photo (: res :match "") 29 | :caption (: res :match "")}) 30 | nil))) 31 | } 32 | -------------------------------------------------------------------------------- /otouto/plugins/user/dump.lua: -------------------------------------------------------------------------------- 1 | local utilities = require('otouto.utilities') 2 | local bindings = require('extern.bindings') 3 | local json = require('dkjson') 4 | 5 | local P = {} 6 | 7 | function P:init(bot) 8 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 9 | :t('dump').table 10 | self.command = 'dump' 11 | self.doc = 'Dumps JSON of the replied-to message or the command.' 12 | end 13 | 14 | function P:action(bot, msg) 15 | if msg.reply_to_message then 16 | bindings.sendMessage{ 17 | chat_id = msg.chat.id, 18 | reply_to_message_id = msg.message_id, 19 | text = '' .. utilities.html_escape( 20 | json.encode(msg.reply_to_message, { indent = true }) 21 | ) .. '', 22 | parse_mode = 'HTML' 23 | } 24 | else 25 | bindings.sendMessage{ 26 | chat_id = msg.chat.id, 27 | reply_to_message_id = msg.message_id, 28 | text = '' .. utilities.html_escape( 29 | json.encode(msg, { indent = true }) 30 | ) .. '', 31 | parse_mode = 'HTML' 32 | } 33 | end 34 | end 35 | 36 | return P 37 | -------------------------------------------------------------------------------- /otouto/plugins/user/echo.fnl: -------------------------------------------------------------------------------- 1 | ;; echo.fnl 2 | ;; Returns input. 3 | 4 | ;; Copyright 2018 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* serpent 9 | otouto.utilities) 10 | 11 | { 12 | :init (fn [self bot] 13 | (set self.command "echo ") 14 | (set self.doc "Repeats a string of text.") 15 | (set self.triggers (utilities.make_triggers bot [] [:echo true])) 16 | (values)) 17 | 18 | :action (fn [self bot msg] 19 | (local input (utilities.input_from_msg msg)) 20 | (if (not input) 21 | (do (utilities.send_plugin_help msg.chat.id msg.message_id bot.config.cmd_pat self) nil) 22 | (let [html_input (utilities.html_escape input) 23 | output (if (= msg.chat.type :supergroup) 24 | (f-str "Echo:\n\"{}\"" (utilities.html_escape input)) 25 | (utilities.html_escape (.. utilities.char.zwnj input)))] 26 | (utilities.send_message msg.chat.id output true nil :html) 27 | nil))) 28 | } 29 | -------------------------------------------------------------------------------- /otouto/plugins/user/eight_ball.fnl: -------------------------------------------------------------------------------- 1 | ;; eight_ball.fnl 2 | ;; Returns magic 8-ball like answers. 3 | 4 | ;; Copyright 2018 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* otouto.utilities 9 | (rename otouto.plugins.user.data.eight_ball data)) 10 | 11 | { :init 12 | (fn [self bot] 13 | (set self.command "8ball") 14 | (set self.doc "Returns an answer from a magic 8-ball!") 15 | (set self.triggers (utilities.make_triggers bot ["[Yy]/[Nn]%p*$"] [:8ball true])) 16 | (values)) 17 | 18 | :action 19 | (fn [self _ msg] 20 | (utilities.send_reply 21 | msg 22 | (and-or (: msg.text_lower :match "y/n%p?$") 23 | (. data.yesno (math.random (# data.yesno))) 24 | (. data.answers (math.random (# data.answers))))))} 25 | 26 | -------------------------------------------------------------------------------- /otouto/plugins/user/full_width.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | full_width.lua 3 | otouto plugin to convert Latin text to Latin full-width text. 4 | 5 | Copyright 2017 topkecleon 6 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | ]] 8 | 9 | local utilities = require('otouto.utilities') 10 | 11 | local P = {} 12 | 13 | function P:init(bot) 14 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 15 | :t('full_?width', true).table 16 | self.command = 'fullwidth ' 17 | self.doc = 'Returns aesthetic text.' 18 | end 19 | 20 | function P:action(bot, msg) 21 | local input = utilities.input_from_msg(msg) 22 | if not input then 23 | utilities.send_reply(msg, self.doc, 'html') 24 | return 25 | end 26 | -- Piece together the output by iterating through every character and 27 | -- changing Latin characters to their full-width counterparts. Characters 28 | -- which are not in the "Basic Latin" set will be ignored. 29 | -- Error handling to check whether or not a given 30 | -- character is in the basic latin set. 31 | local output = {} 32 | 33 | for char in input:gmatch('.') do 34 | local succ, code = pcall(function() return utf8.codepoint(char) end) 35 | -- Full-width codepoints are 65248 higher than their basic counterparts. 36 | if succ and code >= 33 and code <= 126 then 37 | table.insert(output, utf8.char(code+65248)) 38 | else 39 | table.insert(output, char) 40 | end 41 | end 42 | 43 | utilities.send_reply(msg, table.concat(output)) 44 | end 45 | 46 | return P 47 | 48 | -------------------------------------------------------------------------------- /otouto/plugins/user/google_translate.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | google_translate.lua 3 | A plugin for the Google translate API. 4 | 5 | Uses config.lang for the output language, unless specified. 6 | 7 | Copyright 2017 topkecleon 8 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 9 | ]]-- 10 | 11 | local https = require('ssl.https') 12 | local url = require('socket.url') 13 | local dkjson = require('dkjson') 14 | 15 | local utilities = require('otouto.utilities') 16 | 17 | local tl = {} 18 | 19 | function tl:init(bot) 20 | if not bot.config.google_api_key then 21 | io.write("Missing config value: google_api_key.\n\z 22 | \tuser.google_translate will not work without a Google API key.\n") 23 | else 24 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 25 | :t('g?translate', true):t('g?tl', true).table 26 | self.command = 'translate ' 27 | self.doc = bot.config.cmd_pat .. [[translate [lang] (in reply) 28 | Translates input or the replied-to message into the bot's default language. 29 | In non-reply commands, $text follows a line break after the command and language code. 30 | Translation service provided by Google. 31 | Aliases: /gtranslate, /tl, /gtl.]] 32 | -- "gtl" = "good translate" 33 | self.url = 'https://translation.googleapis.com/language/translate/v2?key=' .. 34 | bot.config.google_api_key .. '&format=text&target=%s&q=%s' 35 | end 36 | end 37 | 38 | function tl:action(bot, msg) 39 | local lang = bot.config.lang 40 | local text = utilities.input(msg.text) 41 | 42 | if msg.reply_to_message and #msg.reply_to_message.text > 0 then 43 | if text and text:len() == 2 then 44 | lang = text:lower() 45 | end 46 | text = msg.reply_to_message.text 47 | 48 | else 49 | if text and text:match('^..\n.') then 50 | lang, text = text:match('^(..)\n(.+)$') 51 | end 52 | end 53 | 54 | if not text then 55 | utilities.send_reply(msg, self.doc, 'html') 56 | return 57 | end 58 | 59 | local result, code = https.request(self.url:format(lang, url.escape(text))) 60 | if code ~= 200 then 61 | utilities.send_reply(msg, bot.config.errors.connection) 62 | return 63 | end 64 | 65 | local data = dkjson.decode(result) 66 | if not data.data.translations[1] then 67 | utilities.send_reply(msg, bot.config.errors.results) 68 | return 69 | end 70 | 71 | local output = string.format('%s → %s:\n%s', 72 | data.data.translations[1].detectedSourceLanguage:upper(), 73 | lang:upper(), 74 | utilities.html_escape(data.data.translations[1].translatedText) 75 | ) 76 | utilities.send_reply(msg.reply_to_message or msg, output, 'html') 77 | end 78 | 79 | return tl 80 | 81 | -------------------------------------------------------------------------------- /otouto/plugins/user/greetings.fnl: -------------------------------------------------------------------------------- 1 | ; greetings.fnl 2 | ; Reponds to configurable terms with configurable greetings. 3 | 4 | ; Copyright 2019 topkecleon 5 | ; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* otouto.utilities) 9 | 10 | { 11 | :init (fn [self bot] 12 | (set self.triggers {}) 13 | (each [_ triggers (pairs bot.config.greetings)] 14 | (each [_ trigger (ipairs triggers)] 15 | (let [s (f-str "^{trigger},? {}%p*$" (: bot.info.first_name :lower))] 16 | (table.insert self.triggers s))))) 17 | 18 | :action (fn [_ bot msg] 19 | (var output "") 20 | (each [response triggers (pairs bot.config.greetings)] 21 | (each [_ trigger (ipairs triggers)] 22 | (when (: msg.text_lower :match trigger) 23 | (set output response)))) 24 | (let [nick (: (utilities.get_nick bot msg.from) :gsub "%%" "%%%%")] 25 | (set output (: output :gsub "#NAME" nick))) 26 | (utilities.send_reply msg output)) 27 | } -------------------------------------------------------------------------------- /otouto/plugins/user/hex_color.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | hex_color.lua 3 | Returns an image of the given color code in hexadecimal format. 4 | 5 | If colorhexa.com ever stops working for any reason, it would be simple to 6 | generate these images on-the-fly with ImageMagick installed, like so: 7 | os.execute(string.format( 8 | 'convert -size 128x128 xc:#%s /tmp/%s.png', 9 | hex, 10 | hex 11 | )) 12 | Or alternatively, use a magic table to produce and store them. 13 | local colors = {} 14 | setmetatable(colors, { __index = function(tab, key) 15 | filename = '/tmp/' .. key .. '.png' 16 | os.execute('convert -size 128x128 xc:#' .. key .. ' ' .. filename) 17 | tab[key] = filename 18 | return filename 19 | end}) 20 | 21 | Copyright 2016 topkecleon 22 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 23 | ]]-- 24 | 25 | local bindings = require('extern.bindings') 26 | local utilities = require('otouto.utilities') 27 | 28 | local P = {} 29 | 30 | function P:init(bot) 31 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 32 | :t('colou?r', true):t('hex', true).table 33 | self.command = 'color [ffffff]' 34 | self.doc = [[Returns an image of the given color code. Color codes must be in hexadecimal. 35 | Acceptable code formats: 36 | ABCDEF -> #ABCDEF 37 | F96 -> #FF9966 38 | F5 -> #F5F5F5 39 | F -> #FFFFFF 40 | The preceding hash symbol is optional.]] 41 | self.url = 'http://www.colorhexa.com/%s.png' 42 | self.invalid_number_error = 'Invalid number. See ' .. bot.config.cmd_pat .. 'help color' 43 | end 44 | 45 | function P:action(bot, msg) 46 | local input = utilities.get_word(msg.text, 2) 47 | if input then 48 | input = input:gsub('#', '') 49 | input_is_number = tonumber('0x' .. input) 50 | if input_is_number and (#input <= 3 or #input == 6) then 51 | local hex 52 | if #input == 1 then 53 | hex = input:rep(6) 54 | elseif #input == 2 then 55 | hex = input:rep(3) 56 | elseif #input == 3 then 57 | hex = '' 58 | for s in input:gmatch('.') do 59 | hex = hex .. s .. s 60 | end 61 | elseif #input == 6 then 62 | hex = input 63 | end 64 | bindings.sendPhoto{ 65 | chat_id = msg.chat.id, 66 | reply_to_message_id = msg.message_id, 67 | photo = self.url:format(hex), 68 | caption = '#' .. hex 69 | } 70 | else 71 | utilities.send_reply(msg, self.invalid_number_error) 72 | end 73 | else 74 | utilities.send_reply(msg, self.doc, 'html') 75 | end 76 | end 77 | 78 | return P 79 | -------------------------------------------------------------------------------- /otouto/plugins/user/lastfm.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | lastfm.lua 3 | Returns "now playing" info from last.fm. 4 | 5 | Copyright 2019 topkecleon 6 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | ]]-- 8 | 9 | local url = require('socket.url') 10 | local http = require('socket.http') 11 | local json = require('dkjson') 12 | 13 | local utilities = require('otouto.utilities') 14 | local bindings = require('extern.bindings') 15 | 16 | local p = {} 17 | 18 | function p:init(bot) 19 | assert(bot.config.lastfm_api_key, 20 | 'Missing config value: lastfm_api_key. user.lastfm will not work \z 21 | without a last.fm API key.') 22 | self.url = 'http://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&api_key=' .. 23 | bot.config.lastfm_api_key .. '&format=json&limit=1&user=' 24 | self.test_url = 'http://ws.audioscrobbler.com/2.0/?method=user.getInfo&api_key=' .. 25 | bot.config.lastfm_api_key .. '&format=json&user=' 26 | 27 | self.command = 'np [username]' 28 | self.doc = "Shows the currently playing or last played track \z 29 | from last.fm.\nYou may specify your last.fm username after the \z 30 | command. After that, it will be stored. Otherwise, your Telegram \z 31 | username will be tried. You may delete your stored last.fm username \z 32 | with " .. bot.config.cmd_pat .. "np --." 33 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 34 | :t('np', true).table 35 | 36 | if not bot.database.userdata.lastfm then 37 | bot.database.userdata.lastfm = {} 38 | end 39 | 40 | self.notices = { 41 | deleted = 'Your last.fm username has been deleted.', 42 | invalid = 'Invalid last.fm username.', 43 | specify = 'Please specify your last.fm username, eg ' .. 44 | bot.config.cmd_pat .. 'np durov' 45 | } 46 | end 47 | 48 | function p:test_username(uname) 49 | local jstr, res = http.request(self.test_url .. url.escape(uname)) 50 | if res == 200 then 51 | if json.decode(jstr).error then 52 | return false 53 | else 54 | return true 55 | end 56 | else 57 | return false 58 | end 59 | end 60 | 61 | function p.format_track(track, dname) 62 | return string.format( 63 | 'Now Playing for %s\n🎵 %s\n👤 %s\n💿 %s', 64 | utilities.html_escape(dname), 65 | track.url, 66 | utilities.html_escape(track.name), 67 | utilities.html_escape(track.artist["#text"]), 68 | utilities.html_escape(track.album["#text"]) 69 | ) 70 | end 71 | 72 | function p.format_error(data) 73 | return string.format( 74 | 'Error %s: %s', 75 | data.error, 76 | data.message 77 | ) 78 | end 79 | 80 | -- returns text and optional image URL 81 | -- Whether or not the URL is returned determines whether sendMessage is used 82 | -- or sendPhoto is used. 83 | function p:fetch(uname, dname) 84 | local jstr, res = http.request(self.url .. url.escape(uname)) 85 | local output, img_url 86 | if res == 200 then 87 | local data = json.decode(jstr) 88 | if data.recenttracks then 89 | local track = data.recenttracks.track[1] 90 | for _, image in pairs(track.image) do 91 | if image.size == 'extralarge' then 92 | img_url = image["#text"] 93 | break 94 | end 95 | end 96 | output = self.format_track(track, dname) 97 | elseif data.error then 98 | output = self.format_error(data) 99 | else 100 | error("Unknown response from last.fm API.\n" .. jstr) 101 | end 102 | else 103 | output = false 104 | end 105 | return output, img_url 106 | end 107 | 108 | function p:action(bot, msg, _, user) 109 | local input = utilities.input(msg.text) 110 | local dname = user:display_name() 111 | -- Returned by self.fetch. If img_url is present, sendPhoto is used. 112 | local output, img_url 113 | 114 | -- Delete username. 115 | if input == '--' or input == utilities.char.em_dash then 116 | user.data.lastfm = nil 117 | output = self.notices.deleted 118 | 119 | -- Set username to input, or to TG username if user.data.lastfm not set. 120 | elseif input or (msg.from.username and not user.data.lastfm) then 121 | -- err if invalid username 122 | local new_username = input or msg.from.username 123 | local valid_username = self:test_username(new_username) 124 | if valid_username then 125 | user.data.lastfm = new_username 126 | output, img_url = self:fetch(new_username, dname) 127 | output = output .. "\n\nYour last.fm username has been set to " .. 128 | utilities.html_escape(new_username) .. ". You may change \z 129 | it by specifying your last.fm username in the command, eg " .. 130 | bot.config.cmd_pat .. "np durov" 131 | elseif input then 132 | output = self.notices.invalid 133 | else 134 | output = self.notices.specify 135 | end 136 | 137 | -- Use user.data.lastfm. 138 | elseif user.data.lastfm then -- results 139 | output, img_url = self:fetch(user.data.lastfm, dname) 140 | 141 | else 142 | output = self.notices.specify 143 | end 144 | 145 | if img_url then 146 | bindings.sendPhoto{ 147 | photo = img_url, 148 | caption = output, 149 | parse_mode = 'html', 150 | chat_id = msg.chat.id 151 | } 152 | elseif output then 153 | bindings.sendMessage{ 154 | text = output, 155 | parse_mode = 'html', 156 | chat_id = msg.chat.id, 157 | disable_web_page_preview = true 158 | } 159 | else 160 | bindings.sendMessage{ 161 | text = bot.config.errors.connection, 162 | chat_id = msg.chat.id, 163 | reply_to_message_id = msg.reply_to_message.message_id 164 | } 165 | end 166 | end 167 | 168 | return p -------------------------------------------------------------------------------- /otouto/plugins/user/maybe.fnl: -------------------------------------------------------------------------------- 1 | ;; maybe.fnl 2 | ;; Runs a command, if it feels like it. 3 | 4 | ;; Copyright 2016 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* anise 9 | otouto.utilities) 10 | 11 | { 12 | :init (fn [self bot] 13 | (set self.command "maybe [int%] ") 14 | (set self.doc "Runs a command sometimes (default 50% chance).") 15 | (set self.triggers (utilities.make_triggers bot [] [:maybe true])) 16 | (values)) 17 | 18 | :action (fn [self bot msg] 19 | (local (probability input) 20 | (: msg.text :match (f-str "^{bot.config.cmd_pat}maybe%s+(%d*)%%?%s*(.+)"))) 21 | (if (not input) 22 | (do (utilities.send_plugin_help msg.chat.id msg.message_id bot.config.cmd_pat self) nil) 23 | (let [probability (or (tonumber probability) 50)] 24 | (when (< (* (math.random) 100) probability) 25 | (set msg.text (anise.trim input)) 26 | (: bot :on_message msg))))) 27 | } 28 | -------------------------------------------------------------------------------- /otouto/plugins/user/nickname.fnl: -------------------------------------------------------------------------------- 1 | ;; nickname.fnl 2 | ;; Allows a user to set or delete his nickname. 3 | 4 | ;; Copyright 2019 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (local utilities (require :otouto.utilities)) 8 | 9 | { 10 | :init (fn [self bot] 11 | (set self.command "nick ") 12 | (set self.doc "Set a nickname. Pass -- to delete it.") 13 | (set self.triggers (utilities.make_triggers bot [] [:nick true])) 14 | (if (not bot.database.userdata.nickname) 15 | (set bot.database.userdata.nickname {})) 16 | (set self.db bot.database.userdata.nickname) 17 | (values)) 18 | 19 | ; No input returns nickname, or info if one isn't set. 20 | ; "--" or an em dash deletes a nickname. 21 | :action (fn [_ _ msg _ user] 22 | (utilities.send_reply msg (let [input (utilities.input msg.text)] 23 | (if (not input) 24 | (if user.data.nickname 25 | (.. "Your nickname is " user.data.nickname ".") 26 | ; else 27 | "You have no nickname.") 28 | ; else if input is '--' or em dash 29 | (or (= input "--") (= input utilities.char.em_dash)) 30 | ; Delete the nickname. 31 | (do (set user.data.nickname nil) 32 | "Your nickname has been deleted.") 33 | ; else if input length > 32 34 | (> (utilities.utf8_len input) 32) 35 | "Nicknames cannot exceed 32 characters." 36 | ; else 37 | (let [nick (: input :gsub "\n" "")] 38 | (set user.data.nickname nick) 39 | (.. "Your nickname is now " nick ".")))))) 40 | } -------------------------------------------------------------------------------- /otouto/plugins/user/reactions.fnl: -------------------------------------------------------------------------------- 1 | ;; reactions.fnl 2 | ;; Provides a list of callable emoticons for the poor souls who don't have a 3 | ;; compose key. 4 | 5 | ;; Copyright 2016 topkecleon 6 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | 8 | (require-macros :anise.macros) 9 | (require* otouto.utilities) 10 | 11 | { 12 | :init (fn [self bot] 13 | (set self.command "reactions") 14 | (set self.doc "Returns a list of \"reaction\" emoticon commands.") 15 | (set self.triggers (utilities.make_triggers bot [] :reactions)) 16 | 17 | (local cmd_pat bot.config.cmd_pat) 18 | (local username (: bot.info.username :lower)) 19 | ; Generate a command list message triggered by "/reactions". 20 | (set self.help "Reactions:\n") 21 | (each [trigger reaction (pairs bot.config.reactions)] 22 | (set self.help (f-str "{self.help}• {cmd_pat}{trigger}: {reaction}\n")) 23 | (table.insert self.triggers (f-str "^{cmd_pat}{trigger}")) 24 | (table.insert self.triggers (f-str "^{cmd_pat}{trigger}@{username}")) 25 | (table.insert self.triggers (f-str "{cmd_pat}{trigger}$")) 26 | (table.insert self.triggers (f-str "{cmd_pat}{trigger}@{username}$")) 27 | (table.insert self.triggers (f-str "\n{cmd_pat}{trigger}")) 28 | (table.insert self.triggers (f-str "\n{cmd_pat}{trigger}@{username}")) 29 | (table.insert self.triggers (f-str "{cmd_pat}{trigger}\n")) 30 | (table.insert self.triggers (f-str "{cmd_pat}{trigger}@{username}\n")))) 31 | 32 | :action (fn [self bot msg] 33 | (local cmd_pat bot.config.cmd_pat) 34 | (if (string.match msg.text_lower (f-str "{cmd_pat}reactions")) 35 | (do (utilities.send_message msg.chat.id self.help true nil :html) nil) 36 | (each [trigger reaction (pairs bot.config.reactions)] 37 | (when (string.match msg.text_lower (.. cmd_pat trigger)) 38 | (utilities.send_message msg.chat.id reaction true nil :html))))) 39 | } 40 | -------------------------------------------------------------------------------- /otouto/plugins/user/regex.fnl: -------------------------------------------------------------------------------- 1 | ;; regex.fnl 2 | ;; Sed-like substitution using PCRE regular expressions. Ignores commands with 3 | ;; no reply-to message. 4 | 5 | ;; Copyright 2017 topkecleon 6 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | 8 | (require-macros :anise.macros) 9 | (require* anise 10 | re 11 | (rename rex_pcre rex) 12 | otouto.utilities) 13 | 14 | (local invoke_pattern (re.compile "\ 15 | invocation <- 's/' {~ pcre ~} '/' {~ repl ~} ('/' modifiers)? !.\ 16 | pcre <- ( [^\\/] / f_slash / '\\' )*\ 17 | repl <- ( [^\\/%] / percent / f_slash / capture / '\\' )*\ 18 | \ 19 | modifiers <- { flags? } {~ n_matches? ~} {~ probability? ~}\ 20 | \ 21 | flags <- ('i' / 'm' / 's' / 'x' / 'U' / 'X')+\ 22 | n_matches <- ('#' {[0-9]+}) -> '%1'\ 23 | probability <- ('%' {[0-9]+}) -> '%1'\ 24 | \ 25 | f_slash <- ('\\' '/') -> '/'\ 26 | percent <- '%' -> '%%%%'\ 27 | capture <- ('\\' {[0-9]+}) -> '%%%1'\ 28 | ")) 29 | 30 | (fn process_text [raw_text cmd_pat] 31 | (local text (: raw_text :match (f-str "^{cmd_pat}?(.*)$"))) 32 | (if (not text) 33 | nil 34 | (let [(patt repl flags n_matches probability) (: invoke_pattern :match text)] 35 | (if (not patt) 36 | nil 37 | (values 38 | patt 39 | repl 40 | flags 41 | (and n_matches (tonumber n_matches)) 42 | (and probability (tonumber probability))))))) 43 | 44 | (fn make_n_matches [n_matches probability] 45 | (if 46 | (not probability) 47 | n_matches 48 | (not n_matches) 49 | (fn [] (< (* (math.random) 100) probability)) 50 | ; else 51 | (do 52 | (var matches_left n_matches) 53 | (fn [] 54 | (local tmp (and-or (< matches_left 0) 0 nil)) 55 | (set matches_left (- matches_left 1)) 56 | (values 57 | (< (* (math.random) 100) probability) 58 | tmp))))) 59 | 60 | { 61 | :init (fn [self bot] 62 | (set self.command "s//") 63 | (set self.help_word :regex) 64 | (set self.doc "Replace all matches for the given pattern.\n\z 65 | Uses PCRE regexes.\n\z 66 | \n\z 67 | Modifiers are [<flags>][#<matches>][%probability]:\n\z 68 | * Flags are i, m, s, x, U, and X, as per PCRE\n\z 69 | * Matches is how many matches to replace\n\z 70 | (all matches are replaced by default)\n\z 71 | * Probability is the percentage that a match will\n\z 72 | be replaced (100 by default)") 73 | (set self.triggers [(.. bot.config.cmd_pat "?s/.-/.-$")]) 74 | (local flags_plugin (. bot.named_plugins :admin.flags)) 75 | (set self.flag :regex_unwrapped) 76 | (set self.flag_desc "Regex substitutions aren't prefixed.") 77 | (when flags_plugin 78 | (tset flags_plugin.flags self.flag self.flag_desc)) 79 | (values)) 80 | 81 | :action (fn [self bot msg group] 82 | (if (not msg.reply_to_message) 83 | true 84 | (let [(patt repl flags n_matches probability) (process_text msg.text bot.config.cmd_pat) 85 | n_matches (make_n_matches n_matches probability)] 86 | (if (not patt) 87 | nil 88 | (let [input msg.reply_to_message.text 89 | input (and-or (= msg.reply_to_message.from.id bot.info.id) 90 | (: input :match "^Did you mean:\n\"(.+)\"$") 91 | input) 92 | (success result n_matched) 93 | (pcall (fn [] (rex.gsub input patt repl n_matches flags)))] 94 | (if (= success false) 95 | (let [output (.. "Malformed pattern!\n" (utilities.html_escape result))] 96 | (utilities.send_reply msg output) 97 | nil) 98 | (= n_matched 0) 99 | nil 100 | ; else 101 | (do (var output (anise.trim (: result :sub 1 4000))) 102 | (set output (utilities.html_escape output)) 103 | (when (not (and flags_plugin (. group.data.admin.flags self.flag))) 104 | (set output (f-str "Did you mean:\n\"{}\"" output))) 105 | (utilities.send_reply msg.reply_to_message output :html) 106 | nil))))))) 107 | } 108 | -------------------------------------------------------------------------------- /otouto/plugins/user/reminders.fnl: -------------------------------------------------------------------------------- 1 | ; reminders.fnl 2 | ; Copyright 2018 topkecleon 3 | ; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 4 | 5 | (require-macros :anise.macros) 6 | (require* otouto.utilities 7 | otouto.autils) 8 | 9 | { 10 | :init (fn [self bot] 11 | (set self.command "remind ") 12 | (set self.triggers (utilities.make_triggers bot [] 13 | [:remind true] [:remindme true] [:reminder true])) 14 | (set self.doc "Set a reminder. The interval may be a number of seconds, \z 15 | or a tiem string, eg 3d12h30m (see /help tiem). \z 16 | Reminders support HTML formatting. The text of a replied-to message \z 17 | can be made a reminder, if a valid interval follows the command. \n\z 18 | Example: /remind 4h Look at cat pics. \n\z 19 | Aliases: /remindme, /reminder.") 20 | (values)) 21 | 22 | ; rmdr { 23 | ; :user {:id 55994550 :first_name :Drew} 24 | ; :chat_id -8675309 25 | ; :text "Post a cat pic." 26 | ; :date 1523000000} 27 | :later (fn [_self bot rmdr] 28 | (var output (f-str "Reminder from {} (UTC):\n{rmdr.text}" 29 | (os.date "!%F %T" rmdr.date))) 30 | (utilities.send_message rmdr.chat_id (if (~= rmdr.from.id rmdr.chat_id) 31 | (.. (utilities.lookup_name bot rmdr.from.id rmdr.from) "\n" output) 32 | ;else 33 | output) 34 | nil nil :html)) 35 | 36 | :action (fn [self bot msg] (let [input (utilities.input msg.text)] 37 | (local output (if input (do 38 | (var (text interval) (autils.duration_from_reason input)) 39 | 40 | ; text is message text and/or replied-to text, or nil. 41 | (set text (if (and msg.reply_to_message (> (# msg.reply_to_message.text) 0)) 42 | (if text (.. msg.reply_to_message.text "\n\n" text) 43 | ;else 44 | msg.reply_to_message.text) 45 | text text 46 | ;else 47 | nil)) 48 | 49 | (if (not interval) "Please specify a valid interval. See /help tiem." 50 | (not text) "Please specify text for the reminder." 51 | ;else 52 | (do (: bot :do_later self.name (+ (os.time) interval) { 53 | :from msg.from 54 | :chat_id msg.chat.id 55 | :text text 56 | :date msg.date}) 57 | (.. "I will remind you in " (utilities.tiem.print interval) ":\n" 58 | (utilities.html_escape text))))) 59 | 60 | ; else (if not input) 61 | (utilities.plugin_help bot.config.cmd_pat self))) 62 | 63 | (utilities.send_reply msg output :html))) 64 | } 65 | -------------------------------------------------------------------------------- /otouto/plugins/user/shout.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | shout.lua 3 | Returns an obnoxious shout. 4 | 5 | Copyright 2016 topkecleon 6 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | ]]-- 8 | 9 | local utilities = require('otouto.utilities') 10 | local anise = require('extern.anise') 11 | 12 | local P = {} 13 | 14 | function P:init(bot) 15 | self.command = 'shout ' 16 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat):t('shout', true).table 17 | self.doc = 'Shouts something. Input may be the replied-to message.' 18 | self.utf8_char = '('..utilities.char.utf_8..'*)' 19 | end 20 | 21 | function P:action(bot, msg) 22 | local input = utilities.input_from_msg(msg) 23 | if input then 24 | input = anise.trim(input):upper():gsub("\n", " ") 25 | local output = '' 26 | local inc = 0 27 | local ilen = 0 28 | for match in input:gmatch(self.utf8_char) do 29 | if ilen < 20 then 30 | ilen = ilen + 1 31 | output = output .. match .. ' ' 32 | end 33 | end 34 | ilen = 0 35 | output = output .. '\n' 36 | for match in input:sub(2):gmatch(self.utf8_char) do 37 | if ilen < 19 then 38 | local spacing = '' 39 | for _ = 1, inc do 40 | spacing = spacing .. ' ' 41 | end 42 | inc = inc + 1 43 | ilen = ilen + 1 44 | output = output .. match .. ' ' .. spacing .. match .. '\n' 45 | end 46 | end 47 | output = '' .. anise.trim(output) .. '' 48 | utilities.send_message(msg.chat.id, output, true, false, 'html') 49 | else 50 | utilities.send_reply(msg, self.doc, 'html') 51 | end 52 | end 53 | 54 | return P 55 | 56 | -------------------------------------------------------------------------------- /otouto/plugins/user/slap.fnl: -------------------------------------------------------------------------------- 1 | ;; slap.fnl 2 | ;; Allows users to slap someone. 3 | 4 | ;; Copyright 2019 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* otouto.utilities 9 | (rename otouto.plugins.user.data.slap data)) 10 | 11 | { 12 | :init (fn [self bot] 13 | (set self.command "slap [target]") 14 | (set self.doc "Slap somebody.") 15 | (set self.triggers (utilities.make_triggers bot [] [:slap true])) 16 | (values)) 17 | 18 | :action (fn [self bot msg] 19 | (local victor (utilities.get_nick bot msg.from)) 20 | (local input (utilities.input msg.text)) 21 | (local users bot.database.userdata.info) 22 | (local victim (if msg.reply_to_message 23 | (utilities.get_nick bot msg.reply_to_message.from) 24 | input 25 | (if (= (: input :match "^@(.+)$") bot.info.username) 26 | bot.info.first_name 27 | (: input :match "^@.") 28 | (let [user (utilities.resolve_username bot input)] 29 | (if user (utilities.get_nick bot user) input)) 30 | (and (tonumber input) users users[input]) 31 | (utilities.get_nick bot users[input]) 32 | ; else 33 | input) 34 | ; else 35 | (utilities.get_nick bot msg.from))) 36 | (local victor (if (= victor victim) bot.info.first_name victor)) 37 | 38 | (let [victor (: victor :gsub "%%" "%%%%") 39 | victim (: victim :gsub "%%" "%%%%") 40 | slap (.. utilities.char.zwnj (. data (math.random (# data)))) 41 | output (: (: slap :gsub "VICTOR" victor) :gsub "VICTIM" victim)] 42 | (utilities.send_message msg.chat.id output) 43 | nil)) 44 | } 45 | -------------------------------------------------------------------------------- /otouto/plugins/user/urban_dictionary.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | urban_dictionary.lua 3 | Returns Urban Dictionary definitions. Now featuring paging and links! 4 | 5 | Copyright 2019 topkecleon 6 | This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | ]]-- 8 | 9 | local json = require('dkjson') 10 | local url = require('socket.url') 11 | local https = require('ssl.https') 12 | 13 | local bindings = require('extern.bindings') 14 | local utilities = require('otouto.utilities') 15 | local anise = require('extern.anise') 16 | 17 | local P = {} 18 | 19 | local three_hours = 60 * 60 * 3 20 | 21 | function P:init(bot) 22 | self.command = 'urbandictionary [query]' 23 | self.doc = string.format( 24 | 'Search the Urban Dictionary.\nAliases: %sud, %surban\n\zBy default, \z 25 | results include an inline keyboard for paging and related terms. Use \z 26 | %sud1 for only paging buttons. Use %sud0 for no keyboard.', 27 | bot.config.cmd_pat, 28 | bot.config.cmd_pat, 29 | bot.config.cmd_pat, 30 | bot.config.cmd_pat 31 | ) 32 | self.triggers = utilities.triggers(bot.info.username, bot.config.cmd_pat) 33 | :t('ud%d?', true) 34 | :t('urbandictionary', true) 35 | :t('urban', true).table 36 | 37 | self.kb_level_pattern = '^' .. bot.config.cmd_pat .. 'ud(%d)' 38 | 39 | -- Cache to store definition lists. 40 | -- Should provide a noticeable speed benefit for paging. 41 | self.cache = {} 42 | 43 | -- Schedule a job to clear expired cached lists. 44 | bot:do_later(self.name, os.time() + three_hours) 45 | 46 | self.url = 'http://api.urbandictionary.com/v0/define?term=' 47 | end 48 | 49 | function P:action(bot, msg) 50 | local query = utilities.input_from_msg(msg) 51 | if query then 52 | local list = self:fetch(url.escape(query)) 53 | if list ~= false then 54 | if #list > 0 then 55 | local kb_level = tonumber(msg.text:lower():match(self.kb_level_pattern) or 2) 56 | local message = self:create_message(list, 1, kb_level) 57 | message.chat_id = msg.chat.id 58 | message.reply_to_message_id = msg.message_id 59 | bindings.sendMessage(message) 60 | else 61 | utilities.send_reply(msg, bot.config.errors.results) 62 | end 63 | else 64 | utilities.send_reply(msg, bot.config.errors.connection) 65 | end 66 | else 67 | utilities.send_plugin_help(msg.chat.id, msg.message_id, bot.config.cmd_pat, self) 68 | end 69 | end 70 | 71 | function P:callback_action(_, query) 72 | local escaped_term = utilities.get_word(query.data, 2) 73 | local def_num = tonumber(utilities.get_word(query.data, 3)) 74 | local kb_level = tonumber(utilities.get_word(query.data, 4)) 75 | 76 | local new_list = P:fetch(escaped_term) 77 | local new_message = self:create_message(new_list, def_num, kb_level) 78 | new_message.chat_id = query.message.chat.id 79 | new_message.message_id = query.message.message_id 80 | new_message.parse_mode = 'html' 81 | new_message.disable_web_page_preview = true 82 | 83 | bindings.editMessageText(new_message) 84 | end 85 | 86 | -- Clear cached results which have expired. 87 | function P:later(bot) 88 | for term, tab in pairs(self.cache) do 89 | if tab.expires < os.time() then 90 | self.cache[term] = nil 91 | end 92 | end 93 | -- Schedule another check in three hours. 94 | bot:do_later(self.name, os.time() + three_hours) 95 | end 96 | 97 | -- Fetch a list of definitions from the Urban Dictionary API or the cache. 98 | function P:fetch(escaped_term) 99 | escaped_term = escaped_term:lower() 100 | -- If the term is cached, use that. 101 | if self.cache[escaped_term] then 102 | -- Reset the expiration date. 103 | self.cache[escaped_term].expires = os.time() + three_hours 104 | return self.cache[escaped_term].list 105 | else 106 | -- Otherwise, check the API. 107 | local jstr, response = https.request(self.url .. escaped_term) 108 | if response == 200 then 109 | local data = json.decode(jstr) 110 | 111 | -- Cache the results. 112 | self.cache[escaped_term] = { 113 | expires = os.time() + three_hours, 114 | list = data.list 115 | } 116 | 117 | return data.list 118 | else 119 | return false 120 | end 121 | end 122 | end 123 | 124 | -- i is the index of the entry which should be display. 125 | -- kb_level: 0 is no keyboard. 1 is paging keyboard. 2 is full keyboard. 126 | -- Paging keyboard provides arrows to navigate through several entries for 127 | -- one term. Full keyboard provides arrows as well as keys for related terms 128 | -- which are linked in the definition. 129 | function P:create_message(list, i, kb_level) 130 | local entry = list[i] 131 | local message = { 132 | parse_mode = 'html', 133 | disable_web_page_preview = true, 134 | text = self.format_entry(list, i) 135 | } 136 | 137 | if kb_level > 0 then 138 | -- Initialize the keyboard. 139 | local keyboard = utilities.keyboard('inline_keyboard') 140 | 141 | -- Paging arrows if there is more than one entry for the term. 142 | if #list > 1 then 143 | local prev_entry_num = list[i - 1] and i - 1 or #list 144 | local next_entry_num = list[i + 1] and i + 1 or 1 145 | local escaped_term = url.escape(entry.word) 146 | 147 | keyboard:row() 148 | keyboard:button('◀️', 'callback_data', string.format( 149 | '%s %s %s %s', 150 | self.name, 151 | escaped_term, 152 | prev_entry_num, 153 | kb_level 154 | )) 155 | keyboard:button('▶️', 'callback_data', string.format( 156 | '%s %s %s %s', 157 | self.name, 158 | escaped_term, 159 | next_entry_num, 160 | kb_level 161 | )) 162 | end 163 | 164 | if kb_level > 1 then 165 | -- Set for all terms appearing in the definition or example, which will be on buttons on the keyboard. 166 | local linked_terms = anise.set() 167 | -- Iterate over bracketed terms in the definition. 168 | for term in entry.definition:gmatch('%[(.-)%]') do 169 | -- Don't add a term if it's the same as the initial term. 170 | if term:lower() ~= entry.word:lower() then 171 | linked_terms:add(term:lower()) 172 | end 173 | end 174 | -- Iterate over bracketed terms in the example. 175 | for term in entry.example:gmatch('%[(.-)%]') do 176 | if term:lower() ~= entry.word:lower() then 177 | linked_terms:add(term:lower()) 178 | end 179 | end 180 | 181 | -- Populate with linked terms. 182 | local j = 0 183 | for term in pairs(linked_terms) do 184 | -- When there are three buttons in a row, a new row is started. 185 | if j % 3 == 0 then 186 | keyboard:row() 187 | end 188 | keyboard:button(term, 'callback_data', string.format( 189 | '%s %s %s %s', 190 | self.name, 191 | url.escape(term), 192 | 1, 193 | kb_level 194 | )) 195 | j = j + 1 196 | end 197 | end 198 | 199 | -- Prep the functions 200 | message.reply_markup = keyboard:serialize() 201 | end 202 | 203 | return message 204 | end 205 | 206 | function P.format_entry(list, i) 207 | local entry = list[i] 208 | local definition = entry.definition:gsub('%[(.-)%]', '%1') 209 | local text = string.format( 210 | '%s (%s of %s)\n%s\n\n%s', 211 | entry.word, 212 | entry.permalink, 213 | i, 214 | #list, 215 | entry.written_on:sub(1, 10), 216 | utilities.html_escape(definition) 217 | ) 218 | if entry.example then 219 | local example = entry.example:gsub('%[(.-)%]', '%1') 220 | text = text .. '\n\n' .. utilities.html_escape(example) .. '' 221 | end 222 | return text 223 | end 224 | 225 | return P 226 | -------------------------------------------------------------------------------- /otouto/plugins/user/user_lookup.fnl: -------------------------------------------------------------------------------- 1 | ;; user_lookup.fnl 2 | ;; Returns cached user info, if any, for the given targets. 3 | 4 | ;; Copyright 2018 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* anise 9 | otouto.utilities 10 | otouto.autils) 11 | 12 | { 13 | :init (fn [self bot] 14 | (set self.command "lookup") 15 | (set self.doc "Returns stored user info, if any, for the given users.") 16 | (set self.triggers (utilities.make_triggers bot [] [:lookup true])) 17 | (set self.targeting true) 18 | (values)) 19 | 20 | :action (fn [self bot msg] 21 | (local (targets output) 22 | (autils.targets bot msg {:unknown_ids_err true :self_targeting true})) 23 | (anise.pushcat output (utilities.list_names bot targets)) 24 | (utilities.send_reply msg (table.concat output "\n") :html) 25 | nil) 26 | } 27 | -------------------------------------------------------------------------------- /otouto/plugins/user/whoami.fnl: -------------------------------------------------------------------------------- 1 | ;; whoami.fnl 2 | ;; Returns the user's or replied-to user's display name, username, and ID, in 3 | ;; addition to the group's display name, username, and ID. 4 | 5 | ;; Copyright 2018 topkecleon 6 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 7 | 8 | (require-macros :anise.macros) 9 | (require* otouto.utilities) 10 | 11 | { 12 | :init (fn [self bot] 13 | (set self.command "who") 14 | (set self.doc "Returns user and chat info for you or the replied-to message.") 15 | (set self.triggers (utilities.make_triggers bot [] :who :whoami)) 16 | (values)) 17 | 18 | :action (fn [self bot msg] 19 | (let [; Operate on the replied-to message, if there is one. 20 | msg (or msg.reply_to_message msg) 21 | ; If it's a private conversation, bot is chat, unless bot is from. 22 | chat (if (= msg.from.id msg.chat.id) bot.info msg.chat) 23 | new_or_left (or msg.new_chat_member msg.left_chat_member) 24 | output (if new_or_left 25 | (f-str "{} {} {} {} {}." 26 | (utilities.format_name msg.from) 27 | (if msg.new_chat_member "added" "removed") 28 | (utilities.format_name new_or_left) 29 | (if msg.new_chat_member "to" "from") 30 | (utilities.format_name chat)) 31 | ; else 32 | (f-str "You are {}, and you are messaging {}." 33 | (utilities.format_name msg.from) 34 | (utilities.format_name chat)))] 35 | (utilities.send_message msg.chat.id output true msg.message_id :html) 36 | nil)) 37 | } 38 | -------------------------------------------------------------------------------- /otouto/plugins/user/wikipedia.fnl: -------------------------------------------------------------------------------- 1 | ;; wikipedia.fnl 2 | ;; Returns a Wikipedia result for a given query. 3 | 4 | ;; Copyright 2018 topkecleon 5 | ;; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 6 | 7 | (require-macros :anise.macros) 8 | (require* ssl.https 9 | socket.url 10 | (rename dkjson json) 11 | otouto.utilities) 12 | 13 | { 14 | :init (fn [self bot] 15 | (set self.command "wikipedia ") 16 | (set self.doc 17 | "Returns an article from Wikipedia.\n\z 18 | Aliases: /w, /wiki") 19 | (set self.triggers (utilities.make_triggers bot [] 20 | [:wikipedia true] [:wiki true] [:w true])) 21 | (set self.search_url (.. "https://" bot.config.lang ".wikipedia.org/w/api.php?action=query&list=search&format=json&srsearch=")) 22 | (set self.result_url (.. "https://" bot.config.lang ".wikipedia.org/w/api.php?action=query&format=json&prop=extracts&exchars=4000&explaintext=&titles=")) 23 | (set self.article_url (.. "https://" bot.config.lang ".wikipedia.org/wiki/"))) 24 | 25 | :get_title (fn [dsq] (let [t []] 26 | (each [_ v (pairs dsq)] 27 | (if (not (: v.snippet :match "may refer to")) 28 | (table.insert t v.title))) 29 | (if (> (# t) 0) (. t 1) false))) 30 | 31 | :build (fn [self title text] 32 | (f-str "{}\n{}\nRead more." 33 | (utilities.html_escape title) 34 | (let [l (: text :find "\n")] 35 | (if l (: text :sub 1 (- l 1)) text)) 36 | (.. self.article_url (url.escape title))) 37 | ) 38 | 39 | :action (fn [self bot msg] (let [input (utilities.input_from_msg msg)] 40 | (let [output 41 | (if (not input) self.doc 42 | (let [(res code) (https.request (.. self.search_url (url.escape input)))] 43 | (if (~= code 200) bot.config.errors.connection 44 | (let [data (json.decode res)] 45 | (if (or (not data.query) (= data.query.searchinfo.totalhits 0)) bot.config.errors.results 46 | (let [title (self.get_title data.query.search)] 47 | (if (not title) bot.config.errors.results 48 | (let [(res code) (https.request (.. self.result_url (url.escape title)))] 49 | (if (~= code 200) bot.config.errors.connection 50 | (let [(_ text) (next (. (json.decode res) :query :pages))] 51 | (if (not text) bot.config.errors.results 52 | (: self :build title text.extract))))))))))))] 53 | (utilities.send_reply msg output :html))) 54 | nil) 55 | } 56 | -------------------------------------------------------------------------------- /otouto/plugins/user/xkcd.fnl: -------------------------------------------------------------------------------- 1 | ; xkcd.fnl 2 | ; Copyright 2018 topkecleon 3 | ; This code is licensed under the GNU AGPLv3. See /LICENSE for details. 4 | 5 | (require-macros :anise.macros) 6 | (require* (rename dkjson json) 7 | ssl.https 8 | extern.bindings 9 | otouto.utilities) 10 | 11 | { 12 | :init (fn [self bot] 13 | (set self.command "xkcd [i]") 14 | (set self.doc "Returns the latest xkcd strip or a specified one. \ 15 | i may be \"r\" or \"random\" for a random strip.") 16 | (set self.triggers (utilities.make_triggers bot [] [:xkcd true])) 17 | 18 | (when (not bot.database.xkcd) (: self :later bot))) 19 | 20 | :later (fn [self bot] 21 | (: bot :do_later self.name (+ (os.time) 21600)) 22 | (local (res code) (https.request "https://xkcd.com/info.0.json")) 23 | (when (= code 200) 24 | (set bot.database.xkcd (json.decode res)))) 25 | 26 | :action (fn [self bot msg] (let [input (utilities.get_word msg.text 2) 27 | (res code) (https.request (f-str "https://xkcd.com/{}/info.0.json" 28 | (if (or (= input :r) (= input :random)) 29 | (math.random bot.database.xkcd.num) 30 | (tonumber input) 31 | input 32 | ;else 33 | (tostring bot.database.xkcd.num))))] 34 | (when (= code 200) (let [strip (json.decode res)] 35 | ; Simple way to correct an out-of-date latest strip. 36 | (when (> strip.num bot.database.xkcd.num) (set bot.database.xkcd strip)) 37 | 38 | (bindings.sendPhoto { 39 | :chat_id msg.chat.id 40 | :parse_mode :html 41 | :photo strip.img 42 | :caption (f-str "{}\n{}\nhttps://xkcd.com/{}" 43 | (utilities.html_escape (utilities.fix_utf8 strip.safe_title)) 44 | (utilities.html_escape strip.alt) 45 | strip.num 46 | ) 47 | }))))) 48 | } 49 | -------------------------------------------------------------------------------- /otouto/rot13.fnl: -------------------------------------------------------------------------------- 1 | ;; https://github.com/kennyledet/Algorithm-Implementations/blob/master/ROT13_Cipher/Lua/Yonaba/rot13.lua 2 | ;; MIT licensed 3 | 4 | ;; ROT13 ciphering algorithm implementation 5 | ;; See: http://en.wikipedia.org/wiki/ROT13 6 | 7 | ;; Returns the ASCII bytecode of either 'a' or 'A' 8 | (local a_byte (string.byte :a)) 9 | (local A_byte (string.byte :A)) 10 | (local ascii_base (fn [ch] 11 | (if (= (string.lower ch) ch) a_byte A_byte))) 12 | 13 | (local caesar_cipher_ch (fn [key] (fn [ch] 14 | (let [base (ascii_base ch) 15 | offset (% (+ (- (string.byte ch) base) key) 26)] 16 | (string.char (+ offset base)))))) 17 | 18 | ;; ROT13 is based on Caesar ciphering algorithm, using 13 as a key 19 | (local caesar_cipher (fn [key str] 20 | (string.gsub str "%a" (caesar_cipher_ch key)))) 21 | 22 | { 23 | :cipher (partial caesar_cipher 13) 24 | 25 | :decipher (partial caesar_cipher -13) 26 | } 27 | -------------------------------------------------------------------------------- /shell_preamble.fnl: -------------------------------------------------------------------------------- 1 | (eval-compiler (dofile :fennel_preamble.lua)) 2 | --------------------------------------------------------------------------------