Skip to content

Commit f791273

Browse files
authored
Merge pull request #4 from Paca-AI/feature/enhance-delivery-metadata
Feature/enhance delivery metadata
2 parents 38d5c6f + 25ccd11 commit f791273

3 files changed

Lines changed: 80 additions & 32 deletions

File tree

backend/plugin_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,10 +224,13 @@ func TestTaskRefAndProjectNameInSummaryText(t *testing.T) {
224224
t.Fatalf("projectName = %q, want %q", got, want)
225225
}
226226

227-
_, text := p.buildEventData("task.deleted", payload)
227+
_, text, meta := p.buildEventData("task.deleted", payload)
228228
if want := `Someone deleted task ABC-123 "Fix login bug"`; text != want {
229229
t.Fatalf("buildEventData text = %q, want %q", text, want)
230230
}
231+
if meta.TaskAlias != "ABC-123" {
232+
t.Fatalf("buildEventData meta.TaskAlias = %q, want %q", meta.TaskAlias, "ABC-123")
233+
}
231234

232235
// A project with no task_id_prefix configured falls back to no alias.
233236
tc.DB.SeedRows("projects",

backend/webhooks.go

Lines changed: 75 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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(),

plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"id": "com.paca.webhook",
33
"displayName": "Webhooks",
44
"description": "Sends HTTP webhooks to a URL of your choice when task activity happens in a project.",
5-
"version": "0.1.2",
5+
"version": "0.1.3",
66
"permissions": ["db.read", "db.write", "events.subscribe"],
77
"backend": {
88
"allowedConfigKeys": ["ENCRYPTION_KEY", "PUBLIC_URL"],

0 commit comments

Comments
 (0)