From 86c9d43c9766db762c96b1afde61d7c9e220bb56 Mon Sep 17 00:00:00 2001 From: operator Date: Sat, 18 Apr 2026 20:28:59 +0000 Subject: [PATCH] feat: Discord-based agent wakeup replacing spawn New wakeup flow: 1. Create private Discord channel for the agent 2. Send wakeup message with slot context + workflow reference 3. If Dirigent detected (globalThis.__dirigent), create work-type channel 4. Fallback to api.spawn if Discord not configured New config fields: discordBotToken, discordGuildId New file: plugin/calendar/discord-wakeup.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- plugin/calendar/discord-wakeup.ts | 172 ++++++++++++++++++++++++++++++ plugin/calendar/index.ts | 1 + plugin/index.ts | 57 +++++----- plugin/openclaw.plugin.json | 8 ++ 4 files changed, 209 insertions(+), 29 deletions(-) create mode 100644 plugin/calendar/discord-wakeup.ts diff --git a/plugin/calendar/discord-wakeup.ts b/plugin/calendar/discord-wakeup.ts new file mode 100644 index 0000000..01ca1b3 --- /dev/null +++ b/plugin/calendar/discord-wakeup.ts @@ -0,0 +1,172 @@ +/** + * Discord-based agent wakeup: create a private channel and send a wakeup message. + * + * If Dirigent is detected (via globalThis.__dirigent), creates a work-type channel. + * Otherwise, creates a plain private Discord channel. + */ + +const DISCORD_API = 'https://discord.com/api/v10'; + +interface WakeupConfig { + botToken: string; + guildId: string; + agentDiscordId?: string; + agentId: string; + message: string; + logger: { + info: (...args: any[]) => void; + warn: (...args: any[]) => void; + error: (...args: any[]) => void; + }; +} + +interface DirigentApi { + createWorkChannel?: (params: { + guildId: string; + name: string; + agentDiscordId: string; + }) => Promise; +} + +/** + * Get bot user ID from token (decode JWT-like Discord token). + */ +function getBotUserIdFromToken(token: string): string | null { + try { + const base64 = token.split('.')[0]; + const decoded = Buffer.from(base64, 'base64').toString('utf8'); + return decoded || null; + } catch { + return null; + } +} + +/** + * Create a private Discord channel visible only to the target agent and bot. + */ +async function createPrivateChannel( + token: string, + guildId: string, + name: string, + memberIds: string[], + logger: WakeupConfig['logger'] +): Promise { + const botId = getBotUserIdFromToken(token); + + // Permission overwrites: deny @everyone, allow specific members + const permissionOverwrites = [ + { id: guildId, type: 0, deny: '1024' }, // deny @everyone view + ...memberIds.map(id => ({ id, type: 1, allow: '1024' })), // allow members view + ...(botId ? [{ id: botId, type: 1, allow: '1024' }] : []), + ]; + + try { + const res = await fetch(`${DISCORD_API}/guilds/${guildId}/channels`, { + method: 'POST', + headers: { + 'Authorization': `Bot ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name, + type: 0, // text channel + permission_overwrites: permissionOverwrites, + }), + }); + + if (!res.ok) { + logger.warn(`Discord channel creation failed: ${res.status} ${await res.text()}`); + return null; + } + + const data = await res.json() as { id: string }; + return data.id; + } catch (err) { + logger.error(`Discord channel creation error: ${String(err)}`); + return null; + } +} + +/** + * Send a message to a Discord channel. + */ +async function sendMessage( + token: string, + channelId: string, + content: string, + logger: WakeupConfig['logger'] +): Promise { + try { + const res = await fetch(`${DISCORD_API}/channels/${channelId}/messages`, { + method: 'POST', + headers: { + 'Authorization': `Bot ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content }), + }); + + if (!res.ok) { + logger.warn(`Discord message send failed: ${res.status}`); + return false; + } + return true; + } catch (err) { + logger.error(`Discord message send error: ${String(err)}`); + return false; + } +} + +/** + * Wake an agent via Discord: create a private channel and send the wakeup message. + */ +export async function wakeAgentViaDiscord(config: WakeupConfig): Promise { + const { botToken, guildId, agentDiscordId, agentId, message, logger } = config; + + if (!botToken || !guildId) { + logger.warn('Discord wakeup: botToken or guildId not configured'); + return false; + } + + // Check if Dirigent is available for work channel creation + const dirigent = (globalThis as Record)['__dirigent'] as DirigentApi | undefined; + + let channelId: string | null = null; + const channelName = `hf-wakeup-${agentId}-${Date.now()}`; + + if (dirigent?.createWorkChannel && agentDiscordId) { + // Use Dirigent to create a work-type channel (with turn management) + try { + channelId = await dirigent.createWorkChannel({ + guildId, + name: channelName, + agentDiscordId, + }); + logger.info(`Wakeup channel created via Dirigent: ${channelId}`); + } catch (err) { + logger.warn(`Dirigent work channel creation failed, falling back to plain channel: ${String(err)}`); + } + } + + if (!channelId) { + // Fallback: create a plain private Discord channel + const memberIds = agentDiscordId ? [agentDiscordId] : []; + channelId = await createPrivateChannel(botToken, guildId, channelName, memberIds, logger); + if (channelId) { + logger.info(`Wakeup channel created (plain): ${channelId}`); + } + } + + if (!channelId) { + logger.error('Failed to create wakeup channel'); + return false; + } + + // Send the wakeup message + const sent = await sendMessage(botToken, channelId, message, logger); + if (sent) { + logger.info(`Wakeup message sent to ${channelId} for agent ${agentId}`); + } + + return sent; +} diff --git a/plugin/calendar/index.ts b/plugin/calendar/index.ts index a829ccf..05eebe5 100644 --- a/plugin/calendar/index.ts +++ b/plugin/calendar/index.ts @@ -32,3 +32,4 @@ export * from './types'; export * from './calendar-bridge'; export * from './scheduler'; export * from './schedule-cache'; +export * from './discord-wakeup'; diff --git a/plugin/index.ts b/plugin/index.ts index ecc0287..6f84171 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -188,53 +188,52 @@ export default { } /** - * Wake/spawn agent with task context for slot execution. + * Wake agent via Discord channel creation + message. * This is the callback invoked by CalendarScheduler when a slot is ready. + * + * Priority: + * 1. Discord wakeup (create private channel + send message) + * 2. OpenClaw spawn API (fallback if Discord not configured) */ async function wakeAgent(context: AgentWakeContext): Promise { logger.info(`Waking agent for slot: ${context.taskDescription}`); + const live = resolveConfig(); + const agentId = process.env.AGENT_ID || 'unknown'; try { - // Method 1: Use OpenClaw spawn API if available (preferred) + // Method 1: Discord wakeup (preferred) + const discordBotToken = (live as any).discordBotToken as string | undefined; + const discordGuildId = (live as any).discordGuildId as string | undefined; + + if (discordBotToken && discordGuildId) { + const { wakeAgentViaDiscord } = await import('./calendar/discord-wakeup.js'); + const success = await wakeAgentViaDiscord({ + botToken: discordBotToken, + guildId: discordGuildId, + agentId, + message: context.prompt, + logger, + }); + if (success) return true; + logger.warn('Discord wakeup failed, trying spawn fallback'); + } + + // Method 2: OpenClaw spawn API (fallback) if (api.spawn) { const result = await api.spawn({ task: context.prompt, - timeoutSeconds: context.slot.estimated_duration * 60, // Convert to seconds + timeoutSeconds: context.slot.estimated_duration * 60, }); if (result?.sessionId) { logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`); - - // Track session completion trackSessionCompletion(result.sessionId, context); return true; } } - // Method 2: Send notification/alert to wake agent (fallback) - // This relies on the agent's heartbeat to check for notifications - logger.warn('OpenClaw spawn API not available, using notification fallback'); - - // Send calendar wakeup notification via backend - const live = resolveConfig(); - const agentId = process.env.AGENT_ID || 'unknown'; - - const notifyResponse = await fetch(`${live.backendUrl}/calendar/agent/notify`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Agent-ID': agentId, - 'X-Claw-Identifier': live.identifier || hostname(), - }, - body: JSON.stringify({ - agent_id: agentId, - message: context.prompt, - slot_id: context.slot.id || context.slot.virtual_id, - task_description: context.taskDescription, - }), - }); - - return notifyResponse.ok; + logger.warn('No wakeup method available (configure discordBotToken + discordGuildId)'); + return false; } catch (err) { logger.error('Failed to wake agent:', err); diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 186c986..e9f0fdd 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -63,6 +63,14 @@ "managedMonitor": { "type": "string", "description": "Absolute path to an installed HarborForge.Monitor binary managed by this plugin installer. If set, gateway_start/gateway_stop hooks will start/stop the monitor process automatically." + }, + "discordBotToken": { + "type": "string", + "description": "Discord bot token for agent wakeup. Used to create private channels and send wakeup messages. Set to the same value as Dirigent moderator bot token." + }, + "discordGuildId": { + "type": "string", + "description": "Discord guild ID where wakeup channels are created." } } }