Skip to content
This repository was archived by the owner on Jun 16, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion src/monitor/approval.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
63 changes: 61 additions & 2 deletions src/monitor/approval.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 };
Expand Down Expand Up @@ -217,6 +217,7 @@ type ApprovalA2UIParams = {
groupName?: string;
groupFlag?: string;
groupTitle?: string;
sourceTarget?: A2UI.NavigationTarget;
};

function approvalRequesterName(params: ApprovalA2UIParams): string {
Expand Down Expand Up @@ -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",
Expand All @@ -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" },
Expand All @@ -359,6 +365,7 @@ function buildApprovalA2UIBlobFromParams(params: ApprovalA2UIParams): TlonA2UIBl
{ id: "titleDivider", component: "Divider" },
...contextComponents,
...copyComponents,
...sourceActionComponents,
{ id: "divider", component: "Divider" },
{
id: "details",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -478,6 +536,7 @@ export function buildApprovalA2UIBlob(
groupFlag: approval.groupFlag,
groupTitle: approval.groupTitle,
groupName: displayGroupForApproval(approval.groupFlag, approval.groupTitle, ctx),
sourceTarget: approvalSourceTarget(approval, ctx),
});
}

Expand Down
196 changes: 196 additions & 0 deletions src/urbit/a2ui.ts
Original file line number Diff line number Diff line change
@@ -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<CreateSurfaceMessage | UpdateComponentsMessage>;
};

export const action = {
sendMessage: "tlon.sendMessage",
navigate: "tlon.navigate",
} as const;

function isObject(value: unknown): value is Record<string, unknown> {
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)
);
});
}
}
Loading