Files
koc-loop/lib/task-one-click-push-service.ts

356 lines
9.7 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 { getRuntimeEnv } from "./runtime-env";
import {
sendWecomAppMessage,
type WecomConfig,
} from "./wecom-client";
import { pushProjectTask, type ProjectPushOutcome } from "./project-push-service";
import type { TaskPushRow } from "./wecom-group-push-service";
type FetchLike = typeof fetch;
export class OneClickPushError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "OneClickPushError";
this.status = status;
}
}
export type BoundWecomPartner = {
id: string;
name: string;
wecomExternalUserId: string;
};
export type KocPushResult = {
partnerId: string;
partnerName: string;
externalUserId: string;
ok: boolean;
error: string;
};
export type TaskPushLogRow = {
id: string;
task_id: string;
project_id: string;
push_kind: string;
main_group_count: number;
koc_userids: string;
koc_ok_count: number;
koc_fail_list: string;
group_push_id: string;
detail: string;
created_at: string;
};
export type OneClickPushOutcome = {
linkUrl: string;
projectOutcome: ProjectPushOutcome | null;
kocResults: KocPushResult[];
kocSent: number;
kocFailed: number;
skippedReasons: string[];
logId: string;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export function buildKocDirectMessage(
task: Pick<
TaskPushRow,
| "name"
| "brand"
| "quantity"
| "due_at"
| "task_type"
| "platform"
| "content_format"
| "share_token"
>,
portalUrl: string,
): string {
const portal = text(portalUrl).replace(/\/$/, "");
const claimUrl =
portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
const formatLabel =
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
return [
`【新任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
`平台:${task.platform}|形式:${formatLabel}`,
"数量有限,先到先得,点击链接直接领取(企微内打开免输身份):",
claimUrl || "领取链接生成失败,请联系管理员",
].join("\n");
}
export async function listBoundWecomPartners(
db: DatabaseClient,
): Promise<BoundWecomPartner[]> {
const result = await db
.prepare(
`SELECT id, name, wecom_external_user_id
FROM partners
WHERE wecom_external_user_id IS NOT NULL
AND wecom_external_user_id != ''
ORDER BY name ASC`,
)
.all<{
id: string;
name: string;
wecom_external_user_id: string;
}>();
return result.results.map((row) => ({
id: row.id,
name: row.name,
wecomExternalUserId: row.wecom_external_user_id,
}));
}
export async function findTodayTaskPushLog(
db: DatabaseClient,
taskId: string,
pushKind = "task_open",
): Promise<TaskPushLogRow | null> {
const result = await db
.prepare(
`SELECT id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id,
detail, created_at
FROM wecom_push_logs
WHERE task_id = ? AND push_kind = ?
AND DATE(created_at) = CURDATE()
ORDER BY created_at DESC
LIMIT 1`,
)
.bind(taskId, pushKind)
.first<TaskPushLogRow>();
return result ?? null;
}
export async function listTaskPushLogs(
db: DatabaseClient,
taskId: string,
): Promise<TaskPushLogRow[]> {
const filter = taskId ? "WHERE task_id = ?" : "";
const statement = db
.prepare(
`SELECT id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id,
detail, created_at
FROM wecom_push_logs
${filter}
ORDER BY created_at DESC
LIMIT 50`,
)
.bind(...(taskId ? [taskId] : []));
const result = await statement.all<TaskPushLogRow>();
return result.results;
}
export async function recordCardAck(
db: DatabaseClient,
params: { cardTaskId: string; btnKey: string; operator: string },
) {
const taskId = /^task:([^:]+):/.exec(params.cardTaskId)?.[1] ?? "";
await db
.prepare(
`INSERT INTO wecom_push_logs
(id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
`wpl-${crypto.randomUUID().slice(0, 12)}`,
taskId || params.cardTaskId,
"",
"card_ack",
0,
"[]",
0,
"[]",
"",
JSON.stringify({
cardTaskId: params.cardTaskId,
btnKey: params.btnKey,
operator: params.operator,
}),
)
.run();
}
export async function oneClickPushTask(
db: DatabaseClient,
params: {
taskId: unknown;
sendMainGroup?: unknown;
sendKoc?: unknown;
createGroupTemplate?: unknown;
selectedKocPartnerIds?: unknown;
force?: unknown;
text?: unknown;
},
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<OneClickPushOutcome> {
const taskId = text(params.taskId);
if (!taskId) throw new OneClickPushError("缺少 taskId");
const sendMainGroup = params.sendMainGroup !== false;
const sendKoc = params.sendKoc !== false;
const createGroupTemplate = params.createGroupTemplate === true;
if (!sendMainGroup && !sendKoc && !createGroupTemplate) {
throw new OneClickPushError("请至少选择一个推送目标");
}
const task = await db
.prepare(
`SELECT id, name, brand, quantity, due_at, task_type, platform,
content_format, share_token, project_id
FROM tasks WHERE id = ?`,
)
.bind(taskId)
.first<TaskPushRow & { project_id: string }>();
if (!task) throw new OneClickPushError("任务不存在", 404);
if (!text(task.share_token)) {
throw new OneClickPushError("任务尚未生成领取链接,请先开放领取", 400);
}
if (params.force !== true) {
const existing = await findTodayTaskPushLog(db, taskId, "task_open");
if (existing) {
throw new OneClickPushError(
"今天已推送过该任务;如确需再次推送,请勾选「仍要推送」",
409,
);
}
}
const skippedReasons: string[] = [];
const wantsProjectTargets = sendMainGroup || createGroupTemplate;
let projectOutcome: ProjectPushOutcome | null = null;
if (wantsProjectTargets) {
if (!text(task.project_id)) {
skippedReasons.push("任务未挂项目,已跳过项目总群与任务群");
} else {
projectOutcome = await pushProjectTask(
db,
{
taskId,
sendMainGroup,
sendGroupChats: createGroupTemplate,
text: params.text,
},
config,
fetchImpl,
);
skippedReasons.push(...projectOutcome.skippedReasons);
}
}
const portalUrl = text(getRuntimeEnv().KOC_PORTAL_URL);
const linkUrl = task.share_token
? `${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`
: "";
const kocResults: KocPushResult[] = [];
if (sendKoc) {
const bound = await listBoundWecomPartners(db);
const selected = Array.isArray(params.selectedKocPartnerIds)
? params.selectedKocPartnerIds.map((id) => text(id)).filter(Boolean)
: null;
const targets = selected
? bound.filter((partner) => selected.includes(partner.id))
: bound;
if (targets.length === 0) {
skippedReasons.push("没有可直达的合作方(未勾选或均未绑定企微身份)");
} else {
const message = buildKocDirectMessage(task, portalUrl);
for (const partner of targets) {
try {
const sent = await sendWecomAppMessage(
[partner.wecomExternalUserId],
message,
config,
fetchImpl,
);
const failed = sent.sent === 0;
kocResults.push({
partnerId: partner.id,
partnerName: partner.name,
externalUserId: partner.wecomExternalUserId,
ok: !failed,
error: failed ? "企业微信未送达(invaliduser)" : "",
});
} catch (error) {
kocResults.push({
partnerId: partner.id,
partnerName: partner.name,
externalUserId: partner.wecomExternalUserId,
ok: false,
error: error instanceof Error ? error.message : "发送失败",
});
}
}
}
}
const kocSent = kocResults.filter((item) => item.ok).length;
const kocFailed = kocResults.length - kocSent;
const logId = `wpl-${crypto.randomUUID().slice(0, 12)}`;
const groupPushId =
projectOutcome?.groupResults.map((item) => item.msgid).join(",") ?? "";
await db
.prepare(
`INSERT INTO wecom_push_logs
(id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
logId,
taskId,
text(task.project_id),
"task_open",
projectOutcome ? projectOutcome.appResults.length : 0,
JSON.stringify(
kocResults.map((item) => ({
partnerId: item.partnerId,
name: item.partnerName,
externalUserId: item.externalUserId,
ok: item.ok,
error: item.error,
})),
),
kocSent,
JSON.stringify(kocResults.filter((item) => !item.ok)),
groupPushId,
JSON.stringify({
skippedReasons,
createGroupTemplate,
linkUrl,
}),
)
.run();
return {
linkUrl,
projectOutcome,
kocResults,
kocSent,
kocFailed,
skippedReasons,
logId,
};
}