diff --git a/.docker/dev/docker-compose.yaml b/.docker/dev/docker-compose.yaml new file mode 100644 index 0000000..e057104 --- /dev/null +++ b/.docker/dev/docker-compose.yaml @@ -0,0 +1,30 @@ +version: '2' +services: + bot: + build: ../.. + container_name: pullanusbot + environment: + BOT_TOKEN: 12345678:XXXXXXXXxxxxxxxxXXXXXXXXxxxxxxxxXXX + ADMIN_CHAT_ID: 1488 + volumes: + - ./.directory/pullanusbot-data:/usr/local/share/pullanusbot-data + restart: always + + # Create service with RabbitMQ. + message-broker: + image: rabbitmq:3-management-alpine + container_name: message-broker + ports: + - 5672:5672 # for sender and consumer connections + - 15672:15672 # for serve RabbitMQ GUI + volumes: + - ./.directory/rabbitmq-data/data/:/var/lib/rabbitmq + - ./.directory/rabbitmq-data/log/:/var/log/rabbitmq + restart: always + networks: + - dev-network + +networks: + # Create a new Docker network. + dev-network: + driver: bridge \ No newline at end of file diff --git a/.docker/prod/docker-compose.yaml b/.docker/prod/docker-compose.yaml new file mode 100644 index 0000000..5ce1eae --- /dev/null +++ b/.docker/prod/docker-compose.yaml @@ -0,0 +1,12 @@ +version: '2' +services: + bot: + image: ailinykh/pullanusbot + container_name: pullanusbot + environment: + BOT_TOKEN: 12345678:XXXXXXXXxxxxxxxxXXXXXXXXxxxxxxxxXXX + ADMIN_CHAT_ID: 1488 + AMQP_URL: amqp://guest:guest@localhost:5672/ + volumes: + - ./pullanusbot-data:/usr/local/share/pullanusbot-data + restart: always \ No newline at end of file diff --git a/.docker/supervisord.conf b/.docker/supervisord.conf new file mode 100644 index 0000000..93fc81c --- /dev/null +++ b/.docker/supervisord.conf @@ -0,0 +1,13 @@ +[supervisord] +nodaemon=true +loglevel=debug + +[program:telegram-bot-api] +command=telegram-bot-api --api-id=%(ENV_API_ID)s --api-hash=%(ENV_API_HASH)s --local + +[program:pullanusbot] +command=pullanusbot + +[program:sshd] +command=/usr/sbin/sshd -D +autorestart=true \ No newline at end of file diff --git a/.docker/telegram-bot-api b/.docker/telegram-bot-api new file mode 100755 index 0000000..4d11db7 --- /dev/null +++ b/.docker/telegram-bot-api @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38935e47c90cb92299615e8b14116cf6c03d8a61bfe20a73c7645bddde9aca5c +size 32967920 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6320cd2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +data \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..24eb6e5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,61 @@ +name: build +on: + push: + branches: + - master + +jobs: + build: + name: Build + runs-on: ubuntu-latest + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: true + steps: + + - name: Set up Go 1.15 + uses: actions/setup-go@v1 + with: + go-version: 1.15 + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v1 + + # - name: Git LFS setup + # run: git lfs pull + + - name: Build + run: make build + + - name: Test + run: make test + + - name: Coverage + run: bash <(curl -s https://codecov.io/bash) + + + - name: Generate build number + uses: einaregilsson/build-number@v2 + with: + token: ${{secrets.github_token}} + + - name: Setup tag + run: echo "::set-env name=TAG::0.6.$BUILD_NUMBER" + + - name: Build docker container + run: | + docker build -t ${{ github.repository }}:$TAG -t ${{ github.repository }}:latest . + echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin + docker push --all-tags ${{ github.repository }} + env: + DOCKER_LOGIN: ${{ secrets.DOCKER_LOGIN }} + DOCKER_PWD: ${{ secrets.DOCKER_PWD }} + + - name: Deploy app + run: | + echo "$SSH_IDENTITY_KEY" > identity + chmod 600 identity + ssh -i identity -o StrictHostKeyChecking=no root@proxy.ailinykh.com "/bin/bash ./docker/pullanusbot/deploy_app.sh $TAG" + env: + SSH_IDENTITY_KEY: ${{ secrets.SSH_IDENTITY_KEY }} + diff --git a/.gitignore b/.gitignore index a482b96..850426b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ *.dll *.so *.dylib +*.db +coverage.txt +.env # Test binary, built with `go test -c` *.test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddcfb84 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:stretch as builder +WORKDIR /go/src/github.com/ailinykh/pullanusbot +# cache dependencies first +COPY go.mod go.sum ./ +RUN go mod download +# now build +ADD . . +RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -ldflags '-extldflags "-static"' + +FROM jrottenberg/ffmpeg:4.4-alpine313 +RUN apk update && apk add tzdata python3 --no-cache && \ + wget https://yt-dl.org/downloads/latest/youtube-dl -O /usr/local/bin/youtube-dl && \ + chmod a+rx /usr/local/bin/youtube-dl && \ + ln -s /usr/bin/python3 /usr/bin/python +COPY --from=builder /go/src/github.com/ailinykh/pullanusbot/pullanusbot /usr/local/bin/pullanusbot +WORKDIR /usr/local/share +VOLUME [ "/usr/local/share/pullanusbot-data" ] +ENTRYPOINT pullanusbot \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..985f3f8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 indes + +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..caa454c --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +-include .env + +.PHONY: test run build clean + +all: build run + +run: + ./pullanusbot + +test: + GO_ENV=testing go test ./... -v -coverprofile=coverage.txt -race -covermode=atomic + +build: clean *.go + go build . + +clean: + rm -f pullanusbot \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e418575 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# pullanusbot + +[![Build Status](https://github.com/ailinykh/pullanusbot/workflows/build/badge.svg)](https://github.com/ailinykh/pullanusbot/actions?query=workflow%3Abuild) +[![Go Report Card](https://goreportcard.com/badge/github.com/ailinykh/pullanusbot)](https://goreportcard.com/report/github.com/ailinykh/pullanusbot) +![GitHub](https://img.shields.io/github/license/ailinykh/pullanusbot.svg) + +This bot helps your telegram chat to consume content in more native way + +Let's say somebody sends a link to the webm video: + +![bot-webm-video](https://user-images.githubusercontent.com/939390/95298451-c7757100-0884-11eb-9140-4c6474959720.gif) + +Or a video file as a document: + +![bot-video-convert](https://user-images.githubusercontent.com/939390/95298623-07d4ef00-0885-11eb-92e4-b3c2015f7ecc.gif) + +It's even support links to twitter videos + +![bot-twitter-video](https://user-images.githubusercontent.com/939390/95298730-3783f700-0885-11eb-9650-b0c04e40aa2f.gif) + +... and images! + +![bot-twitter-images](https://user-images.githubusercontent.com/939390/95298790-4cf92100-0885-11eb-8bb2-8adbc91f5b23.gif) + +## how to run + +Setup environment + +```shell +brew install go ffmpeg youtube-dl +``` +clone repo + +```shell +git clone https://github.com/ailinykh/pullanusbot.git +cd pullanusbot +``` + +install go dependencies +```shell +go mod download +``` +obtain bot token from [@BotFather](https://t.me/BotFather) and your telegram ID via `/info` command from [@pullanusbot](https://t.me/pullanusbot) + +```shell +echo "export BOT_TOKEN:=12345678:XXXXXXXXxxxxxxxxXXXXXXXXxxxxxxxxXXX" > .env +``` + +and run! + +```shell +make +``` diff --git a/Readme.MD b/Readme.MD deleted file mode 100644 index 0381ac2..0000000 --- a/Readme.MD +++ /dev/null @@ -1,12 +0,0 @@ -# pullanusbot - -## TODO: - -- [ ] Setup environment -- [ ] Write tests -- [ ] Implement `/pidorules` command -- [ ] Implement `/pidoreg` command -- [ ] Implement `/pidorstats` command -- [ ] Implement `/pidorall` command -- [ ] Implement `/pidorme` command -- [ ] Setup autodeploy \ No newline at end of file diff --git a/api/cookie_jar.go b/api/cookie_jar.go new file mode 100644 index 0000000..3cfeefc --- /dev/null +++ b/api/cookie_jar.go @@ -0,0 +1,47 @@ +package api + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" +) + +type CookieJar struct { + cookies map[string][]*http.Cookie +} + +func (j *CookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.cookies[u.Host] = cookies + data, err := json.MarshalIndent(j.cookies, "", " ") + if err != nil { + fmt.Println("❌⚠️", err) + return + } + filename := "cookies.json" + err = ioutil.WriteFile(filename, data, 0644) + if err != nil { + fmt.Println("❌⚠️", err) + return + } +} + +func (j *CookieJar) Cookies(u *url.URL) []*http.Cookie { + filename := "cookies.json" + data, err := ioutil.ReadFile(filename) + if err != nil { + fmt.Println("⚠️", err) + j.cookies = map[string][]*http.Cookie{} + return []*http.Cookie{} + } + + err = json.Unmarshal(data, &j.cookies) + if err != nil { + fmt.Println("⚠️", err) + j.cookies = map[string][]*http.Cookie{} + return []*http.Cookie{} + } + + return j.cookies[u.Host] +} diff --git a/api/http_client.go b/api/http_client.go new file mode 100644 index 0000000..efe8ebd --- /dev/null +++ b/api/http_client.go @@ -0,0 +1,81 @@ +package api + +import ( + "fmt" + "io/ioutil" + "net/http" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateHttpClient() *HttpClient { + return &HttpClient{map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0"}} +} + +type HttpClient struct { + headers map[string]string +} + +func (c *HttpClient) GetRedirectLocation(url core.URL) (core.URL, error) { + client := http.DefaultClient + req, _ := http.NewRequest("HEAD", url, nil) + + for k, v := range c.headers { + req.Header.Set(k, v) + } + + res, err := client.Do(req) + if err != nil { + return "", err + } + + return res.Request.URL.String(), nil +} + +// GetContentType is a core.IHttpClient interface implementation +func (c *HttpClient) GetContentType(url core.URL) (string, error) { + client := http.DefaultClient + req, _ := http.NewRequest("HEAD", url, nil) + + for k, v := range c.headers { + req.Header.Set(k, v) + } + + res, err := client.Do(req) + if err != nil { + return "", err + } + + if header, ok := res.Header["Content-Type"]; ok { + return header[0], nil + } + return "", fmt.Errorf("content-type not found") +} + +// GetContent is a core.IHttpClient interface implementation +func (c *HttpClient) GetContent(url core.URL) (string, error) { + client := http.DefaultClient + req, _ := http.NewRequest("GET", url, nil) + + for k, v := range c.headers { + req.Header.Set(k, v) + } + + res, err := client.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", err + } + + return string(body), nil +} + +// SetHeader remembers all passed values and applies it to every request +func (c *HttpClient) SetHeader(key string, value string) { + c.headers[key] = value +} diff --git a/api/opengraph_parser.go b/api/opengraph_parser.go new file mode 100644 index 0000000..1774d2c --- /dev/null +++ b/api/opengraph_parser.go @@ -0,0 +1,50 @@ +package api + +import ( + "fmt" + "html" + "regexp" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateOpenGraphParser(l core.ILogger) *OpenGraphParser { + return &OpenGraphParser{l} +} + +type OpenGraphParser struct { + l core.ILogger +} + +// CreateMedia is a core.IMediaFactory interface implementation +func (ogp *OpenGraphParser) CreateMedia(HTMLString string) ([]*core.Media, error) { + video := ogp.parseMeta(HTMLString, "og:video") + if len(video) == 0 { + return nil, fmt.Errorf("video not found") + } + + video = html.UnescapeString(video) + title := ogp.parseMeta(HTMLString, "og:title") + description := ogp.parseMeta(HTMLString, "og:description") + url := ogp.parseMeta(HTMLString, "og:url") + + media := &core.Media{ + ResourceURL: video, + URL: url, + Title: title, + Description: description, + Type: core.TVideo, + } + return []*core.Media{media}, nil +} + +func (ogp *OpenGraphParser) parseMeta(html string, property string) string { + r := regexp.MustCompile(fmt.Sprintf(``, property)) + match := r.FindStringSubmatch(html) + if len(match) == 0 { + ogp.l.Errorf("can't find %s", property) + return "" + } + + return match[1] +} diff --git a/api/telebot.go b/api/telebot.go new file mode 100644 index 0000000..1c73dbe --- /dev/null +++ b/api/telebot.go @@ -0,0 +1,264 @@ +package api + +import ( + "fmt" + "os" + "path" + "strconv" + "strings" + "sync" + "time" + + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/helpers" + tb "gopkg.in/tucnak/telebot.v2" +) + +// Telebot is a telegram API +type Telebot struct { + bot *tb.Bot + logger core.ILogger + commandHandlers []string + textHandlers []core.ITextHandler + documentHandlers []core.IDocumentHandler + imageHandlers []core.IImageHandler + videoHandlers []core.IVideoHandler +} + +// CreateTelebot is a default Telebot factory +func CreateTelebot(token string, logger core.ILogger) *Telebot { + poller := tb.NewMiddlewarePoller(&tb.LongPoller{Timeout: 10 * time.Second}, func(upd *tb.Update) bool { + return true + }) + + var err error + bot, err := tb.NewBot(tb.Settings{ + Token: token, + Poller: poller, + }) + + if err != nil { + panic(err) + } + + telebot := &Telebot{bot, logger, []string{}, []core.ITextHandler{}, []core.IDocumentHandler{}, []core.IImageHandler{}, []core.IVideoHandler{}} + + bot.Handle(tb.OnText, func(m *tb.Message) { + for _, h := range telebot.textHandlers { + err := h.HandleText(makeMessage(m), makeIBot(m, telebot)) + if err != nil { + logger.Errorf("%T: %s", h, err) + telebot.reportError(m, err) + } + } + }) + + var mutex sync.Mutex + + bot.Handle(tb.OnDocument, func(m *tb.Message) { + // TODO: inject `download` to get rid of MIME cheking + if m.Document.MIME[:5] == "video" || m.Document.MIME == "image/gif" { + mutex.Lock() + defer mutex.Unlock() + + logger.Infof("Attempt to download %s %s (sent by %s)", m.Document.FileName, m.Document.MIME, m.Sender.Username) + + path := path.Join(os.TempDir(), m.Document.FileName) + err := bot.Download(&m.Document.File, path) + if err != nil { + logger.Error(err) + return + } + + logger.Infof("Downloaded to %s", strings.ReplaceAll(path, os.TempDir(), "$TMPDIR/")) + defer os.Remove(path) + + for _, h := range telebot.documentHandlers { + err := h.HandleDocument(&core.Document{ + File: core.File{Name: m.Document.FileName, Path: path}, + MIME: m.Document.MIME, + }, makeMessage(m), makeIBot(m, telebot)) + if err != nil { + logger.Errorf("%T: %s", h, err) + telebot.reportError(m, err) + } + } + } + }) + + bot.Handle(tb.OnPhoto, func(m *tb.Message) { + + image := &core.Image{ + ID: m.Photo.FileID, + FileURL: m.Photo.FileURL, + Width: m.Photo.Width, + Height: m.Photo.Height, + } + + for _, h := range telebot.imageHandlers { + err := h.HandleImage(image, makeMessage(m), makeIBot(m, telebot)) + if err != nil { + logger.Errorf("%T: %s", h, err) + telebot.reportError(m, err) + } + } + }) + + bot.Handle(tb.OnVideo, func(m *tb.Message) { + + video := &core.Video{ + ID: m.Video.FileID, + Width: m.Video.Width, + Height: m.Video.Height, + } + + for _, h := range telebot.videoHandlers { + err := h.HandleImage(video, makeMessage(m), makeIBot(m, telebot)) + if err != nil { + logger.Errorf("%T: %s", h, err) + telebot.reportError(m, err) + } + } + }) + + return telebot +} + +// Download is a core.IImageDownloader interface implementation +func (t *Telebot) Download(image *core.Image) (*core.File, error) { + //TODO: potential race condition + file := tb.FromURL(image.FileURL) + file.FileID = image.ID + name := helpers.RandStringRunes(4) + ".jpg" + path := path.Join(os.TempDir(), name) + err := t.bot.Download(&file, path) + if err != nil { + t.logger.Error(err) + return nil, err + } + + t.logger.Infof("image %s downloaded to %s", file.UniqueID, path) + return makeFile(name, path), nil +} + +// AddHandler register object as one of core.Handler's +func (t *Telebot) AddHandler(handler ...interface{}) { + switch h := handler[0].(type) { + case core.IDocumentHandler: + t.documentHandlers = append(t.documentHandlers, h) + case core.ITextHandler: + t.textHandlers = append(t.textHandlers, h) + case core.IImageHandler: + t.imageHandlers = append(t.imageHandlers, h) + case string: + t.registerCommand(h) + if f, ok := handler[1].(func(*core.Message, core.IBot) error); ok { + t.bot.Handle(h, func(m *tb.Message) { + f(makeMessage(m), &TelebotAdapter{m, t}) + }) + } else { + panic("interface must implement func(*core.Message, core.IBot) error") + } + default: + panic(fmt.Sprintf("something wrong with %s", h)) + } +} + +// Run bot loop +func (t *Telebot) Run() { + t.bot.Start() +} + +func (t *Telebot) registerCommand(command string) { + for _, c := range t.commandHandlers { + if c == command { + panic("Handler for " + command + " already set!") + } + } + t.commandHandlers = append(t.commandHandlers, command) +} + +func (t *Telebot) reportError(m *tb.Message, e error) { + chatID, err := strconv.ParseInt(os.Getenv("ADMIN_CHAT_ID"), 10, 64) + if err != nil { + return + } + chat := &tb.Chat{ID: chatID} + opts := &tb.SendOptions{DisableWebPagePreview: true} + t.bot.Forward(chat, m, opts) + t.bot.Send(chat, e.Error(), opts) +} + +func makeMessage(m *tb.Message) *core.Message { + text := m.Text + if m.Document != nil { + text = m.Caption + } + message := &core.Message{ + ID: m.ID, + ChatID: m.Chat.ID, + IsPrivate: m.Private(), + Sender: makeUser(m.Sender), + Text: text, + } + + if m.ReplyTo != nil { + message.ReplyTo = makeMessage(m.ReplyTo) + } + + if m.Video != nil { + message.Video = makeVideo(m.Video) + } + + return message +} + +func makeUser(u *tb.User) *core.User { + return &core.User{ + ID: u.ID, + FirstName: u.FirstName, + LastName: u.LastName, + Username: u.Username, + LanguageCode: u.LanguageCode, + } +} + +func makeVideo(v *tb.Video) *core.Video { + return &core.Video{ + File: core.File{ + Name: v.FileName, + Path: v.FileURL, + }, + ID: v.FileID, + Width: v.Width, + Height: v.Height, + Bitrate: 0, + Duration: v.Duration, + Codec: "", + Thumb: makePhoto(v.Thumbnail), + } +} + +func makePhoto(p *tb.Photo) *core.Image { + return &core.Image{ + File: core.File{ + Name: p.FileLocal, + Path: p.FilePath, + }, + ID: p.FileID, + FileURL: p.FileURL, + Width: p.Width, + Height: p.Height, + } +} + +func makeFile(name string, path string) *core.File { + return &core.File{ + Name: name, + Path: path, + } +} + +func makeIBot(m *tb.Message, t *Telebot) core.IBot { + return &TelebotAdapter{m, t} +} diff --git a/api/telebot_adapter.go b/api/telebot_adapter.go new file mode 100644 index 0000000..6e3dcfa --- /dev/null +++ b/api/telebot_adapter.go @@ -0,0 +1,146 @@ +package api + +import ( + "github.com/ailinykh/pullanusbot/v2/core" + tb "gopkg.in/tucnak/telebot.v2" +) + +// TelebotAdapter combines Telebot and core.IBot +type TelebotAdapter struct { + m *tb.Message + t *Telebot +} + +// SendText is a core.IBot interface implementation +func (a *TelebotAdapter) SendText(text string, params ...interface{}) (*core.Message, error) { + opts := tb.SendOptions{ParseMode: tb.ModeHTML, DisableWebPagePreview: true} + for _, param := range params { + switch m := param.(type) { + case *core.Message: + opts.ReplyTo = &tb.Message{ID: m.ID} + case bool: + opts.DisableWebPagePreview = m + default: + break + } + } + sent, err := a.t.bot.Send(a.m.Chat, text, &opts) + if err != nil { + return nil, err + } + return makeMessage(sent), err +} + +// Delete is a core.IBot interface implementation +func (a *TelebotAdapter) Delete(message *core.Message) error { + return a.t.bot.Delete(&tb.Message{ID: message.ID, Chat: &tb.Chat{ID: message.ChatID}}) +} + +// SendImage is a core.IBot interface implementation +func (a *TelebotAdapter) SendImage(image *core.Image, caption string) (*core.Message, error) { + photo := makeTbPhoto(image, caption) + sent, err := photo.Send(a.t.bot, a.m.Chat, &tb.SendOptions{ParseMode: tb.ModeHTML}) + if err != nil { + return nil, err + } + return makeMessage(sent), err +} + +// SendAlbum is a core.IBot interface implementation +func (a *TelebotAdapter) SendAlbum(images []*core.Image) ([]*core.Message, error) { + album := tb.Album{} + for _, i := range images { + photo := &tb.Photo{File: tb.File{FileID: i.ID}} + album = append(album, photo) + } + + sent, err := a.t.bot.SendAlbum(a.m.Chat, album) + if err != nil { + return nil, err + } + + var messages []*core.Message + for _, m := range sent { + messages = append(messages, makeMessage(&m)) + } + return messages, err +} + +// SendMedia is a core.IBot interface implementation +func (a *TelebotAdapter) SendMedia(media *core.Media) (*core.Message, error) { + var sent *tb.Message + var err error + opts := &tb.SendOptions{ParseMode: tb.ModeHTML, DisableWebPagePreview: true} + switch media.Type { + case core.TPhoto: + a.t.logger.Infof("sending media as photo: %v", media) + file := &tb.Photo{File: tb.FromURL(media.ResourceURL)} + file.Caption = media.Caption + a.t.bot.Notify(a.m.Chat, tb.UploadingPhoto) + sent, err = a.t.bot.Send(a.m.Chat, file, opts) + case core.TVideo: + a.t.logger.Infof("sending media as video: %v", media) + file := &tb.Video{File: tb.FromURL(media.ResourceURL)} + file.Caption = media.Caption + a.t.bot.Notify(a.m.Chat, tb.UploadingVideo) + sent, err = a.t.bot.Send(a.m.Chat, file, opts) + case core.TText: + a.t.logger.Infof("sending media as text: %v", media) + sent, err = a.t.bot.Send(a.m.Chat, media.Caption, opts) + } + + if err != nil { + return nil, err + } + return makeMessage(sent), err +} + +// SendPhotoAlbum is a core.IBot interface implementation +func (a *TelebotAdapter) SendPhotoAlbum(medias []*core.Media) ([]*core.Message, error) { + var photo *tb.Photo + var album = tb.Album{} + + for i, m := range medias { + photo = &tb.Photo{File: tb.FromURL(m.ResourceURL)} + if i == len(medias)-1 { + photo.Caption = m.Caption + photo.ParseMode = tb.ModeHTML + } + album = append(album, photo) + } + + sent, err := a.t.bot.SendAlbum(a.m.Chat, album) + if err != nil { + return nil, err + } + + var messages []*core.Message + for _, m := range sent { + messages = append(messages, makeMessage(&m)) + } + return messages, err +} + +// SendVideo is a core.IBot interface implementation +func (a *TelebotAdapter) SendVideo(vf *core.Video, caption string) (*core.Message, error) { + video := makeTbVideo(vf, caption) + a.t.bot.Notify(a.m.Chat, tb.UploadingVideo) + sent, err := video.Send(a.t.bot, a.m.Chat, &tb.SendOptions{ParseMode: tb.ModeHTML}) + if err != nil { + return nil, err + } + a.t.logger.Infof("%s successfully sent", vf.Name) + return makeMessage(sent), err +} + +// IsUserMemberOfChat is a core.IBot interface implementation +func (a *TelebotAdapter) IsUserMemberOfChat(user *core.User, chatID int64) bool { + chat := &tb.Chat{ID: chatID} + member, err := a.t.bot.ChatMemberOf(chat, makeTbUser(user)) + if err != nil { + a.t.logger.Error(err, member) + } + return member != nil && + member.Role != tb.Left && + member.Role != tb.Kicked +} diff --git a/api/telebot_factory.go b/api/telebot_factory.go new file mode 100644 index 0000000..1a67a02 --- /dev/null +++ b/api/telebot_factory.go @@ -0,0 +1,46 @@ +package api + +import ( + "github.com/ailinykh/pullanusbot/v2/core" + tb "gopkg.in/tucnak/telebot.v2" +) + +func makeTbVideo(vf *core.Video, caption string) *tb.Video { + var video *tb.Video + if len(vf.ID) > 0 { + video = &tb.Video{File: tb.File{FileID: vf.ID}} + video.Caption = caption + } else { + video = &tb.Video{File: tb.FromDisk(vf.Path)} + video.FileName = vf.File.Name + video.Width = vf.Width + video.Height = vf.Height + video.Caption = caption + video.Duration = vf.Duration + video.SupportsStreaming = true + video.Thumbnail = &tb.Photo{ + File: tb.FromDisk(vf.Thumb.Path), + Width: vf.Thumb.Width, + Height: vf.Thumb.Height, + } + } + return video +} + +func makeTbPhoto(image *core.Image, caption string) *tb.Photo { + photo := &tb.Photo{File: tb.FromDisk(image.File.Path)} + if len(image.ID) > 0 { + photo = &tb.Photo{File: tb.File{FileID: image.ID}} + } + photo.Caption = caption + return photo +} + +func makeTbUser(user *core.User) *tb.User { + return &tb.User{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Username: user.Username, + } +} diff --git a/api/telebot_info.go b/api/telebot_info.go new file mode 100644 index 0000000..d98e880 --- /dev/null +++ b/api/telebot_info.go @@ -0,0 +1,52 @@ +package api + +import ( + "fmt" + "strings" + + tb "gopkg.in/tucnak/telebot.v2" +) + +// SetupInfo ... +func (t *Telebot) SetupInfo() { + t.bot.Handle("/info", func(m *tb.Message) { + info := []string{ + "💬 Chat", + fmt.Sprintf("ID: *%d*", m.Chat.ID), + fmt.Sprintf("Title: *%s*", m.Chat.Title), + fmt.Sprintf("Type: *%s*", m.Chat.Type), + "", + "👤 Sender", + fmt.Sprintf("ID: *%d*", m.Sender.ID), + fmt.Sprintf("First: *%s*", m.Sender.FirstName), + fmt.Sprintf("Last: *%s*", m.Sender.LastName), + fmt.Sprintf("Username: *%s*", m.Sender.Username), + "", + } + + if m.ReplyTo != nil { + if m.ReplyTo.OriginalChat != nil { + info = append(info, + "💬 OriginalChat", + fmt.Sprintf("ID: *%d*", m.ReplyTo.OriginalChat.ID), + fmt.Sprintf("Title: *%s*", m.ReplyTo.OriginalChat.Title), + fmt.Sprintf("Type: *%s*", m.ReplyTo.OriginalChat.Type), + "", + ) + } + if m.ReplyTo.OriginalSender != nil { + info = append(info, + "👤 OriginalSender", + fmt.Sprintf("ID: *%d*", m.ReplyTo.OriginalSender.ID), + fmt.Sprintf("First: *%s*", m.ReplyTo.OriginalSender.FirstName), + fmt.Sprintf("Last: *%s*", m.ReplyTo.OriginalSender.LastName), + fmt.Sprintf("Username: *%s*", m.ReplyTo.OriginalSender.Username), + fmt.Sprintf("Name: *%s*", m.ReplyTo.OriginalSenderName), + "", + ) + } + } + + t.bot.Send(m.Chat, strings.Join(info, "\n"), &tb.SendOptions{ParseMode: tb.ModeMarkdown}) + }) +} diff --git a/api/telegraph.go b/api/telegraph.go new file mode 100644 index 0000000..b09598c --- /dev/null +++ b/api/telegraph.go @@ -0,0 +1,74 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "os" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreateTelegraphAPI is a default Telegraph factory +func CreateTelegraphAPI() *Telegraph { + return &Telegraph{} +} + +// Telegraph uploads files to telegra.ph +type Telegraph struct { +} + +type telegraphImage struct { + Src string `json:"src"` +} + +// Upload is a core.IFileUploader interface implementation +func (t *Telegraph) Upload(file *core.File) (core.URL, error) { + fd, err := os.Open(file.Path) + if err != nil { + return "", err + } + defer fd.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", file.Name) + if err != nil { + return "", err + } + + _, err = io.Copy(part, fd) + if err != nil { + return "", err + } + + err = writer.Close() + if err != nil { + return "", err + } + + req, _ := http.NewRequest("POST", "https://telegra.ph/upload", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + + body2, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var images []telegraphImage + err = json.Unmarshal(body2, &images) + if err != nil { + return "", err + } + + url := "https://telegra.ph" + images[0].Src + return url, nil +} diff --git a/api/tiktok.go b/api/tiktok.go new file mode 100644 index 0000000..3182da4 --- /dev/null +++ b/api/tiktok.go @@ -0,0 +1,55 @@ +package api + +type TikTokJSONResponse struct { + ItemInfo TikTokItemInfo +} + +type TikTokHTMLResponse struct { + Props TikTokHTMLProps +} + +type TikTokHTMLProps struct { + PageProps TikTokResponse +} + +type TikTokResponse struct { + ServerCode int + StatusCode int + ItemInfo TikTokItemInfo +} + +type TikTokItemInfo struct { + ItemStruct TikTokItemStruct +} + +type TikTokItemStruct struct { + Desc string + Author TikTokAuthor + Music TikTokMusic + Video TikTokVideo + StickersOnItem []TikTokSticker +} + +type TikTokAuthor struct { + UniqueId string + Nickname string +} + +type TikTokMusic struct { + Id string + Title string + AuthorName string +} + +type TikTokVideo struct { + Id string + DownloadAddr string + ShareCover []string + Bitrate int + CodecType string +} + +type TikTokSticker struct { + StickerText []string + StickerType int +} diff --git a/api/tiktok_api_decorator.go b/api/tiktok_api_decorator.go new file mode 100644 index 0000000..951e1e4 --- /dev/null +++ b/api/tiktok_api_decorator.go @@ -0,0 +1,18 @@ +package api + +func CreateTikTokAPIDecorator(primary ITikTokAPI, secondary ITikTokAPI) ITikTokAPI { + return &TikTokAPIDecorator{primary, secondary} +} + +type TikTokAPIDecorator struct { + primary ITikTokAPI + secondary ITikTokAPI +} + +func (api *TikTokAPIDecorator) GetItem(username string, videoId string) (*TikTokItemStruct, error) { + item, err := api.primary.GetItem(username, videoId) + if err != nil { + return api.secondary.GetItem(username, videoId) + } + return item, nil +} diff --git a/api/tiktok_html_api.go b/api/tiktok_html_api.go new file mode 100644 index 0000000..1b13a7f --- /dev/null +++ b/api/tiktok_html_api.go @@ -0,0 +1,62 @@ +package api + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateTikTokHTMLAPI(l core.ILogger, hc core.IHttpClient, r core.IRand) ITikTokAPI { + return &TikTokHTMLAPI{l, hc, r} +} + +type TikTokHTMLAPI struct { + l core.ILogger + hc core.IHttpClient + r core.IRand +} + +func (api *TikTokHTMLAPI) GetItem(username string, videoId string) (*TikTokItemStruct, error) { + url := "https://www.tiktok.com/" + username + "/video/" + videoId + api.l.Infof("processing %s", url) + api.hc.SetHeader("Cookie", "tt_webid_v2=69"+api.randomDigits(17)+"; Domain=tiktok.com; Path=/; Secure; hostOnly=false; hostOnly=false; aAge=4ms; cAge=4ms") + htmlString, err := api.hc.GetContent(url) + if err != nil { + return nil, err + } + + // os.WriteFile("tiktok-"+strings.Split(url, "/")[5]+".html", []byte(htmlString), 0644) + r := regexp.MustCompile(`