Jean-Marc b52561bfa3
fix(synology-chat): prevent restart loop in startAccount (#23074)
* fix(synology-chat): prevent restart loop in startAccount

startAccount must return a Promise that stays pending while the channel
is running. The gateway wraps the return value in Promise.resolve(), and
when it resolves, the gateway thinks the channel crashed and auto-restarts
with exponential backoff (5s → 10s → 20s..., up to 10 attempts).

Replace the synchronous { stop } return with a Promise<void> that resolves
only when ctx.abortSignal fires, keeping the channel alive until shutdown.

Tested on Synology DS923+ with DSM 7.2 — single startup, no restart loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(synology-chat): add type guards for startAccount return value

startAccount returns `void | { stop: () => void }` — TypeScript requires
a type guard before accessing .stop on the union type. Added proper checks
in both integration and unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(synology-chat): use Readable stream in integration test for Windows compat

Replace EventEmitter + process.nextTick with Readable stream for
request body simulation. The process.nextTick approach caused the test
to hang on Windows CI (120s timeout) because events were not reliably
delivered to readBody() listeners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: stabilize synology gateway account lifecycle (#23074) (thanks @druide67)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-02 20:06:16 +00:00

380 lines
13 KiB
TypeScript

/**
* Synology Chat Channel Plugin for OpenClaw.
*
* Implements the ChannelPlugin interface following the LINE pattern.
*/
import {
DEFAULT_ACCOUNT_ID,
setAccountEnabledInConfigSection,
registerPluginHttpRoute,
buildChannelConfigSchema,
} from "openclaw/plugin-sdk";
import { z } from "zod";
import { listAccountIds, resolveAccount } from "./accounts.js";
import { sendMessage, sendFileUrl } from "./client.js";
import { getSynologyRuntime } from "./runtime.js";
import type { ResolvedSynologyChatAccount } from "./types.js";
import { createWebhookHandler } from "./webhook-handler.js";
const CHANNEL_ID = "synology-chat";
const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrough());
const activeRouteUnregisters = new Map<string, () => void>();
function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise<void> {
return new Promise((resolve) => {
const complete = () => {
onAbort?.();
resolve();
};
if (!signal) {
return;
}
if (signal.aborted) {
complete();
return;
}
signal.addEventListener("abort", complete, { once: true });
});
}
export function createSynologyChatPlugin() {
return {
id: CHANNEL_ID,
meta: {
id: CHANNEL_ID,
label: "Synology Chat",
selectionLabel: "Synology Chat (Webhook)",
detailLabel: "Synology Chat (Webhook)",
docsPath: "/channels/synology-chat",
blurb: "Connect your Synology NAS Chat to OpenClaw",
order: 90,
},
capabilities: {
chatTypes: ["direct" as const],
media: true,
threads: false,
reactions: false,
edit: false,
unsend: false,
reply: false,
effects: false,
blockStreaming: false,
},
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
configSchema: SynologyChatConfigSchema,
config: {
listAccountIds: (cfg: any) => listAccountIds(cfg),
resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
defaultAccountId: (_cfg: any) => DEFAULT_ACCOUNT_ID,
setAccountEnabled: ({ cfg, accountId, enabled }: any) => {
const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
[CHANNEL_ID]: { ...channelConfig, enabled },
},
};
}
return setAccountEnabledInConfigSection({
cfg,
sectionKey: `channels.${CHANNEL_ID}`,
accountId,
enabled,
});
},
},
pairing: {
idLabel: "synologyChatUserId",
normalizeAllowEntry: (entry: string) => entry.toLowerCase().trim(),
notifyApproval: async ({ cfg, id }: { cfg: any; id: string }) => {
const account = resolveAccount(cfg);
if (!account.incomingUrl) return;
await sendMessage(
account.incomingUrl,
"OpenClaw: your access has been approved.",
id,
account.allowInsecureSsl,
);
},
},
security: {
resolveDmPolicy: ({
cfg,
accountId,
account,
}: {
cfg: any;
accountId?: string | null;
account: ResolvedSynologyChatAccount;
}) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const channelCfg = (cfg as any).channels?.["synology-chat"];
const useAccountPath = Boolean(channelCfg?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.synology-chat.accounts.${resolvedAccountId}.`
: "channels.synology-chat.";
return {
policy: account.dmPolicy ?? "allowlist",
allowFrom: account.allowedUserIds ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: "openclaw pairing approve synology-chat <code>",
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
};
},
collectWarnings: ({ account }: { account: ResolvedSynologyChatAccount }) => {
const warnings: string[] = [];
if (!account.token) {
warnings.push(
"- Synology Chat: token is not configured. The webhook will reject all requests.",
);
}
if (!account.incomingUrl) {
warnings.push(
"- Synology Chat: incomingUrl is not configured. The bot cannot send replies.",
);
}
if (account.allowInsecureSsl) {
warnings.push(
"- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.",
);
}
if (account.dmPolicy === "open") {
warnings.push(
'- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.',
);
}
if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) {
warnings.push(
'- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".',
);
}
return warnings;
},
},
messaging: {
normalizeTarget: (target: string) => {
const trimmed = target.trim();
if (!trimmed) return undefined;
// Strip common prefixes
return trimmed.replace(/^synology[-_]?chat:/i, "").trim();
},
targetResolver: {
looksLikeId: (id: string) => {
const trimmed = id?.trim();
if (!trimmed) return false;
// Synology Chat user IDs are numeric
return /^\d+$/.test(trimmed) || /^synology[-_]?chat:/i.test(trimmed);
},
hint: "<userId>",
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
outbound: {
deliveryMode: "gateway" as const,
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }: any) => {
const account: ResolvedSynologyChatAccount = resolveAccount(cfg ?? {}, accountId);
if (!account.incomingUrl) {
throw new Error("Synology Chat incoming URL not configured");
}
const ok = await sendMessage(account.incomingUrl, text, to, account.allowInsecureSsl);
if (!ok) {
throw new Error("Failed to send message to Synology Chat");
}
return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to };
},
sendMedia: async ({ to, mediaUrl, accountId, cfg }: any) => {
const account: ResolvedSynologyChatAccount = resolveAccount(cfg ?? {}, accountId);
if (!account.incomingUrl) {
throw new Error("Synology Chat incoming URL not configured");
}
if (!mediaUrl) {
throw new Error("No media URL provided");
}
const ok = await sendFileUrl(account.incomingUrl, mediaUrl, to, account.allowInsecureSsl);
if (!ok) {
throw new Error("Failed to send media to Synology Chat");
}
return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to };
},
},
gateway: {
startAccount: async (ctx: any) => {
const { cfg, accountId, log } = ctx;
const account = resolveAccount(cfg, accountId);
if (!account.enabled) {
log?.info?.(`Synology Chat account ${accountId} is disabled, skipping`);
return waitUntilAbort(ctx.abortSignal);
}
if (!account.token || !account.incomingUrl) {
log?.warn?.(
`Synology Chat account ${accountId} not fully configured (missing token or incomingUrl)`,
);
return waitUntilAbort(ctx.abortSignal);
}
if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) {
log?.warn?.(
`Synology Chat account ${accountId} has dmPolicy=allowlist but empty allowedUserIds; refusing to start route`,
);
return waitUntilAbort(ctx.abortSignal);
}
log?.info?.(
`Starting Synology Chat channel (account: ${accountId}, path: ${account.webhookPath})`,
);
const handler = createWebhookHandler({
account,
deliver: async (msg) => {
const rt = getSynologyRuntime();
const currentCfg = await rt.config.loadConfig();
// The Chat API user_id (for sending) may differ from the webhook
// user_id (used for sessions/pairing). Use chatUserId for API calls.
const sendUserId = msg.chatUserId ?? msg.from;
// Build MsgContext using SDK's finalizeInboundContext for proper normalization
const msgCtx = rt.channel.reply.finalizeInboundContext({
Body: msg.body,
RawBody: msg.body,
CommandBody: msg.body,
From: `synology-chat:${msg.from}`,
To: `synology-chat:${msg.from}`,
SessionKey: msg.sessionKey,
AccountId: account.accountId,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: `synology-chat:${msg.from}`,
ChatType: msg.chatType,
SenderName: msg.senderName,
SenderId: msg.from,
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
ConversationLabel: msg.senderName || msg.from,
Timestamp: Date.now(),
CommandAuthorized: true,
});
// Dispatch via the SDK's buffered block dispatcher
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: msgCtx,
cfg: currentCfg,
dispatcherOptions: {
deliver: async (payload: { text?: string; body?: string }) => {
const text = payload?.text ?? payload?.body;
if (text) {
await sendMessage(
account.incomingUrl,
text,
sendUserId,
account.allowInsecureSsl,
);
}
},
onReplyStart: () => {
log?.info?.(`Agent reply started for ${msg.from}`);
},
},
});
return null;
},
log,
});
// Deregister any stale route from a previous start (e.g. on auto-restart)
// to avoid "already registered" collisions that trigger infinite loops.
const routeKey = `${accountId}:${account.webhookPath}`;
const prevUnregister = activeRouteUnregisters.get(routeKey);
if (prevUnregister) {
log?.info?.(`Deregistering stale route before re-registering: ${account.webhookPath}`);
prevUnregister();
activeRouteUnregisters.delete(routeKey);
}
const unregister = registerPluginHttpRoute({
path: account.webhookPath,
auth: "plugin",
replaceExisting: true,
pluginId: CHANNEL_ID,
accountId: account.accountId,
log: (msg: string) => log?.info?.(msg),
handler,
});
activeRouteUnregisters.set(routeKey, unregister);
log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`);
// Keep alive until abort signal fires.
// The gateway expects a Promise that stays pending while the channel is running.
// Resolving immediately triggers a restart loop.
return waitUntilAbort(ctx.abortSignal, () => {
log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`);
if (typeof unregister === "function") unregister();
activeRouteUnregisters.delete(routeKey);
});
},
stopAccount: async (ctx: any) => {
ctx.log?.info?.(`Synology Chat account ${ctx.accountId} stopped`);
},
},
agentPrompt: {
messageToolHints: () => [
"",
"### Synology Chat Formatting",
"Synology Chat supports limited formatting. Use these patterns:",
"",
"**Links**: Use `<URL|display text>` to create clickable links.",
" Example: `<https://example.com|Click here>` renders as a clickable link.",
"",
"**File sharing**: Include a publicly accessible URL to share files or images.",
" The NAS will download and attach the file (max 32 MB).",
"",
"**Limitations**:",
"- No markdown, bold, italic, or code blocks",
"- No buttons, cards, or interactive elements",
"- No message editing after send",
"- Keep messages under 2000 characters for best readability",
"",
"**Best practices**:",
"- Use short, clear responses (Synology Chat has a minimal UI)",
"- Use line breaks to separate sections",
"- Use numbered or bulleted lists for clarity",
"- Wrap URLs with `<URL|label>` for user-friendly links",
],
},
};
}