├── .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 |

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 |
30 |
31 |
32 |
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 |
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 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
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 |
--------------------------------------------------------------------------------