|
| 1 | +import chalk from 'chalk'; |
| 2 | +import fs from 'fs/promises'; |
| 3 | +import os from 'os'; |
| 4 | +import { execSync } from 'child_process'; |
| 5 | + |
| 6 | +const NOTIFICATIONS_BASE_URL = 'https://clowdbot-notifications.corp.circuitandchisel.com'; |
| 7 | + |
| 8 | +interface EnableResponse { |
| 9 | + instance?: { webhookUrl?: string; hooksToken?: string }; |
| 10 | + webhook?: { id?: string; url?: string; eventTypes?: string[]; secret?: string; enabled?: boolean }; |
| 11 | + error?: string; |
| 12 | +} |
| 13 | + |
| 14 | +/** |
| 15 | + * Configure hooks in openclaw.json on the running instance. |
| 16 | + * Only runs when inside a Fly instance (FLY_MACHINE_ID is set). |
| 17 | + * Updates openclaw.json with the hooks token and restarts the gateway. |
| 18 | + */ |
| 19 | +async function configureHooksOnInstance(hooksToken: string): Promise<void> { |
| 20 | + if (!process.env.FLY_MACHINE_ID) return; |
| 21 | + |
| 22 | + const configPath = '/data/.openclaw/openclaw.json'; |
| 23 | + try { |
| 24 | + const raw = await fs.readFile(configPath, 'utf-8'); |
| 25 | + const config = JSON.parse(raw); |
| 26 | + |
| 27 | + if (!config.hooks) config.hooks = {}; |
| 28 | + // Already configured with this token — skip |
| 29 | + if (config.hooks.token === hooksToken && config.hooks.enabled === true) return; |
| 30 | + |
| 31 | + config.hooks.enabled = true; |
| 32 | + config.hooks.token = hooksToken; |
| 33 | + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); |
| 34 | + console.log(chalk.gray('Hooks configured in openclaw.json')); |
| 35 | + |
| 36 | + // Restart gateway to pick up new config (watchdog auto-restarts it) |
| 37 | + try { |
| 38 | + execSync('pkill -f openclaw-gateway', { stdio: 'ignore' }); |
| 39 | + console.log(chalk.gray('Gateway restarting to apply hooks config...')); |
| 40 | + } catch { |
| 41 | + // Gateway may not be running yet — config will be picked up on next start |
| 42 | + } |
| 43 | + } catch { |
| 44 | + // Non-fatal — hooks will be configured on next reboot via entrypoint config sync |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * Send a one-time instruction to the agent via /hooks/wake so it saves |
| 50 | + * notification handling instructions to its HEARTBEAT.md file. |
| 51 | + * |
| 52 | + * Trust boundary: This intentionally injects a system-level prompt into the |
| 53 | + * agent's memory. The webhook endpoint MUST validate the Authorization header |
| 54 | + * before acting on the message content. The hardcoded text is controlled by us |
| 55 | + * (not user input) and tells the agent how to broadcast notifications. |
| 56 | + */ |
| 57 | +async function sendHeartbeatInstruction(webhookUrl: string, hooksToken: string): Promise<void> { |
| 58 | + try { |
| 59 | + const res = await fetch(webhookUrl, { |
| 60 | + method: 'POST', |
| 61 | + headers: { |
| 62 | + 'Content-Type': 'application/json', |
| 63 | + 'Authorization': `Bearer ${hooksToken}`, |
| 64 | + }, |
| 65 | + body: JSON.stringify({ |
| 66 | + 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.', |
| 67 | + }), |
| 68 | + }); |
| 69 | + if (!res.ok) { |
| 70 | + console.log(chalk.gray(`Note: Setup instruction returned HTTP ${res.status} — agent may not have received it.`)); |
| 71 | + } else { |
| 72 | + console.log(chalk.gray('Notification instructions sent to your agent.')); |
| 73 | + } |
| 74 | + } catch { |
| 75 | + console.log(chalk.gray('Note: Could not send setup instruction to instance.')); |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +function getMachineId(): string | undefined { |
| 80 | + const flyId = process.env.FLY_MACHINE_ID; |
| 81 | + if (flyId) return flyId; |
| 82 | + |
| 83 | + // Fly sets hostname to the machine ID, but nested shells (e.g. the agent's |
| 84 | + // process) may not inherit FLY_MACHINE_ID. Only use hostname if it looks |
| 85 | + // like a Fly machine ID (hex string, typically 14 chars). |
| 86 | + const hostname = os.hostname(); |
| 87 | + if (hostname && /^[0-9a-f]{10,}$/.test(hostname)) return hostname; |
| 88 | + |
| 89 | + return undefined; |
| 90 | +} |
| 91 | + |
| 92 | +async function getEmailUserId(): Promise<string | undefined> { |
| 93 | + const { getAccountInfo } = await import('./whoami.js'); |
| 94 | + const account = await getAccountInfo(); |
| 95 | + if (!account?.email) return undefined; |
| 96 | + // Extract local part: agent_xyz@atxp.email -> agent_xyz |
| 97 | + return account.email.split('@')[0]; |
| 98 | +} |
| 99 | + |
| 100 | +async function enableNotifications(): Promise<void> { |
| 101 | + const machineId = getMachineId(); |
| 102 | + if (!machineId) { |
| 103 | + console.error(chalk.red('Error: Could not detect Fly machine ID.')); |
| 104 | + console.log('This command must be run from inside a Clowdbot instance.'); |
| 105 | + process.exit(1); |
| 106 | + } |
| 107 | + |
| 108 | + console.log(chalk.gray('Enabling push notifications...')); |
| 109 | + |
| 110 | + // Resolve email user ID for event matching |
| 111 | + const emailUserId = await getEmailUserId(); |
| 112 | + |
| 113 | + const body: Record<string, string> = { machine_id: machineId }; |
| 114 | + if (emailUserId) body.email_user_id = emailUserId; |
| 115 | + |
| 116 | + const res = await fetch(`${NOTIFICATIONS_BASE_URL}/notifications/enable`, { |
| 117 | + method: 'POST', |
| 118 | + headers: { 'Content-Type': 'application/json' }, |
| 119 | + body: JSON.stringify(body), |
| 120 | + }); |
| 121 | + |
| 122 | + const data = await res.json().catch(() => ({})) as EnableResponse; |
| 123 | + if (!res.ok) { |
| 124 | + console.error(chalk.red(`Error: ${data.error || res.statusText}`)); |
| 125 | + process.exit(1); |
| 126 | + } |
| 127 | + |
| 128 | + const { instance, webhook } = data; |
| 129 | + if (!instance?.webhookUrl || !instance?.hooksToken || !webhook) { |
| 130 | + console.error(chalk.red('Error: Unexpected response from notifications service.')); |
| 131 | + process.exit(1); |
| 132 | + } |
| 133 | + |
| 134 | + // Configure hooks locally |
| 135 | + await configureHooksOnInstance(instance.hooksToken); |
| 136 | + |
| 137 | + console.log(chalk.green('Push notifications enabled!')); |
| 138 | + console.log(); |
| 139 | + console.log(' ' + chalk.bold('ID:') + ' ' + (webhook.id || '')); |
| 140 | + console.log(' ' + chalk.bold('URL:') + ' ' + (webhook.url || '')); |
| 141 | + console.log(' ' + chalk.bold('Events:') + ' ' + (webhook.eventTypes?.join(', ') || '')); |
| 142 | + if (webhook.secret) { |
| 143 | + console.log(' ' + chalk.bold('Secret:') + ' ' + chalk.yellow(webhook.secret)); |
| 144 | + console.log(); |
| 145 | + console.log(chalk.gray('Save the secret — it will not be shown again.')); |
| 146 | + console.log(chalk.gray('Use it to verify webhook signatures (HMAC-SHA256).')); |
| 147 | + } |
| 148 | + |
| 149 | + // Send one-time HEARTBEAT.md instruction to the agent |
| 150 | + await sendHeartbeatInstruction(instance.webhookUrl, instance.hooksToken); |
| 151 | +} |
| 152 | + |
| 153 | +function showNotificationsHelp(): void { |
| 154 | + console.log(chalk.bold('Notifications Commands:')); |
| 155 | + console.log(); |
| 156 | + console.log(' ' + chalk.cyan('npx atxp notifications enable') + ' ' + 'Enable push notifications (auto-configured)'); |
| 157 | + console.log(); |
| 158 | + console.log(chalk.bold('Available Events:')); |
| 159 | + console.log(' ' + chalk.green('email.received') + ' ' + 'Triggered when an inbound email arrives'); |
| 160 | + console.log(); |
| 161 | + console.log(chalk.bold('Examples:')); |
| 162 | + console.log(' npx atxp notifications enable'); |
| 163 | +} |
| 164 | + |
| 165 | +export async function notificationsCommand(subCommand: string): Promise<void> { |
| 166 | + if (process.argv.includes('--help') || process.argv.includes('-h') || !subCommand) { |
| 167 | + showNotificationsHelp(); |
| 168 | + return; |
| 169 | + } |
| 170 | + |
| 171 | + switch (subCommand) { |
| 172 | + case 'enable': |
| 173 | + await enableNotifications(); |
| 174 | + break; |
| 175 | + |
| 176 | + default: |
| 177 | + showNotificationsHelp(); |
| 178 | + break; |
| 179 | + } |
| 180 | +} |
0 commit comments