Skip to content
Merged
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
132 changes: 19 additions & 113 deletions packages/atxp/src/commands/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import { execSync } from 'child_process';
const NOTIFICATIONS_BASE_URL = 'https://clowdbot-notifications.corp.circuitandchisel.com';
const OPENCLAW_CONFIG_PATH = '/data/.openclaw/openclaw.json';
const SESSIONS_PATH = '/data/.openclaw/agents/main/sessions/sessions.json';
const WORKSPACE_DIR = '/data/.openclaw/workspace';
const HEARTBEAT_SECTION_HEADER = '# ATXP Notification Relay';

// eslint-disable-next-line no-control-regex
const sanitizeSessionValue = (s: string) => s.replace(/[\x00-\x1f"`[\]]/g, '');
Expand Down Expand Up @@ -56,128 +54,29 @@ async function discoverConnectedChannels(): Promise<NotificationChannel[]> {
}

/**
* Build the HEARTBEAT.md content with notification relay instructions.
* The default heartbeat prompt says "Read HEARTBEAT.md if it exists. Follow it strictly."
* so writing these instructions directly to the file ensures the LLM relays
* system events to all discovered channels.
*/
function buildHeartbeatMd(channels: NotificationChannel[]): string {
const lines = [
HEARTBEAT_SECTION_HEADER,
'',
'When you see System: event lines about new emails or SMS messages,',
'you MUST immediately relay each one to the user.',
'Do NOT reply HEARTBEAT_OK when System events are present.',
'',
];

if (channels.length > 0) {
lines.push('Relay to ALL of these channels:');
for (const c of channels) {
lines.push(`- Use the message tool with channel=\`${c.channel}\` and target=\`${c.to}\``);
}
lines.push('');
}

return lines.join('\n');
}

/**
* Configure hooks, heartbeat delivery target, and HEARTBEAT.md on the instance.
* Configure hooks token on the instance.
* Only runs when inside a Fly instance (FLY_MACHINE_ID is set).
*
* Discovers all connected messaging channels from the session store, writes
* HEARTBEAT.md with relay instructions for each channel, and sets the primary
* delivery target to the first discovered channel.
* Sets hooks.enabled and hooks.token in openclaw.json, restarts gateway if changed.
*/
async function configureHooksOnInstance(hooksToken: string): Promise<void> {
if (!process.env.FLY_MACHINE_ID) return;

const configPath = OPENCLAW_CONFIG_PATH;
try {
const raw = await fs.readFile(configPath, 'utf-8');
const raw = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
const config = JSON.parse(raw);

// Discover connected channels from session store
const channels = await discoverConnectedChannels();

let changed = false;

// Configure hooks
if (!config.hooks) config.hooks = {};
if (config.hooks.token !== hooksToken || config.hooks.enabled !== true) {
config.hooks.enabled = true;
config.hooks.token = hooksToken;
changed = true;
}
if (config.hooks.token === hooksToken && config.hooks.enabled === true) return;

// Set primary delivery target to first discovered channel
if (!config.agents) config.agents = {};
if (!config.agents.defaults) config.agents.defaults = {};
if (!config.agents.defaults.heartbeat) config.agents.defaults.heartbeat = {};
const hb = config.agents.defaults.heartbeat;
config.hooks.enabled = true;
config.hooks.token = hooksToken;
await fs.writeFile(OPENCLAW_CONFIG_PATH, JSON.stringify(config, null, 2));

if (channels.length > 0) {
const primary = channels[0];
if (hb.target !== primary.channel || hb.to !== primary.to) {
hb.target = primary.channel;
hb.to = primary.to;
changed = true;
}
} else if (hb.target !== 'last') {
// No channels discovered — fall back to 'last' and clear stale target
hb.target = 'last';
delete hb.to;
changed = true;
}

if (changed) {
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
console.log(chalk.gray('Hooks and heartbeat configured in openclaw.json'));
}

// Append notification relay instructions to HEARTBEAT.md.
// The default heartbeat prompt reads this file and follows it strictly.
await fs.mkdir(WORKSPACE_DIR, { recursive: true });
const heartbeatPath = `${WORKSPACE_DIR}/HEARTBEAT.md`;
const section = buildHeartbeatMd(channels);
let existing = '';
try { existing = await fs.readFile(heartbeatPath, 'utf-8'); } catch { /* file may not exist */ }
// Replace existing notification section or append if not present.
// Uses split-on-header to avoid regex edge cases with anchors/newlines.
const idx = existing.indexOf(HEARTBEAT_SECTION_HEADER);
if (idx !== -1) {
let before = existing.slice(0, idx);
// Ensure a newline separates preceding content from our section
if (before.length > 0 && !before.endsWith('\n')) before += '\n';
const afterHeader = existing.slice(idx + HEARTBEAT_SECTION_HEADER.length);
// Find next top-level heading. Assumes a preceding newline (standard markdown).
const nextHeading = afterHeader.search(/\n# /);
const after = nextHeading !== -1 ? afterHeader.slice(nextHeading) : '';
await fs.writeFile(heartbeatPath, before + section.trimEnd() + after);
} else {
const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : existing.length > 0 ? '\n' : '';
await fs.writeFile(heartbeatPath, existing + separator + section);
}
console.log(chalk.gray('HEARTBEAT.md updated with notification relay instructions'));

if (channels.length > 0) {
console.log(chalk.gray(`Notification channels: ${channels.map(c => `${c.channel}:${c.to}`).join(', ')}`));
}

// Restart gateway to pick up new config (watchdog auto-restarts it)
if (changed) {
try {
execSync('pkill -f openclaw-gateway', { stdio: 'ignore' });
console.log(chalk.gray('Gateway restarting to apply config...'));
} catch {
// Gateway may not be running yet — config will be picked up on next start
}
}
try {
execSync('pkill -f openclaw-gateway', { stdio: 'ignore' });
} catch { /* gateway may not be running */ }
} catch (err) {
console.log(chalk.yellow('Warning: Could not configure instance locally.'));
console.log(chalk.gray(`${err instanceof Error ? err.message : err}`));
console.log(chalk.gray('Hooks will be configured on next instance reboot.'));
console.error(chalk.red(`Error configuring instance: ${err instanceof Error ? err.message : err}`));
}
}

Expand Down Expand Up @@ -214,11 +113,15 @@ async function enableNotifications(): Promise<void> {

console.log(chalk.gray('Enabling push notifications...'));

// Discover connected channels for delivery targeting
const channels = await discoverConnectedChannels();

// Resolve account ID for event matching
const accountId = await getAccountId();

const body: Record<string, string> = { machine_id: machineId };
const body: Record<string, unknown> = { machine_id: machineId };
if (accountId) body.account_id = accountId;
if (channels.length > 0) body.channels = channels;

let res: Response;
try {
Expand Down Expand Up @@ -253,6 +156,9 @@ async function enableNotifications(): Promise<void> {
console.log(' ' + chalk.bold('ID:') + ' ' + (webhook.id || ''));
console.log(' ' + chalk.bold('URL:') + ' ' + (webhook.url || ''));
console.log(' ' + chalk.bold('Events:') + ' ' + (webhook.eventTypes?.join(', ') || ''));
if (channels.length > 0) {
console.log(' ' + chalk.bold('Channels:') + ' ' + channels.map(c => `${c.channel}:${c.to}`).join(', '));
}
if (webhook.secret) {
console.log(' ' + chalk.bold('Secret:') + ' ' + chalk.yellow(webhook.secret));
console.log();
Expand Down
2 changes: 1 addition & 1 deletion skills/atxp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ Local contacts database for resolving names to phone numbers and emails. Stored

### Notifications

Enable push notifications so your agent receives a POST to its `/hooks/wake` endpoint when events happen (e.g., inbound email or SMS), instead of polling.
Enable push notifications so your agent receives a POST to its `/hooks/agent` endpoint when events happen (e.g., inbound email or SMS), instead of polling.

| Command | Cost | Description |
|---------|------|-------------|
Expand Down
Loading