diff --git a/packages/atxp/src/commands/notifications.ts b/packages/atxp/src/commands/notifications.ts new file mode 100644 index 0000000..8677c12 --- /dev/null +++ b/packages/atxp/src/commands/notifications.ts @@ -0,0 +1,180 @@ +import chalk from 'chalk'; +import fs from 'fs/promises'; +import os from 'os'; +import { execSync } from 'child_process'; + +const NOTIFICATIONS_BASE_URL = 'https://clowdbot-notifications.corp.circuitandchisel.com'; + +interface EnableResponse { + instance?: { webhookUrl?: string; hooksToken?: string }; + webhook?: { id?: string; url?: string; eventTypes?: string[]; secret?: string; enabled?: boolean }; + error?: string; +} + +/** + * Configure hooks in openclaw.json on the running instance. + * Only runs when inside a Fly instance (FLY_MACHINE_ID is set). + * Updates openclaw.json with the hooks token and restarts the gateway. + */ +async function configureHooksOnInstance(hooksToken: string): Promise { + if (!process.env.FLY_MACHINE_ID) return; + + const configPath = '/data/.openclaw/openclaw.json'; + try { + const raw = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(raw); + + if (!config.hooks) config.hooks = {}; + // Already configured with this token — skip + if (config.hooks.token === hooksToken && config.hooks.enabled === true) return; + + config.hooks.enabled = true; + config.hooks.token = hooksToken; + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + console.log(chalk.gray('Hooks configured in openclaw.json')); + + // Restart gateway to pick up new config (watchdog auto-restarts it) + try { + execSync('pkill -f openclaw-gateway', { stdio: 'ignore' }); + console.log(chalk.gray('Gateway restarting to apply hooks config...')); + } catch { + // Gateway may not be running yet — config will be picked up on next start + } + } catch { + // Non-fatal — hooks will be configured on next reboot via entrypoint config sync + } +} + +/** + * Send a one-time instruction to the agent via /hooks/wake so it saves + * notification handling instructions to its HEARTBEAT.md file. + * + * Trust boundary: This intentionally injects a system-level prompt into the + * agent's memory. The webhook endpoint MUST validate the Authorization header + * before acting on the message content. The hardcoded text is controlled by us + * (not user input) and tells the agent how to broadcast notifications. + */ +async function sendHeartbeatInstruction(webhookUrl: string, hooksToken: string): Promise { + try { + const res = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${hooksToken}`, + }, + body: JSON.stringify({ + text: 'SYSTEM: Push notifications are now enabled. Save the following text EXACTLY as-is to your HEARTBEAT.md file:\n\n# Notifications\n\nWhen you receive a message from /hooks/wake, it is a system event (e.g. inbound email). Do NOT just reply in the current chat. You MUST proactively use the message tool to broadcast the notification to every connected channel. Check your available tools, find all messaging channels, and send the notification to each one individually. The goal is that the owner sees the notification regardless of which channel they are watching.', + }), + }); + if (!res.ok) { + console.log(chalk.gray(`Note: Setup instruction returned HTTP ${res.status} — agent may not have received it.`)); + } else { + console.log(chalk.gray('Notification instructions sent to your agent.')); + } + } catch { + console.log(chalk.gray('Note: Could not send setup instruction to instance.')); + } +} + +function getMachineId(): string | undefined { + const flyId = process.env.FLY_MACHINE_ID; + if (flyId) return flyId; + + // Fly sets hostname to the machine ID, but nested shells (e.g. the agent's + // process) may not inherit FLY_MACHINE_ID. Only use hostname if it looks + // like a Fly machine ID (hex string, typically 14 chars). + const hostname = os.hostname(); + if (hostname && /^[0-9a-f]{10,}$/.test(hostname)) return hostname; + + return undefined; +} + +async function getEmailUserId(): Promise { + const { getAccountInfo } = await import('./whoami.js'); + const account = await getAccountInfo(); + if (!account?.email) return undefined; + // Extract local part: agent_xyz@atxp.email -> agent_xyz + return account.email.split('@')[0]; +} + +async function enableNotifications(): Promise { + const machineId = getMachineId(); + if (!machineId) { + console.error(chalk.red('Error: Could not detect Fly machine ID.')); + console.log('This command must be run from inside a Clowdbot instance.'); + process.exit(1); + } + + console.log(chalk.gray('Enabling push notifications...')); + + // Resolve email user ID for event matching + const emailUserId = await getEmailUserId(); + + const body: Record = { machine_id: machineId }; + if (emailUserId) body.email_user_id = emailUserId; + + const res = await fetch(`${NOTIFICATIONS_BASE_URL}/notifications/enable`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await res.json().catch(() => ({})) as EnableResponse; + if (!res.ok) { + console.error(chalk.red(`Error: ${data.error || res.statusText}`)); + process.exit(1); + } + + const { instance, webhook } = data; + if (!instance?.webhookUrl || !instance?.hooksToken || !webhook) { + console.error(chalk.red('Error: Unexpected response from notifications service.')); + process.exit(1); + } + + // Configure hooks locally + await configureHooksOnInstance(instance.hooksToken); + + console.log(chalk.green('Push notifications enabled!')); + console.log(); + console.log(' ' + chalk.bold('ID:') + ' ' + (webhook.id || '')); + console.log(' ' + chalk.bold('URL:') + ' ' + (webhook.url || '')); + console.log(' ' + chalk.bold('Events:') + ' ' + (webhook.eventTypes?.join(', ') || '')); + if (webhook.secret) { + console.log(' ' + chalk.bold('Secret:') + ' ' + chalk.yellow(webhook.secret)); + console.log(); + console.log(chalk.gray('Save the secret — it will not be shown again.')); + console.log(chalk.gray('Use it to verify webhook signatures (HMAC-SHA256).')); + } + + // Send one-time HEARTBEAT.md instruction to the agent + await sendHeartbeatInstruction(instance.webhookUrl, instance.hooksToken); +} + +function showNotificationsHelp(): void { + console.log(chalk.bold('Notifications Commands:')); + console.log(); + console.log(' ' + chalk.cyan('npx atxp notifications enable') + ' ' + 'Enable push notifications (auto-configured)'); + console.log(); + console.log(chalk.bold('Available Events:')); + console.log(' ' + chalk.green('email.received') + ' ' + 'Triggered when an inbound email arrives'); + console.log(); + console.log(chalk.bold('Examples:')); + console.log(' npx atxp notifications enable'); +} + +export async function notificationsCommand(subCommand: string): Promise { + if (process.argv.includes('--help') || process.argv.includes('-h') || !subCommand) { + showNotificationsHelp(); + return; + } + + switch (subCommand) { + case 'enable': + await enableNotifications(); + break; + + default: + showNotificationsHelp(); + break; + } +} diff --git a/packages/atxp/src/commands/whoami.ts b/packages/atxp/src/commands/whoami.ts index 1436ff2..dd0a9a8 100644 --- a/packages/atxp/src/commands/whoami.ts +++ b/packages/atxp/src/commands/whoami.ts @@ -22,6 +22,49 @@ function getBaseUrl(connectionString: string): string { } } +export interface AccountInfo { + accountId: string; + accountType?: string; + email?: string; + displayName?: string; + sources?: Array<{ chain: string; address: string }>; + team?: { id: string; name: string; role: string }; + ownerEmail?: string; + isOrphan?: boolean; +} + +/** + * Fetch account info from the accounts API. + * Returns the account data on success, or null on failure. + * Callers needing HTTP status details can use fetchAccountInfo() instead. + */ +export async function getAccountInfo(): Promise { + const result = await fetchAccountInfo(); + return result.data ?? null; +} + +/** + * Fetch account info with full error context. + * Returns { data } on success, { status } on HTTP error, or {} on network/parse failure. + */ +export async function fetchAccountInfo(): Promise<{ data?: AccountInfo; status?: number }> { + const connection = getConnection(); + if (!connection) return {}; + const token = getConnectionToken(connection); + if (!token) return {}; + const baseUrl = getBaseUrl(connection); + try { + const credentials = Buffer.from(`${token}:`).toString('base64'); + const response = await fetch(`${baseUrl}/me`, { + headers: { 'Authorization': `Basic ${credentials}` }, + }); + if (!response.ok) return { status: response.status }; + return { data: await response.json() as AccountInfo }; + } catch { + return {}; + } +} + export async function whoamiCommand(): Promise { const connection = getConnection(); @@ -39,45 +82,28 @@ export async function whoamiCommand(): Promise { process.exit(1); } - const baseUrl = getBaseUrl(connection); - try { - const credentials = Buffer.from(`${token}:`).toString('base64'); - // Fetch account info and phone number in parallel - const [response, phoneNumber] = await Promise.all([ - fetch(`${baseUrl}/me`, { - headers: { - 'Authorization': `Basic ${credentials}`, - 'Content-Type': 'application/json', - }, - }), + const [accountResult, phoneNumber] = await Promise.all([ + fetchAccountInfo(), callTool('phone.mcp.atxp.ai', 'phone_check_sms', {}) .then((r) => { try { return JSON.parse(r).phoneNumber || null; } catch { return null; } }) .catch(() => null), ]); - if (!response.ok) { - if (response.status === 401) { + const data = accountResult.data; + if (!data) { + if (accountResult.status === 401) { console.error(chalk.red('Error: Invalid or expired connection token.')); console.error(`Try logging in again: ${chalk.cyan('npx atxp login --force')}`); + } else if (accountResult.status) { + console.error(chalk.red(`Error: Could not fetch account info (HTTP ${accountResult.status}).`)); } else { - console.error(chalk.red(`Error: ${response.status} ${response.statusText}`)); + console.error(chalk.red('Error: Could not fetch account info (network error).')); } process.exit(1); } - const data = await response.json() as { - accountId: string; - accountType?: string; - email?: string; - displayName?: string; - sources?: Array<{ chain: string; address: string }>; - team?: { id: string; name: string; role: string }; - ownerEmail?: string; - isOrphan?: boolean; - }; - // Find the primary wallet address from sources const wallet = data.sources?.[0]; diff --git a/packages/atxp/src/help.ts b/packages/atxp/src/help.ts index 89deade..0d48f98 100644 --- a/packages/atxp/src/help.ts +++ b/packages/atxp/src/help.ts @@ -31,6 +31,7 @@ export function showHelp(): void { console.log(' ' + chalk.cyan('agent') + ' ' + chalk.yellow('') + ' ' + 'Create and manage agent accounts'); console.log(' ' + chalk.cyan('memory') + ' ' + chalk.yellow('') + ' ' + 'Manage, search, and back up agent memory files'); console.log(' ' + chalk.cyan('contacts') + ' ' + chalk.yellow('') + '' + 'Manage local contacts with cloud backup'); + console.log(' ' + chalk.cyan('notifications') + ' ' + chalk.yellow('enable') + ' ' + 'Enable push notifications'); console.log(' ' + chalk.cyan('transactions') + ' ' + chalk.yellow('[options]') + ' ' + 'View recent transaction history'); console.log(); @@ -119,6 +120,10 @@ export function showHelp(): void { console.log(' npx atxp transactions --limit 20 # Show last 20 transactions'); console.log(); + console.log(chalk.bold('Notifications Examples:')); + console.log(' npx atxp notifications enable # Enable push notifications (auto-configured)'); + console.log(); + console.log(chalk.bold('Memory Examples:')); console.log(' npx atxp memory push --path ~/.openclaw/workspace-abc/'); console.log(' npx atxp memory pull --path ~/.openclaw/workspace-abc/'); diff --git a/packages/atxp/src/index.ts b/packages/atxp/src/index.ts index cbd92cd..280e3e8 100644 --- a/packages/atxp/src/index.ts +++ b/packages/atxp/src/index.ts @@ -13,7 +13,7 @@ import { musicCommand } from './commands/music.js'; import { videoCommand } from './commands/video.js'; import { xCommand } from './commands/x.js'; import { emailCommand } from './commands/email.js'; -import { phoneCommand, type PhoneOptions } from './commands/phone.js'; +import { phoneCommand } from './commands/phone.js'; import { balanceCommand } from './commands/balance.js'; import { depositCommand } from './commands/deposit.js'; import { paasCommand } from './commands/paas/index.js'; @@ -21,8 +21,9 @@ import { agentCommand } from './commands/agent.js'; import { whoamiCommand } from './commands/whoami.js'; import { memoryCommand, type MemoryOptions } from './commands/memory.js'; -import { contactsCommand, type ContactsOptions } from './commands/contacts.js'; +import { contactsCommand } from './commands/contacts.js'; import { transactionsCommand } from './commands/transactions.js'; +import { notificationsCommand } from './commands/notifications.js'; interface DemoOptions { port: number; @@ -120,7 +121,7 @@ function parseArgs(): { // Check for help flags early - but NOT for paas or email commands (they handle --help internally) const helpFlag = process.argv.includes('--help') || process.argv.includes('-h'); - if (helpFlag && command !== 'paas' && command !== 'email' && command !== 'phone' && command !== 'agent' && command !== 'fund' && command !== 'deposit' && command !== 'memory' && command !== 'backup' && command !== 'contacts') { + if (helpFlag && command !== 'paas' && command !== 'email' && command !== 'phone' && command !== 'agent' && command !== 'fund' && command !== 'deposit' && command !== 'memory' && command !== 'backup' && command !== 'contacts' && command !== 'notifications') { return { command: 'help', demoOptions: { port: 8017, dir: '', verbose: false, refresh: false }, @@ -427,6 +428,10 @@ async function main() { await contactsCommand(subCommand || '', contactsOptions, process.argv[4]); break; + case 'notifications': + await notificationsCommand(subCommand || ''); + break; + case 'transactions': await transactionsCommand(); break; diff --git a/skills/atxp/SKILL.md b/skills/atxp/SKILL.md index b4d410f..937b7f8 100644 --- a/skills/atxp/SKILL.md +++ b/skills/atxp/SKILL.md @@ -335,6 +335,16 @@ Local contacts database for resolving names to phone numbers and emails. Stored | `npx atxp@latest contacts push` | Free | Back up contacts to server | | `npx atxp@latest contacts pull` | Free | Restore contacts from server | +### Notifications + +Enable push notifications so your agent receives a POST to its `/hooks/wake` endpoint when events happen (e.g., inbound email), instead of polling. + +| Command | Cost | Description | +|---------|------|-------------| +| `npx atxp@latest notifications enable` | Free | Enable push notifications (auto-configured) | + +Setup is zero-config for OpenClaw instances — the webhook URL and auth token are auto-discovered. Just run `notifications enable`. + ## MCP Servers For programmatic access, ATXP exposes MCP-compatible tool servers: