├── .gitattributes ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── commands.go ├── common-str.go ├── common-time.go ├── common.go ├── config.go ├── database.go ├── discord.go ├── downloads.go ├── go.mod ├── go.sum ├── handlers.go ├── history.go ├── main.go ├── parse.go ├── regex.go └── vars.go /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Exclude build command shortcut(s) 15 | *.cmd 16 | 17 | # Exclude runnning dev settings 18 | /*.json 19 | /*.jsonc 20 | 21 | # Exclude program output 22 | *.txt 23 | 24 | # Exclude program storage 25 | ** 26 | !*.go -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - "1.19" 4 | 5 | before_install: 6 | - env GO111MODULE=on go mod download 7 | - go get github.com/mitchellh/gox 8 | - go install github.com/mitchellh/gox 9 | 10 | script: 11 | - env GO111MODULE=on env GODEBUG=http2client=0 gox -osarch="windows/amd64 linux/amd64 linux/arm" 12 | 13 | deploy: 14 | provider: releases 15 | token: 16 | secure: VEt15uzzoleub7mqC7iTlxdZOb5Bv+FGAbB39rKoFRsPPMCR04X/P/lQBmTnIjb4/FyZw0DzlWTX6svmglMB/5Ne6L9WYhyJgLdjCiK2qmPmC1dNOYM8wRGV+FOFCPmWKgppE7Bu7+83RjYqachS8u7SLsCcyx9mINx4r434Bvd3iPyvQqMCBctwRLPTbg4rvRfIo6lHJUiEOQgWnzt8OZnVhgSmUUJqg1oYcrWarjTx+NcMPG9HySDLYlUtsJKHv73TPaXLPIyebC4hLyZ8Mm70qynMaxo8x7zDjYeqO46i7gIrBnTTMujukZpVkjd3PpkeNpSFFMIoEIAG+nvPTbF9kIGNLbMGb9rA/dkziaGbbgCU8GggXaqXlvsa8r/DGiqMNE86+ET3XqVtDs/So74LpxqqT6SSX0njx/zZfuAcrOpmFa+LBTWaUGk0p1kl8DI/K9Gg3d3PeGlCK94OyZNpANZm4KYYoENAiMYp+vtQC22ICR6DROPCN85us1OAplvq1E27W4ooL0pvBvmD+O89jg4iZ+2hlmNlohyByPsLnUtNpjhb03EJRg3FKdOBKMXBKK5FpuR7+39dtARi/lb4pxvNMbvEMZtboUlpKrkPJuoh4PC9x8NCPhaHrR4SFX8WdH15K3Okcl5w1gqA+YAQL61AwXM8shCLZPnOB9k= 17 | file: 18 | - "discord-downloader-go_windows-amd64.exe" 19 | - "discord-downloader-go_linux-amd64" 20 | - "discord-downloader-go_linux-arm" 21 | cleanup: false 22 | on: 23 | repo: get-got/discord-downloader-go 24 | tags: true -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.1-alpine AS builder 2 | 3 | RUN apk update && apk upgrade && apk --no-cache add ca-certificates 4 | 5 | COPY . /go/src/github.com/github.com/get-got/discord-downloader-go 6 | WORKDIR /go/src/github.com/github.com/get-got/discord-downloader-go 7 | 8 | RUN go mod download 9 | RUN CGO_ENABLED=0 GODEBUG=http2client=0 GOOS=linux GOARCH=amd64 go build -a -o app . 10 | 11 | FROM scratch 12 | WORKDIR /root/ 13 | COPY --from=builder /go/src/github.com/github.com/get-got/discord-downloader-go/app . 14 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 15 | 16 | ENTRYPOINT ["./app"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 get-got 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Discord Downloader Go 3 |

Maintained since late 2020

4 |

Current development is slow, pull requests welcome

5 |

NOTE: User Accounts currently have rate limiting issues.

6 |
7 |

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | Join the Discord 30 | 31 |

32 |

33 | 34 | Need help? Have suggestions? Join the Discord server 35 | 36 |

37 | 38 | DOWNLOAD LATEST RELEASE 39 | 40 |


41 | 42 |
43 | 44 | This project is a cross-platform cli executable program to interact with a Discord Bot (genuine bot application or user account, limitations apply to both respectively) to locally download files posted from Discord in real-time as well as a full archive of old messages. It can download any directly sent Discord attachments or linked files and supports fetching highest possible quality files from specific sources _([see list below](#supported-download-sources))._ 45 | 46 | It also supports **very extensive** settings configurations and customization, applicable globally or per-server/category/channel/user. Tailor the bot to your exact needs and runtime environment. 47 | 48 |

49 | Originally a fork of Seklfreak's discord-image-downloader-go 50 |

51 |

52 | The original project was abandoned, for a list of differences and why I made an independent project, see below 53 |

54 | 55 | --- 56 | 57 |

SEE THE WIKI FOR MORE

58 | 59 |

Wiki - Getting Started

60 | 61 |

Wiki - Settings

62 | 63 |

Wiki - Settings Examples

64 | 65 |

Wiki - Guide - Commands

66 | 67 |

Wiki - Guide - History (Old Messages)

68 | 69 | | Operating System | Architectures _( ? = available but untested )_ | 70 | | -----------------:|:----------------------------------------------- | 71 | | Windows | **amd64**, arm64 _(?)_, armv7/6/5 _(?)_, 386 _(?)_ 72 | | Linux | **amd64**, **arm64**, **armv7/6/5**,
risc-v64 _(?)_, mips64/64le _(?)_, s390x _(?)_, 386 _(?)_ 73 | | Darwin (Mac) | **amd64**, arm64 _(?)_ 74 | | FreeBSD | amd64 _(?)_, arm64 _(?)_, armv7/6/5 _(?)_, 386 _(?)_ 75 | | OpenBSD | amd64 _(?)_, arm64 _(?)_, armv7/6/5 _(?)_, 386 _(?)_ 76 | | NetBSD | amd64 _(?)_, arm64 _(?)_, armv7/6/5 _(?)_, 386 _(?)_ 77 | 78 |
79 | 80 | --- 81 | 82 | ## ⚠️ **WARNING!** Discord does not allow Automated User Accounts (Self-Bots/User-Bots) 83 | 84 | [Read more in Discord Trust & Safety Team's Official Statement...](https://support.discordapp.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) 85 | 86 | While this project works for user logins, I do not recommend it as you risk account termination. If you can, [use a proper Discord Bot user for this program.](https://discord.com/developers/applications) 87 | 88 | > _NOTE: This only applies to real User Accounts, not Bot users. This program currently works for either._ 89 | 90 | --- 91 | 92 | ## 🤖 Features 93 | 94 | ### Supported Download Sources 95 | 96 | - Direct Links to Files 97 | - Discord File Attachments 98 | - Twitter / X _(unofficial scraping, requires account login, see config section)_ 99 | - Instagram _(unofficial scraping, requires account login, see config section)_ 100 | - Imgur 101 | - Streamable 102 | - Gfycat 103 | - Tistory 104 | - Flickr _(requires API key, see config section)_ 105 | - _I'll always welcome requests but some sources can be tricky to parse..._ 106 | 107 | --- 108 | 109 | ## ❔ FAQ 110 | 111 | - **_Q: How do I install?_** 112 | - **A: [SEE #getting-started](https://github.com/get-got/discord-downloader-go/wiki/Getting-Started)** 113 | 114 | --- 115 | 116 | - **_Q: How do I convert from Seklfreak's discord-image-downloader-go?_** 117 | - **A: Place your config.ini from that program in the same directory as this program and delete any settings.json file if present. The program will import your settings from the old project and make a new settings.json. It will still re-download files that DIDG already downloaded, as the database layout is different and the old database is not imported.** 118 | 119 | --- 120 | 121 | ### Differences from [Seklfreak's _discord-image-downloader-go_](https://github.com/Seklfreak/discord-image-downloader-go) & Why I made this 122 | 123 | - _Better command formatting & support_ 124 | - Configuration is JSON-based rather than ini to allow more elaborate settings and better organization. With this came many features such as channel-specific settings. 125 | - Channel-specific control of downloaded filetypes / content types (considers things like .mov as videos as well, rather than ignore them), Optional dividing of content types into separate folders. 126 | - (Optional) Reactions upon download success. 127 | - (Optional) Discord messages upon encountered errors. 128 | - Extensive bot status/presence customization. 129 | - Consistent Log Formatting, Color-Coded Logging 130 | - Somewhat different organization than original project; initially created from scratch then components ported over. 131 | - _Various fixes, improvements, and dependency updates that I also contributed to Seklfreak's original project._ 132 | 133 | > I've been a user of Seklfreak's project since ~2018 and it's been great for my uses, but there were certain aspects I wanted to expand upon, one of those being customization of channel configuration, and other features like message reactions upon success, differently formatted statuses, etc. If some aspects are rudimentary or messy, please make a pull request, as this is my first project using Go and I've learned everything from observation & Stack Overflow. 134 | 135 | --- 136 | 137 | ## ⚙️ Development 138 | 139 | - I'm a complete amateur with Golang. If anything's bad please make a pull request. 140 | - Follows Semantic Versioning: `[MAJOR].[MINOR].[PATCH]` 141 | - Follows Conventional Commits: `FEAT/FIX/REFACT/DOCS/CHORE/STYLE/TEST` 142 | - [github.com/Seklfreak/discord-image-downloader-go - the original project this was founded on](https://github.com/Seklfreak/discord-image-downloader-go) 143 | -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/Necroforger/dgrouter/exrouter" 12 | "github.com/bwmarrin/discordgo" 13 | "github.com/fatih/color" 14 | ) 15 | 16 | // TODO: Implement this for more? 17 | const ( 18 | cmderrLackingBotAdminPerms = "You do not have permission to use this command. Your User ID must be set as a bot administrator in the settings file." 19 | cmderrSendFailure = "Failed to send command message (requested by %s)...\t%s" 20 | ) 21 | 22 | // safe = logs errors 23 | func safeReply(ctx *exrouter.Context, content string) bool { 24 | if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) { 25 | if _, err := ctx.Reply(content); err != nil { 26 | log.Println(lg("Command", "", color.HiRedString, cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err)) 27 | return false 28 | } else { 29 | return true 30 | } 31 | } else { 32 | log.Println(lg("Command", "", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID)) 33 | return false 34 | } 35 | } 36 | 37 | // TODO: function for handling perm error messages, etc etc to reduce clutter 38 | func handleCommands() *exrouter.Route { 39 | router := exrouter.New() 40 | 41 | //#region Utility Commands 42 | 43 | go router.On("ping", func(ctx *exrouter.Context) { 44 | if isCommandableChannel(ctx.Msg) { 45 | if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) { 46 | log.Println(lg("Command", "Ping", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID)) 47 | } else { 48 | beforePong := time.Now() 49 | pong, err := ctx.Reply("Pong!") 50 | if err != nil { 51 | log.Println(lg("Command", "Ping", color.HiRedString, "Error sending pong message:\t%s", err)) 52 | } else { 53 | afterPong := time.Now() 54 | latency := bot.HeartbeatLatency().Milliseconds() 55 | roundtrip := afterPong.Sub(beforePong).Milliseconds() 56 | mention := ctx.Msg.Author.Mention() 57 | if !config.CommandTagging { // Erase mention if tagging disabled 58 | mention = "" 59 | } 60 | content := fmt.Sprintf("**Latency:** ``%dms`` — **Roundtrip:** ``%dms``", 61 | latency, 62 | roundtrip, 63 | ) 64 | if pong != nil { 65 | if selfbot { 66 | if mention != "" { // Add space if mentioning 67 | mention += " " 68 | } 69 | bot.ChannelMessageEdit(pong.ChannelID, pong.ID, fmt.Sprintf("%s**Command — Ping**\n\n%s", mention, content)) 70 | } else { 71 | bot.ChannelMessageEditComplex(&discordgo.MessageEdit{ 72 | ID: pong.ID, 73 | Channel: pong.ChannelID, 74 | Content: &mention, 75 | Embed: buildEmbed(ctx.Msg.ChannelID, "Command — Ping", content), 76 | }) 77 | } 78 | } 79 | // Log 80 | log.Println(lg("Command", "Ping", color.HiCyanString, "%s pinged bot - Latency: %dms, Roundtrip: %dms", 81 | getUserIdentifier(*ctx.Msg.Author), 82 | latency, 83 | roundtrip), 84 | ) 85 | } 86 | } 87 | } 88 | }).Cat("Utility").Alias("test").Desc("Pings the bot") 89 | 90 | go router.On("help", func(ctx *exrouter.Context) { 91 | if isCommandableChannel(ctx.Msg) { 92 | if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) { 93 | log.Println(lg("Command", "Help", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID)) 94 | } else { 95 | content := "" 96 | for _, cmd := range router.Routes { 97 | if cmd.Category != "Admin" || isBotAdmin(ctx.Msg) { 98 | content += fmt.Sprintf("• \"%s\" : %s", 99 | cmd.Name, 100 | cmd.Description, 101 | ) 102 | if len(cmd.Aliases) > 0 { 103 | content += fmt.Sprintf("\n— Aliases: \"%s\"", strings.Join(cmd.Aliases, "\", \"")) 104 | } 105 | content += "\n\n" 106 | } 107 | } 108 | if _, err := replyEmbed(ctx.Msg, "Command — Help", 109 | fmt.Sprintf("Use commands as ``\"%s \"``\n```%s```\n%s", 110 | config.CommandPrefix, content, projectRepoURL)); err != nil { 111 | log.Println(lg("Command", "Help", color.HiRedString, cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err)) 112 | } 113 | log.Println(lg("Command", "Help", color.HiCyanString, "%s asked for help", getUserIdentifier(*ctx.Msg.Author))) 114 | } 115 | } 116 | }).Cat("Utility").Alias("commands").Desc("Outputs this help menu") 117 | 118 | //#endregion 119 | 120 | //#region Info Commands 121 | 122 | go router.On("status", func(ctx *exrouter.Context) { 123 | if isCommandableChannel(ctx.Msg) { 124 | if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) { 125 | log.Println(lg("Command", "Status", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID)) 126 | } else { 127 | message := fmt.Sprintf("• **Uptime —** %s\n"+ 128 | "• **Started at —** %s\n"+ 129 | "• **Joined Servers —** %d\n"+ 130 | "• **Bound Channels —** %d\n"+ 131 | "• **Bound Cagetories —** %d\n"+ 132 | "• **Bound Servers —** %d\n"+ 133 | "• **Bound Users —** %d\n"+ 134 | "• **Admin Channels —** %d\n"+ 135 | "• **Heartbeat Latency —** %dms", 136 | timeSince(startTime), 137 | startTime.Format("03:04:05pm on Monday, January 2, 2006 (MST)"), 138 | len(bot.State.Guilds), 139 | getBoundChannelsCount(), 140 | getBoundCategoriesCount(), 141 | getBoundServersCount(), 142 | getBoundUsersCount(), 143 | len(config.AdminChannels), 144 | bot.HeartbeatLatency().Milliseconds(), 145 | ) 146 | if sourceConfig := getSource(ctx.Msg); sourceConfig != emptySourceConfig { 147 | configJson, _ := json.MarshalIndent(sourceConfig, "", "\t") 148 | message = message + fmt.Sprintf("\n• **Channel Settings...** ```%s```", string(configJson)) 149 | } 150 | if _, err := replyEmbed(ctx.Msg, "Command — Status", message); err != nil { 151 | log.Println(lg("Command", "Status", color.HiRedString, cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err)) 152 | } 153 | log.Println(lg("Command", "Status", color.HiCyanString, "%s requested status report", getUserIdentifier(*ctx.Msg.Author))) 154 | } 155 | } 156 | }).Cat("Info").Desc("Displays info regarding the current status of the bot") 157 | 158 | go router.On("stats", func(ctx *exrouter.Context) { 159 | if isCommandableChannel(ctx.Msg) { 160 | if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) { 161 | log.Println(lg("Command", "Stats", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID)) 162 | } else { 163 | if sourceConfig := getSource(ctx.Msg); sourceConfig != emptySourceConfig { 164 | if *sourceConfig.AllowCommands { 165 | content := fmt.Sprintf("• **Total Downloads —** %s\n"+ 166 | "• **Downloads in this Channel —** %s", 167 | formatNumber(int64(dbDownloadCount())), 168 | formatNumber(int64(dbDownloadCountByChannel(ctx.Msg.ChannelID))), 169 | ) 170 | //TODO: Count in channel by users 171 | if _, err := replyEmbed(ctx.Msg, "Command — Stats", content); err != nil { 172 | log.Println(lg("Command", "Stats", color.HiRedString, cmderrSendFailure, 173 | getUserIdentifier(*ctx.Msg.Author), err)) 174 | } 175 | log.Println(lg("Command", "Stats", color.HiCyanString, "%s requested stats", 176 | getUserIdentifier(*ctx.Msg.Author))) 177 | } 178 | } 179 | } 180 | } 181 | }).Cat("Info").Desc("Outputs statistics regarding this channel") 182 | 183 | //#endregion 184 | 185 | //#region Admin Commands 186 | 187 | go router.On("history", func(ctx *exrouter.Context) { 188 | if isCommandableChannel(ctx.Msg) { 189 | // Vars 190 | var all = false 191 | var channels []string 192 | 193 | var shouldAbort bool = false 194 | var shouldProcess bool = true 195 | var shouldWipeDB bool = false 196 | var shouldWipeCache bool = false 197 | 198 | var before string 199 | var beforeID string 200 | var since string 201 | var sinceID string 202 | 203 | if len(bot.State.Guilds) == 0 { 204 | log.Println(lg("Command", "History", color.HiRedString, "WARNING: Something is wrong with your Discord cache. This can result in missed channels...")) 205 | } 206 | 207 | //#region Parse Args 208 | for argKey, argValue := range ctx.Args { 209 | if argKey == 0 { // skip head 210 | continue 211 | } 212 | //SUBCOMMAND: cancel 213 | if strings.Contains(strings.ToLower(argValue), "cancel") || 214 | strings.Contains(strings.ToLower(argValue), "stop") { 215 | shouldAbort = true 216 | } else if strings.Contains(strings.ToLower(argValue), "dbwipe") || 217 | strings.Contains(strings.ToLower(argValue), "wipedb") { //SUBCOMMAND: dbwipe 218 | shouldProcess = false 219 | shouldWipeDB = true 220 | } else if strings.Contains(strings.ToLower(argValue), "cachewipe") || 221 | strings.Contains(strings.ToLower(argValue), "wipecache") { //SUBCOMMAND: cachewipe 222 | shouldProcess = false 223 | shouldWipeCache = true 224 | } else if strings.Contains(strings.ToLower(argValue), "help") || 225 | strings.Contains(strings.ToLower(argValue), "info") { //SUBCOMMAND: help 226 | shouldProcess = false 227 | if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) { 228 | //content := fmt.Sprintf("") 229 | _, err := replyEmbed(ctx.Msg, "Command — History Help", "TODO: this") 230 | if err != nil { 231 | log.Println(lg("Command", "History", 232 | color.HiRedString, cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err)) 233 | } 234 | } else { 235 | log.Println(lg("Command", "History", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID)) 236 | } 237 | log.Println(lg("Command", "History", color.CyanString, "%s requested history help.", getUserIdentifier(*ctx.Msg.Author))) 238 | } else if strings.Contains(strings.ToLower(argValue), "list") || 239 | strings.Contains(strings.ToLower(argValue), "status") || 240 | strings.Contains(strings.ToLower(argValue), "output") { //SUBCOMMAND: list 241 | shouldProcess = false 242 | //MARKER: history jobs list 243 | 244 | // 1st 245 | output := fmt.Sprintf("**CURRENT HISTORY JOBS** ~ `%d total, %d running", 246 | historyJobCnt, historyJobCntRunning) 247 | outputC := fmt.Sprintf("CURRENT HISTORY JOBS ~ %d total, %d running", 248 | historyJobCnt, historyJobCntRunning) 249 | if historyJobCntCompleted > 0 { 250 | t := fmt.Sprintf(", %d completed", historyJobCntCompleted) 251 | output += t 252 | outputC += t 253 | } 254 | if historyJobCntWaiting > 0 { 255 | t := fmt.Sprintf(", %d waiting", historyJobCntWaiting) 256 | output += t 257 | outputC += t 258 | } 259 | if historyJobCntAborted > 0 { 260 | t := fmt.Sprintf(", %d cancelled", historyJobCntAborted) 261 | output += t 262 | outputC += t 263 | } 264 | if historyJobCntErrored > 0 { 265 | t := fmt.Sprintf(", %d failed", historyJobCntErrored) 266 | output += t 267 | outputC += t 268 | } 269 | safeReply(ctx, output+"`") 270 | log.Println(lg("Command", "History", color.HiCyanString, outputC)) 271 | 272 | // Following 273 | output = "" 274 | for pair := historyJobs.Oldest(); pair != nil; pair = pair.Next() { 275 | channelID := pair.Key 276 | job := pair.Value 277 | jobSourceName, jobChannelName := channelDisplay(channelID) 278 | 279 | newline := fmt.Sprintf("• _%s_ (%s) `%s - %s`, `updated %s ago, added %s ago`\n", 280 | historyStatusLabel(job.Status), job.OriginUser, jobSourceName, jobChannelName, 281 | timeSinceShort(job.Updated), 282 | timeSinceShort(job.Added)) 283 | redothismath: // bad way but dont care right now 284 | if len(output)+len(newline) > limitMsg { 285 | // send batch 286 | safeReply(ctx, output) 287 | output = "" 288 | goto redothismath 289 | } 290 | output += newline 291 | log.Println(lg("Command", "History", color.HiCyanString, 292 | fmt.Sprintf("%s (%s) %s - %s, updated %s ago, added %s ago", 293 | historyStatusLabel(job.Status), job.OriginUser, jobSourceName, jobChannelName, 294 | timeSinceShort(job.Updated), 295 | timeSinceShort(job.Added)))) // no batching 296 | } 297 | // finish off 298 | if output != "" { 299 | safeReply(ctx, output) 300 | } 301 | // done 302 | log.Println(lg("Command", "History", color.HiRedString, "%s requested statuses of history jobs.", 303 | getUserIdentifier(*ctx.Msg.Author))) 304 | } else if strings.Contains(strings.ToLower(argValue), "--before=") { // before key 305 | before = strings.ReplaceAll(strings.ToLower(argValue), "--before=", "") 306 | if isDate(before) { 307 | beforeID = discordTimestampToSnowflake("2006-01-02", before) 308 | } else if isNumeric(before) { 309 | beforeID = before 310 | } else { // try to parse duration 311 | dur, err := time.ParseDuration(before) 312 | if err == nil { 313 | beforeID = discordTimestampToSnowflake("2006-01-02 15:04:05.999999999 -0700 MST", time.Now().Add(-dur).Format("2006-01-02 15:04:05.999999999 -0700 MST")) 314 | } 315 | } 316 | if config.Debug && beforeID != "" { 317 | log.Println(lg("Command", "History", color.CyanString, "Date before range applied, snowflake %s, converts back to %s", 318 | beforeID, discordSnowflakeToTimestamp(beforeID, "2006-01-02T15:04:05.000Z07:00"))) 319 | } 320 | } else if strings.Contains(strings.ToLower(argValue), "--since=") { // since key 321 | since = strings.ReplaceAll(strings.ToLower(argValue), "--since=", "") 322 | if isDate(since) { 323 | sinceID = discordTimestampToSnowflake("2006-01-02", since) 324 | } else if isNumeric(since) { 325 | sinceID = since 326 | } else { // try to parse duration 327 | dur, err := time.ParseDuration(since) 328 | if err == nil { 329 | sinceID = discordTimestampToSnowflake("2006-01-02 15:04:05.999999999 -0700 MST", time.Now().Add(-dur).Format("2006-01-02 15:04:05.999999999 -0700 MST")) 330 | } 331 | } 332 | if config.Debug && sinceID != "" { 333 | log.Println(lg("Command", "History", color.CyanString, "Date since range applied, snowflake %s, converts back to %s", 334 | sinceID, discordSnowflakeToTimestamp(sinceID, "2006-01-02T15:04:05.000Z07:00"))) 335 | } 336 | } else { 337 | // Actual Source ID(s) 338 | targets := strings.Split(ctx.Args.Get(argKey), ",") 339 | for _, target := range targets { 340 | if isNumeric(target) { 341 | // Test/Use if number is guild 342 | guild, err := bot.State.Guild(target) 343 | if err != nil { 344 | guild, err = bot.Guild(target) 345 | } 346 | if err == nil { 347 | if config.Debug { 348 | log.Println(lg("Command", "History", color.YellowString, 349 | "Specified target %s is a guild: \"%s\", adding all channels...", 350 | target, guild.Name)) 351 | } 352 | for _, ch := range guild.Channels { 353 | if ch.Type != discordgo.ChannelTypeGuildCategory && 354 | ch.Type != discordgo.ChannelTypeGuildStageVoice && 355 | ch.Type != discordgo.ChannelTypeGuildVoice { 356 | channels = append(channels, ch.ID) 357 | if config.Debug { 358 | log.Println(lg("Command", "History", color.YellowString, 359 | "Added %s (#%s in \"%s\") to history queue", 360 | ch.ID, ch.Name, guild.Name)) 361 | } 362 | } 363 | } 364 | } else { // Test/Use if number is channel or category 365 | ch, err := bot.State.Channel(target) 366 | if err != nil { 367 | ch, err = bot.Channel(target) 368 | } 369 | if err == nil { 370 | if ch.Type == discordgo.ChannelTypeGuildCategory { 371 | // Category 372 | for _, guild := range bot.State.Guilds { 373 | for _, ch := range guild.Channels { 374 | if ch.ParentID == target { 375 | channels = append(channels, ch.ID) 376 | if config.Debug { 377 | log.Println(lg("Command", "History", color.YellowString, "Added %s (#%s in %s) to history queue", 378 | ch.ID, ch.Name, ch.GuildID)) 379 | } 380 | } 381 | } 382 | } 383 | } else { // Standard Channel 384 | channels = append(channels, target) 385 | if config.Debug { 386 | log.Println(lg("Command", "History", color.YellowString, "Added %s (#%s in %s) to history queue", 387 | ch.ID, ch.Name, ch.GuildID)) 388 | } 389 | } 390 | } else if config.Debug { 391 | log.Println(lg("Command", "History", color.HiRedString, "All attempts to identify target \"%s\" have failed...", 392 | target)) 393 | } 394 | } 395 | } else if strings.Contains(strings.ToLower(target), "all") { 396 | for _, channel := range getAllRegisteredChannels() { 397 | channels = append(channels, channel.ChannelID) 398 | } 399 | all = true 400 | } else { // Aliasing 401 | for _, channel := range getAllRegisteredChannels() { 402 | if channel.Source.Aliases != nil { 403 | for _, alias := range *channel.Source.Aliases { 404 | if alias != "" && alias != " " { 405 | if strings.EqualFold(alias, target) { 406 | channels = append(channels, channel.ChannelID) 407 | if config.Debug { 408 | log.Println(lg("Command", "History", color.YellowString, 409 | "Added %s to history queue by alias \"%s\"", 410 | channel.ChannelID, alias)) 411 | } 412 | break 413 | } 414 | } 415 | } 416 | } else if channel.Source.Alias != nil { 417 | alias := *channel.Source.Alias 418 | if alias != "" && alias != " " { 419 | if strings.EqualFold(alias, target) { 420 | channels = append(channels, channel.ChannelID) 421 | if config.Debug { 422 | log.Println(lg("Command", "History", color.YellowString, 423 | "Added %s to history queue by alias \"%s\"", 424 | channel.ChannelID, alias)) 425 | } 426 | } 427 | } 428 | } 429 | } 430 | } 431 | } 432 | } 433 | } 434 | //#endregion 435 | 436 | // Local 437 | if len(channels) == 0 { 438 | channels = append(channels, ctx.Msg.ChannelID) 439 | } 440 | // Foreach Channel 441 | for _, channel := range channels { 442 | //#region Process Channels 443 | if shouldProcess && config.Debug { 444 | nameGuild := channel 445 | chinfo, err := bot.State.Channel(channel) 446 | if err != nil { 447 | chinfo, err = bot.Channel(channel) 448 | } 449 | if err == nil { 450 | nameGuild = getServerLabel(chinfo.GuildID) 451 | } 452 | nameCategory := getCategoryLabel(channel) 453 | nameChannel := getChannelLabel(channel, nil) 454 | nameDisplay := fmt.Sprintf("%s / #%s", nameGuild, nameChannel) 455 | if nameCategory != "Category" { 456 | nameDisplay = fmt.Sprintf("%s / %s / #%s", nameGuild, nameCategory, nameChannel) 457 | } 458 | log.Println(lg("Command", "History", color.HiMagentaString, 459 | "Queueing history job for \"%s\"\t\t(%s) ...", nameDisplay, channel)) 460 | } 461 | if !isBotAdmin(ctx.Msg) { 462 | log.Println(lg("Command", "History", color.CyanString, 463 | "%s tried to handle history for %s but lacked proper permission.", 464 | getUserIdentifier(*ctx.Msg.Author), channel)) 465 | if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) { 466 | log.Println(lg("Command", "History", color.HiRedString, fmtBotSendPerm, channel)) 467 | } else { 468 | if _, err := replyEmbed(ctx.Msg, "Command — History", cmderrLackingBotAdminPerms); err != nil { 469 | log.Println(lg("Command", "History", color.HiRedString, cmderrSendFailure, 470 | getUserIdentifier(*ctx.Msg.Author), err)) 471 | } 472 | } 473 | } else { // IS BOT ADMIN 474 | if shouldProcess { // PROCESS TREE; MARKER: history queue via cmd 475 | if shouldAbort { // ABORT 476 | if job, exists := historyJobs.Get(channel); exists && 477 | (job.Status == historyStatusRunning || job.Status == historyStatusWaiting) { 478 | // DOWNLOADING, ABORTING 479 | job.Status = historyStatusAbortRequested 480 | if job.Status == historyStatusWaiting { 481 | job.Status = historyStatusAbortCompleted 482 | } 483 | historyJobs.Set(channel, job) 484 | log.Println(lg("Command", "History", color.CyanString, 485 | "%s cancelled history cataloging for \"%s\"", 486 | getUserIdentifier(*ctx.Msg.Author), channel)) 487 | } else { // NOT DOWNLOADING, ABORTING 488 | log.Println(lg("Command", "History", color.CyanString, 489 | "%s tried to cancel history for \"%s\" but it's not running", 490 | getUserIdentifier(*ctx.Msg.Author), channel)) 491 | } 492 | } else { // RUN 493 | if job, exists := historyJobs.Get(channel); !exists || 494 | (job.Status != historyStatusRunning && job.Status != historyStatusAbortRequested) { 495 | job.Status = historyStatusWaiting 496 | job.OriginChannel = ctx.Msg.ChannelID 497 | job.OriginUser = getUserIdentifier(*ctx.Msg.Author) 498 | job.TargetCommandingMessage = ctx.Msg 499 | job.TargetChannelID = channel 500 | job.TargetBefore = beforeID 501 | job.TargetSince = sinceID 502 | job.Updated = time.Now() 503 | job.Added = time.Now() 504 | historyJobs.Set(channel, job) 505 | } else { // ALREADY RUNNING 506 | log.Println(lg("Command", "History", color.CyanString, 507 | "%s tried using history command but history is already running for %s...", 508 | getUserIdentifier(*ctx.Msg.Author), channel)) 509 | } 510 | } 511 | } 512 | if shouldWipeDB { 513 | if all { 514 | myDB.Close() 515 | time.Sleep(1 * time.Second) 516 | if _, err := os.Stat(pathDatabaseBase); err == nil { 517 | err = os.RemoveAll(pathDatabaseBase) 518 | if err != nil { 519 | log.Println(lg("Command", "History", color.HiRedString, 520 | "Encountered error deleting database folder:\t%s", err)) 521 | } else { 522 | log.Println(lg("Command", "History", color.HiGreenString, 523 | "Deleted database.")) 524 | } 525 | time.Sleep(1 * time.Second) 526 | mainWg.Add(1) 527 | go openDatabase() 528 | break 529 | } else { 530 | log.Println(lg("Command", "History", color.HiRedString, 531 | "Database folder inaccessible:\t%s", err)) 532 | } 533 | } else { 534 | dbDeleteByChannelID(channel) 535 | } 536 | } 537 | if shouldWipeCache { 538 | if all { 539 | if _, err := os.Stat(pathCacheHistory); err == nil { 540 | err = os.RemoveAll(pathCacheHistory) 541 | if err != nil { 542 | log.Println(lg("Command", "History", color.HiRedString, 543 | "Encountered error deleting database folder:\t%s", err)) 544 | } else { 545 | log.Println(lg("Command", "History", color.HiGreenString, 546 | "Deleted database.")) 547 | break 548 | } 549 | } else { 550 | log.Println(lg("Command", "History", color.HiRedString, 551 | "Cache folder inaccessible:\t%s", err)) 552 | } 553 | } else { 554 | fp := pathCacheHistory + string(os.PathSeparator) + channel + ".json" 555 | if _, err := os.Stat(fp); err == nil { 556 | err = os.RemoveAll(fp) 557 | if err != nil { 558 | log.Println(lg("Debug", "History", color.HiRedString, 559 | "Encountered error deleting cache file for %s:\t%s", channel, err)) 560 | } else { 561 | log.Println(lg("Debug", "History", color.HiGreenString, 562 | "Deleted cache file for %s.", channel)) 563 | } 564 | } else { 565 | log.Println(lg("Command", "History", color.HiRedString, 566 | "Cache folder inaccessible:\t%s", err)) 567 | } 568 | } 569 | } 570 | } 571 | //#endregion 572 | } 573 | if shouldWipeDB { 574 | cachedDownloadID = dbDownloadCount() 575 | } 576 | } 577 | }).Cat("Admin").Alias("catalog", "cache").Desc("Catalogs history for this channel") 578 | 579 | go router.On("exit", func(ctx *exrouter.Context) { 580 | if isCommandableChannel(ctx.Msg) { 581 | if isBotAdmin(ctx.Msg) { 582 | if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) { 583 | log.Println(lg("Command", "Exit", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID)) 584 | } else { 585 | if _, err := replyEmbed(ctx.Msg, "Command — Exit", "Exiting program..."); err != nil { 586 | log.Println(lg("Command", "Exit", color.HiRedString, 587 | cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err)) 588 | } 589 | } 590 | log.Println(lg("Command", "Exit", color.HiCyanString, 591 | "%s (bot admin) requested exit, goodbye...", 592 | getUserIdentifier(*ctx.Msg.Author))) 593 | properExit() 594 | } else { 595 | if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) { 596 | log.Println(lg("Command", "Exit", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID)) 597 | } else { 598 | if _, err := replyEmbed(ctx.Msg, "Command — Exit", cmderrLackingBotAdminPerms); err != nil { 599 | log.Println(lg("Command", "Exit", color.HiRedString, 600 | cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err)) 601 | } 602 | } 603 | log.Println(lg("Command", "Exit", color.HiCyanString, 604 | "%s tried to exit but lacked bot admin perms.", getUserIdentifier(*ctx.Msg.Author))) 605 | } 606 | } 607 | }).Cat("Admin").Alias("reload", "kill").Desc("Kills the bot") 608 | 609 | //#endregion 610 | 611 | // Handler for Command Router 612 | go bot.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageCreate) { 613 | 614 | // Override Prefix per-Source 615 | prefix := config.CommandPrefix 616 | if _, err := getChannel(m.ChannelID); err == nil { 617 | if messageConfig := getSource(m.Message); messageConfig != emptySourceConfig { 618 | if messageConfig.CommandPrefix != nil { 619 | prefix = *messageConfig.CommandPrefix 620 | } 621 | } else { 622 | if messageAdminConfig := getAdminChannelConfig(m.Message.ChannelID); messageAdminConfig != emptyAdminChannelConfig { 623 | if messageAdminConfig.CommandPrefix != nil { 624 | prefix = *messageAdminConfig.CommandPrefix 625 | } 626 | } 627 | } 628 | } 629 | 630 | //NOTE: This setup makes it case-insensitive but message content will be lowercase, currently case sensitivity is not necessary. 631 | router.FindAndExecute(bot, strings.ToLower(prefix), bot.State.User.ID, messageToLower(m.Message)) 632 | }) 633 | 634 | return router 635 | } 636 | -------------------------------------------------------------------------------- /common-str.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "path" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | 12 | "golang.org/x/text/unicode/norm" 13 | ) 14 | 15 | var ( 16 | pathBlacklist = []string{"/", "\\", "<", ">", ":", "\"", "|", "?", "*"} 17 | ) 18 | 19 | func stringInSlice(a string, list []string) bool { 20 | for _, b := range list { 21 | if strings.EqualFold(a, b) { 22 | return true 23 | } 24 | } 25 | return false 26 | } 27 | 28 | func formatNumber(n int64) string { 29 | var numberSeparator byte = ',' 30 | if config.EuropeanNumbers { 31 | numberSeparator = '.' 32 | } 33 | 34 | in := strconv.FormatInt(n, 10) 35 | out := make([]byte, len(in)+(len(in)-2+int(in[0]/'0'))/3) 36 | if in[0] == '-' { 37 | in, out[0] = in[1:], '-' 38 | } 39 | 40 | for i, j, k := len(in)-1, len(out)-1, 0; ; i, j = i-1, j-1 { 41 | out[j] = in[i] 42 | if i == 0 { 43 | return string(out) 44 | } 45 | if k++; k == 3 { 46 | j, k = j-1, 0 47 | out[j] = numberSeparator 48 | } 49 | } 50 | } 51 | 52 | func formatNumberShort(x int64) string { 53 | var numberSeparator string = "," 54 | if config.EuropeanNumbers { 55 | numberSeparator = "." 56 | } 57 | var decimalSeparator string = "." 58 | if config.EuropeanNumbers { 59 | decimalSeparator = "," 60 | } 61 | 62 | if x > 1000 { 63 | formattedNumber := formatNumber(x) 64 | splitSlice := strings.Split(formattedNumber, numberSeparator) 65 | suffixes := [4]string{"k", "m", "b", "t"} 66 | partCount := len(splitSlice) - 1 67 | var output string 68 | if splitSlice[1][:1] != "0" { 69 | output = fmt.Sprintf("%s%s%s%s", splitSlice[0], decimalSeparator, splitSlice[1][:1], suffixes[partCount-1]) 70 | } else { 71 | output = fmt.Sprintf("%s%s", splitSlice[0], suffixes[partCount-1]) 72 | } 73 | return output 74 | } 75 | return fmt.Sprint(x) 76 | } 77 | 78 | func pluralS(num int) string { 79 | if num == 1 { 80 | return "" 81 | } 82 | return "s" 83 | } 84 | 85 | func wrapHyphens(i string, l int) string { 86 | n := i 87 | if len(n) < l { 88 | n = "- " + n + " -" 89 | for len(n) < l { 90 | n = "-" + n + "-" 91 | } 92 | } 93 | return n 94 | } 95 | 96 | func wrapHyphensW(i string) string { 97 | return wrapHyphens(i, 90) 98 | } 99 | 100 | func stripSymbols(i string) string { 101 | re, err := regexp.Compile(`[^\w]`) 102 | if err != nil { 103 | log.Fatal(err) 104 | } 105 | return re.ReplaceAllString(i, " ") 106 | } 107 | 108 | func isNumeric(s string) bool { 109 | _, err := strconv.ParseFloat(s, 64) 110 | return err == nil 111 | } 112 | 113 | func clearPathIllegalChars(p string) string { 114 | r := p 115 | for _, key := range pathBlacklist { 116 | r = strings.ReplaceAll(r, key, "") 117 | } 118 | return r 119 | } 120 | 121 | func clearDiacritics(p string) string { 122 | return norm.NFKC.String(p) 123 | } 124 | 125 | func clearNonAscii(p string) string { 126 | re := regexp.MustCompile("[[:^ascii:]]") 127 | return re.ReplaceAllLiteralString(p, "") 128 | } 129 | 130 | func clearDoubleSpaces(p string) string { 131 | ret := p 132 | for { 133 | ret = strings.ReplaceAll(ret, " ", " ") 134 | if !strings.Contains(ret, " ") { 135 | break 136 | } 137 | } 138 | if ret == "" { 139 | return p 140 | } 141 | return ret 142 | } 143 | 144 | func clearPaddedSymbols(p string) string { // currently just spaces 145 | ret := p 146 | for { 147 | if len(ret) == 0 { 148 | break 149 | } 150 | if ret[0] != ' ' { 151 | break 152 | } 153 | ret = ret[1:] 154 | } 155 | for { 156 | if len(ret) == 0 { 157 | break 158 | } 159 | size := len(ret) 160 | if ret[size-1] != ' ' { 161 | break 162 | } 163 | if (size - 1) <= 0 { 164 | break 165 | } else { 166 | ret = ret[:size-1] 167 | } 168 | } 169 | return ret 170 | } 171 | 172 | func clearSourceField(p string, cfg configurationSource) string { 173 | ret := clearPathIllegalChars(p) 174 | 175 | // FilepathNormalizeText 176 | if cfg.FilepathNormalizeText != nil { 177 | if *cfg.FilepathNormalizeText { 178 | ret = clearDiacritics(ret) 179 | } 180 | } 181 | 182 | // FilepathStripSymbols 183 | if cfg.FilepathStripSymbols != nil { 184 | if *cfg.FilepathStripSymbols { 185 | ret = clearNonAscii(ret) 186 | } 187 | } 188 | 189 | // Clear illegal chars and clear extra space. 190 | ret = clearDoubleSpaces(clearPaddedSymbols(clearPathIllegalChars(ret))) 191 | 192 | // if there's nothing left, just return what originally came through, without illegal characters. 193 | if len(regexp.MustCompile(`[^a-zA-Z0-9 ]+`).ReplaceAllLiteralString(ret, "")) == 0 { 194 | return clearPathIllegalChars(p) 195 | } 196 | 197 | return ret 198 | } 199 | 200 | func clearSourceLogField(p string, cfg configurationSourceLog) string { 201 | ret := clearPathIllegalChars(p) 202 | 203 | // FilepathNormalizeText 204 | if cfg.FilepathNormalizeText != nil { 205 | if *cfg.FilepathNormalizeText { 206 | ret = clearDiacritics(ret) 207 | } 208 | } 209 | 210 | // FilepathStripSymbols 211 | if cfg.FilepathStripSymbols != nil { 212 | if *cfg.FilepathStripSymbols { 213 | ret = clearNonAscii(ret) 214 | } 215 | } 216 | 217 | // Clear illegal chars and clear extra space. 218 | ret = clearDoubleSpaces(clearPaddedSymbols(clearPathIllegalChars(ret))) 219 | 220 | // if there's nothing left, just return what originally came through, without illegal characters. 221 | if len(regexp.MustCompile(`[^a-zA-Z0-9 ]+`).ReplaceAllLiteralString(ret, "")) == 0 { 222 | return clearPathIllegalChars(p) 223 | } 224 | 225 | return ret 226 | } 227 | 228 | func filenameFromURL(inputURL string) string { 229 | base := path.Base(inputURL) 230 | parts := strings.Split(base, "?") 231 | return path.Clean(parts[0]) 232 | } 233 | 234 | func filepathExtension(filepath string) string { 235 | if strings.Contains(filepath, "?") { 236 | filepath = strings.Split(filepath, "?")[0] 237 | } 238 | filepath = path.Ext(filepath) 239 | return filepath 240 | } 241 | 242 | func getDomain(URL string) (string, error) { 243 | parsedURL, err := url.Parse(URL) 244 | if err != nil { 245 | return parsedURL.Hostname(), nil 246 | } 247 | return "UNKNOWN", err 248 | } 249 | -------------------------------------------------------------------------------- /common-time.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/hako/durafmt" 8 | ) 9 | 10 | /*const ( 11 | timeFmtAMPML = "pm" 12 | timeFmtAMPMU = "PM" 13 | timeFmtH12 = "3" 14 | timeFmtH24 = "15" 15 | timeFmtMM = "04" // H:MM:SS 16 | timeFmtSS = "05" // H:MM:SS 17 | timeFmtHMM12 = timeFmtH12 + ":" + timeFmtMM 18 | timeFmtHMMSS12 = timeFmtHMM12 + ":" + timeFmtSS 19 | timeFmtHMM12AMPM = timeFmtHMM12 + timeFmtAMPML 20 | timeFmtHMMSS12AMPM = timeFmtHMMSS12 + timeFmtAMPML 21 | timeFmtHMM24 = timeFmtH24 + ":" + timeFmtMM 22 | timeFmtHMMSS24 = timeFmtHMM24 + ":" + timeFmtSS 23 | timeFmtTZ = "MST" 24 | 25 | dateFmtDOM = "2" // Day of month, 2nd 26 | dateFmtMonthNum = "1" 27 | dateFmtMonth = "January" 28 | dateFmtYear = "2006" 29 | dateFmtMDYSlash = dateFmtMonthNum + "/" + dateFmtDOM + "/" + dateFmtYear 30 | dateFmtMDYDash = dateFmtMonthNum + "-" + dateFmtDOM + "-" + dateFmtYear 31 | dateFmtDMYSlash = dateFmtDOM + "/" + dateFmtMonthNum + "/" + dateFmtYear 32 | dateFmtDMYDash = dateFmtDOM + "-" + dateFmtMonthNum + "-" + dateFmtYear 33 | dateFmtDateMD = dateFmtMonth + " " + dateFmtDOM + ", " + dateFmtYear 34 | dateFmtDateDM = dateFmtDOM + " " + dateFmtMonth + ", " + dateFmtYear 35 | )*/ 36 | 37 | func isDate(s string) bool { 38 | _, err := time.Parse("2006-01-02", s) 39 | return err == nil 40 | } 41 | 42 | func shortenTime(input string) string { 43 | input = strings.ReplaceAll(input, " nanoseconds", "ns") 44 | input = strings.ReplaceAll(input, " nanosecond", "ns") 45 | input = strings.ReplaceAll(input, " microseconds", "μs") 46 | input = strings.ReplaceAll(input, " microsecond", "μs") 47 | input = strings.ReplaceAll(input, " milliseconds", "ms") 48 | input = strings.ReplaceAll(input, " millisecond", "ms") 49 | input = strings.ReplaceAll(input, " seconds", "s") 50 | input = strings.ReplaceAll(input, " second", "s") 51 | input = strings.ReplaceAll(input, " minutes", "m") 52 | input = strings.ReplaceAll(input, " minute", "m") 53 | input = strings.ReplaceAll(input, " hours", "h") 54 | input = strings.ReplaceAll(input, " hour", "h") 55 | input = strings.ReplaceAll(input, " days", "d") 56 | input = strings.ReplaceAll(input, " day", "d") 57 | input = strings.ReplaceAll(input, " weeks", "w") 58 | input = strings.ReplaceAll(input, " week", "w") 59 | input = strings.ReplaceAll(input, " months", "mo") 60 | input = strings.ReplaceAll(input, " month", "mo") 61 | return input 62 | } 63 | 64 | func timeSince(input time.Time) string { 65 | return durafmt.Parse(time.Since(input)).String() 66 | } 67 | 68 | func timeSinceShort(input time.Time) string { 69 | return shortenTime(durafmt.ParseShort(time.Since(input)).String()) 70 | } 71 | -------------------------------------------------------------------------------- /common.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "github.com/bwmarrin/discordgo" 13 | "github.com/fatih/color" 14 | "github.com/hashicorp/go-version" 15 | ) 16 | 17 | func uptime() time.Duration { 18 | return time.Since(startTime) //.Truncate(time.Second) 19 | } 20 | 21 | func properExit() { 22 | // Not formatting string because I only want the exit message to be red. 23 | log.Println(lg("Main", "", color.HiRedString, "[EXIT IN 15 SECONDS] Uptime was %s...", timeSince(startTime))) 24 | log.Println(color.HiCyanString("----------------------------------------------------")) 25 | time.Sleep(15 * time.Second) 26 | os.Exit(1) 27 | } 28 | 29 | func getJSON(url string, target interface{}) error { 30 | r, err := http.Get(url) 31 | if err != nil { 32 | return err 33 | } 34 | defer r.Body.Close() 35 | 36 | return json.NewDecoder(r.Body).Decode(target) 37 | } 38 | 39 | func getJSONwithHeaders(url string, target interface{}, headers map[string]string) error { 40 | client := &http.Client{} 41 | req, _ := http.NewRequest("GET", url, nil) 42 | 43 | for k, v := range headers { 44 | req.Header.Set(k, v) 45 | } 46 | 47 | r, err := client.Do(req) 48 | if err != nil { 49 | return err 50 | } 51 | defer r.Body.Close() 52 | 53 | return json.NewDecoder(r.Body).Decode(target) 54 | } 55 | 56 | //#region Github 57 | 58 | type githubReleaseApiObject struct { 59 | TagName string `json:"tag_name"` 60 | } 61 | 62 | var latestGithubRelease string 63 | 64 | func getLatestGithubRelease() string { 65 | githubReleaseApiObject := new(githubReleaseApiObject) 66 | err := getJSON("https://api.github.com/repos/"+projectRepoBase+"/releases/latest", githubReleaseApiObject) 67 | if err != nil { 68 | log.Println(lg("API", "Github", color.RedString, "Error fetching current Release JSON: %s", err)) 69 | return "" 70 | } 71 | return githubReleaseApiObject.TagName 72 | } 73 | 74 | func isLatestGithubRelease() bool { 75 | latestGithubRelease = getLatestGithubRelease() 76 | if latestGithubRelease == "" { 77 | return true 78 | } 79 | 80 | thisVersion, err := version.NewVersion(projectVersion) 81 | if err != nil { 82 | log.Println(lg("API", "Github", color.RedString, "Error parsing current version: %s", err)) 83 | return true 84 | } 85 | 86 | latestVersion, err := version.NewVersion(latestGithubRelease) 87 | if err != nil { 88 | log.Println(lg("API", "Github", color.RedString, "Error parsing latest version: %s", err)) 89 | return true 90 | } 91 | 92 | if latestVersion.GreaterThan(thisVersion) { 93 | return false 94 | } 95 | 96 | return true 97 | } 98 | 99 | //#endregion 100 | 101 | //#region Logging 102 | 103 | func lg(group string, subgroup string, colorFunc func(string, ...interface{}) string, line string, p ...interface{}) string { 104 | colorPrefix := group 105 | switch strings.ToLower(group) { 106 | 107 | case "main": 108 | if subgroup == "" { 109 | colorPrefix = "" 110 | } else { 111 | colorPrefix = "" 112 | } 113 | 114 | case "verbose": 115 | if subgroup == "" { 116 | colorPrefix = color.HiBlueString("[VERBOSE]") 117 | } else { 118 | colorPrefix = color.HiBlueString("[VERBOSE | %s]", subgroup) 119 | } 120 | 121 | case "debug": 122 | if subgroup == "" { 123 | colorPrefix = color.HiYellowString("[DEBUG]") 124 | } else { 125 | colorPrefix = color.HiYellowString("[DEBUG | %s]", subgroup) 126 | } 127 | 128 | case "debug2": 129 | if subgroup == "" { 130 | colorPrefix = color.YellowString("[DEBUG2]") 131 | } else { 132 | colorPrefix = color.YellowString("[DEBUG2 | %s]", subgroup) 133 | } 134 | 135 | case "test": 136 | if subgroup == "" { 137 | colorPrefix = color.HiYellowString("[TEST]") 138 | } else { 139 | colorPrefix = color.HiYellowString("[TEST | %s]", subgroup) 140 | } 141 | 142 | case "info": 143 | if subgroup == "" { 144 | colorPrefix = color.CyanString("[Info]") 145 | } else { 146 | colorPrefix = color.CyanString("[Info | %s]", subgroup) 147 | } 148 | 149 | case "version": 150 | if subgroup == "" { 151 | colorPrefix = color.HiGreenString("[Version]") 152 | } else { 153 | colorPrefix = color.HiGreenString("[Version | %s]", subgroup) 154 | } 155 | 156 | case "settings": 157 | if subgroup == "" { 158 | colorPrefix = color.GreenString("[Settings]") 159 | } else { 160 | colorPrefix = color.GreenString("[Settings | %s]", subgroup) 161 | } 162 | 163 | case "database": 164 | if subgroup == "" { 165 | colorPrefix = color.HiYellowString("[Database]") 166 | } else { 167 | colorPrefix = color.HiYellowString("[Database | %s]", subgroup) 168 | } 169 | 170 | case "setup": 171 | if subgroup == "" { 172 | colorPrefix = color.HiGreenString("[Setup]") 173 | } else { 174 | colorPrefix = color.HiGreenString("[Setup | %s]", subgroup) 175 | } 176 | 177 | case "checkup": 178 | if subgroup == "" { 179 | colorPrefix = color.HiGreenString("[Checkup]") 180 | } else { 181 | colorPrefix = color.HiGreenString("[Checkup | %s]", subgroup) 182 | } 183 | 184 | case "discord": 185 | if subgroup == "" { 186 | colorPrefix = color.HiBlueString("[Discord]") 187 | } else { 188 | colorPrefix = color.HiBlueString("[Discord | %s]", subgroup) 189 | } 190 | 191 | case "history": 192 | if subgroup == "" { 193 | colorPrefix = color.HiCyanString("[History]") 194 | } else { 195 | colorPrefix = color.HiCyanString("[History | %s]", subgroup) 196 | } 197 | 198 | case "command": 199 | if subgroup == "" { 200 | colorPrefix = color.HiGreenString("[Commands]") 201 | } else { 202 | colorPrefix = color.HiGreenString("[Command : %s]", subgroup) 203 | } 204 | 205 | case "download": 206 | if subgroup == "" { 207 | colorPrefix = color.GreenString("[Downloads]") 208 | } else { 209 | colorPrefix = color.GreenString("[Downloads | %s]", subgroup) 210 | } 211 | 212 | case "message": 213 | if subgroup == "" { 214 | colorPrefix = color.CyanString("[Messages]") 215 | } else { 216 | colorPrefix = color.CyanString("[Messages | %s]", subgroup) 217 | } 218 | 219 | case "regex": 220 | if subgroup == "" { 221 | colorPrefix = color.YellowString("[Regex]") 222 | } else { 223 | colorPrefix = color.YellowString("[Regex | %s]", subgroup) 224 | } 225 | 226 | case "api": 227 | if subgroup == "" { 228 | colorPrefix = color.HiMagentaString("[APIs]") 229 | } else { 230 | colorPrefix = color.HiMagentaString("[API | %s]", subgroup) 231 | } 232 | } 233 | 234 | if bot != nil && botReady { 235 | simplePrefix := group 236 | if subgroup != "" { 237 | simplePrefix += ":" + subgroup 238 | } 239 | for _, adminChannel := range config.AdminChannels { 240 | if *adminChannel.LogProgram { 241 | outputToChannel := func(channel string) { 242 | if channel != "" { 243 | if hasPerms(channel, discordgo.PermissionSendMessages) { 244 | if _, err := bot.ChannelMessageSend(channel, 245 | fmt.Sprintf("```%s | [%s] %s```", 246 | time.Now().Format(time.RFC3339), simplePrefix, fmt.Sprintf(line, p...)), 247 | ); err != nil { 248 | log.Println(color.HiRedString("Failed to send message...\t%s", err)) 249 | } 250 | } 251 | } 252 | } 253 | outputToChannel(adminChannel.ChannelID) 254 | if adminChannel.ChannelIDs != nil { 255 | for _, ch := range *adminChannel.ChannelIDs { 256 | outputToChannel(ch) 257 | } 258 | } 259 | } 260 | } 261 | } 262 | 263 | pp := "> " // prefix prefix :) 264 | if strings.ToLower(group) == "debug" || strings.ToLower(subgroup) == "debug" || 265 | strings.ToLower(group) == "debug2" || strings.ToLower(subgroup) == "debug2" { 266 | pp = color.YellowString("? ") 267 | } 268 | if strings.ToLower(group) == "verbose" || strings.ToLower(subgroup) == "verbose" { 269 | pp = color.HiBlueString("? ") 270 | } 271 | 272 | if colorPrefix != "" { 273 | colorPrefix += " " 274 | } 275 | tabPrefix := "" 276 | if config.LogIndent { 277 | tabPrefix = "\t" 278 | } 279 | return tabPrefix + pp + colorPrefix + colorFunc(line, p...) 280 | } 281 | 282 | //#endregion 283 | -------------------------------------------------------------------------------- /database.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/zip" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "time" 12 | 13 | "github.com/HouzuoGuo/tiedot/db" 14 | "github.com/fatih/color" 15 | "github.com/rivo/duplo" 16 | ) 17 | 18 | func openDatabase() { 19 | var openT time.Time 20 | var createT time.Time 21 | // Database 22 | log.Println(lg("Database", "", color.YellowString, "Opening database...\t(this can take a bit...)")) 23 | openT = time.Now() 24 | myDB, err = db.OpenDB(pathDatabaseBase) 25 | if err != nil { 26 | log.Println(lg("Database", "", color.HiRedString, "Unable to open database: %s", err)) 27 | return 28 | } 29 | if myDB.Use("Downloads") == nil { 30 | log.Println(lg("Database", "Setup", color.YellowString, "Creating database, please wait...")) 31 | createT = time.Now() 32 | if err := myDB.Create("Downloads"); err != nil { 33 | log.Println(lg("Database", "Setup", color.HiRedString, "Error while trying to create database: %s", err)) 34 | return 35 | } 36 | log.Println(lg("Database", "Setup", color.HiYellowString, "Created new database...\t(took %s)", timeSinceShort(createT))) 37 | // 38 | log.Println(lg("Database", "Setup", color.YellowString, "Structuring database, please wait...")) 39 | createT = time.Now() 40 | indexColumn := func(col string) { 41 | if err := myDB.Use("Downloads").Index([]string{col}); err != nil { 42 | log.Println(lg("Database", "Setup", color.HiRedString, "Unable to create index for %s: %s", col, err)) 43 | return 44 | } 45 | } 46 | indexColumn("URL") 47 | indexColumn("ChannelID") 48 | indexColumn("UserID") 49 | log.Println(lg("Database", "Setup", color.HiYellowString, "Created database structure...\t(took %s)", timeSinceShort(createT))) 50 | } 51 | // Cache download tally 52 | cachedDownloadID = dbDownloadCount() 53 | log.Println(lg("Database", "", color.HiYellowString, "Database opened, contains %d entries...\t(took %s)", cachedDownloadID, timeSinceShort(openT))) 54 | 55 | // Duplo 56 | if config.Duplo || sourceHasDuplo { 57 | log.Println(lg("Duplo", "", color.HiRedString, "!!! Duplo is barely supported and may cause issues, use at your own risk...")) 58 | duploCatalog = duplo.New() 59 | if _, err := os.Stat(pathCacheDuplo); err == nil { 60 | log.Println(lg("Duplo", "", color.YellowString, "Opening duplo image catalog...")) 61 | openT = time.Now() 62 | storeFile, err := os.ReadFile(pathCacheDuplo) 63 | if err != nil { 64 | log.Println(lg("Duplo", "", color.HiRedString, "Error opening duplo catalog:\t%s", err)) 65 | } else { 66 | err = duploCatalog.GobDecode(storeFile) 67 | if err != nil { 68 | log.Println(lg("Duplo", "", color.HiRedString, "Error decoding duplo catalog:\t%s", err)) 69 | } 70 | if duploCatalog != nil { 71 | log.Println(lg("Duplo", "", color.HiYellowString, "Duplo catalog opened (%d)\t(took %s)", duploCatalog.Size(), timeSinceShort(openT))) 72 | } 73 | } 74 | } 75 | } 76 | } 77 | 78 | func backupDatabase() error { 79 | if err := os.MkdirAll(pathDatabaseBackups, 0755); err != nil { 80 | return err 81 | } 82 | file, err := os.Create(pathDatabaseBackups + string(os.PathSeparator) + time.Now().Format("2006-01-02_15-04-05.000000000") + ".zip") 83 | if err != nil { 84 | return err 85 | } 86 | defer file.Close() 87 | 88 | w := zip.NewWriter(file) 89 | defer w.Close() 90 | 91 | err = filepath.Walk(pathDatabaseBase, func(path string, info os.FileInfo, err error) error { 92 | if err != nil { 93 | return err 94 | } 95 | if info.IsDir() { 96 | return nil 97 | } 98 | file, err := os.Open(path) 99 | if err != nil { 100 | return err 101 | } 102 | defer file.Close() 103 | 104 | // Ensure that `path` is not absolute; it should not start with "/". 105 | // This snippet happens to work because I don't use 106 | // absolute paths, but ensure your real-world code 107 | // transforms path into a zip-root relative path. 108 | f, err := w.Create(path) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | _, err = io.Copy(f, file) 114 | if err != nil { 115 | return err 116 | } 117 | 118 | return nil 119 | }) 120 | if err != nil { 121 | return err 122 | } 123 | return nil 124 | } 125 | 126 | //#region Database Utility 127 | 128 | func dbInsertDownload(download *downloadItem) error { 129 | _, err := myDB.Use("Downloads").Insert(map[string]interface{}{ 130 | "URL": download.URL, 131 | "Time": download.Time.String(), 132 | "Destination": download.Destination, 133 | "Filename": download.Filename, 134 | "ChannelID": download.ChannelID, 135 | "UserID": download.UserID, 136 | }) 137 | return err 138 | } 139 | 140 | func dbFindDownloadByID(id int) *downloadItem { 141 | downloads := myDB.Use("Downloads") 142 | readBack, err := downloads.Read(id) 143 | if err != nil { 144 | log.Println(lg("Database", "Downloads", color.HiRedString, "Failed to read database:\t%s", err)) 145 | } 146 | timeT, _ := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", readBack["Time"].(string)) 147 | return &downloadItem{ 148 | URL: readBack["URL"].(string), 149 | Time: timeT, 150 | Destination: readBack["Destination"].(string), 151 | Filename: readBack["Filename"].(string), 152 | ChannelID: readBack["ChannelID"].(string), 153 | UserID: readBack["UserID"].(string), 154 | } 155 | } 156 | 157 | func dbFindDownloadByURL(inputURL string) []*downloadItem { 158 | var query interface{} 159 | json.Unmarshal([]byte(fmt.Sprintf(`[{"eq": "%s", "in": ["URL"]}]`, inputURL)), &query) 160 | queryResult := make(map[int]struct{}) 161 | db.EvalQuery(query, myDB.Use("Downloads"), &queryResult) 162 | 163 | downloadedImages := make([]*downloadItem, 0) 164 | for id := range queryResult { 165 | downloadedImages = append(downloadedImages, dbFindDownloadByID(id)) 166 | } 167 | return downloadedImages 168 | } 169 | 170 | func dbDeleteByChannelID(channelID string) { 171 | var query interface{} 172 | json.Unmarshal([]byte(fmt.Sprintf(`[{"eq": "%s", "in": ["ChannelID"]}]`, channelID)), &query) 173 | queryResult := make(map[int]struct{}) 174 | db.EvalQuery(query, myDB.Use("Downloads"), &queryResult) 175 | for id := range queryResult { 176 | myDB.Use("Downloads").Delete(id) 177 | } 178 | } 179 | 180 | //#endregion 181 | 182 | //#region Statistics 183 | 184 | func dbDownloadCount() int { 185 | i := 0 186 | myDB.Use("Downloads").ForEachDoc(func(id int, docContent []byte) (willMoveOn bool) { 187 | i++ 188 | return true 189 | }) 190 | return i 191 | } 192 | 193 | func dbDownloadCountByChannel(channelID string) int { 194 | var query interface{} 195 | json.Unmarshal([]byte(fmt.Sprintf(`[{"eq": "%s", "in": ["ChannelID"]}]`, channelID)), &query) 196 | queryResult := make(map[int]struct{}) 197 | db.EvalQuery(query, myDB.Use("Downloads"), &queryResult) 198 | 199 | downloadedImages := make([]*downloadItem, 0) 200 | for id := range queryResult { 201 | downloadedImages = append(downloadedImages, dbFindDownloadByID(id)) 202 | } 203 | return len(downloadedImages) 204 | } 205 | 206 | //#endregion 207 | -------------------------------------------------------------------------------- /discord.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/AvraamMavridis/randomcolor" 14 | "github.com/aidarkhanov/nanoid/v2" 15 | "github.com/bwmarrin/discordgo" 16 | "github.com/dustin/go-humanize" 17 | "github.com/fatih/color" 18 | "github.com/teris-io/shortid" 19 | ) 20 | 21 | const ( 22 | fmtBotSendPerm = "Bot does not have permission to send messages in %s" 23 | ) 24 | 25 | //#region Getters 26 | 27 | func getChannel(channelID string) (*discordgo.Channel, error) { 28 | channel, err := bot.Channel(channelID) 29 | if err != nil { 30 | channel, err = bot.State.Channel(channelID) 31 | } 32 | return channel, err 33 | } 34 | 35 | func getChannelErr(channelID string) error { 36 | _, errr := getChannel(channelID) 37 | return errr 38 | } 39 | 40 | func getServer(guildID string) (*discordgo.Guild, error) { 41 | guild, err := bot.Guild(guildID) 42 | if err != nil { 43 | guild, err = bot.State.Guild(guildID) 44 | } 45 | return guild, err 46 | } 47 | 48 | func getServerErr(guildID string) error { 49 | _, errr := getServer(guildID) 50 | return errr 51 | } 52 | 53 | //#endregion 54 | 55 | //#region Labels 56 | 57 | func getServerLabel(serverID string) (displayLabel string) { 58 | displayLabel = "Discord" 59 | sourceGuild, err := bot.State.Guild(serverID) 60 | if err != nil { 61 | sourceGuild, _ = bot.Guild(serverID) 62 | } 63 | if sourceGuild != nil { 64 | if sourceGuild.Name != "" { 65 | displayLabel = sourceGuild.Name 66 | } 67 | } 68 | return displayLabel 69 | } 70 | 71 | func getCategoryLabel(channelID string) (displayLabel string) { 72 | displayLabel = "Category" 73 | sourceChannel, err := bot.State.Channel(channelID) 74 | if err != nil { 75 | sourceChannel, err = bot.Channel(channelID) 76 | } 77 | if err == nil { 78 | if sourceChannel != nil { 79 | sourceParent, err := bot.State.Channel(sourceChannel.ParentID) 80 | if err != nil { 81 | sourceParent, err = bot.Channel(sourceChannel.ParentID) 82 | } 83 | if err == nil { 84 | if sourceParent != nil { 85 | if sourceChannel.Name != "" { 86 | displayLabel = sourceParent.Name 87 | } 88 | } 89 | } 90 | } 91 | } 92 | return displayLabel 93 | } 94 | 95 | func getChannelLabel(channelID string, channelData *discordgo.Channel) (displayLabel string) { 96 | displayLabel = channelID 97 | sourceChannel, err := bot.State.Channel(channelID) 98 | if err != nil { 99 | sourceChannel, _ = bot.Channel(channelID) 100 | } 101 | if channelData != nil { 102 | sourceChannel = channelData 103 | } 104 | if sourceChannel != nil { 105 | if sourceChannel.Name != "" { 106 | displayLabel = sourceChannel.Name 107 | } else if sourceChannel.Topic != "" { 108 | displayLabel = sourceChannel.Topic 109 | } else { 110 | switch sourceChannel.Type { 111 | case discordgo.ChannelTypeDM: 112 | displayLabel = "DM" 113 | case discordgo.ChannelTypeGroupDM: 114 | displayLabel = "Group-DM" 115 | } 116 | } 117 | } 118 | return displayLabel 119 | } 120 | 121 | func getUserIdentifier(usr discordgo.User) string { 122 | if usr.Discriminator == "0" { 123 | return "@" + usr.Username 124 | } 125 | return fmt.Sprintf("\"%s\"#%s", usr.Username, usr.Discriminator) 126 | } 127 | 128 | //#endregion 129 | 130 | //#region Time 131 | 132 | const ( 133 | discordEpoch = 1420070400000 134 | ) 135 | 136 | //TODO: Clean these two 137 | 138 | func discordTimestampToSnowflake(format string, timestamp string) string { 139 | var snowflake string = "" 140 | var err error 141 | parsed, err := time.ParseInLocation(format, timestamp, time.Local) 142 | if err == nil { 143 | snowflake = fmt.Sprint(((parsed.UnixNano() / int64(time.Millisecond)) - discordEpoch) << 22) 144 | } else { 145 | log.Println(lg("Main", "", color.HiRedString, 146 | "Failed to convert timestamp to discord snowflake... Format: '%s', Timestamp: '%s' - Error:\t%s", 147 | format, timestamp, err)) 148 | } 149 | return snowflake 150 | } 151 | 152 | func discordSnowflakeToTimestamp(snowflake string, format string) string { 153 | i, err := strconv.ParseInt(snowflake, 10, 64) 154 | if err != nil { 155 | return "" 156 | } 157 | t := time.Unix(0, ((i>>22)+discordEpoch)*1000000) 158 | return t.Local().Format(format) 159 | } 160 | 161 | //#endregion 162 | 163 | //#region Messages 164 | 165 | // For command case-insensitivity 166 | func messageToLower(message *discordgo.Message) *discordgo.Message { 167 | newMessage := *message 168 | newMessage.Content = strings.ToLower(newMessage.Content) 169 | return &newMessage 170 | } 171 | 172 | func fixMessage(m *discordgo.Message) *discordgo.Message { 173 | // If message content is empty (likely due to userbot/selfbot) 174 | ubIssue := "Message is corrupted due to endpoint restriction" 175 | if m.Content == "" && len(m.Attachments) == 0 && len(m.Embeds) == 0 { 176 | // Get message history 177 | mCache, err := bot.ChannelMessages(m.ChannelID, 20, "", "", "") 178 | if err == nil { 179 | if len(mCache) > 0 { 180 | for _, mCached := range mCache { 181 | if mCached.ID == m.ID { 182 | // Fix original message having empty Guild ID 183 | serverID := m.GuildID 184 | // Replace message 185 | m = mCached 186 | // ^^ 187 | if m.GuildID == "" && serverID != "" { 188 | m.GuildID = serverID 189 | } 190 | // Parse commands 191 | botCommands.FindAndExecute(bot, strings.ToLower(config.CommandPrefix), bot.State.User.ID, messageToLower(m)) 192 | 193 | break 194 | } 195 | } 196 | } else if config.Debug { 197 | log.Println(lg("Debug", "fixMessage", 198 | color.RedString, "%s, and an attempt to get channel messages found nothing...", 199 | ubIssue)) 200 | } 201 | } else if config.Debug { 202 | log.Println(lg("Debug", "fixMessage", 203 | color.HiRedString, "%s, and an attempt to get channel messages encountered an error:\t%s", ubIssue, err)) 204 | } 205 | } 206 | if m.Content == "" && len(m.Attachments) == 0 && len(m.Embeds) == 0 { 207 | if config.Debug && selfbot { 208 | log.Println(lg("Debug", "fixMessage", 209 | color.YellowString, "%s, and attempts to fix seem to have failed...", ubIssue)) 210 | } 211 | } 212 | return m 213 | } 214 | 215 | //#endregion 216 | 217 | func channelDisplay(channelID string) (sourceName string, sourceChannelName string) { 218 | sourceChannelName = channelID 219 | sourceName = "UNKNOWN" 220 | sourceChannel, err := bot.State.Channel(channelID) 221 | if err != nil { 222 | sourceChannel, _ = bot.Channel(channelID) 223 | } 224 | if sourceChannel != nil { 225 | // Channel Naming 226 | if sourceChannel.Name != "" { 227 | sourceChannelName = "#" + sourceChannel.Name // #example 228 | } 229 | switch sourceChannel.Type { 230 | case discordgo.ChannelTypeGuildText: 231 | case discordgo.ChannelTypeGuildNews: 232 | case discordgo.ChannelTypeGuildNewsThread: 233 | case discordgo.ChannelTypeGuildPrivateThread: 234 | case discordgo.ChannelTypeGuildPublicThread: 235 | // Server Naming 236 | if sourceChannel.GuildID != "" { 237 | sourceGuild, _ := bot.State.Guild(sourceChannel.GuildID) 238 | if sourceGuild != nil && sourceGuild.Name != "" { 239 | sourceName = sourceGuild.Name 240 | } 241 | } 242 | // Category Naming 243 | if sourceChannel.ParentID != "" { 244 | sourceParent, err := bot.State.Channel(sourceChannel.ParentID) 245 | if err != nil { 246 | sourceParent, _ = bot.Channel(sourceChannel.ParentID) 247 | } 248 | if sourceParent != nil { 249 | if sourceParent.Name != "" { 250 | sourceChannelName = sourceParent.Name + " - " + sourceChannelName 251 | } 252 | } 253 | } 254 | case discordgo.ChannelTypeDM: 255 | sourceName = "Direct Messages" 256 | case discordgo.ChannelTypeGroupDM: 257 | sourceName = "Group Messages" 258 | } 259 | } 260 | return sourceName, sourceChannelName 261 | } 262 | 263 | //#region Presence 264 | 265 | func dataKeys(input string) string { 266 | //TODO: Case-insensitive key replacement. -- If no streamlined way to do it, convert to lower to find substring location but replace normally 267 | if strings.Contains(input, "{{") && strings.Contains(input, "}}") { 268 | countInt := int64(dbDownloadCount()) + *config.InflateDownloadCount 269 | timeNow := time.Now() 270 | keys := [][]string{ 271 | {"{{dgVersion}}", 272 | discordgo.VERSION}, 273 | {"{{ddgVersion}}", 274 | projectVersion}, 275 | {"{{apiVersion}}", 276 | discordgo.APIVersion}, 277 | {"{{botUsername}}", 278 | clearPathIllegalChars(botUser.Username)}, 279 | {"{{countNoCommas}}", 280 | fmt.Sprint(countInt)}, 281 | {"{{count}}", 282 | formatNumber(countInt)}, 283 | {"{{countShort}}", 284 | formatNumberShort(countInt)}, 285 | {"{{numServers}}", 286 | fmt.Sprint(len(bot.State.Guilds))}, 287 | {"{{numBoundChannels}}", 288 | fmt.Sprint(getBoundChannelsCount())}, 289 | {"{{numBoundCategories}}", 290 | fmt.Sprint(getBoundCategoriesCount())}, 291 | {"{{numBoundServers}}", 292 | fmt.Sprint(getBoundServersCount())}, 293 | {"{{numBoundUsers}}", 294 | fmt.Sprint(getBoundUsersCount())}, 295 | {"{{numAdminChannels}}", 296 | fmt.Sprint(len(config.AdminChannels))}, 297 | {"{{numAdmins}}", 298 | fmt.Sprint(len(config.Admins))}, 299 | //TODO: redo time stuff 300 | {"{{timeSavedShort}}", 301 | timeLastUpdated.Format("3:04pm")}, 302 | {"{{timeSavedShortTZ}}", 303 | timeLastUpdated.Format("3:04pm MST")}, 304 | {"{{timeSavedMid}}", 305 | timeLastUpdated.Format("3:04pm MST 1/2/2006")}, 306 | {"{{timeSavedLong}}", 307 | timeLastUpdated.Format("3:04:05pm MST - January 2, 2006")}, 308 | {"{{timeSavedShort24}}", 309 | timeLastUpdated.Format("15:04")}, 310 | {"{{timeSavedShortTZ24}}", 311 | timeLastUpdated.Format("15:04 MST")}, 312 | {"{{timeSavedMid24}}", 313 | timeLastUpdated.Format("15:04 MST 2/1/2006")}, 314 | {"{{timeSavedLong24}}", 315 | timeLastUpdated.Format("15:04:05 MST - 2 January, 2006")}, 316 | {"{{timeNowShort}}", 317 | timeNow.Format("3:04pm")}, 318 | {"{{timeNowShortTZ}}", 319 | timeNow.Format("3:04pm MST")}, 320 | {"{{timeNowMid}}", 321 | timeNow.Format("3:04pm MST 1/2/2006")}, 322 | {"{{timeNowLong}}", 323 | timeNow.Format("3:04:05pm MST - January 2, 2006")}, 324 | {"{{timeNowShort24}}", 325 | timeNow.Format("15:04")}, 326 | {"{{timeNowShortTZ24}}", 327 | timeNow.Format("15:04 MST")}, 328 | {"{{timeNowMid24}}", 329 | timeNow.Format("15:04 MST 2/1/2006")}, 330 | {"{{timeNowLong24}}", 331 | timeNow.Format("15:04:05 MST - 2 January, 2006")}, 332 | {"{{uptime}}", 333 | timeSinceShort(startTime)}, 334 | } 335 | for _, key := range keys { 336 | if strings.Contains(input, key[0]) { 337 | input = strings.ReplaceAll(input, key[0], key[1]) 338 | } 339 | } 340 | } 341 | return input 342 | } 343 | 344 | func dataKeysDownload(input string, sourceConfig configurationSource, download downloadRequestStruct, buildingFilename bool) string { 345 | //TODO: same as dataKeys 346 | ret := input 347 | 348 | if buildingFilename { 349 | if sourceConfig == emptySourceConfig { 350 | return config.FilenameFormat 351 | } 352 | ret = config.FilenameFormat 353 | if sourceConfig.FilenameFormat != nil { 354 | if *sourceConfig.FilenameFormat != "" { 355 | ret = *sourceConfig.FilenameFormat 356 | } 357 | } 358 | } 359 | 360 | if strings.Contains(ret, "{{") && strings.Contains(ret, "}}") { 361 | 362 | // Format Filename Date 363 | filenameDateFormat := config.FilenameDateFormat 364 | if sourceConfig.FilenameDateFormat != nil { 365 | if *sourceConfig.FilenameDateFormat != "" { 366 | filenameDateFormat = *sourceConfig.FilenameDateFormat 367 | } 368 | } 369 | messageTime := download.Message.Timestamp 370 | 371 | shortID, err := shortid.Generate() 372 | if err != nil && config.Debug { 373 | log.Println(lg("Debug", "dataKeysDownload", color.HiCyanString, "Error when generating a shortID %s", err)) 374 | } 375 | 376 | nanoID, err := nanoid.New() 377 | if err != nil && config.Debug { 378 | log.Println(lg("Debug", "dataKeysDownload", color.HiCyanString, "Error when creating a nanoID %s", err)) 379 | } 380 | 381 | userID := "" 382 | username := "" 383 | if download.Message.Author != nil { 384 | userID = download.Message.Author.ID 385 | username = download.Message.Author.Username 386 | } 387 | 388 | channelName := download.Message.ChannelID 389 | categoryID := download.Message.ChannelID 390 | categoryName := download.Message.ChannelID 391 | guildName := download.Message.GuildID 392 | 393 | chinfo, err := bot.State.Channel(download.Message.ChannelID) 394 | if err != nil { 395 | chinfo, err = bot.Channel(download.Message.ChannelID) 396 | } 397 | if err == nil { 398 | channelName = chinfo.Name 399 | categoryID = chinfo.ParentID 400 | 401 | catinfo, err := bot.State.Channel(categoryID) 402 | if err != nil { 403 | catinfo, err = bot.Channel(categoryID) 404 | } 405 | if err == nil { 406 | categoryName = catinfo.Name 407 | } 408 | } 409 | guildinfo, err := bot.State.Guild(download.Message.GuildID) 410 | if err != nil { 411 | guildinfo, err = bot.Guild(download.Message.GuildID) 412 | } 413 | if err == nil { 414 | guildName = guildinfo.Name 415 | } 416 | 417 | domain := "unknown" 418 | if parsedURL, err := url.Parse(download.InputURL); err == nil { 419 | domain = parsedURL.Hostname() 420 | } 421 | 422 | fileinfo, err := os.Stat(download.Path + download.Filename) 423 | filesize := "unknown" 424 | if err == nil { 425 | filesize = humanize.Bytes(uint64(fileinfo.Size())) 426 | } 427 | 428 | fmt_msg := download.Message.Content 429 | if buildingFilename { 430 | fmt_msg = clearPathIllegalChars(download.Message.Content) 431 | } 432 | fmt_url := download.InputURL 433 | if buildingFilename { 434 | fmt_url = clearPathIllegalChars(download.InputURL) 435 | } 436 | 437 | keys := [][]string{ 438 | {"{{date}}", messageTime.Format(filenameDateFormat)}, 439 | {"{{file}}", download.Filename}, 440 | {"{{fileType}}", download.Extension}, 441 | {"{{fileSize}}", filesize}, 442 | {"{{attachmentID}}", download.AttachmentID}, 443 | {"{{messageID}}", download.Message.ID}, 444 | {"{{userID}}", userID}, 445 | {"{{username}}", username}, 446 | {"{{usernameNoLeadPeriod}}", func() string { 447 | usernameCleaned := username 448 | for strings.HasPrefix(usernameCleaned, ".") { 449 | if len(usernameCleaned) <= 1 { 450 | break 451 | } else { 452 | usernameCleaned = usernameCleaned[1:] 453 | } 454 | } 455 | return usernameCleaned 456 | }()}, 457 | {"{{channelID}}", download.Message.ChannelID}, 458 | {"{{channelName}}", channelName}, 459 | {"{{categoryID}}", categoryID}, 460 | {"{{categoryName}}", categoryName}, 461 | {"{{serverID}}", download.Message.GuildID}, 462 | {"{{serverName}}", guildName}, 463 | {"{{message}}", fmt_msg}, 464 | {"{{downloadTime}}", timeSinceShort(download.StartTime)}, 465 | {"{{downloadTimeLong}}", timeSince(download.StartTime)}, 466 | {"{{url}}", fmt_url}, 467 | {"{{domain}}", domain}, 468 | {"{{nanoID}}", nanoID}, 469 | {"{{shortID}}", shortID}, 470 | {"{{botUsername}}", 471 | clearPathIllegalChars(botUser.Username)}, 472 | } 473 | for _, key := range keys { 474 | if strings.Contains(ret, key[0]) { 475 | ret = strings.ReplaceAll(ret, key[0], key[1]) 476 | } 477 | } 478 | } 479 | return dataKeys(ret) 480 | } 481 | 482 | func dataKeys_DiscordMessage(input string, m *discordgo.Message) string { 483 | ret := input 484 | if strings.Contains(ret, "{{") && strings.Contains(ret, "}}") && m != nil { 485 | // Basic message data 486 | keys := [][]string{ 487 | {"{{year}}", 488 | fmt.Sprint(m.Timestamp.Year())}, 489 | {"{{monthNum}}", 490 | fmt.Sprintf("%02d", m.Timestamp.Month())}, 491 | {"{{dayOfMonth}}", 492 | fmt.Sprintf("%02d", m.Timestamp.Day())}, 493 | {"{{hour}}", 494 | fmt.Sprintf("%02d", m.Timestamp.Hour())}, 495 | {"{{minute}}", 496 | fmt.Sprintf("%02d", m.Timestamp.Minute())}, 497 | {"{{second}}", 498 | fmt.Sprintf("%02d", m.Timestamp.Second())}, 499 | {"{{timestamp}}", discordSnowflakeToTimestamp(m.ID, "2006-01-02_15-04-05")}, 500 | {"{{timestampYYYYMMDD}}", discordSnowflakeToTimestamp(m.ID, "2006-01-02")}, 501 | {"{{timestampHHMMSS}}", discordSnowflakeToTimestamp(m.ID, "15-04-05")}, 502 | {"{{messageID}}", m.ID}, 503 | {"{{message}}", clearPathIllegalChars(m.Content)}, 504 | {"{{channelID}}", m.ChannelID}, 505 | {"{{botUsername}}", 506 | clearPathIllegalChars(botUser.Username)}, 507 | } 508 | // Author data if present 509 | if m.Author != nil { 510 | keys = append(keys, [][]string{ 511 | {"{{userID}}", m.Author.ID}, 512 | {"{{username}}", clearPathIllegalChars(m.Author.Username)}, 513 | {"{{usernameNoLeadPeriod}}", func() string { 514 | usernameCleaned := clearPathIllegalChars(m.Author.Username) 515 | for strings.HasPrefix(usernameCleaned, ".") { 516 | if len(usernameCleaned) <= 1 { 517 | break 518 | } else { 519 | usernameCleaned = usernameCleaned[1:] 520 | } 521 | } 522 | return usernameCleaned 523 | }()}, 524 | {"{{userDisc}}", m.Author.Discriminator}, 525 | }...) 526 | } 527 | // Lookup channel 528 | var ch *discordgo.Channel = nil 529 | ch, err = bot.Channel(m.ChannelID) 530 | if err != nil || ch == nil { 531 | ch, _ = bot.State.Channel(m.ChannelID) 532 | } 533 | if ch != nil { 534 | keys = append(keys, [][]string{ 535 | {"{{channelName}}", clearPathIllegalChars(ch.Name)}, 536 | {"{{channelTopic}}", clearPathIllegalChars(ch.Topic)}, 537 | {"{{serverID}}", ch.GuildID}, 538 | }...) 539 | // Lookup server 540 | var srv *discordgo.Guild = nil 541 | srv, err = bot.Guild(ch.GuildID) 542 | if err != nil || srv == nil { 543 | srv, _ = bot.State.Guild(ch.GuildID) 544 | } 545 | if srv != nil { 546 | keys = append(keys, [][]string{ 547 | {"{{serverName}}", clearPathIllegalChars(srv.Name)}, 548 | }...) 549 | } 550 | // Lookup parent channel 551 | if ch.ParentID != "" { 552 | var cat *discordgo.Channel = nil 553 | cat, err = bot.Channel(ch.ParentID) 554 | if err != nil || cat == nil { 555 | cat, _ = bot.State.Channel(ch.ParentID) 556 | } 557 | if cat != nil { 558 | if cat.Type == discordgo.ChannelTypeGuildCategory { 559 | keys = append(keys, [][]string{ 560 | {"{{categoryID}}", cat.ID}, 561 | {"{{categoryName}}", clearPathIllegalChars(cat.Name)}, 562 | {"{{forumID}}", ch.ID}, // no check that this is actually a forum, just accountability so it's not using {{}} 563 | {"{{forumName}}", clearPathIllegalChars(ch.Name)}, // ^^^ 564 | }...) 565 | } else { 566 | keys = append(keys, [][]string{ 567 | {"{{threadID}}", ch.ID}, 568 | {"{{threadName}}", clearPathIllegalChars(ch.Name)}, 569 | {"{{threadTopic}}", clearPathIllegalChars(ch.Topic)}, 570 | {"{{forumID}}", cat.ID}, 571 | {"{{forumName}}", clearPathIllegalChars(cat.Name)}, 572 | }...) 573 | // Parent Category 574 | if cat.ParentID != "" { 575 | cat2, err := bot.State.Channel(cat.ParentID) 576 | if err != nil { 577 | cat2, err = bot.Channel(cat.ParentID) 578 | } 579 | if err == nil { 580 | keys = append(keys, [][]string{ 581 | {"{{categoryID}}", cat2.ID}, 582 | {"{{categoryName}}", clearPathIllegalChars(cat2.Name)}, 583 | }...) 584 | } 585 | } 586 | } 587 | } 588 | } 589 | } 590 | for _, key := range keys { 591 | if strings.Contains(ret, key[0]) { 592 | ret = strings.ReplaceAll(ret, key[0], key[1]) 593 | } 594 | } 595 | } 596 | 597 | // Cleanup 598 | ret = strings.ReplaceAll(ret, "{{channelName}}", "DM") 599 | ret = strings.ReplaceAll(ret, "{{channelTopic}}", "DM") 600 | ret = strings.ReplaceAll(ret, "{{serverName}}", "DM") 601 | ret = strings.ReplaceAll(ret, "{{categoryID}}", "Uncategorized") 602 | ret = strings.ReplaceAll(ret, "{{categoryName}}", "Uncategorized") 603 | ret = strings.ReplaceAll(ret, "{{forumID}}", "NOT_FORUM") 604 | ret = strings.ReplaceAll(ret, "{{forumName}}", "NOT_FORUM") 605 | ret = strings.ReplaceAll(ret, "{{threadID}}", "NOT_THREAD") 606 | ret = strings.ReplaceAll(ret, "{{threadName}}", "NOT_THREAD") 607 | ret = strings.ReplaceAll(ret, "{{threadTopic}}", "NOT_THREAD") 608 | 609 | return ret 610 | } 611 | 612 | func dataKeys_DownloadStatus(input string, status downloadStatusStruct, download downloadRequestStruct) string { 613 | ret := input 614 | if strings.Contains(ret, "{{") && strings.Contains(ret, "}}") { 615 | // Basic message data 616 | keys := [][]string{ 617 | {"{{downloadStatus}}", getDownloadStatusShort(status.Status)}, 618 | {"{{downloadStatusLong}}", getDownloadStatus(status.Status)}, 619 | {"{{downloadFilename}}", download.Filename}, 620 | {"{{downloadExt}}", download.Extension}, 621 | {"{{downloadPath}}", download.Path}, 622 | } 623 | for _, key := range keys { 624 | if strings.Contains(ret, key[0]) { 625 | ret = strings.ReplaceAll(ret, key[0], key[1]) 626 | } 627 | } 628 | } 629 | return ret 630 | } 631 | 632 | func updateDiscordPresence() { 633 | if bot != nil && botReady && config.PresenceEnabled { 634 | // Vars 635 | countInt := int64(dbDownloadCount()) + *config.InflateDownloadCount 636 | count := formatNumber(countInt) 637 | countShort := formatNumberShort(countInt) 638 | timeShort := timeLastUpdated.Format("3:04pm") 639 | timeLong := timeLastUpdated.Format("3:04:05pm MST - January 2, 2006") 640 | 641 | // Defaults 642 | status := fmt.Sprintf("%s - %s files", timeShort, countShort) 643 | statusDetails := timeLong 644 | statusState := fmt.Sprintf("%s files total", count) 645 | 646 | // Overwrite Presence 647 | if config.PresenceLabel != nil { 648 | status = *config.PresenceLabel 649 | if status != "" { 650 | status = dataKeys(status) 651 | } 652 | } 653 | // Overwrite Details 654 | if config.PresenceDetails != nil { 655 | statusDetails = *config.PresenceDetails 656 | if statusDetails != "" { 657 | statusDetails = dataKeys(statusDetails) 658 | } 659 | } 660 | // Overwrite State 661 | if config.PresenceState != nil { 662 | statusState = *config.PresenceState 663 | if statusState != "" { 664 | statusState = dataKeys(statusState) 665 | } 666 | } 667 | 668 | // Update 669 | bot.UpdateStatusComplex(discordgo.UpdateStatusData{ 670 | Game: &discordgo.Game{ 671 | Name: status, 672 | Type: config.PresenceType, 673 | Details: statusDetails, // Only visible if real user 674 | State: statusState, 675 | }, 676 | Status: config.PresenceStatus, 677 | }) 678 | } else if config.PresenceStatus != string(discordgo.StatusOnline) { 679 | bot.UpdateStatusComplex(discordgo.UpdateStatusData{ 680 | Status: config.PresenceStatus, 681 | }) 682 | } 683 | } 684 | 685 | //#endregion 686 | 687 | //#region Embeds 688 | 689 | func getEmbedColor(channelID string) int { 690 | var err error 691 | var color *string 692 | var channelInfo *discordgo.Channel 693 | 694 | // Assign Defined Color 695 | if config.EmbedColor != nil { 696 | if *config.EmbedColor != "" { 697 | color = config.EmbedColor 698 | } 699 | } 700 | // Overwrite with Defined Color for Channel 701 | /*var msg *discordgo.Message 702 | msg.ChannelID = channelID 703 | if channelRegistered(msg) { 704 | sourceConfig := getSource(channelID) 705 | if sourceConfig.OverwriteEmbedColor != nil { 706 | if *sourceConfig.OverwriteEmbedColor != "" { 707 | color = sourceConfig.OverwriteEmbedColor 708 | } 709 | } 710 | }*/ 711 | 712 | // Use Defined Color 713 | if color != nil { 714 | // Defined as Role, fetch role color 715 | if *color == "role" || *color == "user" { 716 | botColor := bot.State.UserColor(botUser.ID, channelID) 717 | if botColor != 0 { 718 | return botColor 719 | } 720 | goto color_random 721 | } 722 | // Defined as Random, jump below (not preferred method but seems to work flawlessly) 723 | if *color == "random" || *color == "rand" { 724 | goto color_random 725 | } 726 | 727 | var colorString string = *color 728 | 729 | // Input is Hex 730 | colorString = strings.ReplaceAll(colorString, "#", "") 731 | if convertedHex, err := strconv.ParseUint(colorString, 16, 64); err == nil { 732 | return int(convertedHex) 733 | } 734 | 735 | // Input is Int 736 | if convertedInt, err := strconv.Atoi(colorString); err == nil { 737 | return convertedInt 738 | } 739 | 740 | // Definition is invalid since hasn't returned, so defaults to below... 741 | } 742 | 743 | // User color 744 | channelInfo, err = bot.State.Channel(channelID) 745 | if err != nil { 746 | channelInfo, err = bot.Channel(channelID) 747 | } 748 | if err == nil { 749 | if channelInfo.Type != discordgo.ChannelTypeDM && channelInfo.Type != discordgo.ChannelTypeGroupDM { 750 | if bot.State.UserColor(botUser.ID, channelID) != 0 { 751 | return bot.State.UserColor(botUser.ID, channelID) 752 | } 753 | } 754 | } 755 | 756 | // Random color 757 | color_random: 758 | var randomColor string = randomcolor.GetRandomColorInHex() 759 | if convertedRandom, err := strconv.ParseUint(strings.ReplaceAll(randomColor, "#", ""), 16, 64); err == nil { 760 | return int(convertedRandom) 761 | } 762 | 763 | return 16777215 // white 764 | } 765 | 766 | // Shortcut function for quickly constructing a styled embed with Title & Description 767 | func buildEmbed(channelID string, title string, description string) *discordgo.MessageEmbed { 768 | return &discordgo.MessageEmbed{ 769 | Title: title, 770 | Description: description, 771 | Color: getEmbedColor(channelID), 772 | Footer: &discordgo.MessageEmbedFooter{ 773 | IconURL: projectIcon, 774 | Text: fmt.Sprintf("%s v%s", projectName, projectVersion), 775 | }, 776 | } 777 | } 778 | 779 | // Shortcut function for quickly replying a styled embed with Title & Description 780 | func replyEmbed(m *discordgo.Message, title string, description string) (*discordgo.Message, error) { 781 | if m != nil { 782 | if hasPerms(m.ChannelID, discordgo.PermissionSendMessages) { 783 | mention := m.Author.Mention() 784 | if !config.CommandTagging { // Erase mention if tagging disabled 785 | mention = "" 786 | } 787 | if selfbot { 788 | if mention != "" { // Add space if mentioning 789 | mention += " " 790 | } 791 | return bot.ChannelMessageSend(m.ChannelID, fmt.Sprintf("%s**%s**\n\n%s", mention, title, description)) 792 | } else { 793 | return bot.ChannelMessageSendComplex(m.ChannelID, 794 | &discordgo.MessageSend{ 795 | Content: mention, 796 | Embed: buildEmbed(m.ChannelID, title, description), 797 | }, 798 | ) 799 | } 800 | } 801 | log.Println(lg("Discord", "replyEmbed", color.HiRedString, fmtBotSendPerm, m.ChannelID)) 802 | } 803 | return nil, nil 804 | } 805 | 806 | //#endregion 807 | 808 | //#region Send Status Message 809 | 810 | type sendStatusType int 811 | 812 | const ( 813 | sendStatusStartup sendStatusType = iota 814 | sendStatusReconnect 815 | sendStatusExit 816 | sendStatusSettings 817 | ) 818 | 819 | func sendStatusLabel(status sendStatusType) string { 820 | switch status { 821 | case sendStatusStartup: 822 | return "has launched" 823 | case sendStatusReconnect: 824 | return "has reconnected" 825 | case sendStatusExit: 826 | return "is exiting" 827 | case sendStatusSettings: 828 | return "updated settings" 829 | } 830 | return "is confused" 831 | } 832 | 833 | func sendStatusMessage(status sendStatusType) { 834 | for _, adminChannel := range config.AdminChannels { 835 | if *adminChannel.LogStatus { 836 | var message string 837 | var label string 838 | var emoji string 839 | 840 | //TODO: CLEAN 841 | if status == sendStatusStartup || status == sendStatusReconnect { 842 | label = "startup" 843 | emoji = "🟩" 844 | if status == sendStatusReconnect { 845 | emoji = "🟧" 846 | } 847 | message += fmt.Sprintf("%s %s and connected to %d server%s...\n", projectLabel, sendStatusLabel(status), len(bot.State.Guilds), pluralS(len(bot.State.Guilds))) 848 | message += fmt.Sprintf("\n• Uptime is %s", uptime()) 849 | message += fmt.Sprintf("\n• %s total downloads", formatNumber(int64(dbDownloadCount()))) 850 | message += fmt.Sprintf("\n• Bound to %d channel%s, %d categories, %d server%s, %d user%s", 851 | getBoundChannelsCount(), pluralS(getBoundChannelsCount()), 852 | getBoundCategoriesCount(), 853 | getBoundServersCount(), pluralS(getBoundServersCount()), 854 | getBoundUsersCount(), pluralS(getBoundUsersCount()), 855 | ) 856 | if config.All != nil { 857 | message += "\n• **ALL MODE ENABLED -** Bot will use all available channels" 858 | } 859 | allChannels := getAllRegisteredChannels() 860 | message += fmt.Sprintf("\n• ***Listening to %s channel%s...***\n", formatNumber(int64(len(allChannels))), pluralS(len(allChannels))) 861 | message += fmt.Sprintf("\n_%s_", versions(true)) 862 | } else if status == sendStatusExit { 863 | label = "exit" 864 | emoji = "🟥" 865 | message += fmt.Sprintf("%s %s...\n", projectLabel, sendStatusLabel(status)) 866 | message += fmt.Sprintf("\n• Uptime was %s", uptime()) 867 | message += fmt.Sprintf("\n• %s total downloads", formatNumber(int64(dbDownloadCount()))) 868 | message += fmt.Sprintf("\n• Bound to %d channel%s, %d categories, %d server%s, %d user%s", 869 | getBoundChannelsCount(), pluralS(getBoundChannelsCount()), 870 | getBoundCategoriesCount(), 871 | getBoundServersCount(), pluralS(getBoundServersCount()), 872 | getBoundUsersCount(), pluralS(getBoundUsersCount()), 873 | ) 874 | } else if status == sendStatusSettings { 875 | label = "settings" 876 | emoji = "🟨" 877 | message += fmt.Sprintf("%s %s...\n", projectLabel, sendStatusLabel(status)) 878 | message += fmt.Sprintf("\n• Bound to %d channel%s, %d categories, %d server%s, %d user%s", 879 | getBoundChannelsCount(), pluralS(getBoundChannelsCount()), 880 | getBoundCategoriesCount(), 881 | getBoundServersCount(), pluralS(getBoundServersCount()), 882 | getBoundUsersCount(), pluralS(getBoundUsersCount()), 883 | ) 884 | } 885 | // Send 886 | if config.Debug { 887 | log.Println(lg("Debug", "Bot Status", color.YellowString, "Sending log for %s to admin channel: %s", 888 | strings.ToUpper(label), getChannelLabel(adminChannel.ChannelID, nil))) 889 | } 890 | if hasPerms(adminChannel.ChannelID, discordgo.PermissionEmbedLinks) && !selfbot { 891 | bot.ChannelMessageSendEmbed(adminChannel.ChannelID, 892 | buildEmbed(adminChannel.ChannelID, emoji+" Log — Status", message)) 893 | } else if hasPerms(adminChannel.ChannelID, discordgo.PermissionSendMessages) { 894 | bot.ChannelMessageSend(adminChannel.ChannelID, message) 895 | } else { 896 | log.Println(lg("Debug", "Bot Status", color.HiRedString, "Perms checks failed for sending %s status log to %s", 897 | strings.ToUpper(label), adminChannel.ChannelID)) 898 | } 899 | } 900 | } 901 | } 902 | 903 | func sendErrorMessage(err string) { 904 | for _, adminChannel := range config.AdminChannels { 905 | if *adminChannel.LogErrors { 906 | // Send 907 | if hasPerms(adminChannel.ChannelID, discordgo.PermissionEmbedLinks) && !selfbot { // not confident this is the right permission 908 | if config.Debug { 909 | log.Println(lg("Debug", "sendErrorMessage", color.HiCyanString, "Sending embed log for error to %s", 910 | adminChannel.ChannelID)) 911 | } 912 | bot.ChannelMessageSendEmbed(adminChannel.ChannelID, buildEmbed(adminChannel.ChannelID, "Log — Error", err)) 913 | } else if hasPerms(adminChannel.ChannelID, discordgo.PermissionSendMessages) { 914 | if config.Debug { 915 | log.Println(lg("Debug", "sendErrorMessage", color.HiCyanString, "Sending embed log for error to %s", 916 | adminChannel.ChannelID)) 917 | } 918 | bot.ChannelMessageSend(adminChannel.ChannelID, err) 919 | } else { 920 | log.Println(lg("Debug", "sendErrorMessage", color.HiRedString, "Perms checks failed for sending error log to %s", 921 | adminChannel.ChannelID)) 922 | } 923 | } 924 | } 925 | } 926 | 927 | //#endregion 928 | 929 | //#region Permissions 930 | 931 | func hasPerms(channelID string, permission int64) bool { 932 | if selfbot { 933 | return true 934 | } 935 | 936 | sourceChannel, err := bot.State.Channel(channelID) 937 | if err != nil { 938 | sourceChannel, err = bot.Channel(channelID) 939 | } 940 | if sourceChannel != nil && err == nil { 941 | switch sourceChannel.Type { 942 | case discordgo.ChannelTypeDM: 943 | return true 944 | case discordgo.ChannelTypeGroupDM: 945 | return true 946 | default: 947 | perms, err := bot.UserChannelPermissions(botUser.ID, channelID) 948 | if err == nil { 949 | return perms&permission == permission 950 | } 951 | log.Println(lg("Debug", "hasPerms", color.HiRedString, 952 | "Failed to check permissions (%d) for %s:\t%s", permission, channelID, err)) 953 | } 954 | } 955 | return true 956 | } 957 | 958 | //#endregion 959 | 960 | //#region Download Emojis & Stickers 961 | 962 | func downloadDiscordEmojis() { 963 | 964 | dataKeysEmoji := func(emoji discordgo.Emoji, serverID string) string { 965 | ret := config.EmojisFilenameFormat 966 | keys := [][]string{ 967 | {"{{ID}}", emoji.ID}, 968 | {"{{name}}", emoji.Name}, 969 | } 970 | for _, key := range keys { 971 | if strings.Contains(ret, key[0]) { 972 | ret = strings.ReplaceAll(ret, key[0], key[1]) 973 | } 974 | } 975 | return ret 976 | } 977 | 978 | if config.EmojisServers != nil { 979 | // Handle destination 980 | destination := "emojis" 981 | if config.EmojisDestination != nil { 982 | destination = *config.EmojisDestination 983 | } 984 | if err = os.MkdirAll(destination, 0755); err != nil { 985 | log.Println(lg("Discord", "Emojis", color.HiRedString, "Error while creating destination folder \"%s\": %s", destination, err)) 986 | } 987 | // Start 988 | log.Println(lg("Discord", "Emojis", color.MagentaString, "Starting emoji downloads...")) 989 | for _, serverID := range *config.EmojisServers { 990 | emojis, err := bot.GuildEmojis(serverID) 991 | if err != nil { 992 | log.Println(lg("Discord", "Emojis", color.HiRedString, "Error fetching emojis from %s... %s", serverID, err)) 993 | } else { 994 | guildName := "UNKNOWN" 995 | guild, err := bot.Guild(serverID) 996 | if err == nil { 997 | guildName = guild.Name 998 | } 999 | subfolder := destination + string(os.PathSeparator) + clearPathIllegalChars(guildName) 1000 | if err = os.MkdirAll(subfolder, 0755); err != nil { 1001 | log.Println(lg("Discord", "Emojis", color.HiRedString, "Error while creating subfolder \"%s\": %s", subfolder, err)) 1002 | } 1003 | 1004 | countDownloaded := 0 1005 | countSkipped := 0 1006 | countFailed := 0 1007 | for _, emoji := range emojis { 1008 | url := "https://cdn.discordapp.com/emojis/" + emoji.ID 1009 | 1010 | status, _ := downloadRequestStruct{ 1011 | InputURL: url, 1012 | Filename: dataKeysEmoji(*emoji, serverID), 1013 | Path: subfolder, 1014 | Message: nil, 1015 | Channel: nil, 1016 | FileTime: time.Now(), 1017 | HistoryCmd: false, 1018 | EmojiCmd: true, 1019 | StartTime: time.Now(), 1020 | }.handleDownload() 1021 | 1022 | if status.Status == downloadSuccess { 1023 | countDownloaded++ 1024 | } else if status.Status == downloadSkippedDuplicate { 1025 | countSkipped++ 1026 | } else { 1027 | countFailed++ 1028 | log.Println(lg("Discord", "Emojis", color.HiRedString, 1029 | "Failed to download emoji \"%s\": \t[%d - %s] %v", 1030 | url, status.Status, getDownloadStatus(status.Status), status.Error)) 1031 | } 1032 | } 1033 | 1034 | // Log 1035 | destinationOut := destination 1036 | abs, err := filepath.Abs(destination) 1037 | if err == nil { 1038 | destinationOut = abs 1039 | } 1040 | log.Println(lg("Discord", "Emojis", color.HiMagentaString, 1041 | fmt.Sprintf("%d emojis downloaded, %d skipped, %d failed - Destination: %s", 1042 | countDownloaded, countSkipped, countFailed, destinationOut, 1043 | ))) 1044 | } 1045 | } 1046 | } 1047 | 1048 | } 1049 | 1050 | func downloadDiscordStickers() { 1051 | 1052 | dataKeysSticker := func(sticker discordgo.Sticker) string { 1053 | ret := config.StickersFilenameFormat 1054 | keys := [][]string{ 1055 | {"{{ID}}", sticker.ID}, 1056 | {"{{name}}", sticker.Name}, 1057 | } 1058 | for _, key := range keys { 1059 | if strings.Contains(ret, key[0]) { 1060 | ret = strings.ReplaceAll(ret, key[0], key[1]) 1061 | } 1062 | } 1063 | return ret 1064 | } 1065 | 1066 | if config.StickersServers != nil { 1067 | // Handle destination 1068 | destination := "stickers" 1069 | if config.StickersDestination != nil { 1070 | destination = *config.StickersDestination 1071 | } 1072 | if err = os.MkdirAll(destination, 0755); err != nil { 1073 | log.Println(lg("Discord", "Stickers", color.HiRedString, "Error while creating destination folder \"%s\": %s", destination, err)) 1074 | } 1075 | log.Println(lg("Discord", "Stickers", color.MagentaString, "Starting sticker downloads...")) 1076 | for _, serverID := range *config.StickersServers { 1077 | guildName := "UNKNOWN" 1078 | guild, err := bot.Guild(serverID) 1079 | if err != nil { 1080 | log.Println(lg("Discord", "Stickers", color.HiRedString, "Error fetching server %s... %s", serverID, err)) 1081 | } else { 1082 | guildName = guild.Name 1083 | subfolder := destination + string(os.PathSeparator) + clearPathIllegalChars(guildName) 1084 | if err = os.MkdirAll(subfolder, 0755); err != nil { 1085 | log.Println(lg("Discord", "Emojis", color.HiRedString, "Error while creating subfolder \"%s\": %s", subfolder, err)) 1086 | } 1087 | 1088 | countDownloaded := 0 1089 | countSkipped := 0 1090 | countFailed := 0 1091 | for _, sticker := range guild.Stickers { 1092 | url := "https://media.discordapp.net/stickers/" + sticker.ID 1093 | 1094 | status, _ := downloadRequestStruct{ 1095 | InputURL: url, 1096 | Filename: dataKeysSticker(*sticker), 1097 | Path: subfolder, 1098 | Message: nil, 1099 | Channel: nil, 1100 | FileTime: time.Now(), 1101 | HistoryCmd: false, 1102 | EmojiCmd: true, 1103 | StartTime: time.Now(), 1104 | }.handleDownload() 1105 | 1106 | if status.Status == downloadSuccess { 1107 | countDownloaded++ 1108 | } else if status.Status == downloadSkippedDuplicate { 1109 | countSkipped++ 1110 | } else { 1111 | countFailed++ 1112 | log.Println(lg("Discord", "Stickers", color.HiRedString, 1113 | "Failed to download sticker \"%s\": \t[%d - %s] %v", 1114 | url, status.Status, getDownloadStatus(status.Status), status.Error)) 1115 | } 1116 | } 1117 | 1118 | // Log 1119 | destinationOut := destination 1120 | abs, err := filepath.Abs(destination) 1121 | if err == nil { 1122 | destinationOut = abs 1123 | } 1124 | log.Println(lg("Discord", "Stickers", color.HiMagentaString, 1125 | fmt.Sprintf("%d stickers downloaded, %d skipped, %d failed - Destination: %s", 1126 | countDownloaded, countSkipped, countFailed, destinationOut, 1127 | ))) 1128 | } 1129 | } 1130 | } 1131 | 1132 | } 1133 | 1134 | //#endregion 1135 | 1136 | //#region BOT LOGIN SEQUENCE 1137 | 1138 | func botLoadDiscord() { 1139 | var err error 1140 | 1141 | // Discord Login 1142 | connectBot := func() { 1143 | // Connect Bot 1144 | bot.LogLevel = -1 // to ignore dumb wsapi error 1145 | err = bot.Open() 1146 | if err != nil && !strings.Contains(strings.ToLower(err.Error()), "web socket already opened") { 1147 | log.Println(lg("Discord", "", color.HiRedString, "Discord login failed:\t%s", err)) 1148 | properExit() 1149 | } 1150 | 1151 | bot.LogLevel = config.DiscordLogLevel // reset 1152 | bot.ShouldReconnectOnError = true 1153 | dur, err := time.ParseDuration(fmt.Sprint(config.DiscordTimeout) + "s") 1154 | if err != nil { 1155 | dur, _ = time.ParseDuration("180s") 1156 | } 1157 | bot.Client.Timeout = dur 1158 | 1159 | bot.StateEnabled = true 1160 | bot.State.MaxMessageCount = 100000 1161 | bot.State.TrackChannels = true 1162 | bot.State.TrackThreads = true 1163 | bot.State.TrackMembers = true 1164 | bot.State.TrackThreadMembers = true 1165 | 1166 | botUser, err = bot.User("@me") 1167 | if err != nil { 1168 | botUser = bot.State.User 1169 | } 1170 | } 1171 | 1172 | discord_login_count := 0 1173 | do_discord_login: 1174 | discord_login_count++ 1175 | if discord_login_count > 1 { 1176 | time.Sleep(3 * time.Second) 1177 | } 1178 | 1179 | if config.Credentials.Token != "" && config.Credentials.Token != placeholderToken { 1180 | // Login via Token (Bot or User) 1181 | log.Println(lg("Discord", "", color.GreenString, "Connecting to Discord via Token...")) 1182 | // attempt login without Bot prefix 1183 | bot, err = discordgo.New(config.Credentials.Token) 1184 | connectBot() 1185 | if botUser.Bot { // is bot application, reconnect properly 1186 | //log.Println(lg("Discord", "", color.GreenString, "Reconnecting as bot...")) 1187 | bot, err = discordgo.New("Bot " + config.Credentials.Token) 1188 | } 1189 | 1190 | } else if (config.Credentials.Email != "" && config.Credentials.Email != placeholderEmail) && 1191 | (config.Credentials.Password != "" && config.Credentials.Password != placeholderPassword) { 1192 | // Login via Email+Password (User Only obviously) 1193 | log.Println(lg("Discord", "", color.GreenString, "Connecting via Login...")) 1194 | bot, err = discordgo.New(config.Credentials.Email, config.Credentials.Password) 1195 | } else { 1196 | if discord_login_count > 5 { 1197 | log.Println(lg("Discord", "", color.HiRedString, "No valid credentials for Discord...")) 1198 | properExit() 1199 | } else { 1200 | goto do_discord_login 1201 | } 1202 | } 1203 | if err != nil { 1204 | if discord_login_count > 5 { 1205 | log.Println(lg("Discord", "", color.HiRedString, "Error logging in: %s", err)) 1206 | properExit() 1207 | } else { 1208 | goto do_discord_login 1209 | } 1210 | } 1211 | 1212 | connectBot() 1213 | 1214 | // Fetch Bot's User Info 1215 | botUser, err = bot.User("@me") 1216 | if err != nil { 1217 | botUser = bot.State.User 1218 | if botUser == nil { 1219 | if discord_login_count > 5 { 1220 | log.Println(lg("Discord", "", color.HiRedString, "Error obtaining user details: %s", err)) 1221 | properExit() 1222 | } else { 1223 | goto do_discord_login 1224 | } 1225 | } 1226 | } else if botUser == nil { 1227 | if discord_login_count > 5 { 1228 | log.Println(lg("Discord", "", color.HiRedString, "No error encountered obtaining user details, but it's empty...")) 1229 | properExit() 1230 | } else { 1231 | goto do_discord_login 1232 | } 1233 | } else { 1234 | botReady = true 1235 | log.Println(lg("Discord", "", color.HiGreenString, "Logged into %s", getUserIdentifier(*botUser))) 1236 | if botUser.Bot { 1237 | log.Println(lg("Discord", "Info", color.HiMagentaString, "GENUINE DISCORD BOT APPLICATION")) 1238 | log.Println(lg("Discord", "Info", color.MagentaString, "~ This is the safest way to use this bot.")) 1239 | log.Println(lg("Discord", "Info", color.MagentaString, "~ INTENTS: Make sure you have all 3 intents enabled for this bot in the Discord Developer Portal.")) 1240 | log.Println(lg("Discord", "Info", color.MagentaString, "~ PRESENCE: Details don't work. Only activity and status.")) 1241 | log.Println(lg("Discord", "Info", color.MagentaString, "~ VISIBILITY: You can only see servers you have added the bot to, which requires you to be an admin or have an admin invite the bot.")) 1242 | } else { 1243 | log.Println(lg("Discord", "Info", color.HiYellowString, "!!! USER ACCOUNT / SELF-BOT !!!")) 1244 | log.Println(lg("Discord", "Info", color.HiMagentaString, "~ WARNING: Discord does NOT ALLOW automated user accounts (aka Self-Bots).")) 1245 | log.Println(lg("Discord", "Info", color.MagentaString, "~~~ By using this bot application with a user account, you potentially risk account termination.")) 1246 | log.Println(lg("Discord", "Info", color.MagentaString, "~~~ See the GitHub page for link to Discord's official statement.")) 1247 | log.Println(lg("Discord", "Info", color.MagentaString, "~~~ IF YOU WISH TO AVOID THIS, USE A BOT APPLICATION IF POSSIBLE.")) 1248 | log.Println(lg("Discord", "Info", color.HiMagentaString, "~ DISCORD API BUGS MAY OCCUR - KNOWN ISSUES:")) 1249 | log.Println(lg("Discord", "Info", color.MagentaString, "~~~ Can't see active threads, only archived threads.")) 1250 | log.Println(lg("Discord", "Info", color.HiMagentaString, "~ VISIBILITY: You can download from any channels/servers this account has access to.")) 1251 | } 1252 | } 1253 | if bot.State.User != nil { // is selfbot 1254 | selfbot = bot.State.User.Email != "" 1255 | } 1256 | 1257 | // Event Handlers 1258 | botCommands = handleCommands() 1259 | bot.AddHandler(messageCreate) 1260 | bot.AddHandler(messageUpdate) 1261 | 1262 | // Start Presence 1263 | timeLastUpdated = time.Now() 1264 | go updateDiscordPresence() 1265 | 1266 | //(SV) Source Validation 1267 | var invalidAdminChannels []string 1268 | var invalidServers []string 1269 | var invalidCategories []string 1270 | var invalidChannels []string 1271 | var missingPermsAdminChannels [][]string 1272 | var missingPermsCategories [][]string 1273 | var missingPermsChannels [][]string 1274 | log.Println(lg("Discord", "Validation", color.GreenString, "Validating your configured Discord sources...")) 1275 | 1276 | validateSource := func(checkFunc func(string) error, target string, label string, invalidStack *[]string) bool { 1277 | if err := checkFunc(target); err != nil { 1278 | *invalidStack = append(*invalidStack, target) 1279 | log.Println(lg("Discord", "Validation", color.HiRedString, 1280 | "Bot cannot access %s %s...\t%s", label, target, err)) 1281 | return false 1282 | } 1283 | return true 1284 | } 1285 | checkChannelPerm := func(perm int64, permName string, target string, label string, invalidStack *[][]string) { 1286 | if perms, err := bot.State.UserChannelPermissions(botUser.ID, target); err == nil { 1287 | if perms&perm == 0 { // lacks permission 1288 | *invalidStack = append(*invalidStack, []string{target, permName}) 1289 | log.Println(lg("Discord", "Validation", color.HiRedString, 1290 | "%s %s / %s - Lacks <%s>...", strings.ToUpper(label), target, getChannelLabel(target, nil), permName)) 1291 | } 1292 | } else if config.Debug { 1293 | log.Println(lg("Discord", "Validation", color.HiRedString, 1294 | "Encountered error checking Discord permission <%s> in %s %s / %s...\t%s", 1295 | permName, label, target, getChannelLabel(target, nil), err)) 1296 | } 1297 | } 1298 | 1299 | //(SV) Check Admin Channels 1300 | if config.AdminChannels != nil { 1301 | for _, adminChannel := range config.AdminChannels { 1302 | if adminChannel.ChannelIDs != nil { 1303 | for _, subchannel := range *adminChannel.ChannelIDs { 1304 | if validateSource(getChannelErr, subchannel, "admin subchannel", &invalidAdminChannels) { 1305 | checkChannelPerm(discordgo.PermissionViewChannel, "PermissionViewChannel", 1306 | subchannel, "admin subchannel", &missingPermsChannels) 1307 | checkChannelPerm(discordgo.PermissionSendMessages, "PermissionSendMessages", 1308 | subchannel, "admin subchannel", &missingPermsChannels) 1309 | } 1310 | } 1311 | } else { 1312 | if validateSource(getChannelErr, adminChannel.ChannelID, "admin channel", &invalidAdminChannels) { 1313 | checkChannelPerm(discordgo.PermissionViewChannel, "PermissionViewChannel", 1314 | adminChannel.ChannelID, "admin channel", &missingPermsChannels) 1315 | checkChannelPerm(discordgo.PermissionSendMessages, "PermissionSendMessages", 1316 | adminChannel.ChannelID, "admin channel", &missingPermsChannels) 1317 | } 1318 | } 1319 | } 1320 | } 1321 | //(SV) Check "servers" config.Servers 1322 | for _, server := range config.Servers { 1323 | if server.ServerIDs != nil { 1324 | for _, subserver := range *server.ServerIDs { 1325 | if validateSource(getServerErr, subserver, "subserver", &invalidServers) { 1326 | //TODO: tbd? 1327 | } 1328 | } 1329 | } else { 1330 | if validateSource(getServerErr, server.ServerID, "server", &invalidServers) { 1331 | //TODO: tbd? 1332 | } 1333 | } 1334 | } 1335 | //(SV) Check "categories" config.Categories 1336 | for _, category := range config.Categories { 1337 | if category.CategoryIDs != nil { 1338 | for _, subcategory := range *category.CategoryIDs { 1339 | if validateSource(getChannelErr, subcategory, "subcategory", &invalidCategories) { 1340 | checkChannelPerm(discordgo.PermissionViewChannel, "PermissionViewChannel", 1341 | subcategory, "subcategory", &missingPermsChannels) 1342 | } 1343 | } 1344 | 1345 | } else { 1346 | if validateSource(getChannelErr, category.CategoryID, "category", &invalidCategories) { 1347 | checkChannelPerm(discordgo.PermissionViewChannel, "PermissionViewChannel", 1348 | category.CategoryID, "category", &missingPermsChannels) 1349 | } 1350 | } 1351 | } 1352 | //(SV) Check "channels" config.Channels 1353 | for _, channel := range config.Channels { 1354 | if channel.ChannelIDs != nil { 1355 | for _, subchannel := range *channel.ChannelIDs { 1356 | if validateSource(getChannelErr, subchannel, "subchannel", &invalidChannels) { 1357 | checkChannelPerm(discordgo.PermissionViewChannel, "PermissionViewChannel", 1358 | subchannel, "subchannel", &missingPermsChannels) 1359 | checkChannelPerm(discordgo.PermissionReadMessageHistory, "PermissionReadMessageHistory", 1360 | subchannel, "subchannel", &missingPermsChannels) 1361 | if channel.ReactWhenDownloaded != nil { 1362 | if *channel.ReactWhenDownloaded { 1363 | checkChannelPerm(discordgo.PermissionAddReactions, "PermissionAddReactions", 1364 | subchannel, "subchannel", &missingPermsChannels) 1365 | } 1366 | } 1367 | } 1368 | } 1369 | 1370 | } else { 1371 | if validateSource(getChannelErr, channel.ChannelID, "channel", &invalidChannels) { 1372 | checkChannelPerm(discordgo.PermissionViewChannel, "PermissionViewChannel", 1373 | channel.ChannelID, "channel", &missingPermsChannels) 1374 | checkChannelPerm(discordgo.PermissionReadMessageHistory, "PermissionReadMessageHistory", 1375 | channel.ChannelID, "channel", &missingPermsChannels) 1376 | if channel.ReactWhenDownloaded != nil { 1377 | if *channel.ReactWhenDownloaded { 1378 | checkChannelPerm(discordgo.PermissionAddReactions, "PermissionAddReactions", 1379 | channel.ChannelID, "channel", &missingPermsChannels) 1380 | } 1381 | } 1382 | } 1383 | } 1384 | } 1385 | //(SV) NOTE: No validation for users because no way to do that by just user ID from what I've seen. 1386 | 1387 | //(SV) Output Invalid Sources 1388 | invalidSources := len(invalidAdminChannels) + len(invalidChannels) + len(invalidCategories) + len(invalidServers) 1389 | if invalidSources > 0 { 1390 | log.Println(lg("Discord", "Validation", color.HiRedString, 1391 | "Found %d invalid sources in configuration...", invalidSources)) 1392 | logMsg := fmt.Sprintf("Validation found %d invalid sources...\n", invalidSources) 1393 | if len(invalidAdminChannels) > 0 { 1394 | logMsg += fmt.Sprintf("\n**- Admin Channels: (%d)** - %s", 1395 | len(invalidAdminChannels), strings.Join(invalidAdminChannels, ", ")) 1396 | } 1397 | if len(invalidServers) > 0 { 1398 | logMsg += fmt.Sprintf("\n**- Download Servers: (%d)** - %s", 1399 | len(invalidServers), strings.Join(invalidServers, ", ")) 1400 | } 1401 | if len(invalidCategories) > 0 { 1402 | logMsg += fmt.Sprintf("\n**- Download Categories: (%d)** - %s", 1403 | len(invalidCategories), strings.Join(invalidCategories, ", ")) 1404 | } 1405 | if len(invalidChannels) > 0 { 1406 | logMsg += fmt.Sprintf("\n**- Download Channels: (%d)** - %s", 1407 | len(invalidChannels), strings.Join(invalidChannels, ", ")) 1408 | } 1409 | sendErrorMessage(logMsg) 1410 | } else { 1411 | log.Println(lg("Discord", "Validation", color.HiGreenString, 1412 | "No ID issues detected! Bot can see all configured Discord sources, but that doesn't check Discord permissions...")) 1413 | } 1414 | //(SV) Output Discord Permission Issues 1415 | missingPermsSources := len(missingPermsAdminChannels) + len(missingPermsCategories) + len(missingPermsChannels) 1416 | if missingPermsSources > 0 { 1417 | log.Println(lg("Discord", "Validation", color.HiRedString, 1418 | "Found %d sources with insufficient Discord permissions...", missingPermsSources)) 1419 | log.Println(lg("Discord", "Validation", color.HiRedString, 1420 | "The bot will still function, these are just warnings...")) 1421 | } else { 1422 | log.Println(lg("Discord", "Validation", color.HiGreenString, 1423 | "No permission issues detected! Bot seems to have all required Discord permissions.")) 1424 | } 1425 | 1426 | mainWg.Done() 1427 | } 1428 | 1429 | //#endregion 1430 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/get-got/discord-downloader-go 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/AvraamMavridis/randomcolor v0.0.0-20180822172341-208aff70bf2c 7 | github.com/Davincible/goinsta/v3 v3.2.6 8 | github.com/HouzuoGuo/tiedot v0.0.0-20210905174726-ae1e16866d06 9 | github.com/Necroforger/dgrouter v0.0.0-20200517224846-e66453b957c1 10 | github.com/PuerkitoBio/goquery v1.10.3 11 | github.com/aidarkhanov/nanoid/v2 v2.0.5 12 | github.com/bwmarrin/discordgo v0.28.1 13 | github.com/dustin/go-humanize v1.0.1 14 | github.com/fatih/color v1.18.0 15 | github.com/fsnotify/fsnotify v1.9.0 16 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b 17 | github.com/hashicorp/go-version v1.7.0 18 | github.com/imperatrona/twitter-scraper v0.0.18 19 | github.com/muhammadmuzzammil1998/jsonc v1.0.0 20 | github.com/rivo/duplo v0.0.0-20220703183130-751e882e6b83 21 | github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 22 | github.com/wk8/go-ordered-map/v2 v2.1.8 23 | golang.org/x/text v0.24.0 24 | gopkg.in/ini.v1 v1.67.0 25 | gopkg.in/yaml.v3 v3.0.1 26 | mvdan.cc/xurls/v2 v2.6.0 27 | ) 28 | 29 | require ( 30 | github.com/AlexEidt/Vidio v1.5.1 // indirect 31 | github.com/andybalholm/cascadia v1.3.3 // indirect 32 | github.com/bahlo/generic-list-go v0.2.0 // indirect 33 | github.com/buger/jsonparser v1.1.1 // indirect 34 | github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 // indirect 35 | github.com/chromedp/chromedp v0.13.6 // indirect 36 | github.com/chromedp/sysutil v1.1.0 // indirect 37 | github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 // indirect 38 | github.com/gobwas/httphead v0.1.0 // indirect 39 | github.com/gobwas/pool v0.2.1 // indirect 40 | github.com/gobwas/ws v1.4.0 // indirect 41 | github.com/gorilla/websocket v1.5.3 // indirect 42 | github.com/mailru/easyjson v0.9.0 // indirect 43 | github.com/mattn/go-colorable v0.1.14 // indirect 44 | github.com/mattn/go-isatty v0.0.20 // indirect 45 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect 46 | github.com/pkg/errors v0.9.1 // indirect 47 | github.com/stretchr/testify v1.10.0 // indirect 48 | golang.org/x/crypto v0.37.0 // indirect 49 | golang.org/x/net v0.39.0 // indirect 50 | golang.org/x/sys v0.32.0 // indirect 51 | ) 52 | 53 | replace github.com/bwmarrin/discordgo => github.com/get-got/discordgo v0.27.0-gg.4 54 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/AlexEidt/Vidio v1.5.1 h1:tovwvtgQagUz1vifiL9OeWkg1fP/XUzFazFKh7tFtaE= 2 | github.com/AlexEidt/Vidio v1.5.1/go.mod h1:djhIMnWMqPrC3X6nB6ymGX6uWWlgw+VayYGKE1bNwmI= 3 | github.com/AvraamMavridis/randomcolor v0.0.0-20180822172341-208aff70bf2c h1:XLynE8YGJdvPN65iI+G+Ys5ZUVS6YxWk8WPe/FmBReg= 4 | github.com/AvraamMavridis/randomcolor v0.0.0-20180822172341-208aff70bf2c/go.mod h1:vX+Cl5GOtK2DkzgsggLoeNUbxAcUWBaybCKzVRYsRMo= 5 | github.com/Davincible/goinsta/v3 v3.2.6 h1:+lNIWU6NABWd2VSGe83UQypnef+kzWwjmfgGihPbwD8= 6 | github.com/Davincible/goinsta/v3 v3.2.6/go.mod h1:jIDhrWZmttL/gtXj/mkCaZyeNdAAqW3UYjasOUW0YEw= 7 | github.com/HouzuoGuo/tiedot v0.0.0-20210905174726-ae1e16866d06 h1:FSsxozhq5B9sstCWB1WMvZU/j0zKFFga0F6Wo5+9DGg= 8 | github.com/HouzuoGuo/tiedot v0.0.0-20210905174726-ae1e16866d06/go.mod h1:J2FcoVwTshOscfh8D4LCCVRoHJJQTeCAEkeRSVGnLQs= 9 | github.com/Necroforger/dgrouter v0.0.0-20200517224846-e66453b957c1 h1:3OHJOlf0r1CVSA1E3Ts4uLWsCnucYndMRjNk4rFiQdE= 10 | github.com/Necroforger/dgrouter v0.0.0-20200517224846-e66453b957c1/go.mod h1:FdMxPfOp4ppZW2OJjLagSMri7g5k9luvTm7Y3aIxQSc= 11 | github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= 12 | github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= 13 | github.com/aidarkhanov/nanoid/v2 v2.0.5 h1:HLx5RyDuvOZ6YxlhYTxSU8Il+q7xVKmXM62MfSxziN0= 14 | github.com/aidarkhanov/nanoid/v2 v2.0.5/go.mod h1:YF/U48D1yA3AoGGUdRrCV95J/KJBShvR9TyLqQwdtlI= 15 | github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= 16 | github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= 17 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 18 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 19 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 20 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 21 | github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvPlUSEFlE794XZL4Jmrj8fuxfcLECJE= 22 | github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= 23 | github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk= 24 | github.com/chromedp/chromedp v0.13.6/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A= 25 | github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= 26 | github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= 27 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 28 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 29 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 30 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 31 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 32 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 33 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 34 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 35 | github.com/get-got/discordgo v0.27.0-gg.4 h1:Z/a2+UNuirBq6tD/3Td3ReZl4kBLIFRss3bA+6knWrw= 36 | github.com/get-got/discordgo v0.27.0-gg.4/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= 37 | github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 h1:+VexzzkMLb1tnvpuQdGT/DicIRW7MN8ozsXqBMgp0Hk= 38 | github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 39 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 40 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 41 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 42 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 43 | github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= 44 | github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 45 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 46 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 47 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 48 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 49 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 50 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= 51 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= 52 | github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= 53 | github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 54 | github.com/imperatrona/twitter-scraper v0.0.18 h1:CdDjYCXUaf526SlWnVWHPk3zZa5gKVHGCDDJE9SA8Rk= 55 | github.com/imperatrona/twitter-scraper v0.0.18/go.mod h1:38MY3g/h4V7Xl4HbW9lnkL8S3YiFZenBFv86hN57RG8= 56 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= 57 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= 58 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 59 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 60 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 61 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 62 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 63 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 64 | github.com/muhammadmuzzammil1998/jsonc v1.0.0 h1:8o5gBQn4ZA3NBA9DlTujCj2a4w0tqWrPVjDwhzkgTIs= 65 | github.com/muhammadmuzzammil1998/jsonc v1.0.0/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU= 66 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 67 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 68 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= 69 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= 70 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 71 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 72 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 73 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 74 | github.com/rivo/duplo v0.0.0-20220703183130-751e882e6b83 h1:EmV3gpPYy9yutsoN/DBs1vzinL2FBvNqwFBVnUr0Rfs= 75 | github.com/rivo/duplo v0.0.0-20220703183130-751e882e6b83/go.mod h1:gw8DEItjXFxacZzluOv7azm5G22Vvx/OBZb7Wqoqp9M= 76 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 77 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 78 | github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 h1:xzABM9let0HLLqFypcxvLmlvEciCHL7+Lv+4vwZqecI= 79 | github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569/go.mod h1:2Ly+NIftZN4de9zRmENdYbvPQeaVIYKWpLFStLFEBgI= 80 | github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 81 | github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 82 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 83 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 84 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 85 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 86 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 87 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 88 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 89 | golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= 90 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 91 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 92 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 93 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 94 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 95 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 96 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 97 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 98 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 99 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 100 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 101 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 102 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 103 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 104 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 105 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 106 | golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= 107 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 108 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 109 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 110 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 111 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 112 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 113 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 114 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 115 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 116 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 117 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 118 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 119 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 120 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 121 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 122 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 123 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 125 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 126 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 127 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 128 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 129 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 130 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 131 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 132 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 133 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 134 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 135 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 136 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 137 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 138 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 139 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 140 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 141 | golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= 142 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 143 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 144 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 145 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 146 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 147 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 148 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 149 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 150 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 151 | golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 152 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 153 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 154 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 155 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 156 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 157 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 158 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 159 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 160 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 161 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 162 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 163 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 164 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 165 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 166 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 167 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 168 | mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= 169 | mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= 170 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/bwmarrin/discordgo" 12 | "github.com/fatih/color" 13 | ) 14 | 15 | //#region Events 16 | 17 | var lastMessageID string 18 | 19 | func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { 20 | if lastMessageID != m.ID { 21 | handleMessage(m.Message, nil, false, false) 22 | } 23 | lastMessageID = m.ID 24 | } 25 | 26 | func messageUpdate(s *discordgo.Session, m *discordgo.MessageUpdate) { 27 | if lastMessageID != m.ID { 28 | if m.EditedTimestamp != nil { 29 | handleMessage(m.Message, nil, true, false) 30 | } 31 | } 32 | lastMessageID = m.ID 33 | } 34 | 35 | func handleMessage(m *discordgo.Message, c *discordgo.Channel, edited bool, history bool) []downloadedItem { 36 | shouldBail := false //TODO: this is messy, overlapped purpose with shouldAbort used for filters down below in this func. 37 | shouldBailReason := "" 38 | // Ignore own messages unless told not to 39 | if m.Author.ID == botUser.ID && !config.ScanOwnMessages { 40 | shouldBail = true 41 | shouldBailReason = "config.ScanOwnMessages" 42 | } 43 | 44 | if !history && !edited { 45 | timeLastMessage = time.Now() 46 | } 47 | 48 | // Admin Channel 49 | if isAdminChannelRegistered(m.ChannelID) { 50 | m = fixMessage(m) 51 | 52 | // Log 53 | sendLabel := fmt.Sprintf("%s in \"%s\"#%s", 54 | getUserIdentifier(*m.Author), 55 | getServerLabel(m.GuildID), getChannelLabel(m.ChannelID, nil), 56 | ) 57 | content := m.Content 58 | if len(m.Attachments) > 0 { 59 | content = content + fmt.Sprintf(" (%d attachments)", len(m.Attachments)) 60 | } 61 | if edited { 62 | log.Println(lg("Message", "ADMIN CHANNEL", color.CyanString, "Edited [%s]: %s", sendLabel, content)) 63 | } else { 64 | log.Println(lg("Message", "ADMIN CHANNEL", color.CyanString, "[%s]: %s", sendLabel, content)) 65 | } 66 | } 67 | 68 | // Registered Channel 69 | if sourceConfig := getSource(m); sourceConfig != emptySourceConfig { 70 | // Ignore bots if told to do so 71 | if m.Author.Bot && *sourceConfig.IgnoreBots { 72 | shouldBail = true 73 | shouldBailReason = "config.IgnoreBots" 74 | } 75 | // Ignore if told so by config 76 | if (!history && !*sourceConfig.Enabled) || (edited && !*sourceConfig.ScanEdits) { 77 | shouldBail = true 78 | shouldBailReason = "config.ScanEdits" 79 | } 80 | 81 | // Bail due to basic config rules 82 | if shouldBail { 83 | if config.Debug { 84 | log.Println(lg("Debug", "Message", color.YellowString, 85 | "%s Ignoring message due to %s...", color.HiMagentaString("(CONFIG)"), shouldBailReason)) 86 | } 87 | return nil 88 | } 89 | 90 | m = fixMessage(m) 91 | 92 | // Log 93 | if config.MessageOutput { 94 | sendLabel := fmt.Sprintf("%s in \"%s\"#%s", 95 | getUserIdentifier(*m.Author), 96 | getServerLabel(m.GuildID), getChannelLabel(m.ChannelID, nil), 97 | ) 98 | content := m.Content 99 | if len(m.Attachments) > 0 { 100 | content += fmt.Sprintf(" \t[%d attachments]", len(m.Attachments)) 101 | } 102 | content += fmt.Sprintf(" \t<%s>", m.ID) 103 | 104 | if !history || config.MessageOutputHistory { 105 | addOut := "" 106 | if history && config.MessageOutputHistory && !m.Timestamp.IsZero() { 107 | addOut = fmt.Sprintf(" @ %s", discordSnowflakeToTimestamp(m.ID, "2006-01-02 15-04-05")) 108 | } 109 | if edited { 110 | log.Println(lg("Message", "", color.CyanString, "Edited [%s%s]: %s", sendLabel, addOut, content)) 111 | } else { 112 | log.Println(lg("Message", "", color.CyanString, "[%s%s]: %s", sendLabel, addOut, content)) 113 | } 114 | } 115 | } 116 | 117 | // Log Messages to File 118 | if sourceConfig.LogMessages != nil { 119 | if sourceConfig.LogMessages.Destination != "" { 120 | encounteredErrors := false 121 | savePath := sourceConfig.LogMessages.Destination + string(os.PathSeparator) 122 | 123 | // Subfolder Division - Format Subfolders 124 | if sourceConfig.LogMessages.Subfolders != nil { 125 | subfolders := []string{} 126 | for _, subfolder := range *sourceConfig.LogMessages.Subfolders { 127 | newSubfolder := dataKeys_DiscordMessage(subfolder, m) 128 | 129 | // Scrub subfolder 130 | newSubfolder = clearSourceLogField(newSubfolder, *sourceConfig.LogMessages) 131 | 132 | // Do Fallback if a line contains an unparsed key (if fallback exists). 133 | if strings.Contains(newSubfolder, "{{") && strings.Contains(newSubfolder, "}}") && 134 | sourceConfig.LogMessages.SubfoldersFallback != nil { 135 | subfolders = []string{} 136 | for _, subfolder2 := range *sourceConfig.LogMessages.SubfoldersFallback { 137 | newSubfolder2 := dataKeys_DiscordMessage(subfolder2, m) 138 | 139 | // Scrub subfolder 140 | newSubfolder2 = clearSourceLogField(newSubfolder2, *sourceConfig.LogMessages) 141 | 142 | subfolders = append(subfolders, newSubfolder2) 143 | } 144 | break 145 | } else { 146 | subfolders = append(subfolders, newSubfolder) 147 | } 148 | } 149 | 150 | // Subfolder Dividion - Handle Formatted Subfolders 151 | subpath := "" 152 | for _, subfolder := range subfolders { 153 | subpath = subpath + subfolder + string(os.PathSeparator) 154 | // Create folder 155 | if err := os.MkdirAll(filepath.Clean(savePath+subpath), 0755); err != nil { 156 | log.Println(lg("LogMessages", "", color.HiRedString, 157 | "Error while creating subfolder \"%s\": %s", savePath+subpath, err)) 158 | encounteredErrors = true 159 | } 160 | } 161 | // Format Path 162 | savePath = filepath.Clean(savePath + string(os.PathSeparator) + subpath) // overwrite with new destination path 163 | } 164 | 165 | if !encounteredErrors { 166 | if _, err := os.Stat(savePath); err != nil { 167 | log.Println(lg("LogMessages", "", color.HiRedString, 168 | "Save path %s is invalid... %s", savePath, err)) 169 | } else { 170 | // Format filename 171 | filename := m.ChannelID + ".txt" 172 | if sourceConfig.LogMessages.FilenameFormat != nil { 173 | if *sourceConfig.LogMessages.FilenameFormat != "" { 174 | filename = dataKeys_DiscordMessage(*sourceConfig.LogMessages.FilenameFormat, m) 175 | // if extension presumed missing 176 | if !strings.Contains(filename, ".") { 177 | filename += ".txt" 178 | } 179 | } 180 | } 181 | 182 | // Scrub filename 183 | filename = clearSourceLogField(filename, *sourceConfig.LogMessages) 184 | 185 | // Build path 186 | logPath := filepath.Clean(savePath + string(os.PathSeparator) + filename) 187 | 188 | // Prepend 189 | prefix := "" 190 | if sourceConfig.LogMessages.LinePrefix != nil { 191 | prefix = *sourceConfig.LogMessages.LinePrefix 192 | } 193 | prefix = dataKeys_DiscordMessage(prefix, m) 194 | 195 | // Append 196 | suffix := "" 197 | if sourceConfig.LogMessages.LineSuffix != nil { 198 | suffix = *sourceConfig.LogMessages.LineSuffix 199 | } 200 | suffix = dataKeys_DiscordMessage(suffix, m) 201 | 202 | // New Line 203 | var newLine string 204 | msgContent := m.Content 205 | if contentFmt, err := m.ContentWithMoreMentionsReplaced(bot); err == nil { 206 | msgContent = contentFmt 207 | } 208 | lineContent := msgContent 209 | if sourceConfig.LogMessages.LineContent != nil { 210 | lineContent = *sourceConfig.LogMessages.LineContent 211 | } 212 | keys := [][]string{ 213 | {"{{message}}", msgContent}, 214 | } 215 | for _, key := range keys { 216 | if strings.Contains(lineContent, key[0]) { 217 | lineContent = strings.ReplaceAll(lineContent, key[0], key[1]) 218 | } 219 | } 220 | newLine += "\n" + prefix + lineContent + suffix 221 | 222 | // Read 223 | currentLog := "" 224 | if logfile, err := os.ReadFile(logPath); err == nil { 225 | currentLog = string(logfile) 226 | } 227 | canLog := true 228 | // Filter Duplicates 229 | if sourceConfig.LogMessages.FilterDuplicates != nil { 230 | if *sourceConfig.LogMessages.FilterDuplicates { 231 | if strings.Contains(currentLog, newLine) { 232 | canLog = false 233 | } 234 | } 235 | } 236 | 237 | if canLog { 238 | // Writer 239 | f, err := os.OpenFile(logPath, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0600) 240 | if err != nil { 241 | log.Println(lg("LogMessages", "", color.RedString, "[sourceConfig.LogMessages] Failed to open log file:\t%s", err)) 242 | f.Close() 243 | } 244 | defer f.Close() 245 | 246 | if _, err = f.WriteString(newLine); err != nil { 247 | log.Println(lg("Message", "", color.RedString, "[sourceConfig.LogMessages] Failed to append file:\t%s", err)) 248 | } 249 | } 250 | } 251 | } 252 | } 253 | } 254 | 255 | // Filters 256 | if sourceConfig.Filters != nil { 257 | shouldAbort := false 258 | 259 | if sourceConfig.Filters.AllowedPhrases != nil || 260 | sourceConfig.Filters.AllowedUsers != nil || 261 | sourceConfig.Filters.AllowedRoles != nil { 262 | shouldAbort = true 263 | if config.Debug { 264 | log.Println(lg("Debug", "Message", color.YellowString, 265 | "%s Filter will be ignoring by default...", 266 | color.HiMagentaString("(FILTER)"))) 267 | } 268 | } 269 | 270 | if sourceConfig.Filters.BlockedPhrases != nil { 271 | for _, phrase := range *sourceConfig.Filters.BlockedPhrases { 272 | if strings.Contains(m.Content, phrase) && phrase != "" { 273 | shouldAbort = true 274 | if config.Debug { 275 | log.Println(lg("Debug", "Message", color.YellowString, 276 | "%s blockedPhrases found \"%s\" in message, planning to abort...", 277 | color.HiMagentaString("(FILTER)"), phrase)) 278 | } 279 | break 280 | } 281 | } 282 | } 283 | if sourceConfig.Filters.AllowedPhrases != nil { 284 | for _, phrase := range *sourceConfig.Filters.AllowedPhrases { 285 | if strings.Contains(m.Content, phrase) && phrase != "" { 286 | shouldAbort = false 287 | if config.Debug { 288 | log.Println(lg("Debug", "Message", color.YellowString, 289 | "%s allowedPhrases found \"%s\" in message, planning to process...", 290 | color.HiMagentaString("(FILTER)"), phrase)) 291 | } 292 | break 293 | } 294 | } 295 | } 296 | 297 | if sourceConfig.Filters.BlockedUsers != nil { 298 | if stringInSlice(m.Author.ID, *sourceConfig.Filters.BlockedUsers) { 299 | shouldAbort = true 300 | if config.Debug { 301 | log.Println(lg("Debug", "Message", color.YellowString, 302 | "%s blockedUsers caught %s, planning to abort...", 303 | color.HiMagentaString("(FILTER)"), m.Author.ID)) 304 | } 305 | } 306 | } 307 | if sourceConfig.Filters.AllowedUsers != nil { 308 | if stringInSlice(m.Author.ID, *sourceConfig.Filters.AllowedUsers) { 309 | shouldAbort = false 310 | if config.Debug { 311 | log.Println(lg("Debug", "Message", color.YellowString, 312 | "%s allowedUsers caught %s, planning to process...", 313 | color.HiMagentaString("(FILTER)"), m.Author.ID)) 314 | } 315 | } 316 | } 317 | 318 | if sourceConfig.Filters.BlockedRoles != nil { 319 | member := m.Member 320 | if member == nil { 321 | member, _ = bot.GuildMember(m.GuildID, m.Author.ID) 322 | } 323 | if member != nil { 324 | for _, role := range member.Roles { 325 | if stringInSlice(role, *sourceConfig.Filters.BlockedRoles) { 326 | shouldAbort = true 327 | if config.Debug { 328 | log.Println(lg("Debug", "Message", color.YellowString, 329 | "%s blockedRoles caught %s, planning to abort...", 330 | color.HiMagentaString("(FILTER)"), role)) 331 | } 332 | break 333 | } 334 | } 335 | } 336 | } 337 | if sourceConfig.Filters.AllowedRoles != nil { 338 | member := m.Member 339 | if member == nil { 340 | member, _ = bot.GuildMember(m.GuildID, m.Author.ID) 341 | } 342 | if member != nil { 343 | for _, role := range member.Roles { 344 | if stringInSlice(role, *sourceConfig.Filters.AllowedRoles) { 345 | shouldAbort = false 346 | if config.Debug { 347 | log.Println(lg("Debug", "Message", color.YellowString, 348 | "%s allowedRoles caught %s, planning to allow...", 349 | color.HiMagentaString("(FILTER)"), role)) 350 | } 351 | break 352 | } 353 | } 354 | } 355 | } 356 | 357 | // Abort 358 | if shouldAbort { 359 | if config.Debug { 360 | log.Println(lg("Debug", "Message", color.YellowString, 361 | "%s Filter decided to ignore message...", 362 | color.HiMagentaString("(FILTER)"))) 363 | } 364 | return nil 365 | } 366 | } 367 | 368 | // Delays 369 | delay := 0 370 | if history { 371 | if sourceConfig.DelayHandlingHistory != nil { 372 | delay = *sourceConfig.DelayHandlingHistory 373 | } 374 | } else { 375 | if sourceConfig.DelayHandling != nil { 376 | delay = *sourceConfig.DelayHandling 377 | } 378 | } 379 | if delay > 0 { 380 | if config.Debug { 381 | log.Println(lg("Debug", "Message", color.YellowString, "Delaying for %d milliseconds...", delay)) 382 | } 383 | time.Sleep(time.Duration(delay) * time.Millisecond) 384 | } 385 | 386 | // Process Collected Links 387 | var downloadedItems []downloadedItem 388 | files := getLinksByMessage(m) 389 | for _, file := range files { 390 | // Blank link? 391 | if file.Link == "" { 392 | continue 393 | } 394 | if (*sourceConfig.IgnoreEmojis && strings.HasPrefix(file.Link, "https://cdn.discordapp.com/emojis/")) || 395 | (*sourceConfig.IgnoreStickers && strings.HasPrefix(file.Link, "https://media.discordapp.net/stickers/")) { 396 | continue 397 | } 398 | // Filter Checks 399 | shouldAbort := false 400 | if sourceConfig.Filters.BlockedLinkContent != nil { 401 | for _, phrase := range *sourceConfig.Filters.BlockedLinkContent { 402 | if strings.Contains(file.Link, phrase) && phrase != "" { 403 | shouldAbort = true 404 | if config.Debug { 405 | log.Println(lg("Debug", "Message", color.YellowString, 406 | "%s blockedLinkContent found \"%s\" in link, planning to abort...", 407 | color.HiMagentaString("(FILTER)"), phrase)) 408 | } 409 | break 410 | } 411 | } 412 | } 413 | if sourceConfig.Filters.AllowedLinkContent != nil { 414 | for _, phrase := range *sourceConfig.Filters.AllowedLinkContent { 415 | if strings.Contains(file.Link, phrase) && phrase != "" { 416 | shouldAbort = false 417 | if config.Debug { 418 | log.Println(lg("Debug", "Message", color.YellowString, 419 | "%s allowedLinkContent found \"%s\" in link, planning to process...", 420 | color.HiMagentaString("(FILTER)"), phrase)) 421 | } 422 | break 423 | } 424 | } 425 | } 426 | if shouldAbort { 427 | if config.Debug { 428 | log.Println(lg("Debug", "Message", color.YellowString, 429 | "%s Filter decided to ignore link...", 430 | color.HiMagentaString("(FILTER)"))) 431 | } 432 | continue 433 | } 434 | // Output 435 | if config.Debug && (!history || config.MessageOutputHistory) { 436 | log.Println(lg("Debug", "Message", color.HiCyanString, "FOUND FILE: "+file.Link+fmt.Sprintf(" \t<%s>", m.ID))) 437 | } 438 | // Handle Download 439 | status, filesize := downloadRequestStruct{ 440 | InputURL: file.Link, 441 | Filename: file.Filename, 442 | Path: sourceConfig.Destination, 443 | Message: m, 444 | Channel: c, 445 | FileTime: file.Time, 446 | HistoryCmd: history, 447 | EmojiCmd: false, 448 | StartTime: time.Now(), 449 | AttachmentID: file.AttachmentID, 450 | }.handleDownload() 451 | // Await Status 452 | if status.Status == downloadSuccess { 453 | domain, _ := getDomain(file.Link) 454 | downloadedItems = append(downloadedItems, downloadedItem{ 455 | URL: file.Link, 456 | Domain: domain, 457 | Filesize: filesize, 458 | }) 459 | } 460 | } 461 | return downloadedItems 462 | } 463 | 464 | return nil 465 | } 466 | 467 | //#endregion 468 | -------------------------------------------------------------------------------- /history.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/bwmarrin/discordgo" 12 | "github.com/dustin/go-humanize" 13 | "github.com/fatih/color" 14 | orderedmap "github.com/wk8/go-ordered-map/v2" 15 | ) 16 | 17 | type historyStatus int 18 | 19 | const ( 20 | historyStatusWaiting historyStatus = iota 21 | historyStatusRunning 22 | historyStatusAbortRequested 23 | historyStatusAbortCompleted 24 | historyStatusErrorReadMessageHistoryPerms 25 | historyStatusErrorRequesting 26 | historyStatusCompletedNoMoreMessages 27 | historyStatusCompletedToBeforeFilter 28 | historyStatusCompletedToSinceFilter 29 | ) 30 | 31 | func historyStatusLabel(status historyStatus) string { 32 | switch status { 33 | case historyStatusWaiting: 34 | return "Waiting..." 35 | case historyStatusRunning: 36 | return "Currently Downloading..." 37 | case historyStatusAbortRequested: 38 | return "Abort Requested..." 39 | case historyStatusAbortCompleted: 40 | return "Aborted..." 41 | case historyStatusErrorReadMessageHistoryPerms: 42 | return "ERROR: Cannot Read Message History" 43 | case historyStatusErrorRequesting: 44 | return "ERROR: Message Requests Failed" 45 | case historyStatusCompletedNoMoreMessages: 46 | return "COMPLETE: No More Messages" 47 | case historyStatusCompletedToBeforeFilter: 48 | return "COMPLETE: Exceeded Before Date Filter" 49 | case historyStatusCompletedToSinceFilter: 50 | return "COMPLETE: Exceeded Since Date Filter" 51 | default: 52 | return "Unknown" 53 | } 54 | } 55 | 56 | type historyJob struct { 57 | Status historyStatus 58 | OriginUser string 59 | OriginChannel string 60 | TargetCommandingMessage *discordgo.Message 61 | TargetChannelID string 62 | TargetBefore string 63 | TargetSince string 64 | DownloadCount int64 65 | DownloadSize int64 66 | Updated time.Time 67 | Added time.Time 68 | } 69 | 70 | var ( 71 | historyJobs *orderedmap.OrderedMap[string, historyJob] 72 | historyJobCnt int 73 | historyJobCntWaiting int 74 | historyJobCntRunning int 75 | historyJobCntAborted int 76 | historyJobCntErrored int 77 | historyJobCntCompleted int 78 | ) 79 | 80 | // TODO: cleanup 81 | type historyCache struct { 82 | Updated time.Time 83 | Running bool 84 | RunningBefore string // messageID for last before range attempted if interrupted 85 | CompletedSince string // messageID for last message the bot has 100% assumed completion on (since start of channel) 86 | } 87 | 88 | func handleHistory(commandingMessage *discordgo.Message, subjectChannelID string, before string, since string) int { 89 | var err error 90 | 91 | historyStartTime := time.Now() 92 | 93 | var historyDownloadDuration time.Duration 94 | 95 | // Log Prefix 96 | var commander string = "AUTORUN" 97 | var autorun bool = true 98 | if commandingMessage != nil { // Only time commandingMessage is nil is Autorun 99 | commander = getUserIdentifier(*commandingMessage.Author) 100 | autorun = false 101 | } 102 | logPrefix := fmt.Sprintf("%s/%s: ", subjectChannelID, commander) 103 | 104 | // Skip Requested 105 | if job, exists := historyJobs.Get(subjectChannelID); exists && job.Status != historyStatusWaiting { 106 | log.Println(lg("History", "", color.RedString, logPrefix+"History job skipped, Status: %s", historyStatusLabel(job.Status))) 107 | return -1 108 | } 109 | 110 | // Vars 111 | baseChannelInfo, err := bot.State.Channel(subjectChannelID) 112 | if err != nil { 113 | baseChannelInfo, err = bot.Channel(subjectChannelID) 114 | if err != nil { 115 | log.Println(lg("History", "", color.HiRedString, logPrefix+"Error fetching channel data from discordgo:\t%s", err)) 116 | } 117 | } 118 | 119 | var totalMessages int64 = 0 120 | var totalDownloads int64 = 0 121 | var totalFilesize int64 = 0 122 | var messageRequestCount int = 0 123 | 124 | sourceMessage := discordgo.Message{} // dummy message 125 | sourceMessage.ChannelID = subjectChannelID 126 | sourceMessage.GuildID = baseChannelInfo.GuildID 127 | sourceConfig := getSource(&sourceMessage) 128 | 129 | var responseMsg *discordgo.Message = nil 130 | 131 | guildName := getServerLabel(baseChannelInfo.GuildID) 132 | categoryName := getCategoryLabel(baseChannelInfo.ID) 133 | 134 | subjectChannels := []discordgo.Channel{} 135 | 136 | // Check channel type 137 | baseChannelIsForum := true 138 | if baseChannelInfo.Type != discordgo.ChannelTypeGuildCategory && 139 | baseChannelInfo.Type != discordgo.ChannelTypeGuildForum && 140 | baseChannelInfo.Type != discordgo.ChannelTypeGuildStageVoice && 141 | baseChannelInfo.Type != discordgo.ChannelTypeGuildVoice && 142 | baseChannelInfo.Type != discordgo.ChannelTypeGuildStore { 143 | subjectChannels = append(subjectChannels, *baseChannelInfo) 144 | baseChannelIsForum = false 145 | } 146 | 147 | // Index Threads 148 | indexedThreads := map[string]bool{} 149 | if threads, err := bot.ThreadsActive(subjectChannelID); err == nil { 150 | for _, thread := range threads.Threads { 151 | if indexedThreads[thread.ID] { 152 | continue 153 | } 154 | subjectChannels = append(subjectChannels, *thread) 155 | indexedThreads[thread.ID] = true 156 | } 157 | } 158 | if threads, err := bot.ThreadsArchived(subjectChannelID, nil, 0); err == nil { 159 | for _, thread := range threads.Threads { 160 | if indexedThreads[thread.ID] { 161 | continue 162 | } 163 | subjectChannels = append(subjectChannels, *thread) 164 | indexedThreads[thread.ID] = true 165 | } 166 | } 167 | if threads, err := bot.ThreadsPrivateArchived(subjectChannelID, nil, 0); err == nil { 168 | for _, thread := range threads.Threads { 169 | if indexedThreads[thread.ID] { 170 | continue 171 | } 172 | subjectChannels = append(subjectChannels, *thread) 173 | indexedThreads[thread.ID] = true 174 | } 175 | } 176 | if threads, err := bot.ThreadsPrivateJoinedArchived(subjectChannelID, nil, 0); err == nil { 177 | for _, thread := range threads.Threads { 178 | if indexedThreads[thread.ID] { 179 | continue 180 | } 181 | subjectChannels = append(subjectChannels, *thread) 182 | indexedThreads[thread.ID] = true 183 | } 184 | } 185 | 186 | // Send Status? 187 | var sendStatus bool = true 188 | if (autorun && !config.SendAutoHistoryStatus) || (!autorun && !config.SendHistoryStatus) { 189 | sendStatus = false 190 | } 191 | 192 | // Check Read History perms 193 | if !baseChannelIsForum && !hasPerms(subjectChannelID, discordgo.PermissionReadMessageHistory) { 194 | if job, exists := historyJobs.Get(subjectChannelID); exists { 195 | job.Status = historyStatusRunning 196 | job.Updated = time.Now() 197 | historyJobs.Set(subjectChannelID, job) 198 | } 199 | log.Println(lg("History", "", color.HiRedString, logPrefix+"BOT DOES NOT HAVE PERMISSION TO READ MESSAGE HISTORY!!!")) 200 | } 201 | 202 | // Update Job Status to Downloading 203 | if job, exists := historyJobs.Get(subjectChannelID); exists { 204 | job.Status = historyStatusRunning 205 | job.Updated = time.Now() 206 | historyJobs.Set(subjectChannelID, job) 207 | } 208 | 209 | //#region Cache Files 210 | 211 | openHistoryCache := func(channel string) historyCache { 212 | if f, err := os.ReadFile(pathCacheHistory + string(os.PathSeparator) + channel + ".json"); err == nil { 213 | var ret historyCache 214 | if err = json.Unmarshal(f, &ret); err != nil { 215 | log.Println(lg("Debug", "History", color.RedString, 216 | logPrefix+"Failed to unmarshal json for cache:\t%s", err)) 217 | } else { 218 | return ret 219 | } 220 | } 221 | return historyCache{} 222 | } 223 | 224 | writeHistoryCache := func(channel string, cache historyCache) { 225 | cacheJson, err := json.Marshal(cache) 226 | if err != nil { 227 | log.Println(lg("Debug", "History", color.RedString, 228 | logPrefix+"Failed to format cache into json:\t%s", err)) 229 | } else { 230 | if err := os.MkdirAll(pathCacheHistory, 0755); err != nil { 231 | log.Println(lg("Debug", "History", color.HiRedString, 232 | logPrefix+"Error while creating history cache folder \"%s\": %s", pathCacheHistory, err)) 233 | } 234 | f, err := os.OpenFile( 235 | pathCacheHistory+string(os.PathSeparator)+channel+".json", 236 | os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) 237 | if err != nil { 238 | log.Println(lg("Debug", "History", color.RedString, 239 | logPrefix+"Failed to open cache file:\t%s", err)) 240 | } 241 | if _, err = f.WriteString(string(cacheJson)); err != nil { 242 | log.Println(lg("Debug", "History", color.RedString, 243 | logPrefix+"Failed to write cache file:\t%s", err)) 244 | } else if !autorun && config.Debug { 245 | log.Println(lg("Debug", "History", color.YellowString, 246 | logPrefix+"Wrote to cache file.")) 247 | } 248 | f.Close() 249 | } 250 | } 251 | 252 | deleteHistoryCache := func(channel string) { 253 | fp := pathCacheHistory + string(os.PathSeparator) + channel + ".json" 254 | if _, err := os.Stat(fp); err == nil { 255 | err = os.Remove(fp) 256 | if err != nil { 257 | log.Println(lg("Debug", "History", color.HiRedString, 258 | logPrefix+"Encountered error deleting cache file:\t%s", err)) 259 | } else if commandingMessage != nil && config.Debug { 260 | log.Println(lg("Debug", "History", color.HiRedString, 261 | logPrefix+"Deleted cache file.")) 262 | } 263 | } 264 | } 265 | 266 | //#endregion 267 | 268 | for _, channel := range subjectChannels { 269 | logPrefix = fmt.Sprintf("%s/%s: ", channel.ID, commander) 270 | // Invalid Source? 271 | if sourceConfig == emptySourceConfig { 272 | log.Println(lg("History", "", color.HiRedString, 273 | logPrefix+"Invalid source: "+channel.ID)) 274 | if job, exists := historyJobs.Get(subjectChannelID); exists { 275 | job.Status = historyStatusErrorRequesting 276 | job.Updated = time.Now() 277 | historyJobs.Set(subjectChannelID, job) 278 | } 279 | continue 280 | } else { // Process 281 | logHistoryStatus := true 282 | if sourceConfig.OutputHistoryStatus != nil { 283 | logHistoryStatus = *sourceConfig.OutputHistoryStatus 284 | } 285 | 286 | // Overwrite Send Status 287 | if sourceConfig.SendAutoHistoryStatus != nil { 288 | if autorun && !*sourceConfig.SendAutoHistoryStatus { 289 | sendStatus = false 290 | } 291 | } 292 | if sourceConfig.SendHistoryStatus != nil { 293 | if !autorun && !*sourceConfig.SendHistoryStatus { 294 | sendStatus = false 295 | } 296 | } 297 | 298 | hasPermsToRespond := hasPerms(channel.ID, discordgo.PermissionSendMessages) 299 | if !autorun { 300 | hasPermsToRespond = hasPerms(commandingMessage.ChannelID, discordgo.PermissionSendMessages) 301 | } 302 | 303 | // Date Range Vars 304 | rangeContent := "" 305 | var beforeTime time.Time 306 | var beforeID = before 307 | var sinceID = "" 308 | 309 | // Handle Cache File 310 | if cache := openHistoryCache(channel.ID); cache != (historyCache{}) { 311 | if cache.CompletedSince != "" { 312 | if config.Debug { 313 | log.Println(lg("Debug", "History", color.GreenString, 314 | logPrefix+"Assuming history is completed prior to "+cache.CompletedSince)) 315 | } 316 | since = cache.CompletedSince 317 | } 318 | if cache.Running { 319 | if config.Debug { 320 | log.Println(lg("Debug", "History", color.YellowString, 321 | logPrefix+"Job was interrupted last run, picking up from "+beforeID)) 322 | } 323 | beforeID = cache.RunningBefore 324 | } 325 | } 326 | 327 | //#region Date Range Output 328 | 329 | var beforeRange = before 330 | if beforeRange != "" { 331 | if isDate(beforeRange) { // user has input YYYY-MM-DD 332 | beforeRange = discordTimestampToSnowflake("2006-01-02", beforeID) 333 | } else { // try to parse duration 334 | dur, err := time.ParseDuration(beforeRange) 335 | if err == nil { 336 | beforeRange = discordTimestampToSnowflake("2006-01-02 15:04:05.999999999 -0700 MST", time.Now().Add(-dur).Format("2006-01-02 15:04:05.999999999 -0700 MST")) 337 | } 338 | } 339 | if isNumeric(beforeRange) { // did we convert something (or was it always a number)? 340 | rangeContent += fmt.Sprintf("**Before:** `%s`\n", beforeRange) 341 | } 342 | before = beforeRange 343 | } 344 | 345 | var sinceRange = since 346 | if sinceRange != "" { 347 | if isDate(sinceRange) { // user has input YYYY-MM-DD 348 | sinceRange = discordTimestampToSnowflake("2006-01-02", sinceRange) 349 | } else { // try to parse duration 350 | dur, err := time.ParseDuration(sinceRange) 351 | if err == nil { 352 | sinceRange = discordTimestampToSnowflake("2006-01-02 15:04:05.999999999 -0700 MST", time.Now().Add(-dur).Format("2006-01-02 15:04:05.999999999 -0700 MST")) 353 | } 354 | } 355 | if isNumeric(sinceRange) { // did we convert something (or was it always a number)? 356 | rangeContent += fmt.Sprintf("**Since:** `%s`\n", sinceRange) 357 | } 358 | since = sinceRange 359 | } 360 | 361 | if rangeContent != "" { 362 | rangeContent += "\n" 363 | } 364 | 365 | //#endregion 366 | 367 | channelName := getChannelLabel(channel.ID, &channel) 368 | if channel.ParentID != "" { 369 | channelName = getChannelLabel(channel.ParentID, nil) + " \"" + getChannelLabel(channel.ID, &channel) + "\"" 370 | } 371 | sourceName := fmt.Sprintf("%s / %s", guildName, channelName) 372 | msgSourceDisplay := fmt.Sprintf("`Server:` **%s**\n`Channel:` #%s", guildName, channelName) 373 | if categoryName != "unknown" { 374 | sourceName = fmt.Sprintf("%s / %s / %s", guildName, categoryName, channelName) 375 | msgSourceDisplay = fmt.Sprintf("`Server:` **%s**\n`Category:` _%s_\n`Channel:` #%s", 376 | guildName, categoryName, channelName) 377 | } 378 | 379 | // Initial Status Message 380 | if sendStatus { 381 | if hasPermsToRespond { 382 | responseMsg, err = replyEmbed(commandingMessage, "Command — History", msgSourceDisplay) 383 | if err != nil { 384 | log.Println(lg("History", "", color.HiRedString, 385 | logPrefix+"Failed to send command embed message:\t%s", err)) 386 | } 387 | } else { 388 | log.Println(lg("History", "", color.HiRedString, 389 | logPrefix+fmtBotSendPerm, commandingMessage.ChannelID)) 390 | } 391 | } 392 | log.Println(lg("History", "", color.HiCyanString, logPrefix+"Began checking history for \"%s\"...", sourceName)) 393 | 394 | lastMessageID := "" 395 | MessageRequestingLoop: 396 | for { 397 | // Next 100 398 | if beforeTime != (time.Time{}) { 399 | messageRequestCount++ 400 | writeHistoryCache(channel.ID, historyCache{ 401 | Updated: time.Now(), 402 | Running: true, 403 | RunningBefore: beforeID, 404 | }) 405 | 406 | // Update Status 407 | if logHistoryStatus { 408 | log.Println(lg("History", "", color.CyanString, 409 | logPrefix+"Requesting more, \t%d downloaded (%s), \t%d processed, \tsearching before %s ago (%s)", 410 | totalDownloads, humanize.Bytes(uint64(totalFilesize)), totalMessages, timeSinceShort(beforeTime), beforeTime.String()[:10])) 411 | } 412 | if sendStatus { 413 | var status string 414 | if totalDownloads == 0 { 415 | status = fmt.Sprintf( 416 | "``%s:`` **No files downloaded...**\n"+ 417 | "_%s messages processed, avg %d msg/s_\n\n"+ 418 | "%s\n\n"+ 419 | "%s`(%d)` _Processing more messages, please wait..._\n", 420 | timeSinceShort(historyStartTime), 421 | formatNumber(totalMessages), int(float64(totalMessages)/time.Since(historyStartTime).Seconds()), 422 | msgSourceDisplay, rangeContent, messageRequestCount, 423 | ) 424 | } else { 425 | status = fmt.Sprintf( 426 | "``%s:`` **%s files downloaded...**\n`%s so far, avg %1.1f MB/s`\n"+ 427 | "_%s messages processed, avg %d msg/s_\n\n"+ 428 | "%s\n\n"+ 429 | "%s`(%d)` _Processing more messages, please wait..._\n", 430 | timeSinceShort(historyStartTime), formatNumber(totalDownloads), 431 | humanize.Bytes(uint64(totalFilesize)), float64(totalFilesize/humanize.MByte)/historyDownloadDuration.Seconds(), 432 | formatNumber(totalMessages), int(float64(totalMessages)/time.Since(historyStartTime).Seconds()), 433 | msgSourceDisplay, rangeContent, messageRequestCount, 434 | ) 435 | } 436 | if responseMsg == nil { 437 | log.Println(lg("History", "", color.RedString, 438 | logPrefix+"Tried to edit status message but it doesn't exist, sending new one.")) 439 | if responseMsg, err = replyEmbed(commandingMessage, "Command — History", status); err != nil { // Failed to Edit Status, Send New Message 440 | log.Println(lg("History", "", color.HiRedString, 441 | logPrefix+"Failed to send replacement status message:\t%s", err)) 442 | } 443 | } else { 444 | if !hasPermsToRespond { 445 | log.Println(lg("History", "", color.HiRedString, 446 | logPrefix+fmtBotSendPerm+" - %s", responseMsg.ChannelID, status)) 447 | } else { 448 | // Edit Status 449 | if selfbot { 450 | responseMsg, err = bot.ChannelMessageEdit(responseMsg.ChannelID, responseMsg.ID, 451 | fmt.Sprintf("**Command — History**\n\n%s", status)) 452 | } else { 453 | responseMsg, err = bot.ChannelMessageEditComplex(&discordgo.MessageEdit{ 454 | ID: responseMsg.ID, 455 | Channel: responseMsg.ChannelID, 456 | Embed: buildEmbed(responseMsg.ChannelID, "Command — History", status), 457 | }) 458 | } 459 | // Failed to Edit Status 460 | if err != nil { 461 | log.Println(lg("History", "", color.HiRedString, 462 | logPrefix+"Failed to edit status message, sending new one:\t%s", err)) 463 | if responseMsg, err = replyEmbed(responseMsg, "Command — History", status); err != nil { // Failed to Edit Status, Send New Message 464 | log.Println(lg("History", "", color.HiRedString, 465 | logPrefix+"Failed to send replacement status message:\t%s", err)) 466 | } 467 | } 468 | } 469 | } 470 | } 471 | 472 | // Update presence 473 | timeLastUpdated = time.Now() 474 | if *sourceConfig.PresenceEnabled { 475 | go updateDiscordPresence() 476 | } 477 | } 478 | 479 | // Request More Messages 480 | msg_rq_cnt := 0 481 | request_messages: 482 | msg_rq_cnt++ 483 | if config.HistoryRequestDelay > 0 { 484 | if logHistoryStatus { 485 | log.Println(lg("History", "", color.YellowString, "Delaying next batch request for %d seconds...", config.HistoryRequestDelay)) 486 | } 487 | time.Sleep(time.Second * time.Duration(config.HistoryRequestDelay)) 488 | } 489 | if messages, fetcherr := bot.ChannelMessages(channel.ID, config.HistoryRequestCount, beforeID, sinceID, ""); fetcherr != nil { 490 | // Error requesting messages 491 | if sendStatus { 492 | if !hasPermsToRespond { 493 | log.Println(lg("History", "", color.HiRedString, 494 | logPrefix+fmtBotSendPerm, responseMsg.ChannelID)) 495 | } else { 496 | _, senderr := replyEmbed(responseMsg, "Command — History", 497 | fmt.Sprintf("Encountered an error requesting messages for %s: %s", channel.ID, fetcherr.Error())) 498 | if senderr != nil { 499 | log.Println(lg("History", "", color.HiRedString, 500 | logPrefix+"Failed to send error message:\t%s", senderr)) 501 | } 502 | } 503 | } 504 | log.Println(lg("History", "", color.HiRedString, logPrefix+"Error requesting messages:\t%s", fetcherr)) 505 | if job, exists := historyJobs.Get(subjectChannelID); exists { 506 | job.Status = historyStatusErrorRequesting 507 | job.Updated = time.Now() 508 | historyJobs.Set(subjectChannelID, job) 509 | } 510 | //TODO: delete cahce or handle it differently? 511 | break MessageRequestingLoop 512 | } else { 513 | // No More Messages 514 | if len(messages) <= 0 { 515 | if msg_rq_cnt > 3 { 516 | if job, exists := historyJobs.Get(subjectChannelID); exists { 517 | job.Status = historyStatusCompletedNoMoreMessages 518 | job.Updated = time.Now() 519 | historyJobs.Set(subjectChannelID, job) 520 | } 521 | writeHistoryCache(channel.ID, historyCache{ 522 | Updated: time.Now(), 523 | Running: false, 524 | RunningBefore: "", 525 | CompletedSince: lastMessageID, 526 | }) 527 | break MessageRequestingLoop 528 | } else { // retry to make sure no more 529 | time.Sleep(10 * time.Millisecond) 530 | goto request_messages 531 | } 532 | } 533 | 534 | // Set New Range, this shouldn't be changed regardless of before/since filters. The bot will always go latest to oldest. 535 | beforeID = messages[len(messages)-1].ID 536 | beforeTime = messages[len(messages)-1].Timestamp 537 | sinceID = "" 538 | 539 | // Process Messages 540 | if sourceConfig.HistoryTyping != nil && !autorun { 541 | if *sourceConfig.HistoryTyping && hasPermsToRespond { 542 | bot.ChannelTyping(commandingMessage.ChannelID) 543 | } 544 | } 545 | for _, message := range messages { 546 | // Ordered to Cancel 547 | if job, exists := historyJobs.Get(subjectChannelID); exists { 548 | if job.Status == historyStatusAbortRequested { 549 | job.Status = historyStatusAbortCompleted 550 | job.Updated = time.Now() 551 | historyJobs.Set(subjectChannelID, job) 552 | deleteHistoryCache(channel.ID) //TODO: Replace with different variation of writing cache? 553 | break MessageRequestingLoop 554 | } 555 | } 556 | 557 | lastMessageID = message.ID 558 | 559 | // Check Message Range 560 | message64, _ := strconv.ParseInt(message.ID, 10, 64) 561 | if before != "" { 562 | before64, _ := strconv.ParseInt(before, 10, 64) 563 | if message64 > before64 { // keep scrolling back in messages 564 | continue 565 | } 566 | } 567 | if since != "" { 568 | since64, _ := strconv.ParseInt(since, 10, 64) 569 | if message64 < since64 { // message too old, kill loop 570 | if job, exists := historyJobs.Get(subjectChannelID); exists { 571 | job.Status = historyStatusCompletedToSinceFilter 572 | job.Updated = time.Now() 573 | historyJobs.Set(subjectChannelID, job) 574 | } 575 | deleteHistoryCache(channel.ID) // unsure of consequences of caching when using filters, so deleting to be safe for now. 576 | break MessageRequestingLoop 577 | } 578 | } 579 | 580 | // Process Message 581 | timeStartingDownload := time.Now() 582 | downloadedFiles := handleMessage(message, &channel, false, true) 583 | if len(downloadedFiles) > 0 { 584 | totalDownloads += int64(len(downloadedFiles)) 585 | for _, file := range downloadedFiles { 586 | totalFilesize += file.Filesize 587 | } 588 | historyDownloadDuration += time.Since(timeStartingDownload) 589 | } 590 | totalMessages++ 591 | } 592 | } 593 | } 594 | 595 | // Final log 596 | log.Println(lg("History", "", color.HiGreenString, logPrefix+"Finished history for \"%s\", %s files, %s total", 597 | sourceName, formatNumber(totalDownloads), humanize.Bytes(uint64(totalFilesize)))) 598 | // Final status update 599 | if sendStatus { 600 | jobStatus := "Unknown" 601 | if job, exists := historyJobs.Get(subjectChannelID); exists { 602 | jobStatus = historyStatusLabel(job.Status) 603 | } 604 | var status string 605 | if totalDownloads == 0 { 606 | status = fmt.Sprintf( 607 | "``%s:`` **No files found...**\n"+ 608 | "_%s total messages processed, avg %d msg/s_\n\n"+ 609 | "%s\n\n"+ // msgSourceDisplay^ 610 | "**DONE!** - %s\n"+ 611 | "Ran ``%d`` message history requests\n\n"+ 612 | "%s_Duration was %s_", 613 | timeSinceShort(historyStartTime), 614 | formatNumber(int64(totalMessages)), int(float64(totalMessages)/time.Since(historyStartTime).Seconds()), 615 | msgSourceDisplay, 616 | jobStatus, 617 | messageRequestCount, 618 | rangeContent, timeSince(historyStartTime), 619 | ) 620 | } else { 621 | status = fmt.Sprintf( 622 | "``%s:`` **%s total files downloaded!**\n`%s total, avg %1.1f MB/s`\n"+ 623 | "_%s total messages processed, avg %d msg/s_\n\n"+ 624 | "%s\n\n"+ // msgSourceDisplay^ 625 | "**DONE!** - %s\n"+ 626 | "Ran ``%d`` message history requests\n\n"+ 627 | "%s_Duration was %s_", 628 | timeSinceShort(historyStartTime), formatNumber(int64(totalDownloads)), 629 | humanize.Bytes(uint64(totalFilesize)), float64(totalFilesize/humanize.MByte)/historyDownloadDuration.Seconds(), 630 | formatNumber(int64(totalMessages)), int(float64(totalMessages)/time.Since(historyStartTime).Seconds()), 631 | msgSourceDisplay, 632 | jobStatus, 633 | messageRequestCount, 634 | rangeContent, timeSince(historyStartTime), 635 | ) 636 | } 637 | if !hasPermsToRespond { 638 | log.Println(lg("History", "", color.HiRedString, logPrefix+fmtBotSendPerm, responseMsg.ChannelID)) 639 | } else { 640 | if responseMsg == nil { 641 | log.Println(lg("History", "", color.RedString, 642 | logPrefix+"Tried to edit status message but it doesn't exist, sending new one.")) 643 | if _, err = replyEmbed(commandingMessage, "Command — History", status); err != nil { // Failed to Edit Status, Send New Message 644 | log.Println(lg("History", "", color.HiRedString, 645 | logPrefix+"Failed to send replacement status message:\t%s", err)) 646 | } 647 | } else { 648 | if selfbot { 649 | responseMsg, err = bot.ChannelMessageEdit(responseMsg.ChannelID, responseMsg.ID, 650 | fmt.Sprintf("**Command — History**\n\n%s", status)) 651 | } else { 652 | responseMsg, err = bot.ChannelMessageEditComplex(&discordgo.MessageEdit{ 653 | ID: responseMsg.ID, 654 | Channel: responseMsg.ChannelID, 655 | Embed: buildEmbed(responseMsg.ChannelID, "Command — History", status), 656 | }) 657 | } 658 | // Edit failure 659 | if err != nil { 660 | log.Println(lg("History", "", color.RedString, 661 | logPrefix+"Failed to edit status message, sending new one:\t%s", err)) 662 | if _, err = replyEmbed(responseMsg, "Command — History", status); err != nil { 663 | log.Println(lg("History", "", color.HiRedString, 664 | logPrefix+"Failed to send replacement status message:\t%s", err)) 665 | } 666 | } 667 | } 668 | } 669 | } 670 | } 671 | } 672 | 673 | return int(totalDownloads) 674 | } 675 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "runtime" 10 | "strings" 11 | "sync" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/Davincible/goinsta/v3" 16 | "github.com/HouzuoGuo/tiedot/db" 17 | "github.com/Necroforger/dgrouter/exrouter" 18 | "github.com/bwmarrin/discordgo" 19 | "github.com/fatih/color" 20 | "github.com/fsnotify/fsnotify" 21 | twitterscraper "github.com/imperatrona/twitter-scraper" 22 | "github.com/rivo/duplo" 23 | orderedmap "github.com/wk8/go-ordered-map/v2" 24 | ) 25 | 26 | var ( 27 | // General 28 | err error 29 | loop chan os.Signal 30 | mainWg sync.WaitGroup 31 | startTime time.Time 32 | ddgUpdateAvailable bool = false 33 | autoHistoryInitiated bool = false 34 | 35 | // Downloads 36 | timeLastUpdated time.Time 37 | timeLastDownload time.Time 38 | timeLastMessage time.Time 39 | cachedDownloadID int 40 | configReloadLastTime time.Time 41 | 42 | // Discord 43 | bot *discordgo.Session 44 | botUser *discordgo.User 45 | botCommands *exrouter.Route 46 | selfbot bool = false 47 | botReady bool = false 48 | 49 | // Storage 50 | myDB *db.DB 51 | duploCatalog *duplo.Store 52 | 53 | // APIs 54 | twitterConnected bool = false 55 | twitterScraper *twitterscraper.Scraper 56 | instagramConnected bool = false 57 | instagramClient *goinsta.Instagram 58 | ) 59 | 60 | func versions(multiline bool) string { 61 | if multiline { 62 | return fmt.Sprintf("%s/%s / %s\ndiscordgo v%s (API v%s)", 63 | runtime.GOOS, runtime.GOARCH, runtime.Version(), discordgo.VERSION, discordgo.APIVersion) 64 | } else { 65 | return fmt.Sprintf("%s/%s / %s / discordgo v%s (API v%s)", 66 | runtime.GOOS, runtime.GOARCH, runtime.Version(), discordgo.VERSION, discordgo.APIVersion) 67 | } 68 | } 69 | 70 | func botLoad() { 71 | mainWg.Add(1) 72 | botLoadAPIs() 73 | 74 | mainWg.Add(1) 75 | botLoadDiscord() 76 | } 77 | 78 | func init() { 79 | 80 | //#region Initialize Logging 81 | log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) 82 | log.SetOutput(color.Output) 83 | log.Println(color.HiGreenString(wrapHyphensW(fmt.Sprintf("Welcome to %s v%s", projectName, projectVersion)))) 84 | //#endregion 85 | 86 | //#region Initialize Variables 87 | loop = make(chan os.Signal, 1) 88 | 89 | startTime = time.Now() 90 | 91 | historyJobs = orderedmap.New[string, historyJob]() 92 | 93 | if len(os.Args) > 1 { 94 | configFileBase = os.Args[1] 95 | } 96 | //#endregion 97 | 98 | //#region Github Update Check 99 | if config.GithubUpdateChecking { 100 | ddgUpdateAvailable = !isLatestGithubRelease() 101 | } 102 | //#endregion 103 | } 104 | 105 | func main() { 106 | 107 | //#region <<< CRITICAL INIT >>> 108 | 109 | loadConfig() 110 | openDatabase() 111 | 112 | //#endregion 113 | 114 | // Output Flag Warnings 115 | if config.Verbose { 116 | log.Println(lg("VERBOSE", "", color.HiBlueString, "VERBOSE OUTPUT ENABLED ... just some extra info...")) 117 | } 118 | if config.Debug { 119 | log.Println(lg("DEBUG", "", color.HiYellowString, "DEBUGGING OUTPUT ENABLED ... some troubleshooting data...")) 120 | } 121 | if config.DebugExtra { 122 | log.Println(lg("DEBUG2", "", color.YellowString, "EXTRA DEBUGGING OUTPUT ENABLED ... some in-depth troubleshooting data...")) 123 | } 124 | 125 | mainWg.Wait() // wait because credentials from config 126 | 127 | //#region <<< CONNECTIONS >>> 128 | 129 | mainWg.Add(2) 130 | 131 | go botLoadAPIs() 132 | 133 | go botLoadDiscord() 134 | 135 | //#endregion 136 | 137 | //#region Initialize Regex 138 | mainWg.Add(1) 139 | go func() { 140 | if err = compileRegex(); err != nil { 141 | log.Println(lg("Regex", "", color.HiRedString, "Error initializing:\t%s", err)) 142 | return 143 | } 144 | mainWg.Done() 145 | }() 146 | //#endregion 147 | 148 | //#region [Loops] History Job Processing 149 | go func() { 150 | for { 151 | newJobCount := 0 152 | // Empty Local Cache 153 | nhistoryJobCnt, 154 | nhistoryJobCntWaiting, 155 | nhistoryJobCntRunning, 156 | nhistoryJobCntAborted, 157 | nhistoryJobCntErrored, 158 | nhistoryJobCntCompleted := historyJobs.Len(), 0, 0, 0, 0, 0 159 | 160 | //MARKER: history jobs launch 161 | // do we even bother? 162 | if nhistoryJobCnt > 0 { 163 | // New Cache 164 | for pair := historyJobs.Oldest(); pair != nil; pair = pair.Next() { 165 | job := pair.Value 166 | if job.Status == historyStatusWaiting { 167 | nhistoryJobCntWaiting++ 168 | } else if job.Status == historyStatusRunning { 169 | nhistoryJobCntRunning++ 170 | } else if job.Status == historyStatusAbortRequested || job.Status == historyStatusAbortCompleted { 171 | nhistoryJobCntAborted++ 172 | } else if job.Status == historyStatusErrorReadMessageHistoryPerms || job.Status == historyStatusErrorRequesting { 173 | nhistoryJobCntErrored++ 174 | } else if job.Status >= historyStatusCompletedNoMoreMessages { 175 | nhistoryJobCntCompleted++ 176 | } 177 | } 178 | 179 | // Should Start New Job(s)? 180 | if nhistoryJobCntRunning < config.HistoryMaxJobs || config.HistoryMaxJobs < 1 { 181 | openSlots := config.HistoryMaxJobs - nhistoryJobCntRunning 182 | newJobs := make([]historyJob, openSlots) 183 | // Find Jobs 184 | for pair := historyJobs.Oldest(); pair != nil; pair = pair.Next() { 185 | if newJobCount == openSlots { 186 | break 187 | } 188 | if pair.Value.Status == historyStatusWaiting { 189 | newJobs = append(newJobs, pair.Value) 190 | newJobCount++ 191 | } 192 | } 193 | // Start Jobs 194 | if len(newJobs) > 0 { 195 | for _, job := range newJobs { 196 | if job != (historyJob{}) { 197 | go handleHistory(job.TargetCommandingMessage, job.TargetChannelID, job.TargetBefore, job.TargetSince) 198 | } 199 | } 200 | } 201 | } 202 | } 203 | 204 | // Update Cache 205 | historyJobCnt = nhistoryJobCnt 206 | historyJobCntWaiting = nhistoryJobCntWaiting 207 | historyJobCntRunning = nhistoryJobCntRunning 208 | historyJobCntAborted = nhistoryJobCntAborted 209 | historyJobCntErrored = nhistoryJobCntErrored 210 | historyJobCntCompleted = nhistoryJobCntCompleted 211 | 212 | // Wait before checking again 213 | time.Sleep(time.Duration(config.HistoryManagerRate) * time.Second) 214 | 215 | // Auto Exit 216 | if config.AutoHistoryExit && autoHistoryInitiated && 217 | historyJobs.Len() > 0 && newJobCount == 0 && historyJobCntWaiting == 0 && historyJobCntRunning == 0 { 218 | log.Println(lg("History", "", color.HiCyanString, "Exiting due to auto history completion...")) 219 | properExit() 220 | } 221 | } 222 | }() 223 | //#endregion 224 | 225 | mainWg.Wait() // Once complete, bot is functional 226 | 227 | //#region MISC STARTUP OUTPUT - Github Update Notification, Version, Discord Invite 228 | 229 | log.Println(lg("Version", "", color.MagentaString, versions(false))) 230 | 231 | if config.GithubUpdateChecking { 232 | if ddgUpdateAvailable { 233 | log.Println(lg("Version", "UPDATE", color.HiGreenString, "***\tUPDATE AVAILABLE\t***")) 234 | log.Println(lg("Version", "UPDATE", color.HiGreenString, "DOWNLOAD:\n\n"+projectRepoURL+"/releases/latest\n\n")) 235 | log.Println(lg("Version", "UPDATE", color.HiGreenString, 236 | fmt.Sprintf("You are on v%s, latest is %s", projectVersion, latestGithubRelease), 237 | )) 238 | log.Println(lg("Version", "UPDATE", color.GreenString, "*** See changelogs for information ***")) 239 | log.Println(lg("Version", "UPDATE", color.GreenString, "Check ALL changelogs since your last update!")) 240 | log.Println(lg("Version", "UPDATE", color.HiGreenString, "SOME SETTINGS-BREAKING CHANGES MAY HAVE OCCURED!!")) 241 | time.Sleep(5 * time.Second) 242 | } else { 243 | if "v"+projectVersion == latestGithubRelease { 244 | log.Println(lg("Version", "UPDATE", color.GreenString, "You are on the latest version, v%s", projectVersion)) 245 | } else { 246 | log.Println(lg("Version", "UPDATE", color.GreenString, "No updates available, you are on v%s, latest is %s", projectVersion, latestGithubRelease)) 247 | } 248 | } 249 | } 250 | 251 | log.Println(lg("Info", "", color.HiCyanString, "** NEED HELP? discord-downloader-go Discord Server: https://discord.gg/6Z6FJZVaDV **")) 252 | 253 | //#endregion 254 | 255 | //#region <<< MAIN STARTUP COMPLETE - BOT IS FUNCTIONAL >>> 256 | 257 | if config.Verbose { 258 | log.Println(lg("Verbose", "Startup", color.HiBlueString, "Startup finished, took %s...", uptime())) 259 | } 260 | log.Println(lg("Main", "", color.HiGreenString, 261 | wrapHyphensW(fmt.Sprintf("%s v%s is online with access to %d server%s", 262 | projectLabel, projectVersion, len(bot.State.Guilds), pluralS(len(bot.State.Guilds)))))) 263 | log.Println(lg("Main", "", color.RedString, "CTRL+C to exit...")) 264 | 265 | // Log Status 266 | go sendStatusMessage(sendStatusStartup) 267 | 268 | //#endregion 269 | 270 | //#region Autorun History 271 | type arh struct{ channel, before, since string } 272 | var autoHistoryChannels []arh 273 | // Compile list of channels to autorun history 274 | for _, channel := range getAllRegisteredChannels() { 275 | sourceConfig := getSource(&discordgo.Message{ChannelID: channel.ChannelID}) 276 | if sourceConfig.AutoHistory != nil { 277 | if *sourceConfig.AutoHistory { 278 | var autoHistoryChannel arh 279 | autoHistoryChannel.channel = channel.ChannelID 280 | autoHistoryChannel.before = *sourceConfig.AutoHistoryBefore 281 | autoHistoryChannel.since = *sourceConfig.AutoHistorySince 282 | autoHistoryChannels = append(autoHistoryChannels, autoHistoryChannel) 283 | } 284 | continue 285 | } 286 | } 287 | // Process auto history 288 | for _, ah := range autoHistoryChannels { 289 | //MARKER: history jobs queued from auto 290 | if job, exists := historyJobs.Get(ah.channel); !exists || 291 | (job.Status != historyStatusRunning && job.Status != historyStatusAbortRequested) { 292 | job.Status = historyStatusWaiting 293 | job.OriginChannel = "AUTORUN" 294 | job.OriginUser = "AUTORUN" 295 | job.TargetCommandingMessage = nil 296 | job.TargetChannelID = ah.channel 297 | job.TargetBefore = ah.before 298 | job.TargetSince = ah.since 299 | job.Updated = time.Now() 300 | job.Added = time.Now() 301 | historyJobs.Set(ah.channel, job) 302 | //TODO: signals for this and typical history cmd?? 303 | } 304 | } 305 | autoHistoryInitiated = true 306 | if len(autoHistoryChannels) > 0 { 307 | log.Println(lg("History", "Autorun", color.HiYellowString, 308 | "History Autoruns completed (for %d channel%s)", 309 | len(autoHistoryChannels), pluralS(len(autoHistoryChannels)))) 310 | log.Println(lg("History", "Autorun", color.CyanString, 311 | "Waiting for something else to do...")) 312 | } 313 | //#endregion 314 | 315 | //#region [Loops] Tickers 316 | tickerCheckup := time.NewTicker(time.Duration(config.CheckupRate) * time.Minute) 317 | tickerPresence := time.NewTicker(time.Duration(config.PresenceRefreshRate) * time.Minute) 318 | tickerConnection := time.NewTicker(time.Duration(config.ConnectionCheckRate) * time.Minute) 319 | go func() { 320 | for { 321 | select { 322 | 323 | case <-tickerCheckup.C: 324 | if config.Debug { 325 | //MARKER: history jobs polled for waiting count in checkup 326 | historyJobsWaiting := 0 327 | if historyJobs.Len() > 0 { 328 | for jobPair := historyJobs.Oldest(); jobPair != nil; jobPair.Next() { 329 | job := jobPair.Value 330 | if job.Status == historyStatusWaiting { 331 | historyJobsWaiting++ 332 | } 333 | } 334 | } 335 | str := fmt.Sprintf("... %dms latency,\t\tlast discord heartbeat %s ago,\t\t%s uptime", 336 | bot.HeartbeatLatency().Milliseconds(), 337 | timeSinceShort(bot.LastHeartbeatSent), 338 | timeSinceShort(startTime)) 339 | if !timeLastMessage.IsZero() { 340 | str += fmt.Sprintf(",\tlast message %s ago", 341 | timeSinceShort(timeLastMessage)) 342 | } 343 | if !timeLastDownload.IsZero() { 344 | str += fmt.Sprintf(",\tlast download %s ago", 345 | timeSinceShort(timeLastDownload)) 346 | } 347 | if historyJobsWaiting > 0 { 348 | str += fmt.Sprintf(",\t%d history jobs waiting", historyJobsWaiting) 349 | } 350 | log.Println(lg("Checkup", "", color.YellowString, str)) 351 | } 352 | 353 | case <-tickerPresence.C: 354 | // If bot experiences connection interruption the status will go blank until updated by message, this fixes that 355 | go updateDiscordPresence() 356 | 357 | case <-tickerConnection.C: 358 | if config.ConnectionCheck { 359 | doReconnect := func() { 360 | log.Println(lg("Discord", "", color.YellowString, "Closing Discord connections...")) 361 | bot.Client.CloseIdleConnections() 362 | bot.CloseWithCode(1001) 363 | bot = nil 364 | log.Println(lg("Discord", "", color.RedString, "Discord connections closed!")) 365 | time.Sleep(15 * time.Second) 366 | if config.ExitOnBadConnection { 367 | properExit() 368 | } else { 369 | log.Println(lg("Discord", "", color.GreenString, "Logging in...")) 370 | botLoad() 371 | log.Println(lg("Discord", "", color.HiGreenString, 372 | "Reconnected! The bot *should* resume working...")) 373 | // Log Status 374 | sendStatusMessage(sendStatusReconnect) 375 | } 376 | } 377 | gate, err := bot.Gateway() 378 | if err != nil || gate == "" { 379 | log.Println(lg("Discord", "", color.HiYellowString, 380 | "Bot encountered a gateway error: GATEWAY: %s,\tERR: %s", gate, err)) 381 | doReconnect() 382 | } else if time.Since(bot.LastHeartbeatAck).Seconds() > 4*60 { 383 | log.Println(lg("Discord", "", color.HiYellowString, 384 | "Bot has not received a heartbeat from Discord in 4 minutes...")) 385 | doReconnect() 386 | } 387 | } 388 | } 389 | } 390 | }() 391 | //#endregion 392 | 393 | //#region [Loop] Settings Watcher 394 | if config.WatchSettings { 395 | watcher, err := fsnotify.NewWatcher() 396 | if err != nil { 397 | log.Println(lg("Settings", "Watcher", color.HiRedString, "Error creating NewWatcher:\t%s", err)) 398 | } 399 | defer watcher.Close() 400 | if err = watcher.Add(configFile); err != nil { 401 | log.Println(lg("Settings", "Watcher", color.HiRedString, "Error adding watcher for settings:\t%s", err)) 402 | } 403 | go func() { 404 | for { 405 | select { 406 | case event, ok := <-watcher.Events: 407 | if !ok { 408 | return 409 | } 410 | if event.Op&fsnotify.Write == fsnotify.Write { 411 | // It double-fires the event without time check, might depend on OS but this works anyways 412 | if time.Since(configReloadLastTime).Milliseconds() > 1 { 413 | time.Sleep(1 * time.Second) 414 | log.Println(lg("Settings", "Watcher", color.YellowString, 415 | "Detected changes in \"%s\", reloading...", configFile)) 416 | mainWg.Add(1) 417 | go loadConfig() 418 | allString := "" 419 | if config.All != nil { 420 | allString = ", ALL ENABLED" 421 | } 422 | log.Println(lg("Settings", "Watcher", color.HiYellowString, 423 | "Reloaded - bound to %d channel%s, %d categories, %d server%s, %d user%s%s", 424 | getBoundChannelsCount(), pluralS(getBoundChannelsCount()), 425 | getBoundCategoriesCount(), 426 | getBoundServersCount(), pluralS(getBoundServersCount()), 427 | getBoundUsersCount(), pluralS(getBoundUsersCount()), allString, 428 | )) 429 | 430 | go updateDiscordPresence() 431 | go sendStatusMessage(sendStatusSettings) 432 | configReloadLastTime = time.Now() 433 | } 434 | } 435 | case err, ok := <-watcher.Errors: 436 | if !ok { 437 | return 438 | } 439 | log.Println(color.HiRedString("[Watchers] Error:\t%s", err)) 440 | } 441 | } 442 | }() 443 | } 444 | //#endregion 445 | 446 | //#region Database Backup 447 | 448 | if config.BackupDatabaseOnStart { 449 | if err = backupDatabase(); err != nil { 450 | log.Println(lg("Database", "Backup", color.HiRedString, "Error backing up database:\t%s", err)) 451 | } 452 | } 453 | 454 | //#endregion 455 | 456 | //#region Cache Constants 457 | 458 | go func() { 459 | constants := make(map[string]string) 460 | //--- Compile constants 461 | for _, server := range bot.State.Guilds { 462 | serverKey := fmt.Sprintf("SERVER_%s", stripSymbols(server.Name)) 463 | serverKey = strings.ReplaceAll(serverKey, " ", "_") 464 | for strings.Contains(serverKey, "__") { 465 | serverKey = strings.ReplaceAll(serverKey, "__", "_") 466 | } 467 | serverKey = strings.ToUpper(serverKey) 468 | if constants[serverKey] == "" { 469 | constants[serverKey] = server.ID 470 | } 471 | for _, channel := range server.Channels { 472 | if channel.Type != discordgo.ChannelTypeGuildCategory { 473 | categoryName := "" 474 | if channel.ParentID != "" { 475 | channelParent, err := bot.State.Channel(channel.ParentID) 476 | if err != nil { 477 | channelParent, err = bot.Channel(channel.ParentID) 478 | } 479 | if err == nil { 480 | categoryName = channelParent.Name 481 | } 482 | } 483 | channelKey := fmt.Sprintf("CHANNEL_%s_%s_%s", 484 | stripSymbols(server.Name), stripSymbols(categoryName), stripSymbols(channel.Name)) 485 | channelKey = strings.ReplaceAll(channelKey, " ", "_") 486 | for strings.Contains(channelKey, "__") { 487 | channelKey = strings.ReplaceAll(channelKey, "__", "_") 488 | } 489 | channelKey = strings.ToUpper(channelKey) 490 | if constants[channelKey] == "" { 491 | constants[channelKey] = channel.ID 492 | } 493 | } 494 | } 495 | } 496 | //--- Save constants 497 | if _, err := os.Stat(pathConstants); err == nil { 498 | err = os.Remove(pathConstants) 499 | if err != nil { 500 | log.Println(lg("Constants", "", color.HiRedString, "Encountered error deleting cache file:\t%s", err)) 501 | } 502 | } 503 | var constantsStruct map[string]string 504 | constantsStruct = constants 505 | newJson, err := json.MarshalIndent(constantsStruct, "", "\t") 506 | if err != nil { 507 | log.Println(lg("Constants", "", color.HiRedString, "Failed to format constants...\t%s", err)) 508 | } else { 509 | err := os.WriteFile(pathConstants, newJson, 0644) 510 | if err != nil { 511 | log.Println(lg("Constants", "", color.HiRedString, "Failed to save new constants file...\t%s", err)) 512 | } 513 | } 514 | }() 515 | 516 | //#endregion 517 | 518 | //#region Download Emojis & Stickers (after 5s delay) 519 | 520 | go func() { 521 | time.Sleep(5 * time.Second) 522 | 523 | downloadDiscordEmojis() 524 | 525 | downloadDiscordStickers() 526 | 527 | }() 528 | 529 | //#endregion 530 | 531 | //#region <<< BACKGROUND STARTUP COMPLETE >>> 532 | 533 | if config.Verbose { 534 | log.Println(lg("Verbose", "Startup", color.HiBlueString, "Background task startup finished, took %s...", uptime())) 535 | } 536 | 537 | //#endregion 538 | 539 | // <<<<<< RUNNING >>>>>> 540 | 541 | //#region ----------- TEST ENV / main 542 | 543 | //#endregion ------------------------ 544 | 545 | //#region Exit... 546 | signal.Notify(loop, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, os.Interrupt, os.Kill) 547 | <-loop 548 | 549 | sendStatusMessage(sendStatusExit) // not goroutine because we want to wait to send this before logout 550 | 551 | // Log out of twitter if authenticated. 552 | if twitterScraper != nil { 553 | if twitterScraper.IsLoggedIn() { 554 | twitterScraper.Logout() 555 | } 556 | } 557 | 558 | log.Println(lg("Discord", "", color.GreenString, "Logging out of discord...")) 559 | bot.Close() 560 | 561 | log.Println(lg("Database", "", color.YellowString, "Closing database...")) 562 | myDB.Close() 563 | 564 | log.Println(lg("Main", "", color.HiRedString, "Exiting... ")) 565 | //#endregion 566 | 567 | } 568 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "github.com/Davincible/goinsta/v3" 18 | "github.com/PuerkitoBio/goquery" 19 | "github.com/bwmarrin/discordgo" 20 | "github.com/fatih/color" 21 | twitterscraper "github.com/imperatrona/twitter-scraper" 22 | ) 23 | 24 | const ( 25 | imgurClientID = "08af502a9e70d65" 26 | sneakyUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0" 27 | ) 28 | 29 | func botLoadAPIs() { 30 | // Twitter API 31 | if *config.Credentials.TwitterAuthEnabled { 32 | go func() { 33 | twitterScraper = twitterscraper.New() 34 | if (config.Credentials.TwitterUsername != "" && 35 | config.Credentials.TwitterPassword != "") || 36 | (config.Credentials.TwitterAuthToken != "" && 37 | config.Credentials.TwitterCT0 != "") { 38 | log.Println(lg("API", "Twitter", color.MagentaString, "Connecting...")) 39 | 40 | // Proxy 41 | twitterProxy := func(logIt bool) { 42 | if config.Credentials.TwitterProxy != "" { 43 | err := twitterScraper.SetProxy(config.Credentials.TwitterProxy) 44 | if logIt { 45 | if err != nil { 46 | log.Println(lg("API", "Twitter", color.HiRedString, "Error setting proxy: %s", err.Error())) 47 | } else { 48 | log.Println(lg("API", "Twitter", color.HiMagentaString, "Proxy set to "+config.Credentials.TwitterProxy)) 49 | } 50 | } 51 | } 52 | } 53 | twitterProxy(true) 54 | 55 | twitterImport := func() error { 56 | f, err := os.Open(pathCacheTwitter) 57 | if err != nil { 58 | return err 59 | } 60 | var cookies []*http.Cookie 61 | err = json.NewDecoder(f).Decode(&cookies) 62 | if err != nil { 63 | return err 64 | } 65 | twitterScraper.SetCookies(cookies) 66 | twitterScraper.IsLoggedIn() 67 | _, err = twitterScraper.GetProfile("x") 68 | if err != nil { 69 | return err 70 | } 71 | return nil 72 | } 73 | 74 | twitterExport := func() error { 75 | cookies := twitterScraper.GetCookies() 76 | js, err := json.Marshal(cookies) 77 | if err != nil { 78 | return err 79 | } 80 | f, err := os.Create(pathCacheTwitter) 81 | if err != nil { 82 | return err 83 | } 84 | f.Write(js) 85 | return nil 86 | } 87 | 88 | // Login Loop 89 | twitterLoginCount := 0 90 | do_twitter_login: 91 | twitterLoginCount++ 92 | if twitterLoginCount > 1 { 93 | time.Sleep(3 * time.Second) 94 | } 95 | 96 | if twitterImport() != nil { 97 | twitterScraper.ClearCookies() 98 | var loginerr error 99 | if config.Credentials.TwitterAuthToken != "" && config.Credentials.TwitterCT0 != "" { 100 | if config.Debug { 101 | log.Println(lg("API", "Twitter", color.YellowString, 102 | "Attempting login Auth Token & CT0...")) 103 | } 104 | twitterScraper.SetAuthToken(twitterscraper.AuthToken{ 105 | Token: config.Credentials.TwitterAuthToken, CSRFToken: config.Credentials.TwitterCT0}) 106 | } else if config.Credentials.Twitter2FA != "" { 107 | if config.Debug { 108 | log.Println(lg("API", "Twitter", color.YellowString, 109 | "Attempting login with 2FA/Username/Password...")) 110 | } 111 | loginerr = twitterScraper.Login( 112 | config.Credentials.TwitterUsername, config.Credentials.TwitterPassword, 113 | config.Credentials.Twitter2FA) 114 | } else { 115 | if config.Debug { 116 | log.Println(lg("API", "Twitter", color.YellowString, 117 | "Attempting login with Username/Password...")) 118 | } 119 | loginerr = twitterScraper.Login( 120 | config.Credentials.TwitterUsername, config.Credentials.TwitterPassword) 121 | } 122 | 123 | if loginerr != nil { 124 | log.Println(lg("API", "Twitter", color.HiRedString, "Login Error: %s", loginerr.Error())) 125 | if twitterLoginCount <= 3 { 126 | goto do_twitter_login 127 | } else { 128 | log.Println(lg("API", "Twitter", color.HiRedString, 129 | "Failed to login to Twitter (X), the bot will not fetch this media...")) 130 | } 131 | } else { 132 | if twitterScraper.IsLoggedIn() { 133 | log.Println(lg("API", "Twitter", color.HiMagentaString, fmt.Sprintf("Connected to @%s via new login", config.Credentials.TwitterUsername))) 134 | twitterConnected = true 135 | defer twitterExport() 136 | } else { 137 | log.Println(lg("API", "Twitter", color.HiRedString, 138 | "Scraper login seemed successful but bot is not logged in, Twitter (X) parsing may not work...")) 139 | } 140 | } 141 | } else { 142 | log.Println(lg("API", "Twitter", color.HiMagentaString, 143 | "Connected to @%s via cache", config.Credentials.TwitterUsername)) 144 | twitterConnected = true 145 | } 146 | 147 | if twitterConnected { 148 | twitterProxy(false) 149 | } 150 | } else { 151 | log.Println(lg("API", "Twitter", color.MagentaString, 152 | "Twitter (X) login missing, the bot will not fetch this media...")) 153 | } 154 | }() 155 | } else { 156 | log.Println(lg("API", "Twitter", color.RedString, 157 | "TWITTER AUTHENTICATION IS DISABLED IN SETTINGS...")) 158 | } 159 | 160 | // Instagram API 161 | if *config.Credentials.InstagramAuthEnabled { 162 | go func() { 163 | if config.Credentials.InstagramUsername != "" && 164 | config.Credentials.InstagramPassword != "" { 165 | log.Println(lg("API", "Instagram", color.MagentaString, "Connecting...")) 166 | 167 | // Proxy 168 | instagramProxy := func(logIt bool) { 169 | if config.Credentials.InstagramProxy != "" { 170 | insecure := false 171 | if config.Credentials.InstagramProxyInsecure != nil { 172 | insecure = *config.Credentials.InstagramProxyInsecure 173 | } 174 | forceHTTP2 := false 175 | if config.Credentials.InstagramProxyForceHTTP2 != nil { 176 | forceHTTP2 = *config.Credentials.InstagramProxyForceHTTP2 177 | } 178 | err := instagramClient.SetProxy(config.Credentials.InstagramProxy, insecure, forceHTTP2) 179 | if err != nil { 180 | log.Println(lg("API", "Instagram", color.HiRedString, "Error setting proxy: %s", err.Error())) 181 | } else { 182 | log.Println(lg("API", "Instagram", color.HiMagentaString, "Proxy set to "+config.Credentials.InstagramProxy)) 183 | } 184 | } 185 | } 186 | instagramProxy(true) 187 | 188 | // Login Loop 189 | instagramLoginCount := 0 190 | do_instagram_login: 191 | instagramLoginCount++ 192 | if instagramLoginCount > 1 { 193 | time.Sleep(3 * time.Second) 194 | } 195 | if instagramClient, err = goinsta.Import(pathCacheInstagram); err != nil { 196 | instagramClient = goinsta.New(config.Credentials.InstagramUsername, config.Credentials.InstagramPassword) 197 | if err := instagramClient.Login(); err != nil { 198 | // 2fa 199 | if strings.Contains(err.Error(), "two Factor Autentication required") { 200 | // Generate TOTP 201 | if config.Credentials.InstagramTOTP != nil { 202 | if *config.Credentials.InstagramTOTP != "" { 203 | instagramClient.SetTOTPSeed(*config.Credentials.InstagramTOTP) 204 | if err := instagramClient.TwoFactorInfo.Login2FA(); err != nil { 205 | log.Println(lg("API", "Instagram", color.HiRedString, "2FA-Generated Login Error: %s", err.Error())) 206 | if instagramLoginCount <= 3 { 207 | goto do_instagram_login 208 | } else { 209 | log.Println(lg("API", "Instagram", color.HiRedString, 210 | "Failed to login to Instagram, the bot will not fetch this media...")) 211 | } 212 | } else { 213 | log.Println(lg("API", "Instagram", color.HiMagentaString, 214 | "Connected to @%s via new 2FA-Generated login", instagramClient.Account.Username)) 215 | instagramConnected = true 216 | defer instagramClient.Export(pathCacheInstagram) 217 | } 218 | } 219 | } else { // Manual TOTP 220 | log.Println(lg("API", "Instagram", color.HiYellowString, 221 | "MANUAL 2FA LOGIN\nPlease enter OTP code... ")) 222 | reader := bufio.NewReader(os.Stdin) 223 | inputCode, _ := reader.ReadString('\n') 224 | inputCode = strings.ReplaceAll(inputCode, "\n", "") 225 | inputCode = strings.ReplaceAll(inputCode, "\r", "") 226 | if inputCode != "" { 227 | if err := instagramClient.TwoFactorInfo.Login2FA(inputCode); err != nil { 228 | log.Println(lg("API", "Instagram", color.HiRedString, "2FA-Code Login Error: %s", err.Error())) 229 | if instagramLoginCount <= 3 { 230 | goto do_instagram_login 231 | } else { 232 | log.Println(lg("API", "Instagram", color.HiRedString, 233 | "Failed to login to Instagram, the bot will not fetch this media...")) 234 | } 235 | } else { 236 | log.Println(lg("API", "Instagram", color.HiMagentaString, 237 | "Connected to @%s via new 2FA-Code login", instagramClient.Account.Username)) 238 | instagramConnected = true 239 | defer instagramClient.Export(pathCacheInstagram) 240 | } 241 | } else { 242 | log.Println(lg("API", "Instagram", color.HiRedString, 243 | "Blank OTP given... Try again.")) 244 | } 245 | } 246 | } else { // non-2fa 247 | log.Println(lg("API", "Instagram", color.HiRedString, "Login Error: %s", err.Error())) 248 | if instagramLoginCount <= 3 { 249 | goto do_instagram_login 250 | } else { 251 | log.Println(lg("API", "Instagram", color.HiRedString, 252 | "Failed to login to Instagram, the bot will not fetch this media...")) 253 | } 254 | } 255 | } else { 256 | log.Println(lg("API", "Instagram", color.HiMagentaString, 257 | "Connected to @%s via new login", instagramClient.Account.Username)) 258 | instagramConnected = true 259 | defer instagramClient.Export(pathCacheInstagram) 260 | } 261 | } else { 262 | log.Println(lg("API", "Instagram", color.HiMagentaString, 263 | "Connected to @%s via cache", instagramClient.Account.Username)) 264 | instagramConnected = true 265 | } 266 | if instagramConnected { 267 | instagramProxy(false) 268 | } 269 | } else { 270 | log.Println(lg("API", "Instagram", color.MagentaString, 271 | "Instagram login missing, the bot will not fetch this media...")) 272 | } 273 | }() 274 | } else { 275 | log.Println(lg("API", "Instagram", color.RedString, 276 | "INSTAGRAM AUTHENTICATION IS DISABLED IN SETTINGS...")) 277 | } 278 | 279 | mainWg.Done() 280 | } 281 | 282 | //#region Twitter 283 | 284 | func getTwitterUrls(inputURL string) (map[string]string, error) { 285 | parts := strings.Split(inputURL, ":") 286 | if len(parts) < 2 { 287 | return nil, errors.New("unable to parse Twitter URL") 288 | } 289 | return map[string]string{"https:" + parts[1] + ":orig": filenameFromURL(parts[1])}, nil 290 | } 291 | 292 | func getTwitterStatusUrls(inputURL string, m *discordgo.Message) (map[string]string, error) { 293 | if strings.Contains(inputURL, "/photo/") { 294 | inputURL = inputURL[:strings.Index(inputURL, "/photo/")] 295 | } 296 | if strings.Contains(inputURL, "/video/") { 297 | inputURL = inputURL[:strings.Index(inputURL, "/video/")] 298 | } 299 | 300 | matches := regexUrlTwitterStatus.FindStringSubmatch(inputURL) 301 | _, err := strconv.ParseInt(matches[4], 10, 64) 302 | if err != nil { 303 | return nil, err 304 | } 305 | 306 | // Sometimes it fails to fetch actual content on first request. 307 | retryCount := 0 308 | retryTwitter: 309 | retryCount++ 310 | tweet, err := twitterScraper.GetTweet(matches[4]) 311 | if err != nil { 312 | return nil, err 313 | } 314 | 315 | links := make(map[string]string) 316 | for _, photo := range tweet.Photos { 317 | foundUrls := getParsedLinks(photo.URL, m) 318 | for foundUrlKey, foundUrlValue := range foundUrls { 319 | links[foundUrlKey] = foundUrlValue 320 | } 321 | } 322 | for _, video := range tweet.Videos { 323 | foundUrls := getParsedLinks(video.URL, m) 324 | for foundUrlKey, foundUrlValue := range foundUrls { 325 | links[foundUrlKey] = foundUrlValue 326 | } 327 | } 328 | for _, gif := range tweet.GIFs { 329 | foundUrls := getParsedLinks(gif.URL, m) 330 | for foundUrlKey, foundUrlValue := range foundUrls { 331 | links[foundUrlKey] = foundUrlValue 332 | } 333 | } 334 | 335 | // Sometimes it fails to fetch actual content on first request. 336 | if len(links) == 0 && retryCount < 3 { 337 | if config.Debug { 338 | log.Println(lg("API", "Twitter", color.HiRedString, "No content found in post, retrying %s...", matches[4])) 339 | } 340 | time.Sleep(1 * time.Second) 341 | goto retryTwitter 342 | } 343 | 344 | return links, nil 345 | } 346 | 347 | //#endregion 348 | 349 | //#region Instagram 350 | 351 | func getInstagramUrls(inputURL string, m *discordgo.Message) (map[string]string, error) { 352 | if instagramClient == nil { 353 | return nil, errors.New("invalid Instagram API credentials") 354 | } 355 | 356 | links := make(map[string]string) 357 | 358 | // fix 359 | shortcode := inputURL 360 | if strings.Contains(shortcode, ".com/p/") { 361 | shortcode = shortcode[strings.Index(shortcode, ".com/p/")+7:] 362 | } 363 | if strings.Contains(shortcode, ".com/reel/") { 364 | shortcode = shortcode[strings.Index(shortcode, ".com/reel/")+10:] 365 | } 366 | shortcode = strings.ReplaceAll(shortcode, "/", "") 367 | 368 | // fetch 369 | mediaID, err := goinsta.MediaIDFromShortID(shortcode) 370 | if err == nil { 371 | media, err := instagramClient.GetMedia(mediaID) 372 | if err != nil { 373 | return nil, err 374 | } else { 375 | postType := media.Items[0].MediaToString() 376 | if postType == "carousel" { 377 | for index, item := range media.Items[0].CarouselMedia { 378 | itemType := item.MediaToString() 379 | if itemType == "video" { 380 | url := item.Videos[0].URL 381 | links[url] = fmt.Sprintf("%s %d %s", shortcode, index, media.Items[0].User.Username) 382 | } else if itemType == "photo" { 383 | url := item.Images.GetBest() 384 | links[url] = fmt.Sprintf("%s %d %s", shortcode, index, media.Items[0].User.Username) 385 | } 386 | } 387 | } else if postType == "video" { 388 | url := media.Items[0].Videos[0].URL 389 | links[url] = fmt.Sprintf("%s %s", shortcode, media.Items[0].User.Username) 390 | } else if postType == "photo" { 391 | url := media.Items[0].Images.GetBest() 392 | links[url] = fmt.Sprintf("%s %s", shortcode, media.Items[0].User.Username) 393 | } 394 | } 395 | } 396 | 397 | return links, nil 398 | } 399 | 400 | //#endregion 401 | 402 | //#region Imgur 403 | 404 | func getImgurSingleUrls(url string) (map[string]string, error) { 405 | url = regexp.MustCompile(`(r\/[^\/]+\/)`).ReplaceAllString(url, "") // remove subreddit url 406 | url = strings.Replace(url, "imgur.com/", "imgur.com/download/", -1) 407 | url = strings.Replace(url, ".gifv", "", -1) 408 | return map[string]string{url: ""}, nil 409 | } 410 | 411 | type imgurAlbumObject struct { 412 | Data []struct { 413 | Link string 414 | } 415 | } 416 | 417 | func getImgurAlbumUrls(url string) (map[string]string, error) { 418 | url = regexp.MustCompile(`(#[A-Za-z0-9]+)?$`).ReplaceAllString(url, "") // remove anchor 419 | afterLastSlash := strings.LastIndex(url, "/") 420 | albumId := url[afterLastSlash+1:] 421 | headers := make(map[string]string) 422 | headers["Authorization"] = "Client-ID " + imgurClientID 423 | imgurAlbumObject := new(imgurAlbumObject) 424 | getJSONwithHeaders("https://api.imgur.com/3/album/"+albumId+"/images", imgurAlbumObject, headers) 425 | links := make(map[string]string) 426 | for _, v := range imgurAlbumObject.Data { 427 | links[v.Link] = "" 428 | } 429 | if len(links) <= 0 { 430 | return getImgurSingleUrls(url) 431 | } 432 | log.Printf("Found imgur album with %d images (url: %s)\n", len(links), url) 433 | return links, nil 434 | } 435 | 436 | //#endregion 437 | 438 | //#region Streamable 439 | 440 | type streamableObject struct { 441 | Status int `json:"status"` 442 | Title string `json:"title"` 443 | Files struct { 444 | Mp4 struct { 445 | URL string `json:"url"` 446 | Width int `json:"width"` 447 | Height int `json:"height"` 448 | } `json:"mp4"` 449 | Mp4Mobile struct { 450 | URL string `json:"url"` 451 | Width int `json:"width"` 452 | Height int `json:"height"` 453 | } `json:"mp4-mobile"` 454 | } `json:"files"` 455 | URL string `json:"url"` 456 | ThumbnailURL string `json:"thumbnail_url"` 457 | Message interface{} `json:"message"` 458 | } 459 | 460 | func getStreamableUrls(url string) (map[string]string, error) { 461 | matches := regexUrlStreamable.FindStringSubmatch(url) 462 | shortcode := matches[3] 463 | if shortcode == "" { 464 | return nil, errors.New("unable to get shortcode from URL") 465 | } 466 | reqUrl := fmt.Sprintf("https://api.streamable.com/videos/%s", shortcode) 467 | streamable := new(streamableObject) 468 | getJSON(reqUrl, streamable) 469 | if streamable.Status != 2 || streamable.Files.Mp4.URL == "" { 470 | return nil, errors.New("streamable object has no download candidate") 471 | } 472 | link := streamable.Files.Mp4.URL 473 | if !strings.HasPrefix(link, "http") { 474 | link = "https:" + link 475 | } 476 | links := make(map[string]string) 477 | links[link] = "" 478 | return links, nil 479 | } 480 | 481 | //#endregion 482 | 483 | //#region Gfycat 484 | 485 | type gfycatObject struct { 486 | GfyItem struct { 487 | Mp4URL string `json:"mp4Url"` 488 | } `json:"gfyItem"` 489 | } 490 | 491 | func getGfycatUrls(url string) (map[string]string, error) { 492 | parts := strings.Split(url, "/") 493 | if len(parts) < 3 { 494 | return nil, errors.New("unable to parse Gfycat URL") 495 | } 496 | gfycatId := parts[len(parts)-1] 497 | gfycatObject := new(gfycatObject) 498 | getJSON("https://api.gfycat.com/v1/gfycats/"+gfycatId, gfycatObject) 499 | gfycatUrl := gfycatObject.GfyItem.Mp4URL 500 | if url == "" { 501 | return nil, errors.New("failed to read response from Gfycat") 502 | } 503 | return map[string]string{gfycatUrl: ""}, nil 504 | } 505 | 506 | //#endregion 507 | 508 | //#region Flickr 509 | 510 | type flickrPhotoSizeObject struct { 511 | Label string `json:"label"` 512 | Width int `json:"width"` 513 | Height int `json:"height"` 514 | Source string `json:"source"` 515 | URL string `json:"url"` 516 | Media string `json:"media"` 517 | } 518 | 519 | type flickrPhotoObject struct { 520 | Sizes struct { 521 | Canblog int `json:"canblog"` 522 | Canprint int `json:"canprint"` 523 | Candownload int `json:"candownload"` 524 | Size []flickrPhotoSizeObject `json:"size"` 525 | } `json:"sizes"` 526 | Stat string `json:"stat"` 527 | } 528 | 529 | func getFlickrUrlFromPhotoId(photoId string) string { 530 | reqUrl := fmt.Sprintf("https://www.flickr.com/services/rest/?format=json&nojsoncallback=1&method=%s&api_key=%s&photo_id=%s", 531 | "flickr.photos.getSizes", config.Credentials.FlickrApiKey, photoId) 532 | flickrPhoto := new(flickrPhotoObject) 533 | getJSON(reqUrl, flickrPhoto) 534 | var bestSize flickrPhotoSizeObject 535 | for _, size := range flickrPhoto.Sizes.Size { 536 | if bestSize.Label == "" { 537 | bestSize = size 538 | } else { 539 | if size.Width > bestSize.Width || size.Height > bestSize.Height { 540 | bestSize = size 541 | } 542 | } 543 | } 544 | return bestSize.Source 545 | } 546 | 547 | func getFlickrPhotoUrls(url string) (map[string]string, error) { 548 | if config.Credentials.FlickrApiKey == "" { 549 | return nil, errors.New("invalid Flickr API Key Set") 550 | } 551 | matches := regexUrlFlickrPhoto.FindStringSubmatch(url) 552 | photoId := matches[5] 553 | if photoId == "" { 554 | return nil, errors.New("unable to get Photo ID from URL") 555 | } 556 | return map[string]string{getFlickrUrlFromPhotoId(photoId): ""}, nil 557 | } 558 | 559 | type flickrAlbumObject struct { 560 | Photoset struct { 561 | ID string `json:"id"` 562 | Primary string `json:"primary"` 563 | Owner string `json:"owner"` 564 | Ownername string `json:"ownername"` 565 | Photo []struct { 566 | ID string `json:"id"` 567 | Secret string `json:"secret"` 568 | Server string `json:"server"` 569 | Farm int `json:"farm"` 570 | Title string `json:"title"` 571 | Isprimary string `json:"isprimary"` 572 | Ispublic int `json:"ispublic"` 573 | Isfriend int `json:"isfriend"` 574 | Isfamily int `json:"isfamily"` 575 | } `json:"photo"` 576 | Page int `json:"page"` 577 | PerPage int `json:"per_page"` 578 | Perpage int `json:"perpage"` 579 | Pages int `json:"pages"` 580 | Total string `json:"total"` 581 | Title string `json:"title"` 582 | } `json:"photoset"` 583 | Stat string `json:"stat"` 584 | } 585 | 586 | func getFlickrAlbumUrls(url string) (map[string]string, error) { 587 | if config.Credentials.FlickrApiKey == "" { 588 | return nil, errors.New("invalid Flickr API Key Set") 589 | } 590 | matches := regexUrlFlickrAlbum.FindStringSubmatch(url) 591 | if len(matches) < 10 || matches[9] == "" { 592 | return nil, errors.New("unable to find Flickr Album ID in URL") 593 | } 594 | albumId := matches[9] 595 | if albumId == "" { 596 | return nil, errors.New("unable to get Album ID from URL") 597 | } 598 | reqUrl := fmt.Sprintf("https://www.flickr.com/services/rest/?format=json&nojsoncallback=1&method=%s&api_key=%s&photoset_id=%s&per_page=500", 599 | "flickr.photosets.getPhotos", config.Credentials.FlickrApiKey, albumId) 600 | flickrAlbum := new(flickrAlbumObject) 601 | getJSON(reqUrl, flickrAlbum) 602 | links := make(map[string]string) 603 | for _, photo := range flickrAlbum.Photoset.Photo { 604 | links[getFlickrUrlFromPhotoId(photo.ID)] = "" 605 | } 606 | return links, nil 607 | } 608 | 609 | func getFlickrAlbumShortUrls(url string) (map[string]string, error) { 610 | result, err := http.Get(url) 611 | if err != nil { 612 | return nil, errors.New("Error getting long URL from shortened Flickr Album URL: " + err.Error()) 613 | } 614 | if regexUrlFlickrAlbum.MatchString(result.Request.URL.String()) { 615 | return getFlickrAlbumUrls(result.Request.URL.String()) 616 | } 617 | return nil, errors.New("encountered invalid URL while trying to get long URL from short Flickr Album URL") 618 | } 619 | 620 | //#endregion 621 | 622 | //#region Tistory 623 | 624 | // getTistoryUrls downloads tistory URLs 625 | // http://t1.daumcdn.net/cfile/tistory/[…] => http://t1.daumcdn.net/cfile/tistory/[…] 626 | // http://t1.daumcdn.net/cfile/tistory/[…]?original => as is 627 | func getTistoryUrls(link string) (map[string]string, error) { 628 | if !strings.HasSuffix(link, "?original") { 629 | link += "?original" 630 | } 631 | return map[string]string{link: ""}, nil 632 | } 633 | 634 | func getLegacyTistoryUrls(link string) (map[string]string, error) { 635 | link = strings.Replace(link, "/image/", "/original/", -1) 636 | return map[string]string{link: ""}, nil 637 | } 638 | 639 | func getTistoryWithCDNUrls(urlI string) (map[string]string, error) { 640 | parameters, _ := url.ParseQuery(urlI) 641 | if val, ok := parameters["fname"]; ok { 642 | if len(val) > 0 { 643 | if regexUrlTistoryLegacy.MatchString(val[0]) { 644 | return getLegacyTistoryUrls(val[0]) 645 | } 646 | } 647 | } 648 | return nil, nil 649 | } 650 | 651 | func getPossibleTistorySiteUrls(url string) (map[string]string, error) { 652 | client := new(http.Client) 653 | request, err := http.NewRequest("HEAD", url, nil) 654 | if err != nil { 655 | return nil, err 656 | } 657 | request.Header.Add("Accept-Encoding", "identity") 658 | request.Header.Add("User-Agent", sneakyUserAgent) 659 | respHead, err := client.Do(request) 660 | if err != nil { 661 | return nil, err 662 | } 663 | 664 | contentType := "" 665 | for headerKey, headerValue := range respHead.Header { 666 | if headerKey == "Content-Type" { 667 | contentType = headerValue[0] 668 | } 669 | } 670 | if !strings.Contains(contentType, "text/html") { 671 | return nil, nil 672 | } 673 | 674 | request, err = http.NewRequest("GET", url, nil) 675 | if err != nil { 676 | return nil, err 677 | } 678 | request.Header.Add("Accept-Encoding", "identity") 679 | request.Header.Add("User-Agent", sneakyUserAgent) 680 | resp, err := client.Do(request) 681 | if err != nil { 682 | return nil, err 683 | } 684 | 685 | doc, err := goquery.NewDocumentFromResponse(resp) 686 | if err != nil { 687 | return nil, err 688 | } 689 | 690 | var links = make(map[string]string) 691 | 692 | doc.Find(".article img, #content img, div[role=main] img, .section_blogview img").Each(func(i int, s *goquery.Selection) { 693 | foundUrl, exists := s.Attr("src") 694 | if exists { 695 | if regexUrlTistoryLegacyWithCDN.MatchString(foundUrl) { 696 | finalTistoryUrls, _ := getTistoryWithCDNUrls(foundUrl) 697 | if len(finalTistoryUrls) > 0 { 698 | for finalTistoryUrl := range finalTistoryUrls { 699 | foundFilename := s.AttrOr("filename", "") 700 | links[finalTistoryUrl] = foundFilename 701 | } 702 | } 703 | } else if regexUrlTistoryLegacy.MatchString(foundUrl) { 704 | finalTistoryUrls, _ := getLegacyTistoryUrls(foundUrl) 705 | if len(finalTistoryUrls) > 0 { 706 | for finalTistoryUrl := range finalTistoryUrls { 707 | foundFilename := s.AttrOr("filename", "") 708 | links[finalTistoryUrl] = foundFilename 709 | } 710 | } 711 | } 712 | } 713 | }) 714 | 715 | if len(links) > 0 { 716 | log.Printf("[%s] Found tistory album with %d images (url: %s)\n", time.Now().Format(time.Stamp), len(links), url) 717 | } 718 | return links, nil 719 | } 720 | 721 | //#endregion 722 | 723 | //#region Reddit 724 | 725 | // This is very crude but works for now 726 | type redditThreadObject []struct { 727 | Kind string `json:"kind"` 728 | Data struct { 729 | Children interface{} `json:"children"` 730 | } `json:"data"` 731 | } 732 | 733 | func getRedditPostUrls(link string) (map[string]string, error) { 734 | if strings.Contains(link, "?") { 735 | link = link[:strings.Index(link, "?")] 736 | } 737 | redditThread := new(redditThreadObject) 738 | headers := make(map[string]string) 739 | headers["Accept-Encoding"] = "identity" 740 | headers["User-Agent"] = sneakyUserAgent 741 | err := getJSONwithHeaders(link+".json", redditThread, headers) 742 | if err != nil { 743 | return nil, fmt.Errorf("failed to parse json from reddit post:\t%s", err) 744 | } 745 | 746 | redditPost := (*redditThread)[0].Data.Children.([]interface{})[0].(map[string]interface{}) 747 | redditPostData := redditPost["data"].(map[string]interface{}) 748 | if redditPostData["url_overridden_by_dest"] != nil { 749 | redditLink := redditPostData["url_overridden_by_dest"].(string) 750 | filename := fmt.Sprintf("Reddit-%s_%s %s", redditPostData["subreddit"].(string), redditPostData["id"].(string), filenameFromURL(redditLink)) 751 | return map[string]string{redditLink: filename}, nil 752 | } 753 | return nil, nil 754 | } 755 | 756 | //#endregion 757 | -------------------------------------------------------------------------------- /regex.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | //TODO: Reddit short url ... https://redd.it/post_code 8 | 9 | const ( 10 | regexpUrlTwitter = `^http(s?):\/\/pbs(-[0-9]+)?\.twimg\.com\/media\/[^\./]+\.(jpg|png|jpglarge)((\:[a-z]+)?)$` 11 | regexpUrlTwitterStatus = `^http(s?):\/\/(www\.)?twitter\.com\/([A-Za-z0-9-_\.]+\/status\/|statuses\/|i\/web\/status\/)([0-9]+)$` 12 | regexpUrlInstagram = `^http(s?):\/\/(www\.)?instagram\.com\/p\/[^/]+\/(\?[^/]+)?$` 13 | regexpUrlInstagramReel = `^http(s?):\/\/(www\.)?instagram\.com\/reel\/[^/]+\/(\?[^/]+)?$` 14 | regexpUrlImgurSingle = `^http(s?):\/\/(i\.)?imgur\.com\/[A-Za-z0-9]+(\.gifv)?$` 15 | regexpUrlImgurAlbum = `^http(s?):\/\/imgur\.com\/(a\/|gallery\/|r\/[^\/]+\/)[A-Za-z0-9]+(#[A-Za-z0-9]+)?$` 16 | regexpUrlStreamable = `^http(s?):\/\/(www\.)?streamable\.com\/([0-9a-z]+)$` 17 | regexpUrlGfycat = `^http(s?):\/\/gfycat\.com\/(gifs\/detail\/)?[A-Za-z]+$` 18 | regexpUrlFlickrPhoto = `^http(s)?:\/\/(www\.)?flickr\.com\/photos\/([0-9]+)@([A-Z0-9]+)\/([0-9]+)(\/)?(\/in\/album-([0-9]+)(\/)?)?$` 19 | regexpUrlFlickrAlbum = `^http(s)?:\/\/(www\.)?flickr\.com\/photos\/(([0-9]+)@([A-Z0-9]+)|[A-Za-z0-9]+)\/(albums\/(with\/)?|(sets\/)?)([0-9]+)(\/)?$` 20 | regexpUrlFlickrAlbumShort = `^http(s)?:\/\/((www\.)?flickr\.com\/gp\/[0-9]+@[A-Z0-9]+\/[A-Za-z0-9]+|flic\.kr\/s\/[a-zA-Z0-9]+)$` 21 | regexpUrlTistory = `^http(s?):\/\/t[0-9]+\.daumcdn\.net\/cfile\/tistory\/([A-Z0-9]+?)(\?original)?$` 22 | regexpUrlTistoryLegacy = `^http(s?):\/\/[a-z0-9]+\.uf\.tistory\.com\/(image|original)\/[A-Z0-9]+$` 23 | regexpUrlTistoryLegacyWithCDN = `^http(s)?:\/\/[0-9a-z]+.daumcdn.net\/[a-z]+\/[a-zA-Z0-9\.]+\/\?scode=mtistory&fname=http(s?)%3A%2F%2F[a-z0-9]+\.uf\.tistory\.com%2F(image|original)%2F[A-Z0-9]+$` 24 | regexpUrlPossibleTistorySite = `^http(s)?:\/\/[0-9a-zA-Z\.-]+\/(m\/)?(photo\/)?[0-9]+$` 25 | regexpUrlRedditPost = `^http(s?):\/\/(www\.)?reddit\.com\/r\/([0-9a-zA-Z'_]+)?\/comments\/([0-9a-zA-Z'_]+)\/?([0-9a-zA-Z'_]+)?(.*)?$` 26 | ) 27 | 28 | var ( 29 | regexUrlTwitter *regexp.Regexp 30 | regexUrlTwitterStatus *regexp.Regexp 31 | regexUrlInstagram *regexp.Regexp 32 | regexUrlInstagramReel *regexp.Regexp 33 | regexUrlImgurSingle *regexp.Regexp 34 | regexUrlImgurAlbum *regexp.Regexp 35 | regexUrlStreamable *regexp.Regexp 36 | regexUrlGfycat *regexp.Regexp 37 | regexUrlFlickrPhoto *regexp.Regexp 38 | regexUrlFlickrAlbum *regexp.Regexp 39 | regexUrlFlickrAlbumShort *regexp.Regexp 40 | regexUrlTistory *regexp.Regexp 41 | regexUrlTistoryLegacy *regexp.Regexp 42 | regexUrlTistoryLegacyWithCDN *regexp.Regexp 43 | regexUrlPossibleTistorySite *regexp.Regexp 44 | regexUrlRedditPost *regexp.Regexp 45 | ) 46 | 47 | func compileRegex() error { 48 | var err error 49 | 50 | if regexUrlTwitter, err = regexp.Compile(regexpUrlTwitter); err != nil { 51 | return err 52 | } 53 | if regexUrlTwitterStatus, err = regexp.Compile(regexpUrlTwitterStatus); err != nil { 54 | return err 55 | } 56 | if regexUrlInstagram, err = regexp.Compile(regexpUrlInstagram); err != nil { 57 | return err 58 | } 59 | if regexUrlInstagramReel, err = regexp.Compile(regexpUrlInstagramReel); err != nil { 60 | return err 61 | } 62 | if regexUrlImgurSingle, err = regexp.Compile(regexpUrlImgurSingle); err != nil { 63 | return err 64 | } 65 | if regexUrlImgurAlbum, err = regexp.Compile(regexpUrlImgurAlbum); err != nil { 66 | return err 67 | } 68 | if regexUrlStreamable, err = regexp.Compile(regexpUrlStreamable); err != nil { 69 | return err 70 | } 71 | if regexUrlGfycat, err = regexp.Compile(regexpUrlGfycat); err != nil { 72 | return err 73 | } 74 | if regexUrlFlickrPhoto, err = regexp.Compile(regexpUrlFlickrPhoto); err != nil { 75 | return err 76 | } 77 | if regexUrlFlickrAlbum, err = regexp.Compile(regexpUrlFlickrAlbum); err != nil { 78 | return err 79 | } 80 | if regexUrlFlickrAlbumShort, err = regexp.Compile(regexpUrlFlickrAlbumShort); err != nil { 81 | return err 82 | } 83 | if regexUrlTistory, err = regexp.Compile(regexpUrlTistory); err != nil { 84 | return err 85 | } 86 | if regexUrlTistoryLegacy, err = regexp.Compile(regexpUrlTistoryLegacy); err != nil { 87 | return err 88 | } 89 | if regexUrlTistoryLegacyWithCDN, err = regexp.Compile(regexpUrlTistoryLegacyWithCDN); err != nil { 90 | return err 91 | } 92 | if regexUrlPossibleTistorySite, err = regexp.Compile(regexpUrlPossibleTistorySite); err != nil { 93 | return err 94 | } 95 | if regexUrlRedditPost, err = regexp.Compile(regexpUrlRedditPost); err != nil { 96 | return err 97 | } 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /vars.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | var ( 8 | projectName = "discord-downloader-go" 9 | projectLabel = "Discord Downloader GO" 10 | projectRepoBase = "get-got/discord-downloader-go" 11 | projectRepoURL = "https://github.com/" + projectRepoBase 12 | projectIcon = "https://cdn.discordapp.com/icons/780985109608005703/9dc25f1b91e6d92664590254e0797fad.webp?size=256" 13 | projectVersion = "2.6.1" // follows Semantic Versioning, (http://semver.org/) 14 | 15 | pathCache = "cache" 16 | pathCacheHistory = pathCache + string(os.PathSeparator) + "history" 17 | pathCacheSettingsJSON = pathCache + string(os.PathSeparator) + "settings.json" 18 | pathCacheSettingsYAML = pathCache + string(os.PathSeparator) + "settings.yaml" 19 | pathCacheDuplo = pathCache + string(os.PathSeparator) + ".duplo" 20 | pathCacheTwitter = pathCache + string(os.PathSeparator) + "twitter.json" 21 | pathCacheInstagram = pathCache + string(os.PathSeparator) + "instagram.json" 22 | pathConstants = pathCache + string(os.PathSeparator) + "constants.json" 23 | pathDatabaseBase = "database" 24 | pathDatabaseBackups = "backups" 25 | 26 | defaultReact = "✅" 27 | limitMsg = 2000 28 | ) 29 | --------------------------------------------------------------------------------