Files
openclaw/extensions/synology-chat/src/channel.ts
2026-03-23 00:01:07 -07:00

352 lines
13 KiB
TypeScript

/**
* Synology Chat Channel Plugin for OpenClaw.
*
* Implements the ChannelPlugin interface following the LINE pattern.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
import {
createHybridChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
import {
composeWarningCollectors,
createConditionalWarningCollector,
projectAccountConfigWarningCollector,
projectAccountWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/core";
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
import { listAccountIds, resolveAccount } from "./accounts.js";
import { sendMessage, sendFileUrl } from "./client.js";
import { SynologyChatChannelConfigSchema } from "./config-schema.js";
import {
collectSynologyGatewayRoutingWarnings,
registerSynologyWebhookRoute,
validateSynologyGatewayAccountStartup,
} from "./gateway-runtime.js";
import { synologyChatSetupAdapter, synologyChatSetupWizard } from "./setup-surface.js";
import type { ResolvedSynologyChatAccount } from "./types.js";
const CHANNEL_ID = "synology-chat";
const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynologyChatAccount>({
channelKey: CHANNEL_ID,
resolvePolicy: (account) => account.dmPolicy,
resolveAllowFrom: (account) => account.allowedUserIds,
policyPathSuffix: "dmPolicy",
defaultPolicy: "allowlist",
approveHint: "openclaw pairing approve synology-chat <code>",
normalizeEntry: (raw) => raw.toLowerCase().trim(),
});
type SynologyChannelGatewayContext = {
cfg: OpenClawConfig;
accountId: string;
abortSignal: AbortSignal;
log?: {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
};
type SynologyChannelOutboundContext = {
cfg: OpenClawConfig;
to: string;
text?: string;
mediaUrl?: string;
accountId?: string | null;
};
type SynologyChannelSendTextContext = SynologyChannelOutboundContext & { text: string };
type SynologyChannelSendMediaContext = SynologyChannelOutboundContext & { mediaUrl: string };
type SynologySecurityWarningContext = {
cfg: OpenClawConfig;
account: ResolvedSynologyChatAccount;
};
const synologyChatConfigAdapter = createHybridChannelConfigAdapter<ResolvedSynologyChatAccount>({
sectionKey: CHANNEL_ID,
listAccountIds,
resolveAccount,
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
clearBaseFields: [
"token",
"incomingUrl",
"nasHost",
"webhookPath",
"dangerouslyAllowNameMatching",
"dangerouslyAllowInheritedWebhookPath",
"dmPolicy",
"allowedUserIds",
"rateLimitPerMinute",
"botName",
"allowInsecureSsl",
],
resolveAllowFrom: (account) => account.allowedUserIds,
formatAllowFrom: (allowFrom) =>
allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean),
});
const collectSynologyChatSecurityWarnings =
createConditionalWarningCollector<ResolvedSynologyChatAccount>(
(account) =>
!account.token &&
"- Synology Chat: token is not configured. The webhook will reject all requests.",
(account) =>
!account.incomingUrl &&
"- Synology Chat: incomingUrl is not configured. The bot cannot send replies.",
(account) =>
account.allowInsecureSsl &&
"- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.",
(account) =>
account.dangerouslyAllowNameMatching &&
"- Synology Chat: dangerouslyAllowNameMatching=true re-enables mutable username/nickname recipient matching for replies. Prefer stable numeric user IDs.",
(account) =>
account.dangerouslyAllowInheritedWebhookPath &&
account.webhookPathSource === "inherited-base" &&
"- Synology Chat: dangerouslyAllowInheritedWebhookPath=true opts a named account into a shared inherited webhook path. Prefer an explicit per-account webhookPath.",
(account) =>
account.dmPolicy === "open" &&
'- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.',
(account) =>
account.dmPolicy === "allowlist" &&
account.allowedUserIds.length === 0 &&
'- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".',
);
type SynologyChatOutboundResult = {
channel: typeof CHANNEL_ID;
messageId: string;
chatId: string;
};
type SynologyChatPlugin = Omit<
ChannelPlugin<ResolvedSynologyChatAccount>,
"pairing" | "security" | "messaging" | "directory" | "outbound" | "gateway" | "agentPrompt"
> & {
pairing: {
idLabel: string;
normalizeAllowEntry?: (entry: string) => string;
notifyApproval: (params: { cfg: OpenClawConfig; id: string }) => Promise<void>;
};
security: {
resolveDmPolicy: (params: { cfg: OpenClawConfig; account: ResolvedSynologyChatAccount }) => {
policy: string | null | undefined;
allowFrom?: Array<string | number>;
normalizeEntry?: (raw: string) => string;
} | null;
collectWarnings: (params: {
cfg: OpenClawConfig;
account: ResolvedSynologyChatAccount;
}) => string[];
};
messaging: {
normalizeTarget: (target: string) => string | undefined;
targetResolver: {
looksLikeId: (id: string) => boolean;
hint: string;
};
};
directory: {
self?: NonNullable<ChannelPlugin<ResolvedSynologyChatAccount>["directory"]>["self"];
listPeers?: NonNullable<ChannelPlugin<ResolvedSynologyChatAccount>["directory"]>["listPeers"];
listGroups?: NonNullable<ChannelPlugin<ResolvedSynologyChatAccount>["directory"]>["listGroups"];
};
outbound: {
deliveryMode: "gateway";
textChunkLimit: number;
sendText: (ctx: SynologyChannelSendTextContext) => Promise<SynologyChatOutboundResult>;
sendMedia: (ctx: SynologyChannelOutboundContext) => Promise<SynologyChatOutboundResult>;
};
gateway: {
startAccount: (ctx: SynologyChannelGatewayContext) => Promise<unknown>;
stopAccount: (ctx: SynologyChannelGatewayContext) => Promise<void>;
};
agentPrompt: {
messageToolHints: () => string[];
};
};
const collectSynologyChatRoutingWarnings = projectAccountConfigWarningCollector<
ResolvedSynologyChatAccount,
OpenClawConfig,
SynologySecurityWarningContext
>(
(cfg) => cfg,
({ account, cfg }) => collectSynologyGatewayRoutingWarnings({ account, cfg }),
);
function resolveOutboundAccount(
cfg: OpenClawConfig,
accountId?: string | null,
): ResolvedSynologyChatAccount {
return resolveAccount(cfg ?? {}, accountId);
}
function requireIncomingUrl(account: ResolvedSynologyChatAccount): string {
if (!account.incomingUrl) {
throw new Error("Synology Chat incoming URL not configured");
}
return account.incomingUrl;
}
export function createSynologyChatPlugin(): SynologyChatPlugin {
return createChatChannelPlugin({
base: {
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: SynologyChatChannelConfigSchema,
setup: synologyChatSetupAdapter,
setupWizard: synologyChatSetupWizard,
config: {
...synologyChatConfigAdapter,
},
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: createEmptyChannelDirectoryAdapter(),
gateway: {
startAccount: async (ctx: SynologyChannelGatewayContext) => {
const { cfg, accountId, log, abortSignal } = ctx;
const account = resolveAccount(cfg, accountId);
if (!validateSynologyGatewayAccountStartup({ cfg, account, accountId, log }).ok) {
return waitUntilAbort(abortSignal);
}
log?.info?.(
`Starting Synology Chat channel (account: ${accountId}, path: ${account.webhookPath})`,
);
const unregister = registerSynologyWebhookRoute({ account, accountId, log });
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(abortSignal, () => {
log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`);
unregister();
});
},
stopAccount: async (ctx: SynologyChannelGatewayContext) => {
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",
],
},
},
pairing: {
text: {
idLabel: "synologyChatUserId",
message: "OpenClaw: your access has been approved.",
normalizeAllowEntry: (entry: string) => entry.toLowerCase().trim(),
notify: async ({ cfg, id, message }) => {
const account = resolveAccount(cfg);
if (!account.incomingUrl) return;
await sendMessage(account.incomingUrl, message, id, account.allowInsecureSsl);
},
},
},
security: {
resolveDmPolicy: resolveSynologyChatDmPolicy,
collectWarnings: composeWarningCollectors(
projectAccountWarningCollector<ResolvedSynologyChatAccount, SynologySecurityWarningContext>(
collectSynologyChatSecurityWarnings,
),
collectSynologyChatRoutingWarnings,
),
},
outbound: {
deliveryMode: "gateway" as const,
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }: SynologyChannelSendTextContext) => {
const account = resolveOutboundAccount(cfg ?? {}, accountId);
const incomingUrl = requireIncomingUrl(account);
const ok = await sendMessage(incomingUrl, text, to, account.allowInsecureSsl);
if (!ok) {
throw new Error("Failed to send message to Synology Chat");
}
return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to });
},
sendMedia: async ({ to, mediaUrl, accountId, cfg }: SynologyChannelOutboundContext) => {
const account = resolveOutboundAccount(cfg ?? {}, accountId);
const incomingUrl = requireIncomingUrl(account);
if (!mediaUrl) {
throw new Error("No media URL provided");
}
const ok = await sendFileUrl(incomingUrl, mediaUrl, to, account.allowInsecureSsl);
if (!ok) {
throw new Error("Failed to send media to Synology Chat");
}
return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to });
},
},
}) as unknown as SynologyChatPlugin;
}
export const synologyChatPlugin = createSynologyChatPlugin();