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) <noreply@anthropic.com>
This commit is contained in:
172
plugin/calendar/discord-wakeup.ts
Normal file
172
plugin/calendar/discord-wakeup.ts
Normal file
@@ -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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string, unknown>)['__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;
|
||||||
|
}
|
||||||
@@ -32,3 +32,4 @@ export * from './types';
|
|||||||
export * from './calendar-bridge';
|
export * from './calendar-bridge';
|
||||||
export * from './scheduler';
|
export * from './scheduler';
|
||||||
export * from './schedule-cache';
|
export * from './schedule-cache';
|
||||||
|
export * from './discord-wakeup';
|
||||||
|
|||||||
@@ -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.
|
* 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<boolean> {
|
async function wakeAgent(context: AgentWakeContext): Promise<boolean> {
|
||||||
logger.info(`Waking agent for slot: ${context.taskDescription}`);
|
logger.info(`Waking agent for slot: ${context.taskDescription}`);
|
||||||
|
const live = resolveConfig();
|
||||||
|
const agentId = process.env.AGENT_ID || 'unknown';
|
||||||
|
|
||||||
try {
|
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) {
|
if (api.spawn) {
|
||||||
const result = await api.spawn({
|
const result = await api.spawn({
|
||||||
task: context.prompt,
|
task: context.prompt,
|
||||||
timeoutSeconds: context.slot.estimated_duration * 60, // Convert to seconds
|
timeoutSeconds: context.slot.estimated_duration * 60,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.sessionId) {
|
if (result?.sessionId) {
|
||||||
logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`);
|
logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`);
|
||||||
|
|
||||||
// Track session completion
|
|
||||||
trackSessionCompletion(result.sessionId, context);
|
trackSessionCompletion(result.sessionId, context);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method 2: Send notification/alert to wake agent (fallback)
|
logger.warn('No wakeup method available (configure discordBotToken + discordGuildId)');
|
||||||
// This relies on the agent's heartbeat to check for notifications
|
return false;
|
||||||
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;
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to wake agent:', err);
|
logger.error('Failed to wake agent:', err);
|
||||||
|
|||||||
@@ -63,6 +63,14 @@
|
|||||||
"managedMonitor": {
|
"managedMonitor": {
|
||||||
"type": "string",
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user