diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26ec6bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +bin diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..22cf1be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.4 + +RUN mkdir -p /go/src/app +WORKDIR /go/src/app +COPY ./src /go/src/app + +RUN go-wrapper download +RUN go-wrapper install + +CMD ["go-wrapper", "run", "./*.go"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ca35049 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Konstantin Azizov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d17fe75 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +# Modified Makefile from https://gist.github.com/Stratus3D/a5be23866810735d7413 + +.PHONY: build doc fmt lint run test install vet + +# Prepend our _vendor directory to the system GOPATH +# so that import path resolution will prioritize +# our third party snapshots. +GOPATH := ${PWD}/_vendor:${GOPATH} +export GOPATH + +default: build + +build: vet + go build -v -o ./bin/ftpbot ./src/ + +doc: + godoc -http=:6060 -index + +# http://golang.org/cmd/go/#hdr-Run_gofmt_on_package_sources +fmt: + go fmt ./src/... + +# https://github.com/golang/lint +# go get github.com/golang/lint/golint +lint: + golint ./src + +run: build + ./bin/ftpbot + +test: + go test ./src/... + + +install: + cd src && go get && go install + +vet: + go vet ./src/... diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd020eb --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# FTPBot + +Interact with filesystem of remote computer or server from your PC or smartphone using a Telegram client. + +## Getting started + +1. Grab the latest release +2. [Create telegram bot](https://core.telegram.org/bots#3-how-do-i-create-a-bot) +3. Copy token and run the latest binary with `--token "%YOUR_TOKEN%"` argument + +You can check all available options by running `ftpbot --help`. Don't see an option that you want in the list? Submit an issue about this! + +## Development + +Want to fix a bug or add future? Nice! If you're working on a thing that isn't listed in issues make sure to discuss it first. + +There are 2 ways of building application: + +### Build locally +Requirements: +- Go 1.6+ +- make + +```bash +make install +make +./bin/ftpbot --token "YOUR_TOKEN" +``` + +### Using docker + +Insert `token` argument in Dockerfile and run next commands: + +```bash +docker build . +docker run %container_id% +``` + +## License + +MIT © [Konstantin Azizov](http://g07cha.github.io) diff --git a/src/fs-handlers.go b/src/fs-handlers.go new file mode 100644 index 0000000..3929bbd --- /dev/null +++ b/src/fs-handlers.go @@ -0,0 +1,197 @@ +package main + +import ( + "io/ioutil" + "os" + "path" + "strconv" + "strings" + + "github.com/tucnak/telebot" +) + +// LS provide interface between telebot and native function to list files in current directory +func LS(bot *telebot.Bot, msg telebot.Message) error { + page := 0 + path := GetCurrentState(&msg.Sender).currentPath + args := strings.Split(msg.Text, " ") + if len(args) > 1 { + page, _ = strconv.Atoi(args[1]) + } + + markup, err := lsToMarkup(path, page) + if err != nil { + return err + } + + return bot.SendMessage(msg.Chat, "List of items in "+path, &telebot.SendOptions{ + ReplyMarkup: markup, + }) +} + +// ShowActions determinates and show possible actions that could be done with file from passed query +func ShowActions(bot *telebot.Bot, msg telebot.Message) error { + filename := msg.Text[strings.Index(msg.Text, " ")+1:] + currentPath := GetCurrentState(&msg.Sender).currentPath + file, err := os.Open(path.Join(currentPath, filename)) + if err != nil { + return err + } + + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + return err + } + + folderActions := []telebot.KeyboardButton{ + telebot.KeyboardButton{ + Text: "Open", + Data: "/cd " + fileInfo.Name(), + }, + } + fileActions := []telebot.KeyboardButton{ + telebot.KeyboardButton{ + Text: "Download", + Data: "/download " + fileInfo.Name(), + }, + } + + // Setup markup with base actions + replyMarkup := &telebot.ReplyMarkup{ + InlineKeyboard: [][]telebot.KeyboardButton{ + []telebot.KeyboardButton{ + telebot.KeyboardButton{ + Text: "Delete", + Data: "/confirm delete " + fileInfo.Name(), + }, + }, + []telebot.KeyboardButton{ + telebot.KeyboardButton{ + Text: "Copy", + Data: "/cp " + fileInfo.Name(), + }, + telebot.KeyboardButton{ + Text: "Move", + Data: "/mv " + fileInfo.Name(), + }, + }, + []telebot.KeyboardButton{ + telebot.KeyboardButton{ + Text: "Rename", + Data: "/rename " + fileInfo.Name(), + }, + }, + }, + } + + var selectedActions []telebot.KeyboardButton + + if fileInfo.IsDir() == true { + selectedActions = folderActions + } else { + selectedActions = fileActions + } + + replyMarkup.InlineKeyboard = append(replyMarkup.InlineKeyboard, selectedActions) + + return bot.SendMessage(msg.Chat, "Choose action for "+fileInfo.Name(), &telebot.SendOptions{ + ReplyMarkup: *replyMarkup, + }) +} + +// ChangeDirectory updates current working directory for users and return file listing in new directory +func ChangeDirectory(bot *telebot.Bot, msg telebot.Message) error { + state := GetCurrentState(&msg.Sender) + + newPath := path.Join(state.currentPath, strings.Split(msg.Text, " ")[1]) + state.currentPath = newPath + + markup, err := lsToMarkup(newPath, 0) + if err != nil { + return err + } + + return bot.SendMessage(msg.Chat, "You are now in "+newPath, &telebot.SendOptions{ + ReplyMarkup: markup, + }) +} + +// Download used for downloading files from fs +func Download(bot *telebot.Bot, msg telebot.Message) error { + filename := msg.Text[strings.Index(msg.Text, " ")+1:] + fileExt := filename[strings.LastIndex(filename, ".")+1:] + + file, err := telebot.NewFile(path.Join(GetCurrentState(&msg.Sender).currentPath, filename)) + if err != nil { + return err + } + + switch { + case fileExt == "png" || fileExt == "jpg": + bot.SendPhoto(msg.Sender, &telebot.Photo{File: file}, nil) + case fileExt == "mp3": + bot.SendAudio(msg.Sender, &telebot.Audio{File: file}, nil) + case fileExt == "mp4": + bot.SendVideo(msg.Sender, &telebot.Video{Audio: telebot.Audio{File: file}}, nil) + } + return bot.SendDocument(msg.Sender, &telebot.Document{File: file}, nil) +} + +func lsToMarkup(path string, page int) (telebot.ReplyMarkup, error) { + const ( + maxItemsPerPage int = 30 + maxItemsPerRow int = 3 + ) + + reservedButtons := 1 // Back button by default + files, err := ioutil.ReadDir(path) + files = files[page*maxItemsPerPage:] // Apply paging + if err != nil { + return telebot.ReplyMarkup{}, err + } + if len(files) > maxItemsPerPage { + reservedButtons++ // Reserve slot for next page button + files = files[0:maxItemsPerPage] + } + + markup := telebot.ReplyMarkup{ + InlineKeyboard: make([][]telebot.KeyboardButton, RoundNumber(float32(len(files))/float32(maxItemsPerRow))+reservedButtons), + } + // Add "Back" button at start + markup.InlineKeyboard[0] = []telebot.KeyboardButton{ + telebot.KeyboardButton{ + Text: "..", + Data: "/cd ..", + }, + } + + for i := 1; i <= len(files); i += maxItemsPerRow { + var row []telebot.KeyboardButton + if len(files)-i > maxItemsPerRow { + row = make([]telebot.KeyboardButton, maxItemsPerRow) + } else { + row = make([]telebot.KeyboardButton, len(files)-i) + } + + for index, file := range files[i : i+len(row)] { + row[index] = telebot.KeyboardButton{ + Text: file.Name(), + Data: "/actions " + file.Name(), + } + } + markup.InlineKeyboard[i/maxItemsPerRow+1] = row + } + + if reservedButtons > 1 { + markup.InlineKeyboard[len(markup.InlineKeyboard)-1] = []telebot.KeyboardButton{ + telebot.KeyboardButton{ + Text: "Next page", + Data: "/ls " + strconv.Itoa(page+1), + }, + } + } + + return markup, nil +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..a62cb42 --- /dev/null +++ b/src/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "log" + "time" + + "github.com/mkideal/cli" + "github.com/tucnak/telebot" +) + +type argT struct { + Token string `cli:"*token" usage:"enter telegram bot's token"` +} + +var bot *telebot.Bot +var router *Router + +func main() { + router = GetRouter() + cli.Run(new(argT), func(ctx *cli.Context) error { + var err error + argv := ctx.Argv().(*argT) + + bot, err = telebot.NewBot(argv.Token) + if err != nil { + return err + } + + return nil + }) + + bot.Messages = make(chan telebot.Message) + bot.Callbacks = make(chan telebot.Callback) + + go messages() + go callbacks() + + log.Println("Bot started") + bot.Start(1 * time.Second) +} + +func messages() { + for message := range bot.Messages { + router.handle(bot, message) + } +} + +func callbacks() { + for callback := range bot.Callbacks { + callback.Message.Text = callback.Data + callback.Message.Sender = callback.Sender + + // Mark callback query as readed + bot.AnswerCallbackQuery(&callback, &telebot.CallbackResponse{ + CallbackID: callback.ID, + }) + + router.handle(bot, callback.Message) + } +} diff --git a/src/router.go b/src/router.go new file mode 100644 index 0000000..9bc7df3 --- /dev/null +++ b/src/router.go @@ -0,0 +1,43 @@ +package main + +import ( + "log" + "strings" + + "github.com/tucnak/telebot" +) + +// Router used for flexible working with multiple commands and handlers +type Router struct { + routes map[string]func(*telebot.Bot, telebot.Message) error + handle func(*telebot.Bot, telebot.Message) +} + +// GetRouter initializes application's router +func GetRouter() *Router { + router := Router{ + handle: func(bot *telebot.Bot, message telebot.Message) { + command := getCommand(message.Text) + + if handler, ok := router.routes[command]; ok { + err := handler(bot, message) + if err != nil { + log.Println(message.Text + " HANDLER ERROR:") + log.Println(err) + } + } + }, + routes: make(map[string]func(*telebot.Bot, telebot.Message) error), + } + + router.routes["/ls"] = LS + router.routes["/actions"] = ShowActions + router.routes["/cd"] = ChangeDirectory + router.routes["/download"] = Download + + return &router +} + +func getCommand(text string) string { + return strings.Split(text, " ")[0] +} diff --git a/src/user-state.go b/src/user-state.go new file mode 100644 index 0000000..8adc59c --- /dev/null +++ b/src/user-state.go @@ -0,0 +1,51 @@ +package main + +import ( + "os/user" + + "github.com/tucnak/telebot" +) + +// UserState is storing current state for each user that uses bot +type UserState struct { + user *telebot.User + currentPath string + selectedAction userAction +} + +type userAction uint8 + +// UserActions used for storing available action and preventing random integer insertion +var UserActions = struct { + COPY, MOVE, RENAME userAction +}{1, 2, 3} + +// UsersList global list of users with their current states +var UsersList []UserState + +// NewUser create new user with default parameters +func NewUser(u *telebot.User) UserState { + usr, err := user.Current() // Home directory + if err != nil { + usr = &user.User{HomeDir: "/"} + } + + return UserState{ + user: u, + currentPath: usr.HomeDir, + } +} + +// GetCurrentState retrives state for user or creates new one +func GetCurrentState(u *telebot.User) *UserState { + for index, state := range UsersList { + if state.user.ID == u.ID { + return &UsersList[index] + } + } + + newState := NewUser(u) + UsersList = append(UsersList, newState) + + return &UsersList[len(UsersList)-1] +} diff --git a/src/utils.go b/src/utils.go new file mode 100644 index 0000000..85ab1f4 --- /dev/null +++ b/src/utils.go @@ -0,0 +1,9 @@ +package main + +// RoundNumber does proper rounding for decimals(in case if .1 and more will add one) +func RoundNumber(num float32) int { + if num > float32(int(num)) { + return int(num) + 1 + } + return int(num) +}