271 lines
7.9 KiB
TypeScript
271 lines
7.9 KiB
TypeScript
/**
|
|
* Synology Chat HTTP client.
|
|
* Sends messages TO Synology Chat via the incoming webhook URL.
|
|
*/
|
|
|
|
import * as http from "node:http";
|
|
import * as https from "node:https";
|
|
|
|
const MIN_SEND_INTERVAL_MS = 500;
|
|
let lastSendTime = 0;
|
|
|
|
// --- Chat user_id resolution ---
|
|
// Synology Chat uses two different user_id spaces:
|
|
// - Outgoing webhook user_id: per-integration sequential ID (e.g. 1)
|
|
// - Chat API user_id: global internal ID (e.g. 4)
|
|
// The chatbot API (method=chatbot) requires the Chat API user_id in the
|
|
// user_ids array. We resolve via the user_list API and cache the result.
|
|
|
|
interface ChatUser {
|
|
user_id: number;
|
|
username: string;
|
|
nickname: string;
|
|
}
|
|
|
|
type ChatUserCacheEntry = {
|
|
users: ChatUser[];
|
|
cachedAt: number;
|
|
};
|
|
|
|
type ChatWebhookPayload = {
|
|
text?: string;
|
|
file_url?: string;
|
|
user_ids?: number[];
|
|
};
|
|
|
|
// Cache user lists per bot endpoint to avoid cross-account bleed.
|
|
const chatUserCache = new Map<string, ChatUserCacheEntry>();
|
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
|
/**
|
|
* Send a text message to Synology Chat via the incoming webhook.
|
|
*
|
|
* @param incomingUrl - Synology Chat incoming webhook URL
|
|
* @param text - Message text to send
|
|
* @param userId - Optional user ID to mention with @
|
|
* @returns true if sent successfully
|
|
*/
|
|
export async function sendMessage(
|
|
incomingUrl: string,
|
|
text: string,
|
|
userId?: string | number,
|
|
allowInsecureSsl = true,
|
|
): Promise<boolean> {
|
|
// Synology Chat API requires user_ids (numeric) to specify the recipient
|
|
// The @mention is optional but user_ids is mandatory
|
|
const body = buildWebhookBody({ text }, userId);
|
|
|
|
// Internal rate limit: min 500ms between sends
|
|
const now = Date.now();
|
|
const elapsed = now - lastSendTime;
|
|
if (elapsed < MIN_SEND_INTERVAL_MS) {
|
|
await sleep(MIN_SEND_INTERVAL_MS - elapsed);
|
|
}
|
|
|
|
// Retry with exponential backoff (3 attempts, 300ms base)
|
|
const maxRetries = 3;
|
|
const baseDelay = 300;
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
const ok = await doPost(incomingUrl, body, allowInsecureSsl);
|
|
lastSendTime = Date.now();
|
|
if (ok) return true;
|
|
} catch {
|
|
// will retry
|
|
}
|
|
|
|
if (attempt < maxRetries - 1) {
|
|
await sleep(baseDelay * Math.pow(2, attempt));
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Send a file URL to Synology Chat.
|
|
*/
|
|
export async function sendFileUrl(
|
|
incomingUrl: string,
|
|
fileUrl: string,
|
|
userId?: string | number,
|
|
allowInsecureSsl = true,
|
|
): Promise<boolean> {
|
|
const body = buildWebhookBody({ file_url: fileUrl }, userId);
|
|
|
|
try {
|
|
const ok = await doPost(incomingUrl, body, allowInsecureSsl);
|
|
lastSendTime = Date.now();
|
|
return ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the list of Chat users visible to this bot via the user_list API.
|
|
* Results are cached for CACHE_TTL_MS to avoid excessive API calls.
|
|
*
|
|
* The user_list endpoint uses the same base URL as the chatbot API but
|
|
* with method=user_list instead of method=chatbot.
|
|
*/
|
|
export async function fetchChatUsers(
|
|
incomingUrl: string,
|
|
allowInsecureSsl = true,
|
|
log?: { warn: (...args: unknown[]) => void },
|
|
): Promise<ChatUser[]> {
|
|
const now = Date.now();
|
|
const listUrl = incomingUrl.replace(/method=\w+/, "method=user_list");
|
|
const cached = chatUserCache.get(listUrl);
|
|
if (cached && now - cached.cachedAt < CACHE_TTL_MS) {
|
|
return cached.users;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
let parsedUrl: URL;
|
|
try {
|
|
parsedUrl = new URL(listUrl);
|
|
} catch {
|
|
log?.warn("fetchChatUsers: invalid user_list URL, using cached data");
|
|
resolve(cached?.users ?? []);
|
|
return;
|
|
}
|
|
const transport = parsedUrl.protocol === "https:" ? https : http;
|
|
|
|
transport
|
|
.get(listUrl, { rejectUnauthorized: !allowInsecureSsl } as any, (res) => {
|
|
let data = "";
|
|
res.on("data", (c: Buffer) => {
|
|
data += c.toString();
|
|
});
|
|
res.on("end", () => {
|
|
try {
|
|
const result = JSON.parse(data);
|
|
if (result.success && result.data?.users) {
|
|
const users = result.data.users.map((u: any) => ({
|
|
user_id: u.user_id,
|
|
username: u.username || "",
|
|
nickname: u.nickname || "",
|
|
}));
|
|
chatUserCache.set(listUrl, {
|
|
users,
|
|
cachedAt: now,
|
|
});
|
|
resolve(users);
|
|
} else {
|
|
log?.warn(
|
|
`fetchChatUsers: API returned success=${result.success}, using cached data`,
|
|
);
|
|
resolve(cached?.users ?? []);
|
|
}
|
|
} catch {
|
|
log?.warn("fetchChatUsers: failed to parse user_list response");
|
|
resolve(cached?.users ?? []);
|
|
}
|
|
});
|
|
})
|
|
.on("error", (err) => {
|
|
log?.warn(`fetchChatUsers: HTTP error — ${err instanceof Error ? err.message : err}`);
|
|
resolve(cached?.users ?? []);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve a webhook username to the correct Chat API user_id.
|
|
*
|
|
* Synology Chat outgoing webhooks send a user_id that may NOT match the
|
|
* Chat-internal user_id needed by the chatbot API (method=chatbot).
|
|
* The webhook's "username" field corresponds to the Chat user's "nickname".
|
|
*
|
|
* @param incomingUrl - Bot incoming webhook URL (used to derive user_list URL)
|
|
* @param webhookUsername - The username from the outgoing webhook payload
|
|
* @param allowInsecureSsl - Skip TLS verification
|
|
* @returns The correct Chat user_id, or undefined if not found
|
|
*/
|
|
export async function resolveChatUserId(
|
|
incomingUrl: string,
|
|
webhookUsername: string,
|
|
allowInsecureSsl = true,
|
|
log?: { warn: (...args: unknown[]) => void },
|
|
): Promise<number | undefined> {
|
|
const users = await fetchChatUsers(incomingUrl, allowInsecureSsl, log);
|
|
const lower = webhookUsername.toLowerCase();
|
|
|
|
// Match by nickname first (webhook "username" field = Chat "nickname")
|
|
const byNickname = users.find((u) => u.nickname.toLowerCase() === lower);
|
|
if (byNickname) return byNickname.user_id;
|
|
|
|
// Then by username
|
|
const byUsername = users.find((u) => u.username.toLowerCase() === lower);
|
|
if (byUsername) return byUsername.user_id;
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function buildWebhookBody(payload: ChatWebhookPayload, userId?: string | number): string {
|
|
const numericId = parseNumericUserId(userId);
|
|
if (numericId !== undefined) {
|
|
payload.user_ids = [numericId];
|
|
}
|
|
return `payload=${encodeURIComponent(JSON.stringify(payload))}`;
|
|
}
|
|
|
|
function parseNumericUserId(userId?: string | number): number | undefined {
|
|
if (userId === undefined) {
|
|
return undefined;
|
|
}
|
|
const numericId = typeof userId === "number" ? userId : parseInt(userId, 10);
|
|
return Number.isNaN(numericId) ? undefined : numericId;
|
|
}
|
|
|
|
function doPost(url: string, body: string, allowInsecureSsl = true): Promise<boolean> {
|
|
return new Promise((resolve, reject) => {
|
|
let parsedUrl: URL;
|
|
try {
|
|
parsedUrl = new URL(url);
|
|
} catch {
|
|
reject(new Error(`Invalid URL: ${url}`));
|
|
return;
|
|
}
|
|
const transport = parsedUrl.protocol === "https:" ? https : http;
|
|
|
|
const req = transport.request(
|
|
url,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"Content-Length": Buffer.byteLength(body),
|
|
},
|
|
timeout: 30_000,
|
|
// Synology NAS may use self-signed certs on local network.
|
|
// Set allowInsecureSsl: true in channel config to skip verification.
|
|
rejectUnauthorized: !allowInsecureSsl,
|
|
},
|
|
(res) => {
|
|
let data = "";
|
|
res.on("data", (chunk: Buffer) => {
|
|
data += chunk.toString();
|
|
});
|
|
res.on("end", () => {
|
|
resolve(res.statusCode === 200);
|
|
});
|
|
},
|
|
);
|
|
|
|
req.on("error", reject);
|
|
req.on("timeout", () => {
|
|
req.destroy();
|
|
reject(new Error("Request timeout"));
|
|
});
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|