From abe5eb68027d58a51b1543b65b6cf4eaffe69339 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Tue, 30 Jun 2026 16:49:57 +0000 Subject: [PATCH 1/2] feat: enhance delivery metadata with actor and task alias information --- backend/plugin_test.go | 5 +- backend/webhooks.go | 105 +++++++++++++++++++++++++++++------------ 2 files changed, 79 insertions(+), 31 deletions(-) diff --git a/backend/plugin_test.go b/backend/plugin_test.go index 3eaf64b..9e3cdef 100644 --- a/backend/plugin_test.go +++ b/backend/plugin_test.go @@ -224,10 +224,13 @@ func TestTaskRefAndProjectNameInSummaryText(t *testing.T) { t.Fatalf("projectName = %q, want %q", got, want) } - _, text := p.buildEventData("task.deleted", payload) + _, text, meta := p.buildEventData("task.deleted", payload) if want := `Someone deleted task ABC-123 "Fix login bug"`; text != want { t.Fatalf("buildEventData text = %q, want %q", text, want) } + if meta.TaskAlias != "ABC-123" { + t.Fatalf("buildEventData meta.TaskAlias = %q, want %q", meta.TaskAlias, "ABC-123") + } // A project with no task_id_prefix configured falls back to no alias. tc.DB.SeedRows("projects", diff --git a/backend/webhooks.go b/backend/webhooks.go index f5ca53d..6468bde 100644 --- a/backend/webhooks.go +++ b/backend/webhooks.go @@ -487,15 +487,32 @@ type fieldChange struct { New any `json:"new"` } +// deliveryMeta carries the actor and task-alias identifiers resolved while +// building a delivery's "text" summary, so deliver() can attach them to the +// outbound payload as structured fields (the API previously only embedded +// them in the prose "text" line). +type deliveryMeta struct { + ActorID string + ActorName string + ActorType string // "user" or "agent", "" when no actor on the event + TaskAlias string // e.g. "ABC-123", "" when unset/unconfigured +} + // buildEventData decodes the activity's topic-specific "content" JSON (a // string, as recorded by the activity log) into a structured "data" object // for the delivery payload, and builds a short human-readable summary line — // prefixed with the actor's name — for the envelope's "text" field. Each // topic has its own content shape, so dispatch on topic rather than trying // to interpret it generically. -func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (map[string]any, string) { +func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (map[string]any, string, deliveryMeta) { rawContent, _ := payload["content"].(string) - actor := p.actorName(topic, payload) + actor, actorID, actorType := p.resolveActor(topic, payload) + meta := deliveryMeta{ + ActorID: actorID, + ActorName: actor, + ActorType: actorType, + TaskAlias: p.taskAliasForPayload(payload), + } switch topic { case "task.created": @@ -503,7 +520,7 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma Title string `json:"title"` } _ = json.Unmarshal([]byte(rawContent), &c) - return map[string]any{"title": c.Title}, fmt.Sprintf("%s created %s", actor, p.taskRef(payload)) + return map[string]any{"title": c.Title}, fmt.Sprintf("%s created %s", actor, p.taskRef(payload)), meta case "task.updated": ref := p.taskRef(payload) @@ -512,7 +529,7 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma } _ = json.Unmarshal([]byte(rawContent), &c) if len(c.Changes) == 0 { - return map[string]any{"changes": []fieldChange{}}, fmt.Sprintf("%s updated %s", actor, ref) + return map[string]any{"changes": []fieldChange{}}, fmt.Sprintf("%s updated %s", actor, ref), meta } parts := make([]string, 0, len(c.Changes)) for i, ch := range c.Changes { @@ -529,10 +546,10 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma } parts = append(parts, fmt.Sprintf("%s: %v → %v", ch.Field, ch.Old, ch.New)) } - return map[string]any{"changes": c.Changes}, fmt.Sprintf("%s updated %s — %s", actor, ref, strings.Join(parts, ", ")) + return map[string]any{"changes": c.Changes}, fmt.Sprintf("%s updated %s — %s", actor, ref, strings.Join(parts, ", ")), meta case "task.deleted": - return map[string]any{}, fmt.Sprintf("%s deleted %s", actor, p.taskRef(payload)) + return map[string]any{}, fmt.Sprintf("%s deleted %s", actor, p.taskRef(payload)), meta case "task.link.added": ref := p.taskRef(payload) @@ -542,18 +559,18 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma } _ = json.Unmarshal([]byte(rawContent), &c) return map[string]any{"target_task_id": c.TargetTaskID, "link_type": c.LinkType}, - fmt.Sprintf("%s linked %s to %s (%s)", actor, ref, c.TargetTaskID, c.LinkType) + fmt.Sprintf("%s linked %s to %s (%s)", actor, ref, c.TargetTaskID, c.LinkType), meta case "task.link.removed": var c struct { LinkID string `json:"link_id"` } _ = json.Unmarshal([]byte(rawContent), &c) - return map[string]any{"link_id": c.LinkID}, fmt.Sprintf("%s removed a link from %s", actor, p.taskRef(payload)) + return map[string]any{"link_id": c.LinkID}, fmt.Sprintf("%s removed a link from %s", actor, p.taskRef(payload)), meta case "task.comment.deleted": commentID, _ := payload["id"].(string) - return map[string]any{"comment_id": commentID}, fmt.Sprintf("%s deleted a comment on %s", actor, p.taskRef(payload)) + return map[string]any{"comment_id": commentID}, fmt.Sprintf("%s deleted a comment on %s", actor, p.taskRef(payload)), meta case "task.comment.added", "task.comment.updated", "comment": ref := p.taskRef(payload) @@ -561,9 +578,9 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma text := extractBlockText(rawContent) data := map[string]any{"comment_id": commentID, "text": text} if topic == "task.comment.updated" { - return data, fmt.Sprintf("%s updated a comment on %s", actor, ref) + return data, fmt.Sprintf("%s updated a comment on %s", actor, ref), meta } - return data, fmt.Sprintf("%s commented on %s", actor, ref) + return data, fmt.Sprintf("%s commented on %s", actor, ref), meta case "agent.session.started": var c struct { @@ -572,41 +589,43 @@ func (p *webhookPlugin) buildEventData(topic string, payload map[string]any) (ma } _ = json.Unmarshal([]byte(rawContent), &c) return map[string]any{"conversation_id": c.ConversationID, "agent_id": c.AgentID}, - fmt.Sprintf("%s started an agent session on %s", actor, p.taskRef(payload)) + fmt.Sprintf("%s started an agent session on %s", actor, p.taskRef(payload)), meta case "task.attachment.added": - return map[string]any{}, fmt.Sprintf("%s added an attachment to %s", actor, p.taskRef(payload)) + return map[string]any{}, fmt.Sprintf("%s added an attachment to %s", actor, p.taskRef(payload)), meta case "task.attachment.removed": - return map[string]any{}, fmt.Sprintf("%s removed an attachment from %s", actor, p.taskRef(payload)) + return map[string]any{}, fmt.Sprintf("%s removed an attachment from %s", actor, p.taskRef(payload)), meta case "webhook.test": msg, _ := payload["message"].(string) - return map[string]any{"message": msg}, msg + return map[string]any{"message": msg}, msg, meta default: - return map[string]any{}, fmt.Sprintf("Paca event: %s", topic) + return map[string]any{}, fmt.Sprintf("Paca event: %s", topic), meta } } -// actorName resolves a display name for the activity's actor. The ID space -// in payload["actor_id"] depends on topic: task-level activities record the -// authenticated user's ID, while comment activities record the -// project_members row ID instead — so the join differs by topic. An AI agent -// actor is recorded separately in payload["actor_agent_id"]. -func (p *webhookPlugin) actorName(topic string, payload map[string]any) string { +// resolveActor resolves a display name, ID, and type ("user" or "agent") for +// the activity's actor. The ID space in payload["actor_id"] depends on +// topic: task-level activities record the authenticated user's ID, while +// comment activities record the project_members row ID instead — so the +// join differs by topic. An AI agent actor is recorded separately in +// payload["actor_agent_id"]. Returns actorType "" when the event has no +// resolvable actor at all (id and name fall back to "" and "Someone"). +func (p *webhookPlugin) resolveActor(topic string, payload map[string]any) (name, id, actorType string) { if agentID, ok := payload["actor_agent_id"].(string); ok && agentID != "" { - if name := p.lookupName(`SELECT name FROM agents WHERE id = $1`, agentID); name != "" { - return name + name = p.lookupName(`SELECT name FROM agents WHERE id = $1`, agentID) + if name == "" { + name = "An agent" } - return "An agent" + return name, agentID, "agent" } actorID, _ := payload["actor_id"].(string) if actorID == "" { - return "Someone" + return "Someone", "", "" } - var name string switch topic { case "task.comment.added", "task.comment.updated", "task.comment.deleted", "comment": name = p.lookupName( @@ -617,9 +636,9 @@ func (p *webhookPlugin) actorName(topic string, payload map[string]any) string { name = p.lookupName(`SELECT full_name AS name FROM users WHERE id = $1`, actorID) } if name == "" { - return "Someone" + name = "Someone" } - return name + return name, actorID, "user" } // 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 { return fmt.Sprintf("task %q", title) } +// taskAliasForPayload resolves the task's human-readable alias (e.g. +// "ABC-123") for the delivery payload's structured "task_alias" field, +// returning "" when the event has no task_id or the project has no +// task_id_prefix configured. +func (p *webhookPlugin) taskAliasForPayload(payload map[string]any) string { + taskID, _ := payload["task_id"].(string) + if taskID == "" { + return "" + } + result, err := p.db.Query(`SELECT task_number, project_id FROM tasks WHERE id = $1`, taskID) + if err != nil || len(result.Rows) == 0 { + return "" + } + sc := newRowScanner(result.Columns, result.Rows[0]) + return p.taskAlias(sc.str("project_id"), sc.intVal("task_number")) +} + // taskAlias formats a task's human-readable alias (e.g. "ABC-123") from its // project's task_id_prefix and the task's sequential task_number, or "" // 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 webhookID := sc.str("id") targetURL := sc.str("url") - data, text := p.buildEventData(eventType, payload) + data, text, meta := p.buildEventData(eventType, payload) if pname := p.projectName(payload); pname != "" { data["project_name"] = pname text = fmt.Sprintf("[%s] %s", pname, text) @@ -767,13 +803,22 @@ func (p *webhookPlugin) deliver(sc *scanner, eventType string, payload map[strin data["url"] = url text = text + " - " + url } + actor := map[string]any{"name": meta.ActorName} + if meta.ActorID != "" { + actor["id"] = meta.ActorID + } + if meta.ActorType != "" { + actor["type"] = meta.ActorType + } body, _ := json.Marshal(map[string]any{ "event": eventType, "webhook_id": webhookID, "text": text, "task_id": payload["task_id"], + "task_alias": meta.TaskAlias, "project_id": payload["project_id"], "actor_id": payload["actor_id"], + "actor": actor, "occurred_at": payload["created_at"], "data": data, "sent_at": nowStr(), From 25ccd11e8be0e19315e0f6749d587fed371339a1 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Tue, 30 Jun 2026 16:50:31 +0000 Subject: [PATCH 2/2] feat: update version to 0.1.3 in plugin.json --- plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index d6b9f45..7b0e879 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "id": "com.paca.webhook", "displayName": "Webhooks", "description": "Sends HTTP webhooks to a URL of your choice when task activity happens in a project.", - "version": "0.1.2", + "version": "0.1.3", "permissions": ["db.read", "db.write", "events.subscribe"], "backend": { "allowedConfigKeys": ["ENCRYPTION_KEY", "PUBLIC_URL"],