import type { DatabaseClient } from "./database"; import { shanghaiDateFromTimestamp } from "./collection-service"; import { sendWecomAppMessage, sendWecomRobotMessage, type WecomConfig, } from "./wecom-client"; import { getRuntimeEnv } from "./runtime-env"; type FetchLike = typeof fetch; export type WecomNotifySummary = { dueSoonAttempted: number; dueSoonSent: number; dueSoonFailed: number; dueSoonSkipped: number; digestSent: boolean; }; type DueSoonRow = { distribution_id: string; partner_id: string; partner_name: string; wecom_external_user_id: string | null; task_name: string; due_at: string; content_title: string; }; export function computeDueCutoff(today: string, dueDays: number) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today); if (!match) return today; const [, y, m, d] = match; const date = new Date( Date.UTC(Number(y), Number(m) - 1, Number(d)) + dueDays * 24 * 60 * 60 * 1_000, ); return date.toISOString().slice(0, 10); } export async function runDueSoonWecomNotifications( db: DatabaseClient, config: WecomConfig, fetchImpl: FetchLike = fetch, now: number = Date.now(), ): Promise { const today = shanghaiDateFromTimestamp(now); const cutoff = computeDueCutoff(today, config.dueDays); const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL); const result = await db .prepare( `SELECT d.id AS distribution_id, d.partner_id, p.name AS partner_name, p.wecom_external_user_id, t.name AS task_name, t.due_at, c.title AS content_title FROM distributions d JOIN tasks t ON t.id = d.task_id JOIN partners p ON p.id = d.partner_id JOIN contents c ON c.id = d.content_id WHERE (d.publish_url IS NULL OR d.publish_url = '') AND t.due_at IS NOT NULL AND t.due_at != '' AND t.due_at <= ? ORDER BY t.due_at ASC, p.name ASC`, ) .bind(cutoff) .all(); const grouped = new Map< string, { partnerName: string; externalUserId: string | null; rows: DueSoonRow[]; } >(); for (const row of result.results) { const entry = grouped.get(row.partner_id) ?? { partnerName: row.partner_name, externalUserId: row.wecom_external_user_id, rows: [], }; entry.rows.push(row); if (!entry.externalUserId && row.wecom_external_user_id) { entry.externalUserId = row.wecom_external_user_id; } grouped.set(row.partner_id, entry); } let dueSoonAttempted = 0; let dueSoonSent = 0; let dueSoonFailed = 0; let dueSoonSkipped = 0; const digestTasks: string[] = []; for (const [, entry] of grouped) { dueSoonAttempted += 1; const external = entry.externalUserId ? [entry.externalUserId] : []; const lines = entry.rows.slice(0, 5).map((row) => { return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(截止 ${row.due_at})`; }); const overflow = entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : ""; const link = portalUrl ? `\n领取链接:${portalUrl}` : ""; const content = `${entry.partnerName},你有 ${entry.rows.length} 条内容待发布:\n${lines.join("\n")}${overflow}${link}`; const appResult = await sendWecomAppMessage( external, content, config, fetchImpl, ).catch((error: unknown) => { console.warn( "[KOC LOOP] wecom app message failed", { partner: entry.partnerName, error: safeError(error) }, ); return null; }); if (appResult === null) { dueSoonFailed += 1; } else if (appResult.skipped) { dueSoonSkipped += 1; } else { dueSoonSent += 1; } const earliestDue = entry.rows[0]?.due_at ?? ""; digestTasks.push( `· ${entry.partnerName}(${entry.rows.length} 条,最近截止 ${earliestDue})`, ); } let digestSent = false; if (config.robotWebhook && digestTasks.length > 0) { const digest = `今日待发布催办(${today},截止 ≤ ${cutoff}):\n${digestTasks.join("\n")}`; try { await sendWecomRobotMessage(digest, config, fetchImpl); digestSent = true; } catch (error) { console.warn( "[KOC LOOP] wecom robot digest failed", { error: safeError(error) }, ); } } return { dueSoonAttempted, dueSoonSent, dueSoonFailed, dueSoonSkipped, digestSent, }; } function bindingValue(value: unknown) { return String(value ?? "").trim(); } function truncate(value: string, max: number) { return value.length > max ? `${value.slice(0, max)}…` : value; } function safeError(error: unknown) { return error instanceof Error ? error.message : String(error); }