Store failed activities
Some checks failed
/ docker (push) Failing after 4m0s

If the processing for an activity failed due to internal reasons (NOT
malformed content), store it for a later attempt
This commit is contained in:
Melody Becker 2025-06-08 08:34:47 +02:00
parent 604e25c451
commit f727b30f32
Signed by: mstar
SSH key fingerprint: SHA256:9VAo09aaVNTWKzPW7Hq2LW+ox9OdwmTSHRoD4mlz1yI
2 changed files with 163 additions and 117 deletions

View file

@ -1,6 +1,7 @@
package activitypub
import (
"context"
"database/sql"
"encoding/json"
"fmt"
@ -15,6 +16,7 @@ import (
"git.mstar.dev/mstar/goutils/other"
"github.com/mitchellh/mapstructure"
"github.com/rs/zerolog/hlog"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub"
@ -92,19 +94,20 @@ func userInbox(w http.ResponseWriter, r *http.Request) {
// TODO: Decide how to handle the handler failing for whatever reason
// Add object to unhandled message table and try again later?
// Discard it? And how would a handler return that it failed?
ok = true
switch objectType {
case "Like":
handleLike(w, r, data)
ok = handleLike(w, r, data)
case "Undo":
handleUndo(w, r, data)
ok = handleUndo(w, r, data)
case "Follow":
handleFollow(w, r, data)
ok = handleFollow(w, r, data)
case "Accept":
handleAccept(w, r, data)
ok = handleAccept(w, r, data)
case "Reject":
handleReject(w, r, data)
ok = handleReject(w, r, data)
case "Create":
handleCreate(w, r, data)
ok = handleCreate(w, r, data)
default:
log.Warn().
Str("object-type", objectType).
@ -119,9 +122,23 @@ func userInbox(w http.ResponseWriter, r *http.Request) {
}
_ = webutils.ProblemDetailsStatusOnly(w, 500)
}
if !ok {
err = dbgen.UnhandledMessage.Create(&models.UnhandledMessage{
RawData: body,
ForUserId: userId,
GlobalInbox: false,
})
if err != nil {
log.Error().
Err(err).
Bytes("body", body).
Str("user-id", userId).
Msg("Failed to store failed inbound message for later processing")
}
}
}
func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) {
func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) bool {
log := hlog.FromRequest(r)
activityId := object["id"].(string)
likerUrl, ok := object["actor"].(string)
@ -134,7 +151,7 @@ func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) {
other.IntoPointer(`Request data needs to contain a field "actor" with a string value`),
nil,
)
return
return true
}
// TODO: Account for case where object is embedded in like
targetUrl, ok := object["object"].(string)
@ -147,7 +164,7 @@ func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) {
other.IntoPointer(`Request data needs to contain a field "object" with a string value`),
nil,
)
return
return true
}
targetIdMatches := objectIdRegex.FindStringSubmatch(targetUrl)
if len(targetIdMatches) != 2 {
@ -156,7 +173,7 @@ func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) {
Str("url", targetUrl).
Msg("Url didn't match id extractor regex")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return true
}
targetId := targetIdMatches[1]
// Assume likes can only happen on notes for now
@ -173,11 +190,11 @@ func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) {
other.IntoPointer("There is no note with the target id"),
nil,
)
return
return true
default:
log.Error().Err(err).Str("note-id", targetId).Msg("Failed to get note from db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
// Import liker after verifying that target note is correct
liker, err := activitypub.ImportRemoteAccountByAPUrl(likerUrl)
@ -187,7 +204,7 @@ func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) {
Str("liker-url", likerUrl).
Msg("Failed to import liking remote account")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
reaction := models.Reaction{
Note: *note,
@ -204,6 +221,7 @@ func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) {
_ = tx.Rollback()
log.Error().Err(err).Any("raw-reaction", reaction).Msg("Failed to store reaction in db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return false
}
// TODO: Create corresponding activity too
activity := models.Activity{
@ -217,15 +235,18 @@ func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) {
_ = tx.Rollback()
log.Error().Err(err).Any("raw-reaction", reaction).Msg("Failed to store reaction in db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return false
}
err = tx.Commit()
if err != nil {
log.Error().Err(err).Msg("Failed to commit reaction transaction to db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return false
}
return true
}
func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any) {
func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any) bool {
log := hlog.FromRequest(r)
log.Debug().Msg("Received follow request")
objectId, ok := object["id"].(string)
@ -238,7 +259,7 @@ func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Request data needs to contain a field "id" with a string value`),
nil,
)
return
return true
}
actorApId, ok := object["actor"].(string)
if !ok {
@ -250,7 +271,7 @@ func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Request data needs to contain a field "actor" with a string value`),
nil,
)
return
return true
}
targetUrl, ok := object["object"].(string)
if !ok {
@ -262,7 +283,7 @@ func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Request data needs to contain a field "object" of type string`),
nil,
)
return
return true
}
followedMatch := objectIdRegex.FindStringSubmatch(targetUrl)
if len(followedMatch) != 2 {
@ -274,25 +295,25 @@ func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Object must be a link to a Linstrom AP user`),
nil,
)
return
return true
}
followedId := followedMatch[1]
followed, err := dbgen.User.Where(dbgen.User.ID.Eq(followedId)).First()
switch err {
case gorm.ErrRecordNotFound:
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
return
return true
case nil:
default:
log.Error().Err(err).Str("target-id", followedId).Msg("Failed to get account from db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
follower, err := activitypub.ImportRemoteAccountByAPUrl(actorApId)
if err != nil {
log.Error().Err(err).Msg("Failed to import following account")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
u2u := dbgen.UserToUserRelation
followRelations, err := u2u.Where(
@ -310,10 +331,10 @@ func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any)
Str("followed", followedId).
Msg("Failed to count follow relations")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
if followRelations > 0 {
return
return true
}
tx := dbgen.Q.Begin()
req := models.UserToUserRelation{
@ -326,7 +347,7 @@ func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any)
_ = tx.Rollback()
log.Error().Err(err).Any("follow-request", req).Msg("Failed to store follow request")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
activity := models.Activity{
Id: objectId,
@ -339,79 +360,31 @@ func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any)
_ = tx.Rollback()
log.Error().Err(err).Any("activity", activity).Msg("Failed to store follow activity")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
err = tx.Commit()
if err != nil {
log.Error().Err(err).Msg("Failed to commit follow activity transaction")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
if !followed.RestrictedFollow {
tx = dbgen.Q.Begin()
_, err = u2u.Where(u2u.ID.Eq(req.ID)).UpdateColumn(u2u.Relation, models.RelationFollow)
if err != nil {
_ = tx.Rollback()
log.Error().Err(err).Msg("Failed to update follow to confirmed")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
acceptActivity := models.Activity{
Id: shared.NewId(),
Type: string(models.ActivityAccept),
ObjectId: activity.Id,
ObjectType: uint32(models.ActivitystreamsActivityTargetActivity),
}
err = tx.Activity.Create(&acceptActivity)
if err != nil {
_ = tx.Rollback()
log.Error().Err(err).Msg("Failed to store accept activity in db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
err = tx.Commit()
if err != nil {
log.Error().Err(err).Msg("Failed to commit follow accept to db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
// TODO: Move this to a separate function
go func() {
// TODO: Maybe move this part to a separate function
time.Sleep(time.Millisecond * 20)
webAccept, err := AcceptFromStorage(r.Context(), acceptActivity.Id)
err := AcceptFollow(req.ID, followedId, followedId)
if err != nil {
log.Error().Err(err).Msg("Failed to get accept from db")
return
}
webAccept.Context = activitypub.BaseLdContext
body, err := json.Marshal(webAccept)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal accept")
return
}
res, _, err := webshared.RequestSigned(
"POST",
follower.RemoteInfo.InboxLink,
body,
followed,
)
if err != nil {
log.Error().Err(err).Msg("Failed to send accept")
return
}
if res.StatusCode >= 400 {
body, _ = io.ReadAll(res.Body)
log.Error().
Int("status-code", res.StatusCode).
Bytes("body", body).
Msg("Post of accept failed")
Err(err).
Uint64("follow-request-id", req.ID).
Msg("Failed to auto-accept follow request")
}
}()
}
return true
}
// WARN: Untested as can't send follow activities yet
func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any) {
func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any) bool {
log := hlog.FromRequest(r)
rawTarget, ok := object["object"]
if !ok {
@ -423,7 +396,7 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Request data needs to contain a field "object"`),
nil,
)
return
return true
}
// FIXME: Also handle other undo cases, such as follows
var targetObjectId string
@ -443,7 +416,7 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Target object type must be a string with value "Follow"`),
nil,
)
return
return true
}
targetObjectId, ok = target["id"].(string)
if !ok {
@ -455,7 +428,7 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Missing id in undone object`),
nil,
)
return
return true
}
default:
_ = webutils.ProblemDetails(
@ -466,7 +439,7 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Request data needs to contain a field "object" of type string or object`),
nil,
)
return
return true
}
internalIdMatch := objectIdRegex.FindStringSubmatch(targetObjectId)
if len(internalIdMatch) != 2 {
@ -478,14 +451,14 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Request data target object is not internal id`),
nil,
)
return
return true
}
internalId := internalIdMatch[1]
followActivity, err := dbgen.Activity.Where(dbgen.Activity.Id.Eq(internalId)).First()
switch err {
case gorm.ErrRecordNotFound:
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
return
return true
case nil:
default:
log.Error().
@ -493,7 +466,7 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
Str("target-id", internalId).
Msg("Failed to get target follow activity from db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
relationId := other.Must(strconv.ParseUint(followActivity.ObjectId, 10, 64))
dbrel := dbgen.UserToUserRelation
@ -504,7 +477,7 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
case gorm.ErrRecordNotFound:
// No need to rollback, nothing was done
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
return
return true
case nil:
default:
_ = tx.Rollback()
@ -513,7 +486,7 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
Str("target-id", internalId).
Msg("Failed to update follow status to confirmed follow")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
activity := models.Activity{
Id: object["id"].(string),
@ -529,7 +502,7 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
Str("target-id", internalId).
Msg("Failed to store accept activity in db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
err = tx.Commit()
if err != nil {
@ -538,12 +511,13 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
Str("target-id", internalId).
Msg("Failed to commit accept transaction")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
return true
}
// WARN: Untested as can't send follow activities yet
func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any) {
func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any) bool {
log := hlog.FromRequest(r)
rawTarget, ok := object["object"]
if !ok {
@ -555,7 +529,7 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Request data needs to contain a field "object"`),
nil,
)
return
return true
}
// FIXME: Also handle other undo cases, such as follows
var targetObjectId string
@ -575,7 +549,7 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Target object type must be a string with value "Follow"`),
nil,
)
return
return true
}
targetObjectId, ok = target["id"].(string)
if !ok {
@ -587,7 +561,7 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Missing id in undone object`),
nil,
)
return
return true
}
default:
_ = webutils.ProblemDetails(
@ -598,7 +572,7 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Request data needs to contain a field "object" of type string or object`),
nil,
)
return
return true
}
internalIdMatch := objectIdRegex.FindStringSubmatch(targetObjectId)
if len(internalIdMatch) != 2 {
@ -610,14 +584,14 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer(`Request data target object is not internal id`),
nil,
)
return
return true
}
internalId := internalIdMatch[1]
followActivity, err := dbgen.Activity.Where(dbgen.Activity.Id.Eq(internalId)).First()
switch err {
case gorm.ErrRecordNotFound:
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
return
return true
case nil:
default:
log.Error().
@ -625,7 +599,7 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
Str("target-id", internalId).
Msg("Failed to get target follow activity from db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
relationId := other.Must(strconv.ParseUint(followActivity.ObjectId, 10, 64))
dbrel := dbgen.UserToUserRelation
@ -635,7 +609,7 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
case gorm.ErrRecordNotFound:
// No need to rollback, nothing was done
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
return
return true
case nil:
default:
_ = tx.Rollback()
@ -644,7 +618,7 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
Str("target-id", internalId).
Msg("Failed to delete follow status")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
_, err = tx.Activity.Where(
dbgen.Activity.ObjectId.Eq(followActivity.Id),
@ -655,7 +629,7 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
_ = tx.Rollback()
log.Error().Err(err).Msg("Failed to delete accept for later rejected follow")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
activity := models.Activity{
Id: object["id"].(string),
@ -671,7 +645,7 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
Str("target-id", internalId).
Msg("Failed to store accept activity in db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
err = tx.Commit()
if err != nil {
@ -680,11 +654,12 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
Str("target-id", internalId).
Msg("Failed to commit accept transaction")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
return true
}
func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any) {
func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any) bool {
log := hlog.FromRequest(r)
activity := ActivityCreate{}
err := mapstructure.Decode(object, &activity)
@ -694,7 +669,7 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any)
Any("raw", object).
Msg("Failed to marshal create activity to proper type")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return true
}
actingUser, err := activitypub.ImportRemoteAccountByAPUrl(activity.Actor)
if err != nil {
@ -703,7 +678,7 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any)
Str("actor", activity.Actor).
Msg("Failed to import remote actor for note")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return true
}
switch val := activity.Object.(type) {
case string:
@ -711,14 +686,14 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any)
if err != nil {
log.Error().Err(err).Msg("Failed to get local actor for importing targeted note")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
_, err = activitypub.ImportRemoteNote(val, actor)
if err != nil {
log.Error().Err(err).Str("note-url", val).Msg("Failed to import remote note that landed as id in the inbox")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
}
return
return false
case map[string]any:
default:
_ = webutils.ProblemDetails(
@ -729,7 +704,7 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer("Bad object data for create activity. Must be a struct or string"),
nil,
)
return
return true
}
obj := activity.Object.(map[string]any)
// Dumb hack since published timestamp is still a string at this point
@ -747,7 +722,7 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any)
Any("raw", activity.Object).
Msg("Failed to unmarshal create object into note")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return true
}
if objectNote.Type != "Note" {
_ = webutils.ProblemDetails(
@ -758,7 +733,7 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any)
other.IntoPointer("Bad object data for create activity. object.type must be 'Note'"),
nil,
)
return
return true
}
dbNote := models.Note{
ID: objectNote.Id,
@ -788,7 +763,7 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any)
_ = tx.Rollback()
log.Error().Err(err).Any("note", dbNote).Msg("Failed to create note in db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
createActivity := models.Activity{
Type: string(models.ActivityCreate),
@ -801,12 +776,83 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any)
_ = tx.Rollback()
log.Error().Err(err).Any("note", dbNote).Msg("Failed to create note create activity in db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
err = tx.Commit()
if err != nil {
log.Error().Err(err).Any("note", dbNote).Msg("Failed to submit note creation in db")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
return false
}
return true
}
func AcceptFollow(
ctx context.Context,
followRequestRelationId uint64,
followerId, followedId string,
) error {
u2u := dbgen.UserToUserRelation
a := dbgen.Activity
followRequestActivity, err := a.Where(a.ObjectId.Eq(fmt.Sprint(followRequestRelationId))).
First()
if err != nil {
return other.Error("webaction", "failed to get follow activity", err)
}
follower, err := dbgen.User.GetById(followedId)
if err != nil {
return other.Error("webaction", "failed to get follower from db", err)
}
followed, err := dbgen.User.GetById(followedId)
if err != nil {
return err
}
tx := dbgen.Q.Begin()
_, err = u2u.Where(u2u.ID.Eq(followRequestRelationId)).
UpdateColumn(u2u.Relation, models.RelationFollow)
if err != nil {
_ = tx.Rollback()
return err
}
acceptActivity := models.Activity{
Id: shared.NewId(),
Type: string(models.ActivityAccept),
ObjectId: followRequestActivity.Id,
ObjectType: uint32(models.ActivitystreamsActivityTargetActivity),
}
err = tx.Activity.Create(&acceptActivity)
if err != nil {
_ = tx.Rollback()
return err
}
err = tx.Commit()
if err != nil {
return err
}
webAccept, err := AcceptFromStorage(ctx, acceptActivity.Id)
if err != nil {
return err
}
webAccept.Context = activitypub.BaseLdContext
body, err := json.Marshal(webAccept)
if err != nil {
return err
}
res, _, err := webshared.RequestSigned(
"POST",
follower.RemoteInfo.InboxLink,
body,
followed,
)
if err != nil {
return err
}
if res.StatusCode >= 400 {
body, _ = io.ReadAll(res.Body)
log.Error().
Int("status-code", res.StatusCode).
Bytes("body", body).
Msg("Post of accept failed")
}
return nil
}

View file

@ -12,7 +12,7 @@ import (
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
)
func handleUndo(w http.ResponseWriter, r *http.Request, object map[string]any) {
func handleUndo(w http.ResponseWriter, r *http.Request, object map[string]any) bool {
log := hlog.FromRequest(r)
_ = object["id"].(string)
_, ok := object["actor"].(string)