├── LICENSE ├── README.md ├── active.go ├── add.go ├── check.go ├── count.go ├── del.go ├── deldata.go ├── demo.gif ├── downs.go ├── errors.go ├── goreleaser.yml ├── hashing.go ├── head.go ├── info.go ├── latest.go ├── list.go ├── main.go ├── paused.go ├── recieveTorrent.go ├── search.go ├── seeding.go ├── sort.go ├── speed.go ├── start.go ├── stats.go ├── stop.go ├── tail.go └── trackers.go /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rTelegram 2 | 3 | #### Manage your rTorrent through Telegram. 4 | 5 | 6 | 7 | ## Install 8 | 9 | Just [download](https://github.com/pyed/rtelegram/releases) the appropriate binary for your OS, place `rtelegram` in your `$PATH` and you are good to go. 10 | 11 | Or if you have `Go` installed: `go get -u github.com/pyed/rtelegram` 12 | 13 | ## Requirements 14 | 15 | Thanks to [pyed/rtapi](https://github.com/pyed/rtapi) You don't need a complicated webserver setup, All you need is: 16 | * `rTorrent` compiled with the flag `--with-xmlrpc-c`. Which you probably already have. 17 | * `scgi_port = localhost:5000` in your `rtorrent.rc` file. 18 | 19 | And you should be good to go! 20 | 21 | ## Wiki 22 | 23 | * [Getting started](https://github.com/pyed/rtelegram/wiki) 24 | * [Commands](https://github.com/pyed/rtelegram/wiki/Commands) 25 | * [How to get notifications](https://github.com/pyed/rtelegram/wiki/Notifications) -------------------------------------------------------------------------------- /active.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "time" 7 | 8 | humanize "github.com/pyed/go-humanize" 9 | tgbotapi "gopkg.in/telegram-bot-api.v4" 10 | ) 11 | 12 | // active will send torrents that are actively downloading or uploading 13 | func active() { 14 | torrents, err := rtorrent.Torrents() 15 | if err != nil { 16 | logger.Print(err) 17 | send("active: "+err.Error(), false) 18 | return 19 | } 20 | 21 | buf := new(bytes.Buffer) 22 | for i := range torrents { 23 | if torrents[i].DownRate > 0 || 24 | torrents[i].UpRate > 0 { 25 | torrentName := mdReplacer.Replace(torrents[i].Name) // escape markdown 26 | buf.WriteString(fmt.Sprintf("`<%d>` *%s*\n%s *%s* (%s) ↓ *%s* ↑ *%s* R: *%.2f*\n\n", 27 | i, torrentName, torrents[i].State, humanize.IBytes(torrents[i].Completed), 28 | torrents[i].Percent, humanize.IBytes(torrents[i].DownRate), 29 | humanize.IBytes(torrents[i].UpRate), torrents[i].Ratio)) 30 | } 31 | } 32 | if buf.Len() == 0 { 33 | send("No active torrents", false) 34 | return 35 | } 36 | 37 | msgID := send(buf.String(), true) 38 | 39 | if NoLive { 40 | return 41 | } 42 | 43 | // keep the active list live for 'duration * interval' 44 | for i := 0; i < duration; i++ { 45 | time.Sleep(time.Second * interval) 46 | // reset the buffer to reuse it 47 | buf.Reset() 48 | 49 | // update torrents 50 | torrents, err = rtorrent.Torrents() 51 | if err != nil { 52 | continue // if there was error getting torrents, skip to the next iteration 53 | } 54 | 55 | // do the same loop again 56 | for i := range torrents { 57 | if torrents[i].DownRate > 0 || 58 | torrents[i].DownRate > 0 { 59 | torrentName := mdReplacer.Replace(torrents[i].Name) // replace markdown chars 60 | buf.WriteString(fmt.Sprintf("`<%d>` *%s*\n%s *%s* (%s) ↓ *%s* ↑ *%s* R: *%.2f*\n\n", 61 | i, torrentName, torrents[i].State, humanize.IBytes(torrents[i].Completed), 62 | torrents[i].Percent, humanize.IBytes(torrents[i].DownRate), 63 | humanize.IBytes(torrents[i].UpRate), torrents[i].Ratio)) 64 | } 65 | } 66 | 67 | // no need to check if it is empty, as if the buffer is empty telegram won't change the message 68 | editConf := tgbotapi.NewEditMessageText(chatID, msgID, buf.String()) 69 | editConf.ParseMode = tgbotapi.ModeMarkdown 70 | Bot.Send(editConf) 71 | } 72 | // sleep one more time before putting the dashes 73 | time.Sleep(time.Second * interval) 74 | 75 | // replace the speed with dashes to indicate that we are done being live 76 | buf.Reset() 77 | for i := range torrents { 78 | if torrents[i].DownRate > 0 || 79 | torrents[i].DownRate > 0 { 80 | // escape markdown 81 | torrentName := mdReplacer.Replace(torrents[i].Name) 82 | buf.WriteString(fmt.Sprintf("`<%d>` *%s*\n%s *%s* (%s) ↓ *-* ↑ *-* R: *%.2f*\n\n", 83 | i, torrentName, torrents[i].State, humanize.IBytes(torrents[i].Completed), 84 | torrents[i].Percent, torrents[i].Ratio)) 85 | } 86 | } 87 | 88 | editConf := tgbotapi.NewEditMessageText(chatID, msgID, buf.String()) 89 | editConf.ParseMode = tgbotapi.ModeMarkdown 90 | Bot.Send(editConf) 91 | 92 | } 93 | -------------------------------------------------------------------------------- /add.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | ) 7 | 8 | // add takes an URL to a .torrent file to add it to rtorrent 9 | func add(tokens []string, filename string) { 10 | if len(tokens) == 0 { 11 | send("add: needs at least one URL", false) 12 | return 13 | } 14 | 15 | // loop over the URL/s and add them 16 | // WARNING: it doesn't report error if the same torrent already added. 17 | for _, url := range tokens { 18 | if err := rtorrent.Download(url); err != nil { 19 | logger.Print("add:", err) 20 | send("add: %s"+err.Error(), false) 21 | continue 22 | } 23 | 24 | if filename == "" { 25 | filename = filepath.Base(url) 26 | } 27 | 28 | send(fmt.Sprintf("Added: %s", filename), false) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /check.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | // check takes id[s] of torrent[s] or 'all' to verify them 9 | func check(tokens []string) { 10 | // make sure that we got at least one argument 11 | if len(tokens) == 0 { 12 | send("check: needs an argument", false) 13 | return 14 | } 15 | 16 | torrents, err := rtorrent.Torrents() 17 | if err != nil { 18 | logger.Print("check:", err) 19 | send("check: "+err.Error(), false) 20 | return 21 | } 22 | 23 | // if the first argument is 'all' then start all torrents 24 | if tokens[0] == "all" { 25 | if err := rtorrent.Check(torrents...); err != nil { 26 | logger.Print("check:", err) 27 | send("check: error occurred while verifying some torrents", false) 28 | return 29 | } 30 | send("hash checking all torrents", false) 31 | return 32 | 33 | } 34 | 35 | for _, i := range tokens { 36 | id, err := strconv.Atoi(i) 37 | if err != nil { 38 | send(fmt.Sprintf("check: %s is not a number", i), false) 39 | continue 40 | } 41 | 42 | if id >= len(torrents) || id < 0 { 43 | send(fmt.Sprintf("Check: No torrent with an ID of '%d'", id), false) 44 | continue 45 | } 46 | 47 | if err := rtorrent.Check(torrents[id]); err != nil { 48 | logger.Print("Check:", err) 49 | send("Check: "+err.Error(), false) 50 | continue 51 | } 52 | send(fmt.Sprintf("Checking: %s", torrents[id].Name), false) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /count.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pyed/rtapi" 7 | ) 8 | 9 | // count returns current torrents count per status 10 | func count() { 11 | torrents, err := rtorrent.Torrents() 12 | if err != nil { 13 | logger.Print("count:", err) 14 | send("count: "+err.Error(), false) 15 | return 16 | } 17 | 18 | var Leeching, Seeding, Complete, Stopped, Hashing, Error int 19 | 20 | for i := range torrents { 21 | switch torrents[i].State { 22 | case rtapi.Leeching: 23 | Leeching++ 24 | case rtapi.Seeding: 25 | Seeding++ 26 | case rtapi.Complete: 27 | Complete++ 28 | case rtapi.Stopped: 29 | Stopped++ 30 | case rtapi.Hashing: 31 | Hashing++ 32 | case rtapi.Error: 33 | Error++ 34 | } 35 | } 36 | 37 | msg := fmt.Sprintf("Leeching: *%d*\nSeeding: *%d*\nComplete: *%d*\nStopped: *%d*\nHashing: *%d*\nError: *%d*\n\nTotal: *%d*", 38 | Leeching, Seeding, Complete, Stopped, Hashing, Error, len(torrents)) 39 | 40 | send(msg, true) 41 | 42 | } 43 | -------------------------------------------------------------------------------- /del.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | // del takes an id or more, and delete the corresponding torrent/s 9 | func del(tokens []string) { 10 | // make sure that we got an argument 11 | if len(tokens) == 0 { 12 | send("del: needs an ID", false) 13 | return 14 | } 15 | 16 | torrents, err := rtorrent.Torrents() 17 | if err != nil { 18 | logger.Print("del:", err) 19 | send("del: "+err.Error(), false) 20 | return 21 | } 22 | 23 | // loop over tokens to read each potential id 24 | for _, i := range tokens { 25 | id, err := strconv.Atoi(i) 26 | if err != nil { 27 | send(fmt.Sprintf("del: %s is not an ID", i), false) 28 | return 29 | } 30 | 31 | if err := rtorrent.Delete(false, torrents[id]); err != nil { 32 | logger.Print("del:", err) 33 | send("del: "+err.Error(), false) 34 | continue 35 | } 36 | 37 | send(fmt.Sprintf("Deleted: %s", torrents[id].Name), false) 38 | 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /deldata.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | // deldata takes an id or more, and delete the corresponding torrent/s with their data 9 | func deldata(tokens []string) { 10 | // make sure that we got an argument 11 | if len(tokens) == 0 { 12 | send("deldata: needs an ID", false) 13 | return 14 | } 15 | 16 | torrents, err := rtorrent.Torrents() 17 | if err != nil { 18 | logger.Print("deldata:", err) 19 | send("deldata: "+err.Error(), false) 20 | return 21 | } 22 | 23 | // loop over tokens to read each potential id 24 | for _, i := range tokens { 25 | id, err := strconv.Atoi(i) 26 | if err != nil { 27 | send(fmt.Sprintf("deldata: %s is not an ID", i), false) 28 | return 29 | } 30 | 31 | if err := rtorrent.Delete(true, torrents[id]); err != nil { 32 | logger.Print("deldata:", err) 33 | send("deldata: "+err.Error(), false) 34 | continue 35 | } 36 | 37 | send(fmt.Sprintf("Deleted with data: %s", torrents[id].Name), false) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyed/rtelegram/bd0afc46b065cb8a797a2661cdee787cc7091cd8/demo.gif -------------------------------------------------------------------------------- /downs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/pyed/rtapi" 8 | ) 9 | 10 | // downs will send the names of torrents with status 'Leeching'. 11 | func downs() { 12 | torrents, err := rtorrent.Torrents() 13 | if err != nil { 14 | logger.Print(err) 15 | send("downs: "+err.Error(), false) 16 | return 17 | } 18 | 19 | buf := new(bytes.Buffer) 20 | for i := range torrents { 21 | if torrents[i].State == rtapi.Leeching { 22 | buf.WriteString(fmt.Sprintf("<%d> %s\n", i, torrents[i].Name)) 23 | } 24 | } 25 | 26 | if buf.Len() == 0 { 27 | send("No downloads", false) 28 | return 29 | } 30 | send(buf.String(), false) 31 | } 32 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/pyed/rtapi" 8 | ) 9 | 10 | // errors will list torrents with errors 11 | func errors() { 12 | torrents, err := rtorrent.Torrents() 13 | if err != nil { 14 | logger.Print(err) 15 | send("errors: "+err.Error(), false) 16 | return 17 | } 18 | 19 | buf := new(bytes.Buffer) 20 | for i := range torrents { 21 | if torrents[i].State == rtapi.Error { 22 | buf.WriteString(fmt.Sprintf("<%d> %s\n%s\n\n", 23 | i, torrents[i].Name, torrents[i].Message)) 24 | } 25 | } 26 | if buf.Len() == 0 { 27 | send("No errors", false) 28 | return 29 | } 30 | send(buf.String(), false) 31 | } 32 | -------------------------------------------------------------------------------- /goreleaser.yml: -------------------------------------------------------------------------------- 1 | build: 2 | binary: rtelegram 3 | goos: 4 | - windows 5 | - darwin 6 | - linux 7 | goarch: 8 | - amd64 9 | - 386 10 | ignore: 11 | - goos: darwin 12 | goarch: 386 -------------------------------------------------------------------------------- /hashing.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/pyed/rtapi" 8 | ) 9 | 10 | // hashing will send the names of torrents with the status 'Hashing' 11 | func hashing() { 12 | torrents, err := rtorrent.Torrents() 13 | if err != nil { 14 | logger.Print(err) 15 | send("hashing: "+err.Error(), false) 16 | return 17 | } 18 | 19 | buf := new(bytes.Buffer) 20 | for i := range torrents { 21 | if torrents[i].State == rtapi.Hashing { 22 | buf.WriteString(fmt.Sprintf("<%d> %s\n%s (%s)\n\n", 23 | i, torrents[i].Name, torrents[i].State, 24 | torrents[i].Percent)) 25 | 26 | } 27 | } 28 | 29 | if buf.Len() == 0 { 30 | send("No torrents hashing", false) 31 | return 32 | } 33 | 34 | send(buf.String(), false) 35 | } 36 | -------------------------------------------------------------------------------- /head.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strconv" 7 | "time" 8 | 9 | humanize "github.com/pyed/go-humanize" 10 | tgbotapi "gopkg.in/telegram-bot-api.v4" 11 | ) 12 | 13 | // head will list the first 5 or n torrents 14 | func head(tokens []string) { 15 | var ( 16 | n = 5 // default to 5 17 | err error 18 | ) 19 | 20 | if len(tokens) > 0 { 21 | n, err = strconv.Atoi(tokens[0]) 22 | if err != nil { 23 | send("head: argument must be a number", false) 24 | return 25 | } 26 | } 27 | 28 | torrents, err := rtorrent.Torrents() 29 | if err != nil { 30 | logger.Print(err) 31 | send("head: "+err.Error(), false) 32 | return 33 | } 34 | 35 | // make sure that we stay in the boundaries 36 | if n <= 0 || n > len(torrents) { 37 | n = len(torrents) 38 | } 39 | 40 | buf := new(bytes.Buffer) 41 | for i, torrent := range torrents[:n] { 42 | torrentName := mdReplacer.Replace(torrent.Name) // escape markdown 43 | buf.WriteString(fmt.Sprintf("`<%d>` *%s*\n%s *%s* (%s) ↓ *%s* ↑ *%s* R: *%.2f*\n\n", 44 | i, torrentName, torrent.State, humanize.IBytes(torrent.Completed), 45 | torrent.Percent, humanize.IBytes(torrent.DownRate), 46 | humanize.IBytes(torrent.UpRate), torrent.Ratio)) 47 | } 48 | 49 | if buf.Len() == 0 { 50 | send("head: No torrents", false) 51 | return 52 | } 53 | 54 | msgID := send(buf.String(), true) 55 | 56 | if NoLive { 57 | return 58 | } 59 | 60 | // keep the info live 61 | for i := 0; i < duration; i++ { 62 | time.Sleep(time.Second * interval) 63 | buf.Reset() 64 | 65 | torrents, err = rtorrent.Torrents() 66 | if err != nil { 67 | logger.Print("head:", err) 68 | continue // try again if some error heppened 69 | } 70 | 71 | if len(torrents) < 1 { 72 | continue 73 | } 74 | 75 | // make sure that we stay in the boundaries 76 | if n <= 0 || n > len(torrents) { 77 | n = len(torrents) 78 | } 79 | 80 | for i, torrent := range torrents[:n] { 81 | torrentName := mdReplacer.Replace(torrent.Name) // escape markdown 82 | buf.WriteString(fmt.Sprintf("`<%d>` *%s*\n%s *%s* (%s) ↓ *%s* ↑ *%s* R: *%.2f*\n\n", 83 | i, torrentName, torrent.State, humanize.IBytes(torrent.Completed), 84 | torrent.Percent, humanize.IBytes(torrent.DownRate), 85 | humanize.IBytes(torrent.UpRate), torrent.Ratio)) 86 | } 87 | 88 | // no need to check if it is empty, as if the buffer is empty telegram won't change the message 89 | editConf := tgbotapi.NewEditMessageText(chatID, msgID, buf.String()) 90 | editConf.ParseMode = tgbotapi.ModeMarkdown 91 | Bot.Send(editConf) 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /info.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | humanize "github.com/pyed/go-humanize" 9 | "github.com/pyed/rtapi" 10 | tgbotapi "gopkg.in/telegram-bot-api.v4" 11 | ) 12 | 13 | // info takes an id of a torrent and returns some info about it 14 | func info(tokens []string) { 15 | if len(tokens) == 0 { 16 | send("info: needs a torrent ID number", false) 17 | return 18 | } 19 | 20 | torrents, err := rtorrent.Torrents() 21 | if err != nil { 22 | logger.Print("info:", err) 23 | send("info: "+err.Error(), false) 24 | } 25 | 26 | for _, i := range tokens { 27 | id, err := strconv.Atoi(i) 28 | if err != nil { 29 | send(fmt.Sprintf("info: %s is not a number", i), false) 30 | continue 31 | } 32 | 33 | if id >= len(torrents) || id < 0 { 34 | send(fmt.Sprintf("start: No torrent with an ID of '%d'", id), false) 35 | continue 36 | } 37 | 38 | // format the info 39 | torrentName := mdReplacer.Replace(torrents[id].Name) // escape markdown 40 | info := fmt.Sprintf("*%s*\n%s *%s* (*%s*) ↓ *%s* ↑ *%s* R: *%.2f* UP: *%s*\nAdded: *%s*, ETA: *%d*\nTracker: `%s`", 41 | torrentName, torrents[id].State, humanize.IBytes(torrents[id].Completed), torrents[id].Percent, 42 | humanize.IBytes(torrents[id].DownRate), humanize.IBytes(torrents[id].UpRate), torrents[id].Ratio, 43 | humanize.IBytes(torrents[id].UpTotal), time.Unix(int64(torrents[id].Age), 0).Format(time.Stamp), 44 | torrents[id].ETA, torrents[id].Tracker.Hostname()) 45 | 46 | // send it 47 | msgID := send(info, true) 48 | 49 | if NoLive { 50 | return 51 | } 52 | 53 | // this go-routine will make the info live for 'duration * interval' 54 | go func(hash string, msgID int) { 55 | var torrent *rtapi.Torrent 56 | for i := 0; i < duration; i++ { 57 | time.Sleep(time.Second * interval) 58 | torrent, err = rtorrent.GetTorrent(hash) 59 | if err != nil { 60 | logger.Print("info:", err) 61 | return // if there's an error finding the torrent, maybe got deleted, return 62 | } 63 | 64 | torrentName := mdReplacer.Replace(torrent.Name) // escape markdown 65 | info := fmt.Sprintf("*%s*\n%s *%s* (*%s*) ↓ *%s* ↑ *%s* R: *%.2f* UP: *%s*\nAdded: *%s*, ETA: *%d*\nTracker: `%s`", 66 | torrentName, torrent.State, humanize.IBytes(torrent.Completed), torrent.Percent, 67 | humanize.IBytes(torrent.DownRate), humanize.IBytes(torrent.UpRate), torrent.Ratio, 68 | humanize.IBytes(torrent.UpTotal), time.Unix(int64(torrent.Age), 0).Format(time.Stamp), 69 | torrent.ETA, torrent.Tracker.Hostname()) 70 | 71 | // update the message 72 | editConf := tgbotapi.NewEditMessageText(chatID, msgID, info) 73 | editConf.ParseMode = tgbotapi.ModeMarkdown 74 | Bot.Send(editConf) 75 | 76 | } 77 | // sleep one more time before the dashes 78 | time.Sleep(time.Second * interval) 79 | // at the end write dashes to indicate that we are done being live. 80 | torrentName := mdReplacer.Replace(torrent.Name) // escape markdown 81 | info := fmt.Sprintf("*%s*\n *-* (*-%%*) ↓ *-* ↑ *-* R: *-* UP: *-*\nAdded: *%s*, ETA: *-*\nTracker: `%s`", 82 | torrentName, time.Unix(int64(torrent.Age), 0).Format(time.Stamp), torrent.Tracker.Hostname()) 83 | 84 | editConf := tgbotapi.NewEditMessageText(chatID, msgID, info) 85 | editConf.ParseMode = tgbotapi.ModeMarkdown 86 | Bot.Send(editConf) 87 | }(torrents[id].Hash, msgID) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /latest.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/pyed/rtapi" 9 | ) 10 | 11 | // latest takes n and returns the latest n torrents 12 | func latest(tokens []string) { 13 | var ( 14 | n = 5 // default to 5 15 | err error 16 | ) 17 | 18 | if len(tokens) > 0 { 19 | n, err = strconv.Atoi(tokens[0]) 20 | if err != nil { 21 | send("latest: argument must be a number", false) 22 | return 23 | } 24 | } 25 | 26 | torrents, err := rtorrent.Torrents() 27 | if err != nil { 28 | logger.Print(err) 29 | send("latest: "+err.Error(), false) 30 | return 31 | } 32 | 33 | // make sure that we stay in the boundaries 34 | if n <= 0 || n > len(torrents) { 35 | n = len(torrents) 36 | } 37 | 38 | // sort by age, and set reverse to true to get the latest first 39 | torrents.Sort(rtapi.ByAgeRev) 40 | 41 | buf := new(bytes.Buffer) 42 | for i := range torrents[:n] { 43 | buf.WriteString(fmt.Sprintf("<%d> %s\n", i, torrents[i].Name)) 44 | } 45 | if buf.Len() == 0 { 46 | send("latest: No torrents", false) 47 | return 48 | } 49 | send(buf.String(), false) 50 | } 51 | -------------------------------------------------------------------------------- /list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | ) 8 | 9 | // list will form and send a list of all the torrents 10 | // takes an optional argument which is a query to match against trackers 11 | // to list only torrents that has a tracker that matchs. 12 | func list(tokens []string) { 13 | torrents, err := rtorrent.Torrents() 14 | if err != nil { 15 | logger.Print(err) 16 | send("list: "+err.Error(), false) 17 | return 18 | } 19 | 20 | buf := new(bytes.Buffer) 21 | // if it gets a query, it will list torrents that has trackers that match the query 22 | if len(tokens) != 0 { 23 | // (?i) for case insensitivity 24 | regx, err := regexp.Compile("(?i)" + tokens[0]) 25 | if err != nil { 26 | send("list: "+err.Error(), false) 27 | return 28 | } 29 | 30 | for i := range torrents { 31 | if regx.MatchString(torrents[i].Tracker.Hostname()) { 32 | buf.WriteString(fmt.Sprintf("<%d> %s\n", i, torrents[i].Name)) 33 | } 34 | } 35 | } else { // if we did not get a query, list all torrents 36 | for i := range torrents { 37 | buf.WriteString(fmt.Sprintf("<%d> %s\n", i, torrents[i].Name)) 38 | } 39 | } 40 | 41 | if buf.Len() == 0 { 42 | // if we got a tracker query show different message 43 | if len(tokens) != 0 { 44 | send(fmt.Sprintf("list: No tracker matches: *%s*", tokens[0]), true) 45 | return 46 | } 47 | send("list: No torrents", false) 48 | return 49 | } 50 | 51 | send(buf.String(), false) 52 | } 53 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | "time" 10 | "unicode/utf8" 11 | 12 | "github.com/pyed/rtapi" 13 | "github.com/pyed/tailer" 14 | tgbotapi "gopkg.in/telegram-bot-api.v4" 15 | ) 16 | 17 | const ( 18 | VERSION = "v1.1" 19 | 20 | HELP = ` 21 | *list* or *li* 22 | Lists all the torrents, takes an optional argument which is a query to list only torrents that has a tracker matches the query, or some of it. 23 | 24 | *head* or *he* 25 | Lists the first n number of torrents, n defaults to 5 if no argument is provided. 26 | 27 | *tail* or *ta* 28 | Lists the last n number of torrents, n defaults to 5 if no argument is provided. 29 | 30 | *down* or *dl* 31 | Lists torrents with the status of Downloading or in the queue to download. 32 | 33 | *seeding* or *sd* 34 | Lists torrents with the status of Seeding or in the queue to seed. 35 | 36 | *paused* or *pa* 37 | Lists Paused torrents. 38 | 39 | *checking* or *ch* 40 | Lists torrents with the status of Verifying or in the queue to verify. 41 | 42 | *active* or *ac* 43 | Lists torrents that are actively uploading or downloading. 44 | 45 | *errors* or *er* 46 | Lists torrents with with errors along with the error message. 47 | 48 | *sort* or *so* 49 | Manipulate the sorting of the aforementioned commands, Call it without arguments for more. 50 | 51 | *trackers* or *tr* 52 | Lists all the trackers along with the number of torrents. 53 | 54 | *add* or *ad* 55 | Takes one or many URLs or magnets to add them, You can send a .torrent file via Telegram to add it. 56 | 57 | *search* or *se* 58 | Takes a query and lists torrents with matching names. 59 | 60 | *latest* or *la* 61 | Lists the newest n torrents, n defaults to 5 if no argument is provided. 62 | 63 | *info* or *in* 64 | Takes one or more torrent's IDs to list more info about them. 65 | 66 | *stop* or *sp* 67 | Takes one or more torrent's IDs to stop them, or _all_ to stop all torrents. 68 | 69 | *start* or *st* 70 | Takes one or more torrent's IDs to start them, or _all_ to start all torrents. 71 | 72 | *check* or *ck* 73 | Takes one or more torrent's IDs to verify them, or _all_ to verify all torrents. 74 | 75 | *del* 76 | Takes one or more torrent's IDs to delete them. 77 | 78 | *deldata* 79 | Takes one or more torrent's IDs to delete them and their data. 80 | 81 | *stats* or *sa* 82 | Shows some stats 83 | 84 | *speed* or *ss* 85 | Shows the upload and download speeds. 86 | 87 | *count* or *co* 88 | Shows the torrents counts per status. 89 | 90 | *help* 91 | Shows this help message. 92 | 93 | *version* 94 | Shows version numbers. 95 | 96 | - Prefix commands with '/' if you want to talk to your bot in a group. 97 | - report any issues [here](https://github.com/pyed/rtelegram) 98 | ` 99 | ) 100 | 101 | var ( 102 | 103 | // flags 104 | BotToken string 105 | Masters []string 106 | SCGIURL string 107 | LogFile string 108 | ComLogFile string 109 | NoLive bool 110 | 111 | // telegram 112 | Bot *tgbotapi.BotAPI 113 | Updates <-chan tgbotapi.Update 114 | 115 | // rTorrent 116 | rtorrent *rtapi.Rtorrent 117 | 118 | // chatID will be used to keep track of which chat to send to. 119 | chatID int64 120 | 121 | // logging 122 | logger = log.New(os.Stdout, "", log.LstdFlags) 123 | 124 | // interval in seconds for live updates, affects: "active", "info", "speed", "head", "tail" 125 | interval time.Duration = 3 126 | // duration controls how many intervals will happen 127 | duration = 5 128 | 129 | // asterisk may cause problems parsing markdown, replace it with `•` 130 | // affects only markdown users: info, active, head, tail 131 | mdReplacer = strings.NewReplacer("*", "•") 132 | ) 133 | 134 | // init flags 135 | func init() { 136 | var mastersStr string 137 | // define arguments and parse them. 138 | flag.StringVar(&BotToken, "token", "", "Telegram bot token, Can be passed via environment variable 'RT_TOKEN'") 139 | flag.StringVar(&mastersStr, "masters", "", "Comma-seperated Telegram handlers, The bot will only respond to them, Can be passed via environment variable 'RT_MASTERS'") 140 | flag.StringVar(&SCGIURL, "url", "localhost:5000", "rTorrent SCGI URL") 141 | flag.StringVar(&LogFile, "logfile", "", "Send logs to a file") 142 | flag.StringVar(&ComLogFile, "completed-torrents-logfile", "", "Watch completed torrents log file to notify upon new ones.") 143 | flag.BoolVar(&NoLive, "no-live", false, "Don't edit and update info after sending") 144 | 145 | // set the usage message 146 | flag.Usage = func() { 147 | fmt.Fprint(os.Stderr, "Usage: rtelegram <-token=TOKEN> <-masters=@tuser[,@user2..]> [-url=localhost/unix]\n\n") 148 | fmt.Fprint(os.Stderr, "Example: rtelegram -token=1234abc -masters=user1,user2 -url=localhost:4374\n") 149 | fmt.Fprint(os.Stderr, "Example: RT_TOKEN=1234abc RT_MASTERS=user1 rtelegram\n\n") 150 | flag.PrintDefaults() 151 | } 152 | 153 | flag.Parse() 154 | 155 | // if we don't have BotToken passed, check the environment variable "RT_TOKEN" 156 | if BotToken == "" { 157 | if envVar := os.Getenv("RT_TOKEN"); len(envVar) > 1 { 158 | BotToken = envVar 159 | } else { 160 | fmt.Fprintf(os.Stderr, "Error: Telegram Token is missing!\n") 161 | flag.Usage() 162 | os.Exit(1) 163 | } 164 | } 165 | 166 | // if we don't have masters passed, check the environment variable "RT_MASTERS" 167 | if mastersStr == "" { 168 | if envVar := os.Getenv("RT_MASTERS"); len(envVar) > 1 { 169 | mastersStr = envVar 170 | } else { 171 | fmt.Fprintf(os.Stderr, "Error: I have no masters!\n") 172 | flag.Usage() 173 | os.Exit(1) 174 | } 175 | } 176 | 177 | // process mastersStr into Masters 178 | // get rid of @ and spaces, then split on ',' 179 | mastersStr = strings.Replace(mastersStr, "@", "", -1) 180 | mastersStr = strings.Replace(mastersStr, " ", "", -1) 181 | mastersStr = strings.ToLower(mastersStr) 182 | Masters = strings.Split(mastersStr, ",") 183 | 184 | // if we got a log file, log to it 185 | if LogFile != "" { 186 | logf, err := os.OpenFile(LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 187 | if err != nil { 188 | log.Fatal(err) 189 | } 190 | logger.SetOutput(logf) 191 | } 192 | 193 | // if we got a completed torrents log file, monitor it for torrents completion to notify upon them. 194 | if ComLogFile != "" { 195 | go func() { 196 | ft := tailer.RunFileTailer(ComLogFile, false, nil) 197 | 198 | for { 199 | select { 200 | case line := <-ft.Lines(): 201 | // if we don't have a chatID continue 202 | if chatID == 0 { 203 | continue 204 | } 205 | 206 | msg := fmt.Sprintf("Completed: %s", line) 207 | send(msg, false) 208 | case err := <-ft.Errors(): 209 | logger.Printf("[ERROR] tailing completed torrents log: %s", err) 210 | return 211 | } 212 | 213 | } 214 | }() 215 | } 216 | 217 | // log the flags 218 | logger.Printf("[INFO] Token=%s\n\t\tMasters=%s\n\t\tURL=%s", 219 | BotToken, Masters, SCGIURL) 220 | } 221 | 222 | // init telegram 223 | func init() { 224 | // authorize using the token 225 | var err error 226 | Bot, err = tgbotapi.NewBotAPI(BotToken) 227 | if err != nil { 228 | fmt.Fprintf(os.Stderr, "[ERROR] Telegram: %s\n", err) 229 | os.Exit(1) 230 | } 231 | logger.Printf("[INFO] Authorized: %s", Bot.Self.UserName) 232 | 233 | u := tgbotapi.NewUpdate(0) 234 | u.Timeout = 60 235 | 236 | Updates, err = Bot.GetUpdatesChan(u) 237 | if err != nil { 238 | fmt.Fprintf(os.Stderr, "[ERROR] Telegram: %s\n", err) 239 | os.Exit(1) 240 | } 241 | } 242 | 243 | // init rTorrent 244 | func init() { 245 | var err error 246 | rtorrent, err = rtapi.NewRtorrent(SCGIURL) 247 | if err != nil { 248 | fmt.Fprintf(os.Stderr, "[ERROR] rTorrent: %s\n", err) 249 | os.Exit(1) 250 | } 251 | } 252 | 253 | func main() { 254 | for update := range Updates { 255 | // ignore edited messages 256 | if update.Message == nil { 257 | continue 258 | } 259 | 260 | // ignore non-Masters 261 | if !aMaster(update.Message.From.UserName) { 262 | logger.Printf("[INFO] Ignored a message from: %s", update.Message.From.String()) 263 | continue 264 | } 265 | 266 | // update chatID for complete notification 267 | if chatID != update.Message.Chat.ID { 268 | chatID = update.Message.Chat.ID 269 | } 270 | 271 | // tokenize the update 272 | tokens := strings.Split(update.Message.Text, " ") 273 | command := strings.ToLower(tokens[0]) 274 | 275 | switch command { 276 | case "list", "/list", "li", "/li": 277 | go list(tokens[1:]) 278 | 279 | case "head", "/head", "he", "/he": 280 | go head(tokens[1:]) 281 | 282 | case "tail", "/tail", "ta", "/ta": 283 | go tail(tokens[1:]) 284 | 285 | case "down", "/down", "dl", "/dl": 286 | go downs() 287 | 288 | case "seeding", "/seeding", "sd", "/sd": 289 | go seeding() 290 | 291 | case "paused", "/paused", "pa", "/pa": 292 | go paused() 293 | 294 | case "hashing", "/hashing", "ha", "/ha": 295 | go hashing() 296 | 297 | case "active", "/active", "ac", "/ac": 298 | go active() 299 | 300 | case "errors", "/errors", "er", "/er": 301 | go errors() 302 | 303 | case "sort", "/sort", "so", "/so": 304 | go sort(tokens[1:]) 305 | 306 | case "trackers", "/trackers", "tr", "/tr": 307 | go trackers() 308 | 309 | case "add", "/add", "ad", "/ad": 310 | go add(tokens[1:], "") 311 | 312 | case "search", "/search", "se", "/se": 313 | go search(tokens[1:]) 314 | 315 | case "latest", "/latest", "la", "/la": 316 | go latest(tokens[1:]) 317 | 318 | case "info", "/info", "in", "/in": 319 | go info(tokens[1:]) 320 | 321 | case "stop", "/stop", "sp", "/sp": 322 | go stop(tokens[1:]) 323 | 324 | case "start", "/start", "st", "/st": 325 | go start(tokens[1:]) 326 | 327 | case "check", "/check", "ck", "/ck": 328 | go check(tokens[1:]) 329 | 330 | case "stats", "/stats", "sa", "/sa": 331 | go stats() 332 | 333 | case "speed", "/speed", "ss", "/ss": 334 | go speed() 335 | 336 | case "count", "/count", "co", "/co": 337 | go count() 338 | 339 | case "del", "/del": 340 | go del(tokens[1:]) 341 | 342 | case "deldata", "/deldata": 343 | go deldata(tokens[1:]) 344 | 345 | case "help", "/help": 346 | go send(HELP, true) 347 | 348 | case "version", "/version": 349 | go getVersion() 350 | 351 | case "": 352 | // might be a file received 353 | go receiveTorrent(update) 354 | 355 | default: 356 | // no such command, try help 357 | go send("no such command, try /help", false) 358 | 359 | } 360 | } 361 | } 362 | 363 | // send takes a chat id and a message to send, returns the message id of the send message 364 | func send(text string, markdown bool) int { 365 | // set typing action 366 | action := tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping) 367 | Bot.Send(action) 368 | 369 | // check the rune count, telegram is limited to 4096 chars per message; 370 | // so if our message is > 4096, split it in chunks the send them. 371 | msgRuneCount := utf8.RuneCountInString(text) 372 | LenCheck: 373 | stop := 4095 374 | if msgRuneCount > 4096 { 375 | for text[stop] != 10 { // '\n' 376 | stop-- 377 | } 378 | msg := tgbotapi.NewMessage(chatID, text[:stop]) 379 | msg.DisableWebPagePreview = true 380 | if markdown { 381 | msg.ParseMode = tgbotapi.ModeMarkdown 382 | } 383 | 384 | // send current chunk 385 | if _, err := Bot.Send(msg); err != nil { 386 | logger.Printf("[ERROR] Send: %s", err) 387 | } 388 | // move to the next chunk 389 | text = text[stop:] 390 | msgRuneCount = utf8.RuneCountInString(text) 391 | goto LenCheck 392 | } 393 | 394 | // if msgRuneCount < 4096, send it normally 395 | msg := tgbotapi.NewMessage(chatID, text) 396 | msg.DisableWebPagePreview = true 397 | if markdown { 398 | msg.ParseMode = tgbotapi.ModeMarkdown 399 | } 400 | 401 | resp, err := Bot.Send(msg) 402 | if err != nil { 403 | logger.Printf("[ERROR] Send: %s", err) 404 | } 405 | 406 | return resp.MessageID 407 | } 408 | 409 | // getVersion sends rTorrent/libtorrent version + rtelegram version 410 | func getVersion() { 411 | send(fmt.Sprintf("rTorrent/libtorrent: *%s*\nrtelegram: *%s*", rtorrent.Version, VERSION), true) 412 | } 413 | 414 | // Check if []string contains string 415 | func aMaster(name string) bool { 416 | name = strings.ToLower(name) 417 | for i := range Masters { 418 | if Masters[i] == name { 419 | return true 420 | } 421 | } 422 | return false 423 | } 424 | -------------------------------------------------------------------------------- /paused.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | humanize "github.com/pyed/go-humanize" 8 | "github.com/pyed/rtapi" 9 | ) 10 | 11 | // paused will send the names of the torrents with status 'Paused' 12 | func paused() { 13 | torrents, err := rtorrent.Torrents() 14 | if err != nil { 15 | logger.Print(err) 16 | send("paused: "+err.Error(), false) 17 | return 18 | } 19 | 20 | buf := new(bytes.Buffer) 21 | for i := range torrents { 22 | if torrents[i].State == rtapi.Stopped { 23 | buf.WriteString(fmt.Sprintf("<%d> %s\n%s (%s) DL: %s UL: %s R: %s\n\n", 24 | i, torrents[i].Name, torrents[i].State, 25 | torrents[i].Percent, humanize.IBytes(torrents[i].Completed), 26 | humanize.IBytes(torrents[i].UpTotal), torrents[i].Ratio)) 27 | } 28 | } 29 | 30 | if buf.Len() == 0 { 31 | send("No paused torrents", false) 32 | return 33 | } 34 | 35 | send(buf.String(), false) 36 | } 37 | -------------------------------------------------------------------------------- /recieveTorrent.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/pyed/rtapi" 9 | tgbotapi "gopkg.in/telegram-bot-api.v4" 10 | ) 11 | 12 | // receiveTorrent gets an update that potentially has a .torrent file to add 13 | func receiveTorrent(ud tgbotapi.Update) { 14 | if ud.Message.Document == nil { 15 | return // has no document 16 | } 17 | 18 | // get the file ID and make the config 19 | fconfig := tgbotapi.FileConfig{ 20 | FileID: ud.Message.Document.FileID, 21 | } 22 | file, err := Bot.GetFile(fconfig) 23 | if err != nil { 24 | send("receiver: "+err.Error(), false) 25 | return 26 | } 27 | 28 | // if there's no options, just add the torrent 29 | if ud.Message.Caption == "" { 30 | add([]string{file.Link(BotToken)}, ud.Message.Document.FileName) 31 | return 32 | } 33 | 34 | var tFile rtapi.DotTorrentWithOptions 35 | tFile.Link = file.Link(BotToken) 36 | tFile.Name = ud.Message.Document.FileName 37 | tFile.Dir, tFile.Label = processOptions(ud.Message.Caption) 38 | 39 | // check if dir is there, or try to make it. 40 | if tFile.Dir != "" { 41 | // if there's '~' expand it 42 | if strings.HasPrefix(tFile.Dir, "~") { 43 | homedir, err := os.UserHomeDir() 44 | if err != nil { 45 | send(fmt.Sprintf("receiver: Couldn't expand '~' in: %s", tFile.Dir), false) 46 | return 47 | } 48 | tFile.Dir = strings.Replace(tFile.Dir, "~", homedir, 1) 49 | } 50 | 51 | // if the directory isn't there, create it 52 | if _, err := os.Stat(tFile.Dir); os.IsNotExist(err) { 53 | if err = os.MkdirAll(tFile.Dir, os.ModePerm); err != nil { 54 | send(fmt.Sprintf("receiver: Couldn't make directory %s, error: %s", tFile.Dir, err.Error()), false) 55 | return 56 | } else { 57 | send("New directory created: "+tFile.Dir, false) 58 | } 59 | } 60 | } 61 | 62 | // add the .torrent with options 63 | if err := rtorrent.DownloadWithOptions(&tFile); err != nil { 64 | logger.Print("add with options:", err) 65 | send("add with options: %s"+err.Error(), false) 66 | } 67 | 68 | send(fmt.Sprintf("Added: %s", tFile.Name), false) 69 | 70 | } 71 | 72 | // processOptions looks inside 'ud.Message.Caption' and processes the passed options if any; 73 | // e.g. d=/dir/to/downlaods l=Software, will save the added torrent ; 74 | // torrent to the specified direcotry, and will assigne the label "Software" ; 75 | // to it, labels are saved to "d.custom1", which is used by ruTorrent. ; 76 | func processOptions(options string) (dir, lable string) { 77 | if options == "" { 78 | return 79 | } 80 | 81 | // more options can be added later 82 | sliceOfOptions := strings.Split(options, " ") 83 | for _, o := range sliceOfOptions { 84 | switch { 85 | case strings.HasPrefix(o, "d="): // directory 86 | dir = o[2:] 87 | case strings.HasPrefix(o, "l="): // label 88 | lable = o[2:] 89 | case strings.ContainsAny(o, "/\\"): // maybe a directory without 'd=' 90 | dir = o 91 | default: // if none of the above matches, then just make it a label 92 | lable = o 93 | } 94 | } 95 | return 96 | } 97 | -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | // search takes a query and returns torrents with match 11 | func search(tokens []string) { 12 | // make sure that we got a query 13 | if len(tokens) == 0 { 14 | send("search: needs an argument", false) 15 | return 16 | } 17 | 18 | query := strings.Join(tokens, " ") 19 | // "(?i)" for case insensitivity 20 | regx, err := regexp.Compile("(?i)" + query) 21 | if err != nil { 22 | logger.Print(err) 23 | send("search: "+err.Error(), false) 24 | return 25 | } 26 | 27 | torrents, err := rtorrent.Torrents() 28 | if err != nil { 29 | logger.Print(err) 30 | send("search: "+err.Error(), false) 31 | return 32 | } 33 | 34 | buf := new(bytes.Buffer) 35 | for i := range torrents { 36 | if regx.MatchString(torrents[i].Name) { 37 | buf.WriteString(fmt.Sprintf("<%d> %s\n", i, torrents[i].Name)) 38 | } 39 | } 40 | if buf.Len() == 0 { 41 | send("No matches!", false) 42 | return 43 | } 44 | send(buf.String(), false) 45 | } 46 | -------------------------------------------------------------------------------- /seeding.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/pyed/rtapi" 8 | ) 9 | 10 | // seeding will send the names of the torrents with the status 'Seeding'. 11 | func seeding() { 12 | torrents, err := rtorrent.Torrents() 13 | if err != nil { 14 | logger.Print(err) 15 | send("seeding: "+err.Error(), false) 16 | return 17 | } 18 | 19 | buf := new(bytes.Buffer) 20 | for i := range torrents { 21 | if torrents[i].State == rtapi.Seeding { 22 | buf.WriteString(fmt.Sprintf("<%d> %s\n", i, torrents[i].Name)) 23 | } 24 | } 25 | 26 | if buf.Len() == 0 { 27 | send("No torrents seeding", false) 28 | return 29 | } 30 | 31 | send(buf.String(), false) 32 | 33 | } 34 | -------------------------------------------------------------------------------- /sort.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pyed/rtapi" 7 | ) 8 | 9 | // sort changes torrents sorting 10 | func sort(tokens []string) { 11 | if len(tokens) == 0 { 12 | send(`sort takes one of: 13 | (*name, downrate, uprate, size, ratio, age, upload*) 14 | optionally start with (*rev*) for reversed order 15 | e.g. "*sort rev size*" to get biggest torrents first.`, true) 16 | return 17 | } 18 | 19 | var reversed bool 20 | if strings.ToLower(tokens[0]) == "rev" && 21 | len(tokens) > 1 { 22 | reversed = true 23 | tokens = tokens[1:] 24 | } 25 | 26 | switch strings.ToLower(tokens[0]) { 27 | case "name": 28 | if reversed { 29 | rtapi.CurrentSorting = rtapi.ByNameRev 30 | send("sort: by `reversed name`", true) 31 | break 32 | } 33 | rtapi.CurrentSorting = rtapi.ByName 34 | send("sort: by `name`", true) 35 | 36 | case "downrate": 37 | if reversed { 38 | rtapi.CurrentSorting = rtapi.ByDownRateRev 39 | send("sort: by `reversed down rate`", true) 40 | break 41 | } 42 | rtapi.CurrentSorting = rtapi.ByDownRate 43 | send("sort: by `down rate`", true) 44 | 45 | case "uprate": 46 | if reversed { 47 | rtapi.CurrentSorting = rtapi.ByUpRateRev 48 | send("sort: by `reversed up rate`", true) 49 | break 50 | } 51 | rtapi.CurrentSorting = rtapi.ByUpRate 52 | send("sort: by `up rate`", true) 53 | case "size": 54 | if reversed { 55 | rtapi.CurrentSorting = rtapi.BySizeRev 56 | send("sort: by `reversed size`", true) 57 | break 58 | } 59 | rtapi.CurrentSorting = rtapi.BySize 60 | send("sort: by `size`", true) 61 | case "ratio": 62 | if reversed { 63 | rtapi.CurrentSorting = rtapi.ByRatioRev 64 | send("sort: by `reversed ratio`", true) 65 | break 66 | } 67 | rtapi.CurrentSorting = rtapi.ByRatio 68 | send("sort: by `ratio`", true) 69 | 70 | case "age": 71 | if reversed { 72 | rtapi.CurrentSorting = rtapi.ByAgeRev 73 | send("sort: by `reversed age`", true) 74 | break 75 | } 76 | rtapi.CurrentSorting = rtapi.ByAge 77 | send("sort: by `age`", true) 78 | case "upload": 79 | if reversed { 80 | rtapi.CurrentSorting = rtapi.ByUpTotalRev 81 | send("sort: by `reversed up total`", true) 82 | break 83 | } 84 | rtapi.CurrentSorting = rtapi.ByUpTotal 85 | send("sort: by `up total`", true) 86 | default: 87 | send("unkown sorting method", false) 88 | return 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /speed.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | humanize "github.com/pyed/go-humanize" 8 | tgbotapi "gopkg.in/telegram-bot-api.v4" 9 | ) 10 | 11 | // speed will echo back the current download and upload speeds 12 | func speed() { 13 | down, up := rtorrent.Speeds() 14 | 15 | msg := fmt.Sprintf("↓ %s ↑ %s", humanize.IBytes(down), humanize.IBytes(up)) 16 | 17 | msgID := send(msg, false) 18 | 19 | if NoLive { 20 | return 21 | } 22 | 23 | for i := 0; i < duration; i++ { 24 | time.Sleep(time.Second * interval) 25 | down, up = rtorrent.Speeds() 26 | 27 | msg = fmt.Sprintf("↓ %s ↑ %s", humanize.IBytes(down), humanize.IBytes(up)) 28 | 29 | editConf := tgbotapi.NewEditMessageText(chatID, msgID, msg) 30 | Bot.Send(editConf) 31 | time.Sleep(time.Second * interval) 32 | } 33 | // sleep one more time before switching to dashes 34 | time.Sleep(time.Second * interval) 35 | 36 | // show dashes to indicate that we are done updating. 37 | editConf := tgbotapi.NewEditMessageText(chatID, msgID, "↓ - B ↑ - B") 38 | Bot.Send(editConf) 39 | } 40 | -------------------------------------------------------------------------------- /start.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | // start takes id[s] of torrent[s] or 'all' to start them 9 | func start(tokens []string) { 10 | // make sure that we got at least one argument 11 | if len(tokens) == 0 { 12 | send("start: needs an argument", false) 13 | return 14 | } 15 | 16 | torrents, err := rtorrent.Torrents() 17 | if err != nil { 18 | logger.Print("start:", err) 19 | send("start: "+err.Error(), false) 20 | return 21 | } 22 | 23 | // if the first argument is 'all' then start all torrents 24 | if tokens[0] == "all" { 25 | if err := rtorrent.Start(torrents...); err != nil { 26 | logger.Print("start:", err) 27 | send("start: error occurred while starting some torrents", false) 28 | return 29 | } 30 | send("started all torrents", false) 31 | return 32 | 33 | } 34 | 35 | for _, i := range tokens { 36 | id, err := strconv.Atoi(i) 37 | if err != nil { 38 | send(fmt.Sprintf("start: %s is not a number", i), false) 39 | continue 40 | } 41 | 42 | if id >= len(torrents) || id < 0 { 43 | send(fmt.Sprintf("start: No torrent with an ID of '%d'", id), false) 44 | continue 45 | } 46 | 47 | if err := rtorrent.Start(torrents[id]); err != nil { 48 | logger.Print("start:", err) 49 | send("start: "+err.Error(), false) 50 | continue 51 | } 52 | send(fmt.Sprintf("Started: %s", torrents[id].Name), false) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /stats.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | humanize "github.com/pyed/go-humanize" 7 | ) 8 | 9 | // stats echo back transmission stats 10 | func stats() { 11 | stats, err := rtorrent.Stats() 12 | if err != nil { 13 | logger.Print("stats:", err) 14 | send("stats: "+err.Error(), false) 15 | return 16 | } 17 | 18 | // show 'off' instead of 0 for throttling 19 | var throttleUp, throttleDown string 20 | if stats.ThrottleUp == 0 { 21 | throttleUp = "off" 22 | } else { 23 | throttleUp = humanize.IBytes(stats.ThrottleUp) 24 | } 25 | 26 | if stats.ThrottleDown == 0 { 27 | throttleDown = "off" 28 | } else { 29 | throttleDown = humanize.IBytes(stats.ThrottleDown) 30 | } 31 | 32 | msg := fmt.Sprintf( 33 | ` 34 | \[Throttle *%s* / *%s*] 35 | \[Port *%s*] 36 | \[*%s*] 37 | Total Uploaded: *%s* 38 | Total Download: *%s* 39 | `, 40 | throttleUp, throttleDown, stats.Port, stats.Directory, 41 | humanize.IBytes(stats.TotalUp), humanize.IBytes(stats.TotalDown), 42 | ) 43 | 44 | send(msg, true) 45 | } 46 | -------------------------------------------------------------------------------- /stop.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | // stop takes id[s] of torrent[s] or 'all' to stop them 9 | func stop(tokens []string) { 10 | // make sure that we got at least one argument 11 | if len(tokens) == 0 { 12 | send("stop: needs an argument", false) 13 | return 14 | } 15 | 16 | torrents, err := rtorrent.Torrents() 17 | if err != nil { 18 | logger.Print("stop:", err) 19 | send("stop: "+err.Error(), false) 20 | return 21 | } 22 | 23 | // if the first argument is 'all' then stop all torrents 24 | if tokens[0] == "all" { 25 | if err := rtorrent.Stop(torrents...); err != nil { 26 | logger.Print("stop:", err) 27 | send("stop: error occurred while stopping some torrents", false) 28 | return 29 | } 30 | send("stopped all torrents", false) 31 | return 32 | } 33 | 34 | for _, i := range tokens { 35 | id, err := strconv.Atoi(i) 36 | if err != nil { 37 | send(fmt.Sprintf("stop: %s is not a number", i), false) 38 | continue 39 | } 40 | 41 | if id >= len(torrents) || id < 0 { 42 | send(fmt.Sprintf("stop: No torrent with an ID of '%d'", id), false) 43 | continue 44 | } 45 | 46 | if err := rtorrent.Stop(torrents[id]); err != nil { 47 | logger.Print("stop:", err) 48 | send("stop: "+err.Error(), false) 49 | continue 50 | } 51 | send(fmt.Sprintf("Stopped: %s", torrents[id].Name), false) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tail.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strconv" 7 | "time" 8 | 9 | humanize "github.com/pyed/go-humanize" 10 | tgbotapi "gopkg.in/telegram-bot-api.v4" 11 | ) 12 | 13 | // tail lists the last 5 or n torrents 14 | func tail(tokens []string) { 15 | var ( 16 | n = 5 // default to 5 17 | err error 18 | ) 19 | 20 | if len(tokens) > 0 { 21 | n, err = strconv.Atoi(tokens[0]) 22 | if err != nil { 23 | send("tail: argument must be a number", false) 24 | return 25 | } 26 | } 27 | 28 | torrents, err := rtorrent.Torrents() 29 | if err != nil { 30 | logger.Print(err) 31 | send("tail: "+err.Error(), false) 32 | return 33 | } 34 | 35 | // make sure that we stay in the boundaries 36 | if n <= 0 || n > len(torrents) { 37 | n = len(torrents) 38 | } 39 | 40 | buf := new(bytes.Buffer) 41 | for i, torrent := range torrents[len(torrents)-n:] { 42 | torrentName := mdReplacer.Replace(torrent.Name) // escape markdown 43 | buf.WriteString(fmt.Sprintf("`<%d>` *%s*\n%s *%s* (%s) ↓ *%s* ↑ *%s* R: *%.2f*\n\n", 44 | i+len(torrents)-n, torrentName, torrent.State, humanize.IBytes(torrent.Completed), 45 | torrent.Percent, humanize.IBytes(torrent.DownRate), 46 | humanize.IBytes(torrent.UpRate), torrent.Ratio)) 47 | } 48 | 49 | if buf.Len() == 0 { 50 | send("tail: No torrents", false) 51 | return 52 | } 53 | 54 | msgID := send(buf.String(), true) 55 | 56 | if NoLive { 57 | return 58 | } 59 | 60 | // keep the info live 61 | for i := 0; i < duration; i++ { 62 | time.Sleep(time.Second * interval) 63 | buf.Reset() 64 | 65 | torrents, err = rtorrent.Torrents() 66 | if err != nil { 67 | logger.Print("tail:", err) 68 | continue // try again if some error heppened 69 | } 70 | 71 | if len(torrents) < 1 { 72 | continue 73 | } 74 | 75 | // make sure that we stay in the boundaries 76 | if n <= 0 || n > len(torrents) { 77 | n = len(torrents) 78 | } 79 | 80 | for i, torrent := range torrents[len(torrents)-n:] { 81 | torrentName := mdReplacer.Replace(torrent.Name) // escape markdown 82 | buf.WriteString(fmt.Sprintf("`<%d>` *%s*\n%s *%s* (%s) ↓ *%s* ↑ *%s* R: *%.2f*\n\n", 83 | i+len(torrents)-n, torrentName, torrent.State, humanize.IBytes(torrent.Completed), 84 | torrent.Percent, humanize.IBytes(torrent.DownRate), 85 | humanize.IBytes(torrent.UpRate), torrent.Ratio)) 86 | } 87 | 88 | // no need to check if it is empty, as if the buffer is empty telegram won't change the message 89 | editConf := tgbotapi.NewEditMessageText(chatID, msgID, buf.String()) 90 | editConf.ParseMode = tgbotapi.ModeMarkdown 91 | Bot.Send(editConf) 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /trackers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | ) 8 | 9 | var trackerRegex = regexp.MustCompile(`[https?|udp]://([^:/]*)`) 10 | 11 | // trackers will send a list of trackers and how many torrents each one has 12 | func trackers() { 13 | torrents, err := rtorrent.Torrents() 14 | if err != nil { 15 | logger.Print(err) 16 | send("trackers: "+err.Error(), false) 17 | return 18 | } 19 | 20 | trackers := make(map[string]int) 21 | 22 | for i := range torrents { 23 | currentTracker := torrents[i].Tracker.Hostname() 24 | n, ok := trackers[currentTracker] 25 | if !ok { 26 | trackers[currentTracker] = 1 27 | continue 28 | } 29 | trackers[currentTracker] = n + 1 30 | } 31 | 32 | buf := new(bytes.Buffer) 33 | for k, v := range trackers { 34 | buf.WriteString(fmt.Sprintf("%d - %s\n", v, k)) 35 | } 36 | 37 | if buf.Len() == 0 { 38 | send("No trackers!", false) 39 | return 40 | } 41 | send(buf.String(), false) 42 | } 43 | --------------------------------------------------------------------------------