@@ -487,23 +487,40 @@ type fieldChange struct {
487487 New any `json:"new"`
488488}
489489
490+ // deliveryMeta carries the actor and task-alias identifiers resolved while
491+ // building a delivery's "text" summary, so deliver() can attach them to the
492+ // outbound payload as structured fields (the API previously only embedded
493+ // them in the prose "text" line).
494+ type deliveryMeta struct {
495+ ActorID string
496+ ActorName string
497+ ActorType string // "user" or "agent", "" when no actor on the event
498+ TaskAlias string // e.g. "ABC-123", "" when unset/unconfigured
499+ }
500+
490501// buildEventData decodes the activity's topic-specific "content" JSON (a
491502// string, as recorded by the activity log) into a structured "data" object
492503// for the delivery payload, and builds a short human-readable summary line —
493504// prefixed with the actor's name — for the envelope's "text" field. Each
494505// topic has its own content shape, so dispatch on topic rather than trying
495506// to interpret it generically.
496- func (p * webhookPlugin ) buildEventData (topic string , payload map [string ]any ) (map [string ]any , string ) {
507+ func (p * webhookPlugin ) buildEventData (topic string , payload map [string ]any ) (map [string ]any , string , deliveryMeta ) {
497508 rawContent , _ := payload ["content" ].(string )
498- actor := p .actorName (topic , payload )
509+ actor , actorID , actorType := p .resolveActor (topic , payload )
510+ meta := deliveryMeta {
511+ ActorID : actorID ,
512+ ActorName : actor ,
513+ ActorType : actorType ,
514+ TaskAlias : p .taskAliasForPayload (payload ),
515+ }
499516
500517 switch topic {
501518 case "task.created" :
502519 var c struct {
503520 Title string `json:"title"`
504521 }
505522 _ = json .Unmarshal ([]byte (rawContent ), & c )
506- return map [string ]any {"title" : c .Title }, fmt .Sprintf ("%s created %s" , actor , p .taskRef (payload ))
523+ return map [string ]any {"title" : c .Title }, fmt .Sprintf ("%s created %s" , actor , p .taskRef (payload )), meta
507524
508525 case "task.updated" :
509526 ref := p .taskRef (payload )
@@ -512,7 +529,7 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma
512529 }
513530 _ = json .Unmarshal ([]byte (rawContent ), & c )
514531 if len (c .Changes ) == 0 {
515- return map [string ]any {"changes" : []fieldChange {}}, fmt .Sprintf ("%s updated %s" , actor , ref )
532+ return map [string ]any {"changes" : []fieldChange {}}, fmt .Sprintf ("%s updated %s" , actor , ref ), meta
516533 }
517534 parts := make ([]string , 0 , len (c .Changes ))
518535 for i , ch := range c .Changes {
@@ -529,10 +546,10 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma
529546 }
530547 parts = append (parts , fmt .Sprintf ("%s: %v → %v" , ch .Field , ch .Old , ch .New ))
531548 }
532- return map [string ]any {"changes" : c .Changes }, fmt .Sprintf ("%s updated %s — %s" , actor , ref , strings .Join (parts , ", " ))
549+ return map [string ]any {"changes" : c .Changes }, fmt .Sprintf ("%s updated %s — %s" , actor , ref , strings .Join (parts , ", " )), meta
533550
534551 case "task.deleted" :
535- return map [string ]any {}, fmt .Sprintf ("%s deleted %s" , actor , p .taskRef (payload ))
552+ return map [string ]any {}, fmt .Sprintf ("%s deleted %s" , actor , p .taskRef (payload )), meta
536553
537554 case "task.link.added" :
538555 ref := p .taskRef (payload )
@@ -542,28 +559,28 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma
542559 }
543560 _ = json .Unmarshal ([]byte (rawContent ), & c )
544561 return map [string ]any {"target_task_id" : c .TargetTaskID , "link_type" : c .LinkType },
545- fmt .Sprintf ("%s linked %s to %s (%s)" , actor , ref , c .TargetTaskID , c .LinkType )
562+ fmt .Sprintf ("%s linked %s to %s (%s)" , actor , ref , c .TargetTaskID , c .LinkType ), meta
546563
547564 case "task.link.removed" :
548565 var c struct {
549566 LinkID string `json:"link_id"`
550567 }
551568 _ = json .Unmarshal ([]byte (rawContent ), & c )
552- return map [string ]any {"link_id" : c .LinkID }, fmt .Sprintf ("%s removed a link from %s" , actor , p .taskRef (payload ))
569+ return map [string ]any {"link_id" : c .LinkID }, fmt .Sprintf ("%s removed a link from %s" , actor , p .taskRef (payload )), meta
553570
554571 case "task.comment.deleted" :
555572 commentID , _ := payload ["id" ].(string )
556- return map [string ]any {"comment_id" : commentID }, fmt .Sprintf ("%s deleted a comment on %s" , actor , p .taskRef (payload ))
573+ return map [string ]any {"comment_id" : commentID }, fmt .Sprintf ("%s deleted a comment on %s" , actor , p .taskRef (payload )), meta
557574
558575 case "task.comment.added" , "task.comment.updated" , "comment" :
559576 ref := p .taskRef (payload )
560577 commentID , _ := payload ["id" ].(string )
561578 text := extractBlockText (rawContent )
562579 data := map [string ]any {"comment_id" : commentID , "text" : text }
563580 if topic == "task.comment.updated" {
564- return data , fmt .Sprintf ("%s updated a comment on %s" , actor , ref )
581+ return data , fmt .Sprintf ("%s updated a comment on %s" , actor , ref ), meta
565582 }
566- return data , fmt .Sprintf ("%s commented on %s" , actor , ref )
583+ return data , fmt .Sprintf ("%s commented on %s" , actor , ref ), meta
567584
568585 case "agent.session.started" :
569586 var c struct {
@@ -572,41 +589,43 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma
572589 }
573590 _ = json .Unmarshal ([]byte (rawContent ), & c )
574591 return map [string ]any {"conversation_id" : c .ConversationID , "agent_id" : c .AgentID },
575- fmt .Sprintf ("%s started an agent session on %s" , actor , p .taskRef (payload ))
592+ fmt .Sprintf ("%s started an agent session on %s" , actor , p .taskRef (payload )), meta
576593
577594 case "task.attachment.added" :
578- return map [string ]any {}, fmt .Sprintf ("%s added an attachment to %s" , actor , p .taskRef (payload ))
595+ return map [string ]any {}, fmt .Sprintf ("%s added an attachment to %s" , actor , p .taskRef (payload )), meta
579596
580597 case "task.attachment.removed" :
581- return map [string ]any {}, fmt .Sprintf ("%s removed an attachment from %s" , actor , p .taskRef (payload ))
598+ return map [string ]any {}, fmt .Sprintf ("%s removed an attachment from %s" , actor , p .taskRef (payload )), meta
582599
583600 case "webhook.test" :
584601 msg , _ := payload ["message" ].(string )
585- return map [string ]any {"message" : msg }, msg
602+ return map [string ]any {"message" : msg }, msg , meta
586603
587604 default :
588- return map [string ]any {}, fmt .Sprintf ("Paca event: %s" , topic )
605+ return map [string ]any {}, fmt .Sprintf ("Paca event: %s" , topic ), meta
589606 }
590607}
591608
592- // actorName resolves a display name for the activity's actor. The ID space
593- // in payload["actor_id"] depends on topic: task-level activities record the
594- // authenticated user's ID, while comment activities record the
595- // project_members row ID instead — so the join differs by topic. An AI agent
596- // actor is recorded separately in payload["actor_agent_id"].
597- func (p * webhookPlugin ) actorName (topic string , payload map [string ]any ) string {
609+ // resolveActor resolves a display name, ID, and type ("user" or "agent") for
610+ // the activity's actor. The ID space in payload["actor_id"] depends on
611+ // topic: task-level activities record the authenticated user's ID, while
612+ // comment activities record the project_members row ID instead — so the
613+ // join differs by topic. An AI agent actor is recorded separately in
614+ // payload["actor_agent_id"]. Returns actorType "" when the event has no
615+ // resolvable actor at all (id and name fall back to "" and "Someone").
616+ func (p * webhookPlugin ) resolveActor (topic string , payload map [string ]any ) (name , id , actorType string ) {
598617 if agentID , ok := payload ["actor_agent_id" ].(string ); ok && agentID != "" {
599- if name := p .lookupName (`SELECT name FROM agents WHERE id = $1` , agentID ); name != "" {
600- return name
618+ name = p .lookupName (`SELECT name FROM agents WHERE id = $1` , agentID )
619+ if name == "" {
620+ name = "An agent"
601621 }
602- return "An agent"
622+ return name , agentID , " agent"
603623 }
604624 actorID , _ := payload ["actor_id" ].(string )
605625 if actorID == "" {
606- return "Someone"
626+ return "Someone" , "" , ""
607627 }
608628
609- var name string
610629 switch topic {
611630 case "task.comment.added" , "task.comment.updated" , "task.comment.deleted" , "comment" :
612631 name = p .lookupName (
@@ -617,9 +636,9 @@ func (p *webhookPlugin) actorName(topic string, payload map[string]any) string {
617636 name = p .lookupName (`SELECT full_name AS name FROM users WHERE id = $1` , actorID )
618637 }
619638 if name == "" {
620- return "Someone"
639+ name = "Someone"
621640 }
622- return name
641+ return name , actorID , "user"
623642}
624643
625644// lookupName runs a single-row, single-column ("name") query and returns its
@@ -656,6 +675,23 @@ func (p *webhookPlugin) taskRef(payload map[string]any) string {
656675 return fmt .Sprintf ("task %q" , title )
657676}
658677
678+ // taskAliasForPayload resolves the task's human-readable alias (e.g.
679+ // "ABC-123") for the delivery payload's structured "task_alias" field,
680+ // returning "" when the event has no task_id or the project has no
681+ // task_id_prefix configured.
682+ func (p * webhookPlugin ) taskAliasForPayload (payload map [string ]any ) string {
683+ taskID , _ := payload ["task_id" ].(string )
684+ if taskID == "" {
685+ return ""
686+ }
687+ result , err := p .db .Query (`SELECT task_number, project_id FROM tasks WHERE id = $1` , taskID )
688+ if err != nil || len (result .Rows ) == 0 {
689+ return ""
690+ }
691+ sc := newRowScanner (result .Columns , result .Rows [0 ])
692+ return p .taskAlias (sc .str ("project_id" ), sc .intVal ("task_number" ))
693+ }
694+
659695// taskAlias formats a task's human-readable alias (e.g. "ABC-123") from its
660696// project's task_id_prefix and the task's sequential task_number, or ""
661697// when the project has no prefix configured or the task number is unset.
@@ -758,7 +794,7 @@ func (p *webhookPlugin) deliver(sc *scanner, eventType string, payload map[strin
758794 webhookID := sc .str ("id" )
759795 targetURL := sc .str ("url" )
760796
761- data , text := p .buildEventData (eventType , payload )
797+ data , text , meta := p .buildEventData (eventType , payload )
762798 if pname := p .projectName (payload ); pname != "" {
763799 data ["project_name" ] = pname
764800 text = fmt .Sprintf ("[%s] %s" , pname , text )
@@ -767,13 +803,22 @@ func (p *webhookPlugin) deliver(sc *scanner, eventType string, payload map[strin
767803 data ["url" ] = url
768804 text = text + " - " + url
769805 }
806+ actor := map [string ]any {"name" : meta .ActorName }
807+ if meta .ActorID != "" {
808+ actor ["id" ] = meta .ActorID
809+ }
810+ if meta .ActorType != "" {
811+ actor ["type" ] = meta .ActorType
812+ }
770813 body , _ := json .Marshal (map [string ]any {
771814 "event" : eventType ,
772815 "webhook_id" : webhookID ,
773816 "text" : text ,
774817 "task_id" : payload ["task_id" ],
818+ "task_alias" : meta .TaskAlias ,
775819 "project_id" : payload ["project_id" ],
776820 "actor_id" : payload ["actor_id" ],
821+ "actor" : actor ,
777822 "occurred_at" : payload ["created_at" ],
778823 "data" : data ,
779824 "sent_at" : nowStr (),
0 commit comments