From 6a13e1bccc4a336b4c3f1747f4c09299c8c7dd33 Mon Sep 17 00:00:00 2001 From: doobry Date: Mon, 6 May 2024 18:06:19 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20Add=20a=20Signal=20group=20brid?= =?UTF-8?q?ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Creates a Signal group when the bridge is configured, with following settings: - name & description as configured - predefined avatar (file stored in the signal-cli container) - invite link enabled - add member permissions: every member - edit details permissions: only admin - send messages: only admin * Sends messages (with attachments) to the Signal group * Tries to leave the Signal group when bridge is reset. This doesnt work though if the Signal account is the only admin (which is almost always the case). * Tries to delete message when it's deleted in ticker. --- config.yml.dist | 4 + go.mod | 1 + go.sum | 2 + internal/api/api.go | 2 + internal/api/response/response.go | 1 + internal/api/response/ticker.go | 18 ++ internal/api/tickers.go | 61 +++++++ internal/bridge/bridge.go | 3 +- internal/bridge/signal_group.go | 39 +++++ internal/config/config.go | 33 +++- internal/signal/signal.go | 272 ++++++++++++++++++++++++++++++ internal/storage/message.go | 7 + internal/storage/migrations.go | 1 + internal/storage/ticker.go | 25 +++ 14 files changed, 460 insertions(+), 9 deletions(-) create mode 100644 internal/bridge/signal_group.go create mode 100644 internal/signal/signal.go diff --git a/config.yml.dist b/config.yml.dist index 0612ce96..8e23ebc3 100644 --- a/config.yml.dist +++ b/config.yml.dist @@ -13,6 +13,10 @@ secret: "slorp-panfil-becall-dorp-hashab-incus-biter-lyra-pelage-sarraf-drunk" # telegram configuration telegram: token: "" +# signal group configuration +signal_group: + api_url: "" + account: "" # listen port for prometheus metrics exporter metrics_listen: ":8181" upload: diff --git a/go.mod b/go.mod index ffdb8394..e301cc01 100644 --- a/go.mod +++ b/go.mod @@ -106,6 +106,7 @@ require ( github.com/ugorji/go/codec v1.2.12 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302 // indirect + github.com/ybbus/jsonrpc/v3 v3.1.5 // indirect go.etcd.io/bbolt v1.3.7 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect go.opentelemetry.io/otel v1.21.0 // indirect diff --git a/go.sum b/go.sum index 1dc592dc..0c3985b1 100644 --- a/go.sum +++ b/go.sum @@ -291,6 +291,8 @@ github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSD github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302 h1:MhInbXe4SzcImAKktUvWBCWZgcw6MYf5NfumTj1BhAw= github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= +github.com/ybbus/jsonrpc/v3 v3.1.5 h1:0cC/QzS8OCuXYqqDbYnKKhsEe+IZLrNlDx8KPCieeW0= +github.com/ybbus/jsonrpc/v3 v3.1.5/go.mod h1:U1QbyNfL5Pvi2roT0OpRbJeyvGxfWYSgKJHjxWdAEeE= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= diff --git a/internal/api/api.go b/internal/api/api.go index c584ccd2..00f98ae3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -73,6 +73,8 @@ func API(config config.Config, store storage.Storage, log *logrus.Logger) *gin.E admin.DELETE(`/tickers/:tickerID/mastodon`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerMastodon) admin.PUT(`/tickers/:tickerID/bluesky`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerBluesky) admin.DELETE(`/tickers/:tickerID/bluesky`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerBluesky) + admin.PUT(`/tickers/:tickerID/signal_group`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerSignalGroup) + admin.DELETE(`/tickers/:tickerID/signal_group`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerSignalGroup) admin.DELETE(`/tickers/:tickerID`, user.NeedAdmin(), ticker.PrefetchTicker(store), handler.DeleteTicker) admin.PUT(`/tickers/:tickerID/reset`, ticker.PrefetchTicker(store, storage.WithPreload()), ticker.PrefetchTicker(store), handler.ResetTicker) admin.GET(`/tickers/:tickerID/users`, ticker.PrefetchTicker(store), handler.GetTickerUsers) diff --git a/internal/api/response/response.go b/internal/api/response/response.go index ed06efab..14b52925 100644 --- a/internal/api/response/response.go +++ b/internal/api/response/response.go @@ -22,6 +22,7 @@ const ( UploadsNotFound ErrorMessage = "uploads not found" MastodonError ErrorMessage = "unable to connect to mastodon" BlueskyError ErrorMessage = "unable to connect to bluesky" + SignalGroupError ErrorMessage = "unable to connect to signal" PasswordError ErrorMessage = "could not authenticate password" StatusSuccess Status = `success` diff --git a/internal/api/response/ticker.go b/internal/api/response/ticker.go index d4fe5ce8..d4533177 100644 --- a/internal/api/response/ticker.go +++ b/internal/api/response/ticker.go @@ -18,6 +18,7 @@ type Ticker struct { Telegram Telegram `json:"telegram"` Mastodon Mastodon `json:"mastodon"` Bluesky Bluesky `json:"bluesky"` + SignalGroup SignalGroup `json:"signalGroup"` Location Location `json:"location"` } @@ -55,6 +56,15 @@ type Bluesky struct { Handle string `json:"handle"` } +type SignalGroup struct { + Active bool `json:"active"` + Connected bool `json:"connected"` + GroupID string `json:"groupID"` + GroupName string `json:"groupName"` + GroupDescription string `json:"groupDescription"` + GroupInviteLink string `json:"groupInviteLink"` +} + type Location struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` @@ -97,6 +107,14 @@ func TickerResponse(t storage.Ticker, config config.Config) Ticker { Connected: t.Bluesky.Connected(), Handle: t.Bluesky.Handle, }, + SignalGroup: SignalGroup{ + Active: t.SignalGroup.Active, + Connected: t.SignalGroup.Connected(), + GroupID: t.SignalGroup.GroupID, + GroupName: t.SignalGroup.GroupName, + GroupDescription: t.SignalGroup.GroupDescription, + GroupInviteLink: t.SignalGroup.GroupInviteLink, + }, Location: Location{ Lat: t.Location.Lat, Lon: t.Location.Lon, diff --git a/internal/api/tickers.go b/internal/api/tickers.go index bc35010c..b2204c24 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -11,6 +11,7 @@ import ( "github.com/systemli/ticker/internal/api/helper" "github.com/systemli/ticker/internal/api/response" "github.com/systemli/ticker/internal/bluesky" + "github.com/systemli/ticker/internal/signal" "github.com/systemli/ticker/internal/storage" ) @@ -289,6 +290,66 @@ func (h *handler) DeleteTickerBluesky(c *gin.Context) { c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) } +func (h *handler) PutTickerSignalGroup(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.TickerSignalGroup + err = c.Bind(&body) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeNotFound, response.FormError)) + return + } + + if body.GroupName != "" && body.GroupDescription != "" { + ticker.SignalGroup.GroupName = body.GroupName + ticker.SignalGroup.GroupDescription = body.GroupDescription + err = signal.CreateOrUpdateGroup(&ticker.SignalGroup, h.config) + if err != nil { + log.WithError(err).Error("failed to create or update group") + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.SignalGroupError)) + return + } + } + ticker.SignalGroup.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) DeleteTickerSignalGroup(c *gin.Context) { + ticker, err := helper.Ticker(c) + if err != nil { + c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound)) + return + } + + err = signal.QuitGroup(h.config, ticker.SignalGroup.GroupID) + if err != nil { + log.WithError(err).Error("failed to quit group") + // c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.SignalGroupError)) + // return + } + + ticker.SignalGroup.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/bridge/bridge.go b/internal/bridge/bridge.go index 93dc6c25..a4e73490 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -19,8 +19,9 @@ func RegisterBridges(config config.Config, storage storage.Storage) Bridges { telegram := TelegramBridge{config, storage} mastodon := MastodonBridge{config, storage} bluesky := BlueskyBridge{config, storage} + signalGroup := SignalGroupBridge{config, storage} - return Bridges{"telegram": &telegram, "mastodon": &mastodon, "bluesky": &bluesky} + return Bridges{"telegram": &telegram, "mastodon": &mastodon, "bluesky": &bluesky, "signalGroup": &signalGroup} } func (b *Bridges) Send(ticker storage.Ticker, message *storage.Message) error { diff --git a/internal/bridge/signal_group.go b/internal/bridge/signal_group.go new file mode 100644 index 00000000..499cf2b8 --- /dev/null +++ b/internal/bridge/signal_group.go @@ -0,0 +1,39 @@ +package bridge + +import ( + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/signal" + "github.com/systemli/ticker/internal/storage" +) + +type SignalGroupBridge struct { + config config.Config + storage storage.Storage +} + +func (sb *SignalGroupBridge) Send(ticker storage.Ticker, message *storage.Message) error { + if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active { + return nil + } + + err := signal.SendGroupMessage(sb.config, sb.storage, ticker.SignalGroup.GroupID, message) + if err != nil { + return err + } + + return nil +} + +func (sb *SignalGroupBridge) Delete(ticker storage.Ticker, message *storage.Message) error { + if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active || message.SignalGroup.Timestamp == nil { + return nil + } + + err := signal.DeleteMessage(sb.config, ticker.SignalGroup.GroupID, message) + if err != nil { + return err + } + + return nil + +} diff --git a/internal/config/config.go b/internal/config/config.go index 8114cace..dca4c725 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,14 +14,15 @@ import ( var log = logrus.WithField("package", "config") type Config struct { - Listen string `yaml:"listen"` - LogLevel string `yaml:"log_level"` - LogFormat string `yaml:"log_format"` - Secret string `yaml:"secret"` - Database Database `yaml:"database"` - Telegram Telegram `yaml:"telegram"` - MetricsListen string `yaml:"metrics_listen"` - Upload Upload `yaml:"upload"` + Listen string `yaml:"listen"` + LogLevel string `yaml:"log_level"` + LogFormat string `yaml:"log_format"` + Secret string `yaml:"secret"` + Database Database `yaml:"database"` + Telegram Telegram `yaml:"telegram"` + SignalGroup SignalGroup `yaml:"signal_group"` + MetricsListen string `yaml:"metrics_listen"` + Upload Upload `yaml:"upload"` FileBackend afero.Fs } @@ -35,6 +36,11 @@ type Telegram struct { User tgbotapi.User } +type SignalGroup struct { + ApiUrl string `yaml:"api_url"` + Account string +} + type Upload struct { Path string `yaml:"path"` URL string `yaml:"url"` @@ -63,6 +69,11 @@ func (t *Telegram) Enabled() bool { return t.Token != "" } +// Enabled returns true if requried API URL and account are set. +func (t *SignalGroup) Enabled() bool { + return t.ApiUrl != "" && t.Account != "" +} + // LoadConfig loads config from file. func LoadConfig(path string) Config { c := defaultConfig() @@ -108,6 +119,12 @@ func LoadConfig(path string) Config { if os.Getenv("TICKER_TELEGRAM_TOKEN") != "" { c.Telegram.Token = os.Getenv("TICKER_TELEGRAM_TOKEN") } + if os.Getenv("TICKER_SIGNAL_GROUP_API_URL") != "" { + c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_API_URL") + } + if os.Getenv("TICKER_SIGNAL_GROUP_ACCOUNT") != "" { + c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_ACCOUNT") + } return c } diff --git a/internal/signal/signal.go b/internal/signal/signal.go new file mode 100644 index 00000000..043bc7a8 --- /dev/null +++ b/internal/signal/signal.go @@ -0,0 +1,272 @@ +package signal + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "os" + + "github.com/sirupsen/logrus" + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/storage" + "github.com/ybbus/jsonrpc/v3" +) + +var log = logrus.WithField("package", "signal") + +type createGroupParams struct { + Account string `json:"account"` + Name string `json:"name"` + Description string `json:"description"` + Avatar string `json:"avatar"` + Link string `json:"link"` + SetPermissionAddMember string `json:"setPermissionAddMember"` + SetPermissionEditDetails string `json:"setPermissionEditDetails"` + SetPermissionSendMessages string `json:"setPermissionSendMessages"` + Expiration int `json:"expiration"` +} + +type CreateGroupResponse struct { + GroupID string `json:"groupId"` + Timestamp int `json:"timestamp"` +} + +type updateGroupParams struct { + Account string `json:"account"` + GroupID string `json:"group-id"` + Name string `json:"name"` + Description string `json:"description"` + Avatar string `json:"avatar"` + Link string `json:"link"` + SetPermissionAddMember string `json:"setPermissionAddMember"` + SetPermissionEditDetails string `json:"setPermissionEditDetails"` + SetPermissionSendMessages string `json:"setPermissionSendMessages"` + Expiration int `json:"expiration"` +} + +type UpdateGroupResponse struct { + Timestamp int `json:"timestamp"` +} + +type QuitGroupParams struct { + Account string `json:"account"` + GroupID string `json:"group-id"` + Delete bool `json:"delete"` +} + +type ListGroupsParams struct { + Account string `json:"account"` +} + +type ListGroupsResponseGroup struct { + GroupID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + GroupInviteLink string `json:"groupInviteLink"` +} + +type SendParams struct { + Account string `json:"account"` + GroupID string `json:"group-id"` + Message string `json:"message"` + Attachment []string `json:"attachment"` +} + +type SendResponse struct { + Timestamp *int `json:"timestamp"` +} + +type DeleteParams struct { + Account string `json:"account"` + GroupID string `json:"group-id"` + TargetTimestamp *int `json:"target-timestamp"` +} + +func CreateOrUpdateGroup(ts *storage.TickerSignalGroup, config config.Config) error { + ctx := context.Background() + client := rpcClient(config.SignalGroup.ApiUrl) + + var err error + if ts.GroupID == "" { + // Create new group + var response *CreateGroupResponse + params := createGroupParams{ + Account: config.SignalGroup.Account, + Name: ts.GroupName, + Description: ts.GroupDescription, + Avatar: "/var/lib/signal-cli/data/ticker.png", + Link: "enabled", + SetPermissionAddMember: "every-member", + SetPermissionEditDetails: "only-admins", + SetPermissionSendMessages: "only-admins", + Expiration: 86400, + } + err = client.CallFor(ctx, &response, "updateGroup", ¶ms) + if err != nil { + return err + } + if response.GroupID == "" { + return errors.New("SignalGroup Bridge: No group ID in create group response") + } + log.WithField("groupId", response.GroupID).Debug("Created group") + ts.GroupID = response.GroupID + } else { + // Update existing group + params := updateGroupParams{ + Account: config.SignalGroup.Account, + GroupID: ts.GroupID, + Name: ts.GroupName, + Description: ts.GroupDescription, + Avatar: "/var/lib/signal-cli/data/ticker.png", + Link: "enabled", + SetPermissionAddMember: "every-member", + SetPermissionEditDetails: "only-admins", + SetPermissionSendMessages: "only-admins", + Expiration: 86400, + } + var response *UpdateGroupResponse + err = client.CallFor(ctx, &response, "updateGroup", ¶ms) + if err != nil { + return err + } + } + + g, err := getGroup(config, ts.GroupID) + if err != nil { + return err + } + if g == nil { + return errors.New("SignalGroup Bridge: Group not found") + } + if g.GroupInviteLink == "" { + return errors.New("SignalGroup Bridge: No invite link in group response") + } + + ts.GroupInviteLink = g.GroupInviteLink + + return nil +} + +func QuitGroup(config config.Config, groupID string) error { + ctx := context.Background() + client := rpcClient(config.SignalGroup.ApiUrl) + + params := QuitGroupParams{ + Account: config.SignalGroup.Account, + GroupID: groupID, + Delete: true, + } + + // TODO: cannot leave group if I'm the last admin + // Maybe promote first other member to admin? + var response interface{} + err := client.CallFor(ctx, &response, "leaveGroup", ¶ms) + if err != nil { + return err + } + + return nil +} + +func listGroups(config config.Config) ([]*ListGroupsResponseGroup, error) { + ctx := context.Background() + client := rpcClient(config.SignalGroup.ApiUrl) + + params := ListGroupsParams{ + Account: config.SignalGroup.Account, + } + + var response []*ListGroupsResponseGroup + err := client.CallFor(ctx, &response, "listGroups", ¶ms) + if err != nil { + return nil, err + } + + return response, nil +} + +func getGroup(config config.Config, groupID string) (*ListGroupsResponseGroup, error) { + gl, err := listGroups(config) + if err != nil { + return nil, err + } + + for _, g := range gl { + if g.GroupID == groupID { + return g, nil + } + } + + return nil, nil +} + +func SendGroupMessage(config config.Config, ss storage.Storage, groupID string, message *storage.Message) error { + ctx := context.Background() + client := rpcClient(config.SignalGroup.ApiUrl) + + var attachments []string + if len(message.Attachments) > 0 { + for _, attachment := range message.Attachments { + upload, err := ss.FindUploadByUUID(attachment.UUID) + if err != nil { + log.WithError(err).Error("failed to find upload") + continue + } + + fileContent, err := os.ReadFile(upload.FullPath(config.Upload.Path)) + if err != nil { + log.WithError(err).Error("failed to read file") + continue + } + fileBase64 := base64.StdEncoding.EncodeToString(fileContent) + aString := fmt.Sprintf("data:%s;filename=%s;base64,%s", upload.ContentType, upload.FileName, fileBase64) + attachments = append(attachments, aString) + } + } + + params := SendParams{ + Account: config.SignalGroup.Account, + GroupID: groupID, + Message: message.Text, + Attachment: attachments, + } + + var response *SendResponse + err := client.CallFor(ctx, &response, "send", ¶ms) + if err != nil { + return err + } + if response.Timestamp == nil { + return errors.New("SignalGroup Bridge: No timestamp in send response") + } + + message.SignalGroup = storage.SignalGroupMeta{ + Timestamp: response.Timestamp, + } + + return nil +} + +func DeleteMessage(config config.Config, groupID string, message *storage.Message) error { + ctx := context.Background() + client := rpcClient(config.SignalGroup.ApiUrl) + + params := DeleteParams{ + Account: config.SignalGroup.Account, + GroupID: groupID, + TargetTimestamp: message.SignalGroup.Timestamp, + } + + var response *SendResponse + err := client.CallFor(ctx, &response, "remoteDelete", ¶ms) + if err != nil { + return err + } + + return nil +} + +func rpcClient(apiUrl string) jsonrpc.RPCClient { + return jsonrpc.NewClient(apiUrl) +} diff --git a/internal/storage/message.go b/internal/storage/message.go index 063a44ff..07a46f48 100644 --- a/internal/storage/message.go +++ b/internal/storage/message.go @@ -21,6 +21,7 @@ type Message struct { Telegram TelegramMeta `gorm:"serializer:json"` Mastodon MastodonMeta `gorm:"serializer:json"` Bluesky BlueskyMeta `gorm:"serializer:json"` + SignalGroup SignalGroupMeta `gorm:"serializer:json"` } func NewMessage() Message { @@ -32,6 +33,7 @@ func (m *Message) AsMap() map[string]interface{} { telegram, _ := json.Marshal(m.Telegram) mastodon, _ := json.Marshal(m.Mastodon) bluesky, _ := json.Marshal(m.Bluesky) + signalGroup, _ := json.Marshal(m.SignalGroup) return map[string]interface{}{ "id": m.ID, @@ -43,6 +45,7 @@ func (m *Message) AsMap() map[string]interface{} { "telegram": telegram, "mastodon": mastodon, "bluesky": bluesky, + "signal_group": signalGroup, } } @@ -62,6 +65,10 @@ type BlueskyMeta struct { Cid string } +type SignalGroupMeta struct { + Timestamp *int +} + type Attachment struct { ID int `gorm:"primaryKey"` CreatedAt time.Time diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 76a3746f..013ec8a2 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -9,6 +9,7 @@ func MigrateDB(db *gorm.DB) error { &TickerMastodon{}, &TickerTelegram{}, &TickerBluesky{}, + &TickerSignalGroup{}, &User{}, &Setting{}, &Upload{}, diff --git a/internal/storage/ticker.go b/internal/storage/ticker.go index 25afe708..914d2d33 100644 --- a/internal/storage/ticker.go +++ b/internal/storage/ticker.go @@ -19,6 +19,7 @@ type Ticker struct { Telegram TickerTelegram Mastodon TickerMastodon Bluesky TickerBluesky + SignalGroup TickerSignalGroup Users []User `gorm:"many2many:ticker_users;"` } @@ -141,6 +142,30 @@ func (b *TickerBluesky) Reset() { b.AppKey = "" } +type TickerSignalGroup struct { + ID int `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + TickerID int `gorm:"index"` + Active bool + GroupName string + GroupDescription string + GroupID string + GroupInviteLink string +} + +func (s *TickerSignalGroup) Connected() bool { + return s.GroupID != "" +} + +func (s *TickerSignalGroup) Reset() { + s.Active = false + s.GroupName = "" + s.GroupDescription = "" + s.GroupID = "" + s.GroupInviteLink = "" +} + type TickerLocation struct { Lat float64 Lon float64 From a58a5ad9f3378af75463ee04e47a54995d24f88e Mon Sep 17 00:00:00 2001 From: doobry Date: Tue, 7 May 2024 17:51:08 +0200 Subject: [PATCH 2/7] Allow to set HTTP AUTH user and password for signal-cli JSON-RPC API --- config.yml.dist | 2 ++ internal/config/config.go | 8 ++++++++ internal/signal/signal.go | 23 ++++++++++++++++------- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/config.yml.dist b/config.yml.dist index 8e23ebc3..1fd6a00b 100644 --- a/config.yml.dist +++ b/config.yml.dist @@ -16,6 +16,8 @@ telegram: # signal group configuration signal_group: api_url: "" + api_user: "signal-cli" + api_pass: "" account: "" # listen port for prometheus metrics exporter metrics_listen: ":8181" diff --git a/internal/config/config.go b/internal/config/config.go index dca4c725..99970f23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,6 +38,8 @@ type Telegram struct { type SignalGroup struct { ApiUrl string `yaml:"api_url"` + ApiUser string `yaml:"api_user"` + ApiPass string `yaml:"api_pass"` Account string } @@ -122,6 +124,12 @@ func LoadConfig(path string) Config { if os.Getenv("TICKER_SIGNAL_GROUP_API_URL") != "" { c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_API_URL") } + if os.Getenv("TICKER_SIGNAL_GROUP_API_USER") != "" { + c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_API_USER") + } + if os.Getenv("TICKER_SIGNAL_GROUP_API_PASS") != "" { + c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_API_PASS") + } if os.Getenv("TICKER_SIGNAL_GROUP_ACCOUNT") != "" { c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_ACCOUNT") } diff --git a/internal/signal/signal.go b/internal/signal/signal.go index 043bc7a8..11f5b52a 100644 --- a/internal/signal/signal.go +++ b/internal/signal/signal.go @@ -85,7 +85,7 @@ type DeleteParams struct { func CreateOrUpdateGroup(ts *storage.TickerSignalGroup, config config.Config) error { ctx := context.Background() - client := rpcClient(config.SignalGroup.ApiUrl) + client := rpcClient(config) var err error if ts.GroupID == "" { @@ -150,7 +150,7 @@ func CreateOrUpdateGroup(ts *storage.TickerSignalGroup, config config.Config) er func QuitGroup(config config.Config, groupID string) error { ctx := context.Background() - client := rpcClient(config.SignalGroup.ApiUrl) + client := rpcClient(config) params := QuitGroupParams{ Account: config.SignalGroup.Account, @@ -171,7 +171,7 @@ func QuitGroup(config config.Config, groupID string) error { func listGroups(config config.Config) ([]*ListGroupsResponseGroup, error) { ctx := context.Background() - client := rpcClient(config.SignalGroup.ApiUrl) + client := rpcClient(config) params := ListGroupsParams{ Account: config.SignalGroup.Account, @@ -203,7 +203,7 @@ func getGroup(config config.Config, groupID string) (*ListGroupsResponseGroup, e func SendGroupMessage(config config.Config, ss storage.Storage, groupID string, message *storage.Message) error { ctx := context.Background() - client := rpcClient(config.SignalGroup.ApiUrl) + client := rpcClient(config) var attachments []string if len(message.Attachments) > 0 { @@ -250,7 +250,7 @@ func SendGroupMessage(config config.Config, ss storage.Storage, groupID string, func DeleteMessage(config config.Config, groupID string, message *storage.Message) error { ctx := context.Background() - client := rpcClient(config.SignalGroup.ApiUrl) + client := rpcClient(config) params := DeleteParams{ Account: config.SignalGroup.Account, @@ -267,6 +267,15 @@ func DeleteMessage(config config.Config, groupID string, message *storage.Messag return nil } -func rpcClient(apiUrl string) jsonrpc.RPCClient { - return jsonrpc.NewClient(apiUrl) +func rpcClient(config config.Config) jsonrpc.RPCClient { + if config.SignalGroup.ApiUser != "" && config.SignalGroup.ApiPass != "" { + return jsonrpc.NewClientWithOpts(config.SignalGroup.ApiUrl, &jsonrpc.RPCClientOpts{ + CustomHeaders: map[string]string{ + "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(config.SignalGroup.ApiUser+":"+config.SignalGroup.ApiPass)), + }, + }) + } else { + return jsonrpc.NewClient(config.SignalGroup.ApiUrl) + + } } From b312edbd261ae824962180320f8fa9cb4e81c2f9 Mon Sep 17 00:00:00 2001 From: louis Date: Tue, 21 May 2024 21:01:44 +0200 Subject: [PATCH 3/7] Apply review suggestions --- internal/api/tickers.go | 8 +- internal/bridge/signal_group.go | 72 ++++++++- internal/config/config.go | 11 +- internal/signal/signal.go | 261 +++++++------------------------- internal/storage/message.go | 2 +- 5 files changed, 131 insertions(+), 223 deletions(-) diff --git a/internal/api/tickers.go b/internal/api/tickers.go index b2204c24..9dd14bdf 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -307,7 +307,8 @@ func (h *handler) PutTickerSignalGroup(c *gin.Context) { if body.GroupName != "" && body.GroupDescription != "" { ticker.SignalGroup.GroupName = body.GroupName ticker.SignalGroup.GroupDescription = body.GroupDescription - err = signal.CreateOrUpdateGroup(&ticker.SignalGroup, h.config) + groupClient := signal.NewGroupClient(h.config) + err = groupClient.CreateOrUpdateGroup(&ticker.SignalGroup) if err != nil { log.WithError(err).Error("failed to create or update group") c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.SignalGroupError)) @@ -332,11 +333,10 @@ func (h *handler) DeleteTickerSignalGroup(c *gin.Context) { return } - err = signal.QuitGroup(h.config, ticker.SignalGroup.GroupID) + groupClient := signal.NewGroupClient(h.config) + err = groupClient.QuitGroup(ticker.SignalGroup.GroupID) if err != nil { log.WithError(err).Error("failed to quit group") - // c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.SignalGroupError)) - // return } ticker.SignalGroup.Reset() diff --git a/internal/bridge/signal_group.go b/internal/bridge/signal_group.go index 499cf2b8..ea89c232 100644 --- a/internal/bridge/signal_group.go +++ b/internal/bridge/signal_group.go @@ -1,6 +1,12 @@ package bridge import ( + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "github.com/systemli/ticker/internal/config" "github.com/systemli/ticker/internal/signal" "github.com/systemli/ticker/internal/storage" @@ -11,29 +17,87 @@ type SignalGroupBridge struct { storage storage.Storage } +type SignalGroupResponse struct { + Timestamp int `json:"timestamp"` +} + func (sb *SignalGroupBridge) Send(ticker storage.Ticker, message *storage.Message) error { if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active { return nil } - err := signal.SendGroupMessage(sb.config, sb.storage, ticker.SignalGroup.GroupID, message) + ctx := context.Background() + client := signal.Client(sb.config) + + var attachments []string + if len(message.Attachments) > 0 { + for _, attachment := range message.Attachments { + upload, err := sb.storage.FindUploadByUUID(attachment.UUID) + if err != nil { + log.WithError(err).Error("failed to find upload") + continue + } + + fileContent, err := os.ReadFile(upload.FullPath(sb.config.Upload.Path)) + if err != nil { + log.WithError(err).Error("failed to read file") + continue + } + fileBase64 := base64.StdEncoding.EncodeToString(fileContent) + aString := fmt.Sprintf("data:%s;filename=%s;base64,%s", upload.ContentType, upload.FileName(), fileBase64) + attachments = append(attachments, aString) + } + } + + params := struct { + Account string `json:"account"` + GroupID string `json:"group-id"` + Message string `json:"message"` + Attachment []string `json:"attachment"` + }{ + Account: sb.config.SignalGroup.Account, + GroupID: ticker.SignalGroup.GroupID, + Message: message.Text, + Attachment: attachments, + } + + var response SignalGroupResponse + err := client.CallFor(ctx, &response, "send", ¶ms) if err != nil { return err } + if response.Timestamp == 0 { + return errors.New("SignalGroup Bridge: No timestamp in send response") + } + + message.SignalGroup = storage.SignalGroupMeta{ + Timestamp: response.Timestamp, + } return nil } func (sb *SignalGroupBridge) Delete(ticker storage.Ticker, message *storage.Message) error { - if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active || message.SignalGroup.Timestamp == nil { + if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active || message.SignalGroup.Timestamp != 0 { return nil } - err := signal.DeleteMessage(sb.config, ticker.SignalGroup.GroupID, message) + client := signal.Client(sb.config) + params := struct { + Account string `json:"account"` + GroupID string `json:"group-id"` + TargetTimestamp int `json:"target-timestamp"` + }{ + Account: sb.config.SignalGroup.Account, + GroupID: ticker.SignalGroup.GroupID, + TargetTimestamp: message.SignalGroup.Timestamp, + } + + var response SignalGroupResponse + err := client.CallFor(context.Background(), &response, "remoteDelete", ¶ms) if err != nil { return err } return nil - } diff --git a/internal/config/config.go b/internal/config/config.go index 99970f23..7d3c681b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,9 +38,8 @@ type Telegram struct { type SignalGroup struct { ApiUrl string `yaml:"api_url"` - ApiUser string `yaml:"api_user"` - ApiPass string `yaml:"api_pass"` - Account string + Avatar string `yaml:"avatar"` + Account string `yaml:"account"` } type Upload struct { @@ -124,12 +123,6 @@ func LoadConfig(path string) Config { if os.Getenv("TICKER_SIGNAL_GROUP_API_URL") != "" { c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_API_URL") } - if os.Getenv("TICKER_SIGNAL_GROUP_API_USER") != "" { - c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_API_USER") - } - if os.Getenv("TICKER_SIGNAL_GROUP_API_PASS") != "" { - c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_API_PASS") - } if os.Getenv("TICKER_SIGNAL_GROUP_ACCOUNT") != "" { c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_ACCOUNT") } diff --git a/internal/signal/signal.go b/internal/signal/signal.go index 11f5b52a..b5f3b366 100644 --- a/internal/signal/signal.go +++ b/internal/signal/signal.go @@ -2,63 +2,13 @@ package signal import ( "context" - "encoding/base64" "errors" - "fmt" - "os" - "github.com/sirupsen/logrus" "github.com/systemli/ticker/internal/config" "github.com/systemli/ticker/internal/storage" "github.com/ybbus/jsonrpc/v3" ) -var log = logrus.WithField("package", "signal") - -type createGroupParams struct { - Account string `json:"account"` - Name string `json:"name"` - Description string `json:"description"` - Avatar string `json:"avatar"` - Link string `json:"link"` - SetPermissionAddMember string `json:"setPermissionAddMember"` - SetPermissionEditDetails string `json:"setPermissionEditDetails"` - SetPermissionSendMessages string `json:"setPermissionSendMessages"` - Expiration int `json:"expiration"` -} - -type CreateGroupResponse struct { - GroupID string `json:"groupId"` - Timestamp int `json:"timestamp"` -} - -type updateGroupParams struct { - Account string `json:"account"` - GroupID string `json:"group-id"` - Name string `json:"name"` - Description string `json:"description"` - Avatar string `json:"avatar"` - Link string `json:"link"` - SetPermissionAddMember string `json:"setPermissionAddMember"` - SetPermissionEditDetails string `json:"setPermissionEditDetails"` - SetPermissionSendMessages string `json:"setPermissionSendMessages"` - Expiration int `json:"expiration"` -} - -type UpdateGroupResponse struct { - Timestamp int `json:"timestamp"` -} - -type QuitGroupParams struct { - Account string `json:"account"` - GroupID string `json:"group-id"` - Delete bool `json:"delete"` -} - -type ListGroupsParams struct { - Account string `json:"account"` -} - type ListGroupsResponseGroup struct { GroupID string `json:"id"` Name string `json:"name"` @@ -66,81 +16,55 @@ type ListGroupsResponseGroup struct { GroupInviteLink string `json:"groupInviteLink"` } -type SendParams struct { - Account string `json:"account"` - GroupID string `json:"group-id"` - Message string `json:"message"` - Attachment []string `json:"attachment"` +type GroupClient struct { + cfg config.Config + client jsonrpc.RPCClient } -type SendResponse struct { - Timestamp *int `json:"timestamp"` -} +func NewGroupClient(cfg config.Config) *GroupClient { + client := Client(cfg) -type DeleteParams struct { - Account string `json:"account"` - GroupID string `json:"group-id"` - TargetTimestamp *int `json:"target-timestamp"` + return &GroupClient{cfg, client} } -func CreateOrUpdateGroup(ts *storage.TickerSignalGroup, config config.Config) error { - ctx := context.Background() - client := rpcClient(config) +func (gc *GroupClient) CreateOrUpdateGroup(ts *storage.TickerSignalGroup) error { + params := map[string]interface{}{ + "account": gc.cfg.SignalGroup.Account, + "name": ts.GroupName, + "description": ts.GroupDescription, + "avatar": gc.cfg.SignalGroup.Avatar, + "link": "enabled", + "setPermissionAddMember": "every-member", + "setPermissionEditDetails": "only-admins", + "setPermissionSendMessages": "only-admins", + "expiration": 86400, + } + if ts.GroupID != "" { + params["group-id"] = ts.GroupID + } - var err error + var response struct { + GroupID string `json:"groupId"` + Timestamp int `json:"timestamp"` + } + err := gc.client.CallFor(context.Background(), &response, "updateGroup", ¶ms) + if err != nil { + return err + } if ts.GroupID == "" { - // Create new group - var response *CreateGroupResponse - params := createGroupParams{ - Account: config.SignalGroup.Account, - Name: ts.GroupName, - Description: ts.GroupDescription, - Avatar: "/var/lib/signal-cli/data/ticker.png", - Link: "enabled", - SetPermissionAddMember: "every-member", - SetPermissionEditDetails: "only-admins", - SetPermissionSendMessages: "only-admins", - Expiration: 86400, - } - err = client.CallFor(ctx, &response, "updateGroup", ¶ms) - if err != nil { - return err - } - if response.GroupID == "" { - return errors.New("SignalGroup Bridge: No group ID in create group response") - } - log.WithField("groupId", response.GroupID).Debug("Created group") ts.GroupID = response.GroupID - } else { - // Update existing group - params := updateGroupParams{ - Account: config.SignalGroup.Account, - GroupID: ts.GroupID, - Name: ts.GroupName, - Description: ts.GroupDescription, - Avatar: "/var/lib/signal-cli/data/ticker.png", - Link: "enabled", - SetPermissionAddMember: "every-member", - SetPermissionEditDetails: "only-admins", - SetPermissionSendMessages: "only-admins", - Expiration: 86400, - } - var response *UpdateGroupResponse - err = client.CallFor(ctx, &response, "updateGroup", ¶ms) - if err != nil { - return err - } } - g, err := getGroup(config, ts.GroupID) + if ts.GroupID == "" { + return errors.New("unable to create or update group") + } + + g, err := gc.getGroup(ts.GroupID) if err != nil { return err } - if g == nil { - return errors.New("SignalGroup Bridge: Group not found") - } if g.GroupInviteLink == "" { - return errors.New("SignalGroup Bridge: No invite link in group response") + return errors.New("unable to get group invite link") } ts.GroupInviteLink = g.GroupInviteLink @@ -148,12 +72,13 @@ func CreateOrUpdateGroup(ts *storage.TickerSignalGroup, config config.Config) er return nil } -func QuitGroup(config config.Config, groupID string) error { - ctx := context.Background() - client := rpcClient(config) - - params := QuitGroupParams{ - Account: config.SignalGroup.Account, +func (gc *GroupClient) QuitGroup(groupID string) error { + params := struct { + Account string `json:"account"` + GroupID string `json:"group-id"` + Delete bool `json:"delete"` + }{ + Account: gc.cfg.SignalGroup.Account, GroupID: groupID, Delete: true, } @@ -161,7 +86,7 @@ func QuitGroup(config config.Config, groupID string) error { // TODO: cannot leave group if I'm the last admin // Maybe promote first other member to admin? var response interface{} - err := client.CallFor(ctx, &response, "leaveGroup", ¶ms) + err := gc.client.CallFor(context.Background(), &response, "leaveGroup", ¶ms) if err != nil { return err } @@ -169,16 +94,17 @@ func QuitGroup(config config.Config, groupID string) error { return nil } -func listGroups(config config.Config) ([]*ListGroupsResponseGroup, error) { +func (gc *GroupClient) listGroups() ([]ListGroupsResponseGroup, error) { ctx := context.Background() - client := rpcClient(config) - params := ListGroupsParams{ - Account: config.SignalGroup.Account, + params := struct { + Account string `json:"account"` + }{ + Account: gc.cfg.SignalGroup.Account, } - var response []*ListGroupsResponseGroup - err := client.CallFor(ctx, &response, "listGroups", ¶ms) + var response []ListGroupsResponseGroup + err := gc.client.CallFor(ctx, &response, "listGroups", ¶ms) if err != nil { return nil, err } @@ -186,10 +112,10 @@ func listGroups(config config.Config) ([]*ListGroupsResponseGroup, error) { return response, nil } -func getGroup(config config.Config, groupID string) (*ListGroupsResponseGroup, error) { - gl, err := listGroups(config) +func (gc *GroupClient) getGroup(groupID string) (ListGroupsResponseGroup, error) { + gl, err := gc.listGroups() if err != nil { - return nil, err + return ListGroupsResponseGroup{}, err } for _, g := range gl { @@ -198,84 +124,9 @@ func getGroup(config config.Config, groupID string) (*ListGroupsResponseGroup, e } } - return nil, nil -} - -func SendGroupMessage(config config.Config, ss storage.Storage, groupID string, message *storage.Message) error { - ctx := context.Background() - client := rpcClient(config) - - var attachments []string - if len(message.Attachments) > 0 { - for _, attachment := range message.Attachments { - upload, err := ss.FindUploadByUUID(attachment.UUID) - if err != nil { - log.WithError(err).Error("failed to find upload") - continue - } - - fileContent, err := os.ReadFile(upload.FullPath(config.Upload.Path)) - if err != nil { - log.WithError(err).Error("failed to read file") - continue - } - fileBase64 := base64.StdEncoding.EncodeToString(fileContent) - aString := fmt.Sprintf("data:%s;filename=%s;base64,%s", upload.ContentType, upload.FileName, fileBase64) - attachments = append(attachments, aString) - } - } - - params := SendParams{ - Account: config.SignalGroup.Account, - GroupID: groupID, - Message: message.Text, - Attachment: attachments, - } - - var response *SendResponse - err := client.CallFor(ctx, &response, "send", ¶ms) - if err != nil { - return err - } - if response.Timestamp == nil { - return errors.New("SignalGroup Bridge: No timestamp in send response") - } - - message.SignalGroup = storage.SignalGroupMeta{ - Timestamp: response.Timestamp, - } - - return nil -} - -func DeleteMessage(config config.Config, groupID string, message *storage.Message) error { - ctx := context.Background() - client := rpcClient(config) - - params := DeleteParams{ - Account: config.SignalGroup.Account, - GroupID: groupID, - TargetTimestamp: message.SignalGroup.Timestamp, - } - - var response *SendResponse - err := client.CallFor(ctx, &response, "remoteDelete", ¶ms) - if err != nil { - return err - } - - return nil + return ListGroupsResponseGroup{}, nil } -func rpcClient(config config.Config) jsonrpc.RPCClient { - if config.SignalGroup.ApiUser != "" && config.SignalGroup.ApiPass != "" { - return jsonrpc.NewClientWithOpts(config.SignalGroup.ApiUrl, &jsonrpc.RPCClientOpts{ - CustomHeaders: map[string]string{ - "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(config.SignalGroup.ApiUser+":"+config.SignalGroup.ApiPass)), - }, - }) - } else { - return jsonrpc.NewClient(config.SignalGroup.ApiUrl) - - } +func Client(config config.Config) jsonrpc.RPCClient { + return jsonrpc.NewClient(config.SignalGroup.ApiUrl) } diff --git a/internal/storage/message.go b/internal/storage/message.go index 07a46f48..488eeeec 100644 --- a/internal/storage/message.go +++ b/internal/storage/message.go @@ -66,7 +66,7 @@ type BlueskyMeta struct { } type SignalGroupMeta struct { - Timestamp *int + Timestamp int } type Attachment struct { From d392eb131e68bedef495f007f16352e81685d683 Mon Sep 17 00:00:00 2001 From: doobry Date: Sun, 2 Jun 2024 16:49:32 +0200 Subject: [PATCH 4/7] Add tests for signal group feature --- internal/api/response/ticker_test.go | 12 ++ internal/api/tickers_test.go | 222 +++++++++++++++++++++++++++ internal/bridge/bridge_test.go | 11 +- internal/bridge/signal_group.go | 2 +- internal/bridge/signal_group_test.go | 215 ++++++++++++++++++++++++++ internal/config/config_test.go | 5 + internal/storage/sql_storage_test.go | 3 + internal/storage/ticker.go | 1 + internal/storage/ticker_test.go | 11 ++ 9 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 internal/bridge/signal_group_test.go diff --git a/internal/api/response/ticker_test.go b/internal/api/response/ticker_test.go index 66e05c77..de2045be 100644 --- a/internal/api/response/ticker_test.go +++ b/internal/api/response/ticker_test.go @@ -45,6 +45,13 @@ func (s *TickersResponseTestSuite) TestTickersResponse() { Avatar: "https://example.com/avatar.png", }, }, + SignalGroup: storage.TickerSignalGroup{ + Active: true, + GroupID: "example", + GroupName: "Example", + GroupDescription: "Example", + GroupInviteLink: "https://signal.group/#example", + }, Location: storage.TickerLocation{ Lat: 0.0, Lon: 0.0, @@ -86,6 +93,11 @@ func (s *TickersResponseTestSuite) TestTickersResponse() { s.Equal(ticker.Mastodon.Server, tickerResponse[0].Mastodon.Server) s.Equal(ticker.Mastodon.User.DisplayName, tickerResponse[0].Mastodon.ScreenName) s.Equal(ticker.Mastodon.User.Avatar, tickerResponse[0].Mastodon.ImageURL) + s.Equal(ticker.SignalGroup.Active, tickerResponse[0].SignalGroup.Active) + s.Equal(ticker.SignalGroup.Connected(), tickerResponse[0].SignalGroup.Connected) + s.Equal(ticker.SignalGroup.GroupID, tickerResponse[0].SignalGroup.GroupID) + s.Equal(ticker.SignalGroup.GroupName, tickerResponse[0].SignalGroup.GroupName) + s.Equal(ticker.SignalGroup.GroupDescription, tickerResponse[0].SignalGroup.GroupDescription) s.Equal(ticker.Location.Lat, tickerResponse[0].Location.Lat) s.Equal(ticker.Location.Lon, tickerResponse[0].Location.Lon) } diff --git a/internal/api/tickers_test.go b/internal/api/tickers_test.go index 4771426d..739cc979 100644 --- a/internal/api/tickers_test.go +++ b/internal/api/tickers_test.go @@ -41,6 +41,10 @@ func (s *TickerTestSuite) Run(name string, subtest func()) { s.ctx, _ = gin.CreateTestContext(s.w) s.store = &storage.MockStorage{} s.cfg = config.LoadConfig("") + s.cfg.SignalGroup = config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "+1234567890", + } s.cache = cache.NewCache(time.Minute) subtest() @@ -570,6 +574,224 @@ func (s *TickerTestSuite) TestDeleteTickerBluesky() { }) } +func (s *TickerTestSuite) TestPutTickerSignalGroup() { + s.Run("when ticker not found", func() { + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusNotFound, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when body is invalid", func() { + s.ctx.Set("ticker", storage.Ticker{}) + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", nil) + s.ctx.Request.Header.Add("Content-Type", "application/json") + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when storage returns error", func() { + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("SaveTicker", mock.Anything).Return(errors.New("storage error")).Once() + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when storage returns ticker", func() { + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("SaveTicker", mock.Anything).Return(nil).Once() + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.Equal(gock.IsDone(), true) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when signal-cli API call updateGroup returns error", func() { + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(500) + + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true,"GroupName":"Example","GroupDescription":"Example"}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.True(gock.IsDone()) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when signal-cli API call getGroups returns error", func() { + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "groupId": "sample-group-id", + "timestamp": 1, + }, + "id": 1, + }) + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(500) + + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true,"GroupName":"Example","GroupDescription":"Example"}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.True(gock.IsDone()) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when enabling signal group successfully", func() { + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "groupId": "sample-group-id", + "timestamp": 1, + }, + "id": 1, + }) + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": []map[string]interface{}{ + { + "id": "sample-group-id", + "name": "Example", + "description": "Example", + "groupInviteLink": "https://signal.group/#example", + }, + }, + "id": 1, + }) + + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true,"GroupName":"Example","GroupDescription":"Example"}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("SaveTicker", mock.Anything).Return(nil).Once() + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.True(gock.IsDone()) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when updating signal group successfully", func() { + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "groupId": "sample-group-id", + "timestamp": 1, + }, + "id": 1, + }) + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": []map[string]interface{}{ + { + "id": "sample-group-id", + "name": "Example", + "description": "Example", + "groupInviteLink": "https://signal.group/#example", + }, + }, + "id": 1, + }) + + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"active":true,"GroupId":"sample-group-id","GroupName":"Example","GroupDescription":"Example"}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + s.store.On("SaveTicker", mock.Anything).Return(nil).Once() + + h := s.handler() + h.PutTickerSignalGroup(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.True(gock.IsDone()) + s.store.AssertExpectations(s.T()) + }) +} + +func (s *TickerTestSuite) TestDeleteTickerSignalGroup() { + s.Run("when ticker not found", func() { + h := s.handler() + h.DeleteTickerSignalGroup(s.ctx) + + s.Equal(http.StatusNotFound, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when storage returns error", func() { + s.ctx.Set("ticker", storage.Ticker{}) + s.store.On("SaveTicker", mock.Anything).Return(errors.New("storage error")).Once() + h := s.handler() + h.DeleteTickerSignalGroup(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when storage returns ticker", func() { + s.ctx.Set("ticker", storage.Ticker{}) + s.store.On("SaveTicker", mock.Anything).Return(nil).Once() + h := s.handler() + h.DeleteTickerSignalGroup(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.store.AssertExpectations(s.T()) + }) +} + func (s *TickerTestSuite) TestDeleteTicker() { s.Run("when ticker not found", func() { h := s.handler() diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go index 65e6864e..837c2956 100644 --- a/internal/bridge/bridge_test.go +++ b/internal/bridge/bridge_test.go @@ -53,6 +53,12 @@ func (s *BridgeTestSuite) SetupTest() { Handle: "handle", AppKey: "app_key", }, + SignalGroup: storage.TickerSignalGroup{ + Active: true, + GroupID: "group_id", + GroupName: "group_name", + GroupDescription: "group_description", + }, } messageWithoutBridges = storage.Message{ Text: "Hello World", @@ -90,6 +96,9 @@ func (s *BridgeTestSuite) SetupTest() { Uri: "at://did:plc:sample-uri", Cid: "cid", }, + SignalGroup: storage.SignalGroupMeta{ + Timestamp: 123, + }, } } @@ -141,7 +150,7 @@ func (s *BridgeTestSuite) TestDelete() { func (s *BridgeTestSuite) TestRegisterBridges() { bridges := RegisterBridges(config.Config{}, nil) - s.Equal(3, len(bridges)) + s.Equal(4, len(bridges)) } func TestBrigde(t *testing.T) { diff --git a/internal/bridge/signal_group.go b/internal/bridge/signal_group.go index ea89c232..2438c60f 100644 --- a/internal/bridge/signal_group.go +++ b/internal/bridge/signal_group.go @@ -78,7 +78,7 @@ func (sb *SignalGroupBridge) Send(ticker storage.Ticker, message *storage.Messag } func (sb *SignalGroupBridge) Delete(ticker storage.Ticker, message *storage.Message) error { - if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active || message.SignalGroup.Timestamp != 0 { + if !sb.config.SignalGroup.Enabled() || !ticker.SignalGroup.Connected() || !ticker.SignalGroup.Active || message.SignalGroup.Timestamp == 0 { return nil } diff --git a/internal/bridge/signal_group_test.go b/internal/bridge/signal_group_test.go new file mode 100644 index 00000000..d549bdfe --- /dev/null +++ b/internal/bridge/signal_group_test.go @@ -0,0 +1,215 @@ +package bridge + +import ( + "errors" + + "github.com/h2non/gock" + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/storage" +) + +func (s *BridgeTestSuite) TestSignalGroupSend() { + s.Run("when signalGroup is inactive", func() { + bridge := s.signalGroupBridge(config.Config{}, &storage.MockStorage{}) + + err := bridge.Send(tickerWithoutBridges, &messageWithoutBridges) + s.NoError(err) + }) + + s.Run("when signalGroup is active but signal-cli api fails", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(500) + + err := bridge.Send(tickerWithBridges, &storage.Message{}) + s.Error(err) + s.True(gock.IsDone()) + }) + + s.Run("when response timestamp == 0", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]int{ + "timestamp": 0, + }, + "id": 1, + }) + + err := bridge.Send(tickerWithBridges, &storage.Message{}) + s.Error(err) + s.True(gock.IsDone()) + }) + + s.Run("send message with attachment failed to find upload", func() { + mockStorage := &storage.MockStorage{} + mockStorage.On("FindUploadByUUID", "123").Return(storage.Upload{}, errors.New("failed to find upload")).Once() + mockStorage.On("FindUploadByUUID", "456").Return(storage.Upload{}, errors.New("failed to find upload")).Once() + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, mockStorage) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]int{ + "timestamp": 1, + }, + "id": 1, + }) + + err := bridge.Send(tickerWithBridges, &messageWithBridges) + s.NoError(err) + s.True(gock.IsDone()) + s.True(mockStorage.AssertExpectations(s.T())) + }) + + s.Run("send message with attachment failed to read file", func() { + mockStorage := &storage.MockStorage{} + mockStorage.On("FindUploadByUUID", "123").Return(storage.Upload{UUID: "123", ContentType: "image/gif"}, nil).Once() + mockStorage.On("FindUploadByUUID", "456").Return(storage.Upload{UUID: "456", ContentType: "image/jpeg"}, nil).Once() + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, mockStorage) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]int{ + "timestamp": 1, + }, + "id": 1, + }) + + err := bridge.Send(tickerWithBridges, &messageWithBridges) + s.NoError(err) + s.True(gock.IsDone()) + s.True(mockStorage.AssertExpectations(s.T())) + }) + + s.Run("send message without attachments", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]int{ + "timestamp": 1, + }, + "id": 1, + }) + + err := bridge.Send(tickerWithBridges, &storage.Message{}) + s.NoError(err) + s.True(gock.IsDone()) + }) +} + +func (s *BridgeTestSuite) TestSignalDelete() { + s.Run("when signal not connected", func() { + bridge := s.signalGroupBridge(config.Config{}, &storage.MockStorage{}) + + err := bridge.Delete(tickerWithoutBridges, &messageWithoutBridges) + s.NoError(err) + }) + + s.Run("when message has no signal meta", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + err := bridge.Delete(tickerWithBridges, &messageWithoutBridges) + s.NoError(err) + }) + + s.Run("when signal is inactive", func() { + bridge := s.signalGroupBridge(config.Config{}, &storage.MockStorage{}) + + err := bridge.Delete(tickerWithBridges, &messageWithoutBridges) + s.NoError(err) + }) + + s.Run("when delete fails", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(500) + + err := bridge.Delete(tickerWithBridges, &messageWithBridges) + s.Error(err) + s.True(gock.IsDone()) + }) + + s.Run("happy path", func() { + bridge := s.signalGroupBridge(config.Config{ + SignalGroup: config.SignalGroup{ + ApiUrl: "https://signal-cli.example.org/api/v1/rpc", + Account: "0123456789", + }, + }, &storage.MockStorage{}) + + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]int{ + "timestamp": 1, + }, + "id": 1, + }) + + err := bridge.Delete(tickerWithBridges, &messageWithBridges) + s.NoError(err) + s.True(gock.IsDone()) + }) +} + +func (s *BridgeTestSuite) signalGroupBridge(config config.Config, storage storage.Storage) *SignalGroupBridge { + return &SignalGroupBridge{ + config: config, + storage: storage, + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6285376e..8c83ca34 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -47,6 +47,9 @@ func (s *ConfigTestSuite) TestConfig() { s.Equal("http://localhost:8080", c.Upload.URL) s.Empty(c.Telegram.Token) s.False(c.Telegram.Enabled()) + s.Empty(c.SignalGroup.ApiUrl) + s.Empty(c.SignalGroup.Account) + s.False(c.SignalGroup.Enabled()) }) s.Run("loads config from env", func() { @@ -66,6 +69,8 @@ func (s *ConfigTestSuite) TestConfig() { s.Equal(s.envs["TICKER_UPLOAD_PATH"], c.Upload.Path) s.Equal(s.envs["TICKER_UPLOAD_URL"], c.Upload.URL) s.Equal(s.envs["TICKER_TELEGRAM_TOKEN"], c.Telegram.Token) + s.Equal(s.envs["TICKER_SIGNAL_GROUP_API_URL"], c.SignalGroup.ApiUrl) + s.Equal(s.envs["TICKER_SIGNAL_GROUP_ACCOUNT"], c.SignalGroup.Account) s.True(c.Telegram.Enabled()) for key := range s.envs { diff --git a/internal/storage/sql_storage_test.go b/internal/storage/sql_storage_test.go index a542a2b6..caa2c8a5 100644 --- a/internal/storage/sql_storage_test.go +++ b/internal/storage/sql_storage_test.go @@ -30,6 +30,7 @@ func (s *SqlStorageTestSuite) SetupTest() { &TickerTelegram{}, &TickerMastodon{}, &TickerBluesky{}, + &TickerSignalGroup{}, &User{}, &Message{}, &Upload{}, @@ -46,6 +47,8 @@ func (s *SqlStorageTestSuite) BeforeTest(suiteName, testName string) { s.NoError(s.db.Exec("DELETE FROM tickers").Error) s.NoError(s.db.Exec("DELETE FROM ticker_mastodons").Error) s.NoError(s.db.Exec("DELETE FROM ticker_telegrams").Error) + s.NoError(s.db.Exec("DELETE FROM ticker_blueskies").Error) + s.NoError(s.db.Exec("DELETE FROM ticker_signal_groups").Error) s.NoError(s.db.Exec("DELETE FROM settings").Error) s.NoError(s.db.Exec("DELETE FROM uploads").Error) } diff --git a/internal/storage/ticker.go b/internal/storage/ticker.go index 914d2d33..8aab99ab 100644 --- a/internal/storage/ticker.go +++ b/internal/storage/ticker.go @@ -36,6 +36,7 @@ func (t *Ticker) Reset() { t.Telegram.Reset() t.Mastodon.Reset() t.Bluesky.Reset() + t.SignalGroup.Reset() } func (t *Ticker) AsMap() map[string]interface{} { diff --git a/internal/storage/ticker_test.go b/internal/storage/ticker_test.go index f58cb0ce..5f0117ed 100644 --- a/internal/storage/ticker_test.go +++ b/internal/storage/ticker_test.go @@ -30,6 +30,14 @@ func TestTickerBlueskyConnected(t *testing.T) { assert.True(t, ticker.Bluesky.Connected()) } +func TestTickerSignalGroupConnect(t *testing.T) { + assert.False(t, ticker.SignalGroup.Connected()) + + ticker.SignalGroup.GroupID = "GroupID" + + assert.True(t, ticker.SignalGroup.Connected()) +} + func TestTickerReset(t *testing.T) { ticker.Active = true ticker.Description = "Description" @@ -38,6 +46,8 @@ func TestTickerReset(t *testing.T) { ticker.Information.Twitter = "Twitter" ticker.Telegram.Active = true ticker.Telegram.ChannelName = "ChannelName" + ticker.SignalGroup.Active = true + ticker.SignalGroup.GroupID = "GroupID" ticker.Location.Lat = 1 ticker.Location.Lon = 2 @@ -50,6 +60,7 @@ func TestTickerReset(t *testing.T) { assert.Empty(t, ticker.Information.Email) assert.Empty(t, ticker.Information.Twitter) assert.Empty(t, ticker.Telegram.ChannelName) + assert.Empty(t, ticker.SignalGroup.GroupID) assert.Empty(t, ticker.Location) } From beb3f5e771b2514e68a14959937367c74e5c5a1b Mon Sep 17 00:00:00 2001 From: doobry Date: Thu, 20 Jun 2024 20:15:35 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20Add=20support=20to=20delete=20S?= =?UTF-8?q?ignal=20group=20and=20add=20admins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a Signal group now first removes all other members, then the client quits it as last member. Adding admins adds the number to the group and grants it admin privileges. --- internal/api/api.go | 1 + internal/api/response/response.go | 1 + internal/api/tickers.go | 38 +++++ internal/api/tickers_test.go | 224 +++++++++++++++++++++++++++++- internal/signal/signal.go | 91 ++++++++++-- 5 files changed, 344 insertions(+), 11 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 00f98ae3..ed63c880 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -75,6 +75,7 @@ func API(config config.Config, store storage.Storage, log *logrus.Logger) *gin.E admin.DELETE(`/tickers/:tickerID/bluesky`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerBluesky) admin.PUT(`/tickers/:tickerID/signal_group`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerSignalGroup) admin.DELETE(`/tickers/:tickerID/signal_group`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerSignalGroup) + admin.PUT(`/tickers/:tickerID/signal_group/admin`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerSignalGroupAdmin) admin.DELETE(`/tickers/:tickerID`, user.NeedAdmin(), ticker.PrefetchTicker(store), handler.DeleteTicker) admin.PUT(`/tickers/:tickerID/reset`, ticker.PrefetchTicker(store, storage.WithPreload()), ticker.PrefetchTicker(store), handler.ResetTicker) admin.GET(`/tickers/:tickerID/users`, ticker.PrefetchTicker(store), handler.GetTickerUsers) diff --git a/internal/api/response/response.go b/internal/api/response/response.go index 14b52925..968c7d21 100644 --- a/internal/api/response/response.go +++ b/internal/api/response/response.go @@ -23,6 +23,7 @@ const ( MastodonError ErrorMessage = "unable to connect to mastodon" BlueskyError ErrorMessage = "unable to connect to bluesky" SignalGroupError ErrorMessage = "unable to connect to signal" + SignalGroupDeleteError ErrorMessage = "unable to delete signal group" PasswordError ErrorMessage = "could not authenticate password" StatusSuccess Status = `success` diff --git a/internal/api/tickers.go b/internal/api/tickers.go index 9dd14bdf..c27e4362 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -334,9 +334,19 @@ func (h *handler) DeleteTickerSignalGroup(c *gin.Context) { } groupClient := signal.NewGroupClient(h.config) + + // Remove all members except the account number + err = groupClient.RemoveAllMembers(ticker.SignalGroup.GroupID) + if err != nil { + log.WithError(err).Error("failed to remove members") + return + } + + // Quit the group err = groupClient.QuitGroup(ticker.SignalGroup.GroupID) if err != nil { log.WithError(err).Error("failed to quit group") + return } ticker.SignalGroup.Reset() @@ -350,6 +360,34 @@ func (h *handler) DeleteTickerSignalGroup(c *gin.Context) { c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) } +func (h *handler) PutTickerSignalGroupAdmin(c *gin.Context) { + ticker, err := helper.Ticker(c) + if err != nil { + c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound)) + return + } + + var body struct { + Number string `json:"number" binding:"required"` + } + + err = c.Bind(&body) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeNotFound, response.FormError)) + return + } + + groupClient := signal.NewGroupClient(h.config) + err = groupClient.AddAdminMember(ticker.SignalGroup.GroupID, body.Number) + if err != nil { + log.WithError(err).Error("failed to add member") + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.SignalGroupError)) + return + } + + c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{})) +} + 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 739cc979..3745c934 100644 --- a/internal/api/tickers_test.go +++ b/internal/api/tickers_test.go @@ -624,6 +624,7 @@ func (s *TickerTestSuite) TestPutTickerSignalGroup() { }) s.Run("when signal-cli API call updateGroup returns error", func() { + // updateGroup gock.New("https://signal-cli.example.org"). Post("/api/v1/rpc"). MatchHeader("Content-Type", "application/json"). @@ -643,6 +644,7 @@ func (s *TickerTestSuite) TestPutTickerSignalGroup() { }) s.Run("when signal-cli API call getGroups returns error", func() { + // updateGroup gock.New("https://signal-cli.example.org"). Post("/api/v1/rpc"). MatchHeader("Content-Type", "application/json"). @@ -655,6 +657,7 @@ func (s *TickerTestSuite) TestPutTickerSignalGroup() { }, "id": 1, }) + // listGroups gock.New("https://signal-cli.example.org"). Post("/api/v1/rpc"). MatchHeader("Content-Type", "application/json"). @@ -674,6 +677,7 @@ func (s *TickerTestSuite) TestPutTickerSignalGroup() { }) s.Run("when enabling signal group successfully", func() { + // updateGroup gock.New("https://signal-cli.example.org"). Post("/api/v1/rpc"). MatchHeader("Content-Type", "application/json"). @@ -686,6 +690,7 @@ func (s *TickerTestSuite) TestPutTickerSignalGroup() { }, "id": 1, }) + // listGroups gock.New("https://signal-cli.example.org"). Post("/api/v1/rpc"). MatchHeader("Content-Type", "application/json"). @@ -718,6 +723,7 @@ func (s *TickerTestSuite) TestPutTickerSignalGroup() { }) s.Run("when updating signal group successfully", func() { + // updateGroup gock.New("https://signal-cli.example.org"). Post("/api/v1/rpc"). MatchHeader("Content-Type", "application/json"). @@ -730,6 +736,7 @@ func (s *TickerTestSuite) TestPutTickerSignalGroup() { }, "id": 1, }) + // listGroups gock.New("https://signal-cli.example.org"). Post("/api/v1/rpc"). MatchHeader("Content-Type", "application/json"). @@ -772,7 +779,76 @@ func (s *TickerTestSuite) TestDeleteTickerSignalGroup() { }) s.Run("when storage returns error", func() { - s.ctx.Set("ticker", storage.Ticker{}) + // listGroups + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": []map[string]interface{}{ + { + "id": "sample-group-id", + "name": "Sample", + "description": "Sample", + "members": []map[string]interface{}{ + { + "number": "+1234567890", + "uuid": "12345678-90ab-cdef-1234-567890abcdef", + }, + { + "number": "+9999999999", + "uuid": "99999999-90ab-cdef-1234-567890abcdef", + }, + }, + "groupInviteLink": "https://signal.group/#sample", + }, + }, + "id": 1, + }) + // updateGroup (remove members) + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": []map[string]interface{}{ + { + "results": []interface{}{ + map[string]interface{}{ + "recipientAddress": map[string]string{ + "uuid": "12345678-90ab-cdef-1234-567890abcdef", + "number": "+1234567890", + }, + "type": "SUCCESS", + }, + }, + "timestamp": 1, + }, + }, + "id": 1, + }) + // quitGroup + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "timestamp": 1, + }, + "id": 1, + }) + + s.ctx.Set("ticker", storage.Ticker{ + SignalGroup: storage.TickerSignalGroup{ + GroupID: "sample-group-id", + }, + }) + s.ctx.Request = httptest.NewRequest(http.MethodDelete, "/v1/admin/tickers/1/signal_group", nil) + s.ctx.Request.Header.Add("Content-Type", "application/json") s.store.On("SaveTicker", mock.Anything).Return(errors.New("storage error")).Once() h := s.handler() h.DeleteTickerSignalGroup(s.ctx) @@ -782,7 +858,76 @@ func (s *TickerTestSuite) TestDeleteTickerSignalGroup() { }) s.Run("when storage returns ticker", func() { - s.ctx.Set("ticker", storage.Ticker{}) + // listGroups + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": []map[string]interface{}{ + { + "id": "sample-group-id", + "name": "Sample", + "description": "Sample", + "members": []map[string]interface{}{ + { + "number": "+1234567890", + "uuid": "12345678-90ab-cdef-1234-567890abcdef", + }, + { + "number": "+9999999999", + "uuid": "99999999-90ab-cdef-1234-567890abcdef", + }, + }, + "groupInviteLink": "https://signal.group/#sample", + }, + }, + "id": 1, + }) + // updateGroup (remove members) + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": []map[string]interface{}{ + { + "results": []interface{}{ + map[string]interface{}{ + "recipientAddress": map[string]string{ + "uuid": "12345678-90ab-cdef-1234-567890abcdef", + "number": "+1234567890", + }, + "type": "SUCCESS", + }, + }, + "timestamp": 1, + }, + }, + "id": 1, + }) + // quitGroup + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "timestamp": 1, + }, + "id": 1, + }) + + s.ctx.Set("ticker", storage.Ticker{ + SignalGroup: storage.TickerSignalGroup{ + GroupID: "sample-group-id", + }, + }) + s.ctx.Request = httptest.NewRequest(http.MethodDelete, "/v1/admin/tickers/1/signal_group", nil) + s.ctx.Request.Header.Add("Content-Type", "application/json") s.store.On("SaveTicker", mock.Anything).Return(nil).Once() h := s.handler() h.DeleteTickerSignalGroup(s.ctx) @@ -792,6 +937,81 @@ func (s *TickerTestSuite) TestDeleteTickerSignalGroup() { }) } +func (s *TickerTestSuite) TestPutTickerSignalGroupAdmin() { + s.Run("when ticker not found", func() { + h := s.handler() + h.PutTickerSignalGroupAdmin(s.ctx) + + s.Equal(http.StatusNotFound, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when body is invalid", func() { + s.ctx.Set("ticker", storage.Ticker{}) + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group/admin", nil) + s.ctx.Request.Header.Add("Content-Type", "application/json") + h := s.handler() + h.PutTickerSignalGroupAdmin(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.store.AssertExpectations(s.T()) + }) + + s.Run("when signal-cli API call updateGroup returns error", func() { + // updateGroup (add admin) + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(500) + + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"number":"+1234567890"}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group/admin", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + + h := s.handler() + h.PutTickerSignalGroupAdmin(s.ctx) + + s.Equal(http.StatusBadRequest, s.w.Code) + s.True(gock.IsDone()) + }) + + s.Run("when adding signal group admin successfully", func() { + // updateGroup (add admin) + gock.New("https://signal-cli.example.org"). + Post("/api/v1/rpc"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "jsonrpc": "2.0", + "result": map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{ + "recipientAddress": map[string]string{ + "uuid": "12345678-90ab-cdef-1234-567890abcdef", + "number": "+1234567890", + }, + "type": "SUCCESS", + }, + }, + "timestamp": 1, + }, + "id": 1, + }) + + s.ctx.Set("ticker", storage.Ticker{}) + body := `{"number":"+1234567890"}` + s.ctx.Request = httptest.NewRequest(http.MethodPut, "/v1/admin/tickers/1/signal_group/admin", strings.NewReader(body)) + s.ctx.Request.Header.Add("Content-Type", "application/json") + + h := s.handler() + h.PutTickerSignalGroupAdmin(s.ctx) + + s.Equal(http.StatusOK, s.w.Code) + s.True(gock.IsDone()) + }) +} + func (s *TickerTestSuite) TestDeleteTicker() { s.Run("when ticker not found", func() { h := s.handler() diff --git a/internal/signal/signal.go b/internal/signal/signal.go index b5f3b366..ebdd862f 100644 --- a/internal/signal/signal.go +++ b/internal/signal/signal.go @@ -9,11 +9,17 @@ import ( "github.com/ybbus/jsonrpc/v3" ) +type GroupMember struct { + Number string `json:"number"` + Uuid string `json:"uuid"` +} + type ListGroupsResponseGroup struct { - GroupID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - GroupInviteLink string `json:"groupInviteLink"` + GroupID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Members []GroupMember `json:"members"` + GroupInviteLink string `json:"groupInviteLink"` } type GroupClient struct { @@ -83,10 +89,8 @@ func (gc *GroupClient) QuitGroup(groupID string) error { Delete: true, } - // TODO: cannot leave group if I'm the last admin - // Maybe promote first other member to admin? var response interface{} - err := gc.client.CallFor(context.Background(), &response, "leaveGroup", ¶ms) + err := gc.client.CallFor(context.Background(), &response, "quitGroup", ¶ms) if err != nil { return err } @@ -98,9 +102,11 @@ func (gc *GroupClient) listGroups() ([]ListGroupsResponseGroup, error) { ctx := context.Background() params := struct { - Account string `json:"account"` + Account string `json:"account"` + Detailed bool `json:"detailed"` }{ - Account: gc.cfg.SignalGroup.Account, + Account: gc.cfg.SignalGroup.Account, + Detailed: true, } var response []ListGroupsResponseGroup @@ -127,6 +133,73 @@ func (gc *GroupClient) getGroup(groupID string) (ListGroupsResponseGroup, error) return ListGroupsResponseGroup{}, nil } +func (gc *GroupClient) AddAdminMember(groupId string, number string) error { + numbers := make([]string, 0, 1) + numbers = append(numbers, number) + + params := struct { + Account string `json:"account"` + GroupID string `json:"group-id"` + Member []string `json:"member"` + Admin []string `json:"admin"` + }{ + Account: gc.cfg.SignalGroup.Account, + GroupID: groupId, + Member: numbers, + Admin: numbers, + } + + var response interface{} + err := gc.client.CallFor(context.Background(), &response, "updateGroup", ¶ms) + if err != nil { + return err + } + + return nil +} + +func (gc *GroupClient) RemoveAllMembers(groupId string) error { + g, err := gc.getGroup(groupId) + if err != nil { + return err + } + + numbers := make([]string, 0, len(g.Members)) + for _, m := range g.Members { + // Exclude the account number + if m.Number == gc.cfg.SignalGroup.Account { + continue + } + numbers = append(numbers, m.Number) + } + + if len(numbers) == 0 { + return nil + } + + return gc.removeMembers(groupId, numbers) +} + +func (gc *GroupClient) removeMembers(groupId string, numbers []string) error { + params := struct { + Account string `json:"account"` + GroupID string `json:"group-id"` + RemoveMember []string `json:"remove-member"` + }{ + Account: gc.cfg.SignalGroup.Account, + GroupID: groupId, + RemoveMember: numbers, + } + + var response interface{} + err := gc.client.CallFor(context.Background(), &response, "updateGroup", ¶ms) + if err != nil { + return err + } + + return nil +} + func Client(config config.Config) jsonrpc.RPCClient { return jsonrpc.NewClient(config.SignalGroup.ApiUrl) } From 2325cc880ec7931350c25fb4a5de92520c4b6963 Mon Sep 17 00:00:00 2001 From: louis Date: Sun, 23 Jun 2024 10:23:57 +0200 Subject: [PATCH 6/7] Add Signal Group to Features API --- internal/api/features.go | 3 ++- internal/api/features_test.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/api/features.go b/internal/api/features.go index 0d22f13d..9b132865 100644 --- a/internal/api/features.go +++ b/internal/api/features.go @@ -12,7 +12,8 @@ type FeaturesResponse map[string]bool func NewFeaturesResponse(config config.Config) FeaturesResponse { return FeaturesResponse{ - "telegramEnabled": config.Telegram.Enabled(), + "telegramEnabled": config.Telegram.Enabled(), + "signalGroupEnabled": config.SignalGroup.Enabled(), } } diff --git a/internal/api/features_test.go b/internal/api/features_test.go index 9c8dac4b..419a655a 100644 --- a/internal/api/features_test.go +++ b/internal/api/features_test.go @@ -32,7 +32,7 @@ func (s *FeaturesTestSuite) TestGetFeatures() { h.GetFeatures(c) s.Equal(http.StatusOK, w.Code) - s.Equal(`{"data":{"features":{"telegramEnabled":false}},"status":"success","error":{}}`, w.Body.String()) + s.Equal(`{"data":{"features":{"signalGroupEnabled":false,"telegramEnabled":false}},"status":"success","error":{}}`, w.Body.String()) } func TestFeaturesTestSuite(t *testing.T) { From 298f712237f1b6984197134802553182d4e86e1d Mon Sep 17 00:00:00 2001 From: louis Date: Sun, 23 Jun 2024 10:37:52 +0200 Subject: [PATCH 7/7] Adjust Documentation --- config.yml.dist | 3 +-- docs/configuration.md | 10 +++++++++- internal/config/config.go | 3 +++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/config.yml.dist b/config.yml.dist index 1fd6a00b..5169cfb4 100644 --- a/config.yml.dist +++ b/config.yml.dist @@ -16,8 +16,7 @@ telegram: # signal group configuration signal_group: api_url: "" - api_user: "signal-cli" - api_pass: "" + avatar: "" account: "" # listen port for prometheus metrics exporter metrics_listen: ":8181" diff --git a/docs/configuration.md b/docs/configuration.md index 2b8aa081..4bf5c3ae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,7 +15,12 @@ database: secret: "slorp-panfil-becall-dorp-hashab-incus-biter-lyra-pelage-sarraf-drunk" # telegram configuration telegram: - token: "" + token: "" # telegram bot token +# signal group configuration +signal_group: + api_url: "" # URL to your signal cli (https://github.com/AsamK/signal-cli) + avatar: "" # URL to the avatar for the signal group + account: "" # phone number for the signal account # listen port for prometheus metrics exporter metrics_listen: ":8181" upload: @@ -39,6 +44,9 @@ The following env vars can be used: * `TICKER_INITIATOR` * `TICKER_SECRET` * `TICKER_TELEGRAM_TOKEN` +* `TICKER_SIGNAL_GROUP_API_URL` +* `TICKER_SIGNAL_GROUP_AVATAR` +* `TICKER_SIGNAL_GROUP_ACCOUNT` * `TICKER_METRICS_LISTEN` * `TICKER_UPLOAD_PATH` * `TICKER_UPLOAD_URL` diff --git a/internal/config/config.go b/internal/config/config.go index 7d3c681b..a41ec1ae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -126,6 +126,9 @@ func LoadConfig(path string) Config { if os.Getenv("TICKER_SIGNAL_GROUP_ACCOUNT") != "" { c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_ACCOUNT") } + if os.Getenv("TICKER_SIGNAL_GROUP_AVATAR") != "" { + c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_AVATAR") + } return c }