Compare commits
7 Commits
be30b4b3f4
...
zhi-2026-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d3d24b0ac | ||
|
|
86c9d43c97 | ||
|
|
4a66a7cc90 | ||
|
|
9af8204376 | ||
|
|
248adfaafd | ||
|
|
e4ac7b7af3 | ||
|
|
2088cd12b4 |
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* 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,4 +32,3 @@ export * from './types';
|
||||
export * from './calendar-bridge';
|
||||
export * from './scheduler';
|
||||
export * from './schedule-cache';
|
||||
export * from './discord-wakeup';
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface OpenClawAgentInfo {
|
||||
name: string;
|
||||
isDefault?: boolean;
|
||||
@@ -14,70 +9,38 @@ export interface OpenClawAgentInfo {
|
||||
routing?: string;
|
||||
}
|
||||
|
||||
export async function listOpenClawAgents(logger?: { debug?: (...args: any[]) => void; warn?: (...args: any[]) => void }): Promise<OpenClawAgentInfo[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('openclaw', ['agents', 'list'], {
|
||||
timeout: 15000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return parseOpenClawAgents(stdout);
|
||||
} catch (err) {
|
||||
logger?.warn?.('Failed to run `openclaw agents list`', err);
|
||||
return [];
|
||||
}
|
||||
export async function listOpenClawAgents(_logger?: { debug?: (...args: any[]) => void; warn?: (...args: any[]) => void }): Promise<OpenClawAgentInfo[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parseOpenClawAgents(text: string): OpenClawAgentInfo[] {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const out: OpenClawAgentInfo[] = [];
|
||||
let current: OpenClawAgentInfo | null = null;
|
||||
|
||||
const push = () => {
|
||||
if (current) out.push(current);
|
||||
current = null;
|
||||
};
|
||||
|
||||
const push = () => { if (current) out.push(current); current = null; };
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
if (!line.trim() || line.startsWith('Agents:') || line.startsWith('Routing rules map') || line.startsWith('Channel status reflects')) continue;
|
||||
if (line.startsWith('- ')) {
|
||||
if (!line.trim() || line.startsWith("Agents:") || line.startsWith("Routing rules map") || line.startsWith("Channel status reflects")) continue;
|
||||
if (line.startsWith("- ")) {
|
||||
push();
|
||||
const m = line.match(/^-\s+(.+?)(?:\s+\((default)\))?$/);
|
||||
current = {
|
||||
name: m?.[1] || line.slice(2).trim(),
|
||||
isDefault: m?.[2] === 'default',
|
||||
};
|
||||
current = { name: m?.[1] || line.slice(2).trim(), isDefault: m?.[2] === "default" };
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const trimmed = line.trim();
|
||||
const idx = trimmed.indexOf(':');
|
||||
const idx = trimmed.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
switch (key) {
|
||||
case 'Identity':
|
||||
current.identity = value;
|
||||
break;
|
||||
case 'Workspace':
|
||||
current.workspace = value;
|
||||
break;
|
||||
case 'Agent dir':
|
||||
current.agentDir = value;
|
||||
break;
|
||||
case 'Model':
|
||||
current.model = value;
|
||||
break;
|
||||
case 'Routing rules': {
|
||||
const n = Number(value);
|
||||
current.routingRules = Number.isFinite(n) ? n : undefined;
|
||||
break;
|
||||
}
|
||||
case 'Routing':
|
||||
current.routing = value;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
case "Identity": current.identity = value; break;
|
||||
case "Workspace": current.workspace = value; break;
|
||||
case "Agent dir": current.agentDir = value; break;
|
||||
case "Model": current.model = value; break;
|
||||
case "Routing rules": { const n = Number(value); current.routingRules = Number.isFinite(n) ? n : undefined; break; }
|
||||
case "Routing": current.routing = value; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
push();
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'os';
|
||||
import { getPluginConfig } from './core/config';
|
||||
import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge';
|
||||
import { listOpenClawAgents } from './core/openclaw-agents';
|
||||
import type { OpenClawAgentInfo } from './core/openclaw-agents';
|
||||
import { registerGatewayStartHook } from './hooks/gateway-start';
|
||||
import { registerGatewayStopHook } from './hooks/gateway-stop';
|
||||
import {
|
||||
@@ -32,6 +32,12 @@ interface PluginAPI {
|
||||
warn: (...args: any[]) => void;
|
||||
};
|
||||
version?: string;
|
||||
runtime?: {
|
||||
version?: string;
|
||||
config?: {
|
||||
loadConfig?: () => any;
|
||||
};
|
||||
};
|
||||
config?: Record<string, unknown>;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
on: (event: string, handler: () => void) => void;
|
||||
@@ -96,7 +102,7 @@ export default {
|
||||
avg15: load[2],
|
||||
},
|
||||
openclaw: {
|
||||
version: api.version || 'unknown',
|
||||
version: api.runtime?.version || api.version || 'unknown',
|
||||
pluginVersion: '0.3.1', // Bumped for PLG-CAL-004
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -118,10 +124,21 @@ export default {
|
||||
const bridgeClient = getBridgeClient();
|
||||
if (!bridgeClient) return;
|
||||
|
||||
let agentNames: string[] = [];
|
||||
try {
|
||||
const cfg = api.runtime?.config?.loadConfig?.();
|
||||
const agentsList = cfg?.agents?.list;
|
||||
if (Array.isArray(agentsList)) {
|
||||
agentNames = agentsList
|
||||
.map((a: any) => typeof a === 'string' ? a : a?.name)
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
const meta: OpenClawMeta = {
|
||||
version: api.version || 'unknown',
|
||||
version: api.runtime?.version || api.version || 'unknown',
|
||||
plugin_version: '0.3.1',
|
||||
agents: await listOpenClawAgents(logger),
|
||||
agents: agentNames.map(name => ({ name })),
|
||||
};
|
||||
|
||||
const ok = await bridgeClient.pushOpenClawMeta(meta);
|
||||
@@ -171,52 +188,53 @@ export default {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake agent via Discord channel creation + message.
|
||||
* Wake/spawn agent with task context for slot execution.
|
||||
* 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> {
|
||||
logger.info(`Waking agent for slot: ${context.taskDescription}`);
|
||||
const live = resolveConfig();
|
||||
const agentId = process.env.AGENT_ID || 'unknown';
|
||||
|
||||
try {
|
||||
// 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)
|
||||
// Method 1: Use OpenClaw spawn API if available (preferred)
|
||||
if (api.spawn) {
|
||||
const result = await api.spawn({
|
||||
task: context.prompt,
|
||||
timeoutSeconds: context.slot.estimated_duration * 60,
|
||||
timeoutSeconds: context.slot.estimated_duration * 60, // Convert to seconds
|
||||
});
|
||||
|
||||
if (result?.sessionId) {
|
||||
logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`);
|
||||
|
||||
// Track session completion
|
||||
trackSessionCompletion(result.sessionId, context);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('No wakeup method available (configure discordBotToken + discordGuildId)');
|
||||
return false;
|
||||
// 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;
|
||||
|
||||
} catch (err) {
|
||||
logger.error('Failed to wake agent:', err);
|
||||
|
||||
@@ -63,14 +63,6 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user