├── .golangci.yml ├── LICENSE ├── README.md ├── crackwatch └── crackwatch.go ├── go.mod ├── go.sum ├── images ├── icon.png └── results.png └── main.go /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 15s 3 | allow-parallel-runners: true 4 | 5 | linters-settings: 6 | depguard: 7 | list-type: blacklist 8 | include-go-root: true 9 | packages: 10 | # - log 11 | packages-with-error-message: 12 | # specify an error message to output when a blacklisted package is used 13 | # log: "logging is allowed only by our custom logger" 14 | dogsled: 15 | # checks assignments with too many blank identifiers; default is 2 16 | max-blank-identifiers: 2 17 | dupl: 18 | # tokens count to trigger issue, 150 by default 19 | threshold: 50 20 | errcheck: 21 | # report about not checking of errors in type assetions: `a := b.(MyStruct)`; 22 | # default is false: such cases aren't reported by default. 23 | check-type-assertions: true 24 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 25 | # default is false: such cases aren't reported by default. 26 | check-blank: false 27 | funlen: 28 | lines: 60 29 | statements: 40 30 | gocognit: 31 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 32 | min-complexity: 20 33 | goconst: 34 | # minimal length of string constant, 3 by default 35 | min-len: 3 36 | # minimal occurrences count to trigger, 3 by default 37 | min-occurrences: 3 38 | gocritic: 39 | # Which checks should be enabled; can't be combined with 'disabled-checks'; 40 | # See https://go-critic.github.io/overview#checks-overview 41 | # To check which checks are enabled run `GL_DEBUG=gocritic golangci-lint run` 42 | # By default list of stable checks is used. 43 | # run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks. 44 | enabled-tags: 45 | - diagnostic 46 | - experimental 47 | - opinionated 48 | - performance 49 | - style 50 | # Which checks should be disabled; can't be combined with 'enabled-checks'; default is empty 51 | disabled-checks: 52 | - commentedOutCode 53 | - commentFormatting 54 | - whyNoLint 55 | # settings: # settings passed to gocritic 56 | # captLocal: # must be valid enabled check name 57 | # paramsOnly: true 58 | # rangeValCopy: 59 | # sizeThreshold: 32 60 | gocyclo: 61 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 62 | min-complexity: 10 63 | godox: 64 | # report any comments starting with keywords, this is useful for comments that 65 | # might be left in the code accidentally and should be resolved before merging 66 | keywords: 67 | # - NOTE 68 | - TODO 69 | - FIXME 70 | - OPTIMIZE 71 | - HACK 72 | gofmt: 73 | # simplify code: gofmt with `-s` option, true by default 74 | simplify: true 75 | gofumpt: 76 | # Choose whether or not to use the extra rules that are disabled by default. 77 | extra-rules: true 78 | goimports: 79 | # put imports beginning with prefix after 3rd-party packages; 80 | # it's a comma-separated list of prefixes 81 | # local-prefixes: github.com/org/project 82 | golint: 83 | # minimal confidence for issues, default is 0.8 84 | min-confidence: 0.0 85 | gomnd: 86 | settings: 87 | mnd: 88 | # https://github.com/tommy-muehle/go-mnd/#checks 89 | checks: case,condition,operation,return #argument,assign 90 | # ignored-numbers: 0,1 # Can't use this until https://github.com/golangci/golangci-lint/pull/939 is merged 91 | govet: 92 | # report about shadowed variables 93 | check-shadowing: true 94 | # settings per analyzer 95 | # settings: 96 | # printf: # analyzer name, run `go tool vet help` to see all analyzers 97 | # funcs: # run `go tool vet help printf` to see available settings for `printf` analyzer 98 | # - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof 99 | # - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf 100 | # - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf 101 | # - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf 102 | lll: 103 | # max line length, lines longer will be reported. Default is 120. 104 | # '\t' is counted as 1 character by default, and can be changed with the tab-width option 105 | line-length: 80 106 | # tab width in spaces. Default to 1. 107 | tab-width: 4 108 | maligned: 109 | # print struct with more effective memory layout or not, false by default 110 | suggest-new: true 111 | misspell: 112 | # Correct spellings using locale preferences for US or UK. 113 | # Default is to use a neutral variety of English. 114 | # Setting locale to US will correct the British spelling of 'colour' to 'color'. 115 | locale: en-CA # default was "US" 116 | ignore-words: 117 | # - someword 118 | nakedret: 119 | # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 120 | max-func-lines: 0 121 | prealloc: 122 | # XXX: we don't recommend using this linter before doing performance profiling. 123 | # For most programs usage of prealloc will be a premature optimization. 124 | 125 | # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. 126 | # True by default. 127 | simple: true 128 | range-loops: true # Report preallocation suggestions on range loops, true by default 129 | for-loops: false # Report preallocation suggestions on for loops, false by default 130 | unparam: 131 | # Inspect exported functions, default is false. Set to true if no external program/library imports your code. 132 | # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: 133 | # if it's called for subdir of a project it can't find external interfaces. All text editor integrations 134 | # with golangci-lint call it on a directory with the changed file. 135 | check-exported: false 136 | whitespace: 137 | multi-if: false # Enforces newlines (or comments) after every multi-line if statement 138 | multi-func: false # Enforces newlines (or comments) after every multi-line function signature 139 | wsl: 140 | # If true append is only allowed to be cuddled if appending value is 141 | # matching variables, fields or types on line above. Default is true. 142 | strict-append: true 143 | # Allow calls and assignments to be cuddled as long as the lines have any 144 | # matching variables, fields or types. Default is true. 145 | allow-assign-and-call: true 146 | # Allow multiline assignments to be cuddled. Default is true. 147 | allow-multiline-assign: true 148 | # Allow declarations (var) to be cuddled. 149 | allow-cuddle-declarations: false 150 | # Allow trailing comments in ending of blocks 151 | allow-trailing-comment: false 152 | # Force newlines in end of case at this limit (0 = never). 153 | force-case-trailing-whitespace: 0 154 | 155 | linters: 156 | enable-all: true 157 | disable: 158 | - goimports 159 | - gofmt 160 | # False positives due to not being able to handle our comment style. 161 | - godot 162 | - godox 163 | - goprintffuncname 164 | - wsl 165 | # I _want_ to be able to test private functions. 166 | - testpackage 167 | # This application is far too small to worry about globals. 168 | - gochecknoglobals 169 | # Usually a good idea, but in this case I log and return user-errors one 170 | # right after another, and I feel it's better these are connecated. 171 | - nlreturn 172 | fast: false 173 | 174 | issues: 175 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 176 | max-issues-per-linter: 0 177 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 178 | max-same-issues: 0 179 | # Excluding configuration per-path, per-linter, per-text and per-source 180 | exclude-rules: 181 | # Exclude line length checks on tests. 182 | - path: _test\.go 183 | linters: 184 | - lll 185 | # Exclude comments. 186 | - linters: 187 | - lll 188 | source: "^[\t]*//" 189 | # Exclude URLs in comments. 190 | - linters: 191 | - lll 192 | source: "^[\t]*// http|^[\t]*// http" 193 | # I don't care about godoc, I care more about exporting things I don't want 194 | # users to have manual control over. 195 | - linters: 196 | - golint 197 | text: "returns unexported type" 198 | - linters: 199 | - golint 200 | text: "error strings should not be capitalized or end with punctuation or a newline" 201 | # These two check to see if comments on exported variables start with the variable 202 | # name itself. This doesn't work all the time since sometimes we just want 203 | # to include a "NOTE:". 204 | - linters: 205 | - golint 206 | text: "comment on exported var" 207 | - linters: 208 | - stylecheck 209 | text: "ST1022: comment on exported var" 210 | - linters: 211 | - stylecheck 212 | text: "error strings should not end with punctuation or a newline" 213 | - linters: 214 | - stylecheck 215 | text: "ST1005: error strings should not be capitalized" 216 | # This linter wants every single error to use fmt.Errorf(). We don't. 217 | - linters: 218 | - goerr113 219 | text: "do not define dynamic errors" 220 | # CURL is the correct capitalization if at the beginning of an exported 221 | # function. 222 | - linters: 223 | - gocritic 224 | text: "should not be capitalized" 225 | source: "CURL" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, theGeekPirate 2 | 3 | Permission to use, copy, modify, and distribute this software for any purpose 4 | with or without fee is hereby granted, provided that the above copyright notice 5 | and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 9 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 11 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 12 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 13 | THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Example of what the results appear like in Discord](/images/results.png) 2 | 3 | # PLEASE READ 4 | 5 | [crackwatch.com](https://crackwatch.com) is currently down, and I'm unsure when it will come back up. I'll take a peek every now and then, and assuming they don't change too much, I'll make it work with the new site as well. 6 | 7 | # Description 8 | A bot which uses the websocket connection at [crackwatch.com](https://crackwatch.com) to query results.\ 9 | Should work with no issues on any platform supported by Go. 10 | 11 | # Why? 12 | Because 13 | [the other guys who were making one](https://old.reddit.com/r/CrackWatch/comments/i34eel/discord_bot_prototype) 14 | thought it would be a good idea to\ 15 | keep the source code closed, even though this is a trivial application.\ 16 | In case anyone isn't aware, these bots are able to log any and all messages on\ 17 | the channels they have access to, so it's imperative you run them yourselves.\ 18 | It irritated me enough that I wrote this in a few hours, even though I have\ 19 | no use for it.\ 20 | Feel free to fork the repo and change it for your own usecase, just follow the\ 21 | simple terms of the 22 | [ISC](https://en.wikipedia.org/wiki/ISC_license) 23 | license by making sure you keep an exact copy of the\ 24 | LICENSE file in your repo. 25 | 26 | # Usage 27 | In Discord, enter in the bot command (`!crack` by default) followed by your\ 28 | search term: `!crack test`\ 29 | If there's multiple pages, enter the page number you'd like right after the bot\ 30 | command: `!crack2 test` 31 | 32 | # Run From Source 33 | `go run . -token=` 34 | 35 | # Build and Run 36 | `go build && ./CrackWatchDiscordBot -token=` 37 | 38 | # I _REALLY_ Want to Try Before I Compile 39 | Please be aware that this bot (or anyone else's) should NOT be trusted, as they\ 40 | **all** have the ability to log any and all messages on the channels they have\ 41 | access to. This is here only to demonstrate the functionality before running it\ 42 | yourself. 43 | 44 | https://discord.com/oauth2/authorize?client_id=741678700033998888&permissions=2048&scope=bot 45 | 46 | # Getting Your Very Own Bot Token 47 | 1) Create a new application at https://discord.com/developers/applications 48 | 2) From the menu on the left, click on "Bot" 49 | 3) Click on "Add Bot" 50 | 4) Click on "Click to Reveal Token" 51 | 52 | # Creating the Bot Invite Link 53 | 1) Select the bot from https://discord.com/developers/applications 54 | 2) Go to the following URL after replacing "CLIENT_ID" with your Client ID:\ 55 | https://discord.com/oauth2/authorize?client_id=CLIENT_ID&permissions=2048&scope=bot 56 | 57 | # Possible Improvements 58 | - I can't fathom many people using this application, so it doesn't reuse a\ 59 | single websocket connection, and instead opens a new connection each query. If\ 60 | you have an extremely high-traffic usecase for this, please ensure you respect\ 61 | [crackwatch.com](https://crackwatch.com) by reusing a single connection. It may be easier to use [nhooyr's\ 62 | library](https://github.com/nhooyr/websocket) if this is wanted, not too sure. 63 | - I was contemplating whether or not I wanted to use an embedded message when\ 64 | there was only one result, but ultimately was against it since it would have\ 65 | taken up a bunch of vertical messaging space where it wasn't necessary. Feel\ 66 | free to fork it and add it to your own version, however. 67 | - If you're planning on supporting multiple guilds, you'll want to add a\ 68 | command (as well as a data store) so users from guilds can change the bot\ 69 | command prefix. I didn't do this for my own version because I don't want people\ 70 | using it—I want them running their own. 71 | 72 | # Design Decisions 73 | Q: Why don't you use a tabwriter to align the columns for each result?\ 74 | A: 1) Due to the nature of the need behind the search, your eyes wouldn't be\ 75 | scanning to compare columns of different rows—you'd instead be looking for a\ 76 | game's name (which is at the beginning of each row, sorted alphabetically),\ 77 | then scanning to the right from there for more information about that game.\ 78 | 2) It would use up a lot more of Discord's allotted characters (2000 per\ 79 | message), allowing _far_ less information to be presented per message. 80 | -------------------------------------------------------------------------------- /crackwatch/crackwatch.go: -------------------------------------------------------------------------------- 1 | package crackwatch 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/gorilla/websocket" 13 | ) 14 | 15 | const errUnhandledResponse = "We received a response from crackwatch.com we" + 16 | " weren't expecting, and couldn't handle. Sorry about that—perhaps try it" + 17 | " directly from until we fix this issue?" 18 | 19 | var errCouldNotReachSite = errors.New("crackwatch.com could not be reached.") 20 | 21 | const maxSearchTermLength = 100 22 | 23 | type searchOptions struct { 24 | crackStatus crackStatus 25 | releaseStatus releaseStatus 26 | studioType studioType 27 | orderType orderType 28 | sortOrder sortOrder 29 | } 30 | 31 | // Search query parameters. 32 | type crackStatus string 33 | 34 | const ( 35 | CrackStatusAll crackStatus = "0" 36 | CrackStatusCracked crackStatus = "1" 37 | CrackStatusUncracked crackStatus = "2" 38 | ) 39 | 40 | type releaseStatus string 41 | 42 | const ( 43 | ReleaseStatusAll releaseStatus = "0" 44 | ReleaseStatusReleased releaseStatus = "1" 45 | ReleaseStatusUnreleased releaseStatus = "2" 46 | ) 47 | 48 | type studioType string 49 | 50 | const ( 51 | StudioAll studioType = "0" 52 | StudioAAA studioType = "1" 53 | StudioIndie studioType = "2" 54 | ) 55 | 56 | type orderType string 57 | 58 | const ( 59 | OrderTypeTitle orderType = "title" 60 | OrderTypeReleaseDate orderType = "releaseDate" 61 | OrderTypeCrackDate orderType = "crackDate" 62 | OrderTypeDRM orderType = "protection" 63 | OrderTypeGroup orderType = "group" 64 | OrderTypeNumNFOs orderType = "nfo" 65 | OrderTypePrice orderType = "price" 66 | OrderTypeRatings orderType = "ratings" 67 | OrderTypeComments orderType = "comments" 68 | OrderTypeFollowers orderType = "followers" 69 | ) 70 | 71 | type sortOrder string 72 | 73 | const ( 74 | SortOrderDesc sortOrder = "true" 75 | SortOrderAsc sortOrder = "false" 76 | ) 77 | 78 | type SearchResults struct { 79 | Num int `json:"gameCount"` 80 | Games []struct { 81 | Name string `json:"title"` 82 | ReleaseDate Date 83 | DRM []string `json:"protections"` 84 | CrackedBy []string `json:"groups"` 85 | CrackDate Date 86 | NumFollowers int `json:"followersCount"` 87 | } 88 | } 89 | 90 | type Date struct { 91 | time.Time 92 | } 93 | 94 | // One of the games gives us just the year, and another uses a date which 95 | // doesn't actually exist (Feb. 30th, 2015...). We return the default time 96 | // (0001-01-01 00:00:00 +0000 UTC) in case of an error. 97 | func (t *Date) UnmarshalJSON(b []byte) error { 98 | timeStr := strings.ReplaceAll(string(b), `"`, "") 99 | 100 | if timeStr == "null" || len(timeStr) < 10 { 101 | t.Time = time.Time{} 102 | return nil 103 | } 104 | 105 | // We only care about the date, not the time. 106 | parsedTime, err := time.Parse("2006-01-02", timeStr[:10]) 107 | if err != nil { 108 | t.Time = time.Time{} 109 | return nil 110 | } 111 | 112 | t.Time = parsedTime 113 | return nil 114 | } 115 | 116 | func (t Date) String() string { 117 | return t.Format("2006-01-02") 118 | } 119 | 120 | // The error returned from this function is meant for users. 121 | func Search(term string, page int) (SearchResults, error) { 122 | if len(term) > maxSearchTermLength { 123 | return SearchResults{}, fmt.Errorf("Search term was >%d characters.\n", 124 | maxSearchTermLength) 125 | } 126 | 127 | ws, err := connectToWebsocket() 128 | if err != nil { 129 | return SearchResults{}, err 130 | } 131 | defer ws.Close() 132 | 133 | err = sendSearchQuery(ws, term, strconv.Itoa(page), &searchOptions{ 134 | crackStatus: CrackStatusAll, 135 | releaseStatus: ReleaseStatusAll, 136 | studioType: StudioAll, 137 | orderType: OrderTypeTitle, 138 | sortOrder: SortOrderAsc, 139 | }) 140 | if err != nil { 141 | return SearchResults{}, err 142 | } 143 | 144 | searchResults, err := waitForSearchResults(ws) 145 | if err != nil { 146 | return SearchResults{}, err 147 | } 148 | 149 | return searchResults, nil 150 | } 151 | 152 | func connectToWebsocket() (*websocket.Conn, error) { 153 | // The format of the first two segments after "sockjs" are _very_ flexible. 154 | ws, _, err := websocket.DefaultDialer.Dial( 155 | "wss://crackwatch.com/sockjs/crackwatch/discord_bot/websocket", nil) 156 | if err != nil { 157 | log.Println("Unable to dial the websocket: " + err.Error()) 158 | return nil, errors.New("crackwatch.com did not accept our websocket" + 159 | " connection.") 160 | } 161 | 162 | err = ws.WriteMessage(websocket.TextMessage, 163 | []byte(`["{\"msg\":\"connect\",\"version\":\"1\",\"support\":[\"1\",\"`+ 164 | `pre2\",\"pre1\"]}"]`)) 165 | if err != nil { 166 | log.Println("Unable to write connect message: " + err.Error()) 167 | return nil, errors.New("crackwatch.com did not accept our websocket" + 168 | " connection.") 169 | } 170 | 171 | return ws, nil 172 | } 173 | 174 | // NOTE: I _really_ want to give each of these parameters separate types so they 175 | // can't be confused. _Really_ really. 176 | func sendSearchQuery( 177 | ws *websocket.Conn, term, page string, options *searchOptions, 178 | ) error { 179 | // Ivan \\\"Ironman Stewart's\\\" Super Off-Road 180 | term = strings.ReplaceAll(term, `"`, `\\\"`) 181 | term = strings.TrimSpace(term) 182 | 183 | err := ws.WriteMessage( 184 | websocket.TextMessage, 185 | []byte(`["{\"msg\":\"method\",\"method\":\"games.page\",\"params\":[{\`+ 186 | `"page\":`+page+`,\"orderType\":\"`+string(options.orderType)+`\",`+ 187 | `\"orderDown\":`+string(options.sortOrder)+`,\"search\":\"`+term+ 188 | `\",\"unset\":0,\"released\":`+string(options.releaseStatus)+`,\"c`+ 189 | `racked\":`+string(options.crackStatus)+`,\"isAAA\":`+ 190 | string(options.studioType)+`}],\"id\":\"1\"}"]`)) 191 | if err != nil { 192 | log.Printf("Unable to write search message to websocket with the"+ 193 | " search term %q: %s", term, err) 194 | return errCouldNotReachSite 195 | } 196 | 197 | return nil 198 | } 199 | 200 | func waitForSearchResults(ws *websocket.Conn) (SearchResults, error) { 201 | for { 202 | _, message, err := ws.ReadMessage() 203 | if err != nil { 204 | log.Printf("Unable to read message from server: %s", err) 205 | return SearchResults{}, errCouldNotReachSite 206 | } 207 | 208 | if string(message) == `a["{\"msg\":\"error\",\"reason\":\"Bad request\`+ 209 | `"}"]` { 210 | log.Printf(`Received a "Bad request" response.`) 211 | return SearchResults{}, errors.New(`Received a "Bad request"` + 212 | " response from crackwatch.com.") 213 | } 214 | 215 | if string(message[:22]) != `a["{\"msg\":\"result\"` { 216 | continue 217 | } 218 | 219 | // Remove the unnecessary wrapping array from the response. 220 | escapedResponse := strings.TrimPrefix(string(message), `a["`) 221 | escapedResponse = strings.TrimSuffix(escapedResponse, `"]`) 222 | // Correct escaped quotation marks and slashes. 223 | escapedResponse = strings.ReplaceAll(escapedResponse, `\"`, `"`) 224 | escapedResponse = strings.ReplaceAll(escapedResponse, `\\`, `\`) 225 | 226 | // We unmarshal to a map first, as we want to unmarshal an inner struct 227 | // into our own custom struct. 228 | var responseMap map[string]json.RawMessage 229 | err = json.Unmarshal([]byte(escapedResponse), &responseMap) 230 | if err != nil { 231 | log.Printf("Unable to unmarshal response into map: %s: %s\n", err, 232 | escapedResponse) 233 | return SearchResults{}, errors.New(errUnhandledResponse) 234 | } 235 | 236 | results := SearchResults{} 237 | err = json.Unmarshal(responseMap["result"], &results) 238 | if err != nil { 239 | log.Printf("Unable to unmarshal response: %s: %s\n", err, 240 | escapedResponse) 241 | return SearchResults{}, errors.New(errUnhandledResponse) 242 | } 243 | 244 | return results, nil 245 | } 246 | } 247 | 248 | const ( 249 | DRMUnknown = "Unknown" 250 | DRMDiscCheck = "Disc Check" 251 | DRMNone = "None" 252 | DRMConsole = "Console" 253 | ) 254 | 255 | // NOTE: The keys must be lowercased. 256 | var drmNameMapping = map[string]string{ 257 | "": DRMUnknown, 258 | "-": DRMUnknown, 259 | "activation": DRMUnknown, 260 | "activision": DRMUnknown, 261 | "amazon": "Amazon", 262 | "andmicrosoftwindows": DRMUnknown, 263 | "arcade": DRMUnknown, 264 | "arcsystemworks": DRMUnknown, 265 | "armadillo": "Armadillo", 266 | "arxan": "Arxan", 267 | "arxan+social club": "Arxan/Rockstar Games Social Club", 268 | "arxan-steam": "Arxan/Steam", 269 | "ascgames": DRMUnknown, 270 | "atarisa": DRMUnknown, 271 | "battleeye": DRMUnknown, 272 | "battle.net": "Battle.net", 273 | "battlenet": "Battle.net", 274 | "battlenet-arxan": "Battle.net/Arxan", 275 | "bethesda": DRMUnknown, 276 | "bigfish": DRMUnknown, 277 | "blitsgames": DRMUnknown, 278 | "catalyst": "Catalyst", 279 | "cdautokey": DRMDiscCheck, 280 | "cd-check": DRMDiscCheck, 281 | "cdcheck": DRMDiscCheck, 282 | "cdcheck/re-index": DRMDiscCheck, 283 | "cd-checks": DRMDiscCheck, 284 | "cdchecks": DRMDiscCheck, 285 | "cd-cops": "CD-Cops", 286 | "cddilla": "C-Dilla", 287 | "cdilla": "C-Dilla", 288 | "cd-key": "Serial", 289 | "cd rom": DRMDiscCheck, 290 | "cd-rom": DRMDiscCheck, 291 | "codecheck": "Code Check", 292 | "codewheel": "Code Wheel", 293 | "colorcodes": "Color Codes", 294 | "copylock": "CopyLok", 295 | "copylok": "CopyLok", 296 | "coredesign": DRMUnknown, 297 | "denuvo": "Denuvo", 298 | "denuvo+origin": "Denuvo/Origin", 299 | "denuvo+uplay": "Denuvo/Uplay", 300 | "denuvo+vmpotect": "Denuvo/VMProtect", 301 | "deutschland-spielt": DRMUnknown, 302 | "disc check": DRMDiscCheck, 303 | "disccheck": DRMDiscCheck, 304 | // Might be referring to looking inside of the manual to pass the DRM? 305 | "doccheck": DRMUnknown, 306 | "dos": DRMUnknown, 307 | "dreamcast": DRMConsole, 308 | "dreamforgeintertainment": DRMUnknown, 309 | "drm": DRMUnknown, 310 | "drm free": DRMNone, 311 | "drm-free": DRMNone, 312 | "drmfree": DRMNone, 313 | "drmfreegog": DRMNone, 314 | // I have no idea if they're talking about DVD CSS or something else. 315 | "dvd drm": DRMUnknown, 316 | "dvddrm": DRMUnknown, 317 | "dvd-rom": DRMUnknown, 318 | "eac": DRMUnknown, 319 | "eappx": "EAppX", 320 | "eidosinteractive": DRMUnknown, 321 | "electronicarts": DRMUnknown, 322 | "e-license": "eLicense", 323 | "epic": "Epic Games", 324 | "epicgames": "Epic Games", 325 | "false": DRMUnknown, 326 | "fileintegrity": "File Integrity", 327 | // Simply because a game is free, does not mean it's free of DRM. 328 | "free": DRMUnknown, 329 | "free2play": DRMUnknown, 330 | "free-to-play": DRMUnknown, 331 | // NOTE: This may change in the future, see: 332 | // https://gamejolt.com/f/is-it-possible-to-add-drm-to-game-files-and-sell-steam-keys/344840?sort=top 333 | "gamejolt": DRMNone, 334 | "games for windows": "Games for Windows Live", 335 | "gameshield": "GameShield", 336 | "gog": DRMNone, 337 | "gog.com": DRMNone, 338 | "gog/steam": DRMNone, 339 | // "Die drei ??? Kids - Jagd auf das Phantom" uses this, apparently :D 340 | "icantfindthisgameonanygamestoreplatform": DRMUnknown, 341 | // I can't find anything about "IGC-DVD", even though quite a few 342 | // entries seem to return it. 343 | "igc-dvd": DRMUnknown, 344 | "interactivision a/s": DRMUnknown, 345 | "ios/android": "Mobile", 346 | "ironwrap": "GameShield", 347 | "jowood": DRMUnknown, 348 | "konami": DRMUnknown, 349 | "laserlock": "LaserLock", 350 | "magnussoft": DRMUnknown, 351 | "microids": DRMUnknown, 352 | "microsoft": DRMUnknown, 353 | "microsoftslps": "Microsoft SLPS", 354 | "microsoftstore": "Microsoft Store", 355 | "microsoftwindows": DRMUnknown, 356 | "mmo": DRMUnknown, 357 | "moby": DRMUnknown, 358 | "ms-dos": DRMUnknown, 359 | "myswooop": DRMUnknown, 360 | "n/a": DRMUnknown, 361 | "nes": DRMConsole, 362 | "nintendo": DRMConsole, 363 | "nintendo exclusive": DRMConsole, 364 | "nintendoswitch": DRMConsole, 365 | "no-drm": DRMNone, 366 | "nodrm": DRMNone, 367 | "none": DRMNone, 368 | "nothing": DRMNone, 369 | "notspecified": DRMUnknown, 370 | "novalogic": DRMUnknown, 371 | // Oculus ditched their DRM, so who knows what these games use now. 372 | "oculus": DRMUnknown, 373 | "origin": "Origin", 374 | "patreon": DRMUnknown, 375 | "pc": DRMUnknown, 376 | "pc-dos": DRMUnknown, 377 | "pc-spiel": DRMUnknown, 378 | "play+smile": DRMUnknown, 379 | "playstation3/xbox360": DRMConsole, 380 | "playstation/ios": DRMConsole, 381 | "popcap": DRMUnknown, 382 | "protectcd": "ProtectDISC CD", 383 | "protectcd8": "ProtectDISC CD", 384 | "protectdvd": "ProtectDISC DVD", 385 | // Can't find any information about this DRM. 386 | "reroute": DRMUnknown, 387 | "re-route/size": DRMUnknown, 388 | "retail": DRMUnknown, 389 | // "20000 Meilen unter dem Meer" uses this, although it doesn't actually 390 | // appear to be a DRM scheme. 391 | "ring": DRMUnknown, 392 | "rockstar": "Rockstar Games Social Club", 393 | "safedisc": "SafeDisc", 394 | // They had 4 versions, although I haven't come across any other games 395 | // which state the version yet. 396 | "safedisc2": "SafeDisc v2", 397 | "safedisc4": "SafeDisc v4", 398 | "safedisk": "SafeDisc", 399 | "securom": "SecuROM", 400 | "serial": "Serial", 401 | "serialnumber": "Serial", 402 | "solidshield": "Solidshield", 403 | "stadia": "Google Stadia", 404 | "starforce": "StarForce", 405 | "steam": "Steam", 406 | "steam/arc": "Steam", 407 | "steam/free": "Steam", 408 | "steam/origin": "Steam/Origin", 409 | "steam+uplay": "Steam/Uplay", 410 | "tlgames": DRMUnknown, 411 | "tages": "Tagès", 412 | "tbd": DRMUnknown, 413 | "themida": "Themida", 414 | "ubisoft": DRMUnknown, 415 | "ump": DRMUnknown, 416 | "unknown": DRMUnknown, 417 | "uplay": "Uplay", 418 | "uplay/denuvo": "Uplay/Denuvo", 419 | "uwp": "UWP", 420 | "uwp-arxan": "UWP/Arxan", 421 | "uwp/steam": "UWP/Steam", 422 | "valeroa": "Valeroa", 423 | "vista": DRMUnknown, 424 | "vmprotect": "VMProtect", 425 | "vob/protectcd": "ProtectDISC CD", 426 | "wildgames": DRMUnknown, 427 | "wildtangent": "WildTangent", 428 | "windows": DRMUnknown, 429 | "xbox": DRMConsole, 430 | "xboxlive": DRMConsole, 431 | "ysiphus": DRMUnknown, 432 | "zagravagames": DRMUnknown, 433 | } 434 | 435 | // The DRM names are user-submitted with the worst capitalization and spelling 436 | // you could imagine, if they're even correct in the first place! This attempts 437 | // to normalize them somewhat, but we don't care about nailing every edge case 438 | // since it would be impossible. 439 | func NormalizeDRMNames(names []string) string { 440 | if len(names) == 0 { 441 | return DRMUnknown 442 | } 443 | 444 | // Translate the DRM names using the table above. 445 | properDRMs := []string{} 446 | for _, name := range names { 447 | name = strings.ToLower(name) 448 | value, ok := drmNameMapping[name] 449 | if !ok { 450 | log.Printf("First time coming across the DRM name %q.\n", name) 451 | } 452 | 453 | if value != DRMUnknown { 454 | properDRMs = append(properDRMs, value) 455 | } 456 | } 457 | 458 | // If there's no DRM entries left, then the DRM scheme is unknown. 459 | if len(properDRMs) == 0 { 460 | return DRMUnknown 461 | } 462 | 463 | return strings.Join(properDRMs, "/") 464 | } 465 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module CrackWatchDiscordBot 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/bwmarrin/discordgo v0.22.0 7 | github.com/gorilla/websocket v1.4.2 8 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c // indirect 9 | golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d // indirect 10 | golang.org/x/text v0.3.4 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bwmarrin/discordgo v0.22.0 h1:uBxY1HmlVCsW1IuaPjpCGT6A2DBwRn0nvOguQIxDdFM= 2 | github.com/bwmarrin/discordgo v0.22.0/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M= 3 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 4 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 5 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 6 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 7 | golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 h1:y6ce7gCWtnH+m3dCjzQ1PCuwl28DDIc3VNnvY29DlIA= 8 | golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 9 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 10 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c h1:9HhBz5L/UjnK9XLtiZhYAdue5BVKep3PMmS2LuPDt8k= 11 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 12 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 13 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 14 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 15 | golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d h1:MiWWjyhUzZ+jvhZvloX6ZrUsdEghn8a64Upd8EMHglE= 16 | golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 17 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 18 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 19 | golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc= 20 | golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 21 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 22 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dozn/CrackWatchDiscordBot/9eb79c619255ff42ff80268b41458159955f1559/images/icon.png -------------------------------------------------------------------------------- /images/results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dozn/CrackWatchDiscordBot/9eb79c619255ff42ff80268b41458159955f1559/images/results.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "CrackWatchDiscordBot/crackwatch" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "math" 10 | "os" 11 | "os/signal" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "syscall" 16 | "time" 17 | "unicode" 18 | 19 | "github.com/bwmarrin/discordgo" 20 | "golang.org/x/text/language" 21 | "golang.org/x/text/message" 22 | ) 23 | 24 | var ( 25 | discordBotToken = flag.String("token", "", "Discord bot token.") 26 | botCommand = flag.String("command", "!crack", "Message prefix to"+ 27 | " activate the bot. Can't contain spaces.") 28 | ) 29 | 30 | type guildID = string 31 | 32 | var discordMsgLock = map[guildID]*sync.Mutex{} 33 | 34 | func main() { 35 | log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) 36 | 37 | if err := parseFlags(); err != nil { 38 | log.Fatalln("Error while parsing flags: " + err.Error()) 39 | } 40 | 41 | discordBot, err := newDiscordBot(*discordBotToken) 42 | if err != nil { 43 | log.Fatalln("Unable to create Discord bot: " + err.Error()) 44 | } 45 | defer discordBot.Close() 46 | 47 | discordBot.AddHandler(onMessageReceived) 48 | 49 | waitForSignal() 50 | } 51 | 52 | func parseFlags() error { 53 | flag.Parse() 54 | 55 | if *discordBotToken == "" { 56 | return errors.New("A Discord bot token is required in order for this" + 57 | " application to function.") 58 | } 59 | 60 | for _, run := range *botCommand { 61 | if unicode.IsSpace(run) { 62 | return errors.New("botCommand has a space in it, which will break" + 63 | " parsing!") 64 | } 65 | } 66 | 67 | return nil 68 | } 69 | 70 | // NOTE: Be sure to run .Close() on the bot session once you're done using it. 71 | func newDiscordBot(token string) (*discordgo.Session, error) { 72 | discordSession, err := discordgo.New("Bot " + token) 73 | if err != nil { 74 | return nil, errors.New("Unable to create the Discord session: " + 75 | err.Error()) 76 | } 77 | 78 | if err = discordSession.Open(); err != nil { 79 | return nil, errors.New("Unable to open the Discord session: " + 80 | err.Error()) 81 | } 82 | 83 | return discordSession, nil 84 | } 85 | 86 | func onMessageReceived(s *discordgo.Session, m *discordgo.MessageCreate) { 87 | // Ignore own messages. 88 | if m.Author.ID == s.State.User.ID { 89 | return 90 | } 91 | 92 | messageFields := strings.Fields(strings.ToLower(m.Content)) 93 | if len(messageFields) < 2 || 94 | !strings.HasPrefix(messageFields[0], *botCommand) { 95 | return 96 | } 97 | 98 | page := 0 99 | if len(messageFields[0]) > len(*botCommand) { 100 | pageStr := messageFields[0][len(*botCommand):] 101 | pageInt, err := strconv.Atoi(pageStr) 102 | if err != nil { 103 | sendDiscordMessage(s, m, "You've entered an incorrect page number.") 104 | return 105 | } 106 | 107 | page = pageInt - 1 108 | } 109 | 110 | searchTerm := strings.Join(messageFields[1:], " ") 111 | searchResults, err := crackwatch.Search(searchTerm, page) 112 | if err != nil { 113 | sendDiscordMessage(s, m, "Couldn't connect to crackwatch.com—please "+ 114 | "try again later!") 115 | return 116 | } else if len(searchResults.Games) == 0 { 117 | sendDiscordMessage(s, m, "No games found which matched your query!") 118 | return 119 | } 120 | 121 | for _, messageChunk := range resultsToDiscordChunks(searchResults, page+1) { 122 | sendDiscordMessage(s, m, messageChunk) 123 | } 124 | } 125 | 126 | func resultsToDiscordChunks( 127 | searchResults crackwatch.SearchResults, pageNum int, 128 | ) []string { 129 | var strBuilder strings.Builder 130 | for i, game := range searchResults.Games { 131 | // Game hasn't been cracked yet. 132 | if game.CrackDate.IsZero() { 133 | numFollowersStr := message.NewPrinter(language.English). 134 | Sprintf("%d", game.NumFollowers) 135 | peopleStr := "people" 136 | if game.NumFollowers == 1 { 137 | peopleStr = "person" 138 | } 139 | strBuilder.WriteString( 140 | fmt.Sprintf("🛑%q has %s %s waiting for a crack!", 141 | game.Name, numFollowersStr, peopleStr), 142 | ) 143 | 144 | if i != len(searchResults.Games)-1 { 145 | strBuilder.WriteString("\n") 146 | } 147 | 148 | continue 149 | } 150 | 151 | strBuilder.WriteString( 152 | fmt.Sprintf("🟢%s | %s | %s | %s | %s", 153 | game.Name, game.ReleaseDate, 154 | crackwatch.NormalizeDRMNames(game.DRM), 155 | strings.Join(game.CrackedBy, "/"), 156 | game.CrackDate), 157 | ) 158 | 159 | if i != len(searchResults.Games)-1 { 160 | strBuilder.WriteString("\n") 161 | } 162 | } 163 | 164 | const crackWatchMaxNumResults float64 = 30 165 | footer := fmt.Sprintf("\nPage %d/%.0f```", 166 | pageNum, 167 | math.Ceil(float64(searchResults.Num)/crackWatchMaxNumResults)) 168 | const discordMaxMsgLen = 2000 169 | const header = "```Game Name | Release Date | DRM | Cracked By | Date" + 170 | " Cracked\n" 171 | maxMsgLen := discordMaxMsgLen - len(header+footer) 172 | 173 | msg := strBuilder.String() 174 | messageChunks := []string{} 175 | for len(msg) > maxMsgLen { 176 | idxLastNewline := strings.LastIndex(msg[:maxMsgLen], "\n") 177 | messageChunks = append(messageChunks, 178 | header+msg[:idxLastNewline]+footer) 179 | 180 | // +1 to skip the newline character. 181 | msg = msg[idxLastNewline+1:] 182 | } 183 | 184 | messageChunks = append(messageChunks, header+msg+footer) 185 | 186 | return messageChunks 187 | } 188 | 189 | func sendDiscordMessage( 190 | s *discordgo.Session, m *discordgo.MessageCreate, msg string, 191 | ) { 192 | mutex, ok := discordMsgLock[m.GuildID] 193 | if !ok { 194 | discordMsgLock[m.GuildID] = &sync.Mutex{} 195 | mutex = discordMsgLock[m.GuildID] 196 | } 197 | 198 | mutex.Lock() 199 | 200 | // We purposely ignore all errors when sending messages to Discord, since in 201 | // the rare worst-case scenario, the user just has to send another query. 202 | _, _ = s.ChannelMessageSend(m.ChannelID, msg) 203 | time.Sleep(time.Second) 204 | 205 | mutex.Unlock() 206 | } 207 | 208 | func waitForSignal() { 209 | signalChan := make(chan os.Signal, 1) 210 | signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) 211 | fmt.Printf(time.Now().Format("2006-01-02 @ 15:04:05 MST ")+ 212 | "CrackWatchDiscordBot has shut down due to the %q signal being caught."+ 213 | "\n", <-signalChan) 214 | } 215 | --------------------------------------------------------------------------------