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 1816448b..829064cd 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 f90ee611..31d5b90e 100644 --- a/go.sum +++ b/go.sum @@ -297,6 +297,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