diff --git a/packages/openclaw/src/monitor/approval.test.ts b/packages/openclaw/src/monitor/approval.test.ts index 3780205b5f..5ebb7db5e3 100644 --- a/packages/openclaw/src/monitor/approval.test.ts +++ b/packages/openclaw/src/monitor/approval.test.ts @@ -1,6 +1,6 @@ -import { A2UI } from '@tloncorp/api'; import { describe, expect, it } from 'vitest'; +import { A2UI } from '../urbit/a2ui.js'; import { APPROVAL_TTL_MS, type DisplayContext, @@ -244,6 +244,67 @@ 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( diff --git a/packages/openclaw/src/monitor/approval.ts b/packages/openclaw/src/monitor/approval.ts index 03795c0ff2..d02a68a5bd 100644 --- a/packages/openclaw/src/monitor/approval.ts +++ b/packages/openclaw/src/monitor/approval.ts @@ -1,4 +1,3 @@ -import { A2UI } from '@tloncorp/api'; import { randomUUID } from 'node:crypto'; /** @@ -9,6 +8,7 @@ import { randomUUID } from 'node:crypto'; * (/allow, /reject, /ban). */ import { APPROVAL_TTL_MS, type PendingApproval } from '../settings.js'; +import { A2UI } from '../urbit/a2ui.js'; import { type TlonA2UIBlob, makeA2UIBlob } from '../urbit/blob.js'; export type { PendingApproval }; @@ -225,6 +225,7 @@ type ApprovalA2UIParams = { groupName?: string; groupFlag?: string; groupTitle?: string; + sourceTarget?: A2UI.NavigationTarget; }; function approvalRequesterName(params: ApprovalA2UIParams): string { @@ -320,12 +321,14 @@ function buildApprovalA2UIBlobFromParams( 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', @@ -348,6 +351,9 @@ function buildApprovalA2UIBlobFromParams( }, ] : []; + const sourceActionComponents: A2UI.Component[] = params.sourceTarget + ? [{ id: 'sourceAction', component: 'Row', children: ['viewMessage'] }] + : []; const components: A2UI.Component[] = [ { id: 'root', component: 'Card', child: 'body' }, @@ -371,6 +377,7 @@ function buildApprovalA2UIBlobFromParams( { id: 'titleDivider', component: 'Divider' }, ...contextComponents, ...copyComponents, + ...sourceActionComponents, { id: 'divider', component: 'Divider' }, { id: 'details', @@ -386,8 +393,29 @@ function buildApprovalA2UIBlobFromParams( { 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', @@ -473,6 +501,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 @@ -494,6 +556,7 @@ export function buildApprovalA2UIBlob( approval.groupTitle, ctx ), + sourceTarget: approvalSourceTarget(approval, ctx), }); } diff --git a/packages/openclaw/src/urbit/a2ui.ts b/packages/openclaw/src/urbit/a2ui.ts new file mode 100644 index 0000000000..36b74b5119 --- /dev/null +++ b/packages/openclaw/src/urbit/a2ui.ts @@ -0,0 +1,218 @@ +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/packages/openclaw/src/urbit/blob.ts b/packages/openclaw/src/urbit/blob.ts index 14ced401a5..875a021d4f 100644 --- a/packages/openclaw/src/urbit/blob.ts +++ b/packages/openclaw/src/urbit/blob.ts @@ -1,4 +1,4 @@ -import { A2UI, appendToPostBlob } from '@tloncorp/api'; +import { A2UI } from './a2ui.js'; export function serializeContextLensReferenceBlob( lensId: string, @@ -43,5 +43,5 @@ export function makeA2UIBlob( } export function serializeBlobField(entry: TlonA2UIBlob): string { - return appendToPostBlob(undefined, entry); + return JSON.stringify([entry]); }