Two bugs that prevented turn-manager dormancy from ever triggering: 1. isEmptyTurn too strict: agents output multi-line text ending with "NO_REPLY" on the last line, but the regex ^NO_REPLY$ required the entire string to match. Now checks only the last non-empty line. 2. blocked_pending counter inflation: non-speaker suppressions incremented the counter but their stale NO_REPLYs were discarded at the !isCurrentSpeaker early return without decrementing. Over a full cycle the counter inflated by the number of suppressions, causing the agent's real empty turn to be misidentified as stale when it finally arrived. Fix: at both early-return points in agent_end (!isCurrentSpeaker and !isTurnPending), drain blocked_pending when the turn is empty. Also fixed: pollForTailMatch now uses any-message detection (instead of tail-fingerprint content matching) with a 30 s timeout, avoiding infinite polling when agents send concise Discord messages after verbose LLM output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
273 lines
9.9 KiB
TypeScript
273 lines
9.9 KiB
TypeScript
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
|
|
type Logger = { info: (m: string) => void; warn: (m: string) => void };
|
|
|
|
export function userIdFromBotToken(token: string): string | undefined {
|
|
try {
|
|
const segment = token.split(".")[0];
|
|
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
|
return Buffer.from(padded, "base64").toString("utf8");
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string | undefined {
|
|
const root = (api.config as Record<string, unknown>) || {};
|
|
const channels = (root.channels as Record<string, unknown>) || {};
|
|
const discord = (channels.discord as Record<string, unknown>) || {};
|
|
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
|
const acct = accounts[accountId];
|
|
if (!acct?.token || typeof acct.token !== "string") return undefined;
|
|
return userIdFromBotToken(acct.token);
|
|
}
|
|
|
|
export type ModeratorMessageResult =
|
|
| { ok: true; status: number; channelId: string; messageId?: string }
|
|
| { ok: false; status?: number; channelId: string; error: string };
|
|
|
|
export async function sendModeratorMessage(
|
|
token: string,
|
|
channelId: string,
|
|
content: string,
|
|
logger: Logger,
|
|
): Promise<ModeratorMessageResult> {
|
|
try {
|
|
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bot ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
|
|
const text = await r.text();
|
|
let json: Record<string, unknown> | null = null;
|
|
try {
|
|
json = text ? (JSON.parse(text) as Record<string, unknown>) : null;
|
|
} catch {
|
|
json = null;
|
|
}
|
|
|
|
if (!r.ok) {
|
|
const error = `discord api error (${r.status}): ${text || "<empty response>"}`;
|
|
logger.warn(`dirigent: moderator send failed channel=${channelId} ${error}`);
|
|
return { ok: false, status: r.status, channelId, error };
|
|
}
|
|
|
|
const messageId = typeof json?.id === "string" ? json.id : undefined;
|
|
logger.info(`dirigent: moderator message sent to channel=${channelId} messageId=${messageId ?? "unknown"}`);
|
|
return { ok: true, status: r.status, channelId, messageId };
|
|
} catch (err) {
|
|
const error = String(err);
|
|
logger.warn(`dirigent: moderator send error channel=${channelId}: ${error}`);
|
|
return { ok: false, channelId, error };
|
|
}
|
|
}
|
|
|
|
/** Delete a Discord message. */
|
|
export async function deleteMessage(
|
|
token: string,
|
|
channelId: string,
|
|
messageId: string,
|
|
logger: Logger,
|
|
): Promise<void> {
|
|
try {
|
|
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bot ${token}` },
|
|
});
|
|
if (!r.ok) {
|
|
logger.warn(`dirigent: deleteMessage failed channel=${channelId} msg=${messageId} status=${r.status}`);
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`dirigent: deleteMessage error: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-channel last schedule-trigger message ID.
|
|
* Stored on globalThis so it survives VM-context hot-reloads.
|
|
* Used by sendScheduleTrigger to delete the PREVIOUS trigger when a new one is sent.
|
|
*/
|
|
const _LAST_TRIGGER_KEY = "_dirigentLastTriggerMsgId";
|
|
if (!(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY]) {
|
|
(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] = new Map<string, string>();
|
|
}
|
|
const lastTriggerMsgId: Map<string, string> = (globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] as Map<string, string>;
|
|
|
|
/**
|
|
* Send a schedule-identifier trigger message, then delete the PREVIOUS one for
|
|
* this channel (so only the current trigger is visible at any time).
|
|
*
|
|
* In debugMode the previous message is NOT deleted, leaving a full trigger
|
|
* history visible in Discord for inspection.
|
|
*/
|
|
export async function sendScheduleTrigger(
|
|
token: string,
|
|
channelId: string,
|
|
content: string,
|
|
logger: Logger,
|
|
debugMode = false,
|
|
): Promise<void> {
|
|
const prevMsgId = lastTriggerMsgId.get(channelId);
|
|
const result = await sendModeratorMessage(token, channelId, content, logger);
|
|
if (!result.ok || !result.messageId) return;
|
|
|
|
if (!debugMode) {
|
|
// Track the new message so the NEXT call can delete it
|
|
lastTriggerMsgId.set(channelId, result.messageId);
|
|
// Delete the previous trigger with a small delay
|
|
if (prevMsgId) {
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
await deleteMessage(token, channelId, prevMsgId, logger);
|
|
}
|
|
}
|
|
// debugMode: don't track, don't delete — every trigger stays in history
|
|
}
|
|
|
|
/** Get the latest message ID in a channel (for use as poll anchor). */
|
|
export async function getLatestMessageId(
|
|
token: string,
|
|
channelId: string,
|
|
): Promise<string | undefined> {
|
|
try {
|
|
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages?limit=1`, {
|
|
headers: { Authorization: `Bot ${token}` },
|
|
});
|
|
if (!r.ok) return undefined;
|
|
const msgs = (await r.json()) as Array<{ id: string }>;
|
|
return msgs[0]?.id;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
type DiscordMessage = { id: string; author: { id: string }; content: string };
|
|
|
|
/**
|
|
* Poll the channel until any message from agentDiscordUserId with id > anchorId
|
|
* appears. The anchor is set in before_model_resolve just before the LLM call,
|
|
* so any agent message after it must belong to this turn.
|
|
*
|
|
* Uses exponential back-off starting at initialPollMs, doubling each miss,
|
|
* capped at maxPollMs. Times out after timeoutMs (default 30 s) to avoid
|
|
* getting permanently stuck when the agent's Discord message is never delivered.
|
|
*
|
|
* @returns { matched: true } when found, { interrupted: true } when aborted,
|
|
* { matched: false, interrupted: false } on timeout.
|
|
*/
|
|
export async function pollForTailMatch(opts: {
|
|
token: string;
|
|
channelId: string;
|
|
anchorId: string;
|
|
agentDiscordUserId: string;
|
|
/** Initial poll interval in ms (default 800). */
|
|
initialPollMs?: number;
|
|
/** Maximum poll interval in ms (default 8 000). */
|
|
maxPollMs?: number;
|
|
/** Give up and return after this many ms (default 30 000). */
|
|
timeoutMs?: number;
|
|
/** Callback checked before each poll; if true, polling is aborted. */
|
|
isInterrupted?: () => boolean;
|
|
}): Promise<{ matched: boolean; interrupted: boolean }> {
|
|
const {
|
|
token, channelId, anchorId, agentDiscordUserId,
|
|
initialPollMs = 800,
|
|
maxPollMs = 8_000,
|
|
timeoutMs = 30_000,
|
|
isInterrupted = () => false,
|
|
} = opts;
|
|
|
|
let interval = initialPollMs;
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
while (true) {
|
|
if (isInterrupted()) return { matched: false, interrupted: true };
|
|
if (Date.now() >= deadline) return { matched: false, interrupted: false };
|
|
|
|
try {
|
|
const r = await fetch(
|
|
`https://discord.com/api/v10/channels/${channelId}/messages?limit=20`,
|
|
{ headers: { Authorization: `Bot ${token}` } },
|
|
);
|
|
if (r.ok) {
|
|
const msgs = (await r.json()) as DiscordMessage[];
|
|
const found = msgs.some(
|
|
(m) => m.author.id === agentDiscordUserId && BigInt(m.id) > BigInt(anchorId),
|
|
);
|
|
if (found) return { matched: true, interrupted: false };
|
|
}
|
|
} catch {
|
|
// ignore transient errors, keep polling
|
|
}
|
|
|
|
await new Promise((r) => setTimeout(r, interval));
|
|
// Exponential back-off, capped at maxPollMs
|
|
interval = Math.min(interval * 2, maxPollMs);
|
|
}
|
|
}
|
|
|
|
/** Create a Discord channel in a guild. Returns the new channel ID or throws. */
|
|
export async function createDiscordChannel(opts: {
|
|
token: string;
|
|
guildId: string;
|
|
name: string;
|
|
/** Permission overwrites: [{id, type (0=role/1=member), allow, deny}] */
|
|
permissionOverwrites?: Array<{ id: string; type: number; allow?: string; deny?: string }>;
|
|
logger: Logger;
|
|
}): Promise<string> {
|
|
const { token, guildId, name, permissionOverwrites = [], logger } = opts;
|
|
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name, type: 0, permission_overwrites: permissionOverwrites }),
|
|
});
|
|
const json = (await r.json()) as Record<string, unknown>;
|
|
if (!r.ok) {
|
|
const err = `Discord channel create failed (${r.status}): ${JSON.stringify(json)}`;
|
|
logger.warn(`dirigent: ${err}`);
|
|
throw new Error(err);
|
|
}
|
|
const channelId = json.id as string;
|
|
logger.info(`dirigent: created Discord channel ${name} id=${channelId} in guild=${guildId}`);
|
|
return channelId;
|
|
}
|
|
|
|
/** Fetch guilds where the moderator bot has admin permissions. */
|
|
export async function fetchAdminGuilds(token: string): Promise<Array<{ id: string; name: string }>> {
|
|
const r = await fetch("https://discord.com/api/v10/users/@me/guilds", {
|
|
headers: { Authorization: `Bot ${token}` },
|
|
});
|
|
if (!r.ok) return [];
|
|
const guilds = (await r.json()) as Array<{ id: string; name: string; permissions: string }>;
|
|
const ADMIN = 8n;
|
|
return guilds.filter((g) => (BigInt(g.permissions ?? "0") & ADMIN) === ADMIN);
|
|
}
|
|
|
|
/** Fetch private group channels in a guild visible to the moderator bot. */
|
|
export async function fetchGuildChannels(
|
|
token: string,
|
|
guildId: string,
|
|
): Promise<Array<{ id: string; name: string; type: number }>> {
|
|
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
|
|
headers: { Authorization: `Bot ${token}` },
|
|
});
|
|
if (!r.ok) return [];
|
|
const channels = (await r.json()) as Array<{ id: string; name: string; type: number }>;
|
|
// type 0 = GUILD_TEXT; filter to text channels only (group private channels are type 0)
|
|
return channels.filter((c) => c.type === 0);
|
|
}
|
|
|
|
/** Get bot's own Discord user ID from token. */
|
|
export function getBotUserIdFromToken(token: string): string | undefined {
|
|
try {
|
|
const segment = token.split(".")[0];
|
|
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
|
|
return Buffer.from(padded, "base64").toString("utf8");
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|