├── .gitignore ├── systemd └── telegram-bot-remotecontrol.service ├── config.json.sample ├── main.go ├── PRIVACY.md ├── cmd └── telegram-bot-broadcast │ ├── examples │ ├── system-broadcast.sh │ ├── ssh-login-broadcast.sh │ ├── transmission-complete-broadcast.sh │ └── fail2ban-broadcast.sh │ └── main.go ├── consts └── consts.go ├── database.go ├── util.go ├── go.mod ├── README.md ├── cfg └── config.go ├── transmission.go ├── go.sum └── bot.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | db.sqlite* 3 | 4 | config.json 5 | 6 | telegram-remotecontrol-bot 7 | cmd/telegram-bot-broadcast/telegram-bot-broadcast 8 | -------------------------------------------------------------------------------- /systemd/telegram-bot-remotecontrol.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Telegram Bot for Remote Control 3 | After=syslog.target 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=some_user 9 | Group=some_user 10 | WorkingDirectory=/path/to/telegram-remotecontrol-bot 11 | ExecStart=/path/to/telegram-remotecontrol-bot/telegram-remotecontrol-bot 12 | Restart=always 13 | RestartSec=5 14 | Environment= 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | -------------------------------------------------------------------------------- /config.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "available_ids": [ 3 | "telegram_id_1", 4 | "telegram_id_2", 5 | "telegram_id_3" 6 | ], 7 | "controllable_services": [ 8 | ], 9 | "mount_points": [ 10 | ], 11 | "monitor_interval": 3, 12 | "transmission_rpc_port": 9091, 13 | "transmission_rpc_username": "", 14 | "transmission_rpc_passwd": "", 15 | "cli_port": 59992, 16 | "is_verbose": false, 17 | 18 | "api_token": "0123456789:abcdefghijklmnopqrstuvwyz-x-0a1b2c3d4e" 19 | } 20 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // telegram bot for controlling transmission remotely 2 | package main 3 | 4 | import ( 5 | "context" 6 | "time" 7 | 8 | "github.com/meinside/telegram-remotecontrol-bot/cfg" 9 | ) 10 | 11 | func main() { 12 | launchedAt := time.Now() 13 | 14 | ctx := context.Background() 15 | 16 | // read config file, 17 | if config, err := cfg.GetConfig(ctx); err == nil { 18 | // and run the bot with it 19 | runBot(ctx, config, launchedAt) 20 | } else { 21 | panic(err) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | This document was written to comply with the Telegram's [privacy policy](https://telegram.org/tos/bot-developers#4-privacy). 4 | 5 | ## Collected Data 6 | 7 | This bot is only usable for whitelisted users. 8 | 9 | * username: used for whitelisting users 10 | * chat id, message id: used for replying messages 11 | * message texts and attachments: used for processing messages 12 | 13 | ## Data Storage and Retention 14 | 15 | * All of the above data are stored in the local database for logging and broadcasting messages to whitelisted users. 16 | * None of the above data will be transferred elsewhere. 17 | 18 | -------------------------------------------------------------------------------- /cmd/telegram-bot-broadcast/examples/system-broadcast.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # system-broadcast.sh 4 | # 5 | # created by : meinside@duck.com 6 | # last update: 2023.06.20. 7 | # 8 | # for broadcasting system status 9 | # through telegram-remotecontrol-bot 10 | # (github.com/meinside/telegram-remotecontrol-bot) 11 | # 12 | # Usage: 13 | # 14 | # 1. setup and run telegram-remotecontrol-bot: 15 | # 16 | # https://github.com/meinside/telegram-remotecontrol-bot 17 | # 18 | # 2. install telegram-bot-broadcast: 19 | # 20 | # $ go get -u github.com/meinside/telegram-remotecontrol-bot/cmd/telegram-bot-broadcast 21 | # 22 | # 3. register this script on crontab 23 | 24 | BROADCAST_BIN="/path/to/bin/telegram-bot-broadcast" # XXX - edit this 25 | 26 | HOSTNAME=`hostname` 27 | IP_ADDR=`hostname -I` 28 | UNAME=`uname -a` 29 | UPTIME=`uptime` 30 | DF=`df -h` 31 | TEMP=`vcgencmd measure_temp` 32 | MEMORY=`free -o -h` 33 | 34 | # message 35 | MSG="*system status*: $HOSTNAME ($IP_ADDR) 36 | 37 | $UNAME 38 | 39 | $UPTIME 40 | 41 | $DF 42 | 43 | $TEMP 44 | 45 | $MEMORY" 46 | 47 | # broadcast 48 | $BROADCAST_BIN "$MSG" 49 | -------------------------------------------------------------------------------- /cmd/telegram-bot-broadcast/examples/ssh-login-broadcast.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # ssh-login-broadcast.sh 4 | # 5 | # created by : meinside@duck.com 6 | # last update: 2023.06.20. 7 | # 8 | # for broadcasting successful ssh logins 9 | # through telegram-remotecontrol-bot 10 | # (github.com/meinside/telegram-remotecontrol-bot) 11 | # 12 | # Usage: 13 | # 14 | # 1. setup and run telegram-remotecontrol-bot: 15 | # 16 | # https://github.com/meinside/telegram-remotecontrol-bot 17 | # 18 | # 2. install telegram-bot-broadcast: 19 | # 20 | # $ go get -u github.com/meinside/telegram-remotecontrol-bot/cmd/telegram-bot-broadcast 21 | # 22 | # 3. open /etc/pam.d/sshd, 23 | # $ sudo vi /etc/pam.d/sshd 24 | # 25 | # 4. and append following lines (edit path to yours): 26 | # # for broadcasting to all connected clients on successful logins 27 | # session optional pam_exec.so seteuid /path/to/this/ssh-login-broadcast.sh 28 | 29 | BROADCAST_BIN="/path/to/bin/telegram-bot-broadcast" # XXX - edit this path 30 | 31 | # on session open, 32 | if [ $PAM_TYPE == "open_session" ]; then 33 | # broadcast 34 | $BROADCAST_BIN "*sshd >* $PAM_USER has successfully logged into `hostname`, from $PAM_RHOST" 35 | fi 36 | -------------------------------------------------------------------------------- /cmd/telegram-bot-broadcast/examples/transmission-complete-broadcast.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # transmission-complete-broadcast.sh 4 | # 5 | # created by : meinside@duck.com 6 | # last update: 2023.06.20. 7 | # 8 | # for broadcasting transmission download complete message 9 | # through telegram-remotecontrol-bot 10 | # (github.com/meinside/telegram-remotecontrol-bot) 11 | # 12 | # Usage: 13 | # 14 | # 1. setup and run telegram-remotecontrol-bot: 15 | # 16 | # https://github.com/meinside/telegram-remotecontrol-bot 17 | # 18 | # 2. install telegram-bot-broadcast: 19 | # 20 | # $ go get -u github.com/meinside/telegram-remotecontrol-bot/cmd/telegram-bot-broadcast 21 | # 22 | # 3. configure Transmission to run this script on download complete: 23 | # 24 | # $ sudo systemctl stop transmission-daemon.service 25 | # $ sudo vi /etc/transmission-daemon/settings.json 26 | # 27 | # # change following values: 28 | # "script-torrent-done-enabled": true, 29 | # "script-torrent-done-filename": "/path/to/this/transmission-complete-broadcast.sh", 30 | # 31 | # $ sudo systemctl start transmission-daemon.service 32 | 33 | BROADCAST_BIN="/path/to/bin/telegram-bot-broadcast" # XXX - edit this path 34 | 35 | # broadcast 36 | $BROADCAST_BIN "Transmission > Download completed: '$TR_TORRENT_NAME' in $TR_TORRENT_DIR" 37 | -------------------------------------------------------------------------------- /consts/consts.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | // for constants used globally 4 | 5 | // constants 6 | const ( 7 | // for CLI 8 | HTTPBroadcastPath = `/broadcast` 9 | DefaultCLIPortNumber = 59992 10 | ParamMessage = `m` 11 | QueueSize = 3 12 | 13 | // for Transmission daemon 14 | DefaultTransmissionRPCPort = 9091 15 | 16 | // for monitoring 17 | DefaultMonitorIntervalSeconds = 3 18 | 19 | // commands 20 | CommandStart = `/start` 21 | CommandStatus = `/status` 22 | CommandLogs = `/logs` 23 | CommandHelp = `/help` 24 | CommandCancel = `/cancel` 25 | CommandPrivacy = `/privacy` 26 | 27 | // commands for systemctl 28 | CommandServiceStatus = `/servicestatus` 29 | CommandServiceStart = `/servicestart` 30 | CommandServiceStop = `/servicestop` 31 | 32 | // commands for transmission 33 | CommandTransmissionList = `/trlist` 34 | CommandTransmissionAdd = `/tradd` 35 | CommandTransmissionRemove = `/trremove` 36 | CommandTransmissionDelete = `/trdelete` 37 | 38 | // messages 39 | MessageDefault = `Input your command:` 40 | MessageUnknownCommand = `Unknown command.` 41 | MessageUnprocessableFileFormat = `Unprocessable file format.` 42 | MessageNoControllableServices = `No controllable services.` 43 | MessageNoLogs = `No saved logs.` 44 | MessageServiceToStart = `Select service to start:` 45 | MessageServiceToStop = `Select service to stop:` 46 | MessageTransmissionUpload = `Send magnet, url, or file of target torrent:` 47 | MessageTransmissionRemove = `Send the id of torrent to remove from the list:` 48 | MessageTransmissionDelete = `Send the id of torrent to delete from the list and local storage:` 49 | MessageTransmissionNoTorrents = `No torrents.` 50 | MessageCancel = `Cancel` 51 | MessageCanceled = `Canceled.` 52 | 53 | // number of recent logs 54 | NumRecentLogs = 20 55 | ) 56 | -------------------------------------------------------------------------------- /cmd/telegram-bot-broadcast/examples/fail2ban-broadcast.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # fail2ban-broadcast.sh 4 | # 5 | # created by : meinside@duck.com 6 | # last update: 2023.06.20. 7 | # 8 | # for broadcasting a message on fail2ban's ban action 9 | # through telegram-remotecontrol-bot 10 | # (github.com/meinside/telegram-remotecontrol-bot) 11 | # 12 | # Usage: 13 | # 14 | # 0. install essential packages: 15 | # 16 | # $ sudo apt-get install curl jq 17 | # 18 | # 1. Setup and run telegram-remotecontrol-bot: 19 | # 20 | # https://github.com/meinside/telegram-remotecontrol-bot 21 | # 22 | # 2. Install telegram-bot-broadcast: 23 | # 24 | # $ go get -u github.com/meinside/telegram-remotecontrol-bot/cmd/telegram-bot-broadcast 25 | # 26 | # 3. Duplicate fail2ban's banaction: 27 | # 28 | # $ cd /etc/fail2ban/action.d 29 | # $ sudo cp iptables-multiport.conf iptables-multiport-letmeknow.conf 30 | # 31 | # 4. Append a line at the end of actionban which will execute this bash script: 32 | # 33 | # $ sudo vi iptables-multiport-letmeknow.conf 34 | # 35 | # (example) 36 | # actionban = iptables -I fail2ban- 1 -s -j DROP 37 | # /path/to/this/fail2ban-broadcast.sh "" "" 38 | # 39 | # 5. Change banaction to your newly created one in jail.local: 40 | # 41 | # $ sudo vi /etc/fail2ban/jail.local 42 | # 43 | # ACTIONS 44 | # #banaction = iptables-multiport 45 | # banaction = iptables-multiport-letmeknow 46 | # 47 | # 6. Restart fail2ban service: 48 | # 49 | # $ sudo systemctl restart fail2ban.service 50 | 51 | BROADCAST_BIN="/path/to/bin/telegram-bot-broadcast" # XXX - edit this path 52 | 53 | if [ $# -ge 2 ]; then 54 | PROTOCOL=$1 55 | IP=$2 56 | LOCATION=`curl -s http://geoip.nekudo.com/api/$IP | jq '. | .city, .country.name | select(.!=null) | select(.!=false)' | tr '\n' ' '` 57 | 58 | # broadcast 59 | $BROADCAST_BIN "*fail2ban >* [$PROTOCOL] banned $IP from $LOCATION" 60 | else 61 | # usage 62 | echo "$ fail2ban-broadcast.sh PROTOCOL_NAME BANNED_IP (eg. $ fail2ban-broadcast.sh ssh 8.8.8.8)" 63 | fi 64 | -------------------------------------------------------------------------------- /database.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "path/filepath" 7 | 8 | "github.com/meinside/telegram-remotecontrol-bot/cfg" 9 | "gorm.io/driver/sqlite" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/clause" 12 | ) 13 | 14 | // constants for local database 15 | const ( 16 | dbFilename = "db.sqlite" 17 | ) 18 | 19 | // Database struct 20 | type Database struct { 21 | db *gorm.DB 22 | } 23 | 24 | // Log struct 25 | type Log struct { 26 | gorm.Model 27 | 28 | Type string 29 | Message string 30 | } 31 | 32 | // Chat struct 33 | type Chat struct { 34 | gorm.Model 35 | 36 | ChatID int64 `gorm:"uniqueIndex"` 37 | UserID string 38 | } 39 | 40 | // OpenDB opens database and returns it 41 | func OpenDB() (database *Database, err error) { 42 | var configDir string 43 | configDir, err = cfg.GetConfigDir() 44 | 45 | if err == nil { 46 | dbFilepath := filepath.Join(configDir, dbFilename) 47 | 48 | var db *gorm.DB 49 | if db, err = gorm.Open(sqlite.Open(dbFilepath), &gorm.Config{}); err != nil { 50 | err = fmt.Errorf("gorm failed to open database: %s", err) 51 | } else { 52 | // migrate tables 53 | if err = db.AutoMigrate(&Log{}, &Chat{}); err == nil { 54 | return &Database{db: db}, nil 55 | } else { 56 | err = fmt.Errorf("gorm failed to migrate database: %s", err) 57 | } 58 | } 59 | } 60 | 61 | return nil, err 62 | } 63 | 64 | // save log 65 | func (d *Database) saveLog(typ, msg string) { 66 | if tx := d.db.Create(&Log{Type: typ, Message: msg}); tx.Error != nil { 67 | log.Printf("* failed to save log into local database: %s", tx.Error) 68 | } 69 | } 70 | 71 | // Log logs a message 72 | func (d *Database) Log(msg string) { 73 | d.saveLog("log", msg) 74 | } 75 | 76 | // LogError logs an error message 77 | func (d *Database) LogError(msg string) { 78 | d.saveLog("err", msg) 79 | } 80 | 81 | // GetLogs fetches logs 82 | func (d *Database) GetLogs(latestN int) (result []Log) { 83 | if tx := d.db.Order("id desc").Limit(latestN).Find(&result); tx.Error != nil { 84 | log.Printf("* failed to get logs from local database: %s", tx.Error) 85 | 86 | return []Log{} 87 | } 88 | 89 | return result 90 | } 91 | 92 | // SaveChat saves chat 93 | func (d *Database) SaveChat(chatID int64, userID string) { 94 | if tx := d.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&Chat{ChatID: chatID, UserID: userID}); tx.Error != nil { 95 | log.Printf("* failed to save chat into local database: %s", tx.Error) 96 | } 97 | } 98 | 99 | // DeleteChat deletes chat 100 | func (d *Database) DeleteChat(chatID int) { 101 | if tx := d.db.Where("chat_id = ?", chatID).Delete(&Chat{}); tx.Error != nil { 102 | log.Printf("* failed to delete chat from local database: %s", tx.Error) 103 | } 104 | } 105 | 106 | // GetChats retrieves chats 107 | func (d *Database) GetChats() (result []Chat) { 108 | if tx := d.db.Find(&result); tx.Error != nil { 109 | log.Printf("* failed to get chats from local database: %s", tx.Error) 110 | 111 | return []Chat{} 112 | } 113 | 114 | return result 115 | } 116 | -------------------------------------------------------------------------------- /cmd/telegram-bot-broadcast/main.go: -------------------------------------------------------------------------------- 1 | // cmd/telegram-bot-broadcast/main.go 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | 15 | "github.com/jessevdk/go-flags" 16 | 17 | "github.com/meinside/telegram-remotecontrol-bot/cfg" 18 | "github.com/meinside/telegram-remotecontrol-bot/consts" 19 | ) 20 | 21 | const ( 22 | usageFormat = `-m "MESSAGE_TO_BROADCAST" 23 | %[1]s "MESSAGE_TO_BROADCAST" 24 | echo "something" | %[1]s` 25 | ) 26 | 27 | // struct for parameters 28 | type params struct { 29 | Message *string `short:"m" long:"message" description:"Message to broadcast"` 30 | } 31 | 32 | func main() { 33 | // parse params, 34 | var p params 35 | parser := flags.NewParser( 36 | &p, 37 | flags.HelpFlag|flags.PassDoubleDash, 38 | ) 39 | parser.Usage = fmt.Sprintf(usageFormat, filepath.Base(os.Args[0])) 40 | if _, err := parser.Parse(); err == nil { 41 | // get message from params, 42 | var message string 43 | if p.Message != nil { 44 | message = *p.Message 45 | } else { 46 | args := os.Args[1:] 47 | if len(args) > 0 { 48 | message = strings.Join(args, " ") 49 | } 50 | } 51 | 52 | // read message from standard input, if any 53 | var stdin []byte 54 | stat, _ := os.Stdin.Stat() 55 | if (stat.Mode() & os.ModeCharDevice) == 0 { 56 | stdin, _ = io.ReadAll(os.Stdin) 57 | } 58 | if len(stdin) > 0 { 59 | if len(message) <= 0 { 60 | message = string(stdin) 61 | } else { 62 | // merge message from stdin and parameter 63 | message = string(stdin) + "\n\n" + message 64 | } 65 | } 66 | 67 | // check message 68 | if len(message) <= 0 { 69 | printHelpAndExit(1, parser) 70 | } 71 | 72 | // read port number from config file, 73 | var cliPort int 74 | if config, err := cfg.GetConfig(context.TODO()); err == nil { 75 | cliPort = config.CLIPort 76 | if cliPort <= 0 { 77 | cliPort = consts.DefaultCLIPortNumber 78 | } 79 | } else { 80 | fmt.Printf("Failed to load config, using default port number: %d (%s)\n", consts.DefaultCLIPortNumber, err) 81 | 82 | cliPort = consts.DefaultCLIPortNumber 83 | } 84 | 85 | // send message to local API, 86 | if _, err := http.PostForm( 87 | fmt.Sprintf("http://localhost:%d%s", cliPort, consts.HTTPBroadcastPath), 88 | url.Values{ 89 | consts.ParamMessage: {message}, 90 | }, 91 | ); err != nil { 92 | fmt.Printf("* Broadcast failed: %s\n", err) 93 | } 94 | } else { 95 | if e, ok := err.(*flags.Error); ok { 96 | if e.Type != flags.ErrHelp { 97 | fmt.Printf("Input error: %s\n", e.Error()) 98 | } 99 | 100 | printHelpAndExit(1, parser) 101 | } 102 | 103 | printErrorAndExit(1, "Failed to parse flags: %s\n", err) 104 | } 105 | } 106 | 107 | // print help and exit 108 | func printHelpAndExit( 109 | exit int, 110 | parser *flags.Parser, 111 | ) { 112 | parser.WriteHelp(os.Stderr) 113 | os.Exit(exit) 114 | } 115 | 116 | // print error and exit 117 | func printErrorAndExit( 118 | exit int, 119 | format string, 120 | a ...any, 121 | ) { 122 | fmt.Printf(format, a...) 123 | os.Exit(exit) 124 | } 125 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strings" 7 | "syscall" 8 | "time" 9 | 10 | st "github.com/meinside/rpi-tools/status" 11 | ) 12 | 13 | // calculates uptime of this bot 14 | func uptimeSince(launched time.Time) (uptime string) { 15 | now := time.Now() 16 | gap := now.Sub(launched) 17 | 18 | uptimeSeconds := int(gap.Seconds()) 19 | numDays := uptimeSeconds / (60 * 60 * 24) 20 | numHours := (uptimeSeconds % (60 * 60 * 24)) / (60 * 60) 21 | 22 | return fmt.Sprintf("*%d* day(s) *%d* hour(s)", numDays, numHours) 23 | } 24 | 25 | // calculates memory usage of this bot 26 | func memoryUsage() (usage string) { 27 | sys, heap := st.MemoryUsage() 28 | 29 | return fmt.Sprintf("sys *%.1f MB*, heap *%.1f MB*", float32(sys)/1024/1024, float32(heap)/1024/1024) 30 | } 31 | 32 | // calculates disk usage of the system (https://gist.github.com/lunny/9828326) 33 | func diskUsage(additionalPaths []string) (usage string) { 34 | paths := []string{"/"} 35 | paths = append(paths, additionalPaths...) 36 | 37 | var lines []string 38 | for _, p := range paths { 39 | fs := syscall.Statfs_t{} 40 | if err := syscall.Statfs(p, &fs); err == nil { 41 | all := fs.Blocks * uint64(fs.Bsize) 42 | free := fs.Bavail * uint64(fs.Bsize) 43 | used := all - free 44 | 45 | lines = append(lines, fmt.Sprintf( 46 | " %s all *%.2f GB*, used *%.2f GB*, free *%.2f GB*", 47 | p, 48 | float64(all)/1024/1024/1024, 49 | float64(used)/1024/1024/1024, 50 | float64(free)/1024/1024/1024, 51 | )) 52 | } else { 53 | lines = append(lines, fmt.Sprintf("%s: %s", p, err)) 54 | } 55 | } 56 | 57 | return strings.Join(lines, "\n") 58 | } 59 | 60 | // removes markdown characters for avoiding 61 | // 'Bad Request: Can't parse message text: Can't find end of the entity starting at byte offset ...' errors 62 | // from the server 63 | func removeMarkdownChars(original, replaceWith string) string { 64 | removed := strings.ReplaceAll(original, "*", replaceWith) 65 | removed = strings.ReplaceAll(removed, "_", replaceWith) 66 | removed = strings.ReplaceAll(removed, "`", replaceWith) 67 | return removed 68 | } 69 | 70 | // `systemctl status is-active` 71 | func systemctlStatus(services []string) (statuses map[string]string, success bool) { 72 | statuses = make(map[string]string) 73 | 74 | args := []string{"systemctl", "is-active"} 75 | args = append(args, services...) 76 | 77 | output, _ := sudoRunCmd(args) 78 | for i, status := range strings.Split(output, "\n") { 79 | statuses[services[i]] = status 80 | } 81 | 82 | return statuses, true 83 | } 84 | 85 | // `systemctl start [service]` 86 | func systemctlStart(service string) (message string, err error) { 87 | return sudoRunCmd([]string{"systemctl", "start", service}) 88 | } 89 | 90 | // `systemctl stop [service]` 91 | func systemctlStop(service string) (message string, err error) { 92 | return sudoRunCmd([]string{"systemctl", "stop", service}) 93 | } 94 | 95 | // `systemctl restart [service]` 96 | func systemctlRestart(service string) (message string, err error) { 97 | return sudoRunCmd([]string{"systemctl", "restart", service}) 98 | } 99 | 100 | // sudo run given command with parameters and return combined output 101 | func sudoRunCmd(cmdAndParams []string) (string, error) { 102 | if len(cmdAndParams) < 1 { 103 | return "", fmt.Errorf("no command provided") 104 | } 105 | 106 | output, err := exec.Command("sudo", cmdAndParams...).CombinedOutput() 107 | return strings.TrimRight(string(output), "\n"), err 108 | } 109 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/meinside/telegram-remotecontrol-bot 2 | 3 | go 1.25.4 4 | 5 | require ( 6 | github.com/infisical/go-sdk v0.6.3 7 | github.com/jessevdk/go-flags v1.6.1 8 | github.com/meinside/rpi-tools v0.3.0 9 | github.com/meinside/telegram-bot-go v0.12.0 10 | github.com/meinside/version-go v0.0.3 11 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a 12 | gorm.io/driver/sqlite v1.6.0 13 | gorm.io/gorm v1.31.1 14 | ) 15 | 16 | require ( 17 | cloud.google.com/go/auth v0.18.0 // indirect 18 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 19 | cloud.google.com/go/compute/metadata v0.9.0 // indirect 20 | cloud.google.com/go/iam v1.5.3 // indirect 21 | github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect 22 | github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect 23 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect 24 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect 25 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect 26 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect 27 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect 29 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect 30 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect 31 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect 34 | github.com/aws/smithy-go v1.24.0 // indirect 35 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 36 | github.com/felixge/httpsnoop v1.0.4 // indirect 37 | github.com/go-logr/logr v1.4.3 // indirect 38 | github.com/go-logr/stdr v1.2.2 // indirect 39 | github.com/go-resty/resty/v2 v2.17.1 // indirect 40 | github.com/gofrs/flock v0.13.0 // indirect 41 | github.com/google/s2a-go v0.1.9 // indirect 42 | github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect 43 | github.com/googleapis/gax-go/v2 v2.16.0 // indirect 44 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 45 | github.com/jinzhu/inflection v1.0.0 // indirect 46 | github.com/jinzhu/now v1.1.5 // indirect 47 | github.com/mattn/go-colorable v0.1.14 // indirect 48 | github.com/mattn/go-isatty v0.0.20 // indirect 49 | github.com/mattn/go-sqlite3 v1.14.32 // indirect 50 | github.com/oracle/oci-go-sdk/v65 v65.105.2 // indirect 51 | github.com/rs/zerolog v1.34.0 // indirect 52 | github.com/sony/gobreaker v1.0.0 // indirect 53 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect 54 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect 55 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect 56 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect 57 | go.opentelemetry.io/otel v1.39.0 // indirect 58 | go.opentelemetry.io/otel/metric v1.39.0 // indirect 59 | go.opentelemetry.io/otel/trace v1.39.0 // indirect 60 | golang.org/x/crypto v0.46.0 // indirect 61 | golang.org/x/net v0.48.0 // indirect 62 | golang.org/x/oauth2 v0.34.0 // indirect 63 | golang.org/x/sync v0.19.0 // indirect 64 | golang.org/x/sys v0.39.0 // indirect 65 | golang.org/x/text v0.32.0 // indirect 66 | golang.org/x/time v0.14.0 // indirect 67 | google.golang.org/api v0.258.0 // indirect 68 | google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect 69 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect 70 | google.golang.org/grpc v1.77.0 // indirect 71 | google.golang.org/protobuf v1.36.11 // indirect 72 | ) 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telegram Bot for Controlling Various Things Remotely 2 | 3 | With this bot, you can systemctl start/stop services 4 | 5 | and list/add/remove/delete torrents through your Transmission daemon. 6 | 7 | ## 0. Prepare 8 | 9 | Install Go and [generate your Telegram bot's API token](https://telegram.me/BotFather). 10 | 11 | ## 1. Install 12 | 13 | ```bash 14 | $ git clone https://github.com/meinside/telegram-remotecontrol-bot.git 15 | $ cd telegram-remotecontrol-bot 16 | $ go build 17 | ``` 18 | 19 | or 20 | 21 | ```bash 22 | $ go install github.com/meinside/telegram-remotecontrol-bot@latest 23 | ``` 24 | 25 | ## 2. Configure 26 | 27 | Put your `config.json` file in `$XDG_CONFIG_HOME/telegram-remotecontrol-bot/` directory: 28 | 29 | ```bash 30 | $ mkdir -p ~/.config/telegram-remotecontrol-bot/ 31 | $ cp config.json.sample ~/.config/telegram-remotecontrol-bot/config.json 32 | $ vi config.json 33 | ``` 34 | 35 | and edit values to yours: 36 | 37 | ```json 38 | { 39 | "available_ids": [ 40 | "telegram_id_1", 41 | "telegram_id_2", 42 | "telegram_id_3" 43 | ], 44 | "controllable_services": [ 45 | "vpnserver" 46 | ], 47 | "monitor_interval": 3, 48 | "transmission_rpc_port": 9999, 49 | "transmission_rpc_username": "some_user", 50 | "transmission_rpc_passwd": "some_password", 51 | "cli_port": 59992, 52 | "is_verbose": false, 53 | 54 | "api_token": "0123456789:abcdefghijklmnopqrstuvwyz-x-0a1b2c3d4e" 55 | } 56 | ``` 57 | 58 | When following values are omitted, default values will be applied: 59 | 60 | * **monitor_interval**: 3 seconds 61 | * **transmission_rpc_port**: 9091 62 | * **transmission_rpc_username** or **transmission_rpc_passwd**: no username and password (eg. when **rpc-authentication-required** = false) 63 | 64 | ### Using Infisical 65 | 66 | You can also use [Infisical](https://infisical.com/) for retrieving your bot api token: 67 | 68 | ```json 69 | { 70 | "available_ids": [ 71 | "telegram_id_1", 72 | "telegram_id_2", 73 | "telegram_id_3" 74 | ], 75 | "controllable_services": [ 76 | "vpnserver" 77 | ], 78 | "monitor_interval": 3, 79 | "transmission_rpc_port": 9999, 80 | "transmission_rpc_username": "some_user", 81 | "transmission_rpc_passwd": "some_password", 82 | "cli_port": 59992, 83 | "is_verbose": false, 84 | 85 | "infisical": { 86 | "client_id": "012345-abcdefg-987654321", 87 | "client_secret": "aAbBcCdDeEfFgG0123456789xyzwXYZW", 88 | 89 | "project_id": "012345abcdefg", 90 | "environment": "dev", 91 | "secret_type": "shared", 92 | 93 | "api_token_key_path": "/path/to/your/KEY_TO_API_TOKEN" 94 | } 95 | } 96 | ``` 97 | 98 | ## 3. Run 99 | 100 | Run the built(or installed) binary with: 101 | 102 | ```bash 103 | $ ./telegram-remotecontrol-bot 104 | # or 105 | $ $(go env GOPATH)/bin/telegram-remotecontrol-bot 106 | 107 | ``` 108 | 109 | ## 4. Run as a service 110 | 111 | ### a. systemd 112 | 113 | ```bash 114 | $ sudo cp systemd/telegram-remotecontrol-bot.service /etc/systemd/system/ 115 | $ sudo vi /etc/systemd/system/telegram-remotecontrol-bot.service 116 | ``` 117 | 118 | and edit **User**, **Group**, **WorkingDirectory** and **ExecStart** values. 119 | 120 | It will launch automatically on boot with: 121 | 122 | ```bash 123 | $ sudo systemctl enable telegram-remotecontrol-bot.service 124 | ``` 125 | 126 | and will start with: 127 | 128 | ```bash 129 | $ sudo systemctl start telegram-remotecontrol-bot.service 130 | ``` 131 | 132 | ## 4. Broadcast to all connected clients 133 | 134 | Install command line tool, 135 | 136 | ```bash 137 | $ go install github.com/meinside/telegram-remotecontrol-bot/cmd/telegram-bot-broadcast@latest 138 | ``` 139 | 140 | and run: 141 | 142 | ```bash 143 | $ $(go env GOPATH)/bin/telegram-bot-broadcast "SOME_MESSAGE_TO_BROADCAST" 144 | ``` 145 | 146 | then all connected clients who sent at least one message will receive this message. 147 | 148 | ## 999. License 149 | 150 | MIT 151 | 152 | -------------------------------------------------------------------------------- /cfg/config.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "time" 11 | 12 | // infisical 13 | infisical "github.com/infisical/go-sdk" 14 | "github.com/infisical/go-sdk/packages/models" 15 | 16 | // others 17 | "github.com/tailscale/hujson" 18 | 19 | // constants 20 | "github.com/meinside/telegram-remotecontrol-bot/consts" 21 | ) 22 | 23 | // constants for config 24 | const ( 25 | AppName = `telegram-remotecontrol-bot` 26 | 27 | configFilename = `config.json` 28 | 29 | infisicalTimeoutSeconds = 30 30 | ) 31 | 32 | // Config struct for config file 33 | type Config struct { 34 | AvailableIDs []string `json:"available_ids"` 35 | ControllableServices []string `json:"controllable_services,omitempty"` 36 | MountPoints []string `json:"mount_points,omitempty"` 37 | MonitorInterval int `json:"monitor_interval"` 38 | TransmissionRPCPort int `json:"transmission_rpc_port,omitempty"` 39 | TransmissionRPCUsername string `json:"transmission_rpc_username,omitempty"` 40 | TransmissionRPCPasswd string `json:"transmission_rpc_passwd,omitempty"` 41 | CLIPort int `json:"cli_port"` 42 | IsVerbose bool `json:"is_verbose"` 43 | 44 | // Bot API Token, 45 | APIToken string `json:"api_token,omitempty"` 46 | 47 | // or from Infisical 48 | Infisical *struct { 49 | ClientID string `json:"client_id"` 50 | ClientSecret string `json:"client_secret"` 51 | 52 | ProjectID string `json:"project_id"` 53 | Environment string `json:"environment"` 54 | SecretType string `json:"secret_type"` 55 | 56 | APITokenKeyPath string `json:"api_token_key_path"` 57 | } `json:"infisical,omitempty"` 58 | } 59 | 60 | // GetConfigDir returns the config file's directory. 61 | func GetConfigDir() (configDir string, err error) { 62 | // https://xdgbasedirectoryspecification.com 63 | configDir = os.Getenv("XDG_CONFIG_HOME") 64 | 65 | // If the value of the environment variable is unset, empty, or not an absolute path, use the default 66 | if configDir == "" || configDir[0:1] != "/" { 67 | var homeDir string 68 | if homeDir, err = os.UserHomeDir(); err == nil { 69 | configDir = filepath.Join(homeDir, ".config", AppName) 70 | } 71 | } else { 72 | configDir = filepath.Join(configDir, AppName) 73 | } 74 | 75 | return configDir, err 76 | } 77 | 78 | // GetConfig reads config file and returns it. 79 | func GetConfig(ctx context.Context) (conf Config, err error) { 80 | var configDir string 81 | configDir, err = GetConfigDir() 82 | 83 | if err == nil { 84 | configFilepath := filepath.Join(configDir, configFilename) 85 | 86 | var bytes []byte 87 | if bytes, err = os.ReadFile(configFilepath); err == nil { 88 | if bytes, err = standardizeJSON(bytes); err == nil { 89 | if err = json.Unmarshal(bytes, &conf); err == nil { 90 | if conf.APIToken == "" && conf.Infisical != nil { 91 | ctxInfisical, cancelInfisical := context.WithTimeout(ctx, infisicalTimeoutSeconds*time.Second) 92 | defer cancelInfisical() 93 | 94 | // read bot token from infisical 95 | client := infisical.NewInfisicalClient(ctxInfisical, infisical.Config{ 96 | SiteUrl: "https://app.infisical.com", 97 | }) 98 | 99 | _, err = client.Auth().UniversalAuthLogin(conf.Infisical.ClientID, conf.Infisical.ClientSecret) 100 | if err != nil { 101 | return Config{}, fmt.Errorf("failed to authenticate with Infisical: %s", err) 102 | } 103 | 104 | var keyPath string 105 | var secret models.Secret 106 | 107 | // telegram bot token 108 | keyPath = conf.Infisical.APITokenKeyPath 109 | secret, err = client.Secrets().Retrieve(infisical.RetrieveSecretOptions{ 110 | ProjectID: conf.Infisical.ProjectID, 111 | Type: conf.Infisical.SecretType, 112 | Environment: conf.Infisical.Environment, 113 | SecretPath: path.Dir(keyPath), 114 | SecretKey: path.Base(keyPath), 115 | }) 116 | if err == nil { 117 | conf.APIToken = secret.SecretValue 118 | } else { 119 | return Config{}, fmt.Errorf("failed to retrieve `api_token` from Infisical: %s", err) 120 | } 121 | } 122 | 123 | // fallback values 124 | if conf.TransmissionRPCPort <= 0 { 125 | conf.TransmissionRPCPort = consts.DefaultTransmissionRPCPort 126 | } 127 | if conf.MonitorInterval <= 0 { 128 | conf.MonitorInterval = consts.DefaultMonitorIntervalSeconds 129 | } 130 | 131 | return conf, err 132 | } 133 | } 134 | } 135 | } 136 | 137 | return conf, fmt.Errorf("failed to load config: %s", err) 138 | } 139 | 140 | // standardize given JSON (JWCC) bytes 141 | func standardizeJSON(b []byte) ([]byte, error) { 142 | ast, err := hujson.Parse(b) 143 | if err != nil { 144 | return b, err 145 | } 146 | ast.Standardize() 147 | 148 | return ast.Pack(), nil 149 | } 150 | -------------------------------------------------------------------------------- /transmission.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/meinside/telegram-remotecontrol-bot/consts" 15 | ) 16 | 17 | const ( 18 | httpHeaderXTransmissionSessionID = `X-Transmission-Session-Id` 19 | numRetries = 3 20 | ) 21 | 22 | // RPC request 23 | type rpcRequest struct { 24 | Method string `json:"method"` 25 | Arguments map[string]any `json:"arguments,omitempty"` 26 | Tag int `json:"tag,omitempty"` 27 | } 28 | 29 | // RPC response 30 | type rpcResponse struct { 31 | Result string `json:"result,omitempty"` 32 | Arguments rpcResponseArgs `json:"arguments,omitzero"` 33 | Tag int `json:"tag,omitempty"` 34 | } 35 | 36 | // RPC response arguments 37 | type rpcResponseArgs struct { 38 | TorrentDuplicate any `json:"torrent-duplicate,omitempty"` 39 | Torrents []RPCResponseTorrent `json:"torrents,omitempty"` 40 | } 41 | 42 | // torrent fields to query 43 | var torrentFields []string = []string{ 44 | "id", 45 | "status", 46 | "name", 47 | "rateDownload", // B/s 48 | "rateUpload", // B/s 49 | "percentDone", 50 | "totalSize", 51 | "errorString", 52 | } 53 | 54 | // RPCResponseTorrent for torrent response 55 | type RPCResponseTorrent struct { 56 | ID int `json:"id"` 57 | Status TorrentStatus `json:"status"` 58 | Name string `json:"name"` 59 | RateDownload int64 `json:"rateDownload"` 60 | RateUpload int64 `json:"rateUpload"` 61 | PercentDone float32 `json:"percentDone"` 62 | TotalSize int64 `json:"totalSize"` 63 | Error string `json:"errorString"` 64 | } 65 | 66 | type TorrentStatus int 67 | 68 | const ( 69 | TorrentStatusStopped TorrentStatus = 0 70 | TorrentStatusQueuedToVerifyLocalData TorrentStatus = 1 71 | TorrentStatusVerifyingLocalData TorrentStatus = 2 72 | TorrentStatusQueuedToDownload TorrentStatus = 3 73 | TorrentStatusDownloading TorrentStatus = 4 74 | TorrentStatusQueuedToSeed TorrentStatus = 5 75 | TorrentStatusSeeding TorrentStatus = 6 76 | ) 77 | 78 | var xTransmissionSessionID string = "" 79 | 80 | // convert torrent status to string 81 | func statusToString(s TorrentStatus) string { 82 | switch s { 83 | case TorrentStatusStopped: 84 | return `Stopped` 85 | case TorrentStatusQueuedToVerifyLocalData: 86 | return `Queued to verify local data` 87 | case TorrentStatusVerifyingLocalData: 88 | return `Verifying local data` 89 | case TorrentStatusQueuedToDownload: 90 | return `Queued to download` 91 | case TorrentStatusDownloading: 92 | return `Downloading` 93 | case TorrentStatusQueuedToSeed: 94 | return `Queued to seed` 95 | case TorrentStatusSeeding: 96 | return `Seeding` 97 | default: 98 | return `Unknown` 99 | } 100 | } 101 | 102 | // generate a RPC url for local transmission server 103 | func getLocalTransmissionRPCURL( 104 | port int, 105 | username, passwd string, 106 | ) string { 107 | var rpcURL string 108 | if len(username) > 0 && len(passwd) > 0 { 109 | rpcURL = fmt.Sprintf("http://%s:%s@localhost:%d/transmission/rpc", url.QueryEscape(username), url.QueryEscape(passwd), port) 110 | } else { 111 | rpcURL = fmt.Sprintf("http://localhost:%d/transmission/rpc", port) 112 | } 113 | return rpcURL 114 | } 115 | 116 | // POST to Transmission RPC server 117 | // 118 | // https://trac.transmissionbt.com/browser/trunk/extras/rpc-spec.txt 119 | func post( 120 | port int, 121 | username, passwd string, 122 | request rpcRequest, 123 | numRetriesLeft int, 124 | ) (res []byte, err error) { 125 | if numRetriesLeft <= 0 { 126 | return res, fmt.Errorf("no more retries for this request: %v", request) 127 | } 128 | 129 | var data []byte 130 | if data, err = json.Marshal(request); err == nil { 131 | var req *http.Request 132 | if req, err = http.NewRequest("POST", getLocalTransmissionRPCURL(port, username, passwd), bytes.NewBuffer(data)); err == nil { 133 | // headers 134 | req.Header.Set(httpHeaderXTransmissionSessionID, xTransmissionSessionID) 135 | 136 | var resp *http.Response 137 | client := &http.Client{} 138 | if resp, err = client.Do(req); err == nil { 139 | defer func() { _ = resp.Body.Close() }() 140 | 141 | if resp.StatusCode == http.StatusConflict { 142 | if sessionID, exists := resp.Header[httpHeaderXTransmissionSessionID]; exists && len(sessionID) > 0 { 143 | // update session id 144 | xTransmissionSessionID = sessionID[0] 145 | 146 | return post(port, username, passwd, request, numRetriesLeft-1) // XXX - retry 147 | } 148 | 149 | err = fmt.Errorf("couldn't find '%s' value from http headers", httpHeaderXTransmissionSessionID) 150 | 151 | log.Printf("error in RPC server: %s\n", err.Error()) 152 | } 153 | 154 | res, _ = io.ReadAll(resp.Body) 155 | 156 | if resp.StatusCode != http.StatusOK { 157 | err = fmt.Errorf("HTTP %d (%s)", resp.StatusCode, string(res)) 158 | 159 | log.Printf("error from RPC server: %s\n", err.Error()) 160 | } /* else { 161 | // XXX - for debugging 162 | log.Printf("returned json = %s\n", string(res)) 163 | }*/ 164 | } else { 165 | log.Printf("error while sending request: %s\n", err.Error()) 166 | 167 | return post(port, username, passwd, request, numRetriesLeft-1) // XXX - retry 168 | } 169 | } else { 170 | log.Printf("error while building request: %s\n", err.Error()) 171 | } 172 | } else { 173 | log.Printf("error while marshaling data: %s\n", err.Error()) 174 | } 175 | 176 | return res, err 177 | } 178 | 179 | // GetTorrents retrieves torrent objects. 180 | func GetTorrents( 181 | port int, 182 | username, passwd string, 183 | ) (torrents []RPCResponseTorrent, err error) { 184 | var output []byte 185 | if output, err = post( 186 | port, 187 | username, 188 | passwd, 189 | rpcRequest{ 190 | Method: "torrent-get", 191 | Arguments: map[string]any{ 192 | "fields": torrentFields, 193 | }, 194 | }, 195 | numRetries, 196 | ); err == nil { 197 | var result rpcResponse 198 | if err = json.Unmarshal(output, &result); err == nil { 199 | if result.Result == "success" { 200 | torrents = result.Arguments.Torrents 201 | } else { 202 | err = fmt.Errorf("failed to list torrents") 203 | } 204 | } 205 | } 206 | return torrents, err 207 | } 208 | 209 | // GetList retrieves the list of transmission. 210 | func GetList( 211 | port int, 212 | username, passwd string, 213 | ) string { 214 | var torrents []RPCResponseTorrent 215 | var err error 216 | if torrents, err = GetTorrents(port, username, passwd); err == nil { 217 | numTorrents := len(torrents) 218 | if numTorrents > 0 { 219 | lines := []string{} 220 | 221 | for _, t := range torrents { 222 | if len(t.Error) > 0 { 223 | lines = append(lines, fmt.Sprintf( 224 | `*%d*. _%s_ 225 | ┖ (%s) *%s*`, 226 | t.ID, 227 | removeMarkdownChars(t.Name, " "), 228 | readableSize(t.TotalSize), 229 | t.Error, 230 | )) 231 | } else { 232 | details := []string{} 233 | 234 | switch t.Status { 235 | case TorrentStatusSeeding: 236 | details = append( 237 | details, 238 | fmt.Sprintf( 239 | "%s %s", 240 | statusToString(t.Status), 241 | readableSize(t.TotalSize), 242 | ), 243 | ) 244 | if t.RateUpload > 0 { 245 | details = append(details, fmt.Sprintf("↑%s/s", readableSize(t.RateUpload))) 246 | } 247 | case TorrentStatusDownloading, TorrentStatusStopped: 248 | details = append( 249 | details, 250 | fmt.Sprintf( 251 | "%s %s/%s (%.2f%%)", 252 | statusToString(t.Status), 253 | readableSize(int64(float64(t.TotalSize)*float64(t.PercentDone))), 254 | readableSize(t.TotalSize), 255 | t.PercentDone*100.0, 256 | ), 257 | ) 258 | updown := []string{} 259 | if t.RateDownload > 0 { 260 | updown = append(updown, fmt.Sprintf("↓%s/s", readableSize(t.RateDownload))) 261 | } 262 | if t.RateUpload > 0 { 263 | updown = append(updown, fmt.Sprintf("↑%s/s", readableSize(t.RateUpload))) 264 | } 265 | if len(updown) > 0 { 266 | details = append(details, strings.Join(updown, " ")) 267 | } 268 | default: 269 | details = append( 270 | details, 271 | statusToString(t.Status), 272 | ) 273 | } 274 | // prepend spaces to details 275 | for i := range details { 276 | details[i] = ` ┖ ` + details[i] 277 | } 278 | 279 | lines = append(lines, fmt.Sprintf( 280 | `*%d*. _%s_ 281 | %s`, 282 | t.ID, 283 | removeMarkdownChars(t.Name, " "), 284 | strings.Join(details, "\n"), 285 | )) 286 | } 287 | } 288 | lines = append(lines, `----`) 289 | lines = append(lines, fmt.Sprintf("total %d torrent(s)", numTorrents)) 290 | 291 | return strings.Join(lines, "\n") 292 | } 293 | 294 | return consts.MessageTransmissionNoTorrents 295 | } 296 | 297 | return err.Error() 298 | } 299 | 300 | // AddTorrent adds a torrent(with magnet or .torrent file) to the list of transmission 301 | // and returns the resulting string. 302 | func AddTorrent(port int, username, passwd, torrent string) string { 303 | var output []byte 304 | var err error 305 | if output, err = post(port, username, passwd, rpcRequest{ 306 | Method: "torrent-add", 307 | Arguments: map[string]any{ 308 | "filename": torrent, 309 | }, 310 | }, numRetries); err == nil { 311 | var result rpcResponse 312 | if err = json.Unmarshal(output, &result); err == nil { 313 | if result.Result == "success" { 314 | if result.Arguments.TorrentDuplicate != nil { 315 | return "Duplicated torrent was given." 316 | } 317 | 318 | return "Given torrent was successfully added to the list." 319 | } 320 | 321 | return "Failed to add given torrent." 322 | } 323 | 324 | return fmt.Sprintf("Malformed RPC server response: %s", string(output)) 325 | } 326 | 327 | return fmt.Sprintf("Failed to add given torrent: %s", err) 328 | } 329 | 330 | // remove torrent 331 | func removeTorrent( 332 | port int, 333 | username, passwd string, 334 | torrentID string, deleteLocal bool, 335 | ) string { 336 | if numID, err := strconv.Atoi(torrentID); err == nil { 337 | if output, err := post(port, username, passwd, 338 | rpcRequest{ 339 | Method: "torrent-remove", 340 | Arguments: map[string]any{ 341 | "ids": []int{numID}, 342 | "delete-local-data": deleteLocal, 343 | }, 344 | }, numRetries); err == nil { 345 | var result rpcResponse 346 | if err := json.Unmarshal(output, &result); err == nil { 347 | if result.Result == "success" { 348 | if deleteLocal { 349 | return fmt.Sprintf("Torrent id: %s and its data were successfully deleted", torrentID) 350 | } 351 | 352 | return fmt.Sprintf("Torrent id: %s was successfully removed from the list", torrentID) 353 | } 354 | 355 | return "Failed to remove given torrent." 356 | } 357 | 358 | return fmt.Sprintf("Malformed RPC server response: %s", string(output)) 359 | } 360 | 361 | return fmt.Sprintf("Failed to remove given torrent: %s", err) 362 | } 363 | 364 | return fmt.Sprintf("not a valid torrent id: %s", torrentID) 365 | } 366 | 367 | // convert given number to human-readable size string 368 | func readableSize(num int64) (str string) { 369 | if num < 1<<10 { 370 | // bytes 371 | str = fmt.Sprintf("%dB", num) 372 | } else { 373 | if num < 1<<20 { 374 | // kbytes 375 | str = fmt.Sprintf("%.1fKB", float64(num)/(1<<10)) 376 | } else { 377 | if num < 1<<30 { 378 | // mbytes 379 | str = fmt.Sprintf("%.1fMB", float64(num)/(1<<20)) 380 | } else { 381 | if num < 1<<40 { 382 | // gbytes 383 | str = fmt.Sprintf("%.2fGB", float64(num)/(1<<30)) 384 | } else { 385 | // tbytes 386 | str = fmt.Sprintf("%.2fTB", float64(num)/(1<<40)) 387 | } 388 | } 389 | } 390 | } 391 | return str 392 | } 393 | 394 | // RemoveTorrent cancels/removes a torrent from the list. 395 | func RemoveTorrent( 396 | port int, 397 | username, passwd string, 398 | torrentID string, 399 | ) string { 400 | return removeTorrent(port, username, passwd, torrentID, false) 401 | } 402 | 403 | // DeleteTorrent removes a torrent and its local data from the list. 404 | func DeleteTorrent( 405 | port int, 406 | username, passwd string, 407 | torrentID string, 408 | ) string { 409 | return removeTorrent(port, username, passwd, torrentID, true) 410 | } 411 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= 2 | cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= 3 | cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= 4 | cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= 5 | cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= 6 | cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 7 | cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= 8 | cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= 9 | github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= 10 | github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= 11 | github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= 12 | github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= 13 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= 14 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= 15 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= 16 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= 17 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= 18 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= 19 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= 20 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= 21 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= 22 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= 23 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= 24 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= 25 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= 26 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= 27 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= 28 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= 29 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= 30 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= 31 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= 32 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= 33 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= 34 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= 35 | github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= 36 | github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 37 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 38 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 39 | github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= 40 | github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= 41 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 42 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 43 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 44 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 45 | github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= 46 | github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= 47 | github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= 48 | github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= 49 | github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= 50 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 51 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 52 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 53 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 54 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 55 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 56 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 57 | github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= 58 | github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= 59 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 60 | github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= 61 | github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= 62 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 63 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 64 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 65 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 66 | github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 67 | github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 68 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 69 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 70 | github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= 71 | github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= 72 | github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= 73 | github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= 74 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 75 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 76 | github.com/infisical/go-sdk v0.6.3 h1:ILKgN3G9yfaLj9/Z8phV6IbWm+GDod99I8DRlIWg3kU= 77 | github.com/infisical/go-sdk v0.6.3/go.mod h1:A6l7EhwCkPw8tmJjgA09KtueEHYko+VdGCEupK8hL08= 78 | github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= 79 | github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= 80 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 81 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 82 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 83 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 84 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 85 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 86 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 87 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 88 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 89 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 90 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 91 | github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 92 | github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 93 | github.com/meinside/rpi-tools v0.3.0 h1:cGPdR24nYnb6XvOLDLLwkbxL8oQjt9DGWgklo4KP1xo= 94 | github.com/meinside/rpi-tools v0.3.0/go.mod h1:BecjAr0tw+31C2pcGa/UaY9NLKyVW6EaGR4n664TiFo= 95 | github.com/meinside/telegram-bot-go v0.12.0 h1:H+9G/SxEWtj4Z6ORUBS2BaqHbW+72gUFrGO9zV8P6Xo= 96 | github.com/meinside/telegram-bot-go v0.12.0/go.mod h1:i9gGJrrfhdAIElC/HCUprMmccGjMKPVq52av4n54Y2s= 97 | github.com/meinside/version-go v0.0.3 h1:GXSwi6sTmgpnSR09jAAqDGWeX2Nq52fe5xpitgAhQfM= 98 | github.com/meinside/version-go v0.0.3/go.mod h1:mFvlwbro1E126u4rU727CcHNa8OPFyhq+KDYYNysFj4= 99 | github.com/oracle/oci-go-sdk/v65 v65.105.2 h1:AvZ59xNCGy/b4QT8j2HzIbE75K2nxYGeNirj7wX1XUw= 100 | github.com/oracle/oci-go-sdk/v65 v65.105.2/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs= 101 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 102 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= 103 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 104 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 105 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 106 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 107 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 108 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 109 | github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= 110 | github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= 111 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 112 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 113 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 114 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 115 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I= 116 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= 117 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= 118 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= 119 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 120 | go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 121 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= 122 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= 123 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= 124 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= 125 | go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= 126 | go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= 127 | go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= 128 | go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= 129 | go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= 130 | go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= 131 | go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= 132 | go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= 133 | go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= 134 | go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= 135 | golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= 136 | golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 137 | golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= 138 | golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 139 | golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= 140 | golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 141 | golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 142 | golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 143 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 144 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 145 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 146 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 147 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 148 | golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 149 | golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 150 | golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= 151 | golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 152 | gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= 153 | gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 154 | google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc= 155 | google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww= 156 | google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= 157 | google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= 158 | google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= 159 | google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= 160 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= 161 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= 162 | google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= 163 | google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= 164 | google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 165 | google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 166 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 167 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 168 | gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= 169 | gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= 170 | gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= 171 | gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 172 | -------------------------------------------------------------------------------- /bot.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "slices" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "syscall" 16 | "time" 17 | 18 | bot "github.com/meinside/telegram-bot-go" 19 | "github.com/meinside/telegram-remotecontrol-bot/cfg" 20 | "github.com/meinside/telegram-remotecontrol-bot/consts" 21 | "github.com/meinside/version-go" 22 | ) 23 | 24 | const ( 25 | githubPageURL = "https://github.com/meinside/telegram-remotecontrol-bot" 26 | 27 | requestTimeoutSeconds = 60 28 | ignorableRequestTimeoutSeconds = 5 29 | ) 30 | 31 | type status int16 32 | 33 | // application statuses 34 | const ( 35 | StatusWaiting status = iota 36 | StatusWaitingTransmissionUpload status = iota 37 | ) 38 | 39 | type session struct { 40 | UserID string 41 | CurrentStatus status 42 | } 43 | 44 | type sessionPool struct { 45 | Sessions map[string]session 46 | sync.Mutex 47 | } 48 | 49 | var pool sessionPool 50 | 51 | // keyboards 52 | var allKeyboards = [][]bot.KeyboardButton{ 53 | bot.NewKeyboardButtons(consts.CommandTransmissionList, consts.CommandTransmissionAdd, consts.CommandTransmissionRemove, consts.CommandTransmissionDelete), 54 | bot.NewKeyboardButtons(consts.CommandServiceStatus, consts.CommandServiceStart, consts.CommandServiceStop), 55 | bot.NewKeyboardButtons(consts.CommandStatus, consts.CommandLogs, consts.CommandPrivacy, consts.CommandHelp), 56 | } 57 | 58 | var cancelKeyboard = [][]bot.KeyboardButton{ 59 | bot.NewKeyboardButtons(consts.CommandCancel), 60 | } 61 | 62 | var ( 63 | _stdout = log.New(os.Stdout, "", log.LstdFlags) 64 | _stderr = log.New(os.Stderr, "", log.LstdFlags) 65 | ) 66 | 67 | // check if given Telegram id is available 68 | func isAvailableID(config cfg.Config, id string) bool { 69 | return slices.Contains(config.AvailableIDs, id) 70 | } 71 | 72 | // check if given service is controllable 73 | func isControllableService(controllableServices []string, service string) bool { 74 | return slices.Contains(controllableServices, service) 75 | } 76 | 77 | // for showing help message 78 | func getHelp() string { 79 | return fmt.Sprintf(` 80 | following commands are supported: 81 | 82 | *for transmission* 83 | 84 | %s : show torrent list 85 | %s : add torrent with url or magnet 86 | %s : remove torrent from list 87 | %s : remove torrent and delete data 88 | 89 | *for systemctl* 90 | 91 | %s : show status of each service (systemctl is-active) 92 | %s : start a service (systemctl start) 93 | %s : stop a service (systemctl stop) 94 | 95 | *others* 96 | 97 | %s : show this bot's status 98 | %s : show latest logs of this bot 99 | %s : show privacy policy of this bot 100 | %s : show this help message 101 | `, 102 | consts.CommandTransmissionList, 103 | consts.CommandTransmissionAdd, 104 | consts.CommandTransmissionRemove, 105 | consts.CommandTransmissionDelete, 106 | consts.CommandServiceStatus, 107 | consts.CommandServiceStart, 108 | consts.CommandServiceStop, 109 | consts.CommandStatus, 110 | consts.CommandLogs, 111 | consts.CommandPrivacy, 112 | consts.CommandHelp, 113 | ) 114 | } 115 | 116 | // for showing privacy policy 117 | func getPrivacyPolicy() string { 118 | return fmt.Sprintf(` 119 | privacy policy: 120 | 121 | %s/raw/master/PRIVACY.md 122 | `, githubPageURL) 123 | } 124 | 125 | // get recent logs 126 | func getLogs(db *Database) string { 127 | var lines []string 128 | 129 | logs := db.GetLogs(consts.NumRecentLogs) 130 | 131 | if len(logs) <= 0 { 132 | return consts.MessageNoLogs 133 | } 134 | 135 | for _, log := range logs { 136 | lines = append(lines, fmt.Sprintf("%s %s: %s", log.CreatedAt.Format("2006-01-02 15:04:05"), log.Type, log.Message)) 137 | } 138 | return strings.Join(lines, "\n") 139 | } 140 | 141 | // for showing current status of this bot 142 | func getStatus( 143 | config cfg.Config, 144 | launchedAt time.Time, 145 | ) string { 146 | return fmt.Sprintf("app version: %s\napp uptime: %s\napp memory usage: %s\nsystem disk usage:\n%s", 147 | version.Minimum(), 148 | uptimeSince(launchedAt), 149 | memoryUsage(), 150 | diskUsage(config.MountPoints), 151 | ) 152 | } 153 | 154 | // parse service command and start/stop given service 155 | func parseServiceCommand( 156 | config cfg.Config, 157 | db *Database, 158 | txt string, 159 | ) (message string, keyboards [][]bot.InlineKeyboardButton) { 160 | message = consts.MessageNoControllableServices 161 | 162 | for _, cmd := range []string{consts.CommandServiceStart, consts.CommandServiceStop} { 163 | if strings.HasPrefix(txt, cmd) { 164 | service := strings.TrimSpace(strings.Replace(txt, cmd, "", 1)) 165 | 166 | if isControllableService(config.ControllableServices, service) { 167 | if strings.HasPrefix(txt, consts.CommandServiceStart) { // start service 168 | if output, err := systemctlStart(service); err == nil { 169 | message = fmt.Sprintf("started service: %s", service) 170 | } else { 171 | message = fmt.Sprintf("failed to start service: %s (%s)", service, err) 172 | 173 | logError(db, "service failed to start: %s", output) 174 | } 175 | } else if strings.HasPrefix(txt, consts.CommandServiceStop) { // stop service 176 | if output, err := systemctlStop(service); err == nil { 177 | message = fmt.Sprintf("stopped service: %s", service) 178 | } else { 179 | message = fmt.Sprintf("failed to stop service: %s (%s)", service, err) 180 | 181 | logError(db, "service failed to stop: %s", output) 182 | } 183 | } 184 | } else { 185 | if strings.HasPrefix(txt, consts.CommandServiceStart) { // start service 186 | message = consts.MessageServiceToStart 187 | } else if strings.HasPrefix(txt, consts.CommandServiceStop) { // stop service 188 | message = consts.MessageServiceToStop 189 | } 190 | 191 | keys := map[string]string{} 192 | for _, v := range config.ControllableServices { 193 | keys[v] = fmt.Sprintf("%s %s", cmd, v) 194 | } 195 | keyboards = bot.NewInlineKeyboardButtonsAsRowsWithCallbackData(keys) 196 | 197 | // add cancel button 198 | keyboards = append(keyboards, []bot.InlineKeyboardButton{ 199 | bot.NewInlineKeyboardButton(consts.MessageCancel). 200 | SetCallbackData(consts.CommandCancel), 201 | }) 202 | } 203 | } 204 | continue 205 | } 206 | 207 | return message, keyboards 208 | } 209 | 210 | // parse transmission command 211 | func parseTransmissionCommand( 212 | config cfg.Config, 213 | txt string, 214 | ) (message string, keyboards [][]bot.InlineKeyboardButton) { 215 | if torrents, _ := GetTorrents(config.TransmissionRPCPort, config.TransmissionRPCUsername, config.TransmissionRPCPasswd); len(torrents) > 0 { 216 | for _, cmd := range []string{consts.CommandTransmissionRemove, consts.CommandTransmissionDelete} { 217 | if strings.HasPrefix(txt, cmd) { 218 | param := strings.TrimSpace(strings.Replace(txt, cmd, "", 1)) 219 | 220 | if _, err := strconv.Atoi(param); err == nil { // if torrent id number is given, 221 | if strings.HasPrefix(txt, consts.CommandTransmissionRemove) { // remove torrent 222 | message = RemoveTorrent(config.TransmissionRPCPort, config.TransmissionRPCUsername, config.TransmissionRPCPasswd, param) 223 | } else if strings.HasPrefix(txt, consts.CommandTransmissionDelete) { // delete service 224 | message = DeleteTorrent(config.TransmissionRPCPort, config.TransmissionRPCUsername, config.TransmissionRPCPasswd, param) 225 | } 226 | } else { 227 | if strings.HasPrefix(txt, consts.CommandTransmissionRemove) { // remove torrent 228 | message = consts.MessageTransmissionRemove 229 | } else if strings.HasPrefix(txt, consts.CommandTransmissionDelete) { // delete torrent 230 | message = consts.MessageTransmissionDelete 231 | } 232 | 233 | // inline keyboards 234 | keys := map[string]string{} 235 | for _, t := range torrents { 236 | keys[fmt.Sprintf("%d. %s", t.ID, t.Name)] = fmt.Sprintf("%s %d", cmd, t.ID) 237 | } 238 | keyboards = bot.NewInlineKeyboardButtonsAsRowsWithCallbackData(keys) 239 | 240 | // add cancel button 241 | keyboards = append(keyboards, []bot.InlineKeyboardButton{ 242 | bot.NewInlineKeyboardButton(consts.MessageCancel). 243 | SetCallbackData(consts.CommandCancel), 244 | }) 245 | } 246 | } 247 | continue 248 | } 249 | } else { 250 | message = consts.MessageTransmissionNoTorrents 251 | } 252 | 253 | return message, keyboards 254 | } 255 | 256 | // process incoming update from Telegram 257 | func processUpdate( 258 | ctx context.Context, 259 | b *bot.Bot, 260 | config cfg.Config, 261 | db *Database, 262 | launchedAt time.Time, 263 | update bot.Update, 264 | ) bool { 265 | // check username 266 | var userID string 267 | if from := update.GetFrom(); from == nil { 268 | logError(db, "update has no 'from' value") 269 | 270 | return false 271 | } else { 272 | if from.Username == nil { 273 | logError(db, "has no user name: %s", from.FirstName) 274 | 275 | return false 276 | } 277 | userID = *from.Username 278 | if !isAvailableID(config, userID) { 279 | logError(db, "not an allowed user id: %s", userID) 280 | 281 | return false 282 | } 283 | } 284 | 285 | // save chat id 286 | db.SaveChat(update.Message.Chat.ID, userID) 287 | 288 | // process result 289 | result := false 290 | 291 | pool.Lock() 292 | if s, exists := pool.Sessions[userID]; exists { 293 | // text from message 294 | var txt string 295 | if update.Message.HasText() { 296 | txt = *update.Message.Text 297 | } else { 298 | txt = "" 299 | } 300 | 301 | var message string 302 | options := bot.OptionsSendMessage{}. 303 | SetReplyMarkup(defaultReplyMarkup(true)) 304 | 305 | switch s.CurrentStatus { 306 | case StatusWaiting: 307 | if update.Message.Document != nil { // if a file is received, 308 | // get file info 309 | ctxFileInfo, cancelFileInfo := context.WithTimeout(ctx, requestTimeoutSeconds*time.Second) 310 | defer cancelFileInfo() 311 | fileResult := b.GetFile(ctxFileInfo, update.Message.Document.FileID) 312 | 313 | fileURL := b.GetFileURL(*fileResult.Result) 314 | 315 | // XXX - only support: .torrent 316 | if strings.HasSuffix(fileURL, ".torrent") { 317 | addReaction(ctx, b, update, "👌") 318 | message = AddTorrent(config.TransmissionRPCPort, config.TransmissionRPCUsername, config.TransmissionRPCPasswd, fileURL) 319 | } else { 320 | message = consts.MessageUnprocessableFileFormat 321 | } 322 | } else { 323 | switch { 324 | // magnet url 325 | case strings.HasPrefix(txt, "magnet:"): 326 | addReaction(ctx, b, update, "👌") 327 | message = AddTorrent(config.TransmissionRPCPort, config.TransmissionRPCUsername, config.TransmissionRPCPasswd, txt) 328 | // /start 329 | case strings.HasPrefix(txt, consts.CommandStart): 330 | message = consts.MessageDefault 331 | // systemctl 332 | case strings.HasPrefix(txt, consts.CommandServiceStatus): 333 | statuses, _ := systemctlStatus(config.ControllableServices) 334 | for service, status := range statuses { 335 | message += fmt.Sprintf("%s: *%s*\n", service, status) 336 | } 337 | case strings.HasPrefix(txt, consts.CommandServiceStart) || strings.HasPrefix(txt, consts.CommandServiceStop): 338 | if len(config.ControllableServices) > 0 { 339 | var keyboards [][]bot.InlineKeyboardButton 340 | message, keyboards = parseServiceCommand(config, db, txt) 341 | if keyboards != nil { 342 | options.SetReplyMarkup(bot.NewInlineKeyboardMarkup(keyboards)) 343 | } 344 | } else { 345 | message = consts.MessageNoControllableServices 346 | } 347 | // transmission 348 | case strings.HasPrefix(txt, consts.CommandTransmissionList): 349 | message = GetList(config.TransmissionRPCPort, config.TransmissionRPCUsername, config.TransmissionRPCPasswd) 350 | case strings.HasPrefix(txt, consts.CommandTransmissionAdd): 351 | arg := strings.TrimSpace(strings.Replace(txt, consts.CommandTransmissionAdd, "", 1)) 352 | if strings.HasPrefix(arg, "magnet:") { 353 | message = AddTorrent(config.TransmissionRPCPort, config.TransmissionRPCUsername, config.TransmissionRPCPasswd, arg) 354 | } else { 355 | message = consts.MessageTransmissionUpload 356 | pool.Sessions[userID] = session{ 357 | UserID: userID, 358 | CurrentStatus: StatusWaitingTransmissionUpload, 359 | } 360 | options.SetReplyMarkup(cancelReplyMarkup(true)) 361 | } 362 | case strings.HasPrefix(txt, consts.CommandTransmissionRemove) || strings.HasPrefix(txt, consts.CommandTransmissionDelete): 363 | var keyboards [][]bot.InlineKeyboardButton 364 | message, keyboards = parseTransmissionCommand(config, txt) 365 | if keyboards != nil { 366 | options.SetReplyMarkup(bot.NewInlineKeyboardMarkup(keyboards)) 367 | } 368 | case strings.HasPrefix(txt, consts.CommandStatus): 369 | message = getStatus(config, launchedAt) 370 | case strings.HasPrefix(txt, consts.CommandLogs): 371 | message = getLogs(db) 372 | case strings.HasPrefix(txt, consts.CommandHelp): 373 | message = getHelp() 374 | options.SetReplyMarkup(helpInlineKeyboardMarkup()) 375 | case strings.HasPrefix(txt, consts.CommandPrivacy): 376 | message = getPrivacyPolicy() 377 | // fallback 378 | default: 379 | cmd := removeMarkdownChars(txt, "") 380 | if len(cmd) > 0 { 381 | message = fmt.Sprintf("*%s*: %s", cmd, consts.MessageUnknownCommand) 382 | } else { 383 | message = consts.MessageUnknownCommand 384 | } 385 | } 386 | } 387 | case StatusWaitingTransmissionUpload: 388 | switch { 389 | case strings.HasPrefix(txt, consts.CommandCancel): 390 | message = consts.MessageCanceled 391 | default: 392 | var torrent string 393 | if update.Message.Document != nil { 394 | // get file info 395 | ctxFileInfo, cancelFileInfo := context.WithTimeout(ctx, requestTimeoutSeconds*time.Second) 396 | defer cancelFileInfo() 397 | fileResult := b.GetFile(ctxFileInfo, update.Message.Document.FileID) 398 | 399 | torrent = b.GetFileURL(*fileResult.Result) 400 | } else { 401 | torrent = txt 402 | } 403 | 404 | addReaction(ctx, b, update, "👌") 405 | message = AddTorrent(config.TransmissionRPCPort, config.TransmissionRPCUsername, config.TransmissionRPCPasswd, torrent) 406 | } 407 | 408 | // reset status 409 | pool.Sessions[userID] = session{ 410 | UserID: userID, 411 | CurrentStatus: StatusWaiting, 412 | } 413 | } 414 | 415 | // send message 416 | ctxSend, cancelSend := context.WithTimeout(ctx, requestTimeoutSeconds*time.Second) 417 | defer cancelSend() 418 | if checkMarkdownValidity(message) { 419 | options.SetParseMode(bot.ParseModeMarkdown) 420 | } 421 | if sent := b.SendMessage( 422 | ctxSend, 423 | update.Message.Chat.ID, 424 | message, 425 | options, 426 | ); sent.Ok { 427 | result = true 428 | } else { 429 | var errMessageEmpty bot.ErrMessageEmpty 430 | var errMessageTooLong bot.ErrMessageTooLong 431 | var errNoChatID bot.ErrChatNotFound 432 | var errTooManyRequests bot.ErrTooManyRequests 433 | if errors.As(sent.Error, &errMessageEmpty) { 434 | logError(db, "message is empty") 435 | } else if errors.As(sent.Error, &errMessageTooLong) { 436 | logError(db, "message is too long: %d bytes", len(message)) 437 | } else if errors.As(sent.Error, &errNoChatID) { 438 | logError(db, "no such chat id: %d", update.Message.Chat.ID) 439 | } else if errors.As(sent.Error, &errTooManyRequests) { 440 | logError(db, "too many requests") 441 | } else { 442 | logError(db, "failed to send message: %s", *sent.Description) 443 | } 444 | } 445 | } else { 446 | logError(db, "no session for id: %s", userID) 447 | } 448 | pool.Unlock() 449 | 450 | return result 451 | } 452 | 453 | // add reaction to a message 454 | func addReaction( 455 | ctx context.Context, 456 | b *bot.Bot, 457 | update bot.Update, 458 | reaction string, 459 | ) { 460 | if !update.HasMessage() { 461 | return 462 | } 463 | 464 | chatID := update.Message.Chat.ID 465 | messageID := update.Message.MessageID 466 | 467 | // add reaction 468 | ctxReaction, cancelReaction := context.WithTimeout(ctx, ignorableRequestTimeoutSeconds*time.Second) 469 | defer cancelReaction() 470 | _ = b.SetMessageReaction(ctxReaction, chatID, messageID, bot.NewMessageReactionWithEmoji(reaction)) 471 | } 472 | 473 | // process incoming callback query 474 | func processCallbackQuery( 475 | ctx context.Context, 476 | b *bot.Bot, 477 | config cfg.Config, 478 | db *Database, 479 | update bot.Update, 480 | ) (result bool) { 481 | query := *update.CallbackQuery 482 | txt := *query.Data 483 | 484 | // process result 485 | result = false 486 | 487 | var message string 488 | if strings.HasPrefix(txt, consts.CommandCancel) { 489 | message = "" 490 | } else if strings.HasPrefix(txt, consts.CommandServiceStart) || strings.HasPrefix(txt, consts.CommandServiceStop) { // service 491 | message, _ = parseServiceCommand(config, db, txt) 492 | } else if strings.HasPrefix(txt, consts.CommandTransmissionRemove) || strings.HasPrefix(txt, consts.CommandTransmissionDelete) { // transmission 493 | message, _ = parseTransmissionCommand(config, txt) 494 | } else { 495 | logError(db, "unprocessable callback query: %s", txt) 496 | 497 | return result 498 | } 499 | 500 | // answer callback query 501 | options := bot.OptionsAnswerCallbackQuery{} 502 | if len(message) > 0 { 503 | options.SetText(message) 504 | } 505 | ctxAnswer, cancelAnswer := context.WithTimeout(ctx, requestTimeoutSeconds*time.Second) 506 | defer cancelAnswer() 507 | if apiResult := b.AnswerCallbackQuery(ctxAnswer, query.ID, options); apiResult.Ok { 508 | if len(message) <= 0 { 509 | message = consts.MessageCanceled 510 | } 511 | 512 | // edit message and remove inline keyboards 513 | ctxEdit, cancelEdit := context.WithTimeout(ctx, requestTimeoutSeconds*time.Second) 514 | defer cancelEdit() 515 | if apiResult := b.EditMessageText( 516 | ctxEdit, 517 | message, 518 | bot.OptionsEditMessageText{}. 519 | SetIDs(query.Message.Chat.ID, query.Message.MessageID), 520 | ); apiResult.Ok { 521 | result = true 522 | } else { 523 | logError(db, "failed to edit message text: %s", *apiResult.Description) 524 | } 525 | } else { 526 | logError(db, "failed to answer callback query: %+v", query) 527 | } 528 | 529 | return result 530 | } 531 | 532 | // broadcast a messge to given chats 533 | func broadcast( 534 | ctx context.Context, 535 | client *bot.Bot, 536 | config cfg.Config, 537 | db *Database, 538 | message string, 539 | ) { 540 | for _, chat := range db.GetChats() { 541 | if isAvailableID(config, chat.UserID) { 542 | options := bot.OptionsSendMessage{}. 543 | SetReplyMarkup(defaultReplyMarkup(true)) 544 | if checkMarkdownValidity(message) { 545 | options.SetParseMode(bot.ParseModeMarkdown) 546 | } 547 | ctxSend, cancelSend := context.WithTimeout(ctx, requestTimeoutSeconds*time.Second) 548 | defer cancelSend() 549 | if sent := client.SendMessage( 550 | ctxSend, 551 | chat.ChatID, 552 | message, 553 | options, 554 | ); !sent.Ok { 555 | var errMessageEmpty bot.ErrMessageEmpty 556 | var errMessageTooLong bot.ErrMessageTooLong 557 | var errNoChatID bot.ErrChatNotFound 558 | var errTooManyRequests bot.ErrTooManyRequests 559 | if errors.As(sent.Error, &errMessageEmpty) { 560 | logError(db, "broadcast message is empty") 561 | } else if errors.As(sent.Error, &errMessageTooLong) { 562 | logError(db, "broadcast message is too long: %d bytes", len(message)) 563 | } else if errors.As(sent.Error, &errNoChatID) { 564 | logError(db, "no such chat id for broadcast: %d", chat.ChatID) 565 | } else if errors.As(sent.Error, &errTooManyRequests) { 566 | logError(db, "too many requests for broadcast") 567 | } else { 568 | logError(db, "failed to broadcast to chat id %d: %s", chat.ChatID, *sent.Description) 569 | } 570 | } 571 | } else { 572 | logError(db, "not an allowed user id for boradcasting: %s", chat.UserID) 573 | } 574 | } 575 | } 576 | 577 | // for processing incoming request through HTTP 578 | func httpHandlerForCLI(config cfg.Config, queue chan string) func(w http.ResponseWriter, r *http.Request) { 579 | return func(w http.ResponseWriter, r *http.Request) { 580 | message := strings.TrimSpace(r.FormValue(consts.ParamMessage)) 581 | 582 | if len(message) > 0 { 583 | if config.IsVerbose { 584 | _stdout.Printf("received message from CLI: %s", message) 585 | } 586 | 587 | queue <- message 588 | } 589 | } 590 | } 591 | 592 | // check if given string is valid with markdown characters (true == valid) 593 | func checkMarkdownValidity(txt string) bool { 594 | if strings.Count(txt, "_")%2 == 0 && 595 | strings.Count(txt, "*")%2 == 0 && 596 | strings.Count(txt, "`")%2 == 0 && 597 | strings.Count(txt, "```")%2 == 0 { 598 | return true 599 | } 600 | 601 | return false 602 | } 603 | 604 | // default reply markup for messages 605 | func defaultReplyMarkup(resize bool) bot.ReplyKeyboardMarkup { 606 | return bot.NewReplyKeyboardMarkup(allKeyboards). 607 | SetResizeKeyboard(resize) 608 | } 609 | 610 | // reply markup for cancel 611 | func cancelReplyMarkup(resize bool) bot.ReplyKeyboardMarkup { 612 | return bot.NewReplyKeyboardMarkup(cancelKeyboard). 613 | SetResizeKeyboard(resize) 614 | } 615 | 616 | // inline keyboard markup for help 617 | func helpInlineKeyboardMarkup() bot.InlineKeyboardMarkup { 618 | return bot.NewInlineKeyboardMarkup( // inline keyboard for link to github page 619 | [][]bot.InlineKeyboardButton{ 620 | bot.NewInlineKeyboardButtonsWithURL(map[string]string{ 621 | "GitHub": githubPageURL, 622 | }), 623 | }, 624 | ) 625 | } 626 | 627 | // run bot 628 | func runBot( 629 | ctx context.Context, 630 | config cfg.Config, 631 | launchedAt time.Time, 632 | ) { 633 | // initialize variables 634 | sessions := make(map[string]session) 635 | for _, v := range config.AvailableIDs { 636 | sessions[v] = session{ 637 | UserID: v, 638 | CurrentStatus: StatusWaiting, 639 | } 640 | } 641 | pool = sessionPool{ 642 | Sessions: sessions, 643 | } 644 | queue := make(chan string, consts.QueueSize) 645 | 646 | // open database 647 | db, err := OpenDB() 648 | if err != nil { 649 | _stderr.Fatalf("failed to open database: %s", err) 650 | } 651 | 652 | db.Log("starting server...") 653 | 654 | // catch SIGINT and SIGTERM and terminate gracefully 655 | sig := make(chan os.Signal, 1) 656 | signal.Notify(sig, os.Interrupt, syscall.SIGTERM) 657 | go func() { 658 | <-sig 659 | db.Log("stopping server...") 660 | os.Exit(1) 661 | }() 662 | 663 | client := bot.NewClient(config.APIToken) 664 | client.Verbose = config.IsVerbose 665 | 666 | // get info about this bot 667 | ctxBotInfo, cancelBotInfo := context.WithTimeout(ctx, requestTimeoutSeconds*time.Second) 668 | defer cancelBotInfo() 669 | if me := client.GetMe(ctxBotInfo); me.Ok { 670 | _stdout.Printf("launching bot: @%s (%s)", *me.Result.Username, me.Result.FirstName) 671 | 672 | // delete webhook (getting updates will not work when wehbook is set up) 673 | ctxDeleteWebhook, cancelDeleteWebhook := context.WithTimeout(ctx, requestTimeoutSeconds*time.Second) 674 | defer cancelDeleteWebhook() 675 | if unhooked := client.DeleteWebhook(ctxDeleteWebhook, false); unhooked.Ok { 676 | // wait for CLI message channel 677 | go func() { 678 | // broadcast messages from CLI 679 | for message := range queue { 680 | broadcast(ctx, client, config, db, message) 681 | } 682 | }() 683 | 684 | // start web server for CLI 685 | go func(config cfg.Config) { 686 | if config.CLIPort <= 0 { 687 | config.CLIPort = consts.DefaultCLIPortNumber 688 | } 689 | _stdout.Printf("starting local web server for CLI on port: %d", config.CLIPort) 690 | 691 | http.HandleFunc(consts.HTTPBroadcastPath, httpHandlerForCLI(config, queue)) 692 | if err := http.ListenAndServe(fmt.Sprintf(":%d", config.CLIPort), nil); err != nil { 693 | panic(err) 694 | } 695 | }(config) 696 | 697 | // set update handlers 698 | client.SetMessageHandler(func(b *bot.Bot, update bot.Update, message bot.Message, edited bool) { 699 | // 'is typing...' 700 | ctxAction, cancelAction := context.WithTimeout(ctx, ignorableRequestTimeoutSeconds*time.Second) 701 | defer cancelAction() 702 | _ = b.SendChatAction(ctxAction, message.Chat.ID, bot.ChatActionTyping, nil) 703 | 704 | // process message 705 | processUpdate(ctx, b, config, db, launchedAt, update) 706 | }) 707 | client.SetCallbackQueryHandler(func(b *bot.Bot, update bot.Update, callbackQuery bot.CallbackQuery) { 708 | // 'is typing...' 709 | ctxAction, cancelAction := context.WithTimeout(ctx, ignorableRequestTimeoutSeconds*time.Second) 710 | defer cancelAction() 711 | _ = b.SendChatAction(ctxAction, callbackQuery.Message.Chat.ID, bot.ChatActionTyping, nil) 712 | 713 | // process callback query 714 | processCallbackQuery(ctx, b, config, db, update) 715 | }) 716 | 717 | // wait for new updates 718 | client.StartPollingUpdates(0, config.MonitorInterval, func(b *bot.Bot, update bot.Update, err error) { 719 | if err != nil { 720 | logError(db, "error while receiving update: %s", err) 721 | } 722 | }) 723 | } else { 724 | panic("Failed to delete webhook") 725 | } 726 | } else { 727 | panic("Failed to get info of the bot") 728 | } 729 | } 730 | 731 | // log error to stderr and DB 732 | func logError(db *Database, format string, a ...any) { 733 | _stderr.Printf(format, a...) 734 | 735 | db.LogError(fmt.Sprintf(format, a...)) 736 | } 737 | --------------------------------------------------------------------------------