diff --git a/.docker/dev/docker-compose.yaml b/.docker/dev/docker-compose.yaml new file mode 100644 index 0000000..ba2c6f9 --- /dev/null +++ b/.docker/dev/docker-compose.yaml @@ -0,0 +1,42 @@ +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 + + telegram-bot-api: + image: aiogram/telegram-bot-api:latest + environment: + TELEGRAM_API_ID: 1488 + TELEGRAM_API_HASH: XXXXXXXXxxxxxxxxXXXXXXXXxxxxxxxxXXX + TELEGRAM_VERBOSITY: 1 + volumes: + - ./.directory/telegram-bot-api-data:/var/lib/telegram-bot-api + ports: + - "8081:8081" + 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 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..f677037 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +data +bin \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..724b69c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,63 @@ +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.17 + uses: actions/setup-go@v2 + with: + go-version: 1.17 + + - name: Print go version + run: go version + + - 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..9ce2384 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ *.dll *.so *.dylib +*.db +coverage.txt +.env +bin # Test binary, built with `go test -c` *.test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2360a0e --- /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:5.1-alpine313 +RUN apk update && apk add tzdata python3 --no-cache && \ + wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp && \ + chmod a+rx /usr/local/bin/yt-dlp && \ + 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..f574421 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +-include .env + +.PHONY: all serve kill run test build clean restart + +APP ?= bin/pullanusbot +PID = $(APP).pid +GO_FILES = $(wildcard *.go) + +all: serve + +before: + @echo "🛠 rebuilding an app..." + +serve: run + @fswatch -x -o --event Created --event Updated --event Renamed -r -e '.*' -i '\.go$$' . | xargs -n1 -I{} make restart || make kill + +$(APP): $(GO_FILES) + @go build $? -o $@ + +kill: + @kill `cat $(PID)` || true + +run: build + @$(APP) & echo $$! > $(PID) + +test: + GO_ENV=testing go test ./... -v -coverprofile=coverage.txt -race -covermode=atomic + +build: $(GO_FILES) + @go build -o $(APP) . + +clean: + rm -f $(APP) + +restart: kill before build run \ 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/http_client.go b/api/http_client.go new file mode 100644 index 0000000..05b1456 --- /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 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36"}} +} + +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/instagram.go b/api/instagram.go new file mode 100644 index 0000000..0b6d6b0 --- /dev/null +++ b/api/instagram.go @@ -0,0 +1,48 @@ +package api + +type IgReel struct { + Items []IgReelItem +} + +type IgUser struct { + Username string + FullName string `json:"full_name"` +} + +type IgReelItem struct { + Code string + User IgUser + Caption IgCaption + VideoDuration float64 `json:"video_duration"` + VideoVersions []IgReelVideo `json:"video_versions"` + ClipsMetadata IgReelClipsMetadata `json:"clips_metadata"` +} + +type IgReelVideo struct { + Width int + Height int + URL string +} + +type IgCaption struct { + Text string +} + +type IgReelClipsMetadata struct { + MusicInfo *IgReelMusicInfo `json:"music_info"` + OriginalSoundInfo *IgReelOriginalSoundInfo `json:"original_sound_info"` +} + +type IgReelMusicInfo struct { + MusicAssetInfo IgReelMusicAssetInfo `json:"music_asset_info"` +} + +type IgReelMusicAssetInfo struct { + DisplayArtist string `json:"display_artist"` + Title string + ProgressiveDownloadURL string `json:"progressive_download_url"` +} + +type IgReelOriginalSoundInfo struct { + ProgressiveDownloadURL string `json:"progressive_download_url"` +} diff --git a/api/instagram_api.go b/api/instagram_api.go new file mode 100644 index 0000000..f0960a4 --- /dev/null +++ b/api/instagram_api.go @@ -0,0 +1,117 @@ +package api + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "regexp" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreateInstagramAPI +func CreateInstagramAPI(l core.ILogger, jar http.CookieJar) InstAPI { + client := http.Client{ + Jar: jar, + } + return &InstagramAPI{l, client} +} + +type InstAPI interface { + GetReel(string) (*IgReel, error) +} + +type InstagramHTMLData struct { + appId string + csrfToken string + mediaId string +} + +// Instagram API +type InstagramAPI struct { + l core.ILogger + client http.Client +} + +func (api *InstagramAPI) GetReel(urlString string) (*IgReel, error) { + body, err := api.getContent(urlString, map[string]string{"sec-fetch-mode": "navigate"}) + if err != nil { + api.l.Error(err) + return nil, err + } + + // os.WriteFile("instagram-reel.html", body, 0644) + + data, err := api.parseData(body) + if err != nil { + api.l.Error(err) + return nil, err + } + + urlString = "https://i.instagram.com/api/v1/media/" + data.mediaId + "/info/" + body, err = api.getContent(urlString, map[string]string{"x-ig-app-id": data.appId}) + if err != nil { + api.l.Error(err) + return nil, err + } + + // os.WriteFile("instagram-reel-"+data.mediaId+".json", body, 0644) + + var reel IgReel + err = json.Unmarshal(body, &reel) + if err != nil { + api.l.Error(err) + return nil, err + } + + return &reel, nil +} + +func (api *InstagramAPI) getContent(urlString string, headers map[string]string) ([]byte, error) { + req, err := http.NewRequest("GET", urlString, nil) + req.Header.Set("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36") + for k, v := range headers { + req.Header.Set(k, v) + } + + if err != nil { + api.l.Error(err) + return nil, err + } + resp, err := api.client.Do(req) + if err != nil { + api.l.Error(err) + return nil, err + } + defer resp.Body.Close() + return ioutil.ReadAll(resp.Body) +} + +func (p *InstagramAPI) parseData(data []byte) (*InstagramHTMLData, error) { + appId, err := p.parse(data, `"app_id":"(\d+)"`) + if err != nil { + return nil, err + } + + csrfToken, err := p.parse(data, `"csrf_token":"(\w+)"`) + if err != nil { + return nil, err + } + + mediaId, err := p.parse(data, `"media_id":"(\d+)"`) + if err != nil { + return nil, err + } + + return &InstagramHTMLData{string(appId), string(csrfToken), string(mediaId)}, nil +} + +func (p *InstagramAPI) parse(data []byte, reg string) ([]byte, error) { + r := regexp.MustCompile(reg) + match := r.FindSubmatch(data) + if len(match) < 2 { + return nil, fmt.Errorf("parse `%s` failed", reg) + } + return match[1], nil +} diff --git a/api/json_cookie_jar.go b/api/json_cookie_jar.go new file mode 100644 index 0000000..e0a6f54 --- /dev/null +++ b/api/json_cookie_jar.go @@ -0,0 +1,69 @@ +package api + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/url" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateJsonCookieJar(l core.ILogger, cookieFile string) http.CookieJar { + return &JsonCookieJar{l, cookieFile, []*http.Cookie{}} +} + +type JsonCookieJar struct { + l core.ILogger + filename string + cookies []*http.Cookie +} + +func (jar *JsonCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + jar.cookies = jar.merge(jar.cookies, cookies) + data, err := json.MarshalIndent(jar.cookies, "", " ") + if err != nil { + jar.l.Error(err) + return + } + + err = ioutil.WriteFile(jar.filename, data, 0644) + if err != nil { + jar.l.Error(err) + return + } +} + +func (jar *JsonCookieJar) Cookies(u *url.URL) []*http.Cookie { + data, err := ioutil.ReadFile(jar.filename) + if err != nil { + jar.l.Error(err) + jar.cookies = []*http.Cookie{} + return jar.cookies + } + + err = json.Unmarshal(data, &jar.cookies) + if err != nil { + jar.l.Error(err) + jar.cookies = []*http.Cookie{} + } + + return jar.cookies +} + +func (j *JsonCookieJar) merge(lhs []*http.Cookie, rhs []*http.Cookie) []*http.Cookie { + for _, r := range rhs { + found := false + for _, l := range lhs { + if l.Name == r.Name { + found = true + l = r + break + } + } + if !found { + lhs = append(lhs, r) + } + } + return lhs +} 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/outline_api.go b/api/outline_api.go new file mode 100644 index 0000000..caddcb7 --- /dev/null +++ b/api/outline_api.go @@ -0,0 +1,127 @@ +package api + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreateOutlineAPI is a default OutlineAPI factory +func CreateOutlineAPI(l core.ILogger, url string) *OutlineAPI { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{Transport: tr} + return &OutlineAPI{l, url, client} +} + +type OutlineAPI struct { + l core.ILogger + url string + client *http.Client +} + +type OutlineAPIKeys struct { + AccessKeys []*VpnKey +} + +type VpnKey struct { + ID string + Name string + Password string + Port int + Method string + AccessURL string +} + +func (api *OutlineAPI) GetKeys(chatID int64) ([]*VpnKey, error) { + res, err := api.client.Get(api.url + "/access-keys") + if err != nil { + api.l.Error(err) + return nil, err + } + defer res.Body.Close() + + var keys OutlineAPIKeys + body, _ := ioutil.ReadAll(res.Body) + + err = json.Unmarshal(body, &keys) + if err != nil { + return nil, err + } + + return keys.AccessKeys, nil +} + +func (api *OutlineAPI) CreateKey(chatID int64, name string) (*VpnKey, error) { + res, err := api.client.Post(api.url+"/access-keys", "application/json", bytes.NewBuffer([]byte{})) + + if err != nil { + api.l.Error(err) + return nil, err + } + defer res.Body.Close() + + var key VpnKey + body, _ := ioutil.ReadAll(res.Body) + + err = json.Unmarshal(body, &key) + if err != nil { + api.l.Error(err) + return nil, err + } + + values := map[string]string{"name": name} + data, err := json.Marshal(values) + + if err != nil { + api.l.Error(err) + return nil, err + } + + req, err := http.NewRequest(http.MethodPut, api.url+"/access-keys/"+key.ID+"/name", bytes.NewBuffer(data)) + if err != nil { + api.l.Error(err) + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + res, err = api.client.Do(req) + if err != nil { + api.l.Error(err) + return nil, err + } + + if res.StatusCode != 204 { + api.l.Warningf("unexpected response: %+v", res) + return nil, fmt.Errorf("can't rename created key") + } + + return &key, nil +} + +func (api *OutlineAPI) DeleteKey(key *core.VpnKey) error { + req, err := http.NewRequest(http.MethodDelete, api.url+"/access-keys/"+key.ID, bytes.NewBuffer([]byte{})) + if err != nil { + api.l.Error(err) + return err + } + + res, err := api.client.Do(req) + if err != nil { + api.l.Error(err) + return err + } + + if res.StatusCode != 204 { + api.l.Warningf("unexpected response: %+v", res) + return fmt.Errorf("can't remove key") + } + + return nil +} diff --git a/api/telebot.go b/api/telebot.go new file mode 100644 index 0000000..998f30f --- /dev/null +++ b/api/telebot.go @@ -0,0 +1,366 @@ +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/telebot.v3" +) + +// Telebot is a telegram API +type Telebot struct { + bot *tb.Bot + logger core.ILogger + coreFactory *CoreFactory + multipart *helpers.SendMultipartVideo + 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) + } + + var multipart *helpers.SendMultipartVideo + apiURL := os.Getenv("BOT_API_URL") + if len(apiURL) > 0 { + apiURL = fmt.Sprintf("%s/bot%s/sendVideo", apiURL, token) + multipart = helpers.CreateSendMultipartVideo(logger, apiURL) + } + + telebot := &Telebot{ + bot, + logger, + &CoreFactory{}, + multipart, + []string{}, + []core.ITextHandler{}, + []core.IDocumentHandler{}, + []core.IImageHandler{}, + []core.IVideoHandler{}, + } + + bot.Handle(tb.OnText, func(c tb.Context) error { + var err error + var message = telebot.coreFactory.makeMessage(c.Message()) + var bot = telebot.coreFactory.makeIBot(c.Message(), telebot) + for _, h := range telebot.textHandlers { + err = h.HandleText(message, bot) + if err != nil { + if err.Error() == "not implemented" { + err = nil // skip "not implemented" error + } else { + logger.Errorf("%T: %s", h, err) + telebot.reportError(c.Message(), err) + } + } + } + return err + }) + + var mutex sync.Mutex + + bot.Handle(tb.OnDocument, func(c tb.Context) error { + var err error + var m = c.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 err + } + + 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, + }, telebot.coreFactory.makeMessage(m), telebot.coreFactory.makeIBot(m, telebot)) + if err != nil { + logger.Errorf("%T: %s", h, err) + telebot.reportError(m, err) + } + } + } + return err + }) + + bot.Handle(tb.OnPhoto, func(c tb.Context) error { + var err error + var m = c.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, telebot.coreFactory.makeMessage(m), telebot.coreFactory.makeIBot(m, telebot)) + if err != nil { + logger.Errorf("%T: %s", h, err) + telebot.reportError(m, err) + } + } + return err + }) + + bot.Handle(tb.OnVideo, func(c tb.Context) error { + var err error + var m = c.Message() + video := &core.Video{ + ID: m.Video.FileID, + Width: m.Video.Width, + Height: m.Video.Height, + } + logger.Info(m, video) + for _, h := range telebot.videoHandlers { + err = h.HandleVideo(video, telebot.coreFactory.makeMessage(m), telebot.coreFactory.makeIBot(m, telebot)) + if err != nil { + logger.Errorf("%T: %s", h, err) + telebot.reportError(m, err) + } + } + return 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 t.coreFactory.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 core.IVideoHandler: + t.videoHandlers = append(t.videoHandlers, h) + case string: + t.registerCommand(h) + if f, ok := handler[1].(func(*core.Message, core.IBot) error); ok { + t.bot.Handle(h, func(c tb.Context) error { + m := c.Message() + return f(t.coreFactory.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)) + } + + if h, ok := handler[0].(core.IButtonHandler); ok { + for _, id := range h.GetButtonIds() { + t.bot.Handle("\f"+id, func(c tb.Context) error { + m := c.Message() + cb := c.Callback() + button := core.Button{ + ID: cb.Unique, + Text: c.Text(), + Payload: c.Data(), + } + err := h.ButtonPressed( + &button, + t.coreFactory.makeMessage(m), + t.coreFactory.makeUser(c.Sender()), + t.coreFactory.makeIBot(m, t), + ) + if err != nil { + t.logger.Error(err) + t.reportError(m, err) + resp := tb.CallbackResponse{ + CallbackID: cb.ID, + Text: err.Error(), + } + return t.bot.Respond(cb, &resp) + } + return t.bot.Respond(cb, &tb.CallbackResponse{CallbackID: cb.ID}) + }) + } + } +} + +// 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 { + t.logger.Errorf("ADMIN_CHAT_ID parsing failed %s", os.Getenv("ADMIN_CHAT_ID")) + return + } + chat := &tb.Chat{ID: chatID} + opts := &tb.SendOptions{DisableWebPagePreview: true} + _, err = t.bot.Forward(chat, m, opts) + if err != nil { + t.logger.Error(err) + } + + _, err = t.bot.Send(chat, e.Error(), opts) + if err != nil { + t.logger.Error(err) + } +} + +type CoreFactory struct { +} + +func (factory *CoreFactory) makeMessage(m *tb.Message) *core.Message { + text := m.Text + if m.Document != nil { + text = m.Caption + } + message := &core.Message{ + ID: m.ID, + Chat: factory.makeChat(m.Chat), + IsPrivate: m.Private(), + Sender: factory.makeUser(m.Sender), + Text: text, + } + + if m.ReplyTo != nil { + message.ReplyTo = factory.makeMessage(m.ReplyTo) + } + + if m.Video != nil { + message.Video = factory.makeVideo(m.Video) + } + + return message +} + +func (factory *CoreFactory) makeChat(c *tb.Chat) *core.Chat { + title := c.Title + if c.Type == tb.ChatPrivate { + title = c.FirstName + " " + c.LastName + } + return &core.Chat{ + ID: c.ID, + Title: title, + Type: string(c.Type), + } +} + +func (CoreFactory) 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 (factory *CoreFactory) 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: factory.makePhoto(v.Thumbnail), + } +} + +func (CoreFactory) 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 (CoreFactory) makeCommands(commands []tb.Command) []core.Command { + comands := []core.Command{} + for _, command := range commands { + c := core.Command{ + Text: command.Text, + Description: command.Description, + } + comands = append(comands, c) + } + return comands +} + +func (CoreFactory) makeFile(name string, path string) *core.File { + return &core.File{ + Name: name, + Path: path, + } +} + +func (CoreFactory) 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..3bfe5f2 --- /dev/null +++ b/api/telebot_adapter.go @@ -0,0 +1,218 @@ +package api + +import ( + "encoding/json" + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" + tb "gopkg.in/telebot.v3" +) + +// 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 + case core.Keyboard: + opts.ReplyMarkup = &tb.ReplyMarkup{InlineKeyboard: makeInlineKeyboard(m)} + default: + break + } + } + sent, err := a.t.bot.Send(a.m.Chat, text, &opts) + if err != nil { + return nil, err + } + return a.t.coreFactory.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.Chat.ID}}) +} + +// Edit is a core.IBot interface implementation +func (a *TelebotAdapter) Edit(message *core.Message, what interface{}, options ...interface{}) (*core.Message, error) { + switch v := what.(type) { + case core.Keyboard: + replyMarkup := &tb.ReplyMarkup{InlineKeyboard: makeInlineKeyboard(v)} + m, err := a.t.bot.EditReplyMarkup(makeTbMessage(message), replyMarkup) + if err != nil { + return nil, err + } + return a.t.coreFactory.makeMessage(m), nil + case string: + opts := &tb.SendOptions{ParseMode: tb.ModeHTML, DisableWebPagePreview: true} + for _, opt := range options { + switch o := opt.(type) { + case core.Keyboard: + opts.ReplyMarkup = &tb.ReplyMarkup{InlineKeyboard: makeInlineKeyboard(o)} + default: + break + } + } + m, err := a.t.bot.Edit(makeTbMessage(message), v, opts) + if err != nil { + return nil, err + } + return a.t.coreFactory.makeMessage(m), nil + default: + } + return nil, fmt.Errorf("not implemented") +} + +// 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 a.t.coreFactory.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, a.t.coreFactory.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 = makeCaption(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 = makeCaption(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, makeCaption(media.Caption), opts) + } + + if err != nil { + return nil, err + } + return a.t.coreFactory.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{} + opts := &tb.SendOptions{ParseMode: tb.ModeHTML, DisableWebPagePreview: true} + + for i, m := range medias { + photo = &tb.Photo{File: tb.FromURL(m.ResourceURL)} + if i == len(medias)-1 { + photo.Caption = m.Caption + } + album = append(album, photo) + } + + sent, err := a.t.bot.SendAlbum(a.m.Chat, album, opts) + if err != nil { + return nil, err + } + + var messages []*core.Message + for _, m := range sent { + messages = append(messages, a.t.coreFactory.makeMessage(&m)) + } + return messages, err +} + +// SendVideo is a core.IBot interface implementation +func (a *TelebotAdapter) SendVideo(vf *core.Video, caption string) (*core.Message, error) { + if vf.Size > 50*1024*1024 && a.t.multipart != nil { + body, err := a.t.multipart.SendVideo(vf, caption, a.m.Chat.ID) + if err != nil { + return nil, err + } + var resp struct { + Result *tb.Message + } + err = json.Unmarshal(body, &resp) + if err != nil { + return nil, err + } + return a.t.coreFactory.makeMessage(resp.Result), err + } + a.t.logger.Infof("uploading video %s (%.2f MB)", vf.Name, float64(vf.Size)/1024/1024) + 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 a.t.coreFactory.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 +} + +// GetCommands is a core.IBot interface implementation +func (a *TelebotAdapter) GetCommands(chatID int64) ([]core.Command, error) { + scope := tb.CommandScope{ + Type: tb.CommandScopeChat, + ChatID: chatID, + } + commands, err := a.t.bot.Commands(scope) + if err != nil { + return nil, err + } + return a.t.coreFactory.makeCommands(commands), nil +} + +// SetCommands is a core.IBot interface implementation +func (a *TelebotAdapter) SetCommands(chatID int64, commands []core.Command) error { + scope := tb.CommandScope{ + Type: tb.CommandScopeChat, + ChatID: chatID, + } + return a.t.bot.SetCommands(makeTbCommands(commands), scope) +} diff --git a/api/telebot_factory.go b/api/telebot_factory.go new file mode 100644 index 0000000..fa0bb81 --- /dev/null +++ b/api/telebot_factory.go @@ -0,0 +1,93 @@ +package api + +import ( + "github.com/ailinykh/pullanusbot/v2/core" + tb "gopkg.in/telebot.v3" +) + +func makeTbMessage(m *core.Message) *tb.Message { + message := &tb.Message{ + ID: m.ID, + Chat: &tb.Chat{ID: m.Chat.ID}, + Sender: makeTbUser(m.Sender), + } + if m.ReplyTo != nil { + message.ReplyTo = makeTbMessage(m.ReplyTo) + } + if m.Video != nil { + message.Video = makeTbVideo(m.Video, m.Text) + } + return message +} + +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 = makeCaption(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 = makeCaption(caption) + video.Duration = vf.Duration + video.Streaming = 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 = makeCaption(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, + } +} + +func makeInlineKeyboard(k core.Keyboard) [][]tb.InlineButton { + keyboard := [][]tb.InlineButton{} + for _, buttons := range k { + btns := []tb.InlineButton{} + for _, b := range buttons { + btn := tb.InlineButton{Unique: b.ID, Text: b.Text, Data: b.Payload} + btns = append(btns, btn) + } + keyboard = append(keyboard, btns) + } + return keyboard +} + +func makeTbCommands(commands []core.Command) []tb.Command { + comands := []tb.Command{} + for _, command := range commands { + c := tb.Command{ + Text: command.Text, + Description: command.Description, + } + comands = append(comands, c) + } + return comands +} + +func makeCaption(caption string) string { + if len(caption) > 1024 { + return caption[:1024] + } + return caption +} diff --git a/api/telebot_info.go b/api/telebot_info.go new file mode 100644 index 0000000..de21666 --- /dev/null +++ b/api/telebot_info.go @@ -0,0 +1,54 @@ +package api + +import ( + "fmt" + "strings" + + tb "gopkg.in/telebot.v3" +) + +// SetupInfo ... +func (t *Telebot) SetupInfo() { + t.bot.Handle("/info", func(c tb.Context) error { + m := c.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), + "", + ) + } + } + + _, err := t.bot.Send(m.Chat, strings.Join(info, "\n"), &tb.SendOptions{ParseMode: tb.ModeMarkdown}) + return err + }) +} 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_media_factory.go b/api/tiktok_media_factory.go new file mode 100644 index 0000000..01b61c5 --- /dev/null +++ b/api/tiktok_media_factory.go @@ -0,0 +1,33 @@ +package api + +import ( + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateTikTokMediaFactory(l core.ILogger, api YoutubeApi) core.IMediaFactory { + return &TikTokMediaFactory{l, api} +} + +type TikTokMediaFactory struct { + l core.ILogger + api YoutubeApi +} + +func (factory *TikTokMediaFactory) CreateMedia(url string) ([]*core.Media, error) { + item, err := factory.api.get(url) + if err != nil { + factory.l.Error(err) + return nil, err + } + + media := &core.Media{ + URL: url, + ResourceURL: item.Url, + Title: fmt.Sprintf("%s (@%s)", item.Creator, item.Uploader), + Description: fmt.Sprintf(`(@%s)'s short video with %s - %s.`, item.Uploader, item.Creator, item.Track, item.Artist), + } + + return []*core.Media{media}, nil +} diff --git a/api/tweet.go b/api/tweet.go new file mode 100644 index 0000000..f4244ba --- /dev/null +++ b/api/tweet.go @@ -0,0 +1,147 @@ +package api + +// Tweet is a twitter api representation of a single tweet +type Tweet struct { + ID string `json:"id_str"` + FullText string `json:"full_text"` + Entities Entity `json:"entities"` + ExtendedEntities Entity `json:"extended_entities,omitempty"` + User User `json:"user,omitempty"` + QuotedStatus *Tweet `json:"quoted_status,omitempty"` + Errors []Error `json:"errors,omitempty"` +} + +// User ... +type User struct { + Name string `json:"name"` + ScreenName string `json:"screen_name"` +} + +// Entity ... +type Entity struct { + Urls []URL `json:"urls,omitempty"` + Media []Media `json:"media"` +} + +// Media ... +type Media struct { + MediaUrlHttps string `json:"media_url_https"` + Type string `json:"type"` + VideoInfo VideoInfo `json:"video_info,omitempty"` +} + +// URL ... +type URL struct { + ExpandedURL string `json:"expanded_url"` +} + +// VideoInfo ... +type VideoInfo struct { + Variants []VideoInfoVariant `json:"variants"` +} + +// Error ... +type Error struct { + Message string `json:"message"` + Code int `json:"code"` +} + +func (info *VideoInfo) best() VideoInfoVariant { + variant := info.Variants[0] + for _, v := range info.Variants { + if v.ContentType == "video/mp4" && v.Bitrate > variant.Bitrate { + return v + } + } + return variant +} + +// VideoInfoVariant ... +type VideoInfoVariant struct { + Bitrate int `json:"bitrate"` + ContentType string `json:"content_type"` + URL string `json:"url"` +} + +type TweetScreenshot struct { + TweetId string `json:"tweetId"` + Username string `json:"username"` + URL string `json:"url"` +} + +// GraphQL types +type GraphQLRequest struct { + Variables GraphQLVariables `json:"variables"` + Features GraphQLFeatures `json:"features"` + FieldToggles GraphQLFieldToggles `json:"fieldToggles"` +} + +type GraphQLVariables struct { + TweetId string `json:"tweetId"` + WithCommunity bool `json:"withCommunity"` + IncludePromotedContent bool `json:"includePromotedContent"` + WithVoice bool `json:"withVoice"` +} + +type GraphQLFeatures struct { + CreatorSubscriptionsTweetPreviewApiEnabled bool `json:"creator_subscriptions_tweet_preview_api_enabled"` + FreedomOfSpeechNotReachFetceEnabled bool `json:"freedom_of_speech_not_reach_fetch_enabled"` + GraphqlIsTranslatableRwebTweetIsTranslatableEnabled bool `json:"graphql_is_translatable_rweb_tweet_is_translatable_enabled"` + LongformNotetweetsConsumptionEnabled bool `json:"longform_notetweets_consumption_enabled"` + LongformNotetweetsInlineMediaEnabled bool `json:"longform_notetweets_inline_media_enabled"` + LongformNotetweetsRichTextReadEnabled bool `json:"longform_notetweets_rich_text_read_enabled"` + ResponsiveWebGraphqlSkipUserProfileImageExtensionsEnabled bool `json:"responsive_web_graphql_skip_user_profile_image_extensions_enabled"` + ResponsiveWebEditTweetApiEnabled bool `json:"responsive_web_edit_tweet_api_enabled"` + ResponsiveWebEnhanceCardsEnabled bool `json:"responsive_web_enhance_cards_enabled"` + ResponsiveWebMediaDownloadVideoEnabled bool `json:"responsive_web_media_download_video_enabled"` + ResponsiveWebGraphqlTimelineNavigationEnabled bool `json:"responsive_web_graphql_timeline_navigation_enabled"` + ResponsiveWebGraphqlExcludeDirectiveEnabled bool `json:"responsive_web_graphql_exclude_directive_enabled"` + ResponsiveWebTwitterArticleTweetConsumptionEnabled bool `json:"responsive_web_twitter_article_tweet_consumption_enabled"` + StandardizedNudgesMisinfo bool `json:"standardized_nudges_misinfo"` + TweetAwardsWebTippingEnabled bool `json:"tweet_awards_web_tipping_enabled"` + TweetWithVisibilityResultsPreferGqlLimitedActionsPolicyEnabled bool `json:"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled"` + TweetypieUnmentionOptimizationEnabled bool `json:"tweetypie_unmention_optimization_enabled"` + VerifiedPhoneLabelEnabled bool `json:"verified_phone_label_enabled"` + ViewCountsEverywhereApiEnabled bool `json:"view_counts_everywhere_api_enabled"` +} + +type GraphQLFieldToggles struct { + WithArticleRichContentState bool `json:"withArticleRichContentState"` +} + +type GraphQLResponse struct { + Errors []Error `json:"errors,omitempty"` + Data GraphQLResponseData `json:"data"` +} + +type GraphQLResponseData struct { + TweetResult GraphQLResponseTweetResult `json:"tweetResult"` +} + +type GraphQLResponseTweetResult struct { + Result GraphQLResponseTweetResultResult `json:"result"` +} + +type GraphQLResponseTweetResultResult struct { + Core GraphQLResponseCore `json:"core"` + Legacy Tweet `json:"legacy"` + RestId string `json:"rest_id"` +} + +type GraphQLResponseCore struct { + UserResults GraphQLResponseUserResults `json:"user_results"` +} + +type GraphQLResponseUserResults struct { + Result GraphQLResponseUserResult `json:"result"` +} + +type GraphQLResponseUserResult struct { + Legacy User `json:"legacy"` + RestId string `json:"rest_id"` + Verified bool `json:"is_blue_verified"` +} + +type GuestTokenResponse struct { + GuestToken string `json:"guest_token"` +} diff --git a/api/twitter_api.go b/api/twitter_api.go new file mode 100644 index 0000000..cdb793d --- /dev/null +++ b/api/twitter_api.go @@ -0,0 +1,215 @@ +package api + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreateTwitterAPI is a default Twitter factory +func CreateTwitterAPI(l core.ILogger, t core.ITask) *TwitterAPI { + return &TwitterAPI{l, t, TwitterApiCredentials{ + bearer_token: "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA", + guest_token: "1679397394880888834", + }, + } +} + +type TwitterApiCredentials struct { + bearer_token string + guest_token string +} + +// Twitter API +type TwitterAPI struct { + l core.ILogger + task core.ITask + credentials TwitterApiCredentials +} + +func (api *TwitterAPI) getTweetByID(tweetID string) (*Tweet, error) { + tweet, err := api.getTweetFromGraphQL(tweetID) + if err == nil { + return tweet, err + } + + if err.Error() == "Bad guest token" { + resp, err := api.getGuestToken() + if err != nil { + return nil, err + } + + api.l.Infof("guest token received %s", resp.GuestToken) + + api.credentials = TwitterApiCredentials{ + bearer_token: api.credentials.bearer_token, + guest_token: resp.GuestToken, + } + + return api.getTweetFromGraphQL(tweetID) + } + + return tweet, err +} + +func (api *TwitterAPI) getGuestToken() (*GuestTokenResponse, error) { + api.l.Info("updating guest token") + + req, _ := http.NewRequest("POST", "https://api.twitter.com/1.1/guest/activate.json", nil) + req.Header.Add("Authorization", "Bearer "+api.credentials.bearer_token) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var response GuestTokenResponse + body, _ := ioutil.ReadAll(res.Body) + + err = json.Unmarshal(body, &response) + if err != nil { + return nil, err + } + + return &response, nil +} + +func (api *TwitterAPI) getTweetFromGraphQL(tweetID string) (*Tweet, error) { + data, _ := json.Marshal(GraphQLVariables{tweetID, false, false, false}) + variables := url.QueryEscape(string(data)) + + data, _ = json.Marshal(GraphQLFeatures{ + CreatorSubscriptionsTweetPreviewApiEnabled: true, + FreedomOfSpeechNotReachFetceEnabled: true, + GraphqlIsTranslatableRwebTweetIsTranslatableEnabled: true, + LongformNotetweetsConsumptionEnabled: true, + LongformNotetweetsInlineMediaEnabled: true, + LongformNotetweetsRichTextReadEnabled: true, + ResponsiveWebGraphqlSkipUserProfileImageExtensionsEnabled: false, + ResponsiveWebEditTweetApiEnabled: true, + ResponsiveWebEnhanceCardsEnabled: false, + ResponsiveWebMediaDownloadVideoEnabled: true, + ResponsiveWebGraphqlTimelineNavigationEnabled: true, + ResponsiveWebGraphqlExcludeDirectiveEnabled: true, + ResponsiveWebTwitterArticleTweetConsumptionEnabled: false, + StandardizedNudgesMisinfo: true, + TweetAwardsWebTippingEnabled: false, + TweetWithVisibilityResultsPreferGqlLimitedActionsPolicyEnabled: true, + TweetypieUnmentionOptimizationEnabled: true, + VerifiedPhoneLabelEnabled: false, + ViewCountsEverywhereApiEnabled: true, + }) + features := url.QueryEscape(string(data)) + + data, _ = json.Marshal(GraphQLFieldToggles{false}) + field_toggles := url.QueryEscape(string(data)) + + url := fmt.Sprintf("https://twitter.com/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId?variables=%s&features=%s&fieldToggles=%s", variables, features, field_toggles) + + req, _ := http.NewRequest("GET", url, nil) + req.Header.Add("authorization", "Bearer "+api.credentials.bearer_token) + req.Header.Add("x-guest-token", api.credentials.guest_token) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var response GraphQLResponse + body, _ := ioutil.ReadAll(res.Body) + + // os.WriteFile("tweet-"+tweetID+".json", body, 0644) + + err = json.Unmarshal(body, &response) + if err != nil { + return nil, err + } + + if len(response.Errors) > 0 { + if response.Errors[0].Code == 88 { // "Rate limit exceeded 88" + return nil, fmt.Errorf("%s %s", response.Errors[0].Message, res.Header["X-Rate-Limit-Reset"][0]) + } + return nil, fmt.Errorf(response.Errors[0].Message) + } + + // TODO: combine `twitter_api` with `twitter_media_factory` + tweet := response.Data.TweetResult.Result.Legacy + user := response.Data.TweetResult.Result.Core.UserResults.Result.Legacy + tweet.User = User{Name: user.Name, ScreenName: user.ScreenName} + + return &tweet, nil +} + +func (api *TwitterAPI) getTweetByIdAndToken(tweetID string, creds TwitterApiCredentials) (*Tweet, error) { + client := http.DefaultClient + url := fmt.Sprintf("https://api.twitter.com/1.1/statuses/show.json?id=%s&tweet_mode=extended", tweetID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Add("Authorization", "Bearer "+creds.bearer_token) + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var tweet Tweet + body, _ := ioutil.ReadAll(res.Body) + + err = json.Unmarshal(body, &tweet) + if err != nil { + return nil, err + } + + // os.WriteFile("tweet-"+tweetID+".json", body, 0644) + + if len(tweet.Errors) > 0 { + if tweet.Errors[0].Code == 88 { // "Rate limit exceeded 88" + return nil, fmt.Errorf("%s %s", tweet.Errors[0].Message, res.Header["X-Rate-Limit-Reset"][0]) + } + api.l.Errorf("%s %s", tweet.Errors, creds.guest_token) + return nil, fmt.Errorf(tweet.Errors[0].Message) + } + + return &tweet, err +} + +func (api *TwitterAPI) getScreenshot(tweet *Tweet) (*TweetScreenshot, error) { + ch := make(chan []byte) + task := TweetScreenshot{TweetId: tweet.ID, Username: tweet.User.ScreenName} + data, err := json.Marshal(task) + if err != nil { + api.l.Error(err) + return nil, err + } + + err = api.task.Perform(data, ch) + if err != nil { + api.l.Error(err) + return nil, err + } + + api.l.Infof("retreiving screenshot for %s/%s", tweet.User.ScreenName, tweet.ID) + + select { + case data := <-ch: + api.l.Info(string(data)) + + var screenshot TweetScreenshot + err = json.Unmarshal(data, &screenshot) + if err != nil { + api.l.Error(err) + return nil, err + } + + return &screenshot, nil + + case <-time.After(1 * time.Minute): + return nil, fmt.Errorf("screenshot timeout for %s/%s", tweet.User.ScreenName, tweet.ID) + } +} diff --git a/api/twitter_media_factory.go b/api/twitter_media_factory.go new file mode 100644 index 0000000..0274e51 --- /dev/null +++ b/api/twitter_media_factory.go @@ -0,0 +1,75 @@ +package api + +import ( + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateTwitterMediaFactory(l core.ILogger, t core.ITask) *TwitterMediaFactory { + return &TwitterMediaFactory{l, CreateTwitterAPI(l, t)} +} + +type TwitterMediaFactory struct { + l core.ILogger + api *TwitterAPI +} + +// CreateMedia is a core.IMediaFactory interface implementation +func (tmf *TwitterMediaFactory) CreateMedia(tweetID string) ([]*core.Media, error) { + tweet, err := tmf.api.getTweetByID(tweetID) + if err != nil { + tmf.l.Error(err) + return nil, err + } + + url := "https://twitter.com/" + tweet.User.ScreenName + "/status/" + tweet.ID + media := tweet.ExtendedEntities.Media + + if len(media) == 0 && tweet.QuotedStatus != nil && len(tweet.QuotedStatus.ExtendedEntities.Media) > 0 { + media = tweet.QuotedStatus.ExtendedEntities.Media + tmf.l.Warningf("tweet media is empty, using QuotedStatus instead %s", tweet.ID) + } + + switch len(media) { + case 0: + screenshot, err := tmf.api.getScreenshot(tweet) + if err != nil { + tmf.l.Error(err) + return []*core.Media{{URL: url, Title: tweet.User.Name, Description: tweet.FullText, Type: core.TText}}, nil + } + return []*core.Media{{ResourceURL: screenshot.URL, URL: url, Title: tweet.User.Name, Description: "", Type: core.TPhoto}}, nil + case 1: + if media[0].Type == "video" || media[0].Type == "animated_gif" { + //TODO: Codec ?? + return []*core.Media{{ + ResourceURL: media[0].VideoInfo.best().URL, + URL: url, Title: tweet.User.Name, + Description: tweet.FullText, + Type: core.TVideo, + }}, nil + } else if media[0].Type == "photo" { + return []*core.Media{{ + ResourceURL: media[0].MediaUrlHttps, + URL: url, Title: tweet.User.Name, + Description: tweet.FullText, + Type: core.TPhoto, + }}, nil + } else { + return nil, fmt.Errorf("unexpected type: %s", media[0].Type) + } + default: + // t.sendAlbum(media, tweet, m) + medias := []*core.Media{} + for _, m := range media { + medias = append(medias, &core.Media{ + ResourceURL: m.MediaUrlHttps, + URL: url, + Title: tweet.User.Name, + Description: tweet.FullText, + Type: core.TPhoto, + }) + } + return medias, nil + } +} diff --git a/api/youtube_media_factory.go b/api/youtube_media_factory.go new file mode 100644 index 0000000..06b19b7 --- /dev/null +++ b/api/youtube_media_factory.go @@ -0,0 +1,150 @@ +package api + +import ( + "fmt" + "os" + "os/exec" + "path" + "strings" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateYoutubeMediaFactory(l core.ILogger, api YoutubeApi, fd core.IFileDownloader) *YoutubeMediaFactory { + return &YoutubeMediaFactory{l, api, fd} +} + +type YoutubeMediaFactory struct { + l core.ILogger + api YoutubeApi + fd core.IFileDownloader +} + +// CreateMedia is a core.IMediaFactory interface implementation +func (y *YoutubeMediaFactory) CreateMedia(url string) ([]*core.Media, error) { + resp, err := y.api.get(url) + if err != nil { + y.l.Error(err) + return nil, err + } + + video, audio, err := y.getFormats(resp) + if err != nil { + y.l.Error(err) + return nil, err + } + + return []*core.Media{ + { + URL: video.Url, + Title: resp.Title, + Description: resp.Description, + Duration: int(resp.Duration), + Codec: video.Vcodec, + Size: int(video.Filesize), + Type: core.TVideo, + }, + { + URL: audio.Url, + Title: resp.Title, + Description: resp.Description, + Duration: int(resp.Duration), + Codec: audio.Acodec, + Size: int(audio.Filesize), + Type: core.TAudio, + }, + }, nil +} + +func (y *YoutubeMediaFactory) getFormats(resp *YtDlpResponse) (*YtDlpFormat, *YtDlpFormat, error) { + audio, err := resp.audioFormat() + if err != nil { + y.l.Error(err) + return nil, nil, err + } + + video, err := resp.videoFormat(50_000_000 - audio.Filesize) + if err != nil { + y.l.Error(err) + return nil, nil, err + } + return video, audio, nil +} + +// CreateVideo is a core.IVideoFactory interface implementation +func (y *YoutubeMediaFactory) CreateVideo(id string) (*core.Video, error) { + resp, err := y.api.get(id) + if err != nil { + y.l.Error(err) + return nil, err + } + + video, audio, err := y.getFormats(resp) + if err != nil { + y.l.Error(err) + return nil, err + } + + name := fmt.Sprintf("youtube[%s][%s][%s].mp4", resp.Id, video.FormatNote, audio.FormatId) + videoPath := path.Join(os.TempDir(), name) + + cmd := fmt.Sprintf("yt-dlp -f %s+%s https://youtu.be/%s -o %s", video.FormatId, audio.FormatId, resp.Id, videoPath) + y.l.Info(strings.ReplaceAll(cmd, os.TempDir(), "$TMPDIR/")) + out, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput() + if err != nil { + y.l.Error(err) + return nil, fmt.Errorf(string(out)) + } + + thumb, err := y.getThumb(resp, video) + if err != nil { + return nil, err + } + + return &core.Video{ + File: core.File{Name: name, Path: videoPath, Size: video.Filesize + audio.Filesize}, + Width: video.Width, + Height: video.Height, + Bitrate: 0, + Duration: int(resp.Duration), + Codec: video.Vcodec, + Thumb: thumb, + }, nil +} + +func (y *YoutubeMediaFactory) getThumb(resp *YtDlpResponse, vf *YtDlpFormat) (*core.Image, error) { + filename := fmt.Sprintf("youtube[%s][%s].jpg", resp.Id, vf.FormatId) + originalThumbPath := path.Join(os.TempDir(), filename+"-original") + thumbPath := path.Join(os.TempDir(), filename) + file, err := y.fd.Download(resp.Thumbnail, originalThumbPath) + if err != nil { + y.l.Error(err) + return nil, err + } + defer file.Dispose() + + scale := "320:-1" + if vf.Width < vf.Height { + scale = "-1:320" + } + + cmd := fmt.Sprintf(`ffmpeg -v error -y -i "%s" -vf scale=%s "%s"`, originalThumbPath, scale, thumbPath) + out, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput() + if err != nil { + y.l.Error(err) + y.l.Error(string(out)) + return nil, err + } + + stat, err := os.Stat(thumbPath) + if err != nil { + y.l.Error(err) + return nil, err + } + + return &core.Image{ + File: core.File{Name: filename, Path: thumbPath, Size: stat.Size()}, + Width: vf.Width, + Height: vf.Height, + }, nil +} diff --git a/api/ytdlp_api.go b/api/ytdlp_api.go new file mode 100644 index 0000000..6a083ba --- /dev/null +++ b/api/ytdlp_api.go @@ -0,0 +1,107 @@ +package api + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +type YoutubeApi interface { + get(string) (*YtDlpResponse, error) +} + +func CreateYtDlpApi(l core.ILogger) YoutubeApi { + return &YtDlpApi{l} +} + +type YtDlpApi struct { + l core.ILogger +} + +func (api *YtDlpApi) get(url string) (*YtDlpResponse, error) { + cmd := fmt.Sprintf(`yt-dlp --quiet --no-warnings --dump-json %s`, url) + out, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput() + if err != nil { + api.l.Error(err) + return nil, fmt.Errorf(string(out)) + } + + var resp YtDlpResponse + err = json.Unmarshal(out, &resp) + if err != nil { + api.l.Error(err) + return nil, err + } + return &resp, nil +} + +type YtDlpResponse struct { + Id string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Creator string `json:"creator,omitempty"` // tiktok + Track string `json:"track,omitempty"` // tiktok + Artist string `json:"artist,omitempty"` // tiktok + Duration float64 `json:"duration"` + ExtractorKey string `json:"extractor_key"` + Thumbnail string `json:"thumbnail"` + Uploader string `json:"uploader"` + Url string `json:"url,omitempty"` + Formats []*YtDlpFormat `json:"formats"` +} + +type YtDlpFormat struct { + Container string `json:"container"` + Ext string `json:"ext"` + FormatId string `json:"format_id"` + Format string `json:"format"` + FormatNote string `json:"format_note"` + Filesize int64 `json:"filesize,omitempty"` + Height int `json:"height"` + Width int `json:"width"` + Acodec string `json:"acodec"` + Vcodec string `json:"vcodec"` + Url string `json:"url"` +} + +func (v YtDlpResponse) audioFormat() (*YtDlpFormat, error) { + var original *YtDlpFormat + for _, f := range v.Formats { + if f.FormatId == "140" { + return f, nil + } + if f.Container == "m4a_dash" && strings.Contains(f.Format, "original (default), medium") { + original = f + } + } + + if original != nil { + return original, nil + } + + return nil, fmt.Errorf("140 not found for %s", v.Id) +} + +func (y *YtDlpResponse) videoFormat(limit int64) (*YtDlpFormat, error) { + n := -1 + for i, f := range y.Formats { + if f.Filesize > 0 && f.Filesize < limit && strings.HasPrefix(f.Vcodec, "avc1") && (n == -1 || y.Formats[n].Filesize < f.Filesize) { + n = i + } + } + + if n < 0 { + // the smallest `mp4` video format + for _, f := range y.Formats { + if f.FormatId == "134" { + return f, nil + } + } + return nil, fmt.Errorf("appropriate video format not found") + } + + return y.Formats[n], nil +} diff --git a/core/bot.go b/core/bot.go new file mode 100644 index 0000000..bc951f1 --- /dev/null +++ b/core/bot.go @@ -0,0 +1,16 @@ +package core + +// IBot represents abstract bot interface +type IBot interface { + Delete(*Message) error + Edit(*Message, interface{}, ...interface{}) (*Message, error) + SendText(string, ...interface{}) (*Message, error) + SendImage(*Image, string) (*Message, error) + SendAlbum([]*Image) ([]*Message, error) + SendMedia(*Media) (*Message, error) + SendPhotoAlbum([]*Media) ([]*Message, error) + SendVideo(*Video, string) (*Message, error) + IsUserMemberOfChat(*User, ChatID) bool + GetCommands(ChatID) ([]Command, error) + SetCommands(ChatID, []Command) error +} diff --git a/core/button.go b/core/button.go new file mode 100644 index 0000000..cc2102a --- /dev/null +++ b/core/button.go @@ -0,0 +1,14 @@ +package core + +type Keyboard = [][]*Button + +type IButtonHandler interface { + GetButtonIds() []string + ButtonPressed(*Button, *Message, *User, IBot) error +} + +type Button struct { + ID string + Text string + Payload string +} diff --git a/core/chat.go b/core/chat.go new file mode 100644 index 0000000..487f519 --- /dev/null +++ b/core/chat.go @@ -0,0 +1,9 @@ +package core + +type ChatID = int64 + +type Chat struct { + ID ChatID + Title string + Type string +} diff --git a/core/chat_storage.go b/core/chat_storage.go new file mode 100644 index 0000000..6e26190 --- /dev/null +++ b/core/chat_storage.go @@ -0,0 +1,6 @@ +package core + +type IChatStorage interface { + GetChatByID(ChatID) (*Chat, error) + CreateChat(ChatID, string, string) error +} diff --git a/core/command.go b/core/command.go new file mode 100644 index 0000000..831e292 --- /dev/null +++ b/core/command.go @@ -0,0 +1,15 @@ +package core + +type Command struct { + Text string + Description string +} + +func DefaultCommands() []Command { + return []Command{{Text: "help", Description: ""}} +} + +type ICommandService interface { + EnableCommands(ChatID, []Command, IBot) error + DisableCommands(ChatID, []Command, IBot) error +} diff --git a/core/document.go b/core/document.go new file mode 100644 index 0000000..83f4bf7 --- /dev/null +++ b/core/document.go @@ -0,0 +1,7 @@ +package core + +// Document ... +type Document struct { + File + MIME string +} diff --git a/core/file.go b/core/file.go new file mode 100644 index 0000000..a76a3fd --- /dev/null +++ b/core/file.go @@ -0,0 +1,15 @@ +package core + +import "os" + +// File ... +type File struct { + Name string + Path string + Size int64 +} + +// Dispose for filesystem cleanup +func (f *File) Dispose() error { + return os.Remove(f.Path) +} diff --git a/core/game_storage.go b/core/game_storage.go new file mode 100644 index 0000000..b71d0df --- /dev/null +++ b/core/game_storage.go @@ -0,0 +1,10 @@ +package core + +// IGameStorage is an abstract interface for game players and results handling +type IGameStorage interface { + GetPlayers(ChatID) ([]*User, error) + GetRounds(ChatID) ([]*Round, error) + AddPlayer(ChatID, *User) error + UpdatePlayer(ChatID, *User) error + AddRound(ChatID, *Round) error +} diff --git a/core/handlers.go b/core/handlers.go new file mode 100644 index 0000000..96d7e26 --- /dev/null +++ b/core/handlers.go @@ -0,0 +1,21 @@ +package core + +// IDocumentHandler responds to documents sent in chah +type IDocumentHandler interface { + HandleDocument(*Document, *Message, IBot) error +} + +// ITextHandler responds to all the text messages +type ITextHandler interface { + HandleText(*Message, IBot) error +} + +// IImageHandler responds to images +type IImageHandler interface { + HandleImage(*Image, *Message, IBot) error +} + +// IVideoHandler responds to videos +type IVideoHandler interface { + HandleVideo(*Video, *Message, IBot) error +} diff --git a/core/image.go b/core/image.go new file mode 100644 index 0000000..39c47d2 --- /dev/null +++ b/core/image.go @@ -0,0 +1,10 @@ +package core + +// Image represents remote image file that can be also downloaded +type Image struct { + File + ID string + FileURL string + Width int + Height int +} diff --git a/core/localizer.go b/core/localizer.go new file mode 100644 index 0000000..c4f1201 --- /dev/null +++ b/core/localizer.go @@ -0,0 +1,7 @@ +package core + +// ILocalizer for localization +type ILocalizer interface { + I18n(string, string, ...interface{}) string + AllKeys() []string +} diff --git a/core/logger.go b/core/logger.go new file mode 100644 index 0000000..98bb1c4 --- /dev/null +++ b/core/logger.go @@ -0,0 +1,12 @@ +package core + +// ILogger for logging +type ILogger interface { + Close() + Error(...interface{}) + Errorf(string, ...interface{}) + Info(...interface{}) + Infof(string, ...interface{}) + Warning(...interface{}) + Warningf(string, ...interface{}) +} diff --git a/core/media.go b/core/media.go new file mode 100644 index 0000000..86118d9 --- /dev/null +++ b/core/media.go @@ -0,0 +1,40 @@ +package core + +// URL ... +type URL = string + +// MediaType ... +type MediaType int + +const ( + // Video media type + TVideo MediaType = iota + // Photo media type + TPhoto + // Text media type + TText + // Audio media type + TAudio +) + +// Media ... +type Media struct { + ResourceURL URL + URL URL + Title string + Description string + Caption string + Duration int // video only + Codec string // video only + Size int + Type MediaType +} + +// IMediaFactory creates Media from URL +type IMediaFactory interface { + CreateMedia(URL) ([]*Media, error) +} + +type ISendMediaStrategy interface { + SendMedia([]*Media, IBot) error +} diff --git a/core/message.go b/core/message.go new file mode 100644 index 0000000..c1860d9 --- /dev/null +++ b/core/message.go @@ -0,0 +1,12 @@ +package core + +// Message from chat +type Message struct { + ID int + Chat *Chat + IsPrivate bool + Sender *User + Text string + ReplyTo *Message + Video *Video +} diff --git a/core/networking.go b/core/networking.go new file mode 100644 index 0000000..83d6648 --- /dev/null +++ b/core/networking.go @@ -0,0 +1,24 @@ +package core + +// IFileDownloader turns URL to File +type IFileDownloader interface { + Download(URL, string) (*File, error) +} + +// IFileUploader turns File to URL +type IFileUploader interface { + Upload(*File) (URL, error) +} + +// IImageDownloader download Image to disk +type IImageDownloader interface { + Download(image *Image) (*File, error) +} + +// IHttpClient retreives remote content info +type IHttpClient interface { + GetContentType(URL) (string, error) + GetContent(URL) (string, error) + GetRedirectLocation(url URL) (URL, error) + SetHeader(string, string) +} diff --git a/core/rand.go b/core/rand.go new file mode 100644 index 0000000..8ae652a --- /dev/null +++ b/core/rand.go @@ -0,0 +1,5 @@ +package core + +type IRand interface { + GetRand(int) int +} diff --git a/core/round.go b/core/round.go new file mode 100644 index 0000000..0e852b9 --- /dev/null +++ b/core/round.go @@ -0,0 +1,7 @@ +package core + +// Round is a single game result +type Round struct { + Day string + Winner *User +} diff --git a/core/send_video_strategy.go b/core/send_video_strategy.go new file mode 100644 index 0000000..c58a95e --- /dev/null +++ b/core/send_video_strategy.go @@ -0,0 +1,5 @@ +package core + +type ISendVideoStrategy interface { + SendVideo(*Video, string, IBot) error +} diff --git a/core/settings.go b/core/settings.go new file mode 100644 index 0000000..1833c86 --- /dev/null +++ b/core/settings.go @@ -0,0 +1,16 @@ +package core + +type SettingKey string + +const ( + SFaggotGameEnabled SettingKey = "faggot_game" + SInstagramFlowEnabled SettingKey = "instagram_flow" + SInstagramFlowRemoveSource SettingKey = "instagram_flow_remove_source" + SLinkFlowEnabled SettingKey = "link_flow" + SLinkFlowRemoveSource SettingKey = "link_flow_remove_source" + SPayloadList SettingKey = "payload_list" + STwitterFlowEnabled SettingKey = "twitter_flo" + STwitterFlowRemoveSource SettingKey = "twitter_flow_remove_source" + SYoutubeFlowEnabled SettingKey = "youtube_flow" + SYoutubeFlowRemoveSource SettingKey = "youtube_flow_remove_source" +) diff --git a/core/settings_provider.go b/core/settings_provider.go new file mode 100644 index 0000000..1933585 --- /dev/null +++ b/core/settings_provider.go @@ -0,0 +1,11 @@ +package core + +type ISettingsProvider interface { + GetData(ChatID, SettingKey) ([]byte, error) + SetData(ChatID, SettingKey, []byte) error +} + +type IBoolSettingProvider interface { + GetBool(ChatID, SettingKey) bool + SetBool(ChatID, SettingKey, bool) error +} diff --git a/core/task.go b/core/task.go new file mode 100644 index 0000000..4640921 --- /dev/null +++ b/core/task.go @@ -0,0 +1,9 @@ +package core + +type ITaskFactory interface { + NewTask(string) ITask +} + +type ITask interface { + Perform([]byte, chan []byte) error +} diff --git a/core/user.go b/core/user.go new file mode 100644 index 0000000..723cccb --- /dev/null +++ b/core/user.go @@ -0,0 +1,17 @@ +package core + +// User ... +type User struct { + ID int64 + FirstName string + LastName string + Username string + LanguageCode string +} + +func (u *User) DisplayName() string { + if len(u.Username) == 0 { + return u.FirstName + " " + u.LastName + } + return u.Username +} diff --git a/core/user_storage.go b/core/user_storage.go new file mode 100644 index 0000000..22fa54b --- /dev/null +++ b/core/user_storage.go @@ -0,0 +1,6 @@ +package core + +type IUserStorage interface { + GetUserById(int64) (*User, error) + CreateUser(*User) error +} diff --git a/core/video.go b/core/video.go new file mode 100644 index 0000000..9550956 --- /dev/null +++ b/core/video.go @@ -0,0 +1,21 @@ +package core + +import "os" + +// Video ... +type Video struct { + File + ID string + Width int + Height int + Bitrate int + Duration int + Codec string + Thumb *Image +} + +// Dispose to cleanup filesystem +func (vf *Video) Dispose() { + os.Remove(vf.Path) + os.Remove(vf.Thumb.Path) +} diff --git a/core/video_converter.go b/core/video_converter.go new file mode 100644 index 0000000..5c7622b --- /dev/null +++ b/core/video_converter.go @@ -0,0 +1,7 @@ +package core + +// IVideoConverter convert Video with specified bitrate +type IVideoConverter interface { + GetCodec(string) string + Convert(*Video, int) (*Video, error) +} diff --git a/core/video_factory.go b/core/video_factory.go new file mode 100644 index 0000000..51a59d7 --- /dev/null +++ b/core/video_factory.go @@ -0,0 +1,6 @@ +package core + +// IVideoFactory retreives video file parameters from file on disk +type IVideoFactory interface { + CreateVideo(path string) (*Video, error) +} diff --git a/core/video_splitter.go b/core/video_splitter.go new file mode 100644 index 0000000..9d4bef3 --- /dev/null +++ b/core/video_splitter.go @@ -0,0 +1,6 @@ +package core + +// IVideoSplitter convert Video with specified bitrate +type IVideoSplitter interface { + Split(*Video, int) ([]*Video, error) +} diff --git a/core/vpn.go b/core/vpn.go new file mode 100644 index 0000000..1dd52f3 --- /dev/null +++ b/core/vpn.go @@ -0,0 +1,14 @@ +package core + +type VpnKey struct { + ID string + ChatID ChatID + Title string + Key string +} + +type IVpnAPI interface { + GetKeys(ChatID) ([]*VpnKey, error) + CreateKey(ChatID, string) (*VpnKey, error) + DeleteKey(*VpnKey) error +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..33d3a5f --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/ailinykh/pullanusbot/v2 + +go 1.17 + +require ( + github.com/google/logger v1.1.1 + github.com/google/uuid v1.3.0 + github.com/streadway/amqp v1.0.0 + github.com/stretchr/testify v1.7.1 + gopkg.in/telebot.v3 v3.0.0 + gorm.io/driver/sqlite v1.3.1 + gorm.io/gorm v1.23.3 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-sqlite3 v1.14.12 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7338799 --- /dev/null +++ b/go.sum @@ -0,0 +1,71 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= +github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ= +github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0= +github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mengzhuo/cookiestxt v1.0.2 h1:Z7l8FzDRpCoaFd5gmtluu8MuspOn6mIaIZwcbtIzajM= +github.com/mengzhuo/cookiestxt v1.0.2/go.mod h1:hK5Q6nTJi1tZ0x1Sj3kuxPYpdDPVxF0m+1ebSgBheSs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo= +github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/telebot.v3 v3.0.0 h1:UgHIiE/RdjoDi6nf4xACM7PU3TqiPVV9vvTydCEnrTo= +gopkg.in/telebot.v3 v3.0.0/go.mod h1:7rExV8/0mDDNu9epSrDm/8j22KLaActH1Tbee6YjzWg= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.3.1 h1:bwfE+zTEWklBYoEodIOIBwuWHpnx52Z9zJFW5F33WLk= +gorm.io/driver/sqlite v1.3.1/go.mod h1:wJx0hJspfycZ6myN38x1O/AqLtNS6c5o9TndewFbELg= +gorm.io/gorm v1.23.1/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.23.3 h1:jYh3nm7uLZkrMVfA8WVNjDZryKfr7W+HTlInVgKFJAg= +gorm.io/gorm v1.23.3/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= diff --git a/helpers/bool_setting_provider.go b/helpers/bool_setting_provider.go new file mode 100644 index 0000000..5f08180 --- /dev/null +++ b/helpers/bool_setting_provider.go @@ -0,0 +1,43 @@ +package helpers + +import ( + "encoding/json" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateBoolSettingProvider(settingsProvider core.ISettingsProvider) core.IBoolSettingProvider { + return &BoolSettingProvider{settingsProvider} +} + +type BoolSettingProvider struct { + settingsProvider core.ISettingsProvider +} + +func (provider *BoolSettingProvider) GetBool(chatID core.ChatID, key core.SettingKey) bool { + data, _ := provider.settingsProvider.GetData(chatID, key) + + var settings struct { + Enabled bool + } + + err := json.Unmarshal(data, &settings) + if err != nil { + return false + } + + return settings.Enabled +} + +func (provider *BoolSettingProvider) SetBool(chatID core.ChatID, key core.SettingKey, value bool) error { + var settings struct { + Enabled bool + } + settings.Enabled = value + data, err := json.Marshal(settings) + if err != nil { + return err + } + + return provider.settingsProvider.SetData(chatID, key, data) +} diff --git a/helpers/convert_media_strategy.go b/helpers/convert_media_strategy.go new file mode 100644 index 0000000..37d0533 --- /dev/null +++ b/helpers/convert_media_strategy.go @@ -0,0 +1,90 @@ +package helpers + +import ( + "os" + "path" + "strings" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateConvertMediaStrategy(l core.ILogger, sms core.ISendMediaStrategy, fd core.IFileDownloader, vf core.IVideoFactory, vc core.IVideoConverter) *ConvertMediaStrategy { + return &ConvertMediaStrategy{l, sms, fd, vf, vc} +} + +type ConvertMediaStrategy struct { + l core.ILogger + sms core.ISendMediaStrategy + fd core.IFileDownloader + vf core.IVideoFactory + vc core.IVideoConverter +} + +// SendMedia is a core.ISendMediaStrategy interface implementation +func (cms *ConvertMediaStrategy) SendMedia(media []*core.Media, bot core.IBot) error { + for _, m := range media { + if cms.needToConvert(m) { + cms.l.Infof("expected mp4/h264 codec, but got %s", m.Codec) + return cms.fallbackToConverting(m, bot) + } + } + return cms.sms.SendMedia(media, bot) +} + +func (cms *ConvertMediaStrategy) needToConvert(media *core.Media) bool { + if media.Type != core.TVideo { + return false + } + + for _, codec := range []string{"mp4", "h264"} { + if media.Codec == codec { + return false + } + } + return true +} + +func (cms *ConvertMediaStrategy) fallbackToConverting(media *core.Media, bot core.IBot) error { + cms.l.Info("send by converting") + file, err := cms.downloadMedia(media) + if err != nil { + return err + } + defer file.Dispose() + + vf, err := cms.vf.CreateVideo(file.Path) + if err != nil { + cms.l.Errorf("can't create video file for %s, %v", file.Path, err) + return err + } + defer vf.Dispose() + + vfc, err := cms.vc.Convert(vf, 0) + if err != nil { + cms.l.Errorf("cant convert video file: %v", err) + return err + } + defer vfc.Dispose() + + _, err = bot.SendVideo(vfc, media.Caption) + return err +} + +func (cms *ConvertMediaStrategy) downloadMedia(media *core.Media) (*core.File, error) { + //TODO: duplicated code + filename := path.Base(media.ResourceURL) + if strings.Contains(filename, "?") { + parts := strings.Split(media.ResourceURL, "?") + filename = path.Base(parts[0]) + } + mediaPath := path.Join(os.TempDir(), filename) + file, err := cms.fd.Download(media.ResourceURL, mediaPath) + if err != nil { + cms.l.Errorf("video download error: %v", err) + return nil, err + } + + cms.l.Infof("file downloaded: %s %0.2fMB", file.Name, float64(file.Size)/1024/1024) + + return file, nil +} diff --git a/helpers/download_video_factory.go b/helpers/download_video_factory.go new file mode 100644 index 0000000..ca09b43 --- /dev/null +++ b/helpers/download_video_factory.go @@ -0,0 +1,42 @@ +package helpers + +import ( + "os" + "path" + "strings" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateDownloadVideoFactory(l core.ILogger, fileDownloader core.IFileDownloader, videoFactory core.IVideoFactory) core.IVideoFactory { + return &DownloadVideoFactory{l, fileDownloader, videoFactory} +} + +type DownloadVideoFactory struct { + l core.ILogger + fileDownloader core.IFileDownloader + videoFactory core.IVideoFactory +} + +// CreateVideo is a core.IVideoFactory interface implementation +func (factory *DownloadVideoFactory) CreateVideo(url string) (*core.Video, error) { + filename := path.Base(url) + if strings.Contains(filename, "?") { + parts := strings.Split(url, "?") + filename = path.Base(parts[0]) + } + + if !strings.HasSuffix(filename, ".mp4") { + filename = filename + ".mp4" + } + + videoPath := path.Join(os.TempDir(), filename) + file, err := factory.fileDownloader.Download(url, videoPath) + if err != nil { + factory.l.Error(err) + file.Dispose() + return nil, err + } + factory.l.Infof("file downloaded: %s %0.2fMB", file.Name, float64(file.Size)/1024/1024) + return factory.videoFactory.CreateVideo(file.Path) +} diff --git a/helpers/rand_string_runes.go b/helpers/rand_string_runes.go new file mode 100644 index 0000000..2efbb1a --- /dev/null +++ b/helpers/rand_string_runes.go @@ -0,0 +1,18 @@ +package helpers + +import ( + "math/rand" + "time" +) + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +// RandStringRunes returns a random n-length string +func RandStringRunes(n int) string { + rand.Seed(time.Now().UTC().UnixNano()) + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} diff --git a/helpers/send_media_strategy.go b/helpers/send_media_strategy.go new file mode 100644 index 0000000..4d7e370 --- /dev/null +++ b/helpers/send_media_strategy.go @@ -0,0 +1,28 @@ +package helpers + +import ( + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateSendMediaStrategy(l core.ILogger) *SendMediaStrategy { + return &SendMediaStrategy{l} +} + +type SendMediaStrategy struct { + l core.ILogger +} + +// SendMedia is a core.ISendMediaStrategy interface implementation +func (sms *SendMediaStrategy) SendMedia(media []*core.Media, bot core.IBot) error { + switch len(media) { + case 0: + sms.l.Warning("Unexpected empty media") + case 1: + _, err := bot.SendMedia(media[0]) + return err + default: + _, err := bot.SendPhotoAlbum(media) + return err + } + return nil +} diff --git a/helpers/send_media_strategy_test.go b/helpers/send_media_strategy_test.go new file mode 100644 index 0000000..0b6b5f0 --- /dev/null +++ b/helpers/send_media_strategy_test.go @@ -0,0 +1,45 @@ +package helpers_test + +import ( + "testing" + + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/helpers" + "github.com/ailinykh/pullanusbot/v2/test_helpers" + "github.com/stretchr/testify/assert" +) + +func Test_SendMedia_DoesNotFailOnEmptyMedia(t *testing.T) { + strategy, bot := makeMediaStrategySUT() + media := []*core.Media{} + + strategy.SendMedia(media, bot) + + assert.Equal(t, []string{}, bot.SentMedias) +} + +func Test_SendMedia_SendsASingleMediaTroughABot(t *testing.T) { + strategy, bot := makeMediaStrategySUT() + media := []*core.Media{{ResourceURL: "https://a-url.com"}} + + strategy.SendMedia(media, bot) + + assert.Equal(t, []string{"https://a-url.com"}, bot.SentMedias) +} + +func Test_SendMedia_SendsAGroupMediaTroughABot(t *testing.T) { + strategy, bot := makeMediaStrategySUT() + media := []*core.Media{{ResourceURL: "https://a-url.com"}, {ResourceURL: "https://another-url.com"}} + + strategy.SendMedia(media, bot) + + assert.Equal(t, []string{"https://a-url.com", "https://another-url.com"}, bot.SentMedias) +} + +// Helpers +func makeMediaStrategySUT() (core.ISendMediaStrategy, *test_helpers.FakeBot) { + logger := test_helpers.CreateLogger() + strategy := helpers.CreateSendMediaStrategy(logger) + bot := test_helpers.CreateBot() + return strategy, bot +} diff --git a/helpers/send_multipart_video.go b/helpers/send_multipart_video.go new file mode 100644 index 0000000..6095e4e --- /dev/null +++ b/helpers/send_multipart_video.go @@ -0,0 +1,98 @@ +package helpers + +import ( + "bytes" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// FIXME: SendMultipartVideo should conform to core.ISendVideoStrategy +func CreateSendMultipartVideo(l core.ILogger, url core.URL) *SendMultipartVideo { + return &SendMultipartVideo{l, http.DefaultClient, url} +} + +type SendMultipartVideo struct { + l core.ILogger + client *http.Client + url core.URL +} + +func (strategy *SendMultipartVideo) SendVideo(video *core.Video, caption string, chatId int64) ([]byte, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + strategy.addParams(writer, map[string]interface{}{ + "caption": caption, + "duration": video.Duration, + "width": video.Width, + "height": video.Height, + "supports_streaming": "true", + "parse_mode": "HTML", + "chat_id": chatId, + "video": video.File, + "thumb": video.Thumb.File, + }) + + writer.Close() + + start := time.Now() + strategy.l.Infof("uploading %s (%.2f MB)", video.Name, float64(video.Size)/1024/1024) + r, _ := http.NewRequest("POST", strategy.url, body) + r.Header.Add("Content-Type", writer.FormDataContentType()) + res, err := strategy.client.Do(r) + if err != nil { + strategy.l.Error(err) + return nil, err + } + defer res.Body.Close() + strategy.l.Infof("successfully sent %s (%.2f MB) %s", video.Name, float64(video.Size)/1024/1024, time.Now().Sub(start)) + return ioutil.ReadAll(res.Body) +} + +func (strategy *SendMultipartVideo) addParams(writer *multipart.Writer, params map[string]interface{}) { + for key, param := range params { + var reader io.Reader + var part io.Writer + var err error + switch p := param.(type) { + case string: + part, err = writer.CreateFormField(key) + reader = strings.NewReader(p) + case int: + part, err = writer.CreateFormField(key) + reader = strings.NewReader(strconv.Itoa(p)) + case int64: + part, err = writer.CreateFormField(key) + reader = strings.NewReader(strconv.FormatInt(p, 10)) + case core.File: + file, err := os.Open(p.Path) + if err != nil { + strategy.l.Error(err) + continue + } + defer file.Close() + part, err = writer.CreateFormFile(key, file.Name()) + reader = file + default: + strategy.l.Errorf("unexpected param type %+v", p) + continue + } + + if err != nil { + strategy.l.Error(err) + continue + } + _, err = io.Copy(part, reader) + if err != nil { + strategy.l.Error(err) + } + } +} diff --git a/helpers/send_video_strategy.go b/helpers/send_video_strategy.go new file mode 100644 index 0000000..fc072d5 --- /dev/null +++ b/helpers/send_video_strategy.go @@ -0,0 +1,24 @@ +package helpers + +import ( + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateSendVideoStrategy(l core.ILogger) core.ISendVideoStrategy { + return &SendVideoStrategy{l} +} + +type SendVideoStrategy struct { + l core.ILogger +} + +// SendMedia is a core.ISendVideoStrategy interface implementation +func (strategy *SendVideoStrategy) SendVideo(video *core.Video, caption string, bot core.IBot) error { + _, err := bot.SendVideo(video, caption) + + if err != nil { + strategy.l.Error(err) + } + + return err +} diff --git a/helpers/split_video_strategy.go b/helpers/split_video_strategy.go new file mode 100644 index 0000000..edcda01 --- /dev/null +++ b/helpers/split_video_strategy.go @@ -0,0 +1,53 @@ +package helpers + +import ( + "fmt" + "regexp" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateSendVideoStrategySplitDecorator(l core.ILogger, decoratee core.ISendVideoStrategy, splitter core.IVideoSplitter) core.ISendVideoStrategy { + return &SendVideoStrategySplitDecorator{l, decoratee, splitter} +} + +type SendVideoStrategySplitDecorator struct { + l core.ILogger + decoratee core.ISendVideoStrategy + splitter core.IVideoSplitter +} + +// SendMedia is a core.ISendVideoStrategy interface implementation +func (strategy *SendVideoStrategySplitDecorator) SendVideo(video *core.Video, caption string, bot core.IBot) error { + err := strategy.decoratee.SendVideo(video, caption, bot) + if err != nil && err.Error() == "telegram: Request Entity Too Large (400)" { + strategy.l.Info("Fallback to splitting") + // https://lists.ffmpeg.org/pipermail/ffmpeg-user/2014-January/019556.html + files, err := strategy.splitter.Split(video, 49) + if err != nil { + return err + } + + for _, file := range files { + defer file.Dispose() + } + + r := regexp.MustCompile(`(.*)`) + match := r.FindStringSubmatch(caption) + + for i, file := range files { + c := caption + if len(match) > 0 { + c = r.ReplaceAllString(caption, fmt.Sprintf("[%d/%d] %s", i+1, len(files), match[1])) + } + _, err := bot.SendVideo(file, c) + if err != nil { + return err + } + } + + strategy.l.Info("All parts successfully sent") + return nil + } + return err +} diff --git a/helpers/upload_media_decorator.go b/helpers/upload_media_decorator.go new file mode 100644 index 0000000..50165a6 --- /dev/null +++ b/helpers/upload_media_decorator.go @@ -0,0 +1,83 @@ +package helpers + +import ( + "os" + "path" + "strings" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateUploadMediaDecorator(l core.ILogger, decoratee core.ISendMediaStrategy, fileDownloader core.IFileDownloader, videoFactory core.IVideoFactory, sendVideo core.ISendVideoStrategy) core.ISendMediaStrategy { + return &UploadMediaDecorator{l, decoratee, fileDownloader, videoFactory, sendVideo} +} + +type UploadMediaDecorator struct { + l core.ILogger + decoratee core.ISendMediaStrategy + fileDownloader core.IFileDownloader + videoFactory core.IVideoFactory + sendVideo core.ISendVideoStrategy +} + +// SendMedia is a core.ISendMediaStrategy interface implementation +func (decorator *UploadMediaDecorator) SendMedia(media []*core.Media, bot core.IBot) error { + err := decorator.decoratee.SendMedia(media, bot) + if err != nil { + if strings.Contains(err.Error(), "failed to get HTTP URL content") || strings.Contains(err.Error(), "wrong file identifier/HTTP URL specified") { + return decorator.fallbackToUploading(media[0], bot) + } + } + + return err +} + +func (decorator *UploadMediaDecorator) fallbackToUploading(media *core.Media, bot core.IBot) error { + decorator.l.Info("send by uploading") + file, err := decorator.downloadMedia(media) + if err != nil { + return err + } + defer file.Dispose() + + switch media.Type { + case core.TText: + decorator.l.Warning("unexpected media type") + case core.TPhoto: + image := &core.Image{File: *file} + _, err = bot.SendImage(image, media.Caption) + return err + case core.TVideo: + vf, err := decorator.videoFactory.CreateVideo(file.Path) + if err != nil { + decorator.l.Errorf("can't create video file for %s, %v", file.Path, err) + return err + } + return decorator.sendVideo.SendVideo(vf, media.Caption, bot) + } + return err +} + +func (decorator *UploadMediaDecorator) downloadMedia(media *core.Media) (*core.File, error) { + //TODO: duplicated code + filename := path.Base(media.ResourceURL) + if strings.Contains(filename, "?") { + parts := strings.Split(media.ResourceURL, "?") + filename = path.Base(parts[0]) + } + + if !strings.HasSuffix(filename, ".mp4") { + filename = filename + ".mp4" + } + + mediaPath := path.Join(os.TempDir(), filename) + file, err := decorator.fileDownloader.Download(media.ResourceURL, mediaPath) + if err != nil { + decorator.l.Errorf("video download error: %v", err) + return nil, err + } + + decorator.l.Infof("file downloaded: %s %0.2fMB", file.Name, float64(file.Size)/1024/1024) + + return file, nil +} diff --git a/helpers/upload_media_decorator_test.go b/helpers/upload_media_decorator_test.go new file mode 100644 index 0000000..ebb290b --- /dev/null +++ b/helpers/upload_media_decorator_test.go @@ -0,0 +1,52 @@ +package helpers_test + +import ( + "fmt" + "testing" + + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/helpers" + "github.com/ailinykh/pullanusbot/v2/test_helpers" + "github.com/stretchr/testify/assert" +) + +func Test_UploadMedia_DoesNotFailOnEmptyMedia(t *testing.T) { + strategy, _, bot := makeUploadMediaDecoratorSUT() + media := []*core.Media{} + + strategy.SendMedia(media, bot) + + assert.Equal(t, []string{}, bot.SentMedias) +} + +func Test_UploadMedia_DoesNotFallbackOnGenericError(t *testing.T) { + strategy, proxy, bot := makeUploadMediaDecoratorSUT() + media := []*core.Media{} + proxy.Err = fmt.Errorf("an error") + + err := strategy.SendMedia(media, bot) + + assert.Equal(t, proxy.Err, err) +} + +func Test_UploadMedia_FallbackOnSpecificError(t *testing.T) { + strategy, proxy, bot := makeUploadMediaDecoratorSUT() + media := []*core.Media{{ResourceURL: "https://a-url.com"}} + proxy.Err = fmt.Errorf("failed to get HTTP URL content") + + err := strategy.SendMedia(media, bot) + + assert.Equal(t, nil, err) +} + +// Helpers +func makeUploadMediaDecoratorSUT() (core.ISendMediaStrategy, *test_helpers.FakeSendMediaStrategy, *test_helpers.FakeBot) { + logger := test_helpers.CreateLogger() + send_media_strategy := test_helpers.CreateSendMediaStrategy() + file_downloader := test_helpers.CreateFileDownloader() + video_factory := test_helpers.CreateVideoFactory() + send_video := test_helpers.CreateSendVideoStrategy() + strategy := helpers.CreateUploadMediaDecorator(logger, send_media_strategy, file_downloader, video_factory, send_video) + bot := test_helpers.CreateBot() + return strategy, send_media_strategy, bot +} diff --git a/infrastructure/chat_storage.go b/infrastructure/chat_storage.go new file mode 100644 index 0000000..09a31a1 --- /dev/null +++ b/infrastructure/chat_storage.go @@ -0,0 +1,63 @@ +package infrastructure + +import ( + "time" + + "github.com/ailinykh/pullanusbot/v2/core" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// CreateChatStorage is a default ChatStorage factory +func CreateChatStorage(dbFile string, l core.ILogger) *ChatStorage { + conn, err := gorm.Open(sqlite.Open(dbFile+"?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Error), + }) + if err != nil { + panic(err) + } + + conn.AutoMigrate(&Chat{}) + return &ChatStorage{conn, l} +} + +// ChatStorage implements core.IChatStorage interface +type ChatStorage struct { + conn *gorm.DB + l core.ILogger +} + +type Chat struct { + ID int64 `gorm:"primaryKey"` + Title string + Type string + CreatedAt time.Time `gorm:"autoUpdateTime"` + UpdatedAt time.Time `gorm:"autoCreateTime"` +} + +// GetChatByID is a core.IChatStorage interface implementation +func (s *ChatStorage) GetChatByID(chatID int64) (*core.Chat, error) { + var chat Chat + err := s.conn.First(&chat, chatID).Error + + if err != nil { + s.l.Error(err) + return nil, err + } + + return &core.Chat{ID: chat.ID, Title: chat.Title, Type: chat.Type}, nil +} + +// CreateChat is a core.IChatStorage interface implementation +func (s *ChatStorage) CreateChat(chatID int64, title string, type_ string) error { + chat := Chat{ID: chatID, Title: title, Type: type_} + err := s.conn.Create(&chat).Error + if err != nil { + s.l.Error(err) + return err + } + + s.l.Infof("chat created: {%d %s %s}", chat.ID, chat.Title, chat.Type) + return nil +} diff --git a/infrastructure/common_localizer.go b/infrastructure/common_localizer.go new file mode 100644 index 0000000..0137ae6 --- /dev/null +++ b/infrastructure/common_localizer.go @@ -0,0 +1,66 @@ +package infrastructure + +import ( + "fmt" + "runtime" +) + +func CreateCommonLocalizer() *CommonLocalizer { + return &CommonLocalizer{ + map[string]map[string]string{"ru": { + "start_welcome": "Привет!", + "help": `Вот что я могу: + +- видео, загруженное как файл, я сконвертирую в mp4 и отправлю обратно (до 20MB) +- если прислать мне сылку на видео, я скачаю его и загружу в этот же чат как видео +- ссылки на видео в tiktok, twitter и instagram reels так же поддерживаются +- у меня можно получить доступ к /proxy для telegram (на случай, если его опять заблокируют) +- в групповых чатах ролики на youtube длиною до 10 минут я так же скачиваю и присылаю как видео +- если дать мне права на удаление сообщений, я буду удалять исходное сообщение с ссылкой +- в личном чате я могу скачать и прислать частями по 50MB любой ролик на youtube, достаточно просто прислать мне ссылку +- если прислать мне картинку, я загружу её на telegra.ph и отправлю ссылку в ответ +- функционал постоянно добавляется`, + }, "en": { + "start_welcome": "Welcome!", + "help": `Here is what i can do: + +- If you upload a video file, I will convert it to mp4 and send it back to you (up to 20MB). +- If you send me a http link to a video, I will upload it to this chat as a video file. +- Links to videos on TikTok, Twitter, and Instagram Reels are also supported. +- In group chats, I can download and send YouTube videos up to 10 minutes long as video files. +- If you assign me an admin role in a group chat with delete messages permission, I will delete the original message with the link. +- In a private chat, I can download and send any YouTube video in parts of 50MB each; just send me the link. +- If you send me an image, I will upload it to telegra.ph and send the link in response. +- Functionality is constantly being added. + `, + }}, + } +} + +// CommonLocalizer for faggot game +type CommonLocalizer struct { + langs map[string]map[string]string +} + +// I18n is a core.ILocalizer implementation +func (l *CommonLocalizer) I18n(lang, key string, args ...interface{}) string { + if val, ok := l.langs[lang][key]; ok { + return fmt.Sprintf(val, args...) + } + + if val, ok := l.langs["ru"][key]; ok { + return fmt.Sprintf(val, args...) + } + + _, file, line, _ := runtime.Caller(0) + return fmt.Sprintf("%s:%d KEY_MISSED:\"%s\"", file, line, key) +} + +// AllKeys is a core.ILocalizer implementation +func (l *CommonLocalizer) AllKeys() []string { + keys := make([]string, 0, len(l.langs["ru"])) + for k := range l.langs["ru"] { + keys = append(keys, k) + } + return keys +} diff --git a/infrastructure/ffmpeg.go b/infrastructure/ffmpeg.go new file mode 100644 index 0000000..3e710ec --- /dev/null +++ b/infrastructure/ffmpeg.go @@ -0,0 +1,35 @@ +package infrastructure + +import "fmt" + +type ffpResponse struct { + Streams []ffpStream `json:"streams"` + Format ffpFormat `json:"format"` +} + +type ffpStream struct { + Index int `json:"index"` + CodecName string `json:"codec_name"` + CodecType string `json:"codec_type"` + Width int `json:"width"` + Height int `json:"height"` + BitRate string `json:"bit_rate"` +} + +type ffpFormat struct { + Filename string `json:"filename"` + NbStreams int `json:"nb_streams"` + FormatName string `json:"format_name"` + FormatLongName string `json:"format_long_name"` + Duration string `json:"duration"` + Size string `json:"size"` +} + +func (f ffpResponse) getVideoStream() (ffpStream, error) { + for _, stream := range f.Streams { + if stream.CodecType == "video" { + return stream, nil + } + } + return ffpStream{}, fmt.Errorf("no video stream found") +} diff --git a/infrastructure/ffmpeg_converter.go b/infrastructure/ffmpeg_converter.go new file mode 100644 index 0000000..7ef4cd9 --- /dev/null +++ b/infrastructure/ffmpeg_converter.go @@ -0,0 +1,220 @@ +package infrastructure + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path" + "strconv" + "strings" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreateFfmpegConverter is a basic FfmpegConverter factory +func CreateFfmpegConverter(l core.ILogger) *FfmpegConverter { + return &FfmpegConverter{l} +} + +// FfmpegConverter implements core.IVideoConverter and core.IVideoFactory using ffmpeg +type FfmpegConverter struct { + l core.ILogger +} + +// Convert is a core.IVideoConverter interface implementation +func (c *FfmpegConverter) Convert(vf *core.Video, bitrate int) (*core.Video, error) { + path := path.Join(os.TempDir(), vf.Name+"_converted.mp4") + cmd := fmt.Sprintf(`ffmpeg -v error -y -i "%s" -pix_fmt yuv420p -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" "%s"`, vf.Path, path) + if bitrate > 0 { + cmd = fmt.Sprintf(`ffmpeg -v error -y -i "%s" -c:v libx264 -preset medium -b:v %dk -pass 1 -b:a 128k -f mp4 /dev/null && ffmpeg -v error -y -i "%s" -c:v libx264 -preset medium -b:v %dk -pass 2 -b:a 128k "%s"`, vf.Path, bitrate/1024, vf.Path, bitrate/1024, path) + } + c.l.Info(strings.ReplaceAll(cmd, os.TempDir(), "$TMPDIR/")) + out, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput() + if err != nil { + os.Remove(path) + c.l.Error(err) + return nil, fmt.Errorf(string(out)) + } + + return c.CreateVideo(path) +} + +// GetCodec is a core.IVideoConverter interface implementation +func (c *FfmpegConverter) GetCodec(path string) string { + ffprobe, err := c.getFFProbe(path) + if err != nil { + c.l.Error(err) + return "unknown" + } + + stream, err := ffprobe.getVideoStream() + if err != nil { + c.l.Error(err) + return "unknown" + } + + return stream.CodecName +} + +// CreateMedia is a core.IMediaFactory interface implementation +func (c *FfmpegConverter) CreateMedia(url string) ([]*core.Media, error) { + ffprobe, err := c.getFFProbe(url) + if err != nil { + c.l.Error(err) + return nil, err + } + + stream, err := ffprobe.getVideoStream() + if err != nil { + c.l.Error(err) + return nil, err + } + + size, err := strconv.Atoi(ffprobe.Format.Size) + if err != nil { + c.l.Warning(err) + size = 0 + } + + if ffprobe.Format.FormatName == "image2" { + return []*core.Media{{ResourceURL: url, URL: url, Codec: stream.CodecName, Size: size, Type: core.TPhoto}}, nil + } + + return []*core.Media{{ResourceURL: url, URL: url, Codec: stream.CodecName, Size: size, Type: core.TVideo}}, nil +} + +// CreateVideo is a core.IVideoSplitter interface implementation +func (c *FfmpegConverter) Split(video *core.Video, limit int) ([]*core.Video, error) { + duration, n := 0, 0 + var videos = []*core.Video{} + for duration < video.Duration { + path := fmt.Sprintf("%s[%02d].mp4", video.File.Path, n) + cmd := fmt.Sprintf(`ffmpeg -v error -y -i %s -ss %d -fs %dM -map_metadata 0 -c copy %s`, video.File.Path, duration, limit, path) + c.l.Info(strings.ReplaceAll(cmd, os.TempDir(), "$TMPDIR/")) + out, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput() + if err != nil { + c.l.Error(err) + os.Remove(path) + return nil, fmt.Errorf(string(out)) + } + + file, err := c.CreateVideo(path) + if err != nil { + c.l.Error(err) + os.Remove(path) + if err.Error() == "file is too short" { + // the last piece might be shorter than a second - https://youtu.be/1MLRCczBKn8 + // of have a black screen - https://youtu.be/TQ2szA18aEc + duration += 10 + continue + } else { + return nil, err + } + } + // defer file.Dispose() + + videos = append(videos, file) + duration += file.Duration + n++ + } + return videos, nil +} + +// CreateVideo is a core.IVideoFactory interface implementation +func (c *FfmpegConverter) CreateVideo(path string) (*core.Video, error) { + c.l.Infof("create video: %s", strings.ReplaceAll(path, os.TempDir(), "$TMPDIR/")) + ffprobe, err := c.getFFProbe(path) + if err != nil { + c.l.Error(err) + return nil, err + } + + duration, err := strconv.ParseFloat(ffprobe.Format.Duration, 32) + if err != nil { + c.l.Error(err) + return nil, err + } + + if duration < 10 { + c.l.Errorf("expected duration at least 10 seconds, got %f", duration) + return nil, fmt.Errorf("file is too short") + } + + stream, err := ffprobe.getVideoStream() + if err != nil { + c.l.Error(err) + return nil, err + } + + scale := "320:-1" + if stream.Width < stream.Height { + scale = "-1:320" + } + + thumb, err := c.createThumb(path, scale) + if err != nil { + c.l.Error(err) + return nil, err + } + + bitrate, _ := strconv.Atoi(stream.BitRate) // empty for .gif + + stat, err := os.Stat(path) + if err != nil { + c.l.Error(err) + return nil, err + } + + return &core.Video{ + File: core.File{Name: stat.Name(), Path: path, Size: stat.Size()}, + Width: stream.Width, + Height: stream.Height, + Bitrate: bitrate, + Duration: int(duration), + Codec: stream.CodecName, + Thumb: thumb}, nil +} + +func (c *FfmpegConverter) getFFProbe(file string) (*ffpResponse, error) { + cmd := fmt.Sprintf(`ffprobe -v panic -of json -show_streams -show_format "%s"`, file) + c.l.Info(strings.ReplaceAll(cmd, os.TempDir(), "$TMPDIR/")) + out, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput() + if err != nil { + c.l.Error(err) + return nil, fmt.Errorf(string(out)) + } + + var resp ffpResponse + err = json.Unmarshal(out, &resp) + if err != nil { + c.l.Error(err) + return nil, err + } + + return &resp, nil +} + +func (c *FfmpegConverter) createThumb(videoPath string, scale string) (*core.Image, error) { + thumbPath := videoPath + ".jpg" + + cmd := fmt.Sprintf(`ffmpeg -v error -y -i "%s" -ss 00:00:01.000 -vframes 1 -filter:v scale="%s" "%s"`, videoPath, scale, thumbPath) + c.l.Info(strings.ReplaceAll(cmd, os.TempDir(), "$TMPDIR/")) + out, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput() + if err != nil { + c.l.Error(err) + return nil, fmt.Errorf(string(out)) + } + + ffprobe, err := c.getFFProbe(thumbPath) + if err != nil { + c.l.Error(err) + return nil, err + } + + return &core.Image{ + File: core.File{Name: path.Base(thumbPath), Path: thumbPath}, + Width: ffprobe.Streams[0].Width, + Height: ffprobe.Streams[0].Height, + }, nil +} diff --git a/infrastructure/file_downloader.go b/infrastructure/file_downloader.go new file mode 100644 index 0000000..babd4e6 --- /dev/null +++ b/infrastructure/file_downloader.go @@ -0,0 +1,61 @@ +package infrastructure + +import ( + "io" + "net/http" + "os" + "path" + "strings" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreateFileDownloader is a default FileDownloader factory +func CreateFileDownloader(l core.ILogger) *FileDownloader { + return &FileDownloader{l} +} + +// FileDownloader is a default implementation for core.IFileDownloader +type FileDownloader struct { + l core.ILogger +} + +// Download is a core.IFileDownloader interface implementation +func (downloader *FileDownloader) Download(url core.URL, filepath string) (*core.File, error) { + name := path.Base(filepath) + downloader.l.Infof("downloading %s %s", url, strings.ReplaceAll(filepath, os.TempDir(), "$TMPDIR/")) + // Get the data + client := http.DefaultClient + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0") + req.Header.Set("Referer", url) + res, err := client.Do(req) + if err != nil { + downloader.l.Error(err) + return nil, err + } + defer res.Body.Close() + + // Create the file + out, err := os.Create(filepath) + if err != nil { + downloader.l.Error(err) + return nil, err + } + defer out.Close() + + // Write the body to file + _, err = io.Copy(out, res.Body) + if err != nil { + downloader.l.Error(err) + return nil, err + } + + // Retreive file size + stat, err := os.Stat(filepath) + if err != nil { + downloader.l.Error(err) + return nil, err + } + return &core.File{Name: name, Path: filepath, Size: stat.Size()}, err +} diff --git a/infrastructure/game.go b/infrastructure/game.go new file mode 100644 index 0000000..d16c01e --- /dev/null +++ b/infrastructure/game.go @@ -0,0 +1,29 @@ +package infrastructure + +// Player that can be persistent on disk +type Player struct { + GameID int64 `gorm:"primaryKey"` + UserID int64 `gorm:"primaryKey"` + FirstName string + LastName string + Username string + LanguageCode string +} + +// TableName gorm API +func (Player) TableName() string { + return "faggot_players" +} + +// Round that can be persistent on disk +type Round struct { + GameID int64 + UserID int64 + Day string `gorm:"primaryKey"` + Username string +} + +// TableName gorm API +func (Round) TableName() string { + return "faggot_rounds" +} diff --git a/infrastructure/game_localizer.go b/infrastructure/game_localizer.go new file mode 100644 index 0000000..46e799b --- /dev/null +++ b/infrastructure/game_localizer.go @@ -0,0 +1,123 @@ +package infrastructure + +import ( + "fmt" + "runtime" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateGameLocalizer() core.ILocalizer { + return &GameLocalizer{ + map[string]map[string]string{"ru": { + // Faggot game + "faggot_rules": `Правила игры Пидор Дня (только для групповых чатов): + 1. Зарегистрируйтесь в игру по команде /pidoreg + 2. Подождите пока зарегиструются все (или большинство :) + 3. Запустите розыгрыш по команде /pidor + 4. Просмотр статистики канала по команде /pidorstats, /pidorall + 5. Личная статистика по команде /pidorme + 6. Статистика за 2018 год по комнаде /pidor2018 (так же есть за 2016-2017) + + Важно, розыгрыш проходит только раз в день, повторная команда выведет результат игры. + + Сброс розыгрыша происходит каждый день в 12 часов ночи по UTC+2 (или два часа ночи по Москве).`, + + "faggot_not_available_for_private": "Извините, данная команда недоступна в личных чатах.", + "faggot_added_to_game": "Ты в игре!", + "faggot_info_updated": "Профиль игрока обновлён!", + "faggot_already_in_game": "Эй! Ты уже в игре!", + "faggot_no_players": "Зарегистрированных в игру еще нет, а значит пидор ты - %s", + "faggot_not_enough_players": "Нужно как минимум два игрока, чтобы начать игру! Зарегистрируйся используя /pidoreg", + "faggot_winner_known": "Согласно моей информации, по результатам сегодняшнего розыгрыша пидор дня - %s!", + "faggot_winner_left": "Я нашел пидора дня, но похоже, что он вышел из этого чата (вот пидор!), так что попробуйте еще раз!", + // 0 + "faggot_game_0_0": "Осторожно! Пидор дня активирован!", + "faggot_game_0_1": "Система взломана. Нанесён урон. Запущено планирование контрмер.", + "faggot_game_0_2": "Сейчас поколдуем...", + "faggot_game_0_3": "Инициирую поиск пидора дня...", + "faggot_game_0_4": "Итак... кто же сегодня пидор дня?", + "faggot_game_0_5": "Кто сегодня счастливчик?", + "faggot_game_0_6": "Зачем вы меня разбудили...", + "faggot_game_0_7": "### RUNNING 'TYPIDOR.SH'...", + "faggot_game_0_8": "Woop-woop! That's the sound of da pidor-police!", + "faggot_game_0_9": "Опять в эти ваши игрульки играете? Ну ладно...", + // 1 + "faggot_game_1_0": "Шаманим-шаманим...", + "faggot_game_1_1": "Где-же он...", + "faggot_game_1_2": "Сканирую...", + "faggot_game_1_3": "Военный спутник запущен, коды доступа внутри...", + "faggot_game_1_4": "Хм...", + "faggot_game_1_5": "Интересно...", + "faggot_game_1_6": "Ведётся поиск в базе данных...", + "faggot_game_1_7": "Машины выехали", + "faggot_game_1_8": "(Ворчит) А могли бы на работе делом заниматься", + "faggot_game_1_9": "Выезжаю на место...", + // 2 + "faggot_game_2_0": "Так-так, что же тут у нас...", + "faggot_game_2_1": "КЕК!", + "faggot_game_2_2": "Доступ получен. Аннулирование протокола.", + "faggot_game_2_3": "Проверяю данные...", + "faggot_game_2_4": "Ох...", + "faggot_game_2_5": "Высокий приоритет мобильному юниту.", + "faggot_game_2_6": "Ведётся захват подозреваемого...", + "faggot_game_2_7": "Что с нами стало...", + "faggot_game_2_8": "Сонно смотрит на бумаги", + "faggot_game_2_9": "В этом совершенно нет смысла...", + // 3 + "faggot_game_3_0": "Ого, вы посмотрите только! А пидор дня то - %s", + "faggot_game_3_1": "Кажется, пидор дня - %s", + "faggot_game_3_2": ` ​ .∧_∧ + ( ・ω・。)つ━☆・*。 + ⊂ ノ ・゜+. + しーJ °。+ *´¨) + .· ´¸.·*´¨) + (¸.·´ (¸.·"* ☆ ВЖУХ И ТЫ ПИДОР, %s`, + "faggot_game_3_3": "И прекрасный человек дня сегодня... а нет, ошибка, всего-лишь пидор - %s", + "faggot_game_3_4": "Анализ завершен. Ты пидор, %s", + "faggot_game_3_5": "Ага! Поздравляю! Сегодня ты пидор, %s", + "faggot_game_3_6": "Что? Где? Когда? А ты пидор дня - %s", + "faggot_game_3_7": "Ну ты и пидор, %s", + "faggot_game_3_8": "Кто бы мог подумать, но пидор дня - %s", + "faggot_game_3_9": "Стоять! Не двигаться! Вы объявлены пидором дня, %s", + + "faggot_stats_top": "Топ-10 пидоров за текущий год:", + "faggot_stats_entry": "%d. %s — %d раз(а)", + "faggot_stats_bottom": "Всего участников — %d", + + "faggot_all_top": "Топ-10 пидоров за всё время:", + "faggot_all_entry": "%d. %s — %d раз(а)", + "faggot_all_bottom": "Всего участников — %d", + + "faggot_me": "%s, ты был(а) пидором дня — %d раз!", + }}, + } +} + +// GameLocalizer for faggot game +type GameLocalizer struct { + langs map[string]map[string]string +} + +// I18n is a core.ILocalizer implementation +func (l *GameLocalizer) I18n(lang, key string, args ...interface{}) string { + if val, ok := l.langs[lang][key]; ok { + return fmt.Sprintf(val, args...) + } + + if val, ok := l.langs["ru"][key]; ok { + return fmt.Sprintf(val, args...) + } + + _, file, line, _ := runtime.Caller(0) + return fmt.Sprintf("%s:%d KEY_MISSED:\"%s\"", file, line, key) +} + +// AllKeys is a core.ILocalizer implementation +func (l *GameLocalizer) AllKeys() []string { + keys := make([]string, 0, len(l.langs["ru"])) + for k := range l.langs["ru"] { + keys = append(keys, k) + } + return keys +} diff --git a/infrastructure/game_storage.go b/infrastructure/game_storage.go new file mode 100644 index 0000000..aa8834e --- /dev/null +++ b/infrastructure/game_storage.go @@ -0,0 +1,110 @@ +package infrastructure + +import ( + "github.com/ailinykh/pullanusbot/v2/core" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// CreateGameStorage is a default GameStorage factory +func CreateGameStorage(dbFile string) *GameStorage { + conn, err := gorm.Open(sqlite.Open(dbFile+"?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Error), + }) + if err != nil { + panic(err) + } + + if conn.Migrator().HasTable(&Player{}) && conn.Migrator().HasColumn(&Player{}, "chat_id") { + conn.Migrator().RenameColumn(&Player{}, "chat_id", "game_id") + conn.Migrator().RenameTable("faggot_entries", "faggot_rounds") + conn.Migrator().RenameColumn(&Round{}, "chat_id", "game_id") + } else { + conn.AutoMigrate(&Player{}, &Round{}) + } + return &GameStorage{conn} +} + +// GameStorage implements core.IGameStorage interface +type GameStorage struct { + conn *gorm.DB +} + +// GetPlayers is a core.IGameStorage interface implementation +func (s *GameStorage) GetPlayers(gameID int64) ([]*core.User, error) { + var dbPlayers []Player + var corePlayers []*core.User + s.conn.Where("game_id = ?", gameID).Find(&dbPlayers) + for _, p := range dbPlayers { + user := makeUser(p) + corePlayers = append(corePlayers, user) + } + return corePlayers, nil +} + +// GetRounds is a core.IGameStorage interface implementation +func (s *GameStorage) GetRounds(gameID int64) ([]*core.Round, error) { + players, err := s.GetPlayers(gameID) + if err != nil { + return nil, err + } + var dbRounds []Round + var coreRounds []*core.Round + s.conn.Where("game_id = ?", gameID).Find(&dbRounds) + for _, r := range dbRounds { + for _, p := range players { + if p.ID == r.UserID { + coreRounds = append(coreRounds, &core.Round{Day: r.Day, Winner: p}) + break + } + } + } + return coreRounds, nil +} + +// AddPlayer is a core.IGameStorage interface implementation +func (s *GameStorage) AddPlayer(gameID int64, user *core.User) error { + player := Player{ + GameID: gameID, + UserID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Username: user.Username, + LanguageCode: user.LanguageCode, + } + s.conn.Create(&player) + return nil +} + +// AddPlayer is a core.IGameStorage interface implementation +func (s *GameStorage) UpdatePlayer(gameID int64, user *core.User) error { + player := Player{ + GameID: gameID, + UserID: user.ID, + } + s.conn.Model(&player).Updates(Player{FirstName: user.FirstName, LastName: user.LastName, Username: user.Username}) + return nil +} + +// AddRound is a core.IGameStorage interface implementation +func (s *GameStorage) AddRound(gameID int64, round *core.Round) error { + dbRound := Round{ + GameID: gameID, + UserID: round.Winner.ID, + Day: round.Day, + Username: round.Winner.Username, + } + s.conn.Create(&dbRound) + return nil +} + +func makeUser(player Player) *core.User { + return &core.User{ + ID: player.UserID, + FirstName: player.FirstName, + LastName: player.LastName, + Username: player.Username, + LanguageCode: player.LanguageCode, + } +} diff --git a/infrastructure/in_memory_chat_storage.go b/infrastructure/in_memory_chat_storage.go new file mode 100644 index 0000000..6d99c05 --- /dev/null +++ b/infrastructure/in_memory_chat_storage.go @@ -0,0 +1,33 @@ +package infrastructure + +import ( + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateInMemoryChatStorage() core.IChatStorage { + return &InMemoryChatStorage{make(map[int64]*core.Chat)} +} + +type InMemoryChatStorage struct { + cache map[int64]*core.Chat +} + +// GetChatByID is a core.IChatStorage interface implementation +func (storage *InMemoryChatStorage) GetChatByID(chatID int64) (*core.Chat, error) { + if chat, ok := storage.cache[chatID]; ok { + return chat, nil + } + return nil, fmt.Errorf("record not found") +} + +// CreateChat is a core.IChatStorage interface implementation +func (storage *InMemoryChatStorage) CreateChat(chatID int64, title string, type_ string) error { + storage.cache[chatID] = &core.Chat{ + ID: chatID, + Title: title, + Type: type_, + } + return nil +} diff --git a/infrastructure/in_memory_user_storage.go b/infrastructure/in_memory_user_storage.go new file mode 100644 index 0000000..c8de98e --- /dev/null +++ b/infrastructure/in_memory_user_storage.go @@ -0,0 +1,29 @@ +package infrastructure + +import ( + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateInMemoryUserStorage() core.IUserStorage { + return &InMemoryUserStorage{make(map[int64]*core.User)} +} + +type InMemoryUserStorage struct { + cache map[int64]*core.User +} + +// GetUserById is a core.IUserStorage interface implementation +func (storage *InMemoryUserStorage) GetUserById(userID int64) (*core.User, error) { + if user, ok := storage.cache[userID]; ok { + return user, nil + } + return nil, fmt.Errorf("record not found") +} + +// CreateUser is a core.IUserStorage interface implementation +func (storage *InMemoryUserStorage) CreateUser(user *core.User) error { + storage.cache[user.ID] = user + return nil +} diff --git a/infrastructure/math_rand.go b/infrastructure/math_rand.go new file mode 100644 index 0000000..c4f0a53 --- /dev/null +++ b/infrastructure/math_rand.go @@ -0,0 +1,19 @@ +package infrastructure + +import ( + "math/rand" + "time" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateMathRand() core.IRand { + rand.Seed(time.Now().UTC().UnixNano()) + return MathRand{} +} + +type MathRand struct{} + +func (MathRand) GetRand(n int) int { + return rand.Intn(n) +} diff --git a/infrastructure/outline_storage.go b/infrastructure/outline_storage.go new file mode 100644 index 0000000..a0730d2 --- /dev/null +++ b/infrastructure/outline_storage.go @@ -0,0 +1,74 @@ +package infrastructure + +import ( + "time" + + "github.com/ailinykh/pullanusbot/v2/core" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// CreateOutlineStorage is a default OutlineStorage factory +func CreateOutlineStorage(dbFile string, l core.ILogger) *OutlineStorage { + conn, err := gorm.Open(sqlite.Open(dbFile+"?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Error), + }) + if err != nil { + panic(err) + } + + conn.AutoMigrate(&VpnKey{}) + return &OutlineStorage{conn, l} +} + +type OutlineStorage struct { + conn *gorm.DB + l core.ILogger +} + +type VpnKey struct { + ID string `gorm:"primaryKey"` + ChatID int64 `gorm:"primaryKey"` + Host string `gorm:"primaryKey"` + Title string + Key string + CreatedAt time.Time `gorm:"autoUpdateTime"` + UpdatedAt time.Time `gorm:"autoCreateTime"` +} + +func (storage *OutlineStorage) GetKeys(chatID int64) ([]*VpnKey, error) { + var keys []*VpnKey + res := storage.conn.Where("chat_id = ?", chatID).Find(&keys) + + if res.Error != nil { + storage.l.Error(res.Error) + return nil, res.Error + } + + return keys, nil +} + +func (storage *OutlineStorage) CreateKey(id string, chatID int64, host string, title string, key string) error { + storage.l.Infof("creating key with id: %s, chat_id: %d, host: %s, title: %s, key: %s", id, chatID, host, title, key) + k := VpnKey{ + ID: id, + ChatID: chatID, + Host: host, + Title: title, + Key: key, + } + res := storage.conn.Create(&k) + return res.Error +} + +func (storage *OutlineStorage) DeleteKey(key *core.VpnKey, host string) error { + res := storage.conn.Delete(VpnKey{ + ID: key.ID, + ChatID: key.ChatID, + Host: host, + Title: key.Title, + Key: key.Key, + }) + return res.Error +} diff --git a/infrastructure/rabbit_factory.go b/infrastructure/rabbit_factory.go new file mode 100644 index 0000000..e9d3eec --- /dev/null +++ b/infrastructure/rabbit_factory.go @@ -0,0 +1,59 @@ +package infrastructure + +import ( + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/streadway/amqp" +) + +func CreateRabbitFactory(l core.ILogger, url string) (core.ITaskFactory, func()) { + factory := &RabbitFactory{l: l} + err := factory.reestablishConnection(url) + if err != nil { + panic(err) + } + return factory, factory.Close +} + +type RabbitFactory struct { + l core.ILogger + conn *amqp.Connection + ch *amqp.Channel +} + +// NewTask is a core.ITaskFactory interface implementation +func (q *RabbitFactory) NewTask(name string) core.ITask { + return &RabbitWorker{q.l, name, q.ch} +} + +func (q *RabbitFactory) Close() { + q.ch.Close() + q.conn.Close() +} + +func (q *RabbitFactory) reestablishConnection(url string) error { + conn, err := amqp.Dial(url) + if err != nil { + q.l.Error(err) + return err + } + + ch, err := conn.Channel() + if err != nil { + q.l.Error(err) + return err + } + + q.conn = conn + q.ch = ch + + go func() { + err := <-conn.NotifyClose(make(chan *amqp.Error)) + q.l.Error("connection closed", err) + errr := q.reestablishConnection(url) + if errr != nil { + q.l.Error(errr) + } + }() + + return nil +} diff --git a/infrastructure/rabbit_worker.go b/infrastructure/rabbit_worker.go new file mode 100644 index 0000000..4dafed2 --- /dev/null +++ b/infrastructure/rabbit_worker.go @@ -0,0 +1,67 @@ +package infrastructure + +import ( + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/google/uuid" + "github.com/streadway/amqp" +) + +type RabbitWorker struct { + l core.ILogger + key string + ch *amqp.Channel +} + +func (worker *RabbitWorker) Perform(data []byte, ch chan []byte) error { + q, err := worker.ch.QueueDeclare( + "", // name + true, // durable + false, // delete when unused + true, // exclusive + false, // no-wait + nil, // arguments + ) + if err != nil { + return err + } + + msgs, err := worker.ch.Consume( + q.Name, // queue + "", // consumer + true, // auto-ack + false, // exclusive + false, // no-local + false, // no-wait + nil, // args + ) + if err != nil { + return err + } + + corrId := uuid.NewString() + err = worker.ch.Publish( + "", // exchange + worker.key, // routing key + false, // mandatory + false, // immediate + amqp.Publishing{ + ContentType: "text/plain", + CorrelationId: corrId, + Body: data, + ReplyTo: q.Name, + }) + if err != nil { + return err + } + + go func() { + for d := range msgs { + if corrId == d.CorrelationId { + ch <- d.Body + break + } + } + }() + + return err +} diff --git a/infrastructure/settings_storage.go b/infrastructure/settings_storage.go new file mode 100644 index 0000000..c527882 --- /dev/null +++ b/infrastructure/settings_storage.go @@ -0,0 +1,57 @@ +package infrastructure + +import ( + "time" + + "github.com/ailinykh/pullanusbot/v2/core" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Settings +type Settings struct { + ChatID int64 `gorm:"primaryKey"` + Key string `gorm:"primaryKey"` + Data []byte + CreatedAt time.Time `gorm:"autoUpdateTime"` + UpdatedAt time.Time `gorm:"autoCreateTime"` +} + +func CreateSettingsStorage(dbFile string) core.ISettingsProvider { + conn, err := gorm.Open(sqlite.Open(dbFile+"?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Error), + }) + if err != nil { + panic(err) + } + + conn.AutoMigrate(&Settings{}) + + return &SettingsStorage{conn} +} + +// SettingsStorage implements core.ISettingsProvider interface +type SettingsStorage struct { + conn *gorm.DB +} + +// GetData is a core.ISettingsProvider interface implementation +func (storage *SettingsStorage) GetData(chatID core.ChatID, key core.SettingKey) ([]byte, error) { + var sessings Settings + err := storage.conn.First(&sessings, chatID, key).Error + if err != nil { + return nil, err + } + return sessings.Data, nil +} + +// SetData is a core.ISettingsProvider interface implementation +func (storage *SettingsStorage) SetData(chatID core.ChatID, key core.SettingKey, data []byte) error { + settings := Settings{ + ChatID: chatID, + Key: string(key), + Data: data, + } + return storage.conn.Save(&settings).Error +} diff --git a/infrastructure/user_storage.go b/infrastructure/user_storage.go new file mode 100644 index 0000000..06cdc33 --- /dev/null +++ b/infrastructure/user_storage.go @@ -0,0 +1,74 @@ +package infrastructure + +import ( + "time" + + "github.com/ailinykh/pullanusbot/v2/core" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func CreateUserStorage(dbFile string, l core.ILogger) core.IUserStorage { + conn, err := gorm.Open(sqlite.Open(dbFile+"?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Error), + }) + if err != nil { + panic(err) + } + + conn.AutoMigrate(&User{}) + return &UserStorage{conn, l} +} + +type UserStorage struct { + conn *gorm.DB + l core.ILogger +} + +// User +type User struct { + UserID int64 `gorm:"primaryKey"` + FirstName string + LastName string + Username string + LanguageCode string + CreatedAt time.Time `gorm:"autoUpdateTime"` + UpdatedAt time.Time `gorm:"autoCreateTime"` +} + +// GetUserById is a core.IUserStorage interface implementation +func (storage *UserStorage) GetUserById(userID int64) (*core.User, error) { + var user User + res := storage.conn.First(&user, userID) + + if res.Error != nil { + storage.l.Error(res.Error) + return nil, res.Error + } + return &core.User{ + ID: user.UserID, + FirstName: user.FirstName, + LastName: user.LastName, + Username: user.Username, + LanguageCode: user.LanguageCode}, nil +} + +// CreateUser is a core.IUserStorage interface implementation +func (storage *UserStorage) CreateUser(user *core.User) error { + u := User{ + UserID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Username: user.Username, + LanguageCode: user.LanguageCode, + } + err := storage.conn.Create(&u).Error + if err != nil { + storage.l.Error(err) + return err + } + + storage.l.Infof("user created: %v", user) + return nil +} diff --git a/infrastructure/vpn_localizer.go b/infrastructure/vpn_localizer.go new file mode 100644 index 0000000..b2373d9 --- /dev/null +++ b/infrastructure/vpn_localizer.go @@ -0,0 +1,57 @@ +package infrastructure + +import ( + "fmt" + "runtime" +) + +func CreateVpnLocalizer() *VpnLocalizer { + return &VpnLocalizer{ + map[string]map[string]string{"ru": { + "vpn_button_create_key": "🔑 Создать новый ключ", + "vpn_button_manage_key": "🔐 Управление ключами", + "vpn_button_remove_key": "❌ Удалить ключ", + "vpn_button_back": "⏪ Назад", + "vpn_button_cancel": "❌ Отмена", + "vpn_enter_create_key_name": "Придумайте имя для ключа.\nЭто может быть любой набор слов, который поможет вам понять, для чего вы используете тот или иной ключ.\n\nНапример:\n- Мой ключ\n- Ключ для друзей\n- Родители\n\nнапишите имя в следующем сообщении", + "vpn_enter_create_key_name_too_long": "Давайте придумаем что-то более лаконичное", + "vpn_enter_delete_key_name_top": "Введите имя ключа, который хотите удалить\n", + "vpn_enter_delete_key_name_item": "%s", + "vpn_key_created": "✅ Вы успешно создали новый ключ\n\n%s\n\nтеперь скопируйте ключ в буффер обмена (простым нажатием на него) и вставьте его в приложение", + "vpn_key_deleted": "✅ Ключ \"%s\" удалён!\n\n", + "vpn_key_not_found": "❌ Ключ не найден\n\n", + "vpn_key_list_top": "🔑 Активные ключи:\n", + "vpn_key_list_item": "%d. %s\n%s\n", + "vpn_key_list_bottom": "\nВсего ключей: %d", + "vpn_welcome": "🌏 VPN всего за 3 простых шага\n\n1️⃣ Установите клиент outline на ваше устройство:\n\n📱 iOS / iPhone / iPad\n📱 Android\n\n🖥 macOS\n🪟 Windows\n🐧 Linux\n\n2️⃣ Нажмите на кнопку \"Создать новый ключ\"\n\n3️⃣ Скопируйте полученный ключ в клиент", + }}, + } +} + +// VpnLocalizer for faggot game +type VpnLocalizer struct { + langs map[string]map[string]string +} + +// I18n is a core.ILocalizer implementation +func (l *VpnLocalizer) I18n(lang, key string, args ...interface{}) string { + if val, ok := l.langs[lang][key]; ok { + return fmt.Sprintf(val, args...) + } + + if val, ok := l.langs["ru"][key]; ok { + return fmt.Sprintf(val, args...) + } + + _, file, line, _ := runtime.Caller(0) + return fmt.Sprintf("%s:%d KEY_MISSED:\"%s\"", file, line, key) +} + +// AllKeys is a core.ILocalizer implementation +func (l *VpnLocalizer) AllKeys() []string { + keys := make([]string, 0, len(l.langs["ru"])) + for k := range l.langs["ru"] { + keys = append(keys, k) + } + return keys +} diff --git a/pullanusbot.go b/pullanusbot.go new file mode 100644 index 0000000..45f8375 --- /dev/null +++ b/pullanusbot.go @@ -0,0 +1,135 @@ +package main + +import ( + "os" + "path" + + "github.com/ailinykh/pullanusbot/v2/api" + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/helpers" + "github.com/ailinykh/pullanusbot/v2/infrastructure" + "github.com/ailinykh/pullanusbot/v2/usecases" + "github.com/google/logger" +) + +func main() { + logger := createLogger() + defer logger.Close() + + dbFile := path.Join(getWorkingDir(), "pullanusbot.db") + + settingsProvider := infrastructure.CreateSettingsStorage(dbFile) + boolSettingProvider := helpers.CreateBoolSettingProvider(settingsProvider) + databaseChatStorage := infrastructure.CreateChatStorage(dbFile, logger) + inMemoryChatStorage := infrastructure.CreateInMemoryChatStorage() + chatStorageDecorator := usecases.CreateChatStorageDecorator(logger, inMemoryChatStorage, databaseChatStorage) + telebot := api.CreateTelebot(os.Getenv("BOT_TOKEN"), logger) + telebot.SetupInfo() + + databaseUserStorage := infrastructure.CreateUserStorage(dbFile, logger) + inMemoryUserStorage := infrastructure.CreateInMemoryUserStorage() + userStorageDecorator := usecases.CreateUserStorageDecorator(inMemoryUserStorage, databaseUserStorage) + bootstrapFlow := usecases.CreateBootstrapFlow(logger, chatStorageDecorator, userStorageDecorator) + telebot.AddHandler(bootstrapFlow) + + localizer := infrastructure.CreateGameLocalizer() + gameStorage := infrastructure.CreateGameStorage(dbFile) + rand := infrastructure.CreateMathRand() + commandService := usecases.CreateCommandService(logger) + gameFlow := usecases.CreateGameFlow(logger, localizer, gameStorage, rand, settingsProvider, commandService) + telebot.AddHandler("/pidorules", gameFlow.Rules) + telebot.AddHandler("/pidoreg", gameFlow.Add) + telebot.AddHandler("/pidor", gameFlow.Play) + telebot.AddHandler("/pidorstats", gameFlow.Stats) + telebot.AddHandler("/pidorall", gameFlow.All) + telebot.AddHandler("/pidorme", gameFlow.Me) + + converter := infrastructure.CreateFfmpegConverter(logger) + videoFlow := usecases.CreateVideoFlow(logger, converter, converter) + telebot.AddHandler(videoFlow) + + fileDownloader := infrastructure.CreateFileDownloader(logger) + remoteMediaSender := helpers.CreateSendMediaStrategy(logger) + sendVideoStrategy := helpers.CreateSendVideoStrategy(logger) + sendVideoStrategySplitDecorator := helpers.CreateSendVideoStrategySplitDecorator(logger, sendVideoStrategy, converter) + localMediaSender := helpers.CreateUploadMediaDecorator(logger, remoteMediaSender, fileDownloader, converter, sendVideoStrategySplitDecorator) + + rabbit, close := infrastructure.CreateRabbitFactory(logger, os.Getenv("AMQP_URL")) + defer close() + task := rabbit.NewTask("twitter_queue") + + twitterMediaFactory := api.CreateTwitterMediaFactory(logger, task) + twitterFlow := usecases.CreateTwitterFlow(logger, twitterMediaFactory, localMediaSender) + twitterTimeout := usecases.CreateTwitterTimeout(logger, twitterFlow) + twitterParser := usecases.CreateTwitterParser(logger, twitterTimeout) + twitterRemoveSourceDecorator := usecases.CreateRemoveSourceDecorator(logger, twitterParser, core.STwitterFlowRemoveSource, boolSettingProvider) + telebot.AddHandler(twitterRemoveSourceDecorator) + + httpClient := api.CreateHttpClient() + convertMediaSender := helpers.CreateConvertMediaStrategy(logger, localMediaSender, fileDownloader, converter, converter) + linkFlow := usecases.CreateLinkFlow(logger, httpClient, converter, convertMediaSender) + removeLinkSourceDecorator := usecases.CreateRemoveSourceDecorator(logger, linkFlow, core.SLinkFlowRemoveSource, boolSettingProvider) + telebot.AddHandler(removeLinkSourceDecorator) + + tiktokHttpClient := api.CreateHttpClient() // domain specific headers and cookies + ytdlpApi := api.CreateYtDlpApi(logger) + tiktokMediaFactory := api.CreateTikTokMediaFactory(logger, ytdlpApi) + tiktokFlow := usecases.CreateTikTokFlow(logger, tiktokHttpClient, tiktokMediaFactory, localMediaSender) + telebot.AddHandler(tiktokFlow) + + fileUploader := api.CreateTelegraphAPI() + //TODO: image_downloader := api.CreateTelebotImageDownloader() + imageFlow := usecases.CreateImageFlow(logger, fileUploader, telebot) + telebot.AddHandler(imageFlow) + + publisherFlow := usecases.CreatePublisherFlow(logger) + telebot.AddHandler(publisherFlow) + telebot.AddHandler("/loh666", publisherFlow.HandleRequest) + + youtubeMediaFactory := api.CreateYoutubeMediaFactory(logger, ytdlpApi, fileDownloader) + youtubeFlow := usecases.CreateYoutubeFlow(logger, youtubeMediaFactory, youtubeMediaFactory, sendVideoStrategySplitDecorator) + removeYoutubeSourceDecorator := usecases.CreateRemoveSourceDecorator(logger, youtubeFlow, core.SYoutubeFlowRemoveSource, boolSettingProvider) + telebot.AddHandler(removeYoutubeSourceDecorator) + + telebot.AddHandler("/proxy", func(m *core.Message, bot core.IBot) error { + _ = commandService.EnableCommands(m.Chat.ID, []core.Command{{Text: "proxy", Description: "proxy server for telegram"}}, bot) + _, err := bot.SendText("tg://proxy?server=proxy.ailinykh.com&port=443&secret=dd71ce3b5bf1b7015dc62a76dc244c5aec") + return err + }) + + iDoNotCare := usecases.CreateIDoNotCare() + telebot.AddHandler(iDoNotCare) + + cookies := path.Join(getWorkingDir(), "instagram-cookies.json") + jar := api.CreateJsonCookieJar(logger, cookies) + instaAPI := api.CreateInstagramAPI(logger, jar) + downloadVideoFactory := helpers.CreateDownloadVideoFactory(logger, fileDownloader, converter) + instaFlow := usecases.CreateInstagramFlow(logger, instaAPI, downloadVideoFactory, localMediaSender, sendVideoStrategySplitDecorator) + removeInstaSourceDecorator := usecases.CreateRemoveSourceDecorator(logger, instaFlow, core.SInstagramFlowRemoveSource, boolSettingProvider) + telebot.AddHandler(removeInstaSourceDecorator) + + commonLocalizer := infrastructure.CreateCommonLocalizer() + startFlow := usecases.CreateStartFlow(logger, commonLocalizer, settingsProvider, commandService) + telebot.AddHandler("/start", startFlow.Start) + telebot.AddHandler("/help", startFlow.Help) + // Start endless loop + telebot.Run() +} + +func createLogger() core.ILogger { + logFilePath := path.Join(getWorkingDir(), "pullanusbot.log") + lf, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0660) + if err != nil { + panic(err) + } + + return logger.Init("pullanusbot", true, false, lf) +} + +func getWorkingDir() string { + workingDir := os.Getenv("WORKING_DIR") + if len(workingDir) == 0 { + return "pullanusbot-data" + } + return workingDir +} diff --git a/test_helpers/bot.go b/test_helpers/bot.go new file mode 100644 index 0000000..b0fcfc6 --- /dev/null +++ b/test_helpers/bot.go @@ -0,0 +1,80 @@ +package test_helpers + +import ( + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// https://stackoverflow.com/questions/31794141/can-i-create-shared-test-utilities + +func CreateBot() *FakeBot { + return &FakeBot{[]string{}, []string{}, []string{}, []string{}, make(map[int64][]core.Command), []string{}, map[int64][]string{}} +} + +type FakeBot struct { + SentMedias []string + SentMessages []string + SentVideos []string + RemovedMessages []string + Commands map[int64][]core.Command + ActionLog []string + ChatMembers map[int64][]string +} + +func (FakeBot) SendImage(*core.Image, string) (*core.Message, error) { return nil, nil } +func (FakeBot) SendAlbum([]*core.Image) ([]*core.Message, error) { return nil, nil } + +func (b *FakeBot) SendMedia(media *core.Media) (*core.Message, error) { + b.SentMedias = append(b.SentMedias, media.ResourceURL) + return nil, nil +} + +func (b *FakeBot) SendPhotoAlbum(media []*core.Media) ([]*core.Message, error) { + for _, m := range media { + b.SentMedias = append(b.SentMedias, m.ResourceURL) + } + return nil, nil +} + +func (b *FakeBot) SendVideo(video *core.Video, caption string) (*core.Message, error) { + b.SentVideos = append(b.SentVideos, video.Name) + return nil, nil +} + +func (b *FakeBot) Delete(message *core.Message) error { + b.RemovedMessages = append(b.RemovedMessages, message.Text) + return nil +} + +func (b *FakeBot) Edit(message *core.Message, what interface{}, options ...interface{}) (*core.Message, error) { + return nil, fmt.Errorf("not implemented") +} + +func (b *FakeBot) SendText(text string, args ...interface{}) (*core.Message, error) { + b.SentMessages = append(b.SentMessages, text) + return nil, nil +} + +func (b *FakeBot) IsUserMemberOfChat(user *core.User, chatID int64) bool { + for _, username := range b.ChatMembers[chatID] { + if username == user.Username { + return true + } + } + return false +} + +func (bot *FakeBot) GetCommands(chatID int64) ([]core.Command, error) { + bot.ActionLog = append(bot.ActionLog, fmt.Sprint("get commands ", chatID)) + if commands, ok := bot.Commands[chatID]; ok { + return commands, nil + } + return []core.Command{}, nil +} + +func (bot *FakeBot) SetCommands(chatID int64, commands []core.Command) error { + bot.ActionLog = append(bot.ActionLog, fmt.Sprint("set commands ", chatID, commands)) + bot.Commands[chatID] = commands + return nil +} diff --git a/test_helpers/chat_storage.go b/test_helpers/chat_storage.go new file mode 100644 index 0000000..0438b15 --- /dev/null +++ b/test_helpers/chat_storage.go @@ -0,0 +1,30 @@ +package test_helpers + +import ( + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateChatStorage() *FakeChatStorage { + return &FakeChatStorage{make(map[int64]*core.Chat), nil} +} + +type FakeChatStorage struct { + Chats map[int64]*core.Chat + Err error +} + +// GetChatByID is a core.IChatStorage interface implementation +func (storage *FakeChatStorage) GetChatByID(chatID int64) (*core.Chat, error) { + if user, ok := storage.Chats[chatID]; ok { + return user, nil + } + return nil, fmt.Errorf("record not found") +} + +// CreateChat is a core.IChatStorage interface implementation +func (s *FakeChatStorage) CreateChat(chatID int64, title string, type_ string) error { + s.Chats[chatID] = &core.Chat{ID: chatID, Title: title, Type: type_} + return nil +} diff --git a/test_helpers/command_service.go b/test_helpers/command_service.go new file mode 100644 index 0000000..5ec1b90 --- /dev/null +++ b/test_helpers/command_service.go @@ -0,0 +1,28 @@ +package test_helpers + +import ( + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateCommandService(l core.ILogger) *CommandServiceMock { + return &CommandServiceMock{l, []string{}} +} + +type CommandServiceMock struct { + l core.ILogger + ActionLog []string +} + +// EnableCommands is a core.ICommandService interface implementation +func (service *CommandServiceMock) EnableCommands(chatID int64, commands []core.Command, bot core.IBot) error { + service.ActionLog = append(service.ActionLog, fmt.Sprint("enable commands ", chatID, commands)) + return nil +} + +// DisableCommands is a core.ICommandService interface implementation +func (service *CommandServiceMock) DisableCommands(chatID int64, commands []core.Command, bot core.IBot) error { + service.ActionLog = append(service.ActionLog, fmt.Sprint("disable commands ", chatID, commands)) + return nil +} diff --git a/test_helpers/file_downloader.go b/test_helpers/file_downloader.go new file mode 100644 index 0000000..48da3c9 --- /dev/null +++ b/test_helpers/file_downloader.go @@ -0,0 +1,20 @@ +package test_helpers + +import ( + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateFileDownloader() *FakeFileDownloader { + return &FakeFileDownloader{make(map[string]string), nil} +} + +type FakeFileDownloader struct { + DownloadedFiles map[string]string + Err error +} + +// Download is a core.IFileDownloader interface implementation +func (ffd *FakeFileDownloader) Download(url core.URL, filepath string) (*core.File, error) { + ffd.DownloadedFiles[url] = filepath + return &core.File{Path: filepath}, ffd.Err +} diff --git a/test_helpers/file_uploader.go b/test_helpers/file_uploader.go new file mode 100644 index 0000000..c0f9053 --- /dev/null +++ b/test_helpers/file_uploader.go @@ -0,0 +1,20 @@ +package test_helpers + +import ( + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateFileUploader() *FakeFileUploader { + return &FakeFileUploader{[]string{}, nil} +} + +type FakeFileUploader struct { + Uploaded []string + Err error +} + +// Upload is a core.IFileUploader interface implementation +func (ffu *FakeFileUploader) Upload(file *core.File) (core.URL, error) { + ffu.Uploaded = append(ffu.Uploaded, file.Path) + return file.Path, ffu.Err +} diff --git a/test_helpers/http_client.go b/test_helpers/http_client.go new file mode 100644 index 0000000..53c57f4 --- /dev/null +++ b/test_helpers/http_client.go @@ -0,0 +1,32 @@ +package test_helpers + +import ( + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateHttpClient() *FakeHttpClient { + return &FakeHttpClient{make(map[string]string)} +} + +type FakeHttpClient struct { + ContentTypeForURL map[core.URL]string +} + +func (client *FakeHttpClient) GetContentType(url core.URL) (string, error) { + if contentType, ok := client.ContentTypeForURL[url]; ok { + return contentType, nil + } + return "", fmt.Errorf("content type not found for %s", url) +} + +func (client *FakeHttpClient) GetContent(core.URL) (string, error) { + return "", nil +} + +func (client *FakeHttpClient) GetRedirectLocation(url core.URL) (core.URL, error) { + return "", nil +} + +func (client *FakeHttpClient) SetHeader(string, string) {} diff --git a/test_helpers/image_downloader.go b/test_helpers/image_downloader.go new file mode 100644 index 0000000..c652729 --- /dev/null +++ b/test_helpers/image_downloader.go @@ -0,0 +1,20 @@ +package test_helpers + +import ( + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateImageDownloader() *FakeImageDownloader { + return &FakeImageDownloader{[]string{}, nil} +} + +type FakeImageDownloader struct { + Downloaded []string + Err error +} + +// Upload is a core.IImageDownloader interface implementation +func (fid *FakeImageDownloader) Download(image *core.Image) (*core.File, error) { + fid.Downloaded = append(fid.Downloaded, image.FileURL) + return &core.File{Path: image.Path}, fid.Err +} diff --git a/test_helpers/localizer.go b/test_helpers/localizer.go new file mode 100644 index 0000000..a65673a --- /dev/null +++ b/test_helpers/localizer.go @@ -0,0 +1,26 @@ +package test_helpers + +import "fmt" + +func CreateLocalizer(data map[string]string) *FakeLocalizer { + return &FakeLocalizer{data} +} + +type FakeLocalizer struct { + data map[string]string +} + +func (l *FakeLocalizer) I18n(lang, key string, args ...interface{}) string { + if val, ok := l.data[key]; ok { + return fmt.Sprintf(val, args...) + } + return key +} + +func (l *FakeLocalizer) AllKeys() []string { + keys := make([]string, 0, len(l.data)) + for k := range l.data { + keys = append(keys, k) + } + return keys +} diff --git a/test_helpers/logger.go b/test_helpers/logger.go new file mode 100644 index 0000000..d896afa --- /dev/null +++ b/test_helpers/logger.go @@ -0,0 +1,15 @@ +package test_helpers + +func CreateLogger() *FakeLogger { + return &FakeLogger{} +} + +type FakeLogger struct{} + +func (FakeLogger) Close() {} +func (FakeLogger) Error(...interface{}) {} +func (FakeLogger) Errorf(string, ...interface{}) {} +func (FakeLogger) Info(...interface{}) {} +func (FakeLogger) Infof(string, ...interface{}) {} +func (FakeLogger) Warning(...interface{}) {} +func (FakeLogger) Warningf(string, ...interface{}) {} diff --git a/test_helpers/media_factory.go b/test_helpers/media_factory.go new file mode 100644 index 0000000..6412211 --- /dev/null +++ b/test_helpers/media_factory.go @@ -0,0 +1,16 @@ +package test_helpers + +import "github.com/ailinykh/pullanusbot/v2/core" + +func CreateMediaFactory() *FakeMediaFactory { + return &FakeMediaFactory{[]core.URL{}} +} + +type FakeMediaFactory struct { + URLs []core.URL +} + +func (factory *FakeMediaFactory) CreateMedia(url core.URL) ([]*core.Media, error) { + factory.URLs = append(factory.URLs, url) + return []*core.Media{{URL: url}}, nil +} diff --git a/test_helpers/send_media_strategy.go b/test_helpers/send_media_strategy.go new file mode 100644 index 0000000..1019856 --- /dev/null +++ b/test_helpers/send_media_strategy.go @@ -0,0 +1,22 @@ +package test_helpers + +import ( + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateSendMediaStrategy() *FakeSendMediaStrategy { + return &FakeSendMediaStrategy{[]string{}, nil} +} + +type FakeSendMediaStrategy struct { + SentMedia []string + Err error +} + +// SendMedia is a core.ISendMediaStrategy interface implementation +func (fsms *FakeSendMediaStrategy) SendMedia(media []*core.Media, bot core.IBot) error { + for _, m := range media { + fsms.SentMedia = append(fsms.SentMedia, m.URL) + } + return fsms.Err +} diff --git a/test_helpers/send_video_strategy.go b/test_helpers/send_video_strategy.go new file mode 100644 index 0000000..e33a177 --- /dev/null +++ b/test_helpers/send_video_strategy.go @@ -0,0 +1,20 @@ +package test_helpers + +import ( + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateSendVideoStrategy() *FakeSendVideoStrategy { + return &FakeSendVideoStrategy{[]string{}, nil} +} + +type FakeSendVideoStrategy struct { + SentVideos []string + Err error +} + +// SendVideo is a core.ISendVideoStrategy interface implementation +func (fsms *FakeSendVideoStrategy) SendVideo(video *core.Video, caption string, bot core.IBot) error { + fsms.SentVideos = append(fsms.SentVideos, video.Name) + return fsms.Err +} diff --git a/test_helpers/settings_provider.go b/test_helpers/settings_provider.go new file mode 100644 index 0000000..b64d439 --- /dev/null +++ b/test_helpers/settings_provider.go @@ -0,0 +1,36 @@ +package test_helpers + +import ( + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateSettingsProvider() *FakeSettingsProvider { + return &FakeSettingsProvider{make(map[int64]map[core.SettingKey][]byte), nil} +} + +type FakeSettingsProvider struct { + Data map[core.ChatID]map[core.SettingKey][]byte + Err error +} + +// GetSettings is a core.ISettingsProvider interface implementation +func (s *FakeSettingsProvider) GetData(chatID core.ChatID, key core.SettingKey) ([]byte, error) { + if chat, ok := s.Data[chatID]; ok { + if settings, ok := chat[key]; ok { + return settings, nil + } + } + + return nil, fmt.Errorf("not found") +} + +// SetSettings is a core.ISettingsProvider interface implementation +func (s *FakeSettingsProvider) SetData(chatID core.ChatID, key core.SettingKey, data []byte) error { + if _, ok := s.Data[chatID]; !ok { + s.Data[chatID] = map[core.SettingKey][]byte{} + } + s.Data[chatID][key] = data + return nil +} diff --git a/test_helpers/user_storage.go b/test_helpers/user_storage.go new file mode 100644 index 0000000..7fd1788 --- /dev/null +++ b/test_helpers/user_storage.go @@ -0,0 +1,30 @@ +package test_helpers + +import ( + "fmt" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateUserStorage() *FakeUserStorage { + return &FakeUserStorage{make(map[int64]*core.User), nil} +} + +type FakeUserStorage struct { + Users map[int64]*core.User + Err error +} + +// GetUserById is a core.IUserStorage interface implementation +func (storage *FakeUserStorage) GetUserById(userID int64) (*core.User, error) { + if user, ok := storage.Users[userID]; ok { + return user, nil + } + return nil, fmt.Errorf("record not found") +} + +// CreateUser is a core.IUserStorage interface implementation +func (storage *FakeUserStorage) CreateUser(user *core.User) error { + storage.Users[user.ID] = user + return nil +} diff --git a/test_helpers/video_factory.go b/test_helpers/video_factory.go new file mode 100644 index 0000000..2702c7e --- /dev/null +++ b/test_helpers/video_factory.go @@ -0,0 +1,20 @@ +package test_helpers + +import ( + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateVideoFactory() *FakeVideoFactory { + return &FakeVideoFactory{[]string{}, nil} +} + +type FakeVideoFactory struct { + CreatedVideos []string + Err error +} + +// CreateVideo is a core.IVideoFactory interface implementation +func (fvf *FakeVideoFactory) CreateVideo(path string) (*core.Video, error) { + fvf.CreatedVideos = append(fvf.CreatedVideos, path) + return &core.Video{File: core.File{Path: path}}, fvf.Err +} diff --git a/usecases/bootstrap_flow.go b/usecases/bootstrap_flow.go new file mode 100644 index 0000000..b30e4e1 --- /dev/null +++ b/usecases/bootstrap_flow.go @@ -0,0 +1,60 @@ +package usecases + +import ( + "sync" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateBootstrapFlow(l core.ILogger, chatStorage core.IChatStorage, userStorage core.IUserStorage) core.ITextHandler { + return &BootstrapFlow{l, chatStorage, userStorage, sync.Mutex{}} +} + +type BootstrapFlow struct { + l core.ILogger + chatStorage core.IChatStorage + userStorage core.IUserStorage + lock sync.Mutex +} + +// HandleText is a core.ITextHandler protocol implementation +func (flow *BootstrapFlow) HandleText(message *core.Message, bot core.IBot) error { + flow.lock.Lock() + defer flow.lock.Unlock() + + err := flow.ensureUserExists(message.Sender) + if err != nil { + flow.l.Error(err) + //Do not return? + } + + err = flow.ensureChatExists(message.Chat) + if err != nil { + flow.l.Error(err) + //Do not return? + } + + return err +} + +func (flow *BootstrapFlow) ensureChatExists(chat *core.Chat) error { + _, err := flow.chatStorage.GetChatByID(chat.ID) + if err != nil { + if err.Error() == "record not found" { + return flow.chatStorage.CreateChat(chat.ID, chat.Title, chat.Type) + } + flow.l.Error(err) + } + return err +} + +func (flow *BootstrapFlow) ensureUserExists(user *core.User) error { + _, err := flow.userStorage.GetUserById(user.ID) + if err != nil { + if err.Error() == "record not found" { + return flow.userStorage.CreateUser(user) + } + flow.l.Error(err) + } + return err +} diff --git a/usecases/bootstrap_flow_test.go b/usecases/bootstrap_flow_test.go new file mode 100644 index 0000000..4e3934b --- /dev/null +++ b/usecases/bootstrap_flow_test.go @@ -0,0 +1,70 @@ +package usecases_test + +import ( + "sync" + "testing" + + "github.com/ailinykh/pullanusbot/v2/test_helpers" + "github.com/ailinykh/pullanusbot/v2/usecases" + "github.com/stretchr/testify/assert" +) + +func Test_HandleText_CreateUserData(t *testing.T) { + logger := test_helpers.CreateLogger() + chatStorage := test_helpers.CreateChatStorage() + userStorage := test_helpers.CreateUserStorage() + bootstrapFlow := usecases.CreateBootstrapFlow(logger, chatStorage, userStorage) + + bot := test_helpers.CreateBot() + + messages := []string{ + "/start", + "/start payload", + "/start another_payload", + } + wg := sync.WaitGroup{} + + for _, message := range messages { + wg.Add(1) + go func(text string) { + bootstrapFlow.HandleText(makeMessage(text), bot) + wg.Done() + }(message) + } + + wg.Wait() + + assert.Equal(t, 1, len(userStorage.Users)) + + message := makeMessage("/start") + user, _ := userStorage.GetUserById(message.Sender.ID) + assert.Equal(t, message.Sender, user) +} + +func Test_HandleText_CreateChatData(t *testing.T) { + logger := test_helpers.CreateLogger() + chatStorage := test_helpers.CreateChatStorage() + userStorage := test_helpers.CreateUserStorage() + bootstrapFlow := usecases.CreateBootstrapFlow(logger, chatStorage, userStorage) + + bot := test_helpers.CreateBot() + + messages := []string{ + "/start", + "/some_command", + "some text message", + } + wg := sync.WaitGroup{} + + for _, message := range messages { + wg.Add(1) + go func(text string) { + bootstrapFlow.HandleText(makeMessage(text), bot) + wg.Done() + }(message) + } + + wg.Wait() + + assert.Equal(t, 1, len(chatStorage.Chats)) +} diff --git a/usecases/chat_storage_decorator.go b/usecases/chat_storage_decorator.go new file mode 100644 index 0000000..0767ba6 --- /dev/null +++ b/usecases/chat_storage_decorator.go @@ -0,0 +1,33 @@ +package usecases + +import "github.com/ailinykh/pullanusbot/v2/core" + +func CreateChatStorageDecorator(l core.ILogger, cache core.IChatStorage, db core.IChatStorage) core.IChatStorage { + return &ChatStorageDecorator{l, cache, db} +} + +type ChatStorageDecorator struct { + l core.ILogger + cache core.IChatStorage + db core.IChatStorage +} + +// GetChatByID is a core.IChatStorage interface implementation +func (decorator *ChatStorageDecorator) GetChatByID(chatID int64) (*core.Chat, error) { + chat, err := decorator.cache.GetChatByID(chatID) + if err != nil { + chat, err := decorator.db.GetChatByID(chatID) + if err != nil { + return nil, err + } + _ = decorator.cache.CreateChat(chat.ID, chat.Title, chat.Type) + return chat, nil + } + return chat, nil +} + +// CreateChat is a core.IChatStorage interface implementation +func (decorator *ChatStorageDecorator) CreateChat(chatID int64, title string, type_ string) error { + _ = decorator.cache.CreateChat(chatID, title, type_) + return decorator.db.CreateChat(chatID, title, type_) +} diff --git a/usecases/command_service.go b/usecases/command_service.go new file mode 100644 index 0000000..75e41f8 --- /dev/null +++ b/usecases/command_service.go @@ -0,0 +1,88 @@ +package usecases + +import ( + "sort" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateCommandService(l core.ILogger) core.ICommandService { + return &CommandService{l, make(map[int64][]core.Command)} +} + +type CommandService struct { + l core.ILogger + cache map[int64][]core.Command +} + +type ByText []core.Command + +func (t ByText) Len() int { return len(t) } +func (t ByText) Less(i, j int) bool { return t[i].Text < t[j].Text } +func (t ByText) Swap(i, j int) { t[i], t[j] = t[j], t[i] } + +// EnableCommands is a core.ICommandService interface implementation +func (service *CommandService) EnableCommands(chatID int64, commands []core.Command, bot core.IBot) error { + var existing []core.Command + var err error + if found, ok := service.cache[chatID]; ok { + existing = found + } else { + existing, err = bot.GetCommands(chatID) + if err != nil { + return nil + } + } + + new := []core.Command{} + for _, c := range commands { + if service.contains(c, existing) { + continue + } + new = append(new, c) + } + + if len(new) == 0 { + // service.l.Warning("all the commands already enabled") + return nil + } + + new = append(new, existing...) + service.cache[chatID] = new + sort.Sort(ByText(new)) + return bot.SetCommands(chatID, new) +} + +// DisableCommands is a core.ICommandService interface implementation +func (service *CommandService) DisableCommands(chatID int64, commands []core.Command, bot core.IBot) error { + var existing []core.Command + var err error + if found, ok := service.cache[chatID]; ok { + existing = found + } else { + existing, err = bot.GetCommands(chatID) + if err != nil { + return nil + } + } + + actual := []core.Command{} + for _, c := range existing { + if service.contains(c, commands) { + continue + } + actual = append(actual, c) + } + + service.cache[chatID] = actual + return bot.SetCommands(chatID, actual) +} + +func (CommandService) contains(command core.Command, commands []core.Command) bool { + for _, c := range commands { + if c.Text == command.Text { + return true + } + } + return false +} diff --git a/usecases/command_service_test.go b/usecases/command_service_test.go new file mode 100644 index 0000000..1d8b6f2 --- /dev/null +++ b/usecases/command_service_test.go @@ -0,0 +1,40 @@ +package usecases_test + +import ( + "testing" + + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/test_helpers" + "github.com/ailinykh/pullanusbot/v2/usecases" + "github.com/stretchr/testify/assert" +) + +func Test_EnableCommands_DoNotCallsSetCommandsMoreThanOneTime(t *testing.T) { + bot := test_helpers.CreateBot() + logger := test_helpers.CreateLogger() + service := usecases.CreateCommandService(logger) + + service.EnableCommands(1, []core.Command{{Text: "c1", Description: "d1"}}, bot) + assert.Equal(t, []string{"get commands 1", "set commands 1 [{c1 d1}]"}, bot.ActionLog) + + service.EnableCommands(1, []core.Command{{Text: "c1", Description: "d1"}}, bot) + assert.Equal(t, []string{"get commands 1", "set commands 1 [{c1 d1}]"}, bot.ActionLog) + + service.EnableCommands(1, []core.Command{{Text: "c2", Description: "d2"}}, bot) + assert.Equal(t, []string{"get commands 1", "set commands 1 [{c1 d1}]", "set commands 1 [{c1 d1} {c2 d2}]"}, bot.ActionLog) +} + +func Test_DisableCommands_DoNotCallsSetCommandsMoreThanOneTime(t *testing.T) { + bot := test_helpers.CreateBot() + logger := test_helpers.CreateLogger() + service := usecases.CreateCommandService(logger) + + service.EnableCommands(14, []core.Command{ + {Text: "one", Description: "1"}, + {Text: "two", Description: "2"}, + }, bot) + assert.Equal(t, []string{"get commands 14", "set commands 14 [{one 1} {two 2}]"}, bot.ActionLog) + + service.DisableCommands(14, []core.Command{{Text: "two", Description: "2"}}, bot) + assert.Equal(t, []string{"get commands 14", "set commands 14 [{one 1} {two 2}]", "set commands 14 [{one 1}]"}, bot.ActionLog) +} diff --git a/usecases/faggot_game.go b/usecases/faggot_game.go new file mode 100644 index 0000000..9eea885 --- /dev/null +++ b/usecases/faggot_game.go @@ -0,0 +1,310 @@ +package usecases + +import ( + "encoding/json" + "fmt" + "math/rand" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreateGameFlow is a simple GameFlow factory +func CreateGameFlow(l core.ILogger, t core.ILocalizer, s core.IGameStorage, r core.IRand, settings core.ISettingsProvider, commandService core.ICommandService) *GameFlow { + return &GameFlow{l, t, s, r, settings, commandService, sync.Mutex{}} +} + +// GameFlow represents faggot game logic +type GameFlow struct { + l core.ILogger + t core.ILocalizer + s core.IGameStorage + r core.IRand + settings core.ISettingsProvider + commandService core.ICommandService + mutex sync.Mutex +} + +// Rules of the game +func (flow *GameFlow) Rules(message *core.Message, bot core.IBot) error { + if message.IsPrivate { + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_not_available_for_private")) + return err + } + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_rules")) + return err +} + +// Add a new player to game +func (flow *GameFlow) Add(message *core.Message, bot core.IBot) error { + if message.IsPrivate { + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_not_available_for_private")) + return err + } + players, _ := flow.s.GetPlayers(message.Chat.ID) + for _, p := range players { + if p.ID == message.Sender.ID { + if p.FirstName != message.Sender.FirstName || p.LastName != message.Sender.LastName || p.Username != message.Sender.Username { + _ = flow.s.UpdatePlayer(message.Chat.ID, message.Sender) + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_info_updated")) + return err + } + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_already_in_game")) + return err + } + } + + err := flow.s.AddPlayer(message.Chat.ID, message.Sender) + if err != nil { + return err + } + + _, err = bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_added_to_game")) + return err +} + +// Play game +func (flow *GameFlow) Play(message *core.Message, bot core.IBot) error { + if message.IsPrivate { + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_not_available_for_private")) + return err + } + flow.mutex.Lock() + defer flow.mutex.Unlock() + + flow.checkSettings(message.Chat.ID, bot) + + flow.l.Infof("chat_id: %d, game started by %v", message.Chat.ID, message.Sender) + + players, _ := flow.s.GetPlayers(message.Chat.ID) + switch len(players) { + case 0: + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_no_players", message.Sender.DisplayName())) + return err + case 1: + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_not_enough_players")) + return err + } + + games, _ := flow.s.GetRounds(message.Chat.ID) + loc, _ := time.LoadLocation("Europe/Zurich") + day := time.Now().In(loc).Format("2006-01-02") + + for _, r := range games { + if r.Day == day { + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_winner_known", r.Winner.DisplayName())) + return err + } + } + + winner := players[rand.Intn(len(players))] + + if !bot.IsUserMemberOfChat(winner, message.Chat.ID) { + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_winner_left")) + return err + } + + flow.l.Infof("day: %s, winner: %v", day, winner) + + if winner.ID == message.Sender.ID { + if winner.FirstName != message.Sender.FirstName || winner.LastName != message.Sender.LastName || winner.Username != message.Sender.Username { + err := flow.s.UpdatePlayer(message.Chat.ID, message.Sender) + if err != nil { + flow.l.Error(err) + } else { + flow.l.Infof("player info updated: %v", winner) + } + } + } + + round := &core.Round{Day: day, Winner: winner} + flow.s.AddRound(message.Chat.ID, round) + + for i := 0; i <= 3; i++ { + templates := []string{} + for _, key := range flow.t.AllKeys() { + if strings.HasPrefix(key, fmt.Sprintf("faggot_game_%d", i)) { + templates = append(templates, key) + } + } + template := templates[rand.Intn(len(templates))] + phrase := flow.t.I18n(message.Sender.LanguageCode, template) + + if i == 3 { + // TODO: implementation detail leaked + if len(winner.Username) == 0 { + phrase = flow.t.I18n(message.Sender.LanguageCode, template, fmt.Sprintf(`%s %s`, winner.ID, winner.FirstName, winner.LastName)) + } else { + phrase = flow.t.I18n(message.Sender.LanguageCode, template, "@"+winner.Username) + } + } + + _, err := bot.SendText(phrase) + if err != nil { + flow.l.Error(err) + } + + if os.Getenv("GO_ENV") != "testing" { + r := rand.Intn(3) + 1 + time.Sleep(time.Duration(r) * time.Second) + } + } + + return nil +} + +// All statistics for all time +func (flow *GameFlow) All(message *core.Message, bot core.IBot) error { + if message.IsPrivate { + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_not_available_for_private")) + return err + } + + entries, _ := flow.getStat(message) + messages := []string{flow.t.I18n(message.Sender.LanguageCode, "faggot_all_top"), ""} + for i, e := range entries { + message := flow.t.I18n(message.Sender.LanguageCode, "faggot_all_entry", i+1, e.Player.DisplayName(), e.Score) + messages = append(messages, message) + } + messages = append(messages, "", flow.t.I18n(message.Sender.LanguageCode, "faggot_all_bottom", len(entries))) + _, err := bot.SendText(strings.Join(messages, "\n")) + return err +} + +// Stats returns current year statistics +func (flow *GameFlow) Stats(message *core.Message, bot core.IBot) error { + if message.IsPrivate { + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_not_available_for_private")) + return err + } + + year := strconv.Itoa(time.Now().Year()) + rounds, _ := flow.s.GetRounds(message.Chat.ID) + entries := []Stat{} + players := map[int64]bool{} + + for _, r := range rounds { + players[r.Winner.ID] = true + if strings.HasPrefix(r.Day, year) { + index := Find(entries, r.Winner.ID) + if index == -1 { + entries = append(entries, Stat{Player: r.Winner, Score: 1}) + } else { + entries[index].Score++ + } + } + } + + sort.Slice(entries, func(i, j int) bool { + if entries[i].Score == entries[j].Score { + return entries[i].Player.Username < entries[j].Player.Username + } + return entries[i].Score > entries[j].Score + }) + + messages := []string{flow.t.I18n(message.Sender.LanguageCode, "faggot_stats_top"), ""} + max := len(entries) + if max > 10 { + max = 10 // Top 10 only + } + for i, e := range entries[:max] { + message := flow.t.I18n(message.Sender.LanguageCode, "faggot_stats_entry", i+1, e.Player.DisplayName(), e.Score) + messages = append(messages, message) + } + messages = append(messages, "", flow.t.I18n(message.Sender.LanguageCode, "faggot_stats_bottom", len(players))) + _, err := bot.SendText(strings.Join(messages, "\n")) + return err +} + +// Me returns your personal statistics +func (flow *GameFlow) Me(message *core.Message, bot core.IBot) error { + if message.IsPrivate { + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_not_available_for_private")) + return err + } + + entries, _ := flow.getStat(message) + score := 0 + for _, e := range entries { + if e.Player.ID == message.Sender.ID { + score = e.Score + } + } + _, err := bot.SendText(flow.t.I18n(message.Sender.LanguageCode, "faggot_me", message.Sender.DisplayName(), score)) + return err +} + +func (flow *GameFlow) getStat(message *core.Message) ([]Stat, error) { + entries := []Stat{} + rounds, err := flow.s.GetRounds(message.Chat.ID) + + if err != nil { + return nil, err + } + + for _, r := range rounds { + index := Find(entries, r.Winner.ID) + if index == -1 { + entries = append(entries, Stat{Player: r.Winner, Score: 1}) + } else { + entries[index].Score++ + } + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Score > entries[j].Score + }) + + return entries, nil +} + +func (flow *GameFlow) checkSettings(chatID core.ChatID, bot core.IBot) error { + data, err := flow.settings.GetData(chatID, core.SFaggotGameEnabled) + + if err != nil { + flow.l.Error(err) + } + + var settingsV1 struct { + Enabled bool + } + + err = json.Unmarshal(data, &settingsV1) + if err != nil { + flow.l.Error(err) + // TODO: perform a migration + } + + if settingsV1.Enabled { + return nil + } + + settingsV1.Enabled = true + data, err = json.Marshal(settingsV1) + if err != nil { + flow.l.Error(err) + return err + } + + err = flow.settings.SetData(chatID, core.SFaggotGameEnabled, data) + if err != nil { + flow.l.Error(err) + return err + } + + commands := []core.Command{ + {Text: "pidor", Description: "play the game, see /pidorules first"}, + {Text: "pidorules", Description: "POTD game rules"}, + {Text: "pidoreg", Description: "register for POTD game"}, + {Text: "pidorstats", Description: "POTD game stats for this year"}, + {Text: "pidorall", Description: "POTD game stats for all time"}, + {Text: "pidorme", Description: "POTD personal stats"}, + } + + return flow.commandService.EnableCommands(chatID, commands, bot) +} diff --git a/usecases/faggot_game_test.go b/usecases/faggot_game_test.go new file mode 100644 index 0000000..b9d0107 --- /dev/null +++ b/usecases/faggot_game_test.go @@ -0,0 +1,370 @@ +package usecases_test + +import ( + "fmt" + "strconv" + "strings" + "testing" + "time" + + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/test_helpers" + "github.com/ailinykh/pullanusbot/v2/usecases" + "github.com/stretchr/testify/assert" +) + +func Test_AllTheCommands_WorksOnlyInGroupChats(t *testing.T) { + game, bot, _ := makeSUT(map[string]string{"faggot_not_available_for_private": "group only"}) + message := makeGameMessage(1, "Faggot") + message.IsPrivate = true + + game.Rules(message, bot) + game.Add(message, bot) + game.Play(message, bot) + game.Stats(message, bot) + game.All(message, bot) + game.Me(message, bot) + + for _, m := range bot.SentMessages { + assert.Equal(t, "group only", m) + } +} +func Test_RulesCommand_DeliversRules(t *testing.T) { + game, bot, _ := makeSUT(map[string]string{"faggot_rules": "Game rules:"}) + message := makeGameMessage(1, "Faggot") + + game.Rules(message, bot) + + assert.Equal(t, "Game rules:", bot.SentMessages[0]) +} + +func Test_Add_ChecksAndReplacesPlayerInfoIfNeeded(t *testing.T) { + game, bot, storage := makeSUT() + message := makeGameMessage(1, "Faggot") + player := *message.Sender + player.Username = "old_username" + storage.players = []*core.User{&player} + + game.Add(message, bot) + + assert.Equal(t, []*core.User{message.Sender}, storage.players) + assert.Equal(t, []string{"faggot_info_updated"}, bot.SentMessages) +} + +func Test_Add_AppendsPlayerInGameOnlyOnce(t *testing.T) { + game, bot, storage := makeSUT(map[string]string{ + "faggot_added_to_game": "Player added", + "faggot_already_in_game": "Player already in game", + }) + message := makeGameMessage(1, "Faggot") + + game.Add(message, bot) + + assert.Equal(t, storage.players, []*core.User{message.Sender}) + assert.Equal(t, "Player added", bot.SentMessages[0]) + + game.Add(message, bot) + + assert.Equal(t, storage.players, []*core.User{message.Sender}) + assert.Equal(t, "Player already in game", bot.SentMessages[1]) +} + +func Test_Play_RespondsWithNoPlayers(t *testing.T) { + message := makeGameMessage(1, "Faggot") + game, bot, _ := makeSUT(map[string]string{ + "faggot_no_players": "Nobody in game. So you win, %s!", + }, message) + + err := game.Play(message, bot) + + assert.Nil(t, err) + assert.Equal(t, "Nobody in game. So you win, Faggot!", bot.SentMessages[0]) +} + +func Test_Play_RespondsNotEnoughPlayers(t *testing.T) { + message := makeGameMessage(1, "Faggot") + game, bot, _ := makeSUT(map[string]string{ + "faggot_not_enough_players": "Not enough players", + }, message) + + game.Add(message, bot) + game.Play(message, bot) + + assert.Equal(t, "Not enough players", bot.SentMessages[1]) +} + +func Test_Play_RespondsWithCurrentGameResult(t *testing.T) { + m1 := makeGameMessage(1, "") + m2 := makeGameMessage(2, "") + game, bot, storage := makeSUT(map[string]string{ + "faggot_game_0_0": "0", + "faggot_game_1_0": "1", + "faggot_game_2_0": "2", + "faggot_game_3_0": "%s", + }, m1, m2) + bot.ChatMembers[0] = []string{""} + + game.Add(m1, bot) + game.Add(m2, bot) + game.Play(m1, bot) + + winner := storage.rounds[0].Winner + phrase := fmt.Sprintf(`%s %s`, winner.ID, winner.FirstName, winner.LastName) + assert.Equal(t, "0", bot.SentMessages[2]) + assert.Equal(t, "1", bot.SentMessages[3]) + assert.Equal(t, "2", bot.SentMessages[4]) + assert.Equal(t, phrase, bot.SentMessages[5]) +} +func Test_Play_RespondsWinnerAlreadyKnown(t *testing.T) { + m1 := makeGameMessage(1, "Faggot1") + m2 := makeGameMessage(2, "Faggot2") + game, bot, storage := makeSUT(map[string]string{ + "faggot_game_0_0": "0", + "faggot_game_1_0": "1", + "faggot_game_2_0": "2", + "faggot_game_3_0": "3 %s", + "faggot_winner_known": "Winner already known %s", + }, m1) + bot.ChatMembers[0] = []string{"Faggot1", "Faggot2"} + + game.Add(m1, bot) + game.Add(m2, bot) + game.Play(m1, bot) + + winner := storage.rounds[0].Winner.Username + assert.Equal(t, "0", bot.SentMessages[2]) + assert.Equal(t, "1", bot.SentMessages[3]) + assert.Equal(t, "2", bot.SentMessages[4]) + assert.Equal(t, fmt.Sprintf("3 @%s", winner), bot.SentMessages[5]) + + game.Play(m1, bot) + + assert.Equal(t, fmt.Sprintf("Winner already known %s", winner), bot.SentMessages[6]) +} + +func Test_Play_RespondsWinnerLeftTheChat(t *testing.T) { + m1 := makeGameMessage(1, "Faggot1") + m2 := makeGameMessage(2, "Faggot2") + game, bot, storage := makeSUT(map[string]string{ + "faggot_winner_left": "winner left", + }, m1) + + storage.players = []*core.User{m1.Sender, m2.Sender} + + game.Play(m1, bot) + + assert.Equal(t, []*core.Round{}, storage.rounds) + assert.Equal(t, []string{"winner left"}, bot.SentMessages) +} + +func Test_Stats_RespondsWithDescendingResultsForCurrentYear(t *testing.T) { + year := strconv.Itoa(time.Now().Year()) + game, bot, storage := makeSUT(map[string]string{ + "faggot_stats_top": "top", + "faggot_stats_entry": "index:%d,player:%s,scores:%d", + "faggot_stats_bottom": "total_players:%d", + }) + + expected := []string{ + "top", + "", + "index:1,player:Faggot3,scores:3", + "index:2,player:Faggot1,scores:2", + "index:3,player:Faggot2,scores:1", + "", + "total_players:4", + } + + m1 := makeGameMessage(1, "Faggot1") + m2 := makeGameMessage(2, "Faggot2") + m3 := makeGameMessage(3, "Faggot3") + m4 := makeGameMessage(4, "Faggot4") + + storage.rounds = []*core.Round{ + {Day: year + "-01-01", Winner: m2.Sender}, + {Day: "2020-01-02", Winner: m3.Sender}, + {Day: "2020-01-03", Winner: m4.Sender}, + {Day: year + "-01-02", Winner: m3.Sender}, + {Day: year + "-01-03", Winner: m3.Sender}, + {Day: year + "-01-04", Winner: m3.Sender}, + {Day: year + "-01-05", Winner: m1.Sender}, + {Day: year + "-01-06", Winner: m1.Sender}, + } + + game.Stats(m1, bot) + assert.Equal(t, expected, strings.Split(bot.SentMessages[0], "\n")) +} + +func Test_Stats_RespondsOnlyForTop10Players(t *testing.T) { + game, bot, storage := makeSUT(map[string]string{ + "faggot_stats_top": "top", + "faggot_stats_entry": "index:%d,player:%s,scores:%d", + "faggot_stats_bottom": "total_players:%d", + }) + + expected := []string{ + "top", + "", + "index:1,player:Faggot01,scores:1", + "index:2,player:Faggot02,scores:1", + "index:3,player:Faggot03,scores:1", + "index:4,player:Faggot04,scores:1", + "index:5,player:Faggot05,scores:1", + "index:6,player:Faggot06,scores:1", + "index:7,player:Faggot07,scores:1", + "index:8,player:Faggot08,scores:1", + "index:9,player:Faggot09,scores:1", + "index:10,player:Faggot10,scores:1", + "", + "total_players:99", + } + + var messages []*core.Message + var i int64 + for i = 1; i < 100; i++ { + messages = append(messages, makeGameMessage(i, fmt.Sprintf("Faggot%02d", i))) + } + + for i, m := range messages { + day := fmt.Sprintf("%d-%02d-%02d", time.Now().Year(), i/30+1, i%30) + storage.rounds = append(storage.rounds, &core.Round{Day: day, Winner: m.Sender}) + } + + game.Stats(messages[0], bot) + assert.Equal(t, expected, strings.Split(bot.SentMessages[0], "\n")) +} + +func Test_All_RespondsWithDescendingResultsForAllTime(t *testing.T) { + game, bot, storage := makeSUT(map[string]string{ + "faggot_all_top": "top", + "faggot_all_entry": "index:%d,player:%s,scores:%d", + "faggot_all_bottom": "total_players:%d", + }) + + expected := []string{ + "top", + "", + "index:1,player:Faggot3,scores:4", + "index:2,player:Faggot1,scores:2", + "index:3,player:Faggot2,scores:1", + "", + "total_players:3", + } + + m1 := makeGameMessage(1, "Faggot1") + m2 := makeGameMessage(2, "Faggot2") + m3 := makeGameMessage(3, "Faggot3") + + storage.rounds = []*core.Round{ + {Day: "2021-01-01", Winner: m2.Sender}, + {Day: "2020-01-02", Winner: m3.Sender}, + {Day: "2020-01-02", Winner: m3.Sender}, + {Day: "2021-01-03", Winner: m3.Sender}, + {Day: "2021-01-04", Winner: m3.Sender}, + {Day: "2021-01-05", Winner: m1.Sender}, + {Day: "2021-01-06", Winner: m1.Sender}, + } + + game.All(m1, bot) + assert.Equal(t, expected, strings.Split(bot.SentMessages[0], "\n")) +} + +func Test_Me_RespondsWithPersonalStat(t *testing.T) { + game, bot, storage := makeSUT(map[string]string{ + "faggot_me": "username:%s,scores:%d", + }) + + m1 := makeGameMessage(1, "Faggot1") + m2 := makeGameMessage(2, "Faggot2") + + storage.rounds = []*core.Round{ + {Day: "2021-01-01", Winner: m2.Sender}, + {Day: "2021-01-05", Winner: m1.Sender}, + {Day: "2021-01-06", Winner: m1.Sender}, + } + + game.Me(m1, bot) + assert.Equal(t, fmt.Sprintf("username:%s,scores:%d", m1.Sender.Username, 2), bot.SentMessages[0]) + + game.Me(m2, bot) + assert.Equal(t, fmt.Sprintf("username:%s,scores:%d", m2.Sender.Username, 1), bot.SentMessages[1]) +} + +// Helpers + +func makeGameMessage(id int64, username string) *core.Message { + player := &core.User{ + ID: id, + FirstName: "FirstName" + fmt.Sprint(id), + LastName: "LastName" + fmt.Sprint(id), + Username: username, + } + return &core.Message{ID: 0, Chat: &core.Chat{ID: 0}, Sender: player} +} + +func makeSUT(args ...interface{}) (*usecases.GameFlow, *test_helpers.FakeBot, *GameStorageMock) { + dict := map[string]string{} + storage := &GameStorageMock{players: []*core.User{}, rounds: []*core.Round{}} + bot := test_helpers.CreateBot() + l := &test_helpers.FakeLogger{} + s := test_helpers.CreateSettingsProvider() + + for _, arg := range args { + switch opt := arg.(type) { + case map[string]string: + dict = opt + case *core.Message: + s.SetData(opt.Chat.ID, "key", []byte{}) + } + } + + t := test_helpers.CreateLocalizer(dict) + c := test_helpers.CreateCommandService(l) + r := &RandMock{} + game := usecases.CreateGameFlow(l, t, storage, r, s, c) + return game, bot, storage +} + +// GameStorageMock + +type GameStorageMock struct { + players []*core.User + rounds []*core.Round +} + +func (s *GameStorageMock) AddPlayer(gameID int64, player *core.User) error { + s.players = append(s.players, player) + return nil +} + +func (s *GameStorageMock) UpdatePlayer(gameID int64, user *core.User) error { + for _, p := range s.players { + if p.ID == user.ID { + p.FirstName = user.FirstName + p.LastName = user.LastName + p.Username = user.Username + } + } + return nil +} + +func (s *GameStorageMock) GetPlayers(gameID int64) ([]*core.User, error) { + return s.players, nil +} + +func (s *GameStorageMock) AddRound(gameID int64, round *core.Round) error { + s.rounds = append(s.rounds, round) + return nil +} + +func (s *GameStorageMock) GetRounds(gameID int64) ([]*core.Round, error) { + return s.rounds, nil +} + +// IRandMock + +type RandMock struct{} + +func (RandMock) GetRand(int) int { + return 1 +} diff --git a/usecases/faggot_stat.go b/usecases/faggot_stat.go new file mode 100644 index 0000000..1983efe --- /dev/null +++ b/usecases/faggot_stat.go @@ -0,0 +1,21 @@ +package usecases + +import ( + "github.com/ailinykh/pullanusbot/v2/core" +) + +// Stat represents game statistics +type Stat struct { + Player *core.User + Score int +} + +// Find player by username in current stat +func Find(a []Stat, id int64) int { + for i, n := range a { + if id == n.Player.ID { + return i + } + } + return -1 +} diff --git a/usecases/i_do_not_care.go b/usecases/i_do_not_care.go new file mode 100644 index 0000000..fa0ac57 --- /dev/null +++ b/usecases/i_do_not_care.go @@ -0,0 +1,33 @@ +package usecases + +import ( + "strings" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateIDoNotCare() *IDoNotCare { + return &IDoNotCare{} +} + +type IDoNotCare struct{} + +// HandleText is a core.ITextHandler protocol implementation +func (IDoNotCare) HandleText(message *core.Message, bot core.IBot) error { + if strings.Contains(strings.ToLower(message.Text), "мне всё равно") { + _, err := bot.SendVideo(&core.Video{ID: "BAACAgIAAxkBAAEDfu1kFdKdAAHM4iO92LOC3muxi2yyvosAAgQoAAIZvLFIVaKgRXqfmVgvBA"}, "") + if err != nil { + media := &core.Media{ + ResourceURL: "https://telegra.ph/file/182c624365bea4df6842a.mp4", + Type: core.TVideo, + } + _, err = bot.SendMedia(media) + } + return err + } + if strings.Contains(strings.ToLower(message.Text), "привет, андрей") { + _, err := bot.SendVideo(&core.Video{ID: "BAACAgIAAxkBAAIziWEeZBqlM1_1n2AVaxedGFn3vS-sAAKgDwACSl7xSImLuE-s8DMbIAQ"}, "") + return err + } + return nil +} diff --git a/usecases/image_flow.go b/usecases/image_flow.go new file mode 100644 index 0000000..44c71d8 --- /dev/null +++ b/usecases/image_flow.go @@ -0,0 +1,38 @@ +package usecases + +import "github.com/ailinykh/pullanusbot/v2/core" + +// CreateImageFlow is a basic ImageFlow factory +func CreateImageFlow(l core.ILogger, fileUploader core.IFileUploader, imageDownloader core.IImageDownloader) *ImageFlow { + return &ImageFlow{l, fileUploader, imageDownloader} +} + +// ImageFlow represents convert image to hotlink logic +type ImageFlow struct { + l core.ILogger + fileUploader core.IFileUploader + imageDownloader core.IImageDownloader +} + +// HandleImage is a core.IImageHandler protocol implementation +func (flow *ImageFlow) HandleImage(image *core.Image, message *core.Message, bot core.IBot) error { + if !message.IsPrivate { + return nil + } + + file, err := flow.imageDownloader.Download(image) + if err != nil { + return err + } + //TODO: memory management + defer file.Dispose() + + url, err := flow.fileUploader.Upload(file) + if err != nil { + return err + } + + flow.l.Info(url) + _, err = bot.SendText(url) + return err +} diff --git a/usecases/image_flow_test.go b/usecases/image_flow_test.go new file mode 100644 index 0000000..90cf02e --- /dev/null +++ b/usecases/image_flow_test.go @@ -0,0 +1,29 @@ +package usecases_test + +import ( + "testing" + + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/test_helpers" + "github.com/ailinykh/pullanusbot/v2/usecases" + "github.com/stretchr/testify/assert" +) + +func Test_HandleImage_DownloadsAndUploadsImage(t *testing.T) { + logger := test_helpers.CreateLogger() + file_uploader := test_helpers.CreateFileUploader() + image_downloader := test_helpers.CreateImageDownloader() + image_flow := usecases.CreateImageFlow(logger, file_uploader, image_downloader) + + url := "http://an-image-url.com" + path := "/an/image/path.jpg" + image := &core.Image{FileURL: url, File: core.File{Path: path}} + + message := &core.Message{IsPrivate: true} + bot := test_helpers.CreateBot() + + image_flow.HandleImage(image, message, bot) + + assert.Equal(t, []string{url}, image_downloader.Downloaded) + assert.Equal(t, []string{path}, file_uploader.Uploaded) +} diff --git a/usecases/instagram_flow.go b/usecases/instagram_flow.go new file mode 100644 index 0000000..c210f9f --- /dev/null +++ b/usecases/instagram_flow.go @@ -0,0 +1,103 @@ +package usecases + +import ( + "fmt" + "regexp" + + "github.com/ailinykh/pullanusbot/v2/api" + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateInstagramFlow(l core.ILogger, api api.InstAPI, createVideo core.IVideoFactory, sendMedia core.ISendMediaStrategy, sendVideo core.ISendVideoStrategy) core.ITextHandler { + return &InstagramFlow{l, api, createVideo, sendMedia, sendVideo} +} + +type InstagramFlow struct { + l core.ILogger + api api.InstAPI + createVideo core.IVideoFactory + sendMedia core.ISendMediaStrategy + sendVideo core.ISendVideoStrategy +} + +// HandleText is a core.ITextHandler protocol implementation +func (flow *InstagramFlow) HandleText(message *core.Message, bot core.IBot) error { + r := regexp.MustCompile(`https://www.instagram.com/reel/\S+`) + rmatch := r.FindAllString(message.Text, -1) + + switch len(rmatch) { + case 0: + break + case 1: + return flow.handleReel(rmatch[0], message, bot) + default: + for _, reel := range rmatch { + err := flow.handleReel(reel, message, bot) + if err != nil { + flow.l.Error(err) + return err + } + } + // FIXME: temporal coupling + return fmt.Errorf("do not remove source message") + } + + t := regexp.MustCompile(`https://www.instagram.com/tv/\S+`) + tmatch := t.FindAllString(message.Text, -1) + + // TODO: multiple tv? + if len(tmatch) > 0 { + return flow.handleReel(tmatch[0], message, bot) + } + + return fmt.Errorf("not implemented") +} + +func (flow *InstagramFlow) handleReel(url string, message *core.Message, bot core.IBot) error { + flow.l.Infof("processing %s", url) + reel, err := flow.api.GetReel(url) + if err != nil { + flow.l.Error(err) + return err + } + + if len(reel.Items) < 1 { + return fmt.Errorf("insufficient reel items") + } + + item := reel.Items[0] + + caption := item.Caption.Text + if info := item.ClipsMetadata.MusicInfo; info != nil { + caption = fmt.Sprintf("\n🎶 %s - %s\n\n%s", info.MusicAssetInfo.ProgressiveDownloadURL, info.MusicAssetInfo.DisplayArtist, info.MusicAssetInfo.Title, caption) + } + caption = fmt.Sprintf("📷 %s (by %s)\n%s", url, item.User.FullName, message.Sender.DisplayName(), caption) + + if item.VideoDuration < 360 { // apparently 6 min file takes less than 50 MB + return flow.sendAsMedia(item, caption, message, bot) + } + + video, err := flow.createVideo.CreateVideo(item.VideoVersions[0].URL) + if err != nil { + flow.l.Error(err) + return err + } + defer video.Dispose() + + return flow.sendVideo.SendVideo(video, caption, bot) +} + +func (flow *InstagramFlow) sendAsMedia(item api.IgReelItem, caption string, message *core.Message, bot core.IBot) error { + media := &core.Media{ + ResourceURL: item.VideoVersions[0].URL, + URL: "https://www.instagram.com/reel/" + item.Code + "/", + Title: item.User.FullName, + Caption: caption, + } + + err := flow.sendMedia.SendMedia([]*core.Media{media}, bot) + if err != nil { + flow.l.Error(err) + } + return err +} diff --git a/usecases/link_flow.go b/usecases/link_flow.go new file mode 100644 index 0000000..3fa89f2 --- /dev/null +++ b/usecases/link_flow.go @@ -0,0 +1,69 @@ +package usecases + +import ( + "fmt" + "path" + "regexp" + "strings" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreateLinkFlow is a basic LinkFlow factory +func CreateLinkFlow(l core.ILogger, httpClient core.IHttpClient, mediaFactory core.IMediaFactory, sendMediaStrategy core.ISendMediaStrategy) *LinkFlow { + return &LinkFlow{l, httpClient, mediaFactory, sendMediaStrategy} +} + +// LinkFlow converts hotlink to video/photo attachment +type LinkFlow struct { + l core.ILogger + httpClient core.IHttpClient + mediaFactory core.IMediaFactory + sendMediaStrategy core.ISendMediaStrategy +} + +// HandleText is a core.ITextHandler protocol implementation +func (flow *LinkFlow) HandleText(message *core.Message, bot core.IBot) error { + r := regexp.MustCompile(`^http(\S+)$`) + if r.MatchString(message.Text) { + return flow.handleURL(message.Text, message, bot) + } + return fmt.Errorf("not implemented") +} + +func (flow *LinkFlow) handleURL(url core.URL, message *core.Message, bot core.IBot) error { + contentType, err := flow.httpClient.GetContentType(url) + if err != nil { + flow.l.Error(err, url) + return err + } + + if !strings.HasPrefix(contentType, "video") && !strings.HasPrefix(contentType, "image") { + return fmt.Errorf("not implemented") + } + + media, err := flow.mediaFactory.CreateMedia(url) + if err != nil { + flow.l.Error(err) + return err + } + + for _, m := range media { + switch m.Type { + case core.TPhoto: + m.Caption = fmt.Sprintf(`🖼 %s (by %s)`, m.URL, path.Base(m.URL), message.Sender.DisplayName()) + case core.TVideo: + m.Caption = fmt.Sprintf(`🔗 %s (by %s)`, m.URL, path.Base(m.URL), message.Sender.DisplayName()) + case core.TText: + flow.l.Warningf("Unexpected %+v", m) + } + } + + err = flow.sendMediaStrategy.SendMedia(media, bot) + if err != nil { + flow.l.Error(err) + return err + } + + return nil +} diff --git a/usecases/link_flow_test.go b/usecases/link_flow_test.go new file mode 100644 index 0000000..2925949 --- /dev/null +++ b/usecases/link_flow_test.go @@ -0,0 +1,28 @@ +package usecases_test + +import ( + "testing" + + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/test_helpers" + "github.com/ailinykh/pullanusbot/v2/usecases" + "github.com/stretchr/testify/assert" +) + +func Test_HandleUrl_ConvertsVideoUrlToVideo(t *testing.T) { + bot := test_helpers.CreateBot() + logger := test_helpers.CreateLogger() + http_client := test_helpers.CreateHttpClient() + media_factory := test_helpers.CreateMediaFactory() + send_message_strategy := test_helpers.CreateSendMediaStrategy() + link_flow := usecases.CreateLinkFlow(logger, http_client, media_factory, send_message_strategy) + + url := "http://an-url.com" + http_client.ContentTypeForURL[url] = "video" + message := core.Message{Text: url, Sender: &core.User{Username: "Username"}} + err := link_flow.HandleText(&message, bot) + + assert.Equal(t, nil, err) + assert.Equal(t, []core.URL{url}, media_factory.URLs) + assert.Equal(t, []string{url}, send_message_strategy.SentMedia) +} diff --git a/usecases/ouline_vpn_facade.go b/usecases/ouline_vpn_facade.go new file mode 100644 index 0000000..9abf14d --- /dev/null +++ b/usecases/ouline_vpn_facade.go @@ -0,0 +1,94 @@ +package usecases + +import ( + "fmt" + "net/url" + + "github.com/ailinykh/pullanusbot/v2/api" + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/infrastructure" +) + +func CreateOutlineVpnFacade(apiUrl string, dbFile string, l core.ILogger, userStorage core.IUserStorage) core.IVpnAPI { + u, err := url.Parse(apiUrl) + if err != nil { + panic(err) + } + + api := api.CreateOutlineAPI(l, apiUrl) + outlineStorage := infrastructure.CreateOutlineStorage(dbFile, l) + return &OutlineVpnFacade{l, api, u.Host, outlineStorage, userStorage} +} + +type OutlineVpnFacade struct { + l core.ILogger + api *api.OutlineAPI + host string + outlineStorage *infrastructure.OutlineStorage + userStorage core.IUserStorage +} + +// GetKeys is a core.IVpnAPI interface implementation +func (facade *OutlineVpnFacade) GetKeys(chatID int64) ([]*core.VpnKey, error) { + keys, err := facade.outlineStorage.GetKeys(chatID) + if err != nil { + facade.l.Error(err) + return nil, err + } + + keys2 := []*core.VpnKey{} + for _, k := range keys { + keys2 = append(keys2, &core.VpnKey{ + ID: k.ID, + ChatID: k.ChatID, + Title: k.Title, + Key: k.Key, + }) + } + return keys2, nil +} + +// CreateKey is a core.IVpnAPI interface implementation +func (facade *OutlineVpnFacade) CreateKey(chatID int64, title string) (*core.VpnKey, error) { + keys, err := facade.outlineStorage.GetKeys(chatID) + if err != nil { + facade.l.Error(err) + return nil, err + } + + user, err := facade.userStorage.GetUserById(chatID) // should exist + if err != nil { + facade.l.Error(err) + return nil, err + } + + key, err := facade.api.CreateKey(chatID, fmt.Sprintf("%s %d", user.DisplayName(), len(keys))) + if err != nil { + facade.l.Error(err) + return nil, err + } + + err = facade.outlineStorage.CreateKey(key.ID, chatID, facade.host, title, key.AccessURL) + if err != nil { + facade.l.Error(err) + return nil, err + } + + return &core.VpnKey{ + ID: key.ID, + ChatID: chatID, + Title: title, + Key: key.AccessURL, + }, nil +} + +// DeleteKey is a core.IVpnAPI interface implementation +func (facade *OutlineVpnFacade) DeleteKey(key *core.VpnKey) error { + err := facade.api.DeleteKey(key) + if err != nil { + facade.l.Error(err) + return err + } + + return facade.outlineStorage.DeleteKey(key, facade.host) +} diff --git a/usecases/outline_vpn_flow.go b/usecases/outline_vpn_flow.go new file mode 100644 index 0000000..0b0704a --- /dev/null +++ b/usecases/outline_vpn_flow.go @@ -0,0 +1,218 @@ +package usecases + +import ( + "fmt" + "strings" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateVpnFlow(l core.ILogger, loc core.ILocalizer, api core.IVpnAPI) core.ITextHandler { + flow := OutlineVpnFlow{l, loc, api, make(map[string]func(*core.Message, core.IBot) error), make(map[int64]OutlineVpnState)} + flow.callbacks["vpn_create_key"] = flow.create + flow.callbacks["vpn_manage_key"] = flow.manage + flow.callbacks["vpn_delete_key"] = flow.delete + flow.callbacks["vpn_back"] = flow.back + flow.callbacks["vpn_cancel"] = flow.cancel + return &flow +} + +type OutlineVpnFlow struct { + l core.ILogger + loc core.ILocalizer + api core.IVpnAPI + + callbacks map[string]func(*core.Message, core.IBot) error + state map[int64]OutlineVpnState +} + +type OutlineVpnState struct { + action string + source int +} + +// GetButtonIds is a core.IButtonHandler protocol implementation +func (flow *OutlineVpnFlow) GetButtonIds() []string { + keys := make([]string, len(flow.callbacks)) + + i := 0 + for k := range flow.callbacks { + keys[i] = k + i++ + } + + return keys +} + +// ButtonPressed is a core.IButtonHandler protocol implementation +func (flow *OutlineVpnFlow) ButtonPressed(button *core.Button, message *core.Message, _ *core.User, bot core.IBot) error { + if callback, ok := flow.callbacks[button.ID]; ok { + return callback(message, bot) + } + return fmt.Errorf("not implemented") +} + +// HandleText is a core.ITextHandler protocol implementation +func (flow *OutlineVpnFlow) HandleText(message *core.Message, bot core.IBot) error { + if !message.IsPrivate { + return fmt.Errorf("not implemented") + } + + if state, ok := flow.state[message.Chat.ID]; ok { + return flow.handleAction(state, message, bot) + } + + if message.Text != "/vpnhelp" { + return fmt.Errorf("not implemented") + } + + return flow.help(message, bot) +} + +func (flow *OutlineVpnFlow) help(message *core.Message, bot core.IBot) error { + keys, err := flow.api.GetKeys(message.Chat.ID) + if err != nil { + flow.l.Error(err) + return err + } + + _, err = bot.SendText(flow.loc.I18n(message.Sender.LanguageCode, "vpn_welcome"), flow.getKeyboard(message, keys)) + return err +} + +func (flow *OutlineVpnFlow) create(message *core.Message, bot core.IBot) error { + flow.state[message.Chat.ID] = OutlineVpnState{"create", message.ID} + keyboard := core.Keyboard{[]*core.Button{{ID: "vpn_back", Text: flow.loc.I18n(message.Sender.LanguageCode, "vpn_button_back")}}} + + _, err := bot.Edit(message, flow.loc.I18n(message.Sender.LanguageCode, "vpn_enter_create_key_name"), keyboard) + return err +} + +func (flow *OutlineVpnFlow) manage(message *core.Message, bot core.IBot) error { + keys, err := flow.api.GetKeys(message.Chat.ID) + if err != nil { + flow.l.Error(err) + return err + } + + text := []string{flow.loc.I18n(message.Sender.LanguageCode, "vpn_key_list_top")} + + for idx, key := range keys { + text = append(text, flow.loc.I18n(message.Sender.LanguageCode, "vpn_key_list_item", idx+1, key.Title, key.Key)) + } + + text = append(text, flow.loc.I18n(message.Sender.LanguageCode, "vpn_key_list_bottom", len(keys))) + + keyboard := core.Keyboard{ + []*core.Button{{ID: "vpn_delete_key", Text: flow.loc.I18n(message.Sender.LanguageCode, "vpn_button_remove_key")}}, + []*core.Button{{ID: "vpn_back", Text: flow.loc.I18n(message.Sender.LanguageCode, "vpn_button_back")}}, + } + _, err = bot.Edit(message, strings.Join(text, "\n"), keyboard) + return err +} + +func (flow *OutlineVpnFlow) back(message *core.Message, bot core.IBot) error { + keys, err := flow.api.GetKeys(message.Chat.ID) + if err != nil { + flow.l.Error(err) + return err + } + + delete(flow.state, message.Chat.ID) + + _, err = bot.Edit(message, flow.loc.I18n(message.Sender.LanguageCode, "vpn_welcome"), flow.getKeyboard(message, keys)) + return err +} + +func (flow *OutlineVpnFlow) delete(message *core.Message, bot core.IBot) error { + keys, err := flow.api.GetKeys(message.Chat.ID) + if err != nil { + flow.l.Error(err) + return err + } + + flow.state[message.Chat.ID] = OutlineVpnState{"delete", message.ID} + + text := []string{flow.loc.I18n(message.Sender.LanguageCode, "vpn_enter_delete_key_name_top")} + + for _, key := range keys { + text = append(text, flow.loc.I18n(message.Sender.LanguageCode, "vpn_enter_delete_key_name_item", key.Title)) + } + + keyboard := core.Keyboard{[]*core.Button{ + {ID: "vpn_cancel", Text: flow.loc.I18n(message.Sender.LanguageCode, "vpn_button_cancel")}, + }} + _, err = bot.Edit(message, strings.Join(text, "\n"), keyboard) + return err +} + +func (flow *OutlineVpnFlow) cancel(message *core.Message, bot core.IBot) error { + return flow.back(message, bot) +} + +func (flow *OutlineVpnFlow) getKeyboard(message *core.Message, keys []*core.VpnKey) core.Keyboard { + keyboard := core.Keyboard{} + + if len(keys) < 10 { + keyboard = append(keyboard, []*core.Button{{ID: "vpn_create_key", Text: flow.loc.I18n(message.Sender.LanguageCode, "vpn_button_create_key")}}) + } + + if len(keys) > 0 { + keyboard = append(keyboard, []*core.Button{{ID: "vpn_manage_key", Text: flow.loc.I18n(message.Sender.LanguageCode, "vpn_button_manage_key")}}) + } + + return keyboard +} + +func (flow *OutlineVpnFlow) handleAction(state OutlineVpnState, message *core.Message, bot core.IBot) error { + if state.action == "create" { + if len(message.Text) > 64 { + _, err := bot.SendText(flow.loc.I18n(message.Sender.LanguageCode, "vpn_enter_create_key_name_too_long")) + return err + } + key, err := flow.api.CreateKey(message.Chat.ID, message.Text) + if err != nil { + flow.l.Error(err) + return err + } + + delete(flow.state, message.Chat.ID) + + _ = bot.Delete(&core.Message{ID: state.source, Chat: message.Chat}) + + keyboard := core.Keyboard{[]*core.Button{{ID: "vpn_manage_key", Text: flow.loc.I18n(message.Sender.LanguageCode, "vpn_button_manage_key")}}} + _, err = bot.SendText(flow.loc.I18n(message.Sender.LanguageCode, "vpn_key_created", key.Key), keyboard) + return err + } + + if state.action == "delete" { + keys, err := flow.api.GetKeys(message.Chat.ID) + if err != nil { + flow.l.Error(err) + return err + } + + delete(flow.state, message.Chat.ID) + + _ = bot.Delete(&core.Message{ID: state.source, Chat: message.Chat}) + + keyboard := core.Keyboard{ + []*core.Button{{ID: "vpn_back", Text: flow.loc.I18n(message.Sender.LanguageCode, "vpn_button_back")}}, + } + + for _, k := range keys { + if k.Title == message.Text { + err = flow.api.DeleteKey(k) + if err != nil { + return err + } + _, err = bot.SendText(flow.loc.I18n(message.Sender.LanguageCode, "vpn_key_deleted", k.Title), keyboard) + return err + } + } + _, err = bot.SendText(flow.loc.I18n(message.Sender.LanguageCode, "vpn_key_not_found"), keyboard) + return err + } + + return fmt.Errorf("unexpected action: %s", state.action) +} diff --git a/usecases/publisher_flow.go b/usecases/publisher_flow.go new file mode 100644 index 0000000..a7d22a5 --- /dev/null +++ b/usecases/publisher_flow.go @@ -0,0 +1,128 @@ +package usecases + +import ( + "os" + "strconv" + "time" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreatePublisherFlow is a basic PublisherFlow factory +func CreatePublisherFlow(l core.ILogger) *PublisherFlow { + chatID, err := strconv.ParseInt(os.Getenv("PUBLISHER_CHAT_ID"), 10, 64) + if err != nil { + chatID = 0 + } + + username := os.Getenv("PUBLISHER_USERNAME") + + publisher := PublisherFlow{ + l: l, + chatID: chatID, + username: username, + imageChan: make(chan imgSource), + requestChan: make(chan msgSource), + } + + go publisher.runLoop() + return &publisher +} + +// PublisherFlow represents last sent image keeper logic +type PublisherFlow struct { + l core.ILogger + + chatID int64 + username string + imageChan chan imgSource + requestChan chan msgSource +} + +type imgSource struct { + imageID string + bot core.IBot +} + +type msgSource struct { + message core.Message + bot core.IBot +} + +// HandleImage is a core.IImageHandler protocol implementation +func (p *PublisherFlow) HandleImage(image *core.Image, message *core.Message, bot core.IBot) error { + if message.Chat.ID == p.chatID && message.Sender.Username == p.username { + p.imageChan <- imgSource{image.ID, bot} + } + + return nil +} + +func (p *PublisherFlow) HandleRequest(message *core.Message, bot core.IBot) error { + if message.Chat.ID == p.chatID { + p.requestChan <- msgSource{*message, bot} + } + + return nil +} + +func (p *PublisherFlow) runLoop() { + photos := []string{} + queue := []string{} + + disposal := func(m core.Message, bot core.IBot, timeout int) { + time.Sleep(time.Duration(timeout) * time.Second) + p.l.Infof("disposing message %d from chat %d", m.ID, m.Chat.ID) + err := bot.Delete(&m) + if err != nil { + p.l.Error(err) + } + } + + for { + select { + case is := <-p.imageChan: + p.l.Infof("got photo %s", is.imageID) + queue = append(queue, is.imageID) + + case <-time.After(1 * time.Second): + if len(queue) > 0 { + p.l.Infof("had %d actual photo(s)", len(queue)) + photos = queue + queue = []string{} + } + + case ms := <-p.requestChan: + go disposal(ms.message, ms.bot, 0) + switch count := len(photos); count { + case 0: + _, err := ms.bot.SendText("I have nothing for you comrade major") + if err != nil { + p.l.Error(err) + } + case 1: + p.l.Info("have one actual photo") + sent, err := ms.bot.SendImage(&core.Image{ID: photos[0]}, "") + if err != nil { + p.l.Error(err) + } else { + go disposal(*sent, ms.bot, 30) + } + default: + p.l.Infof("have %d actual photos", count) + album := []*core.Image{} + for _, p := range photos { + album = append(album, &core.Image{ID: p}) + } + sent, err := ms.bot.SendAlbum(album) + if err != nil { + p.l.Error(err) + } else { + for _, m := range sent { + go disposal(*m, ms.bot, 30) + } + } + } + } + } +} diff --git a/usecases/remove_source_decorator.go b/usecases/remove_source_decorator.go new file mode 100644 index 0000000..78cb720 --- /dev/null +++ b/usecases/remove_source_decorator.go @@ -0,0 +1,39 @@ +package usecases + +import ( + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateRemoveSourceDecorator(l core.ILogger, decoratee core.ITextHandler, settingsKey core.SettingKey, settingProvider core.IBoolSettingProvider) *RemoveSourceDecorator { + return &RemoveSourceDecorator{l, decoratee, settingsKey, settingProvider} +} + +type RemoveSourceDecorator struct { + l core.ILogger + decoratee core.ITextHandler + settingsKey core.SettingKey + settingProvider core.IBoolSettingProvider +} + +// HandleText is a core.ITextHandler protocol implementation +func (decorator *RemoveSourceDecorator) HandleText(message *core.Message, bot core.IBot) error { + err := decorator.decoratee.HandleText(message, bot) + //TODO: error handling protocol + if err != nil && err.Error() == "not implemented" { + return nil + } + + if err != nil { + decorator.l.Error(err) + return err + } + + enabled := decorator.settingProvider.GetBool(message.Chat.ID, decorator.settingsKey) + + if enabled { + decorator.l.Infof("removing chat %d message %d", message.Chat.ID, message.ID) + return bot.Delete(message) + } + + return nil +} diff --git a/usecases/start_flow.go b/usecases/start_flow.go new file mode 100644 index 0000000..18e8782 --- /dev/null +++ b/usecases/start_flow.go @@ -0,0 +1,92 @@ +package usecases + +import ( + "encoding/json" + "strings" + "sync" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateStartFlow(l core.ILogger, loc core.ILocalizer, settings core.ISettingsProvider, commandService core.ICommandService) *StartFlow { + return &StartFlow{l, loc, settings, commandService, sync.Mutex{}} +} + +type StartFlow struct { + l core.ILogger + loc core.ILocalizer + settings core.ISettingsProvider + commandService core.ICommandService + lock sync.Mutex +} + +func (flow *StartFlow) Start(message *core.Message, bot core.IBot) error { + flow.lock.Lock() + defer flow.lock.Unlock() + + if strings.HasPrefix(message.Text, "/start") { + if len(message.Text) > 7 { + payload := message.Text[7:] + err := flow.handlePayload(payload, message.Chat.ID) + if err != nil { + flow.l.Error(err) + //return err ? + } + } + + err := flow.commandService.EnableCommands(message.Chat.ID, []core.Command{{Text: "help", Description: "show help message"}}, bot) + if err != nil { + flow.l.Error(err) + // return err ? + } + _, err = bot.SendText(flow.loc.I18n(message.Sender.LanguageCode, "start_welcome") + " " + flow.loc.I18n(message.Sender.LanguageCode, "help")) + return err + } + + return nil +} + +func (flow *StartFlow) Help(message *core.Message, bot core.IBot) error { + _, err := bot.SendText(flow.loc.I18n(message.Sender.LanguageCode, "help")) + return err +} + +func (flow *StartFlow) handlePayload(payload string, chatID int64) error { + data, err := flow.settings.GetData(chatID, core.SPayloadList) + + if err != nil { + flow.l.Error(err) + } + + var settingsV1 struct { + Payload []string + } + + err = json.Unmarshal(data, &settingsV1) + if err != nil { + flow.l.Error(err) + // TODO: perform a migration + } + + if flow.contains(payload, settingsV1.Payload) { + return nil + } + + settingsV1.Payload = append(settingsV1.Payload, payload) + data, err = json.Marshal(settingsV1) + if err != nil { + flow.l.Error(err) + return err + } + + return flow.settings.SetData(chatID, core.SPayloadList, data) +} + +func (flow *StartFlow) contains(payload string, current []string) bool { + for _, p := range current { + if p == payload { + return true + } + } + return false +} diff --git a/usecases/start_flow_test.go b/usecases/start_flow_test.go new file mode 100644 index 0000000..fd20026 --- /dev/null +++ b/usecases/start_flow_test.go @@ -0,0 +1,72 @@ +package usecases_test + +import ( + "encoding/json" + "sync" + "testing" + + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/test_helpers" + "github.com/ailinykh/pullanusbot/v2/usecases" + "github.com/stretchr/testify/assert" +) + +func Test_HandleText_CreateChatPayload(t *testing.T) { + logger := test_helpers.CreateLogger() + loc := test_helpers.CreateLocalizer(map[string]string{}) + settingsProvider := test_helpers.CreateSettingsProvider() + commandService := test_helpers.CreateCommandService(logger) + startFlow := usecases.CreateStartFlow(logger, loc, settingsProvider, commandService) + + bot := test_helpers.CreateBot() + + messages := []string{ + "/start", + "/start payload", + "/start another_payload", + } + wg := sync.WaitGroup{} + + for _, message := range messages { + wg.Add(1) + go func(text string) { + startFlow.Start(makeMessage(text), bot) + wg.Done() + }(message) + } + + wg.Wait() + + assert.Equal(t, 1, len(settingsProvider.Data)) + + message := makeMessage("/start") + data, _ := settingsProvider.GetData(message.Chat.ID, core.SPayloadList) + var settingsV1 struct { + Payload []string + } + _ = json.Unmarshal(data, &settingsV1) + assert.Equal(t, true, contains("payload", settingsV1.Payload)) + assert.Equal(t, true, contains("another_payload", settingsV1.Payload)) + + expected := []string{ + "enable commands 1488 [{help show help message}]", + "enable commands 1488 [{help show help message}]", + "enable commands 1488 [{help show help message}]", + } + assert.Equal(t, expected, commandService.ActionLog) +} + +func makeMessage(text string) *core.Message { + chat := core.Chat{ID: 1488, Title: "Paul Durov", Type: "private"} + sender := core.User{ID: 1, FirstName: "Paul", LastName: "Durov"} + return &core.Message{Text: text, Chat: &chat, Sender: &sender} +} + +func contains(message string, messages []string) bool { + for _, m := range messages { + if m == message { + return true + } + } + return false +} diff --git a/usecases/tiktok_flow.go b/usecases/tiktok_flow.go new file mode 100644 index 0000000..64a5b2b --- /dev/null +++ b/usecases/tiktok_flow.go @@ -0,0 +1,72 @@ +package usecases + +import ( + "fmt" + "regexp" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateTikTokFlow(l core.ILogger, httpClient core.IHttpClient, mediaFactory core.IMediaFactory, sendMediaStrategy core.ISendMediaStrategy) *TikTokFlow { + return &TikTokFlow{l, httpClient, mediaFactory, sendMediaStrategy} +} + +type TikTokFlow struct { + l core.ILogger + httpClient core.IHttpClient + mediaFactory core.IMediaFactory + sendMediaStrategy core.ISendMediaStrategy +} + +// HandleText is a core.ITextHandler protocol implementation +func (flow *TikTokFlow) HandleText(message *core.Message, bot core.IBot) error { + r := regexp.MustCompile(`https?://\w+\.tiktok.com/\S+`) + links := r.FindAllString(message.Text, -1) + for _, l := range links { + err := flow.handleURL(l, message, bot) + if err != nil { + flow.l.Error(err) + return err + } + } + + if len(links) > 0 { + return bot.Delete(message) + } + return nil +} + +func (flow *TikTokFlow) handleURL(url string, message *core.Message, bot core.IBot) error { + flow.l.Infof("processing %s", url) + fullURL, err := flow.httpClient.GetRedirectLocation(url) + if err != nil { + return err + } + + r := regexp.MustCompile(`tiktok\.com/(@\S+)/video/(\d+)`) + match := r.FindStringSubmatch(fullURL) + if len(match) != 3 { + flow.l.Error(match) + return fmt.Errorf("unexpected redirect location %s", fullURL) + } + + // apiURL := "https://www.tiktok.com/node/share/video/" + match[1] + "/" + match[2] + originalURL := "https://www.tiktok.com/" + match[1] + "/video/" + match[2] + flow.l.Infof("original: %s", originalURL) + + media, err := flow.mediaFactory.CreateMedia(originalURL) + if err != nil { + if err.Error() == "Video currently unavailable" { + _, err := bot.SendText(originalURL + "\nV" + err.Error()) + return err + } + return err + } + + for _, m := range media { + m.URL = url + m.Caption = fmt.Sprintf("🎵 %s (by %s)\n%s", url, m.Title, message.Sender.DisplayName(), m.Description) + } + + return flow.sendMediaStrategy.SendMedia(media, bot) +} diff --git a/usecases/twitter_flow.go b/usecases/twitter_flow.go new file mode 100644 index 0000000..024aa05 --- /dev/null +++ b/usecases/twitter_flow.go @@ -0,0 +1,48 @@ +package usecases + +import ( + "fmt" + "regexp" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +type ITweetHandler interface { + Process(string, *core.Message, core.IBot) error +} + +// CreateTwitterFlow is a basic TwitterFlow factory +func CreateTwitterFlow(l core.ILogger, mediaFactory core.IMediaFactory, sendMediaStrategy core.ISendMediaStrategy) *TwitterFlow { + return &TwitterFlow{l, mediaFactory, sendMediaStrategy} +} + +// TwitterFlow represents tweet processing logic +type TwitterFlow struct { + l core.ILogger + mediaFactory core.IMediaFactory + sendMediaStrategy core.ISendMediaStrategy +} + +// Process is a ITweetHandler protocol implementation +func (flow *TwitterFlow) Process(tweetID string, message *core.Message, bot core.IBot) error { + flow.l.Infof("processing tweet %s", tweetID) + media, err := flow.mediaFactory.CreateMedia(tweetID) + if err != nil { + flow.l.Error(err) + return err + } + + for _, m := range media { + re := regexp.MustCompile(`\s?http\S+$`) + text := re.ReplaceAllString(m.Description, "") + m.Caption = fmt.Sprintf("🐦 %s (by %s)\n%s", m.URL, m.Title, message.Sender.DisplayName(), text) + } + + err = flow.sendMediaStrategy.SendMedia(media, bot) + if err != nil { + flow.l.Error(err) + return err + } + + return nil +} diff --git a/usecases/twitter_parser.go b/usecases/twitter_parser.go new file mode 100644 index 0000000..048ab59 --- /dev/null +++ b/usecases/twitter_parser.go @@ -0,0 +1,38 @@ +package usecases + +import ( + "fmt" + "regexp" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateTwitterParser(l core.ILogger, tweetHandler ITweetHandler) *TwitterParser { + return &TwitterParser{l, tweetHandler} +} + +type TwitterParser struct { + l core.ILogger + tweetHandler ITweetHandler +} + +// HandleText is a core.ITextHandler protocol implementation +func (parser *TwitterParser) HandleText(message *core.Message, bot core.IBot) error { + r := regexp.MustCompile(`https://twitter\.com\S+/(\d+)\S*`) + match := r.FindAllStringSubmatch(message.Text, -1) + + if len(match) > 0 { + parser.l.Infof("Processing %s", match[0][0]) + } else { + return fmt.Errorf("not implemented") + } + + for _, m := range match { + err := parser.tweetHandler.Process(m[1], message, bot) + if err != nil { + return err + } + } + + return nil +} diff --git a/usecases/twitter_parser_test.go b/usecases/twitter_parser_test.go new file mode 100644 index 0000000..61246f3 --- /dev/null +++ b/usecases/twitter_parser_test.go @@ -0,0 +1,79 @@ +package usecases_test + +import ( + "fmt" + "testing" + + "github.com/ailinykh/pullanusbot/v2/core" + "github.com/ailinykh/pullanusbot/v2/test_helpers" + "github.com/ailinykh/pullanusbot/v2/usecases" + "github.com/stretchr/testify/assert" +) + +func Test_HandleText_NotFoundAnyLinkByDefault(t *testing.T) { + parser, handler, bot := makeTwitterSUT() + m := makeTweetMessage("a message without any links") + + parser.HandleText(m, bot) + + assert.Equal(t, []string{}, handler.tweets) +} + +func Test_HandleText_FoundTweetLink(t *testing.T) { + parser, handler, bot := makeTwitterSUT() + m := makeTweetMessage("a message with https://twitter.com/status/username/123456") + + parser.HandleText(m, bot) + + assert.Equal(t, []string{"123456"}, handler.tweets) +} + +func Test_HandleText_FoundMultipleTweetLinks(t *testing.T) { + parser, handler, bot := makeTwitterSUT() + m := makeTweetMessage("a message with https://twitter.com/username/status/123456 and https://twitter.com/username/status/789010 and some text") + parser.HandleText(m, bot) + + assert.Equal(t, []string{"123456", "789010"}, handler.tweets) +} + +func Test_HandleText_DoesNotRemoveOriginalMessage(t *testing.T) { + parser, _, bot := makeTwitterSUT() + m := makeTweetMessage("https://twitter.com/username/status/123456 and some other text") + + parser.HandleText(m, bot) + + assert.Equal(t, []string{}, bot.RemovedMessages) +} + +func Test_HandleText_ReturnsErrorOnError(t *testing.T) { + parser, handler, bot := makeTwitterSUT() + m := makeTweetMessage("a message with https://twitter.com/status/username/123456") + handler.err = fmt.Errorf("an error") + + err := parser.HandleText(m, bot) + + assert.Equal(t, "an error", err.Error()) +} + +func makeTwitterSUT() (*usecases.TwitterParser, *FakeTweetHandler, *test_helpers.FakeBot) { + logger := test_helpers.CreateLogger() + handler := &FakeTweetHandler{[]string{}, nil} + parser := usecases.CreateTwitterParser(logger, handler) + bot := test_helpers.CreateBot() + return parser, handler, bot +} + +func makeTweetMessage(text string) *core.Message { + return &core.Message{ID: 0, Text: text} +} + +type FakeTweetHandler struct { + tweets []string + err error +} + +// Process is a ITweetHandler protocol implementation +func (fth *FakeTweetHandler) Process(tweetID string, message *core.Message, bot core.IBot) error { + fth.tweets = append(fth.tweets, tweetID) + return fth.err +} diff --git a/usecases/twitter_timeout.go b/usecases/twitter_timeout.go new file mode 100644 index 0000000..7e4941b --- /dev/null +++ b/usecases/twitter_timeout.go @@ -0,0 +1,76 @@ +package usecases + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + "time" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreateTwitterFlow is a basic TwitterFlow factory +func CreateTwitterTimeout(l core.ILogger, tweetHandler ITweetHandler) *TwitterTimeout { + return &TwitterTimeout{l, tweetHandler, make(map[core.Message]core.Message)} +} + +// TwitterTimeout is a decorator for TwitterFlow to handle API timeouts gracefully +type TwitterTimeout struct { + l core.ILogger + tweetHandler ITweetHandler + replies map[core.Message]core.Message +} + +// Process is a ITweetHandler protocol implementation +func (twitterTimeout *TwitterTimeout) Process(tweetID string, message *core.Message, bot core.IBot) error { + err := twitterTimeout.tweetHandler.Process(tweetID, message, bot) + if err != nil { + if strings.HasPrefix(err.Error(), "Rate limit exceeded") { + timeout, err := twitterTimeout.parseTimeout(err) + if err != nil { + return err + } + + go func() { + time.Sleep(time.Duration(timeout) * time.Second) + _ = twitterTimeout.Process(tweetID, message, bot) + }() + + minutes := timeout / 60 + seconds := timeout % 60 + reply := fmt.Sprintf("twitter api timeout %d min %d sec", minutes, seconds) + sent, err := bot.SendText(reply, message) + if err != nil { + return err + } + twitterTimeout.replies[*message] = *sent + //TODO: delay original message removing somehow + return nil + } + twitterTimeout.l.Error(err) + } else if sent, ok := twitterTimeout.replies[*message]; ok { + _ = bot.Delete(&sent) + delete(twitterTimeout.replies, *message) + } + return err +} + +func (twitterTimeout *TwitterTimeout) parseTimeout(err error) (int64, error) { + r := regexp.MustCompile(`(\-?\d+)$`) + match := r.FindStringSubmatch(err.Error()) + if len(match) < 2 { + return 0, fmt.Errorf("rate limit not found") + } + + limit, err := strconv.ParseInt(match[1], 10, 64) + if err != nil { + return 0, err + } + + timeout := limit - time.Now().Unix() + twitterTimeout.l.Infof("Twitter api timeout %d seconds", timeout) + timeout = int64(math.Max(float64(timeout), 2)) // Twitter api timeout might be negative + return timeout, nil +} diff --git a/usecases/user_storage_decorator.go b/usecases/user_storage_decorator.go new file mode 100644 index 0000000..185748c --- /dev/null +++ b/usecases/user_storage_decorator.go @@ -0,0 +1,32 @@ +package usecases + +import "github.com/ailinykh/pullanusbot/v2/core" + +func CreateUserStorageDecorator(primary core.IUserStorage, secondary core.IUserStorage) core.IUserStorage { + return &UserStorageDecorator{primary, secondary} +} + +type UserStorageDecorator struct { + cache core.IUserStorage + db core.IUserStorage +} + +// GetUserById is a core.IUserStorage interface implementation +func (decorator *UserStorageDecorator) GetUserById(userID int64) (*core.User, error) { + user, err := decorator.cache.GetUserById(userID) + if err != nil { + user, err := decorator.db.GetUserById(userID) + if err != nil { + return nil, err + } + _ = decorator.cache.CreateUser(user) + return user, err + } + return user, err +} + +// CreateUser is a core.IUserStorage interface implementation +func (decorator *UserStorageDecorator) CreateUser(user *core.User) error { + _ = decorator.cache.CreateUser(user) + return decorator.db.CreateUser(user) +} diff --git a/usecases/video_flow.go b/usecases/video_flow.go new file mode 100644 index 0000000..040dcf5 --- /dev/null +++ b/usecases/video_flow.go @@ -0,0 +1,80 @@ +package usecases + +import ( + "fmt" + "math" + "os" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +// CreateVideoFlow is a basic VideoFlow factory +func CreateVideoFlow(l core.ILogger, videoFactory core.IVideoFactory, converter core.IVideoConverter) *VideoFlow { + return &VideoFlow{l, converter, videoFactory} +} + +// VideoFlow represents convert file to video logic +type VideoFlow struct { + l core.ILogger + converter core.IVideoConverter + videoFactory core.IVideoFactory +} + +// HandleDocument is a core.IDocumentHandler protocol implementation +func (flow *VideoFlow) HandleDocument(document *core.Document, message *core.Message, bot core.IBot) error { + vf, err := flow.videoFactory.CreateVideo(document.File.Path) + if err != nil { + flow.l.Error(err) + bot.SendText(err.Error()) + return err + } + defer vf.Dispose() + + expectedBitrate := int(math.Min(float64(vf.Bitrate), 568320)) + + if expectedBitrate != vf.Bitrate { + flow.l.Infof("Converting %s because of bitrate", vf.Name) + cvf, err := flow.converter.Convert(vf, expectedBitrate) + if err != nil { + flow.l.Error(err) + return err + } + defer cvf.Dispose() + fi1, _ := os.Stat(vf.Path) + fi2, _ := os.Stat(cvf.Path) + caption := fmt.Sprintf("%s (by %s)\nsrc: %.2f MB (%d kb/s) %s\ndst: %.2f MB (%d kb/s) %s", vf.Name, message.Sender.DisplayName(), float32(fi1.Size())/1048576, vf.Bitrate/1024, vf.Codec, float32(fi2.Size())/1048576, cvf.Bitrate/1024, cvf.Codec) + _, err = bot.SendVideo(cvf, caption) + if err != nil { + flow.l.Error(err) + return err + } + return bot.Delete(message) + } + + if vf.Codec != "h264" { + flow.l.Infof("Converting %s because of codec %s", vf.Name, vf.Codec) + cvf, err := flow.converter.Convert(vf, 0) + if err != nil { + flow.l.Error(err) + return err + } + defer cvf.Dispose() + fi1, _ := os.Stat(vf.Path) + fi2, _ := os.Stat(cvf.Path) + caption := fmt.Sprintf("%s (by %s)\nsrc: %.2f MB (%d kb/s) %s\ndst: %.2f MB (%d kb/s) %s", vf.Name, message.Sender.DisplayName(), float32(fi1.Size())/1048576, vf.Bitrate/1024, vf.Codec, float32(fi2.Size())/1048576, cvf.Bitrate/1024, cvf.Codec) + _, err = bot.SendVideo(cvf, caption) + if err != nil { + flow.l.Error(err) + return err + } + return bot.Delete(message) + } + + flow.l.Infof("No need to convert %s", vf.Name) + caption := fmt.Sprintf("%s (by %s)", vf.Name, message.Sender.DisplayName()) + _, err = bot.SendVideo(vf, caption) + if err != nil { + return err + } + return bot.Delete(message) +} diff --git a/usecases/youtube_flow.go b/usecases/youtube_flow.go new file mode 100644 index 0000000..c122523 --- /dev/null +++ b/usecases/youtube_flow.go @@ -0,0 +1,77 @@ +package usecases + +import ( + "fmt" + "regexp" + "strings" + "sync" + + "github.com/ailinykh/pullanusbot/v2/core" +) + +func CreateYoutubeFlow(l core.ILogger, mediaFactory core.IMediaFactory, videoFactory core.IVideoFactory, sendStrategy core.ISendVideoStrategy) *YoutubeFlow { + return &YoutubeFlow{l: l, mediaFactory: mediaFactory, videoFactory: videoFactory, sendStrategy: sendStrategy} +} + +type YoutubeFlow struct { + mutex sync.Mutex + l core.ILogger + mediaFactory core.IMediaFactory + videoFactory core.IVideoFactory + sendStrategy core.ISendVideoStrategy +} + +// HandleText is a core.ITextHandler protocol implementation +func (flow *YoutubeFlow) HandleText(message *core.Message, bot core.IBot) error { + r := regexp.MustCompile(`youtu\.?be(\.com)?(\/shorts)?(\/live)?\/(watch\?v=)?([\w\-_]+)`) + + links := r.FindAllStringSubmatch(message.Text, -1) + // TODO: any limits? + for i, l := range links { + err := flow.process(l[5], fmt.Sprintf("%s [%d/%d]", l[0], i+1, len(links)), message, bot) + if err != nil { + flow.l.Error(err) + return err + } + } + + if len(links) > 0 && !strings.Contains(message.Text, " ") { + return nil + } + // TODO: in case of `nil` the original message will be deleted + return fmt.Errorf("not implemented") +} + +func (flow *YoutubeFlow) process(id string, match string, message *core.Message, bot core.IBot) error { + flow.mutex.Lock() + defer flow.mutex.Unlock() + + flow.l.Infof("processing %s from %s", id, match) + id = "https://youtu.be/" + id // -e9_M7-0quU + media, err := flow.mediaFactory.CreateMedia(id) + if err != nil { + flow.l.Error(err) + return err + } + + flow.l.Infof("video: %s %.2f MB %d sec, audio: %s %.2f MB", media[0].Codec, float64(media[0].Size)/1024/1024, media[0].Duration, media[1].Codec, float64(media[1].Size)/1024/1024) + + totlalSize := media[0].Size + media[1].Size + if !message.IsPrivate && totlalSize > 50_000_000 { + flow.l.Infof("skip video in group chat due to size limit exceeded %d", totlalSize) + return nil // TODO: should return error? + } + + file, err := flow.videoFactory.CreateVideo(id) + if err != nil { + return err + } + defer file.Dispose() + + caption := fmt.Sprintf("🎞 %s (by %s)\n\n%s", id, media[0].Title, message.Sender.DisplayName(), media[0].Description) + if len(caption) > 1024 { + caption = caption[:1024] + } + caption = strings.ToValidUTF8(caption, "") + return flow.sendStrategy.SendVideo(file, caption, bot) +}