diff --git a/go.mod b/go.mod index 57aaf92e..8f360286 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/goccy/go-json v0.9.11 github.com/golang/snappy v0.0.4 // indirect github.com/gorilla/feeds v1.1.1 + github.com/mattn/go-mastodon v0.0.5 github.com/onsi/ginkgo/v2 v2.2.0 github.com/onsi/gomega v1.20.2 github.com/swaggo/swag v1.8.6 diff --git a/go.sum b/go.sum index 4bb2e59c..1af81b95 100644 --- a/go.sum +++ b/go.sum @@ -291,6 +291,8 @@ github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY= github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0= @@ -374,6 +376,8 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-mastodon v0.0.5 h1:P0e1/R2v3ho6kM7BUW0noQm8gAqHE0p8Gq1TMapIVAc= +github.com/mattn/go-mastodon v0.0.5/go.mod h1:cg7RFk2pcUfHZw/IvKe1FUzmlq5KnLFqs7eV2PHplV8= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= @@ -515,6 +519,8 @@ github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f h1:oqdnd6OGlOUu1InG37hWcCB3a+Jy3fwjylyVboaNMwY= github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f/go.mod h1:X3Dd1SB8Gt1V968NTzpKFjMM6O8ccta2NPC6MprOxZQ= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= diff --git a/internal/api/api.go b/internal/api/api.go index 0406f9dd..4950e33d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -78,6 +78,8 @@ func API(config config.Config, storage storage.TickerStorage) *gin.Engine { admin.DELETE(`/tickers/:tickerID/twitter`, ticker.PrefetchTicker(storage), handler.DeleteTickerTwitter) admin.PUT(`/tickers/:tickerID/telegram`, ticker.PrefetchTicker(storage), handler.PutTickerTelegram) admin.DELETE(`/tickers/:tickerID/telegram`, ticker.PrefetchTicker(storage), handler.DeleteTickerTelegram) + admin.PUT(`/tickers/:tickerID/mastodon`, ticker.PrefetchTicker(storage), handler.PutTickerMastodon) + admin.DELETE(`/tickers/:tickerID/mastodon`, ticker.PrefetchTicker(storage), handler.DeleteTickerMastodon) admin.DELETE(`/tickers/:tickerID`, user.NeedAdmin(), ticker.PrefetchTicker(storage), handler.DeleteTicker) admin.PUT(`/tickers/:tickerID/reset`, ticker.PrefetchTicker(storage), ticker.PrefetchTicker(storage), handler.ResetTicker) admin.GET(`/tickers/:tickerID/users`, ticker.PrefetchTicker(storage), handler.GetTickerUsers) diff --git a/internal/api/response/response.go b/internal/api/response/response.go index 2fa3c80f..a26f8388 100644 --- a/internal/api/response/response.go +++ b/internal/api/response/response.go @@ -21,6 +21,7 @@ const ( FormError ErrorMessage = "invalid form values" StorageError ErrorMessage = "failed to save" UploadsNotFound ErrorMessage = "uploads not found" + MastodonError ErrorMessage = "unable to connect to mastodon" StatusSuccess Status = `success` StatusError Status = `error` diff --git a/internal/api/response/ticker.go b/internal/api/response/ticker.go index 0e8029ff..670d6caf 100644 --- a/internal/api/response/ticker.go +++ b/internal/api/response/ticker.go @@ -17,6 +17,7 @@ type Ticker struct { Information Information `json:"information"` Twitter Twitter `json:"twitter"` Telegram Telegram `json:"telegram"` + Mastodon Mastodon `json:"mastodon"` Location Location `json:"location"` } @@ -44,6 +45,16 @@ type Telegram struct { ChannelName string `json:"channel_name"` } +type Mastodon struct { + Active bool `json:"active"` + Connected bool `json:"connected"` + Name string `json:"name"` + Server string `json:"server"` + ScreenName string `json:"screen_name"` + Description string `json:"description"` + ImageURL string `json:"image_url"` +} + type Location struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` @@ -78,6 +89,14 @@ func TickerResponse(t storage.Ticker, config config.Config) Ticker { BotUsername: config.TelegramBotUser.UserName, ChannelName: t.Telegram.ChannelName, }, + Mastodon: Mastodon{ + Active: t.Mastodon.Active, + Connected: t.Mastodon.Connected(), + Name: t.Mastodon.User.Username, + Server: t.Mastodon.Server, + ScreenName: t.Mastodon.User.DisplayName, + ImageURL: t.Mastodon.User.Avatar, + }, Location: Location{ Lat: t.Location.Lat, Lon: t.Location.Lon, diff --git a/internal/api/tickers.go b/internal/api/tickers.go index 5d80a39a..03e7efde 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/gin-gonic/gin" + "github.com/mattn/go-mastodon" "github.com/systemli/ticker/internal/api/helper" "github.com/systemli/ticker/internal/api/response" "github.com/systemli/ticker/internal/storage" @@ -223,6 +224,70 @@ func (h *handler) DeleteTickerTelegram(c *gin.Context) { c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) } +func (h *handler) PutTickerMastodon(c *gin.Context) { + ticker, err := helper.Ticker(c) + if err != nil { + c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound)) + return + } + + var body storage.Mastodon + err = c.Bind(&body) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeNotFound, response.FormError)) + return + } + + if body.Secret != "" || body.Token != "" || body.AccessToken != "" || body.Server != "" { + client := mastodon.NewClient(&mastodon.Config{ + Server: body.Server, + ClientID: body.Token, + ClientSecret: body.Secret, + AccessToken: body.AccessToken, + }) + + account, err := client.GetAccountCurrentUser(c.Request.Context()) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeBadCredentials, response.MastodonError)) + return + } + + ticker.Mastodon.Server = body.Server + ticker.Mastodon.Secret = body.Secret + ticker.Mastodon.Token = body.Token + ticker.Mastodon.AccessToken = body.AccessToken + ticker.Mastodon.User = *account + } + + ticker.Mastodon.Active = body.Active + + err = h.storage.SaveTicker(&ticker) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) + return + } + + c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) +} + +func (h *handler) DeleteTickerMastodon(c *gin.Context) { + ticker, err := helper.Ticker(c) + if err != nil { + c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound)) + return + } + + ticker.Mastodon.Reset() + + err = h.storage.SaveTicker(&ticker) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) + return + } + + c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) +} + func (h *handler) DeleteTicker(c *gin.Context) { ticker, err := helper.Ticker(c) if err != nil { diff --git a/internal/api/tickers_test.go b/internal/api/tickers_test.go index 71691d62..819e7a4a 100644 --- a/internal/api/tickers_test.go +++ b/internal/api/tickers_test.go @@ -1,13 +1,16 @@ package api import ( + "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" + "github.com/mattn/go-mastodon" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/systemli/ticker/internal/config" @@ -545,6 +548,152 @@ func TestDeleteTickerTelegram(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } +func TestPutTickerMastodonTickerNotFound(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + s := &storage.MockTickerStorage{} + h := handler{ + storage: s, + config: config.NewConfig(), + } + + h.PutTickerMastodon(c) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestPutTickerMastodonFormError(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("ticker", storage.Ticker{}) + c.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/mastodon", nil) + c.Request.Header.Add("Content-Type", "application/json") + s := &storage.MockTickerStorage{} + h := handler{ + storage: s, + config: config.NewConfig(), + } + + h.PutTickerMastodon(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestPutTickerMastodonConnectError(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("ticker", storage.Ticker{}) + body := `{"active":true,"server":"http://localhost","secret":"secret","token":"token","access_token":"access_token"}` + c.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/mastodon", strings.NewReader(body)) + c.Request.Header.Add("Content-Type", "application/json") + s := &storage.MockTickerStorage{} + s.On("SaveTicker", mock.Anything).Return(nil) + h := handler{ + storage: s, + config: config.NewConfig(), + } + + h.PutTickerMastodon(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestPutTickerMastodonStorageError(t *testing.T) { + w := httptest.NewRecorder() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + account := mastodon.Account{} + json, _ := json.Marshal(account) + w.Write(json) + })) + defer server.Close() + c, _ := gin.CreateTestContext(w) + c.Set("ticker", storage.Ticker{}) + body := fmt.Sprintf(`{"server":"%s","token":"token","secret":"secret","access_token":"access_toklen"}`, server.URL) + c.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/mastodon", strings.NewReader(body)) + c.Request.Header.Add("Content-Type", "application/json") + s := &storage.MockTickerStorage{} + s.On("SaveTicker", mock.Anything).Return(errors.New("storage error")) + h := handler{ + storage: s, + config: config.NewConfig(), + } + + h.PutTickerMastodon(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestPutTickerMastodon(t *testing.T) { + w := httptest.NewRecorder() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + account := mastodon.Account{} + json, _ := json.Marshal(account) + w.Write(json) + })) + defer server.Close() + c, _ := gin.CreateTestContext(w) + c.Set("ticker", storage.Ticker{}) + body := fmt.Sprintf(`{"server":"%s","token":"token","secret":"secret","access_token":"access_toklen"}`, server.URL) + c.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/mastodon", strings.NewReader(body)) + c.Request.Header.Add("Content-Type", "application/json") + s := &storage.MockTickerStorage{} + s.On("SaveTicker", mock.Anything).Return(nil) + h := handler{ + storage: s, + config: config.NewConfig(), + } + + h.PutTickerMastodon(c) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestDeleteTickerMastodonTickerNotFound(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + s := &storage.MockTickerStorage{} + h := handler{ + storage: s, + config: config.NewConfig(), + } + + h.DeleteTickerMastodon(c) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestDeleteTickerMastodonStorageError(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("ticker", storage.Ticker{}) + s := &storage.MockTickerStorage{} + s.On("SaveTicker", mock.Anything).Return(errors.New("storage error")) + h := handler{ + storage: s, + config: config.NewConfig(), + } + + h.DeleteTickerMastodon(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestDeleteTickerMastodon(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("ticker", storage.Ticker{}) + s := &storage.MockTickerStorage{} + s.On("SaveTicker", mock.Anything).Return(nil) + h := handler{ + storage: s, + config: config.NewConfig(), + } + + h.DeleteTickerMastodon(c) + + assert.Equal(t, http.StatusOK, w.Code) +} + func TestDeleteTickerTickerNotFound(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go index 20445bbc..9dcdc285 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -18,8 +18,9 @@ type Bridges map[string]Bridge func RegisterBridges(config config.Config, storage storage.TickerStorage) Bridges { twitter := TwitterBridge{config, storage} telegram := TelegramBridge{config, storage} + mastodon := MastodonBridge{config, storage} - return Bridges{"twitter": &twitter, "telegram": &telegram} + return Bridges{"twitter": &twitter, "telegram": &telegram, "mastodon": &mastodon} } func (b *Bridges) Send(ticker storage.Ticker, message *storage.Message) error { diff --git a/internal/bridge/mastodon.go b/internal/bridge/mastodon.go new file mode 100644 index 00000000..cc56141e --- /dev/null +++ b/internal/bridge/mastodon.go @@ -0,0 +1,80 @@ +package bridge + +import ( + "context" + "errors" + + "github.com/mattn/go-mastodon" + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/storage" +) + +type MastodonBridge struct { + config config.Config + storage storage.TickerStorage +} + +func (mb *MastodonBridge) Send(ticker storage.Ticker, message *storage.Message) error { + if !ticker.Mastodon.Active { + return nil + } + + ctx := context.Background() + client := client(ticker) + + var mediaIDs []mastodon.ID + if len(message.Attachments) > 0 { + for _, attachment := range message.Attachments { + upload, err := mb.storage.FindUploadByUUID(attachment.UUID) + if err != nil { + log.WithError(err).Error("failed to find upload") + continue + } + + media, err := client.UploadMedia(ctx, upload.FullPath(mb.config.UploadPath)) + if err != nil { + log.WithError(err).Error("unable to upload the attachment") + continue + } + mediaIDs = append(mediaIDs, media.ID) + } + } + + toot := mastodon.Toot{ + Status: message.Text, + MediaIDs: mediaIDs, + } + + status, err := client.PostStatus(ctx, &toot) + if err != nil { + return err + } + + message.Mastodon = *status + + return nil +} + +func (mb *MastodonBridge) Delete(ticker storage.Ticker, message *storage.Message) error { + if message.Mastodon.ID == "" { + return nil + } + + if !ticker.Mastodon.Connected() { + return errors.New("unable to delete the status") + } + + ctx := context.Background() + client := client(ticker) + + return client.DeleteStatus(ctx, message.Mastodon.ID) +} + +func client(ticker storage.Ticker) *mastodon.Client { + return mastodon.NewClient(&mastodon.Config{ + Server: ticker.Mastodon.Server, + ClientID: ticker.Mastodon.Token, + ClientSecret: ticker.Mastodon.Secret, + AccessToken: ticker.Mastodon.AccessToken, + }) +} diff --git a/internal/storage/message.go b/internal/storage/message.go index 4768057e..7164c0be 100644 --- a/internal/storage/message.go +++ b/internal/storage/message.go @@ -4,6 +4,7 @@ import ( "time" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/mattn/go-mastodon" geojson "github.com/paulmach/go.geojson" ) @@ -16,6 +17,7 @@ type Message struct { GeoInformation geojson.FeatureCollection Tweet Tweet Telegram TelegramMeta + Mastodon mastodon.Status } func NewMessage() Message { diff --git a/internal/storage/ticker.go b/internal/storage/ticker.go index f531bc30..bc17d6c7 100644 --- a/internal/storage/ticker.go +++ b/internal/storage/ticker.go @@ -4,6 +4,7 @@ import ( "time" "github.com/dghubble/go-twitter/twitter" + "github.com/mattn/go-mastodon" ) type Ticker struct { @@ -16,6 +17,7 @@ type Ticker struct { Information Information Twitter Twitter Telegram Telegram + Mastodon Mastodon Location Location } @@ -33,6 +35,7 @@ func (t *Ticker) Reset() { t.Twitter.Reset() t.Telegram.Reset() + t.Mastodon.Reset() } type Information struct { @@ -72,6 +75,28 @@ func (tg *Telegram) Reset() { tg.ChannelName = "" } +type Mastodon struct { + Active bool `json:"active"` + Server string `json:"server"` + Token string `json:"token"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` + User mastodon.Account +} + +func (m *Mastodon) Connected() bool { + return m.Token != "" && m.Secret != "" && m.AccessToken != "" +} + +func (m *Mastodon) Reset() { + m.Active = false + m.Server = "" + m.Token = "" + m.Secret = "" + m.AccessToken = "" + m.User = mastodon.Account{} +} + type Location struct { Lat float64 Lon float64 diff --git a/internal/storage/ticker_test.go b/internal/storage/ticker_test.go index 8707735d..645d43d7 100644 --- a/internal/storage/ticker_test.go +++ b/internal/storage/ticker_test.go @@ -12,6 +12,10 @@ func TestTickerTwitterConnected(t *testing.T) { assert.False(t, ticker.Twitter.Connected()) } +func TestTickerMastodonConnect(t *testing.T) { + assert.False(t, ticker.Mastodon.Connected()) +} + func TestTickerReset(t *testing.T) { ticker.Active = true ticker.Description = "Description"