├── .github ├── FUNDING.yml └── workflows │ ├── codeql-analysis.yml │ └── docker-build.yml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── Makefile ├── README.md ├── bot ├── config.go ├── emoji.go ├── errors.go ├── shared.go └── types.go ├── cmd ├── args.go ├── embed.go ├── permissions.go ├── responses.go └── util.go ├── go.mod ├── go.sum ├── main.go ├── plugins ├── README.md ├── base-extra │ └── base-extra.go ├── base-fun │ └── base-fun.go ├── base │ └── base.go ├── bookmarker │ └── bookmarker.go ├── doses-logger │ └── doses-logger.go ├── example │ └── example.go ├── leave-join-msg │ └── leave-join-msg.go ├── message-roles │ └── message-roles.go ├── plugins.go ├── remindme │ └── remindme.go ├── role-menu │ └── role-menu.go ├── spotifytoyoutube │ └── spotifytoyoutube.go ├── starboard │ └── starboard.go ├── suggest-topic │ └── suggest-topic.go ├── sys-stats │ └── sys-stats.go └── tenor-delete │ └── tenor-delete.go ├── scripts ├── build-plugins.sh ├── run.sh └── update.sh └── util ├── builtin.go ├── cpu ├── cpu_darwin.go ├── cpu_linux.go └── cpu_other.go ├── formatting.go ├── http.go └── parsing.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://payy.lv'] 2 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '25 22 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | - name: Setup Go 44 | uses: WillAbides/setup-go-faster@v1.7.0 45 | with: 46 | go-version: '1.18.x' 47 | 48 | # Initializes the CodeQL tools for scanning. 49 | - name: Initialize CodeQL 50 | uses: github/codeql-action/init@v1 51 | with: 52 | languages: ${{ matrix.language }} 53 | # If you wish to specify custom queries, you can do so here or in a config file. 54 | # By default, queries listed here will override any specified in a config file. 55 | # Prefix the list here with "+" to use these queries and those in the config file. 56 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 57 | 58 | - run: | 59 | make 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v1 63 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: docker-build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout master 13 | uses: actions/checkout@master 14 | with: 15 | fetch-depth: 1 16 | - name: Login to Docker Hub 17 | uses: docker/login-action@v1 18 | with: 19 | username: ${{ secrets.DOCKER_USER }} 20 | password: ${{ secrets.DOCKER_TOKEN }} 21 | - name: Run Docker build and push 22 | run: make docker-build docker-push 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | config/ 3 | bin/ 4 | taro 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18.0 2 | 3 | RUN mkdir /taro-bot \ 4 | && mkdir /taro-files 5 | ADD . /taro-bot 6 | WORKDIR /taro-bot 7 | 8 | RUN for d in ./plugins/*/; do echo "building $d"; go build -o "bin/" -buildmode=plugin "$d"; done \ 9 | && go build -o taro . 10 | 11 | ENV TZ "Local" 12 | ENV DEBUG "false" 13 | WORKDIR /taro-files 14 | CMD DEBUG="$DEBUG" TZ="$TZ" PLUGIN_DIR="/taro-bot/bin" /taro-bot/scripts/run.sh 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) 2020-2024, froggie and Contributors 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any 5 | purpose with or without fee is hereby granted, provided that the above 6 | copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := l1ving/taro-bot 2 | TAG := $(shell git log -1 --pretty=%h) 3 | IMG := ${NAME}:${TAG} 4 | LATEST := ${NAME}:latest 5 | 6 | taro-bot: clean build build-plugins 7 | 8 | run: taro-bot 9 | ./taro 10 | 11 | build: 12 | go build -o taro . 13 | 14 | deps: 15 | go get -u github.com/diamondburned/arikawa/v3 16 | go get -u github.com/5HT2C/http-bash-requests 17 | go get -u github.com/go-co-op/gocron 18 | go get -u github.com/mackerelio/go-osstat 19 | go get -u github.com/forPelevin/gomoji 20 | go get -u golang.org/x/net 21 | go get -u golang.org/x/text/language 22 | go get -u golang.org/x/text/message 23 | go mod tidy 24 | 25 | clean: 26 | rm -f taro 27 | 28 | build-plugins: 29 | ./scripts/build-plugins.sh 30 | 31 | 32 | docker-build: 33 | @docker build -t ${IMG} . 34 | @docker tag ${IMG} ${LATEST} 35 | 36 | docker-push: 37 | @docker push ${NAME} 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # taro-bot 2 | 3 | [![](https://img.shields.io/badge/discord%20bot-invite!-5865F2?logo=discord&logoColor=white)](https://discord.com/oauth2/authorize?client_id=893216230410952785&permissions=278404582464&scope=bot) 4 | [![CodeFactor](https://img.shields.io/codefactor/grade/github/5HT2/taro-bot?logo=codefactor&logoColor=white)](https://www.codefactor.io/repository/github/5HT2/taro-bot) 5 | [![CodeQL](https://github.com/5HT2/taro-bot/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/5HT2/taro-bot/actions/workflows/codeql-analysis.yml) 6 | [![docker-build](https://github.com/5HT2/taro-bot/actions/workflows/docker-build.yml/badge.svg)](https://github.com/5HT2/taro-bot/actions/workflows/docker-build.yml) 7 | 8 | A Discord bot built in Go and Arikawa. 9 | 10 | **Notable features:** 11 | - Strict arg parsing and selection 12 | - Automatic error handling via friendly messages given to the user :) 13 | - Asynchronous event handling with concurrency-safe configs 14 | - Fully-fledged plugin support 15 | 16 | **A feature (plugin) is able to:** 17 | - Return comprehensive commands, with [info support such as aliases and descriptions](https://github.com/5HT2/taro-bot/blob/99b929ac18d583a38a332405b45dd53d57143b17/plugins/base/base.go#L19). 18 | - Return "auto responses", with [flexible message matching to call Go code](https://github.com/5HT2/taro-bot/blob/99b929ac18d583a38a332405b45dd53d57143b17/plugins/tenor-delete/tenor-delete.go#L28). 19 | - Return scheduled jobs, to be [called at an interval](https://github.com/5HT2/taro-bot/blob/99b929ac18d583a38a332405b45dd53d57143b17/plugins/vintagestory/vintagestory.go#L30). 20 | - Register event handlers to Discord's gateway, such as [when a reaction is added to a message](https://github.com/5HT2/taro-bot/blob/99b929ac18d583a38a332405b45dd53d57143b17/plugins/starboard/starboard.go#L123). 21 | 22 | **All bot features are plugins**, and can be enabled or disabled on demand, with hot-reloading being added soon ([#8](https://github.com/5HT2/taro-bot/issues/8)). 23 | 24 | More information about using and creating plugins is described in the [plugin documentation](https://github.com/5HT2/taro-bot/blob/master/plugins). 25 | 26 | ## Usage 27 | 28 | ``` 29 | git clone git@github.com:5HT2/taro-bot.git && cd taro-bot 30 | make 31 | ./taro 32 | ``` 33 | 34 | You can also do `./update.sh` to run or update the Docker image, provided you have Docker installed. 35 | 36 | #### Config 37 | 38 | This is the simplest example of the `config/config.json` file, you only need `bot_token` to be set. 39 | 40 | ```json 41 | { 42 | "bot_token": "bot token goes here" 43 | } 44 | ``` 45 | 46 | You can also create a `config/plugins.json`, to select which plugins will be loaded. 47 | This is optional, and a default (curated) will load if you do not set it, or if you add `"default"` to the list. 48 | 49 | ```json 50 | { 51 | "loaded_plugins": ["example", "leave-join-msg"] 52 | } 53 | ``` 54 | -------------------------------------------------------------------------------- /bot/config.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/5HT2/taro-bot/util" 7 | "github.com/diamondburned/arikawa/v3/discord" 8 | "github.com/diamondburned/arikawa/v3/gateway" 9 | "log" 10 | "os" 11 | "strings" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | var ( 17 | C Config 18 | P PluginConfig 19 | 20 | DefaultPrefix = "." 21 | DefaultPlugins = []string{"base", "base-extra", "base-fun", "bookmarker", "leave-join-msg", "message-roles", 22 | "role-menu", "spotifytoyoutube", "starboard", "remindme", "sys-stats", "suggest-topic", "tenor-delete"} 23 | 24 | FileMode = os.FileMode(0700) 25 | ) 26 | 27 | type configOperation func(*Config) 28 | type guildOperation func(*GuildConfig) (*GuildConfig, string) 29 | 30 | // GuildContext will modify a GuildConfig non-concurrently. 31 | // Avoid using inside a network or hang-able context whenever possible. 32 | // TODO: Having one "context" per command would be nice to have. 33 | func GuildContext(c discord.GuildID, g guildOperation) { 34 | id := int64(c) 35 | start := time.Now().UnixMilli() 36 | found := false 37 | 38 | C.Run(func(c *Config) { 39 | // Try to find an existing config, and if so, replace it with the result of executed guildOperation 40 | // TODO: This isn't scalable with lots of Guilds, so a map would be preferable. See #6 41 | for n, guild := range c.GuildConfigs { 42 | if guild.ID == id { 43 | // Correct guild found, execute guildOperation 44 | res, fnName := g(&guild) 45 | c.GuildConfigs[n] = *res 46 | found = true 47 | 48 | exec := time.Now().UnixMilli() 49 | log.Printf("Execute: %vms (%s)\n", exec-start, fnName) 50 | break 51 | } 52 | } 53 | 54 | // If we didn't find an existing config, run guildOperation with the defaultConfig, and append it to the list 55 | if !found { 56 | defaultConfig := GuildConfig{ID: id, Prefix: DefaultPrefix} 57 | c.PrefixCache[id] = DefaultPrefix 58 | 59 | res, _ := g(&defaultConfig) 60 | c.GuildConfigs = append(c.GuildConfigs, *res) 61 | } 62 | }) 63 | } 64 | 65 | // Run will modify a Config non-concurrently. 66 | // Avoid using inside a network or hang-able context whenever possible. 67 | func (c *Config) Run(co configOperation) { 68 | c.Mutex.Lock() 69 | defer c.Mutex.Unlock() 70 | co(c) 71 | } 72 | 73 | type Config struct { 74 | Mutex sync.Mutex `json:"-"` // not saved in DB 75 | PrefixCache map[int64]string `json:"-"` // not saved in DB // [guild id]prefix 76 | BotToken string `json:"bot_token"` 77 | FohToken string `json:"foh_token"` 78 | FohPublicUrl string `json:"foh_public_url,omitempty"` // See LoadConfig 79 | FohPublicDir string `json:"foh_public_dir,omitempty"` // See LoadConfig 80 | FohPrivateUrl string `json:"foh_private_url,omitempty"` // See LoadConfig 81 | FohPrivateDir string `json:"foh_private_dir,omitempty"` // See LoadConfig 82 | ActivityName string `json:"activity_name,omitempty"` // See LoadActivityStatus 83 | ActivityUrl string `json:"activity_url,omitempty"` // See LoadActivityStatus 84 | ActivityType uint8 `json:"activity_type,omitempty"` // See LoadActivityStatus 85 | OperatorChannel int64 `json:"operator_channel,omitempty"` 86 | OperatorIDs []int64 `json:"operator_ids,omitempty"` 87 | OperatorAliases map[string][]string `json:"operator_aliases,omitempty"` 88 | GuildConfigs []GuildConfig `json:"guild_configs,omitempty"` 89 | } 90 | 91 | type GuildConfig struct { 92 | ID int64 `json:"id"` 93 | Prefix string `json:"prefix,omitempty"` 94 | Permissions PermissionGroups `json:"permissions,omitempty"` 95 | ArchiveRole int64 `json:"archive_role,omitempty"` // TODO: Migrate 96 | ArchiveCategory int64 `json:"archive_category,omitempty"` // TODO: Migrate 97 | EnabledTopicChannels []int64 `json:"enabled_topic_channels,omitempty"` // TODO: Migrate 98 | ActiveTopicVotes []ActiveTopicVote `json:"active_topic_votes,omitempty"` // TODO: Migrate 99 | TopicVoteThreshold int64 `json:"topic_vote_threshold,omitempty"` // TODO: Migrate 100 | TopicVoteEmoji string `json:"topic_vote_emoji,omitempty"` // TODO: Migrate 101 | Starboard StarboardConfig `json:"starboard_config"` // TODO: Migrate 102 | } 103 | 104 | type PluginConfig struct { 105 | Mutex sync.Mutex `json:"-"` // not saved in DB 106 | LoadedPlugins []string `json:"loaded_plugins"` // A list of plugins to load, overrides DefaultPlugins 107 | } 108 | 109 | // SetupConfigSaving will run SaveConfig and SavePluginConfig every 5 minutes with a ticker 110 | func SetupConfigSaving() { 111 | ticker := time.NewTicker(5 * time.Minute) 112 | go func() { 113 | for { 114 | select { 115 | case <-ticker.C: 116 | SaveConfig() 117 | SavePluginConfig() 118 | } 119 | } 120 | }() 121 | } 122 | 123 | func LoadConfig() { 124 | bytes, err := os.ReadFile("config/config.json") 125 | if err != nil { 126 | log.Fatalf("error loading config: %v\n", err) 127 | } 128 | 129 | if err := json.Unmarshal(bytes, &C); err != nil { 130 | log.Fatalf("error unmarshalling config: %v\n", err) 131 | } 132 | 133 | C.Run(func(c *Config) { 134 | // Load prefix cache 135 | c.PrefixCache = make(map[int64]string, 0) 136 | 137 | for _, g := range c.GuildConfigs { 138 | c.PrefixCache[g.ID] = g.Prefix 139 | } 140 | 141 | // Load default fs-over-http urls and dir if not set 142 | c.FohPrivateUrl = valueOrDefault(c.FohPrivateUrl, "http://localhost:6010") 143 | c.FohPrivateDir = valueOrDefault(c.FohPrivateDir, "/public/media/") 144 | c.FohPublicUrl = valueOrDefault(c.FohPublicUrl, "https://cdn.l1v.in") 145 | c.FohPublicDir = valueOrDefault(c.FohPublicDir, "/") 146 | }) 147 | } 148 | 149 | func SaveConfig() { 150 | var bytes []byte 151 | var err error = nil 152 | 153 | C.Run(func(c *Config) { 154 | bytes, err = json.MarshalIndent(c, "", " ") 155 | }) 156 | 157 | if err != nil { 158 | log.Printf("failed to marshal config: %v\n", err) 159 | return 160 | } 161 | 162 | err = os.WriteFile("config/config.json", bytes, FileMode) 163 | if err != nil { 164 | log.Printf("failed to write config: %v\n", err) 165 | } else { 166 | log.Printf("saved taro config\n") 167 | } 168 | } 169 | 170 | func LoadPluginConfig() { 171 | bytes, err := os.ReadFile("config/plugins.json") 172 | if err != nil { 173 | log.Printf("error loading plugin config: %v\n", err) 174 | log.Printf("loading default config/plugins.json\n") 175 | 176 | P = PluginConfig{LoadedPlugins: make([]string, 0)} 177 | } else { 178 | if err := json.Unmarshal(bytes, &P); err != nil { 179 | log.Fatalf("error unmarshalling plugin config: %v\n", err) 180 | } 181 | } 182 | } 183 | 184 | func SavePluginConfig() { 185 | bytes, err := json.MarshalIndent(&P, "", " ") 186 | 187 | if err != nil { 188 | log.Printf("failed to marshal plugin config: %v\n", err) 189 | return 190 | } 191 | 192 | err = os.WriteFile("config/plugins.json", bytes, FileMode) 193 | if err != nil { 194 | log.Printf("failed to write plugin config: %v\n", err) 195 | } else { 196 | log.Printf("saved taro plugin config\n") 197 | } 198 | } 199 | 200 | // LoadActivityStatus will load the activity information from the config. 201 | // Using USER_ID, USER_TAG and USER_USERNAME as replacements for the discord.Activity name are all supported. 202 | // Setting URL is only useful for a Twitch or YouTube discord.StreamingActivity. 203 | // The activity type uint8 is derived from its position in the list, eg, 0 == discord.GameActivity and 2 == discord.ListeningActivity. 204 | func LoadActivityStatus() { 205 | name := "" 206 | url := "" 207 | var activityType uint8 = 0 208 | 209 | C.Run(func(c *Config) { 210 | name = c.ActivityName 211 | url = c.ActivityUrl 212 | activityType = c.ActivityType 213 | }) 214 | name = strings.ReplaceAll(name, "USER_ID", fmt.Sprintf("%v", User.ID)) 215 | name = strings.ReplaceAll(name, "USER_TAG", fmt.Sprintf("%v", util.FormattedUserTag(*User))) 216 | name = strings.ReplaceAll(name, "USER_USERNAME", fmt.Sprintf("%v", User.Username)) 217 | 218 | if err := Client.Gateway().Send(Ctx, &gateway.UpdatePresenceCommand{ 219 | Activities: []discord.Activity{{Name: name, URL: url, Type: discord.ActivityType(activityType)}}, 220 | }); err != nil { 221 | log.Printf("error loading activity status: %v\n", err) 222 | } 223 | } 224 | 225 | func SetPrefix(fnName string, id discord.GuildID, prefix string) (string, error) { 226 | // Filter spaces 227 | prefix = strings.ReplaceAll(prefix, " ", "") 228 | if len(prefix) == 0 { 229 | return "", GenericError(fnName, "getting prefix", "prefix is empty") 230 | } 231 | 232 | // Prefix is okay, set it in the cache 233 | C.Run(func(config *Config) { 234 | config.PrefixCache[int64(id)] = prefix 235 | }) 236 | 237 | // Also set it in the guild 238 | GuildContext(id, func(g *GuildConfig) (*GuildConfig, string) { 239 | g.Prefix = prefix 240 | return g, fnName 241 | }) 242 | 243 | return prefix, nil 244 | } 245 | 246 | // valueOrDefault is used to get a default value if the current value length is 0 247 | func valueOrDefault(val, def string) string { 248 | if len(val) == 0 { 249 | return def 250 | } 251 | 252 | return val 253 | } 254 | -------------------------------------------------------------------------------- /bot/emoji.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "github.com/diamondburned/arikawa/v3/discord" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | escapedWarning = "%E2%9A%A0%EF%B8%8F" 11 | warningEmoji = discord.APIEmoji(escapedWarning) 12 | ) 13 | 14 | // EmojiApiAsConfig will convert a discord.APIEmoji to our own config format, to preserve the animated attribute 15 | func EmojiApiAsConfig(e *discord.APIEmoji, animated bool) string { 16 | if e == nil { 17 | return EmojiApiAsConfig(&warningEmoji, animated) 18 | } 19 | 20 | a := ":" 21 | if animated { 22 | a = "a:" 23 | } 24 | 25 | str := e.PathString() 26 | if strings.Contains(str, ":") { 27 | str = a + str 28 | } 29 | 30 | return str 31 | } 32 | 33 | // EmojiConfigAsApi will convert our own emoji format back to a discord.APIEmoji 34 | func EmojiConfigAsApi(e string) (discord.APIEmoji, error) { 35 | e = strings.TrimPrefix(e, "a:") 36 | 37 | str, err := url.QueryUnescape(e) 38 | if err != nil { 39 | return warningEmoji, err 40 | } 41 | 42 | return discord.APIEmoji(str), nil 43 | } 44 | 45 | // EmojiApiFormatted will format a discord.APIEmoji for display in a message 46 | func EmojiApiFormatted(e *discord.APIEmoji, animated bool) (string, error) { 47 | return EmojiConfigFormatted(EmojiApiAsConfig(e, animated)) 48 | } 49 | 50 | // EmojiConfigFormatted will format a taro config emoji for display in a message 51 | func EmojiConfigFormatted(e string) (string, error) { 52 | split := strings.Split(e, ":") 53 | if len(split) > 1 { 54 | return "<" + e + ">", nil // Discord custom emojis 55 | } 56 | 57 | return url.QueryUnescape(e) // Unicode emojis 58 | } 59 | -------------------------------------------------------------------------------- /bot/errors.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | type Error struct { 4 | Func string 5 | Action string 6 | Err string 7 | } 8 | 9 | func (e *Error) Error() string { 10 | return "taro." + e.Func + ":\n error with: " + e.Action + "\n because: " + e.Err 11 | } 12 | 13 | func GenericSyntaxError(fn, input, reason string) *Error { 14 | return &Error{fn, "parsing \"" + input + "\"", reason} 15 | } 16 | 17 | func SyntaxError(fn, input string) *Error { 18 | return GenericSyntaxError(fn, input, "invalid syntax") 19 | } 20 | 21 | func GenericError(fn, action, err string) *Error { 22 | return &Error{fn, action, err} 23 | } 24 | -------------------------------------------------------------------------------- /bot/shared.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | "github.com/diamondburned/arikawa/v3/discord" 6 | "github.com/diamondburned/arikawa/v3/state" 7 | "github.com/go-co-op/gocron" 8 | "log" 9 | "net/http" 10 | "os" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | var ( 16 | Commands = make([]CommandInfo, 0) 17 | Responses = make([]ResponseInfo, 0) 18 | Handlers = make([]HandlerInfo, 0) 19 | Jobs = make([]JobInfo, 0) 20 | Mutex = sync.Mutex{} 21 | 22 | HttpClient = http.Client{Timeout: 5 * time.Second} 23 | Client state.State 24 | Ctx = context.Background() 25 | User *discord.User 26 | PermissionsHex = 278404582480 // this is currently only used in base.go, but it is in shared.go because it is bot-level and should be set by the person maintaining the bot code 27 | Scheduler = gocron.NewScheduler(getTimeZone()) 28 | 29 | SuccessColor discord.Color = 0x3cde5a 30 | ErrorColor discord.Color = 0xde413c 31 | WarnColor discord.Color = 0xde953c 32 | DefaultColor discord.Color = 0x493cde 33 | WhiteColor discord.Color = 0xfefefe 34 | BlueColor discord.Color = 0x0099FF 35 | ) 36 | 37 | func getTimeZone() *time.Location { 38 | tzEnv := os.Getenv("TZ") 39 | if len(tzEnv) == 0 { 40 | tzEnv = "Local" 41 | } 42 | 43 | l, err := time.LoadLocation(tzEnv) 44 | if err != nil { 45 | log.Printf("error loading timezone, defaulting to UTC: %v\n", err) 46 | return time.UTC 47 | } 48 | 49 | log.Printf("using location \"%s\" for timezone\n", l) 50 | return l 51 | } 52 | -------------------------------------------------------------------------------- /bot/types.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | // 4 | // For types that should be shared 5 | // 6 | 7 | import ( 8 | "fmt" 9 | "github.com/diamondburned/arikawa/v3/gateway" 10 | "github.com/go-co-op/gocron" 11 | "reflect" 12 | "strings" 13 | ) 14 | 15 | // 16 | // CommandInfo is the info a command provides to register itself. 17 | // Fn is the function that is executed to complete the Command. 18 | // The Name and Aliases are used to call the command via Discord. 19 | type CommandInfo struct { 20 | Fn func(Command) error 21 | FnName string 22 | Name string 23 | Description string 24 | Aliases []string 25 | GuildOnly bool 26 | } 27 | 28 | // Command is passed to CommandInfo.Fn's arguments when a Command is executed. 29 | type Command struct { 30 | E *gateway.MessageCreateEvent 31 | FnName string 32 | Name string 33 | Args []string 34 | } 35 | 36 | func (i CommandInfo) String() string { 37 | return fmt.Sprintf("[%s, %s, %s, %s, %v]", i.FnName, i.Name, i.Description, i.Aliases, i.GuildOnly) 38 | } 39 | 40 | func (i CommandInfo) MarkdownString() string { 41 | aliases := "" 42 | if len(i.Aliases) > 0 { 43 | aliases = "(" + strings.Join(i.Aliases, ", ") + ")" 44 | } 45 | description := i.Description 46 | if len(description) == 0 { 47 | description = "No Description" 48 | } 49 | 50 | return fmt.Sprintf("**%s** %s\n%s", i.Name, aliases, description) 51 | } 52 | 53 | // 54 | // ResponseInfo is the info a response provides to register itself. 55 | // Fn is the function that is executed to complete the Response. 56 | // The Regexes are used to call the response via Discord. 57 | type ResponseInfo struct { 58 | Fn func(Response) `json:"fn"` 59 | Regexes []string `json:"regexes"` 60 | MatchMin int `json:"match_min"` 61 | LockChannels []int64 `json:"lock_channels,omitempty"` 62 | LockUsers []int64 `json:"lock_users,omitempty"` 63 | } 64 | 65 | func (i ResponseInfo) String() string { 66 | return fmt.Sprintf("[%p, %v, %s]", i.Fn, i.MatchMin, i.Regexes) 67 | } 68 | 69 | // Response is passed to Response.Fn's arguments when a Response is executed. 70 | type Response struct { 71 | E *gateway.MessageCreateEvent 72 | } 73 | 74 | // 75 | // JobInfo is used by features in order to easily return a job, and allow the bot to handle the errors 76 | type JobInfo struct { 77 | Fn func() (*gocron.Job, error) 78 | Name string 79 | } 80 | 81 | func (i JobInfo) String() string { 82 | return fmt.Sprintf("[%s, %p]", i.Name, i.Fn) 83 | } 84 | 85 | // 86 | // HandlerInfo is used by features in order to register a gateway handler 87 | type HandlerInfo struct { 88 | Fn func(interface{}) 89 | FnName string 90 | FnType reflect.Type 91 | FnRm func() 92 | } 93 | 94 | func (i HandlerInfo) String() string { 95 | return fmt.Sprintf("[%p, %s, %s, %p]", i.Fn, i.FnName, i.FnType, i.FnRm) 96 | } 97 | 98 | // 99 | // PermissionGroups is collection of "permissions". Each permission is a list of user IDs that have said permission. 100 | // Switching this to a list of {Name, Users} would maybe be better code-wise. 101 | type PermissionGroups struct { 102 | ManageChannels []int64 `json:"manage_channels,omitempty"` 103 | ManagePermissions []int64 `json:"manage_permissions,omitempty"` 104 | Moderation []int64 `json:"moderation,omitempty"` 105 | } 106 | 107 | // 108 | // ActiveTopicVote is used by suggest-topic.go 109 | type ActiveTopicVote struct { 110 | Message int64 `json:"message"` 111 | Author int64 `json:"author"` 112 | Topic string `json:"topic"` 113 | } 114 | 115 | // 116 | // StarboardConfig is used by commands.go and starboard.go 117 | type StarboardConfig struct { 118 | Channel int64 `json:"channel,omitempty"` // channel post ID 119 | NsfwChannel int64 `json:"nsfw_channel,omitempty"` // nsfw post channel ID 120 | Messages []StarboardMessage `json:"messages,omitempty"` 121 | Threshold int64 `json:"threshold,omitempty"` 122 | } 123 | 124 | // StarboardMessage is used by starboard.go 125 | type StarboardMessage struct { 126 | Author int64 `json:"author"` // the original author ID 127 | CID int64 `json:"channel_id"` // the original channel ID 128 | ID int64 `json:"id"` // the original message ID 129 | PostID int64 `json:"message"` // the starboard post message ID 130 | IsNsfw bool `json:"nsfw"` // if the original message was made in an NSFW channel 131 | Stars []int64 `json:"stars"` // list of added user IDs 132 | } 133 | -------------------------------------------------------------------------------- /cmd/args.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/5HT2/taro-bot/bot" 5 | "github.com/diamondburned/arikawa/v3/discord" 6 | "github.com/forPelevin/gomoji" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var ( 14 | UrlRegex = regexp.MustCompile(`https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}([.:])[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)`) 15 | emojiUrlRegex = regexp.MustCompile(`^http(s)?://cdn\.discordapp\.com/emojis/([0-9]+)`) 16 | discordEmojiRegex = regexp.MustCompile("<(a|):([A-z0-9_]+):([0-9]+)>") 17 | pingRegex = regexp.MustCompile("<@!?[0-9]+>") 18 | channelRegex = regexp.MustCompile("<#[0-9]+>") 19 | mentionFormats = regexp.MustCompile("[<@!#&>]") 20 | ) 21 | 22 | // ParseAllArgs will return the combined existing args 23 | func ParseAllArgs(a []string) (string, *bot.Error) { 24 | s := strings.Join(a, " ") 25 | if len(a) == 0 { 26 | return "", bot.GenericSyntaxError("ParseAllArgs", "nothing", "expected arguments!") 27 | } 28 | return s, nil 29 | } 30 | 31 | // ParseInt64Arg will return an int64 from s, or -1 and an error 32 | func ParseInt64Arg(a []string, pos int) (int64, *bot.Error) { 33 | s, argErr := checkArgExists(a, pos, "ParseInt64Arg") 34 | if argErr != nil { 35 | return -1, argErr 36 | } 37 | 38 | i, err := strconv.ParseInt(s, 10, 64) 39 | if err != nil { 40 | return -1, bot.GenericSyntaxError("ParseInt64Arg", s, "expected int64") 41 | } 42 | return i, nil 43 | } 44 | 45 | // ParseInt64SliceArg will return an []int64, or nil and an error 46 | func ParseInt64SliceArg(a []string, pos1 int, pos2 int) ([]int64, *bot.Error) { 47 | if pos2 == -1 { 48 | pos2 = len(a) 49 | } 50 | 51 | s, err := getArgRange(a, pos1, pos2, "ParseInt64SliceArg") 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | elems := make([]int64, 0) 57 | for _, c := range s { 58 | if i, err := strconv.ParseInt(c, 10, 64); err != nil { 59 | return nil, bot.GenericSyntaxError("ParseInt64SliceArg", c, "expected int64") 60 | } else { 61 | elems = append(elems, i) 62 | } 63 | } 64 | 65 | return elems, nil 66 | } 67 | 68 | // ParseUserArg will return the ID of a mentioned user, or -1 and an error 69 | func ParseUserArg(a []string, pos int) (int64, *bot.Error) { 70 | s, argErr := checkArgExists(a, pos, "ParseUserArg") 71 | if argErr != nil { 72 | return -1, argErr 73 | } 74 | 75 | ok := pingRegex.MatchString(s) 76 | if ok { 77 | id := mentionFormats.ReplaceAllString(s, "") 78 | i, err := strconv.ParseInt(id, 10, 64) 79 | if err != nil { 80 | return -1, bot.GenericSyntaxError("ParseUserArg", s, err.Error()) 81 | } 82 | return i, nil 83 | } 84 | return -1, bot.GenericSyntaxError("ParseUserArg", s, "expected user mention") 85 | } 86 | 87 | // ParseUrlArg will return a URL, or "" and an error 88 | func ParseUrlArg(a []string, pos int) (string, *bot.Error) { 89 | s, argErr := checkArgExists(a, pos, "ParseUrlArg") 90 | if argErr != nil { 91 | return "", argErr 92 | } 93 | 94 | ok := UrlRegex.MatchString(s) 95 | if ok { 96 | return s, nil 97 | } 98 | return "", bot.GenericSyntaxError("ParseUrlArg", s, "expected http or https url") 99 | } 100 | 101 | // ParseEmojiArg will return a discord.APIEmoji and the animated status, or nil, false and an error 102 | func ParseEmojiArg(a []string, pos int, allowOmit bool) (*discord.APIEmoji, bool, *bot.Error) { 103 | s, argErr := checkArgExists(a, pos, "ParseEmojiArg") 104 | if argErr != nil { 105 | if allowOmit { 106 | return nil, false, nil 107 | } 108 | return nil, false, argErr 109 | } 110 | 111 | if e := gomoji.CollectAll(s); len(e) == 1 { 112 | emoji := discord.APIEmoji(e[0].Character) 113 | return &emoji, false, nil 114 | } 115 | 116 | emoji := discordEmojiRegex.FindStringSubmatch(s) 117 | if len(emoji) < 4 { 118 | return nil, false, bot.GenericSyntaxError("ParseEmojiArg", s, "expected full emoji") 119 | } 120 | 121 | id, err := strconv.Atoi(emoji[3]) 122 | if err != nil { 123 | return nil, false, bot.GenericSyntaxError("ParseEmojiArg", s, "expected int") 124 | } 125 | 126 | apiEmoji := discord.NewCustomEmoji(discord.EmojiID(id), emoji[2]) 127 | animated := emoji[1] == "a" 128 | return &apiEmoji, animated, nil 129 | } 130 | 131 | // ParseEmojiIdArg will return an emoji ID, or -1 and an error 132 | func ParseEmojiIdArg(a []string, pos int) (int64, *bot.Error) { 133 | s, argErr := checkArgExists(a, pos, "ParseEmojiArg") 134 | if argErr != nil { 135 | return -1, argErr 136 | } 137 | 138 | emoji := discordEmojiRegex.FindStringSubmatch(s) 139 | if len(emoji) < 4 { 140 | return -1, bot.GenericSyntaxError("ParseEmojiIdArg", s, "expected full emoji") 141 | } 142 | 143 | id, err := strconv.ParseInt(emoji[3], 10, 64) 144 | if err != nil { 145 | return -1, bot.GenericSyntaxError("ParseEmojiIdArg", s, "expected int") 146 | } 147 | 148 | return id, nil 149 | } 150 | 151 | // ParseEmojiUrlArg will return an emoji ID, or -1 and an error 152 | func ParseEmojiUrlArg(a []string, pos int) (int64, *bot.Error) { 153 | s, argErr := checkArgExists(a, pos, "ParseEmojiUrlArg") 154 | if argErr != nil { 155 | return -1, argErr 156 | } 157 | 158 | emoji := emojiUrlRegex.FindStringSubmatch(s) 159 | if len(emoji) < 3 { 160 | return -1, bot.GenericSyntaxError("ParseEmojiUrlArg", s, "couldn't parse emoji url") 161 | } 162 | 163 | if id, err := strconv.ParseInt(emoji[2], 10, 64); err != nil { 164 | return -1, bot.GenericSyntaxError("ParseEmojiUrlArg", s, err.Error()) 165 | } else { 166 | return id, nil 167 | } 168 | } 169 | 170 | // ParseChannelSliceArg will return the IDs of the mentioned channels, or nil and an error 171 | func ParseChannelSliceArg(a []string, pos1 int, pos2 int) ([]int64, *bot.Error) { 172 | if pos2 == -1 { 173 | pos2 = len(a) 174 | } 175 | 176 | s, err := getArgRange(a, pos1, pos2, "ParseChannelSliceArg") 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | elems := make([]int64, 0) 182 | for _, c := range s { 183 | if id, err := validateChannelArg(c, "ParseChannelStringArg"); err != nil { 184 | return nil, err 185 | } else { 186 | elems = append(elems, id) 187 | } 188 | } 189 | 190 | return elems, nil 191 | } 192 | 193 | // ParseChannelArg will return the ID of a mentioned channel, or -1 and an error 194 | func ParseChannelArg(a []string, pos int) (int64, *bot.Error) { 195 | s, argErr := checkArgExists(a, pos, "ParseChannelArg") 196 | if argErr != nil { 197 | return -1, argErr 198 | } 199 | 200 | return validateChannelArg(s, "ParseChannelArg") 201 | } 202 | 203 | // ParseStringArg will return the selected string, or "" with an error 204 | func ParseStringArg(a []string, pos int, toLower bool) (string, *bot.Error) { 205 | s, argErr := checkArgExists(a, pos, "ParseStringArg") 206 | if argErr != nil { 207 | return "", argErr 208 | } 209 | if toLower { 210 | return strings.ToLower(s), nil 211 | } 212 | return s, nil 213 | } 214 | 215 | // ParseStringSliceArg will return the strings in a range, or a nil and an error 216 | func ParseStringSliceArg(a []string, pos1 int, pos2 int) ([]string, *bot.Error) { 217 | if pos2 == -1 { 218 | pos2 = len(a) 219 | } 220 | 221 | s, err := getArgRange(a, pos1, pos2, "ParseStringSliceArg") 222 | if err != nil { 223 | return nil, err 224 | } 225 | 226 | elems := make([]string, 0) 227 | for _, str := range s { 228 | elems = append(elems, str) 229 | } 230 | 231 | return elems, nil 232 | } 233 | 234 | // ParseBoolArg will return a bool (True / true / 1), or false with an error 235 | func ParseBoolArg(a []string, pos int) (bool, *bot.Error) { 236 | s, argErr := checkArgExists(a, pos, "ParseStringArg") 237 | if argErr != nil { 238 | return false, argErr 239 | } 240 | 241 | switch strings.ToLower(s) { 242 | case "true", "1": 243 | return true, nil 244 | case "false", "0": 245 | return false, nil 246 | default: 247 | return false, bot.GenericSyntaxError("ParseBoolArg", s, "expected boolean") 248 | } 249 | } 250 | 251 | func ParseDurationArg(a []string, pos int) (time.Duration, *bot.Error) { 252 | s, argErr := checkArgExists(a, pos, "ParseDurationArg") 253 | if argErr != nil { 254 | return time.Duration(0), argErr 255 | } 256 | 257 | i, err := strconv.ParseInt(s, 10, 64) 258 | if err == nil { // if we parse an int64, treat it as a future unix second 259 | return time.Duration((i - time.Now().Unix()) * 1000000000), nil 260 | } 261 | 262 | t, err := time.ParseDuration(s) 263 | if err != nil { 264 | return time.Duration(0), bot.GenericSyntaxError("ParseDurationArg", s, err.Error()) 265 | } 266 | 267 | return t, nil 268 | } 269 | 270 | // validateChannelArg will return a valid channel mention, or nil and an error if it is invalid 271 | func validateChannelArg(s string, fn string) (int64, *bot.Error) { 272 | ok := channelRegex.MatchString(s) 273 | if ok { 274 | id := mentionFormats.ReplaceAllString(s, "") 275 | i, err := strconv.ParseInt(id, 10, 64) 276 | if err != nil { 277 | return -1, bot.GenericSyntaxError(fn, s, err.Error()) 278 | } 279 | return i, nil 280 | } 281 | return -1, bot.GenericSyntaxError(fn, s, "expected channel mention") 282 | } 283 | 284 | // getArgRange will return the elements in a from pos1 to pos2, or nil and an error if the range is invalid 285 | func getArgRange(a []string, pos1 int, pos2 int, fn string) (s []string, err *bot.Error) { 286 | elems := make([]string, 0) 287 | 288 | for pos := pos1; pos <= pos2; pos++ { 289 | if e, argErr := checkArgExists(a, pos, fn); argErr != nil { 290 | return nil, argErr 291 | } else { 292 | elems = append(elems, e) 293 | } 294 | } 295 | 296 | return elems, nil 297 | } 298 | 299 | // checkArgExists will return a[pos - 1] if said index exists, otherwise it will return an error 300 | func checkArgExists(a []string, pos int, fn string) (s string, err *bot.Error) { 301 | pos -= 1 // we want to increment this so ParseGenericArg(c.args, 1) will return the first arg 302 | // prevent panic if dev made an error 303 | if pos < 0 { 304 | pos = 1 305 | } 306 | 307 | if len(a) > pos { 308 | return a[pos], nil 309 | } 310 | 311 | // the position in the command the user is giving 312 | pos += 1 313 | return "", bot.GenericError(fn, "getting arg "+strconv.Itoa(pos), "arg is missing") 314 | } 315 | -------------------------------------------------------------------------------- /cmd/embed.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/util" 7 | "github.com/diamondburned/arikawa/v3/discord" 8 | "github.com/diamondburned/arikawa/v3/gateway" 9 | "log" 10 | "strings" 11 | ) 12 | 13 | func SendCustomEmbed(c discord.ChannelID, e ...discord.Embed) (*discord.Message, error) { 14 | msg, err := bot.Client.SendEmbeds( 15 | c, 16 | e..., 17 | ) 18 | if err != nil { 19 | log.Printf("Error sending embed: %v (%v)", err, e) 20 | } 21 | return msg, err 22 | } 23 | 24 | func SendCustomMessage(c discord.ChannelID, content string) (*discord.Message, error) { 25 | msg, err := bot.Client.SendMessage( 26 | c, 27 | content, 28 | ) 29 | if err != nil { 30 | log.Printf("Error sending message: %v", err) 31 | } 32 | return msg, err 33 | } 34 | 35 | func SendExternalErrorEmbed(c discord.ChannelID, cmdName string, err error) (*discord.Message, error) { 36 | return SendCustomEmbed(c, MakeEmbed("Error running `"+cmdName+"`", err.Error(), bot.ErrorColor)) 37 | } 38 | 39 | func SendErrorEmbed(c bot.Command, err error) { 40 | _, _ = SendEmbed(c.E, "Error running `"+c.Name+"`", err.Error(), bot.ErrorColor) 41 | } 42 | 43 | func SendEmbed(e *gateway.MessageCreateEvent, title, description string, color discord.Color) (*discord.Message, error) { 44 | return SendEmbedFooter(e, title, description, "", color) 45 | } 46 | 47 | func SendEmbedFooter(e *gateway.MessageCreateEvent, title, description, footer string, color discord.Color) (*discord.Message, error) { 48 | embed := MakeEmbed(title, description, color) 49 | embed.Footer = &discord.EmbedFooter{Text: footer} 50 | msg, err := bot.Client.SendEmbeds( 51 | e.ChannelID, 52 | embed, 53 | ) 54 | if err != nil { 55 | log.Printf("Error sending embed: %v (%v)", err, embed) 56 | } 57 | return msg, err 58 | } 59 | 60 | func SendMessage(e *gateway.MessageCreateEvent, content string) (*discord.Message, error) { 61 | msg, err := bot.Client.SendMessage( 62 | e.ChannelID, 63 | content, 64 | ) 65 | if err != nil { 66 | log.Printf("Error sending message: %v", err) 67 | } 68 | return msg, err 69 | } 70 | 71 | func SendMessageEmbedSafe(c discord.ChannelID, content string, embed *discord.Embed) (*discord.Message, error) { 72 | if embed != nil { 73 | return bot.Client.SendMessage(c, content, *embed) 74 | } 75 | 76 | return bot.Client.SendMessage(c, content) 77 | } 78 | 79 | func SendDirectMessageEmbedSafe(id discord.UserID, content string, embed *discord.Embed) (*discord.Message, error) { 80 | channel, err := bot.Client.CreatePrivateChannel(id) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return SendMessageEmbedSafe(channel.ID, content, embed) 86 | } 87 | 88 | func SendDirectMessage(userID discord.UserID, contents string) (*discord.Message, error) { 89 | channel, err := bot.Client.CreatePrivateChannel(userID) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | message, err := SendCustomMessage(channel.ID, contents) 95 | 96 | return message, err 97 | } 98 | 99 | func CreateEmbedAuthor(member discord.Member) *discord.EmbedAuthor { 100 | name := member.Nick 101 | if len(name) == 0 { 102 | name = member.User.Username 103 | } 104 | 105 | return &discord.EmbedAuthor{ 106 | Name: name, 107 | Icon: fmt.Sprintf("%s?size=2048", member.User.AvatarURL()), 108 | } 109 | } 110 | 111 | func CreateEmbedAuthorUser(user discord.User) *discord.EmbedAuthor { 112 | return &discord.EmbedAuthor{ 113 | Name: util.FormattedUserTag(user), 114 | Icon: fmt.Sprintf("%s?size=2048", user.AvatarURL()), 115 | } 116 | } 117 | 118 | func CreateMessageLink(guild int64, message *discord.Message, jump, dm bool) string { 119 | link := fmt.Sprintf("https://discord.com/channels/%v/%v/%v", guild, message.ChannelID, message.ID) 120 | if dm { 121 | link = fmt.Sprintf("https://discord.com/channels/@me/%v/%v", message.ChannelID, message.ID) 122 | } 123 | 124 | if jump { 125 | return "[Jump!](" + link + ")" 126 | } 127 | return link 128 | } 129 | 130 | func CreateMessageLinkInt64(guild, message, channel int64, jump, dm bool) string { 131 | link := fmt.Sprintf("https://discord.com/channels/%v/%v/%v", guild, channel, message) 132 | if dm { 133 | link = fmt.Sprintf("https://discord.com/channels/@me/%v/%v", channel, message) 134 | } 135 | 136 | if jump { 137 | return "[Jump!](" + link + ")" 138 | } 139 | return link 140 | } 141 | 142 | func MakeEmbed(title string, description string, color discord.Color) discord.Embed { 143 | return discord.Embed{ 144 | Title: title, 145 | Description: description, 146 | Color: color, 147 | } 148 | } 149 | 150 | func GetEmbedAttachmentAndContent(msg discord.Message) (string, *discord.EmbedImage) { 151 | // Try to find a URL in the message content 152 | description := msg.Content 153 | url := UrlRegex.MatchString(msg.Content) 154 | 155 | // Set the embed image to the URL and try to find the first attached image in the message attachments 156 | var image *discord.EmbedImage = nil 157 | for _, attachment := range msg.Attachments { 158 | if strings.HasPrefix(attachment.ContentType, "image/") { 159 | image = &discord.EmbedImage{URL: attachment.URL} 160 | url = false // Don't remove URL in embed if we found an image attachment (eg, twitter link + image attachment) 161 | break 162 | } 163 | } 164 | 165 | // If we found only a URL (no other text) in the message content, and the found URL has an image extension, and we didn't find an attached image 166 | // Set the description to nothing and set the image to the found URL 167 | if url { 168 | urlMatch := UrlRegex.FindStringSubmatch(msg.Content) 169 | 170 | if len(urlMatch) > 0 && FileExtMatches(ImageExtensions, urlMatch[0]) { // extract URL and make sure we have one 171 | // remove the URL from the message content, keep other content 172 | description = strings.ReplaceAll(msg.Content, urlMatch[0], "") 173 | image = &discord.EmbedImage{URL: urlMatch[0]} 174 | } 175 | } 176 | 177 | return description, image 178 | } 179 | -------------------------------------------------------------------------------- /cmd/permissions.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/util" 7 | "github.com/diamondburned/arikawa/v3/discord" 8 | "github.com/diamondburned/arikawa/v3/gateway" 9 | "log" 10 | "strings" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | var ( 16 | Permissions = []Permission{PermUndefined, PermChannels, PermPermissions, PermModerate, PermOperator} 17 | PermissionCache = permissionCache{} 18 | ) 19 | 20 | type Permission int64 21 | 22 | const ( 23 | PermUndefined Permission = iota 24 | PermChannels 25 | PermPermissions 26 | PermModerate 27 | PermOperator // "operator" is a special permission, managed by bot.C.OperatorIDs 28 | ) 29 | 30 | func (p Permission) String() string { 31 | switch p { 32 | case PermChannels: 33 | return "channels" 34 | case PermPermissions: 35 | return "permissions" 36 | case PermModerate: 37 | return "moderate" 38 | case PermOperator: 39 | return "operator" 40 | default: 41 | return "undefined" 42 | } 43 | } 44 | 45 | type permissionCache struct { 46 | guilds []guildAdmins 47 | mutex sync.Mutex 48 | } 49 | 50 | type guildAdmins struct { 51 | id discord.GuildID 52 | admins []guildUser 53 | } 54 | 55 | type guildUser struct { 56 | lastCheck int64 57 | id discord.UserID 58 | member discord.Member 59 | admin bool 60 | roles []discord.RoleID 61 | } 62 | 63 | // HasPermission will return if the author of a command has said permission 64 | func HasPermission(c bot.Command, p Permission) *bot.Error { 65 | id := int64(c.E.Author.ID) 66 | 67 | if id == 0 { 68 | return bot.GenericError(c.FnName, "checking permission", "id is `0`") 69 | } 70 | 71 | if p == PermOperator { 72 | opIDs := make([]int64, 0) 73 | bot.C.Run(func(c *bot.Config) { 74 | opIDs = c.OperatorIDs 75 | }) 76 | 77 | if !util.SliceContains(opIDs, int64(c.E.Author.ID)) { 78 | return bot.GenericError(c.FnName, "running command", util.GetUserMention(id)+" is not a bot operator") 79 | } 80 | 81 | return nil 82 | } 83 | 84 | if !c.E.GuildID.IsValid() { 85 | return bot.GenericError(c.FnName, "running command", "command run in a non-guild, permissions are not supported here") 86 | } 87 | 88 | if !UserHasPermission(c, p, id) { 89 | return bot.GenericError(c.FnName, "running command", fmt.Sprintf("%s is missing the \"%s\" permission", util.GetUserMention(id), p)) 90 | } 91 | 92 | return nil 93 | } 94 | 95 | // UserHasPermission will return if the user with id has said permission 96 | func UserHasPermission(c bot.Command, p Permission, id int64) bool { 97 | if HasAdminCached(c.E.GuildID, c.E.Member.RoleIDs, c.E.Author) { 98 | return true 99 | } 100 | 101 | users := make([]int64, 0) 102 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 103 | users = getPermissionSlice(p, g) 104 | return g, "UserHasPermission: " + c.FnName 105 | }) 106 | 107 | return util.SliceContains(users, id) 108 | } 109 | 110 | // GivePermission will return nil if the permission was successfully given to the user with a matching id 111 | func GivePermission(c bot.Command, pStr string, id int64) error { 112 | var err error = nil 113 | 114 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 115 | p := GetPermission(pStr) 116 | users := getPermissionSlice(p, g) 117 | 118 | if !util.SliceContains(users, id) { 119 | users = append(users, id) 120 | } else { 121 | err = bot.GenericError("GivePermission", 122 | "giving permission to "+util.GetUserMention(id), 123 | fmt.Sprintf("user already has permission \"%s\"", p)) 124 | } 125 | 126 | if err == nil { 127 | switch p { 128 | case PermChannels: 129 | g.Permissions.ManageChannels = users 130 | case PermPermissions: 131 | g.Permissions.ManagePermissions = users 132 | case PermModerate: 133 | g.Permissions.Moderation = users 134 | default: 135 | err = bot.GenericError("GivePermission", 136 | "giving permission to "+util.GetUserMention(id), 137 | fmt.Sprintf("couldn't find permission type \"%s\"", pStr)) 138 | } 139 | } 140 | 141 | return g, "GivePermission: " + c.FnName 142 | }) 143 | 144 | return err 145 | } 146 | 147 | // GetPermission will return a valid Permission type from a string 148 | func GetPermission(pStr string) Permission { 149 | pStr = strings.ToLower(pStr) 150 | 151 | for _, p := range Permissions { 152 | if p.String() == pStr { 153 | return p 154 | } 155 | } 156 | 157 | return PermUndefined 158 | } 159 | 160 | // UpdateMemberCache will forcibly update the member cache 161 | func UpdateMemberCache(e *gateway.GuildMemberUpdateEvent) { 162 | log.Printf("updating member cache\n") 163 | hasAdmin(e.GuildID, e.RoleIDs, e.User) 164 | } 165 | 166 | func getPermissionSlice(p Permission, guild *bot.GuildConfig) []int64 { 167 | switch p { 168 | case PermChannels: 169 | return guild.Permissions.ManageChannels 170 | case PermPermissions: 171 | return guild.Permissions.ManagePermissions 172 | case PermModerate: 173 | return guild.Permissions.Moderation 174 | default: 175 | return make([]int64, 0) 176 | } 177 | } 178 | 179 | func HasAdminCached(id discord.GuildID, memberRoles []discord.RoleID, user discord.User) bool { 180 | PermissionCache.mutex.Lock() 181 | defer PermissionCache.mutex.Unlock() 182 | 183 | for _, g := range PermissionCache.guilds { 184 | if g.id == id { 185 | for _, u := range g.admins { 186 | // If ID matches and the last check was more recent than 10 minutes ago 187 | if u.id == user.ID && time.Now().Unix()-u.lastCheck < 600 { 188 | log.Printf("HasAdminCached: found %v\n", u.id) 189 | return u.admin 190 | } 191 | } 192 | } 193 | } 194 | 195 | log.Printf("HasAdminCached: didn't find anyone\n") 196 | return hasAdmin(id, memberRoles, user) 197 | } 198 | 199 | func hasAdmin(id discord.GuildID, memberRoles []discord.RoleID, user discord.User) bool { 200 | if PermissionCache.mutex.TryLock() { 201 | defer PermissionCache.mutex.Unlock() 202 | } 203 | 204 | roles, err := bot.Client.Roles(id) 205 | if err != nil { 206 | return false 207 | } 208 | guild, err := bot.Client.Guild(id) 209 | if err != nil { 210 | return false 211 | } 212 | 213 | admin := false 214 | if guild.OwnerID != user.ID { 215 | for _, r := range roles { 216 | if r.Permissions.Has(discord.PermissionAdministrator) && util.SliceContains(memberRoles, r.ID) { 217 | admin = true 218 | break 219 | } 220 | } 221 | } else { 222 | admin = true 223 | } 224 | 225 | found := false 226 | 227 | // Look through guilds 228 | for n, g := range PermissionCache.guilds { 229 | // If guild ID matches 230 | if g.id == id { 231 | foundUser := false 232 | found = true 233 | 234 | // Look through cached admins 235 | for n, u := range g.admins { 236 | if u.id == user.ID { 237 | foundUser = true 238 | u.admin = admin 239 | u.lastCheck = time.Now().Unix() 240 | g.admins[n] = u 241 | 242 | log.Printf("permission cache: found existing cache %v, setting to %v\n", user.ID, admin) 243 | break 244 | } 245 | } 246 | 247 | // If didn't find cached admin 248 | if !foundUser { 249 | u := guildUser{lastCheck: time.Now().Unix(), id: user.ID, admin: admin} 250 | g.admins = append(g.admins, u) 251 | log.Printf("permission cache: didn't find existing cache %v, setting to %v\n", user.ID, admin) 252 | } 253 | 254 | PermissionCache.guilds[n] = g 255 | break 256 | } 257 | } 258 | 259 | if !found { 260 | PermissionCache.guilds = append(PermissionCache.guilds, guildAdmins{id, []guildUser{{lastCheck: time.Now().Unix(), id: user.ID, admin: admin}}}) 261 | } 262 | 263 | return admin 264 | } 265 | -------------------------------------------------------------------------------- /cmd/responses.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "C" 4 | import ( 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/util" 7 | "github.com/diamondburned/arikawa/v3/gateway" 8 | "log" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | // ResponseHandler will find a global response from the config and send it, if found 14 | func ResponseHandler(e *gateway.MessageCreateEvent) { 15 | defer util.LogPanic() 16 | 17 | // Don't respond to bot messages. 18 | if e.Author.Bot { 19 | return 20 | } 21 | 22 | // TODO: Per-guild responses and configuration 23 | // TODO: compiling and caching support could be added here to improve speed 24 | go func() { 25 | for _, response := range bot.Responses { 26 | runResponse(e, response) 27 | } 28 | }() 29 | } 30 | 31 | func runResponse(e *gateway.MessageCreateEvent, response bot.ResponseInfo) { 32 | if findResponse(e, response) { 33 | sendResponse(e, response) 34 | } 35 | } 36 | 37 | func sendResponse(e *gateway.MessageCreateEvent, response bot.ResponseInfo) { 38 | // If there is a channel whitelist, and it doesn't contain the original message's channel ID, return 39 | if e.ChannelID.IsValid() && len(response.LockChannels) > 0 && !util.SliceContains(response.LockChannels, int64(e.ChannelID)) { 40 | return 41 | } 42 | 43 | // If there is a user whitelist, and it doesn't contain the original author's ID, return 44 | if e.ChannelID.IsValid() && len(response.LockUsers) > 0 && !util.SliceContains(response.LockUsers, int64(e.Author.ID)) { 45 | return 46 | } 47 | 48 | if response.Fn != nil { 49 | response.Fn(bot.Response{E: e}) 50 | } 51 | } 52 | 53 | func findResponse(e *gateway.MessageCreateEvent, response bot.ResponseInfo) bool { 54 | matched := 0 55 | message := []byte(e.Message.Content) 56 | for _, regex := range response.Regexes { 57 | // Allow using a variable in the regex to represent the current bot user 58 | // TODO: Documentation for these 59 | regex = strings.ReplaceAll(regex, "DISCORD_BOT_ID", bot.User.ID.String()) 60 | 61 | found, err := regexp.Match(regex, message) 62 | if err != nil { 63 | log.Printf("Error matching \"%s\": %v\n", regex, err) 64 | } 65 | if found { 66 | matched += 1 67 | } 68 | 69 | if matched >= response.MatchMin { 70 | return true 71 | } 72 | } 73 | 74 | return false 75 | } 76 | -------------------------------------------------------------------------------- /cmd/util.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/5HT2/taro-bot/bot" 5 | "github.com/5HT2/taro-bot/util" 6 | "github.com/diamondburned/arikawa/v3/discord" 7 | "github.com/diamondburned/arikawa/v3/gateway" 8 | "log" 9 | "path/filepath" 10 | "strings" 11 | ) 12 | 13 | var ( 14 | ImageExtensions = []string{".jpg", ".jpeg", ".png", ".gif", ".gifv"} 15 | ) 16 | 17 | // CommandHandler will parse commands and run the appropriate command func 18 | func CommandHandler(e *gateway.MessageCreateEvent) { 19 | if e.GuildID.IsValid() && e.ChannelID.IsValid() && bot.C.OperatorChannel == int64(e.ChannelID) { 20 | return 21 | } 22 | 23 | cmdName, cmdArgs := extractCommand(e.Message) 24 | CommandHandlerWithCommand(e, cmdName, cmdArgs) 25 | } 26 | 27 | func CommandHandlerWithCommand(e *gateway.MessageCreateEvent, cmdName string, cmdArgs []string) { 28 | defer util.LogPanic() 29 | 30 | // Don't respond to bot messages. 31 | if e.Author.Bot { 32 | return 33 | } 34 | 35 | if len(cmdName) == 0 { 36 | return 37 | } 38 | 39 | cmdInfo := getCommandWithName(cmdName) 40 | if cmdInfo != nil { 41 | command := bot.Command{E: e, FnName: cmdInfo.FnName, Name: cmdName, Args: cmdArgs} 42 | 43 | if cmdInfo.GuildOnly && !e.GuildID.IsValid() { 44 | _, err := SendEmbed(e, "Error", "The `"+cmdInfo.Name+"` command only works in guilds!", bot.ErrorColor) 45 | if err != nil { 46 | log.Printf("Error with \"%s\" command (Cancelled): %v\n", cmdName, err) 47 | } 48 | return 49 | } 50 | 51 | if err := cmdInfo.Fn(command); err != nil { 52 | log.Printf("Error with \"%s\" command: %v\n", cmdName, err) 53 | SendErrorEmbed(command, err) 54 | } 55 | } 56 | } 57 | 58 | // extractCommand will extract a command name and args from a message with a prefix 59 | func extractCommand(message discord.Message) (string, []string) { 60 | content := message.Content 61 | prefix := bot.DefaultPrefix 62 | ok := true 63 | 64 | if !message.GuildID.IsValid() { 65 | prefix = "" 66 | } else { 67 | bot.C.Run(func(c *bot.Config) { 68 | prefix, ok = c.PrefixCache[int64(message.GuildID)] 69 | }) 70 | 71 | // If the PrefixCache somehow doesn't have a prefix, set a default one and log it. 72 | // This is most likely when the bot has joined a new guild without accessing GuildContext 73 | if !ok { 74 | log.Printf("expected prefix to be in prefix cache: %s (%s)\n", 75 | message.GuildID, CreateMessageLink(int64(message.GuildID), &message, false, false)) 76 | 77 | bot.GuildContext(message.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 78 | g.Prefix = bot.DefaultPrefix 79 | return g, "extractCommand: reset prefix" 80 | }) 81 | 82 | prefix = bot.DefaultPrefix 83 | } 84 | } 85 | 86 | // If command doesn't start with a dot, or it's just a dot 87 | if !strings.HasPrefix(content, prefix) || len(content) < (1+len(prefix)) { 88 | return "", []string{} 89 | } 90 | 91 | // Remove prefix 92 | content = content[1*len(prefix):] 93 | // Split by space to remove everything after the prefix 94 | contentArr := strings.Split(content, " ") 95 | // Get first element of slice (the command name) 96 | contentLower := strings.ToLower(contentArr[0]) 97 | // Remove first element of slice (the command name) 98 | contentArr = append(contentArr[:0], contentArr[1:]...) 99 | return contentLower, contentArr 100 | } 101 | 102 | // getCommandWithName will return the found CommandInfo with a matching name or alias 103 | func getCommandWithName(name string) *bot.CommandInfo { 104 | for _, cmd := range bot.Commands { // TODO: Use hashmap for performance with lots of commands 105 | if cmd.Name == name || util.SliceContains(cmd.Aliases, name) { 106 | return &cmd 107 | } 108 | } 109 | return nil 110 | } 111 | 112 | func FileExtMatches(s []string, file string) bool { 113 | found := false 114 | file = strings.ToLower(file) 115 | 116 | for _, e := range s { 117 | if filepath.Ext(file) == e { 118 | found = true 119 | break 120 | } 121 | } 122 | 123 | return found 124 | } 125 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/5HT2/taro-bot 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/5HT2C/http-bash-requests v0.0.0-20230107083338-afbcb46f86cb 7 | github.com/diamondburned/arikawa/v3 v3.2.0 8 | github.com/forPelevin/gomoji v1.1.8 9 | github.com/go-co-op/gocron v1.18.0 10 | github.com/mackerelio/go-osstat v0.2.3 11 | golang.org/x/net v0.23.0 12 | golang.org/x/text v0.14.0 13 | ) 14 | 15 | require ( 16 | github.com/gorilla/schema v1.2.0 // indirect 17 | github.com/gorilla/websocket v1.5.0 // indirect 18 | github.com/pkg/errors v0.9.1 // indirect 19 | github.com/rivo/uniseg v0.4.4 // indirect 20 | github.com/robfig/cron/v3 v3.0.1 // indirect 21 | golang.org/x/sync v0.1.0 // indirect 22 | golang.org/x/sys v0.18.0 // indirect 23 | golang.org/x/time v0.3.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/5HT2C/http-bash-requests v0.0.0-20230107083338-afbcb46f86cb h1:jWy9uZcTnTcdVRZHQBSrph4S4a0+ciPLa3nf7Koo0Y4= 2 | github.com/5HT2C/http-bash-requests v0.0.0-20230107083338-afbcb46f86cb/go.mod h1:t3wm2V3hWLZ5ycremRIoU4w+9lJ0LiBXNYSl5k1r3kc= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/diamondburned/arikawa/v3 v3.2.0 h1:aBUhg94pxblT6ks4EV7qxEk44tnl0ico67ydqjVnv9g= 5 | github.com/diamondburned/arikawa/v3 v3.2.0/go.mod h1:5jBSNnp82Z/EhsKa6Wk9FsOqSxfVkNZDTDBPOj47LpY= 6 | github.com/forPelevin/gomoji v1.1.8 h1:JElzDdt0TyiUlecy6PfITDL6eGvIaxqYH1V52zrd0qQ= 7 | github.com/forPelevin/gomoji v1.1.8/go.mod h1:8+Z3KNGkdslmeGZBC3tCrwMrcPy5GRzAD+gL9NAwMXg= 8 | github.com/go-co-op/gocron v1.18.0 h1:SxTyJ5xnSN4byCq7b10LmmszFdxQlSQJod8s3gbnXxA= 9 | github.com/go-co-op/gocron v1.18.0/go.mod h1:sD/a0Aadtw5CpflUJ/lpP9Vfdk979Wl1Sg33HPHg0FY= 10 | github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= 11 | github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= 12 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 13 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 14 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 15 | github.com/mackerelio/go-osstat v0.2.3 h1:jAMXD5erlDE39kdX2CU7YwCGRcxIO33u/p8+Fhe5dJw= 16 | github.com/mackerelio/go-osstat v0.2.3/go.mod h1:DQbPOnsss9JHIXgBStc/dnhhir3gbd3YH+Dbdi7ptMA= 17 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 18 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 21 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 22 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 23 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 24 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 25 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 26 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 27 | golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= 28 | golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 29 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 30 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 31 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 32 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 | golang.org/x/sys v0.0.0-20211001092434-39dca1131b70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 35 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 36 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 37 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 38 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 39 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 40 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 41 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 42 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 43 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 44 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 45 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/5HT2/taro-bot/bot" 7 | "github.com/5HT2/taro-bot/cmd" 8 | "github.com/5HT2/taro-bot/plugins" 9 | "github.com/5HT2/taro-bot/util" 10 | "github.com/diamondburned/arikawa/v3/gateway" 11 | "github.com/diamondburned/arikawa/v3/state" 12 | "log" 13 | "net/http" 14 | "os" 15 | "os/signal" 16 | "runtime" 17 | "strings" 18 | "syscall" 19 | ) 20 | 21 | var ( 22 | pluginDir = flag.String("plugindir", "bin", "Default dir to search for plugins") 23 | debugLog = flag.Bool("debug", false, "Debug messages and faster config saving") 24 | ) 25 | 26 | func main() { 27 | flag.Parse() 28 | log.Printf("Running on Go version: %s\n", runtime.Version()) 29 | 30 | // Load configs before anything else, as it will be needed 31 | bot.LoadConfig() 32 | bot.LoadPluginConfig() 33 | var token = bot.C.BotToken 34 | if token == "" { 35 | log.Fatalln("No bot_token given") 36 | } 37 | 38 | s := state.NewWithIntents("Bot "+token, 39 | gateway.IntentGuildMessages, 40 | gateway.IntentGuildEmojis, 41 | gateway.IntentGuildMessageReactions, 42 | gateway.IntentDirectMessages, 43 | gateway.IntentGuildMembers, 44 | ) 45 | bot.Client = *s 46 | 47 | if s == nil { 48 | log.Fatalln("Session failed: is nil") 49 | } 50 | 51 | // Add handlers 52 | s.AddHandler(func(e *gateway.MessageCreateEvent) { 53 | go cmd.CommandHandler(e) 54 | go cmd.ResponseHandler(e) 55 | }) 56 | s.AddHandler(func(e *gateway.GuildMemberUpdateEvent) { 57 | go cmd.UpdateMemberCache(e) 58 | }) 59 | 60 | if err := s.Open(bot.Ctx); err != nil { 61 | log.Fatalln("Failed to connect:", err) 62 | } 63 | 64 | // Cancel context when SIGINT / SIGKILL / SIGTERM. SIGTERM is used by `docker stop` 65 | ctx, cancel := signal.NotifyContext(bot.Ctx, os.Interrupt, os.Kill, syscall.SIGTERM) 66 | defer cancel() 67 | 68 | if err := s.Open(ctx); err != nil { 69 | log.Println("cannot open:", err) 70 | } 71 | 72 | u, err := s.Me() 73 | if err != nil { 74 | log.Fatalln("Failed to get bot user:", err) 75 | } 76 | bot.User = u 77 | http.DefaultClient = &bot.HttpClient 78 | 79 | // We want http bash requests immediately accessible just in case something needs them. 80 | // Though, this shouldn't really ever happen, it doesn't hurt. 81 | util.RegisterHttpBashRequests() 82 | 83 | // Call plugins after logging in with the bot, but before doing anything else at all 84 | go plugins.RegisterAll(*pluginDir) 85 | 86 | // Set up the bots status 87 | go bot.LoadActivityStatus() 88 | 89 | // Now we can start the routine-based tasks 90 | go bot.SetupConfigSaving() 91 | go bot.Scheduler.StartAsync() 92 | 93 | log.Printf("Started as %v (%s). Debugging is set to `%v`.\n", u.ID, util.FormattedUserTag(*u), *debugLog) 94 | 95 | go checkGuildCounts(s) 96 | 97 | <-ctx.Done() // block until Ctrl+C / SIGINT / SIGTERM 98 | 99 | log.Println("received signal, shutting down") 100 | 101 | bot.SaveConfig() 102 | bot.SavePluginConfig() 103 | plugins.SaveConfig() 104 | plugins.Shutdown() 105 | 106 | if err := s.Close(); err != nil { 107 | log.Println("cannot close:", err) 108 | } 109 | 110 | log.Println("closed connection") 111 | } 112 | 113 | func checkGuildCounts(s *state.State) { 114 | guilds, err := s.Guilds() 115 | if err != nil { 116 | log.Printf("checkGuildCounts: %v\n", err) 117 | } 118 | 119 | fmtGuilds := make([]string, 0) 120 | members := 0 121 | for _, guild := range guilds { 122 | if guildMembers, err := s.Members(guild.ID); err == nil { 123 | numMembers := len(guildMembers) 124 | members += numMembers 125 | fmtGuilds = append(fmtGuilds, fmt.Sprintf("- %v - %s - (%s)", guild.ID, guild.Name, util.JoinIntAndStr(numMembers, "member"))) 126 | } 127 | } 128 | 129 | log.Printf( 130 | "Currently serving %s on %s\n%s", 131 | util.JoinIntAndStr(members, "user"), 132 | util.JoinIntAndStr(len(guilds), "guild"), 133 | strings.Join(fmtGuilds, "\n"), 134 | ) 135 | } 136 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # plugins 2 | 3 | This README describes how to use plugins. See the [README](../README.md) in the root of the repo for a description of what plugins can do. 4 | 5 | 1. [Default plugins](#default-plugins) 6 | 2. [Compiling a plugin](#compiling-a-plugin) 7 | 3. [Hot-reloading plugins](#hot-reloading-plugins) 8 | 4. [Creating a plugin](#creating-a-plugin) 9 | 5. [Docker](#docker) 10 | 11 | ## Default plugins 12 | 13 | Various plugins are loaded by default when using the bot, stored in the `DefaultPlugins` variable in `bot/config.go`. 14 | 15 | You can configure the plugins that are loaded by modifying the `config/plugins.json` file, as described in the main README. 16 | 17 | You are also able to change the directory that the bot looks for plugins in, which is `-pluginDir="bin"` by default. Changing this flag will not compile your plugins for you, the Makefile will check `plugins/` for compiling, and output them to `bin/`. 18 | 19 | ## Compiling a plugin 20 | 21 | Running `make` on its own will compile the bot along with the plugins, by default. This is the code that `make` calls for compiling all plugins: 22 | 23 | ```bash 24 | for d in ./plugins/*/; do 25 | echo "building $$d" 26 | go build -o "bin/" -buildmode=plugin "$$d" 27 | done 28 | ``` 29 | 30 | You can compile a single plugin on your own using 31 | ```bash 32 | go build -o "bin/" -buildmode=plugin "plugins/my-plugin/" 33 | ``` 34 | 35 | If you want your plugin to be loaded, you must add it to the `DefaultPlugins` list in `bot/config.go`, or the `config/plugins.json` file. 36 | 37 | ## Hot-reloading plugins 38 | 39 | Currently, hot-reloading is technically possible but there are no commands to do so from the user-end. This README will be updated as issue [#8](https://github.com/5HT2/taro-bot/issues/8) is updated. 40 | 41 | ## Creating a plugin 42 | 43 | All a plugin has to do is 44 | - Have a `plugin-name.go` with a `package main` which declares a `func InitPlugin(_ *plugins.PluginInit) *plugins.Plugin`. 45 | - Be inside the `plugins/` (or other) directory in a directory under its own name, for example, `plugins/base/base.go` or `plugins/base-extra/base-extra.go` 46 | 47 | The actual [`plugins.go`](https://github.com/5HT2/taro-bot/blob/master/plugins/plugins.go) code is heavily documented and explains the technical process of how plugins are loaded and work. 48 | 49 | An example plugin's `example.go` can be found [in the `plugins` folder](https://github.com/5HT2/taro-bot/blob/master/plugins/example/example.go). 50 | 51 | ## Docker 52 | 53 | You can modify the plugins to be loaded via Docker with the `config/plugins.json` file, as described in the main README. 54 | -------------------------------------------------------------------------------- /plugins/base-fun/base-fun.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/cmd" 7 | "github.com/5HT2/taro-bot/plugins" 8 | "github.com/5HT2/taro-bot/util" 9 | "github.com/diamondburned/arikawa/v3/api" 10 | "github.com/diamondburned/arikawa/v3/discord" 11 | "net/http" 12 | "strconv" 13 | ) 14 | 15 | func InitPlugin(_ *plugins.PluginInit) *plugins.Plugin { 16 | return &plugins.Plugin{ 17 | Name: "Taro Base Fun", 18 | Description: "The fun commands as included as part of the bot", 19 | Version: "1.0.0", 20 | Commands: []bot.CommandInfo{{ 21 | Fn: FrogCommand, 22 | FnName: "FrogCommand", 23 | Name: "frog", 24 | Description: "\\*hands you a random frog pic\\*", 25 | }, { 26 | Fn: StealEmojiCommand, 27 | FnName: "StealEmojiCommand", 28 | Name: "stealemoji", 29 | Aliases: []string{"se"}, 30 | Description: "Upload an emoji to the current guild", 31 | GuildOnly: true, 32 | }}, 33 | Responses: []bot.ResponseInfo{}, 34 | } 35 | } 36 | 37 | func FrogCommand(c bot.Command) error { 38 | frogData, _, err := util.RequestUrl("https://frog.pics/api/random", http.MethodGet) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | type FrogPicture struct { 44 | ImageUrl string `json:"image_url"` 45 | MedianColor string `json:"median_color"` 46 | } 47 | var frogPicture FrogPicture 48 | 49 | if err := json.Unmarshal(frogData, &frogPicture); err != nil { 50 | return err 51 | } 52 | 53 | color, err := util.ParseHexColorFast("#" + frogPicture.MedianColor) 54 | if err != nil { 55 | return bot.SyntaxError("ParseHexColorFast", err.Error()) 56 | } 57 | 58 | embed := discord.Embed{ 59 | Color: discord.Color(util.ConvertColorToInt32(color)), 60 | Image: &discord.EmbedImage{URL: frogPicture.ImageUrl}, 61 | } 62 | 63 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, embed) 64 | return err 65 | } 66 | 67 | func StealEmojiCommand(c bot.Command) error { 68 | // try to get emoji ID 69 | emojiID, argErr := cmd.ParseInt64Arg(c.Args, 1) 70 | // try to get emoji URL 71 | if argErr != nil { 72 | emojiID, argErr = cmd.ParseEmojiUrlArg(c.Args, 1) 73 | } 74 | // try to get sent emoji 75 | if argErr != nil { 76 | emojiID, argErr = cmd.ParseEmojiIdArg(c.Args, 1) 77 | } 78 | // no emoji found 79 | if argErr != nil { 80 | return argErr 81 | } 82 | 83 | // 84 | // we now have the emoji ID, get the name 85 | 86 | emojiName, argErr := cmd.ParseStringArg(c.Args, 2, false) 87 | if argErr != nil { 88 | return bot.GenericError(c.FnName, "getting emoji name", "expected emoji name") 89 | } 90 | 91 | // 92 | // we now have the emoji ID and name, get the bytes 93 | 94 | url := "https://cdn.discordapp.com/emojis/" + strconv.FormatInt(emojiID, 10) 95 | bytes, res, err := util.RequestUrl(url+".gif", http.MethodGet) 96 | if err != nil { 97 | return err 98 | } 99 | if res.StatusCode != 200 { 100 | bytes, res, err = util.RequestUrl(url+".png", http.MethodGet) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | if res.StatusCode != 200 { 106 | return bot.GenericError(c.FnName, "getting emoji bytes", "status was "+res.Status) 107 | } 108 | } 109 | 110 | // now we try to upload it 111 | 112 | image := api.Image{ContentType: res.Header.Get("content-type"), Content: bytes} 113 | createEmojiData := api.CreateEmojiData{ 114 | Name: emojiName, 115 | Image: image, 116 | AuditLogReason: api.AuditLogReason( 117 | "emoji created by " + util.GetUserMention(int64(c.E.Author.ID)), 118 | ), 119 | } 120 | 121 | if emoji, err := bot.Client.CreateEmoji(c.E.GuildID, createEmojiData); err != nil { 122 | // error with uploading 123 | return bot.GenericError(c.FnName, "uploading emoji", err.Error()) 124 | } else { 125 | // uploaded successfully, send a nice embed 126 | _, err := bot.Client.SendMessage( 127 | c.E.ChannelID, 128 | emoji.String(), 129 | discord.Embed{Title: "Emoji stolen ;)", Color: bot.SuccessColor}, 130 | ) 131 | return err 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /plugins/base/base.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/cmd" 7 | "github.com/5HT2/taro-bot/plugins" 8 | "github.com/5HT2/taro-bot/util" 9 | "github.com/diamondburned/arikawa/v3/discord" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | func InitPlugin(_ *plugins.PluginInit) *plugins.Plugin { 15 | return &plugins.Plugin{ 16 | Name: "Taro Base", 17 | Description: "The base commands and responses included as part of the bot", 18 | Version: "1.0.1", 19 | Commands: []bot.CommandInfo{{ 20 | Fn: InviteCommand, 21 | FnName: "InviteCommand", 22 | Name: "invite", 23 | Description: "Invite the bot to your own server!", 24 | }, { 25 | Fn: HelpCommand, 26 | FnName: "HelpCommand", 27 | Name: "help", 28 | Aliases: []string{"h"}, 29 | Description: "Print a list of available commands", 30 | }, { 31 | Fn: OperatorConfigCommand, 32 | FnName: "OperatorConfigCommand", 33 | Name: "operatorconfig", 34 | Aliases: []string{"opcfg"}, 35 | Description: "Allows the bot operator to configure bot-level settings", 36 | }, { 37 | Fn: PingCommand, 38 | FnName: "PingCommand", 39 | Name: "ping", 40 | Description: "Returns the current API latency", 41 | }, { 42 | Fn: PrefixCommand, 43 | FnName: "PrefixCommand", 44 | Name: "prefix", 45 | Description: "Set the bot prefix for your guild", 46 | GuildOnly: true, 47 | }}, 48 | Responses: []bot.ResponseInfo{{ 49 | Fn: PrefixResponse, 50 | Regexes: []string{"<@!?DISCORD_BOT_ID>", "(prefix|help)"}, 51 | MatchMin: 2, 52 | }}, 53 | } 54 | } 55 | 56 | func OperatorConfigCommand(c bot.Command) error { 57 | if err := cmd.HasPermission(c, cmd.PermOperator); err != nil { 58 | return err 59 | } 60 | 61 | arg1, _ := cmd.ParseStringArg(c.Args, 1, true) 62 | args, _ := cmd.ParseStringSliceArg(c.Args, 2, -1) 63 | argInt, _ := cmd.ParseInt64Arg(c.Args, 2) 64 | joinedArgs := strings.Join(args, " ") 65 | 66 | t := "Operator Config " 67 | var err error 68 | 69 | switch arg1 { 70 | case "activity_name": 71 | bot.C.Run(func(co *bot.Config) { 72 | if len(args) == 0 { 73 | _, err = cmd.SendEmbed(c.E, t+"`activity_name`", fmt.Sprintf("The current `activity_name` is\n```\n%s\n```", co.ActivityName), bot.DefaultColor) 74 | } else { 75 | co.ActivityName = joinedArgs 76 | _, err = cmd.SendEmbed(c.E, t+"`activity_name`", fmt.Sprintf("Set `activity_name` to\n```\n%s\n```", co.ActivityName), bot.SuccessColor) 77 | } 78 | }) 79 | bot.LoadActivityStatus() 80 | case "activity_url": 81 | bot.C.Run(func(co *bot.Config) { 82 | if len(args) == 0 { 83 | _, err = cmd.SendEmbed(c.E, t+"`activity_url`", fmt.Sprintf("The current `activity_url` is\n```\n%s\n```", co.ActivityUrl), bot.DefaultColor) 84 | } else { 85 | co.ActivityUrl = joinedArgs 86 | _, err = cmd.SendEmbed(c.E, t+"`activity_url`", fmt.Sprintf("Set `activity_url` to\n```\n%s\n```", co.ActivityUrl), bot.SuccessColor) 87 | } 88 | }) 89 | bot.LoadActivityStatus() 90 | case "activity_type": 91 | bot.C.Run(func(co *bot.Config) { 92 | if argInt == -1 { 93 | _, err = cmd.SendEmbed(c.E, t+"`activity_type`", fmt.Sprintf("The current `activity_type` is `%v`", co.ActivityType), bot.DefaultColor) 94 | } else { 95 | co.ActivityType = uint8(argInt) 96 | _, err = cmd.SendEmbed(c.E, t+"`activity_type`", fmt.Sprintf("Set `activity_type` to `%v`", co.ActivityType), bot.SuccessColor) 97 | } 98 | }) 99 | bot.LoadActivityStatus() 100 | case "operator_channel": 101 | bot.C.Run(func(co *bot.Config) { 102 | if argInt == -1 { 103 | _, err = cmd.SendEmbedFooter(c.E, t+"`operator_channel`", fmt.Sprintf("The current `operator_channel` is `%v`", co.OperatorChannel), "This change might take a reload to apply!", bot.DefaultColor) 104 | } else { 105 | co.OperatorChannel = argInt 106 | _, err = cmd.SendEmbedFooter(c.E, t+"`operator_channel`", fmt.Sprintf("Set `operator_channel` to `%v`", co.OperatorChannel), "This change might take a reload to apply!", bot.WarnColor) 107 | } 108 | }) 109 | case "operator_ids": 110 | bot.C.Run(func(co *bot.Config) { 111 | if len(args) == 0 { 112 | _, err = cmd.SendEmbed(c.E, t+"`operator_ids`", fmt.Sprintf("The current `operator_ids` is `%v`", co.OperatorIDs), bot.DefaultColor) 113 | } else { 114 | ids := make([]int64, 0) 115 | for _, arg := range args { 116 | if id, err := strconv.ParseInt(arg, 10, 64); err == nil { 117 | ids = append(ids, id) 118 | } 119 | } 120 | co.OperatorIDs = ids 121 | _, err = cmd.SendEmbed(c.E, t+"`operator_ids`", fmt.Sprintf("Set `operator_ids` to `%v`", co.OperatorIDs), bot.SuccessColor) 122 | } 123 | }) 124 | case "reset_prefix": 125 | if argInt == -1 || argInt == 0 { 126 | _, err = cmd.SendEmbed(c.E, t+"`reset_prefix`", "You have to provide a guild ID to reset its prefix!", bot.ErrorColor) 127 | } else { 128 | _, err = bot.SetPrefix(c.FnName, c.E.GuildID, bot.DefaultPrefix) 129 | if err == nil { 130 | _, err = cmd.SendEmbed(c.E, t+"`reset_prefix`", fmt.Sprintf("Reset prefix for guild `%v`!", argInt), bot.SuccessColor) 131 | } 132 | } 133 | default: 134 | _, err = cmd.SendEmbed(c.E, 135 | "Operator Config", 136 | "Available arguments are:\n- `activity_name [activity name]`\n- `activity_url [activity url]`\n- `activity_type [activity type]`\n- `operator_channel [operator channel id]`\n- `operator_ids [operator ids]`\n- `reset_prefix [guild id]`", 137 | bot.DefaultColor) 138 | } 139 | 140 | return err 141 | } 142 | 143 | func HelpCommand(c bot.Command) error { 144 | fmtCmds := make([]string, 0) 145 | for _, command := range bot.Commands { 146 | // Filter GuildOnly commands when not in a guild 147 | if !command.GuildOnly || c.E.GuildID.IsValid() { 148 | fmtCmds = append(fmtCmds, command.MarkdownString()) 149 | } 150 | } 151 | 152 | _, err := cmd.SendEmbed(c.E, 153 | "Taro Help", 154 | strings.Join(fmtCmds, "\n\n"), 155 | bot.DefaultColor) 156 | return err 157 | } 158 | 159 | func InviteCommand(c bot.Command) error { 160 | _, err := cmd.SendEmbed(c.E, 161 | bot.User.Username+" invite", fmt.Sprintf("[Click to add me to your own server!](https://discord.com/oauth2/authorize?client_id=%v&permissions=%v&scope=bot)", bot.User.ID, bot.PermissionsHex), 162 | bot.SuccessColor, 163 | ) 164 | return err 165 | } 166 | 167 | func PingCommand(c bot.Command) error { 168 | if msg, err := cmd.SendEmbed(c.E, 169 | "Ping!", 170 | "Waiting for API response...", 171 | bot.DefaultColor); err != nil { 172 | return err 173 | } else { 174 | msgTime := c.E.Timestamp.Time().UnixMilli() 175 | curTime := msg.Timestamp.Time().UnixMilli() 176 | 177 | embed := cmd.MakeEmbed("Pong!", fmt.Sprintf("Latency is %sms", util.FormattedNum(curTime-msgTime)), bot.SuccessColor) 178 | _, err = bot.Client.EditMessage(msg.ChannelID, msg.ID, "", embed) 179 | return err 180 | } 181 | } 182 | 183 | func PrefixCommand(c bot.Command) error { 184 | arg, argErr := cmd.ParseStringArg(c.Args, 1, false) 185 | if argErr != nil { 186 | return argErr 187 | } 188 | 189 | arg, err := bot.SetPrefix(c.FnName, c.E.GuildID, arg) 190 | 191 | embed := discord.Embed{ 192 | Description: "Set prefix to `" + arg + "`", 193 | Footer: &discord.EmbedFooter{Text: "At any time you can ping the bot with the word \"prefix\" to get the current prefix"}, 194 | Color: bot.SuccessColor, 195 | } 196 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, embed) 197 | return err 198 | } 199 | 200 | func PrefixResponse(r bot.Response) { 201 | if !r.E.GuildID.IsValid() { 202 | _, _ = cmd.SendEmbed(r.E, "", "Commands in DMs don't use a prefix!\nUse `help` for a list of commands.", bot.DefaultColor) 203 | return 204 | } 205 | 206 | prefix := bot.DefaultPrefix 207 | bot.GuildContext(r.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 208 | prefix = g.Prefix 209 | return g, "PrefixResponse" 210 | }) 211 | 212 | _, _ = cmd.SendEmbed(r.E, "", fmt.Sprintf("The current prefix is `%s`\nUse `%shelp` for a list of commands.", prefix, prefix), bot.DefaultColor) 213 | } 214 | -------------------------------------------------------------------------------- /plugins/bookmarker/bookmarker.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/cmd" 7 | "github.com/5HT2/taro-bot/plugins" 8 | "github.com/5HT2/taro-bot/util" 9 | "github.com/diamondburned/arikawa/v3/discord" 10 | "github.com/diamondburned/arikawa/v3/gateway" 11 | "reflect" 12 | "sync" 13 | ) 14 | 15 | var ( 16 | p *plugins.Plugin 17 | mutex sync.Mutex 18 | 19 | enabledFooter = discord.EmbedFooter{Text: "Messages will be DMed to you when you react with a 🔖."} 20 | escapedBookmark = "%F0%9F%94%96" 21 | ) 22 | 23 | type config struct { 24 | EnabledGuilds map[string]bool `json:"enabled_guilds,omitempty"` // [guild id]bool 25 | } 26 | 27 | func InitPlugin(i *plugins.PluginInit) *plugins.Plugin { 28 | // All the `FeatureNameInfo` fields are optional, and can be omitted. 29 | p = &plugins.Plugin{ 30 | Name: "Bookmarker", 31 | Description: "Bookmark messages to your DMs", 32 | Version: "1.0.0", 33 | Commands: []bot.CommandInfo{{ 34 | Fn: BookmarkConfigCommand, 35 | FnName: "BookmarkConfigCommand", 36 | Name: "bookmarkconfig", 37 | Aliases: []string{"bcfg"}, 38 | Description: "Enable or disable bookmarking messages", 39 | GuildOnly: true, 40 | }}, 41 | ConfigType: reflect.TypeOf(config{}), 42 | Handlers: []bot.HandlerInfo{{ 43 | Fn: BookmarkReactionHandler, 44 | FnName: "BookmarkReactionHandler", 45 | FnType: reflect.TypeOf(func(*gateway.MessageReactionAddEvent) {}), 46 | }}, 47 | } 48 | p.ConfigDir = i.ConfigDir 49 | p.Config = p.LoadConfig() 50 | return p 51 | } 52 | 53 | func BookmarkConfigCommand(c bot.Command) error { 54 | mutex.Lock() 55 | defer mutex.Unlock() 56 | 57 | enabled := true // enabled by default 58 | id := c.E.GuildID.String() 59 | 60 | if p.Config != nil { 61 | enabledGuild, ok := p.Config.(config).EnabledGuilds[id] 62 | 63 | if ok { 64 | enabled = enabledGuild 65 | } 66 | } 67 | 68 | var err error = nil 69 | arg, _ := cmd.ParseStringArg(c.Args, 1, true) 70 | 71 | switch arg { 72 | case "toggle": 73 | enabled = !enabled 74 | if enabled { 75 | embed := cmd.MakeEmbed(p.Name, "Bookmarker enabled!", bot.SuccessColor) 76 | embed.Footer = &enabledFooter 77 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, embed) 78 | } else { 79 | _, err = cmd.SendEmbed(c.E, p.Name, "Bookmarker disabled!", bot.ErrorColor) 80 | } 81 | default: 82 | if enabled { 83 | embed := cmd.MakeEmbed(p.Name, "Bookmarker is currently enabled!\nUse `bookmarkerconfig toggle` to disable it.", bot.SuccessColor) 84 | embed.Footer = &enabledFooter 85 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, embed) 86 | } else { 87 | _, err = cmd.SendEmbed(c.E, p.Name, "Bookmarker is currently disabled!\nUse `bookmarkerconfig toggle` to enable it.", bot.ErrorColor) 88 | } 89 | } 90 | 91 | if p.Config == nil { 92 | guilds := make(map[string]bool) 93 | guilds[id] = enabled 94 | cfg := config{EnabledGuilds: guilds} 95 | p.Config = cfg 96 | } else { 97 | p.Config.(config).EnabledGuilds[id] = enabled 98 | } 99 | 100 | return err 101 | } 102 | 103 | func BookmarkReactionHandler(i interface{}) { 104 | mutex.Lock() 105 | defer mutex.Unlock() 106 | defer util.LogPanic() 107 | e := i.(*gateway.MessageReactionAddEvent) 108 | 109 | // Bot reacted 110 | if e.Member.User.Bot { 111 | return 112 | } 113 | 114 | // Not a bookmark emoji 115 | if e.Emoji.APIString().PathString() != escapedBookmark { 116 | return 117 | } 118 | 119 | sendBookmark := false 120 | 121 | if p.Config == nil { 122 | // Enabled by default 123 | sendBookmark = true 124 | } else { 125 | enabled, ok := p.Config.(config).EnabledGuilds[e.GuildID.String()] 126 | 127 | // If not in the config (enabled by default) or explicitly enabled 128 | if !ok || enabled { 129 | sendBookmark = true 130 | } 131 | } 132 | 133 | if sendBookmark { 134 | msg, err := bot.Client.Message(e.ChannelID, e.MessageID) 135 | if err != nil { 136 | return 137 | } 138 | 139 | content := fmt.Sprintf("🔖 from <#%v>", e.ChannelID) 140 | field := discord.EmbedField{Name: "Source", Value: cmd.CreateMessageLink(int64(e.GuildID), msg, true, false)} 141 | footer := discord.EmbedFooter{Text: fmt.Sprintf("%v", msg.Author.ID)} 142 | 143 | description, image := cmd.GetEmbedAttachmentAndContent(*msg) 144 | 145 | embed := discord.Embed{ 146 | Description: description, 147 | Author: cmd.CreateEmbedAuthorUser(msg.Author), 148 | Timestamp: msg.Timestamp, 149 | Fields: []discord.EmbedField{field}, 150 | Footer: &footer, 151 | Image: image, 152 | Color: bot.BlueColor, 153 | } 154 | 155 | _, err = cmd.SendDirectMessageEmbedSafe(e.UserID, content, &embed) 156 | if err != nil { 157 | _, _ = cmd.SendCustomEmbed(e.ChannelID, cmd.MakeEmbed("Failed to send bookmark!\nServer -> Privacy Settings -> ✅ Allow direct messages from server members.", fmt.Sprintf("```\n%s\n```", err), bot.ErrorColor)) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /plugins/doses-logger/doses-logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/cmd" 7 | "github.com/5HT2/taro-bot/plugins" 8 | "github.com/5HT2/taro-bot/util" 9 | "github.com/5HT2C/http-bash-requests/httpBashRequests" 10 | "net/http" 11 | "reflect" 12 | "strings" 13 | ) 14 | 15 | var ( 16 | p *plugins.Plugin 17 | ) 18 | 19 | type config struct { 20 | FohToken string `json:"foh_token"` 21 | } 22 | 23 | func InitPlugin(i *plugins.PluginInit) *plugins.Plugin { 24 | p = &plugins.Plugin{ 25 | Name: "Doses Logger", 26 | Description: "An interface with the `doses-logger` CLI tool.", 27 | Version: "1.0.0", 28 | Commands: []bot.CommandInfo{{ 29 | Fn: DoseCommand, 30 | FnName: "DoseCommand", 31 | Name: "dose", 32 | Description: "Manage medication and substance doses", 33 | }}, 34 | ConfigType: reflect.TypeOf(config{}), 35 | } 36 | p.ConfigDir = i.ConfigDir 37 | p.Config = p.LoadConfig() 38 | return p 39 | } 40 | 41 | func DoseCommand(c bot.Command) error { 42 | if p.Config == nil || p.Config.(config).FohToken == "" { 43 | return bot.GenericError(c.FnName, "running command", "`foh_token` not set") 44 | } 45 | 46 | // Make URL of public file 47 | file := fmt.Sprintf("http://localhost:6010/media/doses-%v.json", c.E.Author.ID) 48 | 49 | // Get args to pass to command 50 | argsTmp, _ := cmd.ParseStringSliceArg(c.Args, 1, -1) 51 | args := make([]string, 0) 52 | frog := false 53 | 54 | // Strip non-allowed args being passed 55 | for _, arg := range argsTmp { 56 | // Strip leading dashes until we only have one 57 | for { 58 | // arg needs to be longer than 2, if it's shorter it can't have --[some flag] 59 | // arg also needs to have a dash in the first and second position, otherwise we don't need to strip a dash 60 | if len(arg) <= 2 || arg[0] != '-' || arg[1] != '-' { 61 | break 62 | } 63 | 64 | arg = arg[1:] 65 | } 66 | 67 | switch strings.ToLower(arg) { 68 | case "-url", "-token": 69 | continue 70 | case "-frog": 71 | frog = true 72 | file = "http://localhost:6010/media/doses.json" 73 | continue 74 | default: 75 | args = append(args, arg) 76 | } 77 | } 78 | 79 | // final arg parsing 80 | pArgs := strings.Join(args, " ") 81 | sep := "" 82 | if len(pArgs) > 0 { 83 | sep = " " 84 | } 85 | 86 | if frog && (strings.Contains(pArgs, "-add") || strings.Contains(pArgs, "-rm")) { 87 | return bot.GenericError(c.FnName, "parsing args", "`-frog` cannot be used with `-add` or `-rm`!") 88 | } 89 | 90 | parsedArgs := fmt.Sprintf(`%s%s-token=%s -url=%s`, pArgs, sep, p.Config.(config).FohToken, file) 91 | // end arg parsing 92 | 93 | // get dose db for user 94 | res, _ := http.Get(file) 95 | if res == nil { 96 | _, err := cmd.SendEmbed(c.E, c.Name, "`res` was nil, is fs-over-http running?", bot.ErrorColor) 97 | return err 98 | } 99 | 100 | // if not found, do we need to make a json file for the user? 101 | if res.StatusCode == 404 { 102 | file = fmt.Sprintf("http://localhost:6010/public/media/doses-%v.json", c.E.Author.ID) 103 | 104 | // TODO: Use http stdlib 105 | if res, err := httpBashRequests.Run(fmt.Sprintf("curl -X POST -H \"Auth: %s\" %s -F \"content=[]\"", p.Config.(config).FohToken, file)); err != nil { 106 | return err 107 | } else if _, err := cmd.SendEmbed(c.E, "", fmt.Sprintf("```\n%s\n```", util.TailLinesLimit(string(res), 2040)), bot.DefaultColor); err != nil { 108 | return err 109 | } 110 | 111 | cmd.CommandHandlerWithCommand(c.E, c.Name, c.Args) 112 | return nil 113 | } else if res.StatusCode != 200 { // another http error? (shouldn't happen ever) 114 | _, err := cmd.SendEmbed(c.E, c.Name, fmt.Sprintf("Status for %s was %v, do you need to make a new file?", file, res.StatusCode), bot.ErrorColor) 115 | return err 116 | } 117 | 118 | // now we execute the doses-logger 119 | if res, err := httpBashRequests.RunBinary(parsedArgs, "doses-logger/doses-logger", "", true); err != nil { 120 | return err 121 | } else { 122 | _, err := cmd.SendEmbed(c.E, "", fmt.Sprintf("```\n%s\n```", util.TailLinesLimit(string(res), 2040)), bot.DefaultColor) 123 | return err 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /plugins/example/example.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/cmd" 7 | "github.com/5HT2/taro-bot/plugins" 8 | "github.com/5HT2/taro-bot/util" 9 | "github.com/diamondburned/arikawa/v3/gateway" 10 | "github.com/go-co-op/gocron" 11 | "log" 12 | "math/rand" 13 | "reflect" 14 | ) 15 | 16 | var p *plugins.Plugin 17 | 18 | type config struct { 19 | Fn string `json:"fn"` 20 | } 21 | 22 | // InitPlugin is called when a plugin is registered, and is used to register commands, responses, jobs and handlers. 23 | func InitPlugin(i *plugins.PluginInit) *plugins.Plugin { 24 | // All the `FeatureNameInfo` fields are optional, and can be omitted. 25 | p = &plugins.Plugin{ 26 | Name: "Example plugin", 27 | Description: "This is an example plugin", 28 | Version: "1.0.0", 29 | // Commands are called explicitly, with a prefix. For example, `.example` or `.err`. 30 | Commands: []bot.CommandInfo{{ 31 | Fn: ExampleCommand, 32 | FnName: "ExampleCommand", 33 | Name: "example", 34 | Description: "This command is an example", 35 | }, { 36 | Fn: ErrorCommand, 37 | FnName: "ErrorCommand", 38 | Name: "error", 39 | Aliases: []string{"err", "e"}, 40 | Description: "This command will only return errors", 41 | }}, 42 | // This is used to ensure type safety when loading the Config 43 | // If you forget to declare this and use p.LoadConfig(), you will get a safe panic when loading 44 | ConfigType: reflect.TypeOf(config{}), 45 | // Responses are called based on regex matching the message. 46 | // DISCORD_BOT_ID is replaced in the regex matching, and this response will be called by pinging the bot with the word test or help. 47 | // MatchMin means that a minimum of two of the Regexes need to match. 48 | // Using a MatchMin of 1 means you would only need to match a ping OR the test|help sequence. 49 | Responses: []bot.ResponseInfo{{ 50 | Fn: TestResponse, 51 | Regexes: []string{"<@!?DISCORD_BOT_ID>", "(test|help)"}, 52 | MatchMin: 2, 53 | }}, 54 | // Jobs are called when they are scheduled. This job is scheduled for every minute. 55 | // Other examples include `bot.Scheduler.Every(1).Day().At("04:00")` (running a job every day at 4am) 56 | // as well as `bot.Scheduler.Cron("15 4 * * SUN")` (running a job every sunday at 4:15am, see https://crontab.guru/ for more). 57 | // The Name is used to identify the job when registering, and should be unique. The name itself doesn't actually matter as long as it is unique. 58 | Jobs: []bot.JobInfo{{ 59 | Fn: func() (*gocron.Job, error) { 60 | return bot.Scheduler.Every(1).Minute().Do(EveryMinuteJob) 61 | }, 62 | Name: "example-plugin-every-minute", 63 | }}, 64 | // Handlers are functions that are registered to discord's event gateway. The documentation can be found at https://discord.com/developers/docs/topics/gateway 65 | // FnType is used to ensure type safety and simplify registration syntax. 66 | Handlers: []bot.HandlerInfo{{ 67 | Fn: ReactionHandler, 68 | FnName: "ReactionHandler", 69 | FnType: reflect.TypeOf(func(*gateway.MessageReactionAddEvent) {}), 70 | }}, 71 | // ShutdownFn optionally allows you to register a function that will run when the bot has been killed / stopped. 72 | ShutdownFn: Shutdown, 73 | StartupFn: Startup, 74 | } 75 | // This is required to set the config directory initially. 76 | p.ConfigDir = i.ConfigDir 77 | // When loading a config, you should cast not cast it, as it will be nil by default. 78 | // Instead, check if it is nil before doing .(config) in order to use it. 79 | p.Config = p.LoadConfig() 80 | return p 81 | } 82 | 83 | // Startup will run after all plugins have been loaded and before schedulers are started 84 | func Startup() { 85 | log.Println("hello from the example plugin!") 86 | } 87 | 88 | // Shutdown will run when the bot is killed / stopped, and says goodbye to the console. 89 | func Shutdown() { 90 | log.Println("goodbye from the example plugin!") 91 | } 92 | 93 | // ExampleCommand (.example) is a basic example of returning just a message with a command. 94 | func ExampleCommand(c bot.Command) error { 95 | _, err := cmd.SendEmbed(c.E, "Example Command", "This command is an example", bot.DefaultColor) 96 | return err // error here is an error received by discord, it's usually nil, but we want to handle it anyways 97 | } 98 | 99 | // ErrorCommand (.err) will return only errors when called, as an example of how errors are handled in the bot. 100 | func ErrorCommand(c bot.Command) error { 101 | // Errors are not usually defined in the command, and instead you use the return function to handle an error 102 | // mid-command when you want to stop. 103 | errors := []bot.Error{{ 104 | Func: "ErrorCommand", 105 | Action: "doing something", 106 | Err: "expected something else", 107 | }, { 108 | Func: "ErrorCommand", 109 | Action: "doing another thing", 110 | Err: "expected not an error", 111 | }, { 112 | Func: "ErrorCommand", 113 | Action: "one last thing", 114 | Err: "big oops", 115 | }} 116 | 117 | // Choose a random error to return 118 | randomIndex := rand.Intn(len(errors)) 119 | err := errors[randomIndex] 120 | 121 | _, _ = cmd.SendMessage(c.E, "We're doing something here, doesn't matter") 122 | return &err // return random error as an example 123 | } 124 | 125 | // TestResponse will send an embed when a message contains the @mention (ping) of the bot and the word help or the word test. 126 | func TestResponse(r bot.Response) { 127 | _, _ = cmd.SendEmbed(r.E, "Test Response", "This response was called auto-magically", bot.DefaultColor) 128 | } 129 | 130 | // EveryMinuteJob will print something to the console every minute. 131 | func EveryMinuteJob() { 132 | log.Printf("This was called from the example plugin, and is called every minute\n") 133 | } 134 | 135 | // ReactionHandler will send a message whenever someone adds a reaction to a message, as well as info about the reaction. 136 | func ReactionHandler(i interface{}) { 137 | defer util.LogPanic() // handle panics and log them. panics are safe even without this, but aren't logged. 138 | e := i.(*gateway.MessageReactionAddEvent) // this is necessary to access the event. FnType ensures that this is safe. 139 | 140 | _, _ = cmd.SendCustomMessage(e.ChannelID, fmt.Sprintf("This is in response to a reaction added by <@%v>, the emoji name is `%s`", e.UserID, e.Emoji.Name)) 141 | } 142 | -------------------------------------------------------------------------------- /plugins/leave-join-msg/leave-join-msg.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/5HT2/taro-bot/bot" 7 | "github.com/5HT2/taro-bot/cmd" 8 | "github.com/5HT2/taro-bot/plugins" 9 | "github.com/5HT2/taro-bot/util" 10 | "github.com/diamondburned/arikawa/v3/discord" 11 | "github.com/diamondburned/arikawa/v3/gateway" 12 | "log" 13 | "reflect" 14 | "strings" 15 | "sync" 16 | ) 17 | 18 | var ( 19 | p *plugins.Plugin 20 | mutex sync.Mutex 21 | ) 22 | 23 | type config struct { 24 | Guilds map[string]MsgConfig `json:"guilds,omitempty"` // [guild id]MsgConfig 25 | } 26 | 27 | type MsgConfig struct { 28 | JoinMessage Message `json:"join_message"` 29 | LeaveMessage Message `json:"leave_message"` 30 | } 31 | 32 | type Message struct { 33 | Enabled bool `json:"enabled,omitempty"` 34 | Channel int64 `json:"channel,omitempty"` 35 | Content string `json:"content,omitempty"` 36 | Embed *discord.Embed `json:"embed,omitempty"` 37 | CollapseMessage bool `json:"collapse_message,omitempty"` 38 | LastMessage int64 `json:"last_message,omitempty"` 39 | } 40 | 41 | func InitPlugin(i *plugins.PluginInit) *plugins.Plugin { 42 | p = &plugins.Plugin{ 43 | Name: "Leave & Join Msg", 44 | Description: "Send a message when a user leaves or joins. `USER_ID` and `USER_TAG` are allowed for use in messages", 45 | Version: "1.0.0", 46 | Commands: []bot.CommandInfo{{ 47 | Fn: LeaveJoinMsgCfgCommand, 48 | FnName: "LeaveJoinMsgCfgCommand", 49 | Name: "leavejoinconfig", 50 | Aliases: []string{"ljcfg"}, 51 | Description: "Edit leave & join msg config", 52 | GuildOnly: true, 53 | }}, 54 | ConfigType: reflect.TypeOf(config{}), 55 | Handlers: []bot.HandlerInfo{{ 56 | Fn: LeaveJoinAddHandler, 57 | FnName: "LeaveJoinAddHandler", 58 | FnType: reflect.TypeOf(func(event *gateway.GuildMemberAddEvent) {}), 59 | }, { 60 | Fn: LeaveJoinRemoveHandler, 61 | FnName: "LeaveJoinRemoveHandler", 62 | FnType: reflect.TypeOf(func(event *gateway.GuildMemberRemoveEvent) {}), 63 | }}, 64 | } 65 | p.ConfigDir = i.ConfigDir 66 | p.Config = p.LoadConfig() 67 | return p 68 | } 69 | 70 | func LeaveJoinAddHandler(i interface{}) { 71 | mutex.Lock() 72 | defer mutex.Unlock() 73 | defer util.LogPanic() 74 | e := i.(*gateway.GuildMemberAddEvent) 75 | 76 | if p.Config == nil { 77 | return 78 | } 79 | 80 | if cfg, ok := p.Config.(config).Guilds[e.GuildID.String()]; ok && cfg.JoinMessage.Enabled { 81 | message := strings.ReplaceAll(cfg.JoinMessage.Content, "USER_ID", e.User.ID.String()) 82 | message = strings.ReplaceAll(message, "USER_TAG", util.FormattedUserTag(e.User)) 83 | 84 | if msg, err := cmd.SendMessageEmbedSafe(discord.ChannelID(cfg.JoinMessage.Channel), message, cfg.JoinMessage.Embed); err != nil { 85 | log.Printf("error sending join message: %v\n", err) 86 | } else { 87 | if cfg.JoinMessage.CollapseMessage && cfg.JoinMessage.LastMessage != 0 { 88 | _ = bot.Client.DeleteMessage(discord.ChannelID(cfg.JoinMessage.Channel), discord.MessageID(cfg.JoinMessage.LastMessage), "join message collapsed") 89 | } 90 | 91 | cfg.JoinMessage.LastMessage = int64(msg.ID) 92 | p.Config.(config).Guilds[e.GuildID.String()] = cfg 93 | } 94 | } 95 | } 96 | 97 | func LeaveJoinRemoveHandler(i interface{}) { 98 | mutex.Lock() 99 | defer mutex.Unlock() 100 | defer util.LogPanic() 101 | e := i.(*gateway.GuildMemberRemoveEvent) 102 | 103 | if p.Config == nil { 104 | return 105 | } 106 | 107 | if cfg, ok := p.Config.(config).Guilds[e.GuildID.String()]; ok && cfg.LeaveMessage.Enabled { 108 | message := strings.ReplaceAll(cfg.LeaveMessage.Content, "USER_ID", e.User.ID.String()) 109 | message = strings.ReplaceAll(message, "USER_TAG", util.FormattedUserTag(e.User)) 110 | 111 | if msg, err := cmd.SendMessageEmbedSafe(discord.ChannelID(cfg.LeaveMessage.Channel), message, cfg.LeaveMessage.Embed); err != nil { 112 | log.Printf("error sending leave message: %v\n", err) 113 | } else { 114 | if cfg.LeaveMessage.CollapseMessage && cfg.LeaveMessage.LastMessage != 0 { 115 | _ = bot.Client.DeleteMessage(discord.ChannelID(cfg.LeaveMessage.Channel), discord.MessageID(cfg.LeaveMessage.LastMessage), "leave message collapsed") 116 | } 117 | 118 | cfg.LeaveMessage.LastMessage = int64(msg.ID) 119 | p.Config.(config).Guilds[e.GuildID.String()] = cfg 120 | } 121 | } 122 | } 123 | 124 | func LeaveJoinMsgCfgCommand(c bot.Command) error { 125 | if err := cmd.HasPermission(c, cmd.PermModerate); err != nil { 126 | return err 127 | } 128 | 129 | mutex.Lock() 130 | defer mutex.Unlock() 131 | 132 | msgConfig := MsgConfig{JoinMessage: Message{}, LeaveMessage: Message{}} 133 | 134 | if p.Config != nil { 135 | if guildMsgConfig, ok := p.Config.(config).Guilds[c.E.GuildID.String()]; ok { 136 | msgConfig = guildMsgConfig 137 | } 138 | } 139 | 140 | var err error = nil 141 | 142 | arg, _ := cmd.ParseStringArg(c.Args, 1, true) 143 | arg2, _ := cmd.ParseStringArg(c.Args, 2, true) 144 | arg3, argErr := cmd.ParseStringSliceArg(c.Args, 3, -1) 145 | argChannel, argChannelErr := cmd.ParseChannelArg(c.Args, 3) 146 | argEnabled, argEnabledErr := cmd.ParseBoolArg(c.Args, 3) 147 | argCollapse, argCollapseErr := cmd.ParseBoolArg(c.Args, 3) 148 | 149 | defaultResponse := func() error { 150 | _, err := cmd.SendEmbed(c.E, "Leave & Join Message", "Available arguments are:\n- `join|leave channel|message|embed|enabled|collapse `", bot.DefaultColor) 151 | return err 152 | } 153 | 154 | subArgs := func(s string, msg Message) Message { 155 | switch arg2 { 156 | case "channel": 157 | if argChannelErr != nil { 158 | if msg.Channel == 0 { 159 | _, err = cmd.SendEmbed(c.E, s+" Message Channel", s+" Message channel is not set!", bot.WarnColor) 160 | 161 | } else { 162 | _, err = cmd.SendEmbed(c.E, s+" Message Channel", fmt.Sprintf("%s Message channel is set to <#%v>!", s, msg.Channel), bot.DefaultColor) 163 | } 164 | } else { 165 | msg.Channel = argChannel 166 | _, err = cmd.SendEmbed(c.E, s+" Message Channel", fmt.Sprintf("Set %s Message channel to <#%v>!", s, argChannel), bot.SuccessColor) 167 | } 168 | case "message": 169 | if argErr != nil { 170 | _, err = cmd.SendEmbed(c.E, s+" Message Content", fmt.Sprintf("%s Message content is set to \n```\n%s\n```", s, msg.Content), bot.DefaultColor) 171 | } else { 172 | msg.Content = strings.Join(arg3, " ") 173 | _, err = cmd.SendEmbed(c.E, s+" Message Content", fmt.Sprintf("Set %s Message content to \n```\n%s\n```", s, msg.Content), bot.SuccessColor) 174 | } 175 | case "embed": 176 | if argErr != nil || len(arg3) == 0 { 177 | embed := cmd.MakeEmbed(s+" Message Embed", fmt.Sprintf("%s Message embed is set to:", s), bot.DefaultColor) 178 | 179 | if msg.Embed != nil { 180 | log.Println("here1") 181 | _, err = bot.Client.SendMessage(c.E.ChannelID, "", embed, *msg.Embed) 182 | } else { 183 | log.Println("here2") 184 | _, err = bot.Client.SendMessage(c.E.ChannelID, "", embed) 185 | } 186 | } else { 187 | if err == nil { 188 | var embed discord.Embed 189 | err = json.Unmarshal([]byte(strings.Join(arg3, " ")), &embed) 190 | 191 | if err == nil { 192 | msg.Embed = &embed 193 | _, err = bot.Client.SendMessage(c.E.ChannelID, "", cmd.MakeEmbed(s+" Message Embed", fmt.Sprintf("Set %s Message embed to:", s), bot.SuccessColor), embed) 194 | } 195 | } 196 | } 197 | case "enabled": 198 | if argEnabledErr != nil { 199 | if msg.Enabled { 200 | _, err = cmd.SendEmbed(c.E, s+" Message", s+" Message is enabled!", bot.SuccessColor) 201 | } else { 202 | _, err = cmd.SendEmbed(c.E, s+" Message", s+" Message is not enabled!", bot.WarnColor) 203 | } 204 | } else { 205 | msg.Enabled = argEnabled 206 | if argEnabled { 207 | _, err = cmd.SendEmbed(c.E, s+" Message", "✅ Enabled "+s+" Message!", bot.SuccessColor) 208 | } else { 209 | _, err = cmd.SendEmbed(c.E, s+" Message", "⛔ Disabled "+s+" Message!", bot.ErrorColor) 210 | } 211 | } 212 | case "collapse": 213 | if argCollapseErr != nil { 214 | if msg.CollapseMessage { 215 | _, err = cmd.SendEmbed(c.E, s+" Message Collapsing", s+" Message Collapsing is enabled!", bot.SuccessColor) 216 | } else { 217 | _, err = cmd.SendEmbed(c.E, s+" Message Collapsing", s+" Message Collapsing is not enabled!", bot.WarnColor) 218 | } 219 | } else { 220 | msg.CollapseMessage = argCollapse 221 | if argCollapse { 222 | _, err = cmd.SendEmbed(c.E, s+" Message Collapsing", "✅ Enabled collapsing for "+s+" Message!", bot.SuccessColor) 223 | } else { 224 | _, err = cmd.SendEmbed(c.E, s+" Message Collapsing", "⛔ Disabled collapsing for "+s+" Message!", bot.ErrorColor) 225 | } 226 | } 227 | default: 228 | err = defaultResponse() 229 | } 230 | 231 | return msg 232 | } 233 | 234 | switch arg { 235 | case "join": 236 | msgConfig.JoinMessage = subArgs("Join", msgConfig.JoinMessage) 237 | case "leave": 238 | msgConfig.LeaveMessage = subArgs("Leave", msgConfig.LeaveMessage) 239 | default: 240 | err = defaultResponse() 241 | } 242 | 243 | if p.Config == nil { 244 | guilds := make(map[string]MsgConfig, 0) 245 | guilds[c.E.GuildID.String()] = msgConfig 246 | cfg := config{Guilds: guilds} 247 | p.Config = cfg 248 | } else { 249 | p.Config.(config).Guilds[c.E.GuildID.String()] = msgConfig 250 | } 251 | 252 | return err 253 | } 254 | -------------------------------------------------------------------------------- /plugins/message-roles/message-roles.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/cmd" 7 | "github.com/5HT2/taro-bot/plugins" 8 | "github.com/5HT2/taro-bot/util" 9 | "github.com/diamondburned/arikawa/v3/api" 10 | "github.com/diamondburned/arikawa/v3/discord" 11 | "log" 12 | "reflect" 13 | "sort" 14 | "strconv" 15 | "strings" 16 | "sync" 17 | "time" 18 | ) 19 | 20 | var ( 21 | p *plugins.Plugin 22 | mutex sync.Mutex 23 | ) 24 | 25 | type config struct { 26 | StartDate time.Time `json:"start_date"` // Date bot started keeping track of User.TotalMsgs 27 | GuildUsers map[string]map[string]User `json:"guild_users,omitempty"` // [guild id][user id]User 28 | GuildRoles map[string][]Role `json:"guild_configs,omitempty"` // [guild id][]Role 29 | // this could also be a [guild id][role id]Role for performance reasons, but it's only loop-searched in commands, 30 | // so it can stay like this for now. 31 | } 32 | 33 | type User struct { 34 | TotalMsgs int64 `json:"total_msgs"` // number of messages sent while in that guild, ignoring any kind of whitelist / blacklist rules 35 | TotalRoleMsgs int64 `json:"total_role_msgs"` // number of messages sent, respecting whitelist / blacklist 36 | Msgs map[string]int64 `json:"msgs"` // [role id]number of messages 37 | GivenRoles map[string]bool `json:"given_roles"` // [role id]given role 38 | } 39 | 40 | type Role struct { 41 | LevelUpMsg bool `json:"level_up_msg"` 42 | Threshold int64 `json:"threshold"` 43 | ID int64 `json:"role"` 44 | Whitelist []int64 `json:"whitelist"` 45 | Blacklist []int64 `json:"blacklist"` 46 | } 47 | 48 | func InitPlugin(i *plugins.PluginInit) *plugins.Plugin { 49 | p = &plugins.Plugin{ 50 | Name: "Message Roles", 51 | Description: "Assign a role once a message threshold has been reached", 52 | Version: "1.0.2", 53 | Commands: []bot.CommandInfo{{ 54 | Fn: MessageRolesConfigCommand, 55 | FnName: "MessageRolesConfigCommand", 56 | Name: "messagerolesconfig", 57 | Aliases: []string{"mrcfg"}, 58 | Description: "Edit message roles config", 59 | GuildOnly: true, 60 | }, { 61 | Fn: MessageTopCommand, 62 | FnName: "MessageTopCommand", 63 | Name: "messagetop", 64 | Aliases: []string{"msgtop", "leaderboard"}, 65 | Description: "Message Leaderboard", 66 | GuildOnly: true, 67 | }}, 68 | Responses: []bot.ResponseInfo{{ 69 | Fn: MsgThresholdMsgResponse, 70 | Regexes: []string{"."}, 71 | MatchMin: 1, 72 | }}, 73 | ConfigType: reflect.TypeOf(config{}), 74 | StartupFn: func() { 75 | if cfg, ok := p.Config.(config); ok { 76 | if cfg.StartDate.IsZero() { 77 | cfg.StartDate = time.Now() 78 | } 79 | 80 | p.Config = cfg 81 | } else { 82 | p.Config = config{StartDate: time.Now()} 83 | } 84 | }, 85 | } 86 | p.ConfigDir = i.ConfigDir 87 | p.Config = p.LoadConfig() 88 | return p 89 | } 90 | 91 | func MsgThresholdMsgResponse(r bot.Response) { 92 | mutex.Lock() 93 | defer mutex.Unlock() 94 | 95 | roles := make([]Role, 0) 96 | if guildRoles, ok := p.Config.(config).GuildRoles[r.E.GuildID.String()]; ok { 97 | roles = guildRoles 98 | } 99 | 100 | // this will go and validate if the message channel is in the whitelist or blacklist, or neither, and bump the message count for said role 101 | bumpMessages := func(roles []Role, user User, channel discord.ChannelID) User { 102 | user.TotalMsgs += 1 103 | 104 | for _, role := range roles { 105 | roleID := strconv.FormatInt(role.ID, 10) 106 | 107 | if len(role.Whitelist) > 0 { 108 | // If a whitelist is enabled and the channel IS in the whitelist. 109 | // We can't collapse this with && because we don't want the else to happen if a whitelist is enabled 110 | // and the channel ISN'T in the whitelist. 111 | if util.SliceContains(role.Whitelist, int64(channel)) { 112 | user.Msgs[roleID] += 1 113 | user.TotalRoleMsgs += 1 114 | } 115 | } else if len(role.Blacklist) > 0 { 116 | // If a blacklist is enabled and the channel IS NOT in the blacklist. 117 | // We can't collapse this with && because we don't want the else to happen if a blacklist is enabled 118 | // and the channel IS in the blacklist. 119 | if !util.SliceContains(role.Blacklist, int64(channel)) { 120 | user.Msgs[roleID] += 1 121 | user.TotalRoleMsgs += 1 122 | } 123 | } else { 124 | user.Msgs[roleID] += 1 125 | user.TotalRoleMsgs += 1 126 | } 127 | } 128 | 129 | return user 130 | } 131 | 132 | // this will check each role if the threshold is met or not, and assign it if so 133 | checkThreshold := func(roles []Role, user User) User { 134 | for _, role := range roles { 135 | roleID := strconv.FormatInt(role.ID, 10) 136 | givenRole, _ := user.GivenRoles[roleID] 137 | 138 | if !givenRole && user.Msgs[roleID] >= role.Threshold && role.ID != 0 && role.Threshold != 0 { 139 | // Assign role 140 | reason := fmt.Sprintf("user messages met threshold of %v for role <@&%v>", role.Threshold, role.ID) 141 | data := api.AddRoleData{AuditLogReason: api.AuditLogReason(reason)} 142 | log.Printf("attempting to add threshold role: %v (%s)\n", role.ID, data) 143 | 144 | if err := bot.Client.AddRole(r.E.GuildID, r.E.Author.ID, discord.RoleID(role.ID), data); err != nil { 145 | log.Printf("failed to add threshold role: %v\n", err) 146 | } else { 147 | user.GivenRoles[roleID] = true 148 | 149 | author := cmd.CreateEmbedAuthor(*r.E.Member) 150 | _, _ = cmd.SendMessageEmbedSafe(r.E.ChannelID, r.E.Author.Mention(), &discord.Embed{ 151 | Title: "Role Level Up!", 152 | Description: fmt.Sprintf("Congrats! 🎉 You've earned the role <@&%v>!", role.ID), 153 | Author: author, 154 | Footer: &discord.EmbedFooter{Text: "Messages sent since"}, 155 | Timestamp: discord.Timestamp(p.Config.(config).StartDate), 156 | Color: bot.DefaultColor, 157 | }) 158 | } 159 | } 160 | } 161 | 162 | return user 163 | } 164 | 165 | // Check if the guild has an existing config 166 | if cfg, ok := p.Config.(config).GuildUsers[r.E.GuildID.String()]; ok { 167 | // Make a new user, populate it 168 | user := User{Msgs: make(map[string]int64), GivenRoles: make(map[string]bool)} 169 | 170 | // If the guild has an existing config, does this user exist in it yet? 171 | if guildUser, ok := cfg[r.E.Author.ID.String()]; ok { 172 | user = guildUser 173 | } 174 | 175 | // User not in this guild's config, add them to it. 176 | user = bumpMessages(roles, user, r.E.ChannelID) 177 | user = checkThreshold(roles, user) 178 | 179 | // Update the config 180 | p.Config.(config).GuildUsers[r.E.GuildID.String()][r.E.Author.ID.String()] = user 181 | } else { 182 | // Make a new user, populate it 183 | user := User{Msgs: make(map[string]int64), GivenRoles: make(map[string]bool)} 184 | user = bumpMessages(roles, user, r.E.ChannelID) 185 | user = checkThreshold(roles, user) 186 | 187 | // Users map not found, create it 188 | users := make(map[string]User) 189 | users[r.E.Author.ID.String()] = user 190 | 191 | // If there are no guilds with users, create a new guild and replace it with the `users` map 192 | if len(p.Config.(config).GuildUsers) == 0 { 193 | guilds := make(map[string]map[string]User, 0) 194 | guilds[r.E.GuildID.String()] = users 195 | 196 | cfg := p.Config.(config) 197 | cfg.GuildUsers = guilds 198 | 199 | p.Config = cfg 200 | } 201 | 202 | // Save `users` map in the config 203 | p.Config.(config).GuildUsers[r.E.GuildID.String()] = users 204 | } 205 | } 206 | 207 | func MessageRolesConfigCommand(c bot.Command) error { 208 | if err := cmd.HasPermission(c, cmd.PermModerate); err != nil { 209 | return err 210 | } 211 | 212 | mutex.Lock() 213 | defer mutex.Unlock() 214 | 215 | getPrintEmbed := func(title string, roles ...Role) discord.Embed { 216 | lines := make([]string, 0) 217 | for _, role := range roles { 218 | lu := "🔕" 219 | if role.LevelUpMsg { 220 | lu = "🔔" 221 | } 222 | 223 | a1 := "" 224 | a2 := "" 225 | if len(role.Whitelist) > 0 { 226 | a1 = fmt.Sprintf("\n✅ Whitelist: %s", util.JoinInt64Slice(role.Whitelist, ", ", "<#", ">")) 227 | } 228 | if len(role.Blacklist) > 0 { 229 | a2 = fmt.Sprintf("\n⛔ Blacklist: %s", util.JoinInt64Slice(role.Blacklist, ", ", "<#", ">")) 230 | } 231 | 232 | lines = append(lines, fmt.Sprintf("<@&%v>\n%s %s messages%s%s", role.ID, lu, util.FormattedNum(role.Threshold), a1, a2)) 233 | } 234 | 235 | embed := cmd.MakeEmbed(title, strings.Join(lines, "\n\n"), bot.DefaultColor) 236 | return embed 237 | } 238 | 239 | roles := make([]Role, 0) 240 | if guildRoles, ok := p.Config.(config).GuildRoles[c.E.GuildID.String()]; ok { 241 | roles = guildRoles 242 | } 243 | 244 | arg, _ := cmd.ParseStringArg(c.Args, 1, true) 245 | var err error = nil 246 | 247 | switch arg { 248 | case "role": 249 | roleID, argErr1 := cmd.ParseInt64Arg(c.Args, 2) 250 | threshold, argErr2 := cmd.ParseInt64Arg(c.Args, 3) 251 | 252 | // For the future: some people might expect that setting a threshold to 0 will "auto-role" people, when in 253 | // reality it will only apply once the user sends any messages. This is probably fine for now. 254 | if threshold < 0 { 255 | threshold = 0 256 | } 257 | 258 | if argErr1 != nil { 259 | return argErr1 260 | } 261 | if argErr2 != nil { 262 | return argErr2 263 | } 264 | 265 | found := false 266 | for n, r := range roles { 267 | if r.ID == roleID { 268 | r.Threshold = threshold 269 | roles[n] = r 270 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, 271 | cmd.MakeEmbed(p.Name, fmt.Sprintf("Changed threshold for <@&%v> to %s!", r.ID, util.FormattedNum(r.Threshold)), bot.SuccessColor), 272 | getPrintEmbed("", r), 273 | ) 274 | 275 | found = true 276 | break 277 | } 278 | } 279 | 280 | if !found { 281 | newRole := Role{Threshold: threshold, ID: roleID} 282 | roles = append(roles, newRole) 283 | 284 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, 285 | cmd.MakeEmbed(p.Name, fmt.Sprintf("Created role <@&%v> with threshold %s!", roleID, util.FormattedNum(threshold)), bot.SuccessColor), 286 | getPrintEmbed("", newRole), 287 | ) 288 | } 289 | case "remove": // TODO: This should also reset the given roles for each user 290 | roleID, argErr1 := cmd.ParseInt64Arg(c.Args, 2) 291 | 292 | if argErr1 != nil { 293 | return argErr1 294 | } 295 | 296 | orderedRoles := make([]Role, 0) 297 | roleRm := Role{} 298 | 299 | for _, r := range roles { 300 | if r.ID != roleID { 301 | orderedRoles = append(orderedRoles, r) 302 | } else { 303 | roleRm = r 304 | } 305 | } 306 | 307 | if len(orderedRoles) < len(roles) { 308 | e1 := getPrintEmbed("", roleRm) 309 | e2 := getPrintEmbed("", orderedRoles...) 310 | e1.Color = bot.ErrorColor 311 | 312 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, 313 | cmd.MakeEmbed(p.Name, fmt.Sprintf("Removed role <@&%v>!", roleID), bot.SuccessColor), 314 | e1, e2, 315 | ) 316 | } else { 317 | _, err = cmd.SendEmbed(c.E, p.Name, "This role is not setup for Message Roles! Add it using the `role` argument.", bot.ErrorColor) 318 | } 319 | 320 | roles = orderedRoles 321 | case "whitelist": 322 | role, argErr1 := cmd.ParseInt64Arg(c.Args, 2) 323 | channel, argErr2 := cmd.ParseChannelArg(c.Args, 3) 324 | 325 | if argErr1 != nil { 326 | return argErr1 327 | } 328 | if argErr2 != nil { 329 | return argErr2 330 | } 331 | 332 | found := false 333 | for n, r := range roles { 334 | if r.ID == role { 335 | if util.SliceContains(r.Whitelist, channel) { 336 | r.Whitelist = util.SliceRemove(r.Whitelist, channel) 337 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, 338 | cmd.MakeEmbed(p.Name, fmt.Sprintf("Removed <#%v> from <@&%v>'s whitelist", channel, r.ID), bot.SuccessColor), 339 | getPrintEmbed("", r), 340 | ) 341 | } else { 342 | r.Whitelist = append(r.Whitelist, channel) 343 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, 344 | cmd.MakeEmbed(p.Name, fmt.Sprintf("Added <#%v> from <@&%v>'s whitelist", channel, r.ID), bot.SuccessColor), 345 | getPrintEmbed("", r), 346 | ) 347 | } 348 | 349 | roles[n] = r 350 | found = true 351 | break 352 | } 353 | } 354 | 355 | if !found { 356 | _, err = cmd.SendEmbed(c.E, p.Name, "This role is not setup for Message Roles! Add it using the `role` argument.", bot.ErrorColor) 357 | } 358 | case "blacklist": 359 | role, argErr1 := cmd.ParseInt64Arg(c.Args, 2) 360 | channel, argErr2 := cmd.ParseChannelArg(c.Args, 3) 361 | user0, argErr3 := cmd.ParseInt64Arg(c.Args, 3) 362 | user1, argErr4 := cmd.ParseUserArg(c.Args, 3) 363 | 364 | blacklistChannel := func() { 365 | found := false 366 | for n, r := range roles { 367 | if r.ID == role { 368 | if util.SliceContains(r.Blacklist, channel) { 369 | r.Blacklist = util.SliceRemove(r.Blacklist, channel) 370 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, 371 | cmd.MakeEmbed(p.Name, fmt.Sprintf("Removed <#%v> from <@&%v>'s blacklist", channel, r.ID), bot.SuccessColor), 372 | getPrintEmbed("", r), 373 | ) 374 | 375 | } else { 376 | r.Blacklist = append(r.Blacklist, channel) 377 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, 378 | cmd.MakeEmbed(p.Name, fmt.Sprintf("Added <#%v> to <@&%v>'s blacklist", channel, r.ID), bot.SuccessColor), 379 | getPrintEmbed("", r), 380 | ) 381 | } 382 | 383 | roles[n] = r 384 | found = true 385 | break 386 | } 387 | } 388 | 389 | if !found { 390 | _, err = cmd.SendEmbed(c.E, p.Name, "This role is not setup for Message Roles! Add it using the `role` argument.", bot.ErrorColor) 391 | } 392 | } 393 | 394 | blacklistUser := func() error { 395 | user := user0 // try arg 3 as int64 396 | if argErr3 != nil { // arg 3 wasn't int64, try user mention 397 | user = user1 398 | } 399 | if argErr4 != nil { // arg3 wasn't user mention, exit 400 | return argErr4 401 | } 402 | 403 | if discordUser, err := bot.Client.User(discord.UserID(user)); err != nil { 404 | return err 405 | } else { 406 | roleStr := fmt.Sprintf("%v", role) 407 | 408 | // Check if the guild has an existing config 409 | if cfg, ok := p.Config.(config).GuildUsers[c.E.GuildID.String()]; ok { 410 | user := User{Msgs: make(map[string]int64), GivenRoles: make(map[string]bool)} 411 | 412 | // If the guild has an existing config, does this user exist in it yet? 413 | if guildUser, ok := cfg[discordUser.ID.String()]; ok { 414 | user = guildUser 415 | } 416 | 417 | // User not in this guild's config, add them to it. 418 | user.GivenRoles[roleStr] = true 419 | 420 | // Update the config 421 | p.Config.(config).GuildUsers[c.E.GuildID.String()][discordUser.ID.String()] = user 422 | } else { 423 | // Make a new user, populate it 424 | user := User{Msgs: make(map[string]int64), GivenRoles: make(map[string]bool)} 425 | user.GivenRoles[roleStr] = true 426 | 427 | // Users map not found, create it 428 | users := make(map[string]User) 429 | users[discordUser.ID.String()] = user 430 | 431 | // If there are no guilds with users, create a new guild and replace it with the `users` map 432 | if len(p.Config.(config).GuildUsers) == 0 { 433 | guilds := make(map[string]map[string]User, 0) 434 | guilds[c.E.GuildID.String()] = users 435 | 436 | cfg := p.Config.(config) 437 | cfg.GuildUsers = guilds 438 | 439 | p.Config = cfg 440 | } 441 | 442 | // Save `users` map in the config 443 | p.Config.(config).GuildUsers[c.E.GuildID.String()] = users 444 | } 445 | 446 | _, err = cmd.SendEmbed(c.E, p.Name, fmt.Sprintf("Succesfully blacklisted <@%v> from getting <@&%v>!", user, role), bot.SuccessColor) 447 | return err 448 | } 449 | } 450 | 451 | if argErr1 != nil { 452 | return argErr1 453 | } 454 | if argErr2 == nil { // parsed a channel mention successfully 455 | blacklistChannel() 456 | } else { // didn't parse a channel, blacklistUser will check if the new arg is a user or not 457 | return blacklistUser() 458 | } 459 | case "lvlupmsg": 460 | mode, argErr1 := cmd.ParseStringArg(c.Args, 2, true) 461 | if argErr1 != nil { 462 | return argErr1 463 | } 464 | 465 | if mode != "enable" && mode != "disable" { 466 | return bot.GenericSyntaxError(c.FnName, mode, "expected `enable` or `disable`") 467 | } 468 | 469 | roleAll, argErr2 := cmd.ParseStringArg(c.Args, 3, true) 470 | roleList, argErr3 := cmd.ParseInt64SliceArg(c.Args, 3, -1) 471 | 472 | if argErr2 != nil && argErr3 != nil { 473 | return argErr2 474 | } 475 | 476 | if roleAll != "all" && argErr3 != nil { 477 | return argErr3 478 | } 479 | 480 | cfg, ok := p.Config.(config).GuildRoles[c.E.GuildID.String()] 481 | if !ok { 482 | _, err = cmd.SendEmbed(c.E, p.Name, "You don't have any roles setup for Message Roles! Add one using the `role` argument.", bot.ErrorColor) 483 | return err 484 | } 485 | 486 | modifiedRoles := make([]Role, 0) 487 | toggleMsg := mode == "enable" 488 | 489 | if roleAll == "all" { 490 | for _, role := range cfg { // for every role in the config 491 | role.LevelUpMsg = toggleMsg // modify it 492 | modifiedRoles = append(modifiedRoles, role) 493 | } 494 | } else { 495 | for _, role := range cfg { // for every role in the config 496 | for _, selectedRole := range roleList { // for every role in the args 497 | if role.ID == selectedRole { 498 | role.LevelUpMsg = toggleMsg // modify it 499 | modifiedRoles = append(modifiedRoles, role) 500 | } 501 | } 502 | } 503 | } 504 | 505 | // Update the config 506 | p.Config.(config).GuildRoles[c.E.GuildID.String()] = cfg 507 | 508 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, 509 | cmd.MakeEmbed(p.Name, "Updated level up messages:", bot.SuccessColor), 510 | getPrintEmbed("", modifiedRoles...), 511 | ) 512 | return err 513 | 514 | case "list": 515 | if len(roles) == 0 { 516 | _, err = cmd.SendEmbed(c.E, p.Name, "No message roles setup!", bot.WarnColor) 517 | } else { 518 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, getPrintEmbed(p.Name, roles...)) 519 | } 520 | default: 521 | _, err = cmd.SendEmbed(c.E, 522 | "Configure Message Roles", 523 | "Available arguments are:\n"+ 524 | "- `role [role id] [threshold]`\n"+ 525 | "- `remove [role id]`\n"+ 526 | "- `whitelist [role id] [channel]`\n"+ 527 | "- `blacklist [role id] [channel]`\n"+ 528 | "- `blacklist [role id] [user]`\n"+ 529 | "- `lvlupmsg enable|disable all`\n"+ 530 | "- `lvlupmsg enable|disable [role ids]`\n"+ 531 | "- `list`", 532 | bot.DefaultColor) 533 | } 534 | 535 | if p.Config == nil { 536 | guilds := make(map[string][]Role, 0) 537 | guilds[c.E.GuildID.String()] = roles 538 | cfg := config{GuildRoles: guilds} 539 | p.Config = cfg 540 | } else { 541 | p.Config.(config).GuildRoles[c.E.GuildID.String()] = roles 542 | } 543 | 544 | return err 545 | } 546 | 547 | func MessageTopCommand(c bot.Command) error { 548 | mutex.Lock() 549 | defer mutex.Unlock() 550 | 551 | if cfg, ok := p.Config.(config).GuildUsers[c.E.GuildID.String()]; ok { 552 | topUsers := make([]string, 0) 553 | 554 | for k := range cfg { 555 | if len(k) == 0 { 556 | continue 557 | } 558 | 559 | topUsers = append(topUsers, k) 560 | } 561 | 562 | sort.SliceStable(topUsers, func(i, j int) bool { 563 | return cfg[topUsers[i]].TotalMsgs > cfg[topUsers[j]].TotalMsgs 564 | }) 565 | 566 | lines := make([]string, 0) 567 | fields := make([]discord.EmbedField, 0) 568 | 569 | id := c.E.Author.ID.String() 570 | selfPos := 0 571 | selfNum := "" 572 | 573 | for n, u := range topUsers { 574 | if u == id { 575 | selfPos = n + 1 576 | selfNum = util.FormattedNum(cfg[u].TotalMsgs) 577 | } 578 | 579 | if n < 3 { 580 | emoji := "" 581 | switch n { 582 | case 0: 583 | emoji = "🥇" 584 | case 1: 585 | emoji = "🥈" 586 | case 2: 587 | emoji = "🥉" 588 | } 589 | 590 | fields = append(fields, discord.EmbedField{ 591 | Name: emoji, 592 | Value: fmt.Sprintf("<@%s>: %s", u, util.FormattedNum(cfg[u].TotalMsgs)), 593 | }) 594 | } else { 595 | lines = append(lines, fmt.Sprintf("#%v <@%s>: %s", n+1, u, util.FormattedNum(cfg[u].TotalMsgs))) 596 | } 597 | } 598 | 599 | if len(lines) > 0 { 600 | fields = append(fields, discord.EmbedField{ 601 | Name: "​", Value: util.HeadLinesLimit(strings.Join(lines, "\n"), 1024), 602 | }) 603 | } 604 | 605 | author := cmd.CreateEmbedAuthor(*c.E.Member) 606 | if selfPos != 0 { 607 | author.Name += fmt.Sprintf(" (#%v: %s)", selfPos, selfNum) 608 | } 609 | 610 | _, err := cmd.SendCustomEmbed(c.E.ChannelID, discord.Embed{ 611 | Title: "Message Leaderboard", 612 | Author: author, 613 | Fields: fields, 614 | Footer: &discord.EmbedFooter{Text: "Messages sent since"}, 615 | Timestamp: discord.Timestamp(p.Config.(config).StartDate), 616 | Color: bot.DefaultColor, 617 | }) 618 | 619 | return err 620 | } 621 | 622 | _, err := cmd.SendEmbed(c.E, p.Name, "GuildUsers config for this guild is missing! Contact a developer for help, this shouldn't ever happen.", bot.ErrorColor) 623 | return err 624 | } 625 | -------------------------------------------------------------------------------- /plugins/plugins.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/5HT2/taro-bot/bot" 7 | "github.com/5HT2/taro-bot/util" 8 | "github.com/diamondburned/arikawa/v3/gateway" 9 | "io/ioutil" 10 | "log" 11 | "os" 12 | "path/filepath" 13 | "plugin" 14 | "reflect" 15 | "strings" 16 | "time" 17 | ) 18 | 19 | var ( 20 | fileMode = os.FileMode(0755) 21 | plugins = make([]*Plugin, 0) 22 | ) 23 | 24 | type PluginInit struct { 25 | ConfigDir string 26 | } 27 | 28 | type Plugin struct { 29 | Name string // Name of the plugin to display to users 30 | Description string // Description of what the plugin does 31 | Version string // Version in semver, e.g.., 1.1.0 32 | Config interface{} // Config is the Plugin's config, can be nil 33 | ConfigDir string // ConfigDir is the name of the config directory 34 | ConfigType reflect.Type // ConfigType is the type to validate parse the config with 35 | Commands []bot.CommandInfo // Commands to register, could be none 36 | Responses []bot.ResponseInfo // Responses to register, could be none 37 | Handlers []bot.HandlerInfo // Handlers to register, could be none 38 | Jobs []bot.JobInfo // Jobs to register, could be none 39 | StartupFn func() // ShutdownFn is a function to be called when the bot starts up 40 | ShutdownFn func() // ShutdownFn is a function to be called when the bot shuts down 41 | } 42 | 43 | func (p *Plugin) String() string { 44 | return fmt.Sprintf("[%s, %s, %s, %s, %s, %s, %s, %s, %s]", p.Name, p.Description, p.Version, p.ConfigDir, p.ConfigType, p.Commands, p.Responses, p.Handlers, p.Jobs) 45 | } 46 | 47 | // Register will register a plugin's commands, responses and jobs to the bot 48 | func (p *Plugin) Register() { 49 | plugins = append(plugins, p) 50 | 51 | bot.Commands = append(bot.Commands, p.Commands...) 52 | bot.Responses = append(bot.Responses, p.Responses...) 53 | bot.Handlers = append(bot.Handlers, p.Handlers...) // these need to have RegisterHandlers called in order to function 54 | bot.Jobs = append(bot.Jobs, p.Jobs...) // these need to have RegisterJobs called in order to function 55 | } 56 | 57 | func (p *Plugin) LoadConfig() (i interface{}) { 58 | defer util.LogPanic() // This code is unsafe, we should log if it panics 59 | 60 | if p.ConfigDir == "" { 61 | log.Fatalln("plugin config load failed: p.ConfigDir is unset!") 62 | } 63 | 64 | bytes, err := os.ReadFile(getConfigPath(p)) 65 | if err != nil { 66 | log.Printf("plugin config reading failed (%s): %s\n", p.Name, err) 67 | return i 68 | } 69 | 70 | obj, err := util.NewInterface(p.ConfigType, bytes) // unsafe 71 | if err != nil { 72 | log.Printf("plugin config unmarshalling failed (%s): %s\n", p.Name, err) 73 | return i 74 | } 75 | 76 | log.Printf("plugin config loaded for %s\n", p.Name) 77 | return obj 78 | } 79 | 80 | func (p *Plugin) SaveConfig() { 81 | if p.Config == nil || p.ConfigType == nil || p.ConfigDir == "" { 82 | log.Printf("skipping saving %s\n", p.Name) 83 | return 84 | } 85 | 86 | // This is faster than checking if it exists 87 | _ = os.Mkdir("config/"+p.ConfigDir, fileMode) 88 | 89 | if bytes, err := json.MarshalIndent(p.Config, "", " "); err != nil { 90 | log.Printf("plugin config marshalling failed (%s): %s\n", p.Name, err) 91 | } else { 92 | if err = os.WriteFile(getConfigPath(p), bytes, fileMode); err != nil { 93 | log.Printf("plugin config writing failed (%s): %s\n", p.Name, err) 94 | } else { 95 | log.Printf("saved config for %s\n", p.Name) 96 | } 97 | } 98 | } 99 | 100 | // Startup will run the startup function for all plugins 101 | func Startup() { 102 | for _, p := range plugins { 103 | if p.StartupFn != nil { 104 | p.StartupFn() 105 | } 106 | } 107 | } 108 | 109 | // Shutdown will run the shutdown function for all plugins 110 | func Shutdown() { 111 | for _, p := range plugins { 112 | if p.ShutdownFn != nil { 113 | p.ShutdownFn() 114 | } 115 | } 116 | } 117 | 118 | // SaveConfig will save all plugin configs 119 | func SaveConfig() { 120 | for _, p := range plugins { 121 | p.SaveConfig() 122 | } 123 | } 124 | 125 | // SetupConfigSaving will run each plugin's SaveConfig every 5 minutes with a ticker 126 | func SetupConfigSaving() { 127 | ticker := time.NewTicker(5 * time.Minute) 128 | go func() { 129 | for { 130 | select { 131 | case <-ticker.C: 132 | SaveConfig() 133 | } 134 | } 135 | }() 136 | } 137 | 138 | // Load will load all the plugins 139 | func Load(dir string) { 140 | d, err := ioutil.ReadDir(dir) 141 | if err != nil { 142 | log.Printf("plugin loading failed: couldn't load dir: %s\n", err) 143 | return 144 | } 145 | 146 | plugins := parsePluginsList() 147 | 148 | log.Printf("plugin list: [%s]\n", strings.Join(plugins, ", ")) 149 | 150 | for _, entry := range d { 151 | func() { 152 | defer util.LogPanic() // plugins can panic when returning their PluginInit 153 | 154 | if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".so") && util.SliceContains(plugins, entry.Name()) { 155 | pluginPath := filepath.Join(dir, entry.Name()) 156 | log.Printf("plugin found: %s\n", entry.Name()) 157 | 158 | p, err := plugin.Open(pluginPath) 159 | if err != nil { 160 | log.Printf("plugin load failed: couldn't open plugin: %s (%s)\n", entry.Name(), err) 161 | return 162 | } 163 | 164 | fn, err := p.Lookup("InitPlugin") 165 | if err != nil { 166 | log.Printf("plugin load failed: couldn't lookup symbols: %s (%s)\n", entry.Name(), err) 167 | return 168 | } 169 | 170 | if fn == nil { 171 | log.Printf("plugin load failed: fn nil\n") 172 | return 173 | } 174 | 175 | // Pass the ConfigDir to the PluginInit, so plugins can access it while loading their initial config. 176 | // This requires an extra step on the user's part when writing a plugin, but the plugin loading will fail 177 | // and let the user know if they forgot to do so. This isn't ideal, but it allows the renaming of plugin 178 | // names, without breaking the config or relying on parsing to be consistent. 179 | pluginInit := &PluginInit{ConfigDir: strings.TrimSuffix(entry.Name(), ".so")} 180 | // Create the init function to execute, to attempt plugin registration. 181 | initFn := fn.(func(manager *PluginInit) *Plugin) 182 | 183 | if p := initFn(pluginInit); p != nil { 184 | p.Register() 185 | log.Printf("plugin registered: %s\n", p) 186 | } else { 187 | log.Printf("plugin load failed: %s (nil)\n", entry.Name()) 188 | } 189 | } 190 | }() 191 | } 192 | } 193 | 194 | // ClearJobs will clear all registered jobs 195 | func ClearJobs() { 196 | bot.Scheduler.Clear() 197 | bot.Jobs = make([]bot.JobInfo, 0) 198 | } 199 | 200 | // RegisterJobs will go through bot.Jobs and handle the re-registration of them 201 | func RegisterJobs() { 202 | for _, job := range bot.Jobs { 203 | RegisterJob(job) 204 | } 205 | } 206 | 207 | // RegisterJob registers a job for use with gocron. Ensure you add the job to bot.Jobs for de-registration with ClearJobs. 208 | func RegisterJob(job bot.JobInfo) { 209 | if rJob, err := job.Fn(); err != nil { 210 | log.Printf("failed to register job (%s): %v\n", job.Name, err) 211 | } else { 212 | log.Printf("registered job (%s): %v\n", job.Name, rJob) 213 | } 214 | } 215 | 216 | // RegisterJobConcurrent registers a job with RegisterJob concurrently, and optionally adds the job to bot.Jobs to be tracked. 217 | func RegisterJobConcurrent(job bot.JobInfo, addGlobally bool) { 218 | bot.Mutex.Lock() 219 | defer bot.Mutex.Unlock() 220 | 221 | if addGlobally { 222 | bot.Jobs = append(bot.Jobs, job) 223 | } 224 | 225 | RegisterJob(job) 226 | } 227 | 228 | // ClearHandlers will go through bot.Handlers and handle the de-registration of them 229 | func ClearHandlers() { 230 | for _, handler := range bot.Handlers { 231 | if handler.FnRm != nil { 232 | handler.FnRm() 233 | } 234 | } 235 | 236 | bot.Handlers = make([]bot.HandlerInfo, 0) 237 | } 238 | 239 | // RegisterHandlers will go through bot.Handlers and handle the re-registration of them 240 | func RegisterHandlers() { 241 | for n, i := range bot.Handlers { 242 | // This is necessary because the loop mutates bot.Handlers as an invisible side effect. 243 | // Removing this will cause ghosts to enter your computer and call bot.Client.AddHandler even when fn == nil 244 | handler := bot.HandlerInfo{Fn: i.Fn, FnName: i.FnName, FnType: i.FnType} 245 | 246 | var fn any 247 | // Implement necessary handler types here when failing to register. 248 | // This has to be done manually, by hand, because Go is unable to pass a real type as a parameter. 249 | // Believe me, I tried doing so with reflection and got nothing to show for it after 5 hours. 250 | // If this behavior changes as Go finally figures out their situation with generics, that would be 251 | // nice to implement here, as a consideration for the future. 252 | switch handler.FnType { 253 | case reflect.TypeOf(func(e *gateway.MessageReactionAddEvent) {}): 254 | fn = func(e *gateway.MessageReactionAddEvent) { 255 | handler.Fn(e) 256 | } 257 | case reflect.TypeOf(func(e *gateway.MessageReactionRemoveEvent) {}): 258 | fn = func(e *gateway.MessageReactionRemoveEvent) { 259 | handler.Fn(e) 260 | } 261 | case reflect.TypeOf(func(e *gateway.GuildMemberAddEvent) {}): 262 | fn = func(e *gateway.GuildMemberAddEvent) { 263 | handler.Fn(e) 264 | } 265 | case reflect.TypeOf(func(e *gateway.GuildMemberRemoveEvent) {}): 266 | fn = func(e *gateway.GuildMemberRemoveEvent) { 267 | handler.Fn(e) 268 | } 269 | default: 270 | log.Printf("failed to register handler (%s): type %v not recognized\n", handler.FnName, handler.FnType) 271 | continue 272 | } 273 | 274 | if fn != nil { 275 | rm := bot.Client.AddHandler(fn) 276 | bot.Handlers[n].FnRm = rm 277 | log.Printf("registered handler: %v\n", bot.Handlers[n]) 278 | } 279 | } 280 | } 281 | 282 | // RegisterAll will register all bot features, and then load plugins 283 | func RegisterAll(dir string) { 284 | bot.Mutex.Lock() 285 | defer bot.Mutex.Unlock() 286 | 287 | // This is done to clear the existing plugins that have already been registered, if this is called after the bot 288 | // has already been initialized. This allows reloading plugins at runtime. 289 | plugins = make([]*Plugin, 0) 290 | bot.Commands = make([]bot.CommandInfo, 0) 291 | bot.Responses = make([]bot.ResponseInfo, 0) 292 | 293 | // We want to do this before registering plugins 294 | ClearHandlers() 295 | ClearJobs() 296 | 297 | // This registers the plugins we have downloaded 298 | // This does not build new plugins for us, which instead has to be done separately 299 | Load(dir) 300 | 301 | // This registers the new jobs that plugins have scheduled, and the handlers that they return 302 | RegisterHandlers() 303 | RegisterJobs() 304 | 305 | // This enables config saving for all loaded plugins 306 | SetupConfigSaving() 307 | 308 | // This runs the startup sequence for all loaded plugins that have it 309 | Startup() 310 | } 311 | 312 | func parsePluginsList() []string { 313 | plugins := make([]string, 0) 314 | 315 | for _, p := range bot.P.LoadedPlugins { 316 | if p == "default" { 317 | continue 318 | } 319 | 320 | p += ".so" 321 | if !util.SliceContains(plugins, p) { 322 | plugins = append(plugins, p) 323 | } 324 | } 325 | 326 | if len(bot.P.LoadedPlugins) == 0 || util.SliceContains(bot.P.LoadedPlugins, "default") { 327 | for _, p := range bot.DefaultPlugins { 328 | p += ".so" 329 | if !util.SliceContains(plugins, p) { 330 | plugins = append(plugins, p) 331 | } 332 | } 333 | } 334 | return plugins 335 | } 336 | 337 | func getConfigPath(p *Plugin) string { 338 | return fmt.Sprintf("config/%s/%s.json", p.ConfigDir, p.Version) 339 | } 340 | -------------------------------------------------------------------------------- /plugins/remindme/remindme.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/cmd" 7 | "github.com/5HT2/taro-bot/plugins" 8 | "github.com/diamondburned/arikawa/v3/discord" 9 | "github.com/go-co-op/gocron" 10 | "log" 11 | "reflect" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "time" 16 | ) 17 | 18 | var ( 19 | p *plugins.Plugin 20 | mutex sync.Mutex 21 | ) 22 | 23 | type config struct { 24 | Reminders map[string]Reminder `json:"reminders"` // [msg id]Reminder 25 | } 26 | 27 | type Reminder struct { 28 | ID int64 `json:"id"` // ID of the original message used to create the reminder, used as a unique identifier 29 | Time time.Time `json:"time"` // Time in epoch seconds, to send the reminder 30 | Channel int64 `json:"channel"` // Channel to send message inside 31 | Guild int64 `json:"guild"` // Guild the message originates from 32 | User discord.User `json:"user"` // User object of the reminder author 33 | Timestamp discord.Timestamp `json:"timestamp"` // Timestamp of the original message 34 | DM bool `json:"dm"` // DM is if the reminder is in the user's direct messages with the bot 35 | Contents string `json:"contents"` // Contents of the message to send 36 | } 37 | 38 | func (r Reminder) String() string { 39 | return fmt.Sprintf("[%v, %v, %v, %v, %v, %v, %v, \"%s\"]", r.ID, r.Time.Unix(), r.Channel, r.Guild, r.User.ID, r.Timestamp, r.DM, r.Contents) 40 | } 41 | 42 | func InitPlugin(i *plugins.PluginInit) *plugins.Plugin { 43 | p = &plugins.Plugin{ 44 | Name: "Remind Me", 45 | Description: "Set a reminder for yourself at a later date!", 46 | Version: "1.0.0", 47 | Commands: []bot.CommandInfo{{ 48 | Fn: RemindMeCommand, 49 | FnName: "RemindMeCommand", 50 | Name: "remindme", 51 | Aliases: []string{"remind", "r"}, 52 | Description: "Set a reminder for yourself!", 53 | }}, 54 | ConfigType: reflect.TypeOf(config{}), 55 | } 56 | p.ConfigDir = i.ConfigDir 57 | p.Config = p.LoadConfig() 58 | p.Jobs = generateJobs() // even if the plugin is reloaded, InitPlugin should re-add the reminders with this 59 | return p 60 | } 61 | 62 | func RemindMeCommand(c bot.Command) error { 63 | duration, err := cmd.ParseDurationArg(c.Args, 1) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if duration < 0 { 69 | return bot.GenericError(c.FnName, "getting date", fmt.Sprintf("you cannot set reminders in the past (%s ago)", duration)) 70 | } 71 | 72 | args, _ := cmd.ParseStringSliceArg(c.Args, 2, -1) 73 | content := strings.Join(args, " ") 74 | if len(args) == 0 { 75 | content = "No reminder message set!" 76 | } 77 | 78 | t := time.Now().Add(duration) 79 | reminder := Reminder{ 80 | ID: int64(c.E.ID), 81 | Time: t, 82 | Channel: int64(c.E.ChannelID), 83 | Guild: int64(c.E.GuildID), 84 | User: c.E.Author, 85 | Timestamp: c.E.Timestamp, 86 | DM: !c.E.GuildID.IsValid(), 87 | Contents: content, 88 | } 89 | 90 | createAndRegisterReminder(reminder) 91 | 92 | _, err1 := cmd.SendEmbed( 93 | c.E, 94 | p.Name, 95 | fmt.Sprintf("Successfully created reminder for , you will be reminded on !", t.Unix(), t.Unix()), 96 | bot.SuccessColor, 97 | ) 98 | return err1 99 | } 100 | 101 | // createAndRegisterReminder will create a job from a Reminder and register it right away 102 | func createAndRegisterReminder(r Reminder) { 103 | job := createJob(r) 104 | id := strconv.FormatInt(r.ID, 10) 105 | 106 | if p.Config == nil || p.Config.(config).Reminders == nil { 107 | reminders := make(map[string]Reminder, 0) 108 | reminders[id] = r 109 | cfg := config{Reminders: reminders} 110 | p.Config = cfg 111 | } else { 112 | p.Config.(config).Reminders[id] = r 113 | } 114 | 115 | plugins.RegisterJobConcurrent(job, true) 116 | } 117 | 118 | // generateJobs will generate the initial jobs needed from a plugin load 119 | func generateJobs() []bot.JobInfo { 120 | if p.Config == nil || p.Config.(config).Reminders == nil { 121 | return []bot.JobInfo{} 122 | } 123 | 124 | jobs := make([]bot.JobInfo, 0) 125 | 126 | for _, reminder := range p.Config.(config).Reminders { 127 | jobs = append(jobs, createJob(reminder)) 128 | } 129 | 130 | return jobs 131 | } 132 | 133 | func createJob(r Reminder) bot.JobInfo { 134 | fn := func() { 135 | mutex.Lock() 136 | defer mutex.Unlock() 137 | 138 | field := discord.EmbedField{Name: "Source", Value: cmd.CreateMessageLinkInt64(r.Guild, r.ID, r.Channel, true, r.DM)} 139 | footer := discord.EmbedFooter{Text: r.User.ID.String()} 140 | embed := &discord.Embed{ 141 | Description: r.Contents, 142 | Author: cmd.CreateEmbedAuthorUser(r.User), 143 | Fields: []discord.EmbedField{field}, 144 | Footer: &footer, 145 | Timestamp: r.Timestamp, 146 | Color: bot.BlueColor, 147 | } 148 | 149 | var err error 150 | 151 | if r.DM { 152 | _, err = cmd.SendDirectMessageEmbedSafe(r.User.ID, fmt.Sprintf("📝 from <#%v>", r.Channel), embed) 153 | } else { 154 | _, err = cmd.SendMessageEmbedSafe(discord.ChannelID(r.Channel), fmt.Sprintf("📝 from <#%v> <@%v>", r.Channel, r.User.ID), embed) 155 | } 156 | 157 | if err != nil { 158 | log.Printf("failed to deliver reminder: %v\n%s\n", err, r) 159 | } 160 | 161 | // Remove after attempting to send reminder 162 | if p.Config != nil && p.Config.(config).Reminders != nil { 163 | delete(p.Config.(config).Reminders, strconv.FormatInt(r.ID, 10)) 164 | } 165 | } 166 | 167 | job := bot.JobInfo{ 168 | Fn: func() (*gocron.Job, error) { 169 | return bot.Scheduler.Every(1).LimitRunsTo(1).StartAt(r.Time).Do(fn) 170 | }, 171 | Name: fmt.Sprintf("remindme-plugin-schedule-%v", r.ID), 172 | } 173 | 174 | return job 175 | } 176 | -------------------------------------------------------------------------------- /plugins/role-menu/role-menu.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/5HT2/taro-bot/bot" 7 | "github.com/5HT2/taro-bot/cmd" 8 | "github.com/5HT2/taro-bot/plugins" 9 | "github.com/5HT2/taro-bot/util" 10 | "github.com/diamondburned/arikawa/v3/api" 11 | "github.com/diamondburned/arikawa/v3/discord" 12 | "github.com/diamondburned/arikawa/v3/gateway" 13 | "log" 14 | "reflect" 15 | "strconv" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | var p *plugins.Plugin 21 | 22 | type config struct { 23 | Menus map[string]map[string]Menu `json:"menus"` // [guild id][message id]Menu 24 | } 25 | 26 | // Menu stores the information needed to operate a role menu 27 | type Menu struct { 28 | Channel int64 `json:"channel,omitempty"` // channel id 29 | Roles map[string]Role `json:"roles"` // [api emoji]Role 30 | } 31 | 32 | // Role is used to assign roles from a Menu 33 | type Role struct { 34 | RoleID int64 `json:"role_id"` 35 | } 36 | 37 | // RoleConfig is used when setting up a role menu and is parsed into a Menu format 38 | type RoleConfig struct { 39 | ID string `json:"-"` // MessageID as a string 40 | MessageID int64 `json:"message_id,omitempty"` 41 | ChannelID int64 `json:"channel_id,omitempty"` 42 | Roles []RoleConfigRole `json:"roles"` 43 | } 44 | 45 | // RoleConfigRole is used when setting up a role menu and is parsed into a Menu and Role format 46 | type RoleConfigRole struct { 47 | Emoji string `json:"emoji"` 48 | RoleID int64 `json:"id"` 49 | } 50 | 51 | func InitPlugin(i *plugins.PluginInit) *plugins.Plugin { 52 | p = &plugins.Plugin{ 53 | Name: "Role Menu", 54 | Description: "Create menus to assign roles with reactions!", 55 | Version: "1.0.0", 56 | Commands: []bot.CommandInfo{{ 57 | Fn: RoleMenuCommand, 58 | FnName: "RoleMenuCommand", 59 | Name: "rolemenu", 60 | Aliases: []string{"rmcfg"}, 61 | Description: "Create a role menu", 62 | GuildOnly: true, 63 | }}, 64 | ConfigType: reflect.TypeOf(config{}), 65 | Handlers: []bot.HandlerInfo{{ 66 | Fn: RoleMenuReactionAddHandler, 67 | FnName: "RoleMenuReactionAddHandler", 68 | FnType: reflect.TypeOf(func(*gateway.MessageReactionAddEvent) {}), 69 | }}, 70 | } 71 | p.ConfigDir = i.ConfigDir 72 | p.Config = p.LoadConfig() 73 | return p 74 | } 75 | 76 | // Example usage: 77 | // .rolemenu create {"roles": [{"emoji": "<:astolfo:880936523644669962>", "id": 881205936818122754 }, {"emoji": "<:trans_sunglasses:880628887481102336>", "id": 881206354658885673 }, {"emoji": "<:painedsmug:880628887871160350>", "id": 881206111536025620 }, {"emoji": "<:hewwo:880928545256394792>", "id": 881206521235644447 }]} 78 | 79 | func RoleMenuCommand(c bot.Command) error { 80 | if err := cmd.HasPermission(c, cmd.PermModerate); err != nil { 81 | return err 82 | } 83 | 84 | firstArg, argErr := cmd.ParseStringArg(c.Args, 1, true) 85 | 86 | defaultHelp := func() error { 87 | _, err := cmd.SendEmbed(c.E, p.Name, 88 | "Available arguments are:\n- `create|add|remove [role json]`\n\n`create` a new role menu\n`add` roles\n`remove` existing roles", 89 | bot.DefaultColor) 90 | return err 91 | } 92 | 93 | if argErr != nil { 94 | return defaultHelp() 95 | } 96 | 97 | rolesJson, _ := cmd.ParseAllArgs(c.Args[1:]) 98 | var roleConfig RoleConfig 99 | if err := json.Unmarshal([]byte(rolesJson), &roleConfig); err != nil { 100 | return err 101 | } 102 | 103 | // 104 | // Begin role message parsing section 105 | 106 | roles := make(map[string]Role, 0) 107 | 108 | // Parse the command args into an actual config now, and validate them as actual emojis 109 | for n, rc := range roleConfig.Roles { 110 | emoji, animated, argErr := cmd.ParseEmojiArg([]string{rc.Emoji}, 1, false) 111 | if argErr != nil { 112 | return argErr 113 | } 114 | 115 | parsedEmoji := bot.EmojiApiAsConfig(emoji, animated) 116 | roles[parsedEmoji] = Role{rc.RoleID} // use config emoji, roles is used for the menu creation 117 | roleConfig.Roles[n].Emoji = parsedEmoji // use config emoji. roleConfig is only used for add and remove later on 118 | } 119 | 120 | // 121 | // End role message parsing section 122 | 123 | getLines := func() string { 124 | lines := make([]string, 0) // formatted role menu message 125 | 126 | for parsedEmoji, role := range roles { 127 | if strings.Contains(parsedEmoji, ":") { 128 | parsedEmoji = "<" + parsedEmoji + ">" // embed 129 | } else { 130 | apiEmoji, _ := bot.EmojiConfigAsApi(parsedEmoji) 131 | parsedEmoji = string(apiEmoji) 132 | } 133 | lines = append(lines, fmt.Sprintf("%s <@&%v>", parsedEmoji, role.RoleID)) 134 | } 135 | return strings.Join(lines, "\n") 136 | } 137 | 138 | messageIDCheck := func(rc RoleConfig) (RoleConfig, *discord.Message, error) { 139 | if rc.MessageID == 0 { 140 | msg, _ := cmd.SendEmbed(c.E, p.Name, "`message_id` must be set to add to an existing role menu!", bot.ErrorColor) 141 | return rc, msg, bot.GenericError(c.FnName, "modifying role menu", "`message_id` not set") 142 | } 143 | if rc.ChannelID == 0 { 144 | rc.ChannelID = int64(c.E.ChannelID) 145 | msg, err := cmd.SendEmbed(c.E, p.Name, "`channel_id` not set, defaulting to existing channel. Editing menu...", bot.WarnColor) 146 | return rc, msg, err 147 | } 148 | 149 | msg, err := cmd.SendEmbed(c.E, p.Name, "`message_id` and `channel_id` set, editing menu...", bot.SuccessColor) 150 | return rc, msg, err 151 | } 152 | 153 | getMenu := func(c bot.Command, rc RoleConfig) (*Menu, error) { 154 | var menu *Menu 155 | if p.Config != nil { 156 | if guild, ok := p.Config.(config).Menus[c.E.GuildID.String()]; ok { 157 | if m, ok := guild[rc.ID]; ok { 158 | menu = &m 159 | } 160 | } 161 | } 162 | 163 | if menu == nil { 164 | return nil, bot.GenericError(c.FnName, "getting existing role menu", "none found") 165 | } 166 | return menu, nil 167 | } 168 | 169 | setMenu := func(c bot.Command, rc RoleConfig, m Menu) { 170 | if p.Config != nil { 171 | p.Config.(config).Menus[c.E.GuildID.String()][rc.ID] = m 172 | } else { 173 | menus := make(map[string]map[string]Menu, 0) 174 | msgMenu := make(map[string]Menu) 175 | msgMenu[rc.ID] = m 176 | menus[c.E.GuildID.String()] = msgMenu 177 | p.Config = config{Menus: menus} 178 | } 179 | } 180 | 181 | switch firstArg { 182 | case "add": 183 | roleConfig, logMsg, err := messageIDCheck(roleConfig) 184 | go func() { 185 | if logMsg != nil { // we're not handling the original message's error in this case, so we should check this 186 | time.Sleep(5 * time.Second) 187 | _ = bot.Client.DeleteMessage(logMsg.ChannelID, logMsg.ID, "cleaning up log msg") 188 | } 189 | }() 190 | if err != nil { 191 | return err 192 | } 193 | 194 | roleConfig.ID = strconv.FormatInt(roleConfig.MessageID, 10) 195 | 196 | existingMenu, err := getMenu(c, roleConfig) 197 | if err != nil { 198 | return err 199 | } 200 | 201 | newRoles := make(map[string]Role) 202 | 203 | // Create new roles to add 204 | for _, role := range roleConfig.Roles { 205 | newRoles[role.Emoji] = Role{RoleID: role.RoleID} 206 | } 207 | 208 | // Add old roles if they haven't been added yet 209 | for emoji, role := range existingMenu.Roles { 210 | if _, ok := newRoles[emoji]; !ok { // doesn't exist in new roles to add 211 | newRoles[emoji] = role 212 | } // else // does exist in the new roles, that means our old emoji now has a new role ID. the current `role.RoleID` is the old role ID 213 | } 214 | 215 | // Save menu in global config 216 | existingMenu.Roles = newRoles 217 | setMenu(c, roleConfig, *existingMenu) 218 | 219 | // Edit the menu message 220 | roles = newRoles 221 | 222 | if _, err := bot.Client.EditMessage(discord.ChannelID(roleConfig.ChannelID), discord.MessageID(roleConfig.MessageID), getLines()); err != nil { 223 | return err 224 | } 225 | 226 | // Add the emojis for the new roles 227 | for n, parsedEmoji := range roleConfig.Roles { 228 | apiEmoji, _ := bot.EmojiConfigAsApi(parsedEmoji.Emoji) 229 | if err := bot.Client.React(discord.ChannelID(roleConfig.ChannelID), discord.MessageID(roleConfig.MessageID), apiEmoji); err != nil { 230 | log.Printf("failed to react when creating role menu: %v\n", err) 231 | } 232 | 233 | if n < len(roleConfig.Roles)-1 { 234 | time.Sleep(750 * time.Millisecond) // We want to wait for the actual rate-limit, but Arikawa does not handle that for you 235 | } 236 | } 237 | 238 | msg, err := cmd.SendEmbed(c.E, p.Name, "Edited role menu!", bot.SuccessColor) 239 | if err != nil { 240 | return err 241 | } 242 | 243 | time.Sleep(5 * time.Second) 244 | err = bot.Client.DeleteMessage(msg.ChannelID, msg.ID, "cleaning up log msg") 245 | return err 246 | case "remove": 247 | roleConfig, logMsg, err := messageIDCheck(roleConfig) 248 | go func() { 249 | if logMsg != nil { // we're not handling the original message's error in this case, so we should check this 250 | time.Sleep(5 * time.Second) 251 | _ = bot.Client.DeleteMessage(logMsg.ChannelID, logMsg.ID, "cleaning up log msg") 252 | } 253 | }() 254 | if err != nil { 255 | return err 256 | } 257 | 258 | roleConfig.ID = strconv.FormatInt(roleConfig.MessageID, 10) 259 | 260 | existingMenu, err := getMenu(c, roleConfig) 261 | if err != nil { 262 | return err 263 | } 264 | 265 | oldRoles := make(map[string]Role) 266 | 267 | // Only add roles from the existingMenu that aren't in the roleConfig (set by the user in their message) 268 | for emoji, role := range existingMenu.Roles { 269 | if !util.SliceContains(roleConfig.Roles, RoleConfigRole{RoleID: role.RoleID, Emoji: emoji}) { 270 | oldRoles[emoji] = role 271 | } 272 | } 273 | 274 | // Save menu in global config 275 | existingMenu.Roles = oldRoles 276 | setMenu(c, roleConfig, *existingMenu) 277 | 278 | // Edit the menu message 279 | roles = oldRoles 280 | 281 | if _, err := bot.Client.EditMessage(discord.ChannelID(roleConfig.ChannelID), discord.MessageID(roleConfig.MessageID), getLines()); err != nil { 282 | return err 283 | } 284 | 285 | // Remove the emojis for the removed roles 286 | for n, parsedEmoji := range roleConfig.Roles { 287 | apiEmoji, _ := bot.EmojiConfigAsApi(parsedEmoji.Emoji) 288 | if err := bot.Client.Unreact(discord.ChannelID(roleConfig.ChannelID), discord.MessageID(roleConfig.MessageID), apiEmoji); err != nil { 289 | log.Printf("failed to unreact when creating role menu: %v\n", err) 290 | } 291 | 292 | if n < len(roleConfig.Roles)-1 { 293 | time.Sleep(750 * time.Millisecond) // We want to wait for the actual rate-limit, but Arikawa does not handle that for you 294 | } 295 | } 296 | 297 | msg, err := cmd.SendEmbed(c.E, p.Name, "Edited role menu!", bot.SuccessColor) 298 | if err != nil { 299 | return err 300 | } 301 | 302 | time.Sleep(5 * time.Second) 303 | err = bot.Client.DeleteMessage(msg.ChannelID, msg.ID, "cleaning up log msg") 304 | return err 305 | case "create": 306 | if msgOriginal, err := cmd.SendEmbed(c.E, "Role Menu", "Creating role menu...", bot.WarnColor); err != nil { 307 | return err 308 | } else { 309 | if msg, err := bot.Client.SendMessage(c.E.ChannelID, "Creating role menu..."); err != nil { 310 | return err 311 | } else { 312 | // Edit role menu text into existing message 313 | msg, err = bot.Client.EditMessage(msg.ChannelID, msg.ID, getLines()) 314 | 315 | // 316 | // Save final menu in config 317 | 318 | menus := make(map[string]map[string]Menu, 0) 319 | if p.Config != nil { 320 | menus = p.Config.(config).Menus // copy over the menus for other builds and our current guild 321 | } 322 | 323 | createdMenu := Menu{Channel: int64(c.E.ChannelID), Roles: roles} 324 | 325 | if _, ok := menus[c.E.GuildID.String()]; ok { 326 | menus[c.E.GuildID.String()][msg.ID.String()] = createdMenu 327 | } else { 328 | messageMenu := make(map[string]Menu) 329 | messageMenu[msg.ID.String()] = createdMenu 330 | menus[c.E.GuildID.String()] = messageMenu 331 | } 332 | 333 | p.Config = config{Menus: menus} 334 | 335 | // Add reactions to menu 336 | for parsedEmoji := range roles { 337 | apiEmoji, _ := bot.EmojiConfigAsApi(parsedEmoji) 338 | if err := bot.Client.React(msg.ChannelID, msg.ID, apiEmoji); err != nil { 339 | log.Printf("failed to react when creating role menu: %v\n", err) 340 | } 341 | time.Sleep(750 * time.Millisecond) // We want to wait for the actual rate-limit, but Arikawa does not handle that for you 342 | } 343 | 344 | msg, _ = bot.Client.EditMessage( 345 | msgOriginal.ChannelID, 346 | msgOriginal.ID, 347 | "", 348 | discord.Embed{ 349 | Title: "Role Menu", 350 | Description: "Successfully created role menu!", 351 | Color: bot.SuccessColor, 352 | }, 353 | ) 354 | time.Sleep(5 * time.Second) 355 | 356 | err = bot.Client.DeleteMessage(msg.ChannelID, msg.ID, "cleaning up log msg") 357 | return err 358 | } 359 | } 360 | default: 361 | return defaultHelp() 362 | } 363 | } 364 | 365 | func RoleMenuReactionAddHandler(i interface{}) { 366 | defer util.LogPanic() 367 | e := i.(*gateway.MessageReactionAddEvent) 368 | 369 | // Don't modify bots / self 370 | if e.Member.User.Bot { 371 | return 372 | } 373 | 374 | roleID, auditLogReason := getRoleFromEvent(e.GuildID, e.MessageID, e.ChannelID, e.Emoji, true) 375 | if roleID == 0 || roleID == -1 { 376 | return 377 | } 378 | 379 | // Remove role if user already has it 380 | if util.SliceContains(e.Member.RoleIDs, discord.RoleID(roleID)) { 381 | log.Printf("trying to remove role (toggle): %v (%s)\n", roleID, auditLogReason) 382 | 383 | if err := bot.Client.RemoveRole(e.GuildID, e.UserID, discord.RoleID(roleID), auditLogReason); err != nil { 384 | log.Printf("failed to remove reaction role (toggle): %v\n", err) 385 | } 386 | return 387 | } 388 | 389 | // Otherwise, we add the role 390 | log.Printf("trying to add role: %v (%s)\n", roleID, auditLogReason) 391 | 392 | if err := bot.Client.AddRole(e.GuildID, e.UserID, discord.RoleID(roleID), api.AddRoleData{AuditLogReason: auditLogReason}); err != nil { 393 | log.Printf("failed to add reaction role: %v\n", err) 394 | } 395 | } 396 | 397 | func getRoleFromEvent(id discord.GuildID, messageID discord.MessageID, channelID discord.ChannelID, emoji discord.Emoji, add bool) (int64, api.AuditLogReason) { 398 | if p.Config == nil { 399 | return -1, "" // Not configured 400 | } 401 | 402 | roleMenus, ok := p.Config.(config).Menus[id.String()] 403 | if !ok { 404 | return -1, "" // No Menu configured 405 | } 406 | 407 | menu, ok := roleMenus[messageID.String()] 408 | if !ok { 409 | return -1, "" // Reacted message does not have a Menu 410 | } 411 | 412 | apiEmoji := emoji.APIString() 413 | role, ok := menu.Roles[bot.EmojiApiAsConfig(&apiEmoji, emoji.Animated)] 414 | 415 | textReacted := "reacted" 416 | textTo := "to" 417 | if !add { 418 | textReacted = "removed" 419 | textTo = "from" 420 | } 421 | 422 | auditLogReason := api.AuditLogReason(fmt.Sprintf("user %s %s %s %v/%v", textReacted, emoji, textTo, channelID, messageID)) 423 | return role.RoleID, auditLogReason 424 | } 425 | -------------------------------------------------------------------------------- /plugins/spotifytoyoutube/spotifytoyoutube.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/5HT2/taro-bot/bot" 8 | "github.com/5HT2/taro-bot/cmd" 9 | "github.com/5HT2/taro-bot/plugins" 10 | "github.com/5HT2/taro-bot/util" 11 | "github.com/go-co-op/gocron" 12 | "golang.org/x/net/html" 13 | "log" 14 | "net/http" 15 | "net/url" 16 | "path" 17 | "regexp" 18 | "strconv" 19 | "strings" 20 | "time" 21 | ) 22 | 23 | var ( 24 | p *plugins.Plugin 25 | spotifyBaseUrl = `https://open.spotify.com/track/` 26 | spotifyRegex = regexp.MustCompile(`https?://open\.spotify\.com/track/[a-zA-Z\d]\S{2,}`) 27 | spotifyTitleRegex = regexp.MustCompile(`(.*) - song( and lyrics)? by (.*) \\\| Spotify`) 28 | 29 | instances []InvidiousInstance 30 | cachedResults = make(map[string]string, 0) // [spotify ID]YouTube ID 31 | ) 32 | 33 | func InitPlugin(_ *plugins.PluginInit) *plugins.Plugin { 34 | p = &plugins.Plugin{ 35 | Name: "Spotify to YouTube", 36 | Description: "Turns Spotify links into YouTube links", 37 | Version: "1.0.0", 38 | Commands: []bot.CommandInfo{{ 39 | Fn: YoutubeCommand, 40 | FnName: "YoutubeCommand", 41 | Name: "youtube", 42 | Aliases: []string{"yt"}, 43 | Description: "Search YouTube for a video!", 44 | }, { 45 | Fn: YoutubeTestCommand, 46 | FnName: "YoutubeTestCommand", 47 | Name: "youtubetest", 48 | Aliases: []string{"ytt"}, 49 | Description: "Benchmark how long it takes to query Youtube.", 50 | }}, 51 | Responses: []bot.ResponseInfo{{ 52 | Fn: SpotifyToYoutubeResponse, 53 | Regexes: []string{spotifyRegex.String()}, 54 | MatchMin: 1, 55 | }}, 56 | Jobs: []bot.JobInfo{{ 57 | Fn: func() (*gocron.Job, error) { 58 | return bot.Scheduler.Every(1).Hour().Do(updateInstances, "hourly job") 59 | }, 60 | Name: "invidious-instances-update", 61 | }}, 62 | } 63 | return p 64 | } 65 | 66 | type InvidiousInstance struct { 67 | Flag string `json:"flag"` 68 | Region string `json:"region"` 69 | API bool `json:"api"` 70 | URI string `json:"uri"` 71 | } 72 | 73 | type SearchResult struct { 74 | Type string `json:"type"` 75 | ID string `json:"videoId"` 76 | Title string `json:"title"` 77 | } 78 | 79 | func (r SearchResult) String() string { 80 | return fmt.Sprintf("[%s, %s, %s]", r.Type, r.ID, r.Title) 81 | } 82 | 83 | func YoutubeTestCommand(c bot.Command) error { 84 | _, err := queryYoutube("test", true) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | diff := time.Now().UnixMilli() - c.E.Timestamp.Time().UnixMilli() 90 | _, err = cmd.SendEmbed(c.E, p.Name, fmt.Sprintf("Took %vms to query youtube video!", diff), bot.SuccessColor) 91 | 92 | return err 93 | } 94 | 95 | func YoutubeCommand(c bot.Command) error { 96 | args, _ := cmd.ParseStringSliceArg(c.Args, 1, -1) 97 | s := strings.Join(args, " ") 98 | if len(s) == 0 { 99 | return bot.GenericSyntaxError(c.FnName, s, "expected video title") 100 | } 101 | 102 | searchResult, err := queryYoutube(s, true) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | if searchResult == nil { 108 | _, err = cmd.SendEmbed(c.E, p.Name, "Error: No search results found", bot.ErrorColor) 109 | return err 110 | } 111 | 112 | _, err = cmd.SendMessage(c.E, "https://youtu.be/"+searchResult.ID) 113 | return err 114 | } 115 | 116 | func SpotifyToYoutubeResponse(r bot.Response) { 117 | // Get the Spotify link from the message 118 | // 119 | 120 | spotifyUrl := spotifyRegex.FindStringSubmatch(r.E.Content) 121 | if len(spotifyUrl) == 0 { 122 | _, _ = cmd.SendEmbed(r.E, p.Name, "Error: Couldn't find Spotify link in message", bot.ErrorColor) 123 | return 124 | } 125 | 126 | parsedSpotifyUrl, err := url.Parse(spotifyUrl[0]) 127 | if err != nil { 128 | _, _ = cmd.SendEmbed(r.E, p.Name, "Error: "+err.Error(), bot.ErrorColor) 129 | return 130 | } 131 | 132 | spotifyID := path.Base(parsedSpotifyUrl.Path) 133 | log.Printf("spotifyID: %s\n\n", spotifyID) 134 | 135 | if ytID, ok := cachedResults[spotifyID]; ok { 136 | log.Printf("spotifyID: found ytID cache: %s\n", ytID) 137 | 138 | _, _ = cmd.SendMessage(r.E, "https://youtu.be/"+ytID) 139 | return 140 | } 141 | 142 | // Get Artist and Song Title from Spotify 143 | // 144 | 145 | content, resp, err := util.RequestUrl(spotifyBaseUrl+spotifyID, http.MethodGet) 146 | if err != nil { 147 | _, _ = cmd.SendEmbed(r.E, p.Name, "Error: "+err.Error(), bot.ErrorColor) 148 | return 149 | } 150 | if resp.StatusCode != http.StatusOK { 151 | _, _ = cmd.SendEmbed(r.E, p.Name, "Error: Spotify returned a `"+strconv.Itoa(resp.StatusCode)+"` status code, expected `200`", bot.ErrorColor) 152 | return 153 | } 154 | 155 | node, err := util.ExtractNode(string(content), func(node *html.Node) bool { 156 | return node.Data == "title" && node.FirstChild.Data != "more-icon-android" && node.FirstChild.Data != "Spotify" 157 | }) 158 | if err != nil { 159 | _, _ = cmd.SendEmbed(r.E, p.Name, "Error: "+err.Error(), bot.ErrorColor) 160 | return 161 | } 162 | 163 | text := &bytes.Buffer{} 164 | util.ExtractNodeText(node, text) 165 | log.Printf("SpotifyToYoutube: text: %s\n", text.String()) 166 | 167 | res := spotifyTitleRegex.FindStringSubmatch(regexp.QuoteMeta(text.String())) 168 | if len(res) == 0 { 169 | _, _ = cmd.SendEmbed(r.E, p.Name, "Error: Couldn't parse Spotify song title", bot.ErrorColor) 170 | return 171 | } 172 | 173 | log.Printf("SpotifyToYoutube: res: [%s]\n", strings.Join(res, ", ")) 174 | 175 | if len(res) != 4 { 176 | _, _ = cmd.SendEmbed(r.E, p.Name, "Error: `res` is not 4: `["+strings.Join(res, ", ")+"]`", bot.ErrorColor) 177 | return 178 | } 179 | 180 | artistAndSong := res[3] + " - " + res[1] // Artist - Song Title 181 | searchResult, err := queryYoutube(artistAndSong, true) 182 | if err != nil { 183 | _, _ = cmd.SendEmbed(r.E, p.Name, "Error:\n"+err.Error(), bot.ErrorColor) 184 | return 185 | } 186 | 187 | if searchResult == nil { 188 | _, _ = cmd.SendEmbed(r.E, p.Name, "Error: No search results found", bot.ErrorColor) 189 | return 190 | } 191 | 192 | cachedResults[spotifyID] = searchResult.ID 193 | _, _ = cmd.SendMessage(r.E, "https://youtu.be/"+searchResult.ID) 194 | } 195 | 196 | func queryYoutube(query string, firstRun bool) (*SearchResult, error) { 197 | if len(instances) == 0 { 198 | updateInstances("queryYoutube called") 199 | } 200 | 201 | // Make list of instances to query 202 | // 203 | 204 | query = url.PathEscape(strings.ReplaceAll(query, "\"", "")) // remove quotes and path escape 205 | searchQuery := "/api/v1/search?q=" + query 206 | searchUrls := make([]string, 0) 207 | 208 | for _, instance := range instances { 209 | searchUrls = append(searchUrls, instance.URI+searchQuery) // this will use more memory but reduces code complexity 210 | } 211 | 212 | if len(searchUrls) == 0 { 213 | updateInstances("queryYoutube searchUrls == 0") 214 | if firstRun { 215 | return queryYoutube(query, false) 216 | } 217 | return nil, bot.GenericError("queryYoutube", "Searching query", "No Invidious instances found") 218 | } 219 | 220 | log.Printf("queryYoutube: searchUrls %s\n", searchUrls) 221 | 222 | // Query all available search URLs 223 | // 224 | 225 | content := util.RequestUrlRetry(searchUrls, http.MethodGet, http.StatusOK) 226 | if content == nil { 227 | return nil, bot.GenericError("queryYoutube", "Searching `searchUrls`", "nil response received") 228 | } 229 | 230 | // Parse returned YouTube result 231 | // 232 | 233 | var searchResults []SearchResult 234 | err := json.Unmarshal(content, &searchResults) 235 | if err != nil { 236 | return nil, err 237 | } 238 | 239 | var searchResult *SearchResult = nil 240 | // pick first result with Type "video" 241 | for _, r := range searchResults { 242 | if r.Type != "video" { 243 | continue 244 | } 245 | searchResult = &r 246 | break 247 | } 248 | 249 | log.Printf("queryYoutube: searchResult %s\n", searchResult) 250 | 251 | return searchResult, nil 252 | } 253 | 254 | func updateInstances(reason string) { 255 | log.Printf("updateInstances: updating because: %s\n", reason) 256 | 257 | getInstancesFn := func() ([]byte, error) { 258 | b, _, err := util.RequestUrl("https://api.invidious.io/instances.json?sort_by=users,health", http.MethodGet) 259 | return b, err 260 | } 261 | 262 | instancesStr, err := util.RetryFunc(getInstancesFn, 2, 300) // This will take a max of ~16 seconds to execute, with a 5s timeout 263 | if err != nil { 264 | log.Printf("updateInstances: %v\n", err) 265 | } else { 266 | // We don't want to replace the cache if it errored 267 | 268 | var instanceResponse [][]InvidiousInstance 269 | // For some reason this will always error even though it gives the expected result 270 | _ = json.Unmarshal(instancesStr, &instanceResponse) 271 | 272 | instances = make([]InvidiousInstance, 0) 273 | 274 | for _, instance := range instanceResponse { 275 | // instance[0] is the instance URI 276 | // instance[1] is the object with said instance's info 277 | if instance[1].API == true { 278 | instances = append(instances, instance[1]) 279 | } 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /plugins/starboard/starboard.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/5HT2/taro-bot/bot" 6 | "github.com/5HT2/taro-bot/cmd" 7 | "github.com/5HT2/taro-bot/plugins" 8 | "github.com/5HT2/taro-bot/util" 9 | "github.com/diamondburned/arikawa/v3/discord" 10 | "github.com/diamondburned/arikawa/v3/gateway" 11 | "log" 12 | "reflect" 13 | "sort" 14 | "strconv" 15 | "time" 16 | ) 17 | 18 | var ( 19 | escapedStar = "%E2%AD%90" 20 | stars3Emoji = "⭐" 21 | stars5Emoji = "🌟" 22 | stars6Emoji = "💫" 23 | stars9Emoji = "✨" 24 | 25 | starboardColor discord.Color = 0xffac33 26 | ) 27 | 28 | func InitPlugin(_ *plugins.PluginInit) *plugins.Plugin { 29 | return &plugins.Plugin{ 30 | Name: "Starboard", 31 | Description: "Pin messages to a custom channel", 32 | Version: "1.0.0", 33 | Commands: []bot.CommandInfo{{ 34 | Fn: StarboardConfigCommand, 35 | FnName: "StarboardConfigCommand", 36 | Name: "starboardconfig", 37 | Aliases: []string{"starboardcfg", "scfg"}, 38 | Description: "Configure Starboard", 39 | GuildOnly: true, 40 | }, { 41 | Fn: StarboardTopPostsCommand, 42 | FnName: "StarboardTopPostsCommand", 43 | Name: "starboardtopposts", 44 | Aliases: []string{"sbtop"}, 45 | Description: "Get the most starred posts in this guild!", 46 | GuildOnly: true, 47 | }}, 48 | Responses: []bot.ResponseInfo{}, 49 | Handlers: []bot.HandlerInfo{{ 50 | Fn: StarboardReactionHandler, 51 | FnName: "StarboardReactionHandler", 52 | FnType: reflect.TypeOf(func(*gateway.MessageReactionAddEvent) {}), 53 | }}, 54 | } 55 | } 56 | 57 | func StarboardTopPostsCommand(c bot.Command) error { 58 | nsfwArg, argErr := cmd.ParseStringArg(c.Args, 1, false) 59 | nsfw, argErr := cmd.ParseBoolArg(c.Args, 1) 60 | if argErr != nil && len(nsfwArg) > 0 { 61 | _, err := cmd.SendEmbed(c.E, c.Name, 62 | "Available arguments are:\n- ``", 63 | bot.DefaultColor) 64 | return err 65 | } 66 | 67 | channel, err := bot.Client.Channel(c.E.ChannelID) 68 | if err != nil { 69 | return err 70 | 71 | } 72 | 73 | if nsfw && !channel.NSFW { 74 | _, err := cmd.SendEmbed(c.E, c.Name, "You can only use the `nsfw` arg in NSFW channels!", bot.ErrorColor) 75 | return err 76 | } 77 | 78 | posts := make([]bot.StarboardMessage, 0) 79 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 80 | for _, p := range g.Starboard.Messages { 81 | if p.IsNsfw == nsfw { 82 | posts = append(posts, p) 83 | } 84 | } 85 | return g, "StarboardTopPostsCommand: get g.Starboard.Messages" 86 | }) 87 | 88 | if len(posts) == 0 { 89 | _, err := cmd.SendEmbed(c.E, c.Name, "This server doesn't have any starboard posts. Try again when you have more!", bot.WarnColor) 90 | return err 91 | } 92 | 93 | // sort by number of stars 94 | sort.Slice(posts, func(i, j int) bool { 95 | return len(posts[i].Stars) > len(posts[j].Stars) 96 | }) 97 | 98 | embeds := make([]discord.Embed, 0) 99 | limit := 0 100 | 101 | for _, p := range posts { 102 | limit++ 103 | if limit > 5 { 104 | break 105 | } 106 | 107 | var embedAuthor discord.EmbedAuthor 108 | if member, err := bot.Client.Member(c.E.GuildID, discord.UserID(p.Author)); err == nil { 109 | embedAuthor = *cmd.CreateEmbedAuthor(*member) 110 | } else if user, err := bot.Client.User(discord.UserID(p.Author)); err == nil { 111 | embedAuthor = *cmd.CreateEmbedAuthorUser(*user) 112 | } 113 | 114 | field := discord.EmbedField{Name: "View Post", Value: cmd.CreateMessageLinkInt64(int64(c.E.GuildID), p.ID, p.CID, true, false)} 115 | footer := discord.EmbedFooter{Text: fmt.Sprintf("%v", p.Author)} 116 | embed := discord.Embed{ 117 | Description: getEmojiChannelMention(len(p.Stars), p.CID), 118 | Author: &embedAuthor, 119 | Fields: []discord.EmbedField{field}, 120 | Footer: &footer, 121 | Color: starboardColor, 122 | } 123 | 124 | embeds = append(embeds, embed) 125 | } 126 | 127 | _, err = bot.Client.SendEmbeds(c.E.ChannelID, embeds...) 128 | return err 129 | } 130 | 131 | func StarboardConfigCommand(c bot.Command) error { 132 | if err := cmd.HasPermission(c, cmd.PermChannels); err != nil { 133 | return err 134 | } 135 | 136 | if arg, err := cmd.ParseStringArg(c.Args, 1, true); err != nil { 137 | return err 138 | } else { 139 | arg2, errParse := cmd.ParseChannelArg(c.Args, 2) 140 | var err error = nil 141 | 142 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 143 | switch arg { 144 | case "regular": 145 | if errParse != nil { 146 | g.Starboard.Channel = 0 147 | _, err = cmd.SendEmbed(c.E, "Starboard Channels", "⛔ Disabled regular starboard", bot.ErrorColor) 148 | return g, "StarboardConfigCommand: disable regular starboard" 149 | } else { 150 | g.Starboard.Channel = arg2 151 | _, err = cmd.SendEmbed(c.E, "Starboard Channels", "✅ Enabled regular starboard", bot.SuccessColor) 152 | return g, "StarboardConfigCommand: enable regular starboard" 153 | } 154 | case "nsfw": 155 | if errParse != nil { 156 | g.Starboard.NsfwChannel = 0 157 | _, err = cmd.SendEmbed(c.E, "Starboard Channels", "⛔ Disabled NSFW starboard", bot.ErrorColor) 158 | return g, "StarboardConfigCommand: disable nsfw starboard" 159 | } else { 160 | g.Starboard.NsfwChannel = arg2 161 | _, err = cmd.SendEmbed(c.E, "Starboard Channels", "✅ Enabled NSFW starboard", bot.SuccessColor) 162 | return g, "StarboardConfigCommand: enable nsfw starboard" 163 | } 164 | case "threshold": 165 | if arg3, errParse := cmd.ParseInt64Arg(c.Args, 2); errParse != nil { 166 | _, err = cmd.SendEmbed(c.E, "Starboard Threshold", fmt.Sprintf("Current star threshold is: %v", g.Starboard.Threshold), bot.DefaultColor) 167 | } else { 168 | if arg3 <= 0 { 169 | arg3 = 1 170 | } 171 | 172 | g.Starboard.Threshold = arg3 173 | _, err = cmd.SendEmbed(c.E, "Starboard Threshold", fmt.Sprintf("✅ Set threshold to: %v", arg3), bot.SuccessColor) 174 | } 175 | 176 | return g, "StarboardConfigCommand: set threshold" 177 | case "list": 178 | regularC := "✅ Regular Starboard (<#" + strconv.FormatInt(g.Starboard.Channel, 10) + ">)" 179 | nsfwC := "✅ NSFW Starboard (<#" + strconv.FormatInt(g.Starboard.NsfwChannel, 10) + ">)" 180 | if g.Starboard.Channel == 0 { 181 | regularC = "⛔ Regular Starboard" 182 | } 183 | if g.Starboard.NsfwChannel == 0 { 184 | nsfwC = "⛔ NSFW Starboard" 185 | } 186 | 187 | embed := discord.Embed{ 188 | Title: "Starboard Channels", 189 | Description: regularC + "\n" + nsfwC, 190 | Color: bot.DefaultColor, 191 | } 192 | _, err = cmd.SendCustomEmbed(c.E.ChannelID, embed) 193 | return g, "StarboardConfigCommand: list starboard channels" 194 | default: 195 | _, err = cmd.SendEmbed(c.E, 196 | "Configure Starboard", 197 | "Available arguments are:\n- `list`\n- `threshold `\n- `nsfw|regular [channel]`", 198 | bot.DefaultColor) 199 | return g, "StarboardConfigCommand: show help" 200 | } 201 | }) 202 | return err 203 | } 204 | } 205 | 206 | func StarboardReactionHandler(i interface{}) { 207 | defer util.LogPanic() 208 | 209 | e := i.(*gateway.MessageReactionAddEvent) 210 | start := time.Now().UnixMilli() 211 | 212 | bot.GuildContext(e.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 213 | if g.Starboard.Threshold == 0 { 214 | g.Starboard.Threshold = 3 215 | } 216 | 217 | // Not starred by a guild member 218 | if e.Member == nil { 219 | log.Printf("Not a guild member\n") 220 | return g, "StarboardReactionHandler: check guild member" 221 | } 222 | 223 | // Not a star 224 | if e.Emoji.APIString().PathString() != escapedStar { 225 | return g, "StarboardReactionHandler: check reaction emoji" 226 | } 227 | 228 | msg, err := bot.Client.Message(e.ChannelID, e.MessageID) 229 | if err != nil { 230 | return g, "StarboardReactionHandler: get reaction message" 231 | } 232 | channel, err := bot.Client.Channel(e.ChannelID) 233 | if err != nil { 234 | return g, "StarboardReactionHandler: get reaction channel" 235 | } 236 | 237 | var sMsg *bot.StarboardMessage = nil 238 | newPost := true 239 | cID := int64(channel.ID) 240 | 241 | log.Printf("Checking channel for starboard message %s\n", cmd.CreateMessageLink(int64(e.GuildID), msg, false, false)) 242 | 243 | // If user reacts to a post in a starboard channel 244 | if cID == g.Starboard.Channel || cID == g.Starboard.NsfwChannel { 245 | for _, m := range g.Starboard.Messages { 246 | // If the reaction message ID matches a starboard post, or if it matches an original message that *has* a starboard post 247 | if m.PostID == int64(msg.ID) || m.ID == int64(msg.ID) { 248 | sMsg = &m 249 | newPost = false 250 | break 251 | } 252 | } 253 | } else { // else if a user reacts to a post in a regular channel 254 | for _, m := range g.Starboard.Messages { 255 | if m.ID == int64(msg.ID) { 256 | sMsg = &m 257 | newPost = false 258 | break 259 | } 260 | } 261 | 262 | // If starred before channel ID was added, and the reaction is from the origin channel, update the stored one 263 | if !newPost && sMsg.CID == 0 { 264 | sMsg.CID = int64(msg.ChannelID) 265 | } 266 | } 267 | 268 | if newPost { 269 | sMsg = &bot.StarboardMessage{ 270 | Author: int64(msg.Author.ID), 271 | CID: int64(msg.ChannelID), 272 | ID: int64(msg.ID), 273 | PostID: 0, 274 | IsNsfw: channel.NSFW, 275 | Stars: make([]int64, 0), 276 | } 277 | log.Printf("Making new starboard message: %v\n", sMsg) 278 | } 279 | 280 | // Channel to send starboard message to 281 | cID = g.Starboard.Channel 282 | if sMsg.IsNsfw == true { 283 | cID = g.Starboard.NsfwChannel 284 | } 285 | 286 | // Channel hasn't been set 287 | if cID == 0 { 288 | log.Printf("Channel ID is 0\n") 289 | return g, "StarboardReactionHandler: check cID" 290 | } 291 | 292 | // Get post channel and ensure it exists 293 | postChannel, err := bot.Client.Channel(discord.ChannelID(cID)) 294 | if err != nil { 295 | log.Printf("Couldn't get post channel\n") 296 | return g, "StarboardReactionHandler: get post channel" 297 | } 298 | 299 | // When adding a new star, ensure star user is not the same as author 300 | // And also check if they've already been added 301 | sUserID := int64(e.Member.User.ID) 302 | if sMsg.Author != sUserID && !util.SliceContains(sMsg.Stars, sUserID) { 303 | sMsg.Stars = append(sMsg.Stars, sUserID) 304 | } 305 | log.Printf("sUserID: %v\nsMsg:%v\n", sUserID, sMsg) 306 | 307 | // Update our reactions in case any are missing from the API 308 | for _, reaction := range msg.Reactions { 309 | if reaction.Emoji.APIString().PathString() == escapedStar { 310 | userReactions, err := bot.Client.Reactions(msg.ChannelID, msg.ID, reaction.Emoji.APIString(), 0) 311 | if err != nil { 312 | log.Printf("Failed to get userReactions: %s\n", err) 313 | return g, "StarboardReactionHandler: update sMsg.Stars" 314 | } 315 | 316 | for _, userReaction := range userReactions { 317 | sUserID = int64(userReaction.ID) 318 | 319 | if sMsg.Author != sUserID && !util.SliceContains(sMsg.Stars, sUserID) { 320 | sMsg.Stars = append(sMsg.Stars, sUserID) 321 | } 322 | } 323 | break 324 | } 325 | } 326 | 327 | stars := len(sMsg.Stars) 328 | 329 | // Not enough stars in sMsg to make post 330 | if int64(stars) < g.Starboard.Threshold { 331 | log.Printf("Not enough stars: %v\n", sMsg.Stars) 332 | return g, "StarboardReactionHandler: check notEnoughStars" 333 | } 334 | 335 | content := getEmojiChannelMention(stars, sMsg.CID) 336 | 337 | // Attempt to get existing message, and make a new one if it isn't there 338 | pMsg, err := bot.Client.Message(postChannel.ID, discord.MessageID(sMsg.PostID)) 339 | if err != nil { 340 | log.Printf("Couldn't get pMsg (%v / %v) %v\n", postChannel.ID, sMsg.PostID, err) 341 | 342 | // 343 | // Construct new starboard post if it couldn't retrieve an existing one 344 | 345 | member, err := bot.Client.Member(e.GuildID, discord.UserID(sMsg.Author)) 346 | if err != nil { 347 | log.Printf("Couldn't get member %v\n", err) 348 | return g, "StarboardReactionHandler: get sMsg.Author" 349 | } 350 | 351 | description, image := cmd.GetEmbedAttachmentAndContent(*msg) 352 | field := discord.EmbedField{Name: "Source", Value: cmd.CreateMessageLink(int64(e.GuildID), msg, true, false)} 353 | footer := discord.EmbedFooter{Text: fmt.Sprintf("%v", sMsg.Author)} 354 | embed := discord.Embed{ 355 | Description: description, 356 | Author: cmd.CreateEmbedAuthor(*member), 357 | Fields: []discord.EmbedField{field}, 358 | Footer: &footer, 359 | Timestamp: msg.Timestamp, 360 | Color: starboardColor, 361 | Image: image, 362 | } 363 | 364 | log.Printf("Embed image: %v\n", embed.Image) 365 | 366 | msg, err = bot.Client.SendMessage(postChannel.ID, content, embed) 367 | if err != nil { 368 | log.Printf("Error sending starboard post: %v\n", err) 369 | } else { 370 | sMsg.PostID = int64(msg.ID) 371 | } 372 | } else { 373 | // Edit the post if it exists 374 | _, err = bot.Client.EditMessage(postChannel.ID, discord.MessageID(sMsg.PostID), content, pMsg.Embeds...) 375 | if err != nil { 376 | log.Printf("Error updating starboard post: %v\n", err) 377 | } 378 | } 379 | 380 | // Now that we have updated the stars and starboard post ID, save it in the config 381 | if newPost { 382 | g.Starboard.Messages = append(g.Starboard.Messages, *sMsg) 383 | } else { 384 | for i, m := range g.Starboard.Messages { 385 | if m.ID == sMsg.ID { 386 | g.Starboard.Messages[i] = *sMsg 387 | } 388 | } 389 | } 390 | 391 | return g, "StarboardReactionHandler: update post" 392 | }) 393 | 394 | log.Printf("Execute: %vms (StarboardReactionHandler)\n", time.Now().UnixMilli()-start) 395 | } 396 | 397 | func getEmoji(stars int) (emoji string) { 398 | switch stars { 399 | case 0, 1, 2, 3, 4: 400 | emoji = stars3Emoji 401 | case 5: 402 | emoji = stars5Emoji 403 | case 6, 7, 8: 404 | emoji = stars6Emoji 405 | default: 406 | emoji = stars9Emoji 407 | } 408 | 409 | return emoji 410 | } 411 | 412 | func getEmojiChannelMention(stars int, channel int64) string { 413 | return fmt.Sprintf("%s **%v** <#%v>", getEmoji(stars), stars, channel) 414 | } 415 | -------------------------------------------------------------------------------- /plugins/suggest-topic/suggest-topic.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/5HT2/taro-bot/bot" 5 | "github.com/5HT2/taro-bot/cmd" 6 | "github.com/5HT2/taro-bot/plugins" 7 | "github.com/5HT2/taro-bot/util" 8 | "github.com/diamondburned/arikawa/v3/api" 9 | "github.com/diamondburned/arikawa/v3/discord" 10 | "github.com/diamondburned/arikawa/v3/gateway" 11 | "github.com/diamondburned/arikawa/v3/utils/json/option" 12 | "reflect" 13 | "strconv" 14 | "strings" 15 | ) 16 | 17 | var ( 18 | escapedCheckmark = "%E2%9C%85" 19 | ) 20 | 21 | func InitPlugin(_ *plugins.PluginInit) *plugins.Plugin { 22 | return &plugins.Plugin{ 23 | Name: "Suggest Topic", 24 | Description: "Allow suggesting a topic for the current channel", 25 | Version: "1.0.0", 26 | Commands: []bot.CommandInfo{{ 27 | Fn: TopicConfigCommand, 28 | FnName: "TopicConfigCommand", 29 | Name: "topicconfig", 30 | Aliases: []string{"topiccfg"}, 31 | Description: "Configure allowed topic channels", 32 | GuildOnly: true, 33 | }, { 34 | Fn: TopicCommand, 35 | FnName: "TopicCommand", 36 | Name: "topic", 37 | Description: "Suggest a new topic for the current channel", 38 | GuildOnly: true, 39 | }}, 40 | Responses: []bot.ResponseInfo{}, 41 | Handlers: []bot.HandlerInfo{{ 42 | Fn: TopicReactionHandler, 43 | FnName: "TopicReactionHandler", 44 | FnType: reflect.TypeOf(func(*gateway.MessageReactionAddEvent) {}), 45 | }}, 46 | } 47 | } 48 | 49 | func TopicConfigCommand(c bot.Command) error { 50 | if err := cmd.HasPermission(c, cmd.PermChannels); err != nil { 51 | return err 52 | } 53 | 54 | channels := []int64{int64(c.E.ChannelID)} 55 | 56 | if argChannels, err := cmd.ParseChannelSliceArg(c.Args, 2, -1); err == nil && len(argChannels) != 0 { 57 | channels = argChannels 58 | } 59 | channelsStr := util.JoinInt64Slice(channels, ", ", "<#", ">") 60 | 61 | arg1, err := cmd.ParseStringArg(c.Args, 1, true) 62 | if err != nil { 63 | arg1 = "help" 64 | err = nil 65 | } 66 | 67 | switch arg1 { 68 | case "enable": 69 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 70 | for _, channel := range channels { 71 | if !util.SliceContains(g.EnabledTopicChannels, channel) { 72 | g.EnabledTopicChannels = append(g.EnabledTopicChannels, channel) 73 | } 74 | } 75 | return g, "TopicConfigCommand: topic enable" 76 | }) 77 | _, err := cmd.SendEmbed(c.E, "Configure Topics", "✅ Added "+channelsStr+" to the allowed topic channels", bot.SuccessColor) 78 | return err 79 | case "disable": 80 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 81 | for _, channel := range channels { 82 | if util.SliceContains(g.EnabledTopicChannels, channel) { 83 | g.EnabledTopicChannels = util.SliceRemove(g.EnabledTopicChannels, channel) 84 | } 85 | } 86 | return g, "TopicConfigCommand: topic disable" 87 | }) 88 | _, err := cmd.SendEmbed(c.E, "Configure Topics", "⛔ Removed "+channelsStr+" from the allowed topic channels", bot.ErrorColor) 89 | return err 90 | case "emoji": 91 | arg2, animated, err3 := cmd.ParseEmojiArg(c.Args, 2, true) 92 | if err3 != nil { 93 | return err3 94 | } 95 | 96 | if arg2 == nil { 97 | if emoji, err := topicVoteEmoji(c.E.GuildID); err != nil { 98 | return err 99 | } else { 100 | _, err = cmd.SendEmbed(c.E, "Current Topic Vote Emoji:", emoji, bot.DefaultColor) 101 | return err 102 | } 103 | } else { 104 | configEmoji := bot.EmojiApiAsConfig(arg2, animated) 105 | emoji, err := bot.EmojiConfigFormatted(configEmoji) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 111 | g.TopicVoteEmoji = configEmoji 112 | return g, "TopicConfigCommand: update TopicVoteEmoji" 113 | }) 114 | 115 | _, err = cmd.SendEmbed(c.E, "Set Topic Vote Emoji To:", emoji, bot.SuccessColor) 116 | return err 117 | } 118 | case "threshold": 119 | arg2, err2 := cmd.ParseInt64Arg(c.Args, 2) 120 | if err2 != nil { 121 | return err2 122 | } 123 | 124 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 125 | if arg2 <= 0 { 126 | arg2 = 3 127 | } 128 | 129 | g.TopicVoteThreshold = arg2 130 | return g, "TopicConfigCommand: update topic vote threshold" 131 | }) 132 | 133 | _, err := cmd.SendEmbed(c.E, "Set Topic Vote Threshold To:", strconv.FormatInt(arg2, 10), bot.SuccessColor) 134 | return err 135 | case "list": 136 | noTopicChan := false 137 | formattedChannels := "" 138 | 139 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 140 | formattedChannels = util.JoinInt64Slice(g.EnabledTopicChannels, "\n", "✅ <#", ">") 141 | noTopicChan = len(g.EnabledTopicChannels) == 0 142 | return g, "TopicConfigCommand: get enabled topic channels" 143 | }) 144 | 145 | if noTopicChan { 146 | _, err := cmd.SendEmbed(c.E, "Configure Topics", "There are currently no allowed topic channels", bot.DefaultColor) 147 | return err 148 | } 149 | 150 | _, err := cmd.SendEmbed(c.E, "Configure Topics", "Allowed Topic Channels:\n\n"+formattedChannels, bot.DefaultColor) 151 | return err 152 | default: 153 | _, err := cmd.SendEmbed(c.E, 154 | "Configure Topics", 155 | "Available arguments are:\n- `list`\n- `threshold [threshold]`\n- `enable|disable [channel]`", 156 | bot.DefaultColor) 157 | return err 158 | } 159 | } 160 | 161 | func TopicCommand(c bot.Command) error { 162 | topic, argErr := cmd.ParseAllArgs(c.Args) 163 | if argErr != nil { 164 | _, err := cmd.SendEmbed(c.E, 165 | "Suggest Topic", 166 | "Available arguments are:\n- `[topic to suggest]`\nUse the `topicconfig` command to figure topic channels!", 167 | bot.DefaultColor) 168 | return err 169 | } 170 | 171 | topicsEnabled := false 172 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 173 | topicsEnabled = util.SliceContains(g.EnabledTopicChannels, int64(c.E.ChannelID)) 174 | return g, "TopicCommand: check topicsEnabled" 175 | }) 176 | 177 | if !topicsEnabled { 178 | _, err := cmd.SendEmbed(c.E, "Topics are disabled in this channel!", "", bot.ErrorColor) 179 | return err 180 | } 181 | 182 | msg, err := cmd.SendEmbed(c.E, "New topic suggested!", c.E.Author.Mention()+" suggests: "+topic, bot.DefaultColor) 183 | if err != nil { 184 | return err 185 | } 186 | 187 | emoji, err := topicVoteApiEmoji(c.E.GuildID) 188 | if err != nil { 189 | return err 190 | } 191 | 192 | bot.GuildContext(c.E.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 193 | g.ActiveTopicVotes = append(g.ActiveTopicVotes, bot.ActiveTopicVote{Message: int64(msg.ID), Author: int64(c.E.Author.ID), Topic: topic}) 194 | return g, "TopicCommand: append ActiveTopicVotes" 195 | }) 196 | 197 | if err := bot.Client.React(msg.ChannelID, msg.ID, emoji); err != nil { 198 | return err 199 | } 200 | 201 | return nil 202 | } 203 | 204 | func TopicReactionHandler(i interface{}) { 205 | defer util.LogPanic() 206 | e := i.(*gateway.MessageReactionAddEvent) 207 | 208 | reactionMatchesActiveVote := false 209 | bot.GuildContext(e.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 210 | // Find an activeTopicVote that matches `e`'s reaction 211 | for _, vote := range g.ActiveTopicVotes { 212 | if int64(e.MessageID) == vote.Message { 213 | reactionMatchesActiveVote = true 214 | break 215 | } 216 | } 217 | 218 | // While we're here, make sure the vote threshold isn't the default 219 | if g.TopicVoteThreshold == 0 { 220 | g.TopicVoteThreshold = 3 221 | } 222 | return g, "TopicReactionHandler: check reaction emoji" 223 | }) 224 | 225 | if reactionMatchesActiveVote { 226 | message, err := bot.Client.Message(e.ChannelID, e.MessageID) 227 | if err != nil { 228 | return 229 | } 230 | 231 | emoji, err := topicVoteApiEmoji(e.GuildID) 232 | if err != nil { 233 | return 234 | } 235 | 236 | for _, reaction := range message.Reactions { 237 | if reaction.Emoji.APIString() == emoji { 238 | offset := 0 239 | if reaction.Me { 240 | offset = 1 241 | } 242 | 243 | meetsThreshold := false 244 | bot.GuildContext(e.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 245 | meetsThreshold = int64(reaction.Count-offset) >= g.TopicVoteThreshold 246 | return g, "TopicReactionHandler: check meetsThreshold" 247 | }) 248 | 249 | if meetsThreshold { 250 | vote := removeActiveVote(e) 251 | channel, err := bot.Client.Channel(e.ChannelID) 252 | if err != nil { 253 | return 254 | } 255 | 256 | oldTopic := "No previous topic set" 257 | if len(channel.Topic) > 0 { 258 | oldTopic = "\nOld topic was \"" + channel.Topic + "\"" 259 | } 260 | 261 | embed := discord.Embed{ 262 | Title: "New channel topic!", 263 | Description: "The topic is now **" + vote.Topic + "**, suggested by <@" + 264 | strconv.FormatInt(vote.Author, 10) + ">!", 265 | Footer: &discord.EmbedFooter{Text: oldTopic}, 266 | Color: bot.SuccessColor, 267 | } 268 | 269 | data := api.ModifyChannelData{Topic: option.NewNullableString(vote.Topic)} 270 | if err = bot.Client.ModifyChannel(e.ChannelID, data); err != nil { 271 | _, _ = cmd.SendExternalErrorEmbed(e.ChannelID, "TopicReactionHandler", err) 272 | } else { 273 | _, _ = cmd.SendCustomEmbed(e.ChannelID, embed) 274 | } 275 | } 276 | break 277 | } 278 | } 279 | } 280 | } 281 | 282 | func removeActiveVote(e *gateway.MessageReactionAddEvent) bot.ActiveTopicVote { 283 | oldVotes := make([]bot.ActiveTopicVote, 0) 284 | var removedVote bot.ActiveTopicVote 285 | message := int64(e.MessageID) 286 | 287 | bot.GuildContext(e.GuildID, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 288 | for _, vote := range g.ActiveTopicVotes { 289 | if message != vote.Message { 290 | oldVotes = append(oldVotes, vote) 291 | } else { 292 | removedVote = vote 293 | } 294 | } 295 | 296 | g.ActiveTopicVotes = oldVotes 297 | return g, "removeActiveVote" 298 | }) 299 | 300 | return removedVote 301 | } 302 | 303 | func topicVoteEmoji(id discord.GuildID) (string, error) { 304 | e := "" 305 | bot.GuildContext(id, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 306 | e = g.TopicVoteEmoji 307 | 308 | if len(e) == 0 { 309 | g.TopicVoteEmoji = escapedCheckmark 310 | } else { 311 | e = strings.TrimSuffix(e, "a:") 312 | } 313 | return g, "topicVoteEmoji" 314 | }) 315 | 316 | return bot.EmojiConfigFormatted(e) 317 | } 318 | 319 | func topicVoteApiEmoji(id discord.GuildID) (discord.APIEmoji, error) { 320 | e := "" 321 | bot.GuildContext(id, func(g *bot.GuildConfig) (*bot.GuildConfig, string) { 322 | e = g.TopicVoteEmoji 323 | 324 | if len(e) == 0 { 325 | g.TopicVoteEmoji = escapedCheckmark 326 | } 327 | return g, "topicVoteApiEmoji" 328 | }) 329 | 330 | return bot.EmojiConfigAsApi(e) 331 | } 332 | -------------------------------------------------------------------------------- /plugins/sys-stats/sys-stats.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "time" 10 | 11 | "github.com/5HT2/taro-bot/bot" 12 | "github.com/5HT2/taro-bot/cmd" 13 | "github.com/5HT2/taro-bot/plugins" 14 | cu "github.com/5HT2/taro-bot/util/cpu" 15 | "github.com/mackerelio/go-osstat/cpu" 16 | "github.com/mackerelio/go-osstat/memory" 17 | "github.com/mackerelio/go-osstat/uptime" 18 | ) 19 | 20 | var ( 21 | runningFetches = make(map[string]chan bool) 22 | ) 23 | 24 | func InitPlugin(_ *plugins.PluginInit) *plugins.Plugin { 25 | return &plugins.Plugin{ 26 | Name: "System Stats", 27 | Description: "Provides system statistics", 28 | Version: "1.0.0", 29 | Commands: []bot.CommandInfo{{ 30 | Fn: SysStatsCommand, 31 | FnName: "SysStatsCommand", 32 | Name: "systemstats", 33 | Aliases: []string{"stats", "stat", "sysstat"}, 34 | Description: "Provides system statistics", 35 | }}, 36 | } 37 | } 38 | 39 | func spacedString(s string, offset int) string { 40 | return fmt.Sprintf("%s%s", s, strings.Repeat(" ", offset-len(s))) 41 | } 42 | 43 | func SysStatsCommand(c bot.Command) error { 44 | // If we have a running fetch command in this guild, cancel it 45 | if quit, ok := runningFetches[c.E.GuildID.String()]; ok { 46 | quit <- true 47 | } else { 48 | // Start a new quit channel 49 | runningFetches[c.E.GuildID.String()] = make(chan bool) 50 | } 51 | 52 | // Determine displayed shell based on 53 | shell := "$" 54 | if err := cmd.HasPermission(c, cmd.PermOperator); err == nil { 55 | shell = "#" 56 | } 57 | if err := cmd.HasPermission(c, cmd.PermModerate); err == nil { 58 | shell = "#" 59 | } 60 | 61 | // Get hostname 62 | hostname, err := os.Hostname() 63 | if err != nil { 64 | return bot.GenericError(c.FnName, "getting hostname", err.Error()) 65 | } 66 | hostname = "taro@" + hostname 67 | 68 | // Get kernel release number 69 | kernelRelease, err := os.ReadFile("/proc/sys/kernel/osrelease") 70 | if err != nil { 71 | // Try for darwin 72 | if out, err := exec.Command("uname", "-r").CombinedOutput(); err != nil { 73 | return bot.GenericSyntaxError(c.FnName, "getting kernel release", err.Error()) 74 | } else { 75 | kernelRelease = append(out, []byte("-macOS")...) 76 | } 77 | } 78 | 79 | // Get current uptime 80 | uptimeDuration, err := uptime.Get() 81 | if err != nil { 82 | return bot.GenericError(c.FnName, "getting uptime", err.Error()) 83 | } 84 | 85 | var days int64 = 0 86 | hours := uptimeDuration.Hours() 87 | if hours >= 24.0 { 88 | days = int64(hours / 24) 89 | } 90 | hoursInt := int64(hours) - (days * 24) 91 | hoursS := "s" 92 | if hoursInt == 1 { 93 | hoursS = "" 94 | } 95 | 96 | // Get starting CPU usage 97 | cpuBefore, err := cpu.Get() 98 | if err != nil { 99 | return bot.GenericError(c.FnName, "getting cpu info", err.Error()) 100 | } 101 | 102 | // Fetch data to display inside fetch 103 | s := []string{ 104 | hostname, 105 | strings.ReplaceAll(fmt.Sprintf("%s", kernelRelease), "\n", ""), 106 | fmt.Sprintf("%v days, %v hour%s", days, hoursInt, hoursS), 107 | "[calculating...]", // We have to wait at least one second, so instead we update it later 108 | "[calculating...]", // Not necessary to wait for memory, technically, but it looks nicer this way 109 | } 110 | 111 | // Calculate longest number of trailing spaces required for width, based on data line length 112 | o := 0 113 | for _, i := range s { 114 | if len(i) > o { 115 | o = len(i) 116 | } 117 | } 118 | o += 1 119 | 120 | // Generate the fetch text art with given data 121 | generateFetch := func() string { 122 | info := fmt.Sprintf("%s:~%s %s\n", hostname, shell, c.Name) 123 | info += fmt.Sprintf(" ┌──────────────%s─┐\n", strings.Repeat("─", o)) 124 | info += fmt.Sprintf(" │ Hostname > %s │\n", spacedString(s[0], o)) 125 | info += fmt.Sprintf(" │ Kernel > %s │\n", spacedString(s[1], o)) 126 | info += fmt.Sprintf(" │ Uptime > %s │\n", spacedString(s[2], o)) 127 | info += fmt.Sprintf(" │ CPU Load > %s │\n", spacedString(s[3], o)) 128 | info += fmt.Sprintf(" │ Memory > %s │\n", spacedString(s[4], o)) 129 | info += fmt.Sprintf(" └──────────────%s─┘\n", strings.Repeat("─", o)) 130 | return fmt.Sprintf("```yml\n%s```", info) 131 | } 132 | 133 | // Start a goroutine for 60 seconds which updates CPU and memory in the fetch image. 134 | // If another stats command is started in the same guild, this fetch is cancelled in favor of the new one. 135 | go func() { 136 | msg, _ := cmd.SendMessage(c.E, generateFetch()) 137 | i := 0 138 | 139 | for { 140 | select { // only allow one running fetch per guild, by cancelling when receiving a quit signal from another message 141 | case <-runningFetches[c.E.GuildID.String()]: 142 | return 143 | default: 144 | i++ 145 | time.Sleep(time.Duration(1500) * time.Millisecond) 146 | 147 | cpuAfter, err := cpu.Get() 148 | if err != nil { 149 | log.Printf("%s\n", bot.GenericError(c.FnName, "getting cpu info", err.Error())) 150 | break 151 | } 152 | 153 | mem, err := memory.Get() 154 | if err != nil { 155 | log.Printf("%s\n", bot.GenericError(c.FnName, "getting memory info", err.Error())) 156 | break 157 | } 158 | 159 | s[3] = fmt.Sprintf( 160 | "%.2f%% (%v cores)", 161 | float64(cpuAfter.User-cpuBefore.User)/float64(cpuAfter.Total-cpuBefore.Total)*100, 162 | cu.GetCoresStr(cpuAfter), 163 | ) 164 | 165 | s[4] = fmt.Sprintf( 166 | "%.2f GB/%.2f GB", 167 | float64(mem.Used)/1024*0.000001, float64(mem.Total)/1024*0.000001, 168 | ) 169 | 170 | msg, _ = bot.Client.EditMessage(c.E.ChannelID, msg.ID, generateFetch()) 171 | 172 | // 40 iterations * 1500ms = 1 minute 173 | if i == 40 { 174 | return 175 | } 176 | } 177 | } 178 | }() 179 | 180 | return nil 181 | } 182 | -------------------------------------------------------------------------------- /plugins/tenor-delete/tenor-delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/5HT2/taro-bot/bot" 5 | "github.com/5HT2/taro-bot/cmd" 6 | "github.com/5HT2/taro-bot/plugins" 7 | "log" 8 | "reflect" 9 | "regexp" 10 | "sync" 11 | ) 12 | 13 | var ( 14 | p *plugins.Plugin 15 | mutex sync.Mutex 16 | tenorRegex = regexp.MustCompile(`http(s)?://t([ex])nor\.[A-z]+/view/.*`) 17 | ) 18 | 19 | type config struct { 20 | Guilds map[string]bool `json:"guilds,omitempty"` // [guild id]enabled 21 | } 22 | 23 | func InitPlugin(i *plugins.PluginInit) *plugins.Plugin { 24 | p = &plugins.Plugin{ 25 | Name: "Tenor Delete", 26 | Description: "Automatically delete tenor gifs", 27 | Version: "1.0.0", 28 | ConfigType: reflect.TypeOf(config{}), 29 | Commands: []bot.CommandInfo{{ 30 | Fn: TenorDeleteCommand, 31 | FnName: "TenorDeleteCommand", 32 | Name: "tenordelete", 33 | Description: "Toggle tenor deletion on or off", 34 | GuildOnly: true, 35 | }}, 36 | Responses: []bot.ResponseInfo{{ 37 | Fn: TenorDeleteResponse, 38 | Regexes: []string{tenorRegex.String()}, 39 | MatchMin: 1, 40 | }}, 41 | } 42 | p.ConfigDir = i.ConfigDir 43 | p.Config = p.LoadConfig() 44 | return p 45 | } 46 | 47 | func TenorDeleteResponse(r bot.Response) { 48 | mutex.Lock() 49 | defer mutex.Unlock() 50 | 51 | if p.Config == nil { 52 | return 53 | } 54 | 55 | if enabled, ok := p.Config.(config).Guilds[r.E.GuildID.String()]; ok && enabled { 56 | if err := bot.Client.DeleteMessage(r.E.ChannelID, r.E.Message.ID, "Matched Tenor gif"); err != nil { 57 | log.Printf("TenorDeleteResponse: %v\n", err) 58 | } 59 | } 60 | } 61 | 62 | func TenorDeleteCommand(c bot.Command) error { 63 | if err := cmd.HasPermission(c, cmd.PermModerate); err != nil { 64 | return err 65 | } 66 | 67 | id := c.E.GuildID.String() 68 | var err error = nil 69 | 70 | mutex.Lock() 71 | defer mutex.Unlock() 72 | 73 | if p.Config == nil { 74 | p.Config = config{Guilds: map[string]bool{id: false}} 75 | } 76 | 77 | enabled, _ := p.Config.(config).Guilds[id] 78 | p.Config.(config).Guilds[id] = !enabled 79 | 80 | if !enabled { 81 | _, err = cmd.SendEmbed(c.E, "Tenor Delete", "✅ Enabled Tenor Delete for this guild", bot.SuccessColor) 82 | } else { 83 | _, err = cmd.SendEmbed(c.E, "Tenor Delete", "⛔ Disabled Tenor Delete for this guild", bot.ErrorColor) 84 | } 85 | 86 | return err 87 | } 88 | -------------------------------------------------------------------------------- /scripts/build-plugins.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | PLUGINS_FILE="config/plugins.json" 4 | 5 | build_all() { 6 | for d in ./plugins/*/; do 7 | echo "building $d" 8 | go build -o "bin/" -buildmode=plugin "$d" 9 | done 10 | } 11 | 12 | plugin_loaded() { 13 | jq "select(.loaded_plugins != []).loaded_plugins | index(\"$(basename "$1")\")" "$PLUGINS_FILE" 14 | } 15 | 16 | DEFAULT_LOADED="$(plugin_loaded "default")" 17 | 18 | if [ -z "$(which jq)" ]; then 19 | echo "jq is not installed, doing unoptimized build..." 20 | build_all 21 | elif [ ! -f "$PLUGINS_FILE" ]; then 22 | echo "$PLUGINS_FILE is missing, doing unoptimized build..." 23 | build_all 24 | elif [ "$(jq ".loaded_plugins == []" "$PLUGINS_FILE")" = "true" ]; then 25 | echo "loaded_plugins is not set, building all plugins..." 26 | build_all 27 | elif [ -n "$DEFAULT_LOADED" ] && [ "$DEFAULT_LOADED" != "null" ]; then 28 | echo "loaded_plugins contains \"default\", building all plugins..." 29 | build_all 30 | else 31 | echo "building selected plugins..." 32 | for d in ./plugins/*/; do 33 | LOADED="$(plugin_loaded "$d")" 34 | 35 | if [ -n "$LOADED" ] && [ "$LOADED" != "null" ]; then 36 | echo "building $d" 37 | go build -o "bin/" -buildmode=plugin "$d" 38 | fi 39 | done 40 | fi 41 | -------------------------------------------------------------------------------- /scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | /taro-bot/taro -debug="$DEBUG" -plugindir="$PLUGIN_DIR" 4 | -------------------------------------------------------------------------------- /scripts/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # shellcheck disable=SC1091 4 | source "$HOME/.env" 5 | if [[ -z "$TARO_PATH" ]]; then 6 | echo "TARO_PATH not set!" 7 | exit 1 8 | fi 9 | 10 | docker pull l1ving/taro-bot:latest 11 | 12 | docker stop taro || echo "Could not stop missing container taro" 13 | docker rm taro || echo "Could not remove missing container taro" 14 | 15 | docker run --name taro \ 16 | --mount type=bind,source="$TARO_PATH",target=/taro-files \ 17 | --network host -d --env TZ="America/New_York" \ 18 | l1ving/taro-bot 19 | -------------------------------------------------------------------------------- /util/builtin.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "reflect" 7 | "runtime/debug" 8 | "sort" 9 | "strconv" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // 15 | // Additions to Go's std-lib's builtin 16 | // 17 | 18 | type retryFunction func() ([]byte, error) 19 | 20 | func LogPanic() { 21 | if x := recover(); x != nil { 22 | // recovering from a panic; x contains whatever was passed to panic() 23 | log.Printf("panic: %s\n", debug.Stack()) 24 | } 25 | } 26 | 27 | // RetryFunc will re-try fn by n number of times, in addition to one regular try 28 | func RetryFunc(fn retryFunction, n int, delayMs time.Duration) ([]byte, error) { 29 | if n < 0 { 30 | n = 0 31 | } 32 | 33 | for n > 0 { 34 | b, err := fn() 35 | if err == nil { 36 | return b, err 37 | } 38 | n-- 39 | 40 | // Wait before re-trying, if we have re-tries left. 41 | if n > 0 && delayMs > 0 { 42 | time.Sleep(delayMs * time.Millisecond) 43 | } 44 | } 45 | 46 | return fn() 47 | } 48 | 49 | func NewInterface(typ reflect.Type, data []byte) (interface{}, error) { 50 | if typ.Kind() == reflect.Ptr { 51 | typ = typ.Elem() 52 | dst := reflect.New(typ).Elem() 53 | err := json.Unmarshal(data, dst.Addr().Interface()) 54 | return dst.Addr().Interface(), err 55 | } else { 56 | dst := reflect.New(typ).Elem() 57 | err := json.Unmarshal(data, dst.Addr().Interface()) 58 | return dst.Interface(), err 59 | } 60 | } 61 | 62 | // SliceContains will return if slice s contains e 63 | func SliceContains[T comparable](s []T, e T) bool { 64 | for _, a := range s { 65 | if a == e { 66 | return true 67 | } 68 | } 69 | return false 70 | } 71 | 72 | // SliceRemove will remove m from s 73 | func SliceRemove[T comparable](s []T, m T) []T { 74 | ns := make([]T, 0) 75 | for _, in := range s { 76 | if in != m { 77 | ns = append(ns, in) 78 | } 79 | } 80 | return ns 81 | } 82 | 83 | // SliceRemoveIndex will remove index i from s 84 | func SliceRemoveIndex[T comparable](s []T, i int) []T { 85 | s[i] = s[len(s)-1] 86 | return s[:len(s)-1] 87 | } 88 | 89 | // SliceReverse will reverse the order of s 90 | func SliceReverse[S ~[]T, T any](s S) { 91 | for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { 92 | s[i], s[j] = s[j], s[i] 93 | } 94 | } 95 | 96 | // SliceSortAlphanumeric will sort a string slice alphanumerically 97 | func SliceSortAlphanumeric[S ~[]T, T string](s S) { 98 | sort.Slice(s, func(i, j int) bool { 99 | // check if we have numbers, sort them accordingly 100 | if z, err := strconv.Atoi(string(s[i])); err == nil { 101 | if y, err := strconv.Atoi(string(s[j])); err == nil { 102 | return y < z 103 | } 104 | // if we get only one number, always say its greater than letter 105 | return true 106 | } 107 | // compare letters normally 108 | return s[j] > s[i] 109 | }) 110 | } 111 | 112 | // SlicesCondition will return if all values of []T match condition c 113 | func SlicesCondition[T comparable](s []T, c func(s T) bool) bool { 114 | for _, v := range s { 115 | if !c(v) { 116 | return false 117 | } 118 | } 119 | return true 120 | } 121 | 122 | // SliceJoin will join any slice based on the property or value that c returns 123 | func SliceJoin[T any](s []T, sep string, c func(s T) *string) string { 124 | ns := make([]string, 0) 125 | for _, v := range s { 126 | if n := c(v); n != nil { 127 | ns = append(ns, *n) 128 | } 129 | } 130 | return strings.Join(ns, sep) 131 | } 132 | 133 | // SliceEqual returns true if all bytes of a and b are the same 134 | func SliceEqual[T comparable](a []T, b []T) bool { 135 | if len(a) != len(b) { 136 | return false 137 | } 138 | for i := range a { 139 | if a[i] != b[i] { 140 | return false 141 | } 142 | } 143 | return true 144 | } 145 | -------------------------------------------------------------------------------- /util/cpu/cpu_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin && cgo 2 | // +build darwin,cgo 3 | 4 | package cpu 5 | 6 | import ( 7 | "github.com/mackerelio/go-osstat/cpu" 8 | ) 9 | 10 | func GetCores(s *cpu.Stats) int { 11 | return -1 12 | } 13 | 14 | func GetCoresStr(s *cpu.Stats) string { 15 | return "?" 16 | } 17 | -------------------------------------------------------------------------------- /util/cpu/cpu_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package cpu 5 | 6 | import ( 7 | "fmt" 8 | "github.com/mackerelio/go-osstat/cpu" 9 | ) 10 | 11 | func GetCores(s *cpu.Stats) int { 12 | return s.CPUCount 13 | } 14 | 15 | func GetCoresStr(s *cpu.Stats) string { 16 | return fmt.Sprintf("%v", s.CPUCount) 17 | } 18 | -------------------------------------------------------------------------------- /util/cpu/cpu_other.go: -------------------------------------------------------------------------------- 1 | //go:build !linux && !darwin 2 | // +build !linux,!darwin 3 | 4 | package cpu 5 | 6 | import ( 7 | "github.com/mackerelio/go-osstat/cpu" 8 | ) 9 | 10 | func GetCores(s *cpu.Stats) int { 11 | return -1 12 | } 13 | 14 | func GetCoresStr(s *cpu.Stats) string { 15 | return "?" 16 | } 17 | -------------------------------------------------------------------------------- /util/formatting.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "github.com/diamondburned/arikawa/v3/discord" 6 | "golang.org/x/text/language" 7 | "golang.org/x/text/message" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | var ( 13 | printer = message.NewPrinter(language.English) 14 | ) 15 | 16 | // HeadLinesLimit will take the first amount of lines that fit into the X char limit 17 | // If the string does not consist of split lines, instead just fit the first amount of chars. 18 | func HeadLinesLimit(s string, limit int) string { 19 | lines := strings.Split(s, "\n") 20 | 21 | // We don't have any lines to work with - just get the first chars in s that fit into limit 22 | if len(lines) <= 1 { 23 | if limit > len(s) { // Don't slice out of bounds 24 | limit = len(s) 25 | } 26 | 27 | return s[:limit] 28 | } 29 | 30 | reached := 0 31 | headedLines := make([]string, 0) 32 | for _, line := range lines { 33 | if len(line)+reached <= limit { 34 | reached += len(line) 35 | reached += 1 // for newline 36 | headedLines = append(headedLines, line) 37 | } else { 38 | break 39 | } 40 | } 41 | 42 | return strings.Join(headedLines, "\n") 43 | } 44 | 45 | // TailLinesLimit will take the last amount of lines that fit into the X char limit. 46 | // If the string does not consist of split lines, instead just fit the last amount of chars. 47 | func TailLinesLimit(s string, limit int) string { 48 | lines := strings.Split(s, "\n") 49 | 50 | // We don't have any lines to work with - just get the last chars in s that fit into limit 51 | if len(lines) <= 1 { 52 | last := len(s) - limit 53 | 54 | if last < 0 { // Don't slice out of bounds 55 | last = 0 56 | } 57 | 58 | return s[last:] 59 | } 60 | 61 | // Reverse the order of the lines, we want to Tail them 62 | SliceReverse(lines) 63 | 64 | reached := 0 65 | tailedLines := make([]string, 0) 66 | for _, line := range lines { 67 | if len(line)+reached <= limit { 68 | reached += len(line) 69 | reached += 1 // for newline 70 | tailedLines = append(tailedLines, line) 71 | } else { 72 | break 73 | } 74 | } 75 | 76 | // Undo the reverse sort 77 | SliceReverse(tailedLines) 78 | 79 | return strings.Join(tailedLines, "\n") 80 | } 81 | 82 | // JoinInt64Slice will join i with sep 83 | func JoinInt64Slice(i []int64, sep string, prefix string, suffix string) string { 84 | elems := make([]string, 0) 85 | for _, e := range i { 86 | elems = append(elems, prefix+strconv.FormatInt(e, 10)+suffix) 87 | } 88 | return strings.Join(elems, sep) 89 | } 90 | 91 | // GetUserMention will return a formatted user mention from an id 92 | func GetUserMention(id int64) string { 93 | return "<@!" + strconv.FormatInt(id, 10) + ">" 94 | } 95 | 96 | // FormattedTime will turn seconds into a pretty time representation 97 | func FormattedTime(secondsIn int64) string { 98 | hours := secondsIn / 3600 99 | minutes := (secondsIn / 60) - (60 * hours) 100 | seconds := secondsIn % 60 101 | 102 | units := make([]string, 0) 103 | if hours != 0 { 104 | units = append(units, JoinInt64AndStr(hours, "hour")) 105 | } 106 | if minutes != 0 { 107 | units = append(units, JoinInt64AndStr(minutes, "minute")) 108 | } 109 | if seconds != 0 || (hours == 0 && minutes == 0) { 110 | units = append(units, JoinInt64AndStr(seconds, "second")) 111 | } 112 | 113 | return strings.Join(units, ", ") 114 | } 115 | 116 | // FormattedNum will insert commas as necessary in large numbers 117 | func FormattedNum(num int64) string { 118 | return printer.Sprintf("%d", num) 119 | } 120 | 121 | // FormattedUserTag will return the user#discrim if it's non-0 otherwise just username 122 | func FormattedUserTag(u discord.User) string { 123 | if u.Discriminator == "0" { 124 | return u.Username 125 | } 126 | 127 | return u.Tag() 128 | } 129 | 130 | // JoinInt64AndStr will join and add a plural s to the str if int is not 1, for example, "0 hours", "1 hour", "2 hours". 131 | func JoinInt64AndStr(int int64, str string) string { 132 | plural := "s" 133 | if int == 1 { 134 | plural = "" 135 | } 136 | return fmt.Sprintf("%s %s%s", FormattedNum(int), str, plural) 137 | } 138 | 139 | // JoinIntAndStr is a wrapper for JoinInt64AndStr 140 | func JoinIntAndStr(int int, str string) string { 141 | return JoinInt64AndStr(int64(int), str) 142 | } 143 | -------------------------------------------------------------------------------- /util/http.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/5HT2C/http-bash-requests/httpBashRequests" 5 | "io" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // RequestUrl will return the bytes of the body of url 11 | func RequestUrl(url string, method string) ([]byte, *http.Response, error) { 12 | req, err := http.NewRequest(method, url, nil) 13 | if err != nil { 14 | return nil, nil, err 15 | } 16 | 17 | return RequestUrlReq(req) 18 | } 19 | 20 | // RequestUrlFn will execute fn on req, then return the bytes of the body of url 21 | func RequestUrlFn(url string, method string, fn func(req *http.Request)) ([]byte, *http.Response, error) { 22 | req, err := http.NewRequest(method, url, nil) 23 | if err != nil { 24 | return nil, nil, err 25 | } 26 | fn(req) 27 | 28 | return RequestUrlReq(req) 29 | } 30 | 31 | // RequestUrlReq will return the bytes of the body of request 32 | func RequestUrlReq(req *http.Request) ([]byte, *http.Response, error) { 33 | res, err := http.DefaultClient.Do(req) 34 | if err != nil { 35 | return nil, nil, err 36 | } 37 | 38 | if res.Body != nil { 39 | defer res.Body.Close() 40 | } 41 | 42 | body, err := io.ReadAll(res.Body) 43 | if err != nil { 44 | return nil, nil, err 45 | } 46 | 47 | return body, res, nil 48 | } 49 | 50 | // RequestUrlRetry will return the bytes of the body of the first successful url 51 | func RequestUrlRetry(urls []string, method string, code int) (bytes []byte) { 52 | for _, url := range urls { 53 | content, res, err := RequestUrl(url, method) 54 | if err == nil && res.StatusCode == code { 55 | return content 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func RegisterHttpBashRequests() { 63 | client := httpBashRequests.Client{Addr: "http://localhost:6016", HttpClient: &http.Client{Timeout: 5 * time.Minute}} 64 | httpBashRequests.Setup(&client) 65 | } 66 | -------------------------------------------------------------------------------- /util/parsing.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "golang.org/x/net/html" 8 | "image/color" 9 | "strings" 10 | ) 11 | 12 | type extractNodeCondition func(*html.Node) bool 13 | 14 | // ExtractNode will select the first node to match extractNodeCondition, for example 15 | // res, err := ExtractNode(string(content), func(str string) bool { return str == "title" }) 16 | func ExtractNode(content string, fn extractNodeCondition) (*html.Node, error) { 17 | doc, _ := html.Parse(strings.NewReader(content)) 18 | var n *html.Node 19 | var crawler func(*html.Node) 20 | 21 | crawler = func(node *html.Node) { 22 | if node.Type == html.ElementNode && fn(node) { 23 | n = node 24 | return 25 | } 26 | for child := node.FirstChild; child != nil; child = child.NextSibling { 27 | crawler(child) 28 | } 29 | } 30 | crawler(doc) 31 | if n != nil { 32 | return n, nil 33 | } 34 | return nil, errors.New("missing matching tag in the node tree") 35 | } 36 | 37 | func ExtractNodeText(n *html.Node, buf *bytes.Buffer) { 38 | if n.Type == html.TextNode { 39 | buf.WriteString(n.Data) 40 | } 41 | for c := n.FirstChild; c != nil; c = c.NextSibling { 42 | ExtractNodeText(c, buf) 43 | } 44 | } 45 | 46 | // ConvertColorToInt32 will convert 3 uint8s into one int32 47 | func ConvertColorToInt32(c color.RGBA) int32 { 48 | return int32((uint32(c.R) << 16) | (uint32(c.G) << 8) | (uint32(c.B) << 0)) 49 | } 50 | 51 | // ParseHexColorFast will take a hex string, and convert it to a color.RGBA 52 | func ParseHexColorFast(s string) (c color.RGBA, err error) { 53 | c.A = 0xff 54 | 55 | if s[0] != '#' { 56 | s = "#" + s 57 | } 58 | 59 | hexToByte := func(b byte) byte { 60 | switch { 61 | case b >= '0' && b <= '9': 62 | return b - '0' 63 | case b >= 'a' && b <= 'f': 64 | return b - 'a' + 10 65 | case b >= 'A' && b <= 'F': 66 | return b - 'A' + 10 67 | } 68 | err = errors.New(fmt.Sprintf("`%c` is not hexadecimal", b)) 69 | return 0 70 | } 71 | 72 | switch len(s) { 73 | case 7: 74 | c.R = hexToByte(s[1])<<4 + hexToByte(s[2]) 75 | c.G = hexToByte(s[3])<<4 + hexToByte(s[4]) 76 | c.B = hexToByte(s[5])<<4 + hexToByte(s[6]) 77 | case 4: 78 | c.R = hexToByte(s[1]) * 17 79 | c.G = hexToByte(s[2]) * 17 80 | c.B = hexToByte(s[3]) * 17 81 | default: 82 | err = errors.New(fmt.Sprintf("`%s` must be 4 or 7 chars long, found %v chars", s, len(s))) 83 | } 84 | return 85 | } 86 | --------------------------------------------------------------------------------