Files
koc-loop/lib/wecom-notifier-service.ts
ABAPPLO dd5b603149 feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人
- 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人
- 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行
- 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events)
- 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表
- 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群)
- 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件
- 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
2026-09-04 18:03:46 +08:00

442 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DatabaseClient } from "./database";
import { shanghaiDateFromTimestamp } from "./collection-service";
import {
sendWecomAppMessage,
sendWecomRobotMessage,
sendWecomTemplateCard,
type WecomConfig,
} from "./wecom-client";
import { sendTaskChatMessage } from "./task-group-service";
import { getRuntimeEnv } from "./runtime-env";
import { releaseUnfinishedDistribution } from "./distribution-release-service";
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_id: string;
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(),
taskId?: string,
): Promise<WecomNotifySummary> {
const today = shanghaiDateFromTimestamp(now);
const cutoff = computeDueCutoff(today, config.dueDays);
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
const taskFilter = String(taskId ?? "").trim();
const dueSoonBase = `SELECT
d.id AS distribution_id,
d.partner_id,
p.name AS partner_name,
p.wecom_external_user_id,
t.id AS task_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 (
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL)
OR (t.task_type != 'screenshot_collect'
AND (d.publish_url IS NULL OR d.publish_url = ''))
)
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at <= ?`;
const statement = db
.prepare(
taskFilter
? `${dueSoonBase} AND t.id = ? ORDER BY t.due_at ASC, p.name ASC`
: `${dueSoonBase} ORDER BY t.due_at ASC, p.name ASC`,
)
.bind(...(taskFilter ? [cutoff, taskFilter] : [cutoff]));
const result = await statement.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})`,
);
}
const byTask = new Map<
string,
{ taskName: string; dueAt: string; rows: DueSoonRow[] }
>();
for (const row of result.results) {
const entry = byTask.get(row.task_id) ?? {
taskName: row.task_name,
dueAt: row.due_at,
rows: [],
};
entry.rows.push(row);
byTask.set(row.task_id, entry);
}
for (const [groupTaskId, entry] of byTask) {
const partners = [...new Set(entry.rows.map((row) => row.partner_name))];
const overflow = partners.length > 5 ? ` 等 ${partners.length} 位` : "";
await sendTaskChatMessage(
db,
groupTaskId,
`【催办】《${truncate(entry.taskName, 24)}》有 ${entry.rows.length} 条内容待发布(截止 ${entry.dueAt}):\n${partners.slice(0, 5).join("、")}${overflow}`,
config,
fetchImpl,
);
}
let digestSent = false;
if (config.robotWebhook && digestTasks.length > 0 && !taskFilter) {
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();
}
export function resolveAutoReleaseDays(value: string | undefined) {
const parsed = Number(String(value ?? "").trim());
if (!Number.isFinite(parsed) || parsed < 1) return 0;
return Math.min(90, Math.floor(parsed));
}
type OverdueRow = {
id: string;
task_id: string;
task_name: string;
due_at: string;
project_id: string;
partner_id: string;
partner_name: string;
};
export type OverdueEscalationSummary = {
overdueTasks: number;
overdueDistributions: number;
robotSent: boolean;
cardSent: number;
cardFailed: number;
};
async function queryOverdueUnpublished(
db: DatabaseClient,
cutoff: string,
): Promise<OverdueRow[]> {
const result = await db
.prepare(
`SELECT d.id, t.id AS task_id, t.name AS task_name, t.due_at,
t.project_id, p.id AS partner_id, p.name AS partner_name
FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id
WHERE (
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL)
OR (t.task_type != 'screenshot_collect'
AND (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, t.name ASC`,
)
.bind(cutoff)
.all<OverdueRow>();
return result.results;
}
export async function runOverdueEscalation(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
): Promise<OverdueEscalationSummary> {
const today = shanghaiDateFromTimestamp(now);
const rows = await queryOverdueUnpublished(db, today);
const byTask = new Map<
string,
{ taskName: string; dueAt: string; projectId: string; count: number; partners: Set<string> }
>();
for (const row of rows) {
const entry = byTask.get(row.task_id) ?? {
taskName: row.task_name,
dueAt: row.due_at,
projectId: bindingValue(row.project_id),
count: 0,
partners: new Set<string>(),
};
entry.count += 1;
entry.partners.add(row.partner_name || row.partner_id);
byTask.set(row.task_id, entry);
}
const summary: OverdueEscalationSummary = {
overdueTasks: byTask.size,
overdueDistributions: rows.length,
robotSent: false,
cardSent: 0,
cardFailed: 0,
};
if (rows.length === 0) return summary;
if (config.robotWebhook) {
const lines = [...byTask.values()].map((entry) => {
const partners = [...entry.partners].slice(0, 5).join("、");
const overflow = entry.partners.size > 5 ? ` 等 ${entry.partners.size} 位` : "";
return `· ${truncate(entry.taskName, 24)}(截止 ${entry.dueAt}):${entry.count} 条超期未发布 — ${partners}${overflow}`;
});
try {
await sendWecomRobotMessage(
`【超期未发布升级】${today}\n${lines.join("\n")}\n请运营跟进催办或释放。`,
config,
fetchImpl,
);
summary.robotSent = true;
} catch (error) {
console.warn("[KOC LOOP] overdue escalation robot failed", {
error: safeError(error),
});
}
}
const env = getRuntimeEnv();
const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL);
for (const [taskId, entry] of byTask) {
await sendTaskChatMessage(
db,
taskId,
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${entry.count} 条内容超期未发布,请跟进催办或释放。`,
config,
fetchImpl,
);
if (!entry.projectId) continue;
try {
const project = await db
.prepare("SELECT main_group_member_ids FROM projects WHERE id = ?")
.bind(entry.projectId)
.first<{ main_group_member_ids: string }>();
const members = safeParseIdList(project?.main_group_member_ids);
if (members.length === 0) continue;
const sent = await sendWecomTemplateCard(
config,
{
touser: members,
taskId: `task:${taskId}:due`,
title: `超期催办:${truncate(entry.taskName, 20)}`,
description: `${entry.count} 条内容超期未发布(截止 ${entry.dueAt}),请跟进催办或释放`,
url: cardUrl,
buttons: [
{ key: "view_task", name: "查看任务", url: cardUrl },
{ key: "mark_handled", name: "已处理" },
],
},
fetchImpl,
);
if (!sent.skipped) {
summary.cardSent += sent.sent;
summary.cardFailed += sent.failed;
}
} catch (error) {
summary.cardFailed += 1;
console.warn("[KOC LOOP] overdue escalation card failed", {
taskId,
error: safeError(error),
});
}
}
return summary;
}
export type AutoReleaseSummary = {
enabled: boolean;
days: number;
cutoff: string;
released: number;
blocked: number;
failed: number;
robotSent: boolean;
};
export async function runOverdueAutoRelease(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
): Promise<AutoReleaseSummary> {
const days = resolveAutoReleaseDays(getRuntimeEnv().WECOM_AUTO_RELEASE_DAYS);
const summary: AutoReleaseSummary = {
enabled: days > 0,
days,
cutoff: "",
released: 0,
blocked: 0,
failed: 0,
robotSent: false,
};
if (days <= 0) return summary;
const today = shanghaiDateFromTimestamp(now);
summary.cutoff = computeDueCutoff(today, -days);
const rows = await queryOverdueUnpublished(db, summary.cutoff);
for (const row of rows) {
try {
await releaseUnfinishedDistribution(db, row.id);
summary.released += 1;
} catch (error) {
const status = (error as { status?: number }).status;
if (status === 409) {
summary.blocked += 1;
} else {
summary.failed += 1;
console.warn("[KOC LOOP] auto release failed", {
distributionId: row.id,
error: safeError(error),
});
}
}
}
if (summary.released > 0 && config.robotWebhook) {
try {
await sendWecomRobotMessage(
`【超期自动释放】截止 ${summary.cutoff} 前仍未发布的内容已释放 ${summary.released} 条回库存(跳过 ${summary.blocked} 条已提交、失败 ${summary.failed} 条),可重新领取。`,
config,
fetchImpl,
);
summary.robotSent = true;
} catch (error) {
console.warn("[KOC LOOP] auto release robot failed", {
error: safeError(error),
});
}
}
return summary;
}
function safeParseIdList(value: string | null | undefined) {
try {
const parsed = JSON.parse(String(value ?? "[]"));
if (!Array.isArray(parsed)) return [];
return parsed.map((item) => String(item)).filter(Boolean);
} catch {
return [];
}
}
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);
}