From f7a3f012623117ef43b751349b939ac9864db311 Mon Sep 17 00:00:00 2001 From: Dan Brewster Date: Fri, 29 May 2026 01:43:00 -0400 Subject: [PATCH] add approval source message links --- src/monitor/approval.test.ts | 61 ++++++++++- src/monitor/approval.ts | 63 ++++++++++- src/urbit/a2ui.ts | 196 +++++++++++++++++++++++++++++++++++ src/urbit/blob.ts | 4 +- 4 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 src/urbit/a2ui.ts diff --git a/src/monitor/approval.test.ts b/src/monitor/approval.test.ts index 2c0be34d..f2665285 100644 --- a/src/monitor/approval.test.ts +++ b/src/monitor/approval.test.ts @@ -1,5 +1,5 @@ -import { A2UI } from "@tloncorp/api"; import { describe, expect, it } from "vitest"; +import { A2UI } from "../urbit/a2ui.js"; import { type DisplayContext, type PendingApproval, @@ -200,6 +200,65 @@ describe("buildApprovalA2UIBlob", () => { } }); + it("adds view message navigation for dm and channel approvals with source messages", () => { + const dm = buildApprovalA2UIBlob({ + id: "da1b2", + type: "dm", + requestingShip: "~sampel-palnet", + timestamp: 1, + messagePreview: "Hello, I would like to chat with your bot.", + originalMessage: { + messageId: "170.141.184.507", + messageText: "Hello, I would like to chat with your bot.", + messageContent: [], + timestamp: 1, + }, + }); + const channel = buildApprovalA2UIBlob( + { + id: "c3d4e", + type: "channel", + requestingShip: "~littel-wolfur", + channelNest: "chat/~host/general", + timestamp: 1, + messagePreview: "@bot can you review this build before I merge?", + originalMessage: { + messageId: "170.141.184.621", + messageText: "@bot can you review this build before I merge?", + messageContent: [], + timestamp: 1, + parentId: "170.141.184.600", + }, + }, + ctx, + ); + + expect(A2UI.validateBlobEntry(dm)).toBe(true); + expect(A2UI.validateBlobEntry(channel)).toBe(true); + expect(JSON.stringify(dm)).toContain("View message"); + expect(JSON.stringify(dm)).toContain('"name":"tlon.navigate"'); + expect(JSON.stringify(dm)).toContain('"channelId":"~sampel-palnet"'); + expect(JSON.stringify(dm)).toContain('"postId":"170.141.184.507"'); + expect(JSON.stringify(channel)).toContain('"channelId":"chat/~host/general"'); + expect(JSON.stringify(channel)).toContain('"parentId":"170.141.184.600"'); + expect(JSON.stringify(channel)).toContain('"groupId":"~host/cool-group"'); + }); + + it("does not add view message navigation to group invites", () => { + const approval = buildApprovalA2UIBlob({ + id: "g5f6a", + type: "group", + requestingShip: "~robin-dasler", + groupFlag: "~robin-dasler/garden-club", + groupTitle: "Garden Club", + timestamp: 1, + }); + + expect(A2UI.validateBlobEntry(approval)).toBe(true); + expect(JSON.stringify(approval)).not.toContain("View message"); + expect(JSON.stringify(approval)).not.toContain("tlon.navigate"); + }); + it("formats the visible notification text by request type", () => { expect(formatApprovalRequestNotification({ type: "dm", diff --git a/src/monitor/approval.ts b/src/monitor/approval.ts index 2ab1e192..3cc54239 100644 --- a/src/monitor/approval.ts +++ b/src/monitor/approval.ts @@ -1,4 +1,3 @@ -import { A2UI } from "@tloncorp/api"; import { randomUUID } from "node:crypto"; /** * Approval system for managing DM, channel mention, and group invite approvals. @@ -8,6 +7,7 @@ import { randomUUID } from "node:crypto"; * (/allow, /reject, /ban). */ import type { PendingApproval } from "../settings.js"; +import { A2UI } from "../urbit/a2ui.js"; import { makeA2UIBlob, type TlonA2UIBlob } from "../urbit/blob.js"; export type { PendingApproval }; @@ -217,6 +217,7 @@ type ApprovalA2UIParams = { groupName?: string; groupFlag?: string; groupTitle?: string; + sourceTarget?: A2UI.NavigationTarget; }; function approvalRequesterName(params: ApprovalA2UIParams): string { @@ -310,12 +311,14 @@ function buildApprovalA2UIBlobFromParams(params: ApprovalA2UIParams): TlonA2UIBl const contextLines = approvalContextLines(params); const contextIds = contextLines.map((_, index) => `context${index}`); const copy = approvalCopy(params); + const actionChildren = ["allow", "reject", "ban"]; const bodyChildren = [ "eyebrow", "title", "titleDivider", ...contextIds, ...(copy ? ["copy"] : []), + ...(params.sourceTarget ? ["sourceAction"] : []), "divider", "details", "actions", @@ -336,6 +339,9 @@ function buildApprovalA2UIBlobFromParams(params: ApprovalA2UIParams): TlonA2UIBl }, ] : []; + const sourceActionComponents: A2UI.Component[] = params.sourceTarget + ? [{ id: "sourceAction", component: "Row", children: ["viewMessage"] }] + : []; const components: A2UI.Component[] = [ { id: "root", component: "Card", child: "body" }, @@ -359,6 +365,7 @@ function buildApprovalA2UIBlobFromParams(params: ApprovalA2UIParams): TlonA2UIBl { id: "titleDivider", component: "Divider" }, ...contextComponents, ...copyComponents, + ...sourceActionComponents, { id: "divider", component: "Divider" }, { id: "details", @@ -374,8 +381,25 @@ function buildApprovalA2UIBlobFromParams(params: ApprovalA2UIParams): TlonA2UIBl { id: "actions", component: "Row", - children: ["allow", "reject", "ban"], + children: actionChildren, }, + ...(params.sourceTarget + ? [ + { + id: "viewMessage", + component: "Button", + variant: "secondary", + child: "viewMessageLabel", + action: { + event: { + name: A2UI.action.navigate, + context: { target: params.sourceTarget }, + }, + }, + } as const, + { id: "viewMessageLabel", component: "Text", text: "View message" } as const, + ] + : []), { id: "allow", component: "Button", @@ -461,6 +485,40 @@ function displayGroupForApproval( return titleOverride || ctx?.groupNames?.get(flag) || flag; } +function approvalSourceTarget( + approval: PendingApproval, + ctx?: DisplayContext, +): A2UI.NavigationTarget | undefined { + const messageId = approval.originalMessage?.messageId; + if (!messageId) { + return undefined; + } + + const base = { + type: "message" as const, + postId: messageId, + authorId: approval.requestingShip, + parentId: approval.originalMessage?.parentId, + }; + + if (approval.type === "dm") { + return { + ...base, + channelId: approval.requestingShip, + }; + } + + if (approval.type === "channel" && approval.channelNest) { + return { + ...base, + channelId: approval.channelNest, + groupId: ctx?.channelGroups?.get(approval.channelNest), + }; + } + + return undefined; +} + export function buildApprovalA2UIBlob( approval: PendingApproval, ctx?: DisplayContext, @@ -478,6 +536,7 @@ export function buildApprovalA2UIBlob( groupFlag: approval.groupFlag, groupTitle: approval.groupTitle, groupName: displayGroupForApproval(approval.groupFlag, approval.groupTitle, ctx), + sourceTarget: approvalSourceTarget(approval, ctx), }); } diff --git a/src/urbit/a2ui.ts b/src/urbit/a2ui.ts new file mode 100644 index 00000000..e2b45bf1 --- /dev/null +++ b/src/urbit/a2ui.ts @@ -0,0 +1,196 @@ +export namespace A2UI { + export type Text = { + id: string; + component: "Text"; + text: string; + variant?: "caption" | "h3" | "h4"; + }; + + export type NavigationTarget = + | { + type: "message"; + channelId: string; + postId: string; + parentId?: string; + authorId?: string; + groupId?: string; + } + | { type: "channel"; channelId: string; groupId?: string; selectedPostId?: string } + | { type: "group"; groupId: string } + | { type: "profile"; userId: string; groupId?: string; channelId?: string } + | { type: "chatDetails"; chatType: "group" | "channel"; chatId: string; groupId?: string } + | { type: "chatVolume"; chatType: "group" | "channel"; chatId: string; groupId?: string }; + + export type ButtonAction = { + event: + | { + name: typeof action.sendMessage; + context: { text: string }; + } + | { + name: typeof action.navigate; + context: { target: NavigationTarget }; + }; + }; + + export type Button = { + id: string; + component: "Button"; + child: string; + variant?: "default" | "primary" | "secondary" | "borderless"; + action: ButtonAction; + }; + + export type Component = + | Text + | Button + | { + id: string; + component: "Card"; + child: string; + } + | { + id: string; + component: "Column" | "Row"; + children: string[]; + } + | { + id: string; + component: "Divider"; + }; + + export type CreateSurfaceMessage = { + version: "v0.9"; + createSurface: { + surfaceId: string; + catalogId: string; + }; + }; + + export type UpdateComponentsMessage = { + version: "v0.9"; + updateComponents: { + surfaceId: string; + root: string; + components: Component[]; + }; + }; + + export type BlobEntry = { + type: "a2ui"; + version: 1; + messages: Array; + }; + + export const action = { + sendMessage: "tlon.sendMessage", + navigate: "tlon.navigate", + } as const; + + function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } + + function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; + } + + function isOptionalString(value: unknown): boolean { + return value === undefined || isNonEmptyString(value); + } + + function isNavigationTarget(value: unknown): value is NavigationTarget { + if (!isObject(value)) { + return false; + } + switch (value.type) { + case "message": + return ( + isNonEmptyString(value.channelId) && + isNonEmptyString(value.postId) && + isOptionalString(value.parentId) && + isOptionalString(value.authorId) && + isOptionalString(value.groupId) + ); + case "channel": + return ( + isNonEmptyString(value.channelId) && + isOptionalString(value.groupId) && + isOptionalString(value.selectedPostId) + ); + case "group": + return isNonEmptyString(value.groupId); + case "profile": + return ( + isNonEmptyString(value.userId) && + isOptionalString(value.groupId) && + isOptionalString(value.channelId) + ); + case "chatDetails": + case "chatVolume": + return ( + (value.chatType === "group" || value.chatType === "channel") && + isNonEmptyString(value.chatId) && + isOptionalString(value.groupId) + ); + default: + return false; + } + } + + function isButtonAction(value: unknown): value is ButtonAction { + if (!isObject(value) || !isObject(value.event)) { + return false; + } + const { event } = value; + if (event.name === action.sendMessage) { + return isObject(event.context) && isNonEmptyString(event.context.text); + } + if (event.name === action.navigate) { + return isObject(event.context) && isNavigationTarget(event.context.target); + } + return false; + } + + function isComponent(value: unknown): value is Component { + if (!isObject(value) || typeof value.id !== "string" || typeof value.component !== "string") { + return false; + } + if (value.component === "Text") { + return typeof value.text === "string"; + } + if (value.component === "Button") { + return typeof value.child === "string" && isButtonAction(value.action); + } + if (value.component === "Card") { + return typeof value.child === "string"; + } + if (value.component === "Column" || value.component === "Row") { + return ( + Array.isArray(value.children) && value.children.every((child) => typeof child === "string") + ); + } + return value.component === "Divider"; + } + + export function validateBlobEntry(entry: unknown): entry is BlobEntry { + if (!isObject(entry) || entry.type !== "a2ui" || entry.version !== 1) { + return false; + } + if (!Array.isArray(entry.messages)) { + return false; + } + + return entry.messages.some((message) => { + return ( + isObject(message) && + message.version === "v0.9" && + isObject(message.updateComponents) && + typeof message.updateComponents.surfaceId === "string" && + typeof message.updateComponents.root === "string" && + Array.isArray(message.updateComponents.components) && + message.updateComponents.components.every(isComponent) + ); + }); + } +} diff --git a/src/urbit/blob.ts b/src/urbit/blob.ts index c60a33f5..a94808cf 100644 --- a/src/urbit/blob.ts +++ b/src/urbit/blob.ts @@ -1,4 +1,4 @@ -import { A2UI, appendToPostBlob } from "@tloncorp/api"; +import { A2UI } from "./a2ui.js"; export const TLON_A2UI_CATALOG_ID = "tlon.a2ui.basic.v1"; export type TlonA2UIBlob = A2UI.BlobEntry; @@ -29,5 +29,5 @@ export function makeA2UIBlob( } export function serializeBlobField(entry: TlonA2UIBlob): string { - return appendToPostBlob(undefined, entry); + return JSON.stringify([entry]); }