mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2026-01-06 14:53:15 -06:00
[feature] add support for polls + receiving federated status edits (#2330)
This commit is contained in:
parent
7204ccedc3
commit
e9e5dc5a40
84 changed files with 3992 additions and 570 deletions
|
|
@ -365,12 +365,13 @@ func (d *Dereferencer) enrichStatus(
|
|||
|
||||
// Use existing status ID.
|
||||
latestStatus.ID = status.ID
|
||||
|
||||
if latestStatus.ID == "" {
|
||||
|
||||
// Generate new status ID from the provided creation date.
|
||||
latestStatus.ID, err = id.NewULIDFromTime(latestStatus.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, nil, gtserror.Newf("invalid created at date: %w", err)
|
||||
log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err)
|
||||
latestStatus.ID = id.NewULID() // just use "now"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,6 +380,11 @@ func (d *Dereferencer) enrichStatus(
|
|||
latestStatus.FetchedAt = time.Now()
|
||||
latestStatus.Local = status.Local
|
||||
|
||||
// Ensure the status' poll remains consistent, else reset the poll.
|
||||
if err := d.fetchStatusPoll(ctx, status, latestStatus); err != nil {
|
||||
return nil, nil, gtserror.Newf("error populating poll for status %s: %w", uri, err)
|
||||
}
|
||||
|
||||
// Ensure the status' mentions are populated, and pass in existing to check for changes.
|
||||
if err := d.fetchStatusMentions(ctx, requestUser, status, latestStatus); err != nil {
|
||||
return nil, nil, gtserror.Newf("error populating mentions for status %s: %w", uri, err)
|
||||
|
|
@ -533,7 +539,7 @@ func (d *Dereferencer) fetchStatusMentions(ctx context.Context, requestUser stri
|
|||
// support for edited status revision history.
|
||||
mention.ID, err = id.NewULIDFromTime(status.CreatedAt)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "invalid created at date: %v", err)
|
||||
log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err)
|
||||
mention.ID = id.NewULID() // just use "now"
|
||||
}
|
||||
|
||||
|
|
@ -681,6 +687,101 @@ func (d *Dereferencer) fetchStatusTags(ctx context.Context, existing, status *gt
|
|||
return nil
|
||||
}
|
||||
|
||||
func (d *Dereferencer) fetchStatusPoll(ctx context.Context, existing, status *gtsmodel.Status) error {
|
||||
var (
|
||||
// insertStatusPoll generates ID and inserts the poll attached to status into the database.
|
||||
insertStatusPoll = func(ctx context.Context, status *gtsmodel.Status) error {
|
||||
var err error
|
||||
|
||||
// Generate new ID for poll from the status CreatedAt.
|
||||
// TODO: update this to use "edited_at" when we add
|
||||
// support for edited status revision history.
|
||||
status.Poll.ID, err = id.NewULIDFromTime(status.CreatedAt)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err)
|
||||
status.Poll.ID = id.NewULID() // just use "now"
|
||||
}
|
||||
|
||||
// Update the status<->poll links.
|
||||
status.PollID = status.Poll.ID
|
||||
status.Poll.StatusID = status.ID
|
||||
status.Poll.Status = status
|
||||
|
||||
// Insert this latest poll into the database.
|
||||
err = d.state.DB.PutPoll(ctx, status.Poll)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error putting in database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteStatusPoll deletes the poll with ID, and all attached votes, from the database.
|
||||
deleteStatusPoll = func(ctx context.Context, pollID string) error {
|
||||
if err := d.state.DB.DeletePollByID(ctx, pollID); err != nil {
|
||||
return gtserror.Newf("error deleting existing poll from database: %w", err)
|
||||
}
|
||||
if err := d.state.DB.DeletePollVotes(ctx, pollID); err != nil {
|
||||
return gtserror.Newf("error deleting existing votes from database: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
)
|
||||
|
||||
switch {
|
||||
case existing.Poll == nil && status.Poll == nil:
|
||||
// no poll before or after, nothing to do.
|
||||
return nil
|
||||
|
||||
case existing.Poll == nil && status.Poll != nil:
|
||||
// no previous poll, insert new poll!
|
||||
return insertStatusPoll(ctx, status)
|
||||
|
||||
case /*existing.Poll != nil &&*/ status.Poll == nil:
|
||||
// existing poll has been deleted, remove this.
|
||||
return deleteStatusPoll(ctx, existing.PollID)
|
||||
|
||||
case /*existing.Poll != nil && status.Poll != nil && */
|
||||
!slices.Equal(existing.Poll.Options, status.Poll.Options) ||
|
||||
!existing.Poll.ExpiresAt.Equal(status.Poll.ExpiresAt):
|
||||
// poll has changed since original, delete and reinsert new.
|
||||
if err := deleteStatusPoll(ctx, existing.PollID); err != nil {
|
||||
return err
|
||||
}
|
||||
return insertStatusPoll(ctx, status)
|
||||
|
||||
case /*existing.Poll != nil && status.Poll != nil && */
|
||||
!existing.Poll.ClosedAt.Equal(status.Poll.ClosedAt) ||
|
||||
!slices.Equal(existing.Poll.Votes, status.Poll.Votes) ||
|
||||
existing.Poll.Voters != status.Poll.Voters:
|
||||
// Since we last saw it, the poll has updated!
|
||||
// Whether that be stats, or close time.
|
||||
poll := existing.Poll
|
||||
poll.Closing = (!poll.Closed() && status.Poll.Closed())
|
||||
poll.ClosedAt = status.Poll.ClosedAt
|
||||
poll.Voters = status.Poll.Voters
|
||||
poll.Votes = status.Poll.Votes
|
||||
|
||||
// Update poll model in the database (specifically only the possible changed columns).
|
||||
if err := d.state.DB.UpdatePoll(ctx, poll, "closed_at", "voters", "votes"); err != nil {
|
||||
return gtserror.Newf("error updating poll: %w", err)
|
||||
}
|
||||
|
||||
// Update poll on status.
|
||||
status.PollID = poll.ID
|
||||
status.Poll = poll
|
||||
return nil
|
||||
|
||||
default:
|
||||
// latest and existing
|
||||
// polls are up to date.
|
||||
poll := existing.Poll
|
||||
status.PollID = poll.ID
|
||||
status.Poll = poll
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dereferencer) fetchStatusAttachments(ctx context.Context, tsport transport.Transport, existing, status *gtsmodel.Status) error {
|
||||
// Allocate new slice to take the yet-to-be fetched attachment IDs.
|
||||
status.AttachmentIDs = make([]string, len(status.Attachments))
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
|
|
@ -141,6 +142,22 @@ func (f *federatingDB) activityCreate(
|
|||
// Extract objects from create activity.
|
||||
objects := ap.ExtractObjects(create)
|
||||
|
||||
// Extract PollOptionables (votes!) from objects slice.
|
||||
optionables, objects := ap.ExtractPollOptionables(objects)
|
||||
|
||||
if len(optionables) > 0 {
|
||||
// Handle provided poll vote(s) creation, this can
|
||||
// be for single or multiple votes in the same poll.
|
||||
err := f.createPollOptionables(ctx,
|
||||
receivingAccount,
|
||||
requestingAccount,
|
||||
optionables,
|
||||
)
|
||||
if err != nil {
|
||||
errs.Appendf("error creating poll vote(s): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract Statusables from objects slice (this must be
|
||||
// done AFTER extracting options due to how AS typing works).
|
||||
statusables, objects := ap.ExtractStatusables(objects)
|
||||
|
|
@ -169,6 +186,112 @@ func (f *federatingDB) activityCreate(
|
|||
return errs.Combine()
|
||||
}
|
||||
|
||||
// createPollOptionable handles a Create activity for a PollOptionable.
|
||||
// This function doesn't handle database insertion, only validation checks
|
||||
// before passing off to a worker for asynchronous processing.
|
||||
func (f *federatingDB) createPollOptionables(
|
||||
ctx context.Context,
|
||||
receiver *gtsmodel.Account,
|
||||
requester *gtsmodel.Account,
|
||||
options []ap.PollOptionable,
|
||||
) error {
|
||||
var (
|
||||
// the origin Status w/ Poll the vote
|
||||
// options are in. This gets set on first
|
||||
// iteration, relevant checks performed
|
||||
// then re-used in each further iteration.
|
||||
inReplyTo *gtsmodel.Status
|
||||
|
||||
// the resulting slices of Poll.Option
|
||||
// choice indices passed into the new
|
||||
// created PollVote object.
|
||||
choices []int
|
||||
)
|
||||
|
||||
for _, option := range options {
|
||||
// Extract the "inReplyTo" property.
|
||||
inReplyToURIs := ap.GetInReplyTo(option)
|
||||
if len(inReplyToURIs) != 1 {
|
||||
return gtserror.Newf("invalid inReplyTo property length: %d", len(inReplyToURIs))
|
||||
}
|
||||
|
||||
// Stringify the inReplyTo URI.
|
||||
statusURI := inReplyToURIs[0].String()
|
||||
|
||||
if inReplyTo == nil {
|
||||
var err error
|
||||
|
||||
// This is the first object in the activity slice,
|
||||
// check database for the poll source status by URI.
|
||||
inReplyTo, err = f.state.DB.GetStatusByURI(ctx, statusURI)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error getting poll source from database %s: %w", statusURI, err)
|
||||
}
|
||||
|
||||
switch {
|
||||
// The origin status isn't a poll?
|
||||
case inReplyTo.PollID == "":
|
||||
return gtserror.Newf("poll vote in status %s without poll", statusURI)
|
||||
|
||||
// We don't own the poll ...
|
||||
case !*inReplyTo.Local:
|
||||
return gtserror.Newf("poll vote in remote status %s", statusURI)
|
||||
}
|
||||
|
||||
// Check whether user has already vote in this poll.
|
||||
// (we only check this for the first object, as multiple
|
||||
// may be sent in response to a multiple-choice poll).
|
||||
vote, err := f.state.DB.GetPollVoteBy(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
inReplyTo.PollID,
|
||||
requester.ID,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return gtserror.Newf("error getting status %s poll votes from database: %w", statusURI, err)
|
||||
}
|
||||
|
||||
if vote != nil {
|
||||
log.Warnf(ctx, "%s has already voted in poll %s", requester.URI, statusURI)
|
||||
return nil // this is a useful warning for admins to report to us from logs
|
||||
}
|
||||
}
|
||||
|
||||
if statusURI != inReplyTo.URI {
|
||||
// All activity votes should be to the same poll per activity.
|
||||
return gtserror.New("votes to multiple polls in single activity")
|
||||
}
|
||||
|
||||
// Extract the poll option name.
|
||||
name := ap.ExtractName(option)
|
||||
|
||||
// Check that this is a valid option name.
|
||||
choice := inReplyTo.Poll.GetChoice(name)
|
||||
if choice == -1 {
|
||||
return gtserror.Newf("poll vote in status %s invalid: %s", statusURI, name)
|
||||
}
|
||||
|
||||
// Append the option index to choices.
|
||||
choices = append(choices, choice)
|
||||
}
|
||||
|
||||
// Enqueue message to the fedi API worker with poll vote(s).
|
||||
f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
|
||||
APActivityType: ap.ActivityCreate,
|
||||
APObjectType: ap.ActivityQuestion,
|
||||
GTSModel: >smodel.PollVote{
|
||||
ID: id.NewULID(),
|
||||
Choices: choices,
|
||||
AccountID: requester.ID,
|
||||
Account: requester,
|
||||
PollID: inReplyTo.PollID,
|
||||
Poll: inReplyTo.Poll,
|
||||
},
|
||||
ReceivingAccount: receiver,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createStatusable handles a Create activity for a Statusable.
|
||||
// This function won't insert anything in the database yet,
|
||||
// but will pass the Statusable (if appropriate) through to
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue