From 5853179728d0917f01ca9f83eff00545f95361c7 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Tue, 25 May 2021 17:42:17 +0200 Subject: [PATCH] start work on notifications --- .../api/client/notification/notification.go | 66 +++++++++++++++++ .../client/notification/notificationsget.go | 72 +++++++++++++++++++ internal/api/model/notification.go | 2 +- internal/api/model/status.go | 2 +- internal/db/db.go | 2 + internal/db/pg/pg.go | 29 ++++++++ internal/federation/federating_db.go | 21 ++++++ internal/gotosocial/actions.go | 4 ++ internal/gtsmodel/notification.go | 70 ++++++++++++++++++ internal/message/fromcommonprocess.go | 20 +++++- internal/message/fromfederatorprocess.go | 10 +++ internal/message/notificationsprocess.go | 24 +++++++ internal/message/processor.go | 3 + internal/typeutils/asinterfaces.go | 9 +++ internal/typeutils/astointernal.go | 42 +++++++++++ internal/typeutils/converter.go | 4 ++ internal/typeutils/internaltofrontend.go | 62 +++++++++++++++- 17 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 internal/api/client/notification/notification.go create mode 100644 internal/api/client/notification/notificationsget.go create mode 100644 internal/gtsmodel/notification.go create mode 100644 internal/message/notificationsprocess.go diff --git a/internal/api/client/notification/notification.go b/internal/api/client/notification/notification.go new file mode 100644 index 0000000..bc06b31 --- /dev/null +++ b/internal/api/client/notification/notification.go @@ -0,0 +1,66 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package notification + +import ( + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // IDKey is for notification UUIDs + IDKey = "id" + // BasePath is the base path for serving the notification API + BasePath = "/api/v1/notifications" + // BasePathWithID is just the base path with the ID key in it. + // Use this anywhere you need to know the ID of the notification being queried. + BasePathWithID = BasePath + "/:" + IDKey + + // MaxIDKey is the url query for setting a max notification ID to return + MaxIDKey = "max_id" + // Limit key is for specifying maximum number of notifications to return. + LimitKey = "limit" +) + +// Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with notifications +type Module struct { + config *config.Config + processor message.Processor + log *logrus.Logger +} + +// New returns a new notification module +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { + return &Module{ + config: config, + processor: processor, + log: log, + } +} + +// Route attaches all routes from this module to the given router +func (m *Module) Route(r router.Router) error { + r.AttachHandler(http.MethodGet, BasePath, m.NotificationsGETHandler) + return nil +} diff --git a/internal/api/client/notification/notificationsget.go b/internal/api/client/notification/notificationsget.go new file mode 100644 index 0000000..3e49708 --- /dev/null +++ b/internal/api/client/notification/notificationsget.go @@ -0,0 +1,72 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package notification + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *Module) NotificationsGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "NotificationsGETHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.Authed(c, true, true, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Errorf("error authing status faved by request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) + return + } + + limit := 20 + limitString := c.Query(LimitKey) + if limitString != "" { + i, err := strconv.ParseInt(limitString, 10, 64) + if err != nil { + l.Debugf("error parsing limit string: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse limit query param"}) + return + } + limit = int(i) + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + notifs, errWithCode := m.processor.NotificationsGet(authed, limit, maxID) + if errWithCode != nil { + l.Debugf("error processing notifications get: %s", errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.JSON(http.StatusOK, notifs) +} diff --git a/internal/api/model/notification.go b/internal/api/model/notification.go index c8d080e..2163251 100644 --- a/internal/api/model/notification.go +++ b/internal/api/model/notification.go @@ -41,5 +41,5 @@ type Notification struct { // OPTIONAL // Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls. - Status *Status `json:"status"` + Status *Status `json:"status,omitempty"` } diff --git a/internal/api/model/status.go b/internal/api/model/status.go index 2456d1a..963ef4f 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -31,7 +31,7 @@ type Status struct { // Is this status marked as sensitive content? Sensitive bool `json:"sensitive"` // Subject or summary line, below which status content is collapsed until expanded. - SpoilerText string `json:"spoiler_text,omitempty"` + SpoilerText string `json:"spoiler_text"` // Visibility of this status. Visibility Visibility `json:"visibility"` // Primary language of this status. (ISO 639 Part 1 two-letter language code) diff --git a/internal/db/db.go b/internal/db/db.go index 9ad8115..e71484a 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -284,6 +284,8 @@ type DB interface { // It will use the given filters and try to return as many statuses up to the limit as possible. GetHomeTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) + GetNotificationsForAccount(accountID string, limit int, maxID string) ([]*gtsmodel.Notification, error) + /* USEFUL CONVERSION FUNCTIONS */ diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go index 7f65055..9b6c7a1 100644 --- a/internal/db/pg/pg.go +++ b/internal/db/pg/pg.go @@ -1138,6 +1138,35 @@ func (ps *postgresService) GetHomeTimelineForAccount(accountID string, maxID str return statuses, nil } +func (ps *postgresService) GetNotificationsForAccount(accountID string, limit int, maxID string) ([]*gtsmodel.Notification, error) { + notifications := []*gtsmodel.Notification{} + + q := ps.conn.Model(¬ifications).Where("target_account_id = ?", accountID) + + + if maxID != "" { + n := >smodel.Notification{} + if err := ps.conn.Model(n).Where("id = ?", maxID).Select(); err != nil { + return nil, err + } + q = q.Where("created_at < ?", n.CreatedAt) + } + + if limit != 0 { + q = q.Limit(limit) + } + + q = q.Order("created_at DESC") + + if err := q.Select(); err != nil { + if err != pg.ErrNoRows { + return nil, err + } + + } + return notifications, nil +} + /* CONVERSION FUNCTIONS */ diff --git a/internal/federation/federating_db.go b/internal/federation/federating_db.go index 6ae4dc0..dc29c84 100644 --- a/internal/federation/federating_db.go +++ b/internal/federation/federating_db.go @@ -496,6 +496,27 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { return fmt.Errorf("database error accepting follow request: %s", err) } } + case gtsmodel.ActivityStreamsLike: + like, ok := asType.(vocab.ActivityStreamsLike) + if !ok { + return errors.New("could not convert type to like") + } + + fave, err := f.typeConverter.ASLikeToFave(like) + if err != nil { + return fmt.Errorf("could not convert Like to fave: %s", err) + } + + if err := f.db.Put(fave); err != nil { + return fmt.Errorf("database error inserting fave: %s", err) + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsLike, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: fave, + ReceivingAccount: targetAcct, + } } return nil } diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index 8e6c50a..e39cd09 100644 --- a/internal/gotosocial/actions.go +++ b/internal/gotosocial/actions.go @@ -37,6 +37,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" "github.com/superseriousbusiness/gotosocial/internal/api/client/instance" mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" + "github.com/superseriousbusiness/gotosocial/internal/api/client/notification" "github.com/superseriousbusiness/gotosocial/internal/api/client/status" "github.com/superseriousbusiness/gotosocial/internal/api/client/timeline" "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" @@ -73,6 +74,7 @@ var models []interface{} = []interface{}{ >smodel.User{}, >smodel.Emoji{}, >smodel.Instance{}, + >smodel.Notification{}, &oauth.Token{}, &oauth.Client{}, } @@ -118,6 +120,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr webfingerModule := webfinger.New(c, processor, log) usersModule := user.New(c, processor, log) timelineModule := timeline.New(c, processor, log) + notificationModule := notification.New(c, processor, log) mm := mediaModule.New(c, processor, log) fileServerModule := fileserver.New(c, processor, log) adminModule := admin.New(c, processor, log) @@ -141,6 +144,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr webfingerModule, usersModule, timelineModule, + notificationModule, } for _, m := range apis { diff --git a/internal/gtsmodel/notification.go b/internal/gtsmodel/notification.go new file mode 100644 index 0000000..35e0ca1 --- /dev/null +++ b/internal/gtsmodel/notification.go @@ -0,0 +1,70 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package gtsmodel + +import "time" + +// Notification models an alert/notification sent to an account about something like a reblog, like, new follow request, etc. +type Notification struct { + // ID of this notification in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + // Type of this notification + NotificationType NotificationType `pg:",notnull"` + // Creation time of this notification + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Which account does this notification target (ie., who will receive the notification?) + TargetAccountID string `pg:",notnull"` + // Which account performed the action that created this notification? + OriginAccountID string `pg:",notnull"` + // If the notification pertains to a status, what is the database ID of that status? + StatusID string + // Has this notification been read already? + Read bool + + /* + NON-DATABASE fields + */ + + // gts model of the target account, won't be put in the database, it's just for convenience when passing the notification around. + GTSTargetAccount *Account `pg:"-"` + // gts model of the origin account, won't be put in the database, it's just for convenience when passing the notification around. + GTSOriginAccount *Account `pg:"-"` + // gts model of the relevant status, won't be put in the database, it's just for convenience when passing the notification around. + GTSStatus *Status `pg:"-"` +} + +// NotificationType describes the reason/type of this notification. +type NotificationType string + +const ( + // NotificationFollow -- someone followed you + NotificationFollow NotificationType = "follow" + // NotificationFollowRequest -- someone requested to follow you + NotificationFollowRequest NotificationType = "follow_request" + // NotificationMention -- someone mentioned you in their status + NotificationMention NotificationType = "mention" + // NotificationReblog -- someone boosted one of your statuses + NotificationReblog NotificationType = "reblog" + // NotifiationFave -- someone faved/liked one of your statuses + NotificationFave NotificationType = "favourite" + // NotificationPoll -- a poll you voted in or created has ended + NotificationPoll NotificationType = "poll" + // NotificationStatus -- someone you enabled notifications for has posted a status. + NotificationStatus NotificationType = "status" +) diff --git a/internal/message/fromcommonprocess.go b/internal/message/fromcommonprocess.go index 2403a8b..7822cfb 100644 --- a/internal/message/fromcommonprocess.go +++ b/internal/message/fromcommonprocess.go @@ -18,7 +18,11 @@ package message -import "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +import ( + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) func (p *processor) notifyStatus(status *gtsmodel.Status) error { return nil @@ -29,5 +33,17 @@ func (p *processor) notifyFollow(follow *gtsmodel.Follow) error { } func (p *processor) notifyFave(fave *gtsmodel.StatusFave) error { - return nil + + notif := >smodel.Notification{ + NotificationType: gtsmodel.NotificationFave, + TargetAccountID: fave.TargetAccountID, + OriginAccountID: fave.AccountID, + StatusID: fave.StatusID, + } + + if err := p.db.Put(notif); err != nil { + return fmt.Errorf("notifyFave: error putting fave in database: %s", err) + } + + return nil } diff --git a/internal/message/fromfederatorprocess.go b/internal/message/fromfederatorprocess.go index d3ebce4..a54868b 100644 --- a/internal/message/fromfederatorprocess.go +++ b/internal/message/fromfederatorprocess.go @@ -74,6 +74,16 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er if err := p.db.UpdateByID(incomingAccount.ID, incomingAccount); err != nil { return fmt.Errorf("error updating dereferenced account in the db: %s", err) } + case gtsmodel.ActivityStreamsLike: + // CREATE A FAVE + incomingFave, ok := federatorMsg.GTSModel.(*gtsmodel.StatusFave) + if !ok { + return errors.New("like was not parseable as *gtsmodel.StatusFave") + } + + if err := p.notifyFave(incomingFave); err != nil { + return err + } } case gtsmodel.ActivityStreamsUpdate: // UPDATE diff --git a/internal/message/notificationsprocess.go b/internal/message/notificationsprocess.go new file mode 100644 index 0000000..64726b7 --- /dev/null +++ b/internal/message/notificationsprocess.go @@ -0,0 +1,24 @@ +package message + +import ( + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) NotificationsGet(authed *oauth.Auth, limit int, maxID string) ([]*apimodel.Notification, ErrorWithCode) { + notifs, err := p.db.GetNotificationsForAccount(authed.Account.ID, limit, maxID) + if err != nil { + return nil, NewErrorInternalError(err) + } + + mastoNotifs := []*apimodel.Notification{} + for _, n := range notifs { + mastoNotif, err := p.tc.NotificationToMasto(n) + if err != nil { + return nil, NewErrorInternalError(err) + } + mastoNotifs = append(mastoNotifs, mastoNotif) + } + + return mastoNotifs, nil +} diff --git a/internal/message/processor.go b/internal/message/processor.go index bcd64d4..49a4f6f 100644 --- a/internal/message/processor.go +++ b/internal/message/processor.go @@ -106,6 +106,9 @@ type Processor interface { // MediaUpdate handles the PUT of a media attachment with the given ID and form MediaUpdate(authed *oauth.Auth, attachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, ErrorWithCode) + // NotificationsGet + NotificationsGet(authed *oauth.Auth, limit int, maxID string) ([]*apimodel.Notification, ErrorWithCode) + // StatusCreate processes the given form to create a new status, returning the api model representation of that status if it's OK. StatusCreate(authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) // StatusDelete processes the delete of a given status, returning the deleted status if the delete goes through. diff --git a/internal/typeutils/asinterfaces.go b/internal/typeutils/asinterfaces.go index c31a37a..eea7fd7 100644 --- a/internal/typeutils/asinterfaces.go +++ b/internal/typeutils/asinterfaces.go @@ -102,6 +102,15 @@ type Followable interface { withObject } +// Likeable represents the minimum interface for an activitystreams 'like' activity. +type Likeable interface { + withJSONLDId + withTypeName + + withActor + withObject +} + type withJSONLDId interface { GetJSONLDId() vocab.JSONLDIdProperty } diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index dcc2674..0458292 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -380,6 +380,48 @@ func (c *converter) ASFollowToFollow(followable Followable) (*gtsmodel.Follow, e return follow, nil } +func (c *converter) ASLikeToFave(likeable Likeable) (*gtsmodel.StatusFave, error) { + idProp := likeable.GetJSONLDId() + if idProp == nil || !idProp.IsIRI() { + return nil, errors.New("no id property set on like, or was not an iri") + } + uri := idProp.GetIRI().String() + + origin, err := extractActor(likeable) + if err != nil { + return nil, errors.New("error extracting actor property from like") + } + originAccount := >smodel.Account{} + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: origin.String()}}, originAccount); err != nil { + return nil, fmt.Errorf("error extracting account with uri %s from the database: %s", origin.String(), err) + } + + target, err := extractObject(likeable) + if err != nil { + return nil, errors.New("error extracting object property from like") + } + + targetStatus := >smodel.Status{} + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: target.String()}}, targetStatus); err != nil { + return nil, fmt.Errorf("error extracting status with uri %s from the database: %s", target.String(), err) + } + + targetAccount := >smodel.Account{} + if err := c.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, fmt.Errorf("error extracting account with id %s from the database: %s", targetStatus.AccountID, err) + } + + return >smodel.StatusFave{ + TargetAccountID: targetAccount.ID, + StatusID: targetStatus.ID, + AccountID: originAccount.ID, + URI: uri, + GTSStatus: targetStatus, + GTSTargetAccount: targetAccount, + GTSFavingAccount: originAccount, + }, nil +} + func isPublic(tos []*url.URL) bool { for _, entry := range tos { if strings.EqualFold(entry.String(), "https://www.w3.org/ns/activitystreams#Public") { diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 3ced209..4c1c828 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -84,6 +84,8 @@ type TypeConverter interface { // RelationshipToMasto converts a gts relationship into its mastodon equivalent for serving in various places RelationshipToMasto(r *gtsmodel.Relationship) (*model.Relationship, error) + NotificationToMasto(n *gtsmodel.Notification) (*model.Notification, error) + /* FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL */ @@ -107,6 +109,8 @@ type TypeConverter interface { ASFollowToFollowRequest(followable Followable) (*gtsmodel.FollowRequest, error) // ASFollowToFollowRequest converts a remote activitystreams `follow` representation into gts model follow. ASFollowToFollow(followable Followable) (*gtsmodel.Follow, error) + // ASLikeToFave converts a remote activitystreams 'like' representation into a gts model status fave. + ASLikeToFave(likeable Likeable) (*gtsmodel.StatusFave, error) /* INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 7fbe9eb..1861fba 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -138,6 +138,9 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, e fields = append(fields, mField) } + emojis := []model.Emoji{} + // TODO: account emojis + var acct string if a.Domain != "" { // this is a remote user @@ -165,7 +168,7 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, e FollowingCount: followingCount, StatusesCount: statusesCount, LastStatusAt: lastStatusAt, - Emojis: nil, // TODO: implement this + Emojis: emojis, // TODO: implement this Fields: fields, }, nil } @@ -594,3 +597,60 @@ func (c *converter) RelationshipToMasto(r *gtsmodel.Relationship) (*model.Relati Note: r.Note, }, nil } + +func (c *converter) NotificationToMasto(n *gtsmodel.Notification) (*model.Notification, error) { + + if n.GTSTargetAccount == nil { + tAccount := >smodel.Account{} + if err := c.db.GetByID(n.TargetAccountID, tAccount); err != nil { + return nil, fmt.Errorf("NotificationToMasto: error getting target account with id %s from the db: %s", n.TargetAccountID, err) + } + n.GTSTargetAccount = tAccount + } + + if n.GTSOriginAccount == nil { + ogAccount := >smodel.Account{} + if err := c.db.GetByID(n.OriginAccountID, ogAccount); err != nil { + return nil, fmt.Errorf("NotificationToMasto: error getting origin account with id %s from the db: %s", n.OriginAccountID, err) + } + n.GTSOriginAccount = ogAccount + } + mastoAccount, err := c.AccountToMastoPublic(n.GTSOriginAccount) + if err != nil { + return nil, fmt.Errorf("NotificationToMasto: error converting account to masto: %s", err) + } + + var mastoStatus *model.Status + if n.StatusID != "" { + if n.GTSStatus == nil { + status := >smodel.Status{} + if err := c.db.GetByID(n.StatusID, status); err != nil { + return nil, fmt.Errorf("NotificationToMasto: error getting status with id %s from the db: %s", n.StatusID, err) + } + n.GTSStatus = status + } + + var replyToAccount *gtsmodel.Account + if n.GTSStatus.InReplyToAccountID != "" { + r := >smodel.Account{} + if err := c.db.GetByID(n.GTSStatus.InReplyToAccountID, r); err != nil { + return nil, fmt.Errorf("NotificationToMasto: error getting replied to account with id %s from the db: %s", n.GTSStatus.InReplyToAccountID, err) + } + replyToAccount = r + } + + var err error + mastoStatus, err = c.StatusToMasto(n.GTSStatus, n.GTSTargetAccount, n.GTSTargetAccount, nil, replyToAccount, nil) + if err != nil { + return nil, fmt.Errorf("NotificationToMasto: error converting status to masto: %s", err) + } + } + + return &model.Notification{ + ID: n.ID, + Type: string(n.NotificationType), + CreatedAt: n.CreatedAt.Format(time.RFC3339), + Account: mastoAccount, + Status: mastoStatus, + }, nil +}