feat: 接入企业微信通知(临期催办 + 群机器人汇总)
新增 lib/wecom-client.ts 与 lib/wecom-notifier-service.ts,支持 群机器人 webhook 与应用消息双通道;scheduler 加入临期 N 天催办 与每日管理员汇总;action 路由补 bind_wecom_external_id 与 send_test_wecom 两个管理端动作,配套测试。
This commit is contained in:
171
lib/wecom-notifier-service.ts
Normal file
171
lib/wecom-notifier-service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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<WecomNotifySummary> {
|
||||
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<DueSoonRow>();
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user