Files
koc-loop/lib/wecom-group-push-service.ts

327 lines
9.0 KiB
TypeScript
Raw Normal View History

import type { DatabaseClient } from "./database";
import { getRuntimeEnv } from "./runtime-env";
import {
createGroupMsgTemplate,
listCustomerGroupChats,
WecomClientError,
type WecomConfig,
} from "./wecom-client";
type FetchLike = typeof fetch;
export const WECOM_GROUP_BATCH_LIMIT = 2000;
const TEXT_MAX_BYTES = 4000;
const LINK_TITLE_MAX_BYTES = 128;
const LINK_DESC_MAX_BYTES = 512;
const LINK_URL_MAX_BYTES = 2048;
export type TaskPushRow = {
id: string;
name: string;
brand: string;
quantity: number;
due_at: string;
task_type: string;
platform: string;
content_format: string;
share_token: string | null;
};
export type TaskPushMessage = {
text: string;
link: { title: string; desc: string; url: string } | null;
};
export type GroupChatRow = {
chat_id: string;
name: string;
owner_user_id: string;
member_count: number;
status: number;
};
export type GroupPushResult = {
sender: string;
msgid: string;
chatCount: number;
failList: string[];
};
export type GroupPushRecord = {
id: string;
taskId: string;
msgid: string;
sender: string;
chatIds: string[];
failList: string[];
createdAt: string;
};
function bindingValue(value: unknown) {
return String(value ?? "").trim();
}
export function trimToBytes(value: string, maxBytes: number) {
let output = "";
let used = 0;
for (const char of Array.from(value)) {
const size = Buffer.byteLength(char, "utf8");
if (used + size > maxBytes) break;
output += char;
used += size;
}
return output;
}
export function buildTaskGroupPushMessage(
task: TaskPushRow,
portalUrl: string,
): TaskPushMessage {
const portal = portalUrl.trim().replace(/\/$/, "");
const shareToken = bindingValue(task.share_token);
const claimUrl = portal && shareToken ? `${portal}/?task=${shareToken}` : "";
const formatLabel =
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
const lines = [
`【新任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
`平台:${task.platform}|形式:${formatLabel}`,
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
];
return {
text: trimToBytes(lines.join("\n"), TEXT_MAX_BYTES),
link: claimUrl
? {
title: trimToBytes(task.name, LINK_TITLE_MAX_BYTES),
desc: trimToBytes(
`品牌 ${task.brand} · ${task.quantity} 份 · 截止 ${task.due_at}`,
LINK_DESC_MAX_BYTES,
),
url: claimUrl.slice(0, LINK_URL_MAX_BYTES),
}
: null,
};
}
export function groupChatsByOwner(chats: GroupChatRow[]) {
const grouped = new Map<string, string[]>();
for (const chat of chats) {
const owner = bindingValue(chat.owner_user_id);
if (!owner) continue;
grouped.set(owner, [...(grouped.get(owner) ?? []), chat.chat_id]);
}
return [...grouped.entries()].map(([ownerUserId, chatIds]) => ({
ownerUserId,
chatIds,
}));
}
export async function syncGroupChats(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<GroupChatRow[]> {
if (!(config.corpId && config.agentId && config.secret)) {
throw new WecomClientError(
"企业微信自建应用未配置WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET",
400,
);
}
const groups = await listCustomerGroupChats(config, fetchImpl);
const syncedAt = new Date().toISOString();
await db.prepare("DELETE FROM wecom_group_chats").run();
const inserts = groups.map((group) =>
db
.prepare(
`INSERT INTO wecom_group_chats
(chat_id, name, owner_user_id, member_count, status, synced_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
group.chatId,
group.name,
group.ownerUserId,
group.memberCount,
group.status,
syncedAt,
),
);
if (inserts.length > 0) await db.batch(inserts);
return groups.map((group) => ({
chat_id: group.chatId,
name: group.name,
owner_user_id: group.ownerUserId,
member_count: group.memberCount,
status: group.status,
}));
}
export async function listGroupChatRows(db: DatabaseClient) {
const result = await db
.prepare(
`SELECT chat_id, name, owner_user_id, member_count, status
FROM wecom_group_chats
ORDER BY member_count DESC`,
)
.all<GroupChatRow>();
return result.results;
}
export async function listGroupPushes(
db: DatabaseClient,
taskId?: string,
): Promise<GroupPushRecord[]> {
const filter = taskId ? "WHERE task_id = ?" : "";
const statement = db
.prepare(
`SELECT id, task_id, msgid, sender, chat_ids, fail_list, created_at
FROM wecom_group_pushes
${filter}
ORDER BY created_at DESC
LIMIT 50`,
)
.bind(...(taskId ? [taskId] : []));
const result = await statement.all<{
id: string;
task_id: string;
msgid: string;
sender: string;
chat_ids: string;
fail_list: string;
created_at: string;
}>();
return result.results.map((row) => ({
id: row.id,
taskId: row.task_id,
msgid: row.msgid,
sender: row.sender,
chatIds: safeParseArray(row.chat_ids),
failList: safeParseArray(row.fail_list),
createdAt: row.created_at,
}));
}
export async function pushTaskToGroupChats(
db: DatabaseClient,
params: { taskId: string; chatIds: unknown; text?: unknown },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<{ results: GroupPushResult[]; linkUrl: string }> {
const taskId = bindingValue(params.taskId);
if (!taskId) throw new WecomClientError("缺少 taskId", 400);
const requested = [
...new Set(
(Array.isArray(params.chatIds) ? params.chatIds : [])
.map((id) => bindingValue(id))
.filter(Boolean),
),
];
if (requested.length === 0) {
throw new WecomClientError("请选择要发送的客户群", 400);
}
const task = await db
.prepare(
`SELECT id, name, brand, quantity, due_at, task_type, platform,
content_format, share_token
FROM tasks WHERE id = ?`,
)
.bind(taskId)
.first<TaskPushRow>();
if (!task) throw new WecomClientError("任务不存在", 404);
const chatRows: GroupChatRow[] = [];
for (let index = 0; index < requested.length; index += 100) {
const chunk = requested.slice(index, index + 100);
const placeholders = chunk.map(() => "?").join(", ");
const result = await db
.prepare(
`SELECT chat_id, name, owner_user_id, member_count, status
FROM wecom_group_chats WHERE chat_id IN (${placeholders})`,
)
.bind(...chunk)
.all<GroupChatRow>();
chatRows.push(...result.results);
}
const byId = new Map(chatRows.map((row) => [row.chat_id, row]));
const missing = requested.filter((id) => !byId.has(id));
if (missing.length > 0) {
throw new WecomClientError(
`以下客户群未同步到平台,请先同步群列表:${missing.join("、")}`,
400,
);
}
const noOwner = requested
.map((id) => byId.get(id))
.filter((row) => row && !bindingValue(row.owner_user_id));
if (noOwner.length > 0) {
throw new WecomClientError(
`群「${noOwner.map((row) => row?.name || row?.chat_id).join("、")}」缺少群主信息,请重新同步群列表`,
400,
);
}
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
const fallback = buildTaskGroupPushMessage(task, portalUrl);
const text = bindingValue(params.text) || fallback.text;
const link = fallback.link;
if (!text && !link) {
throw new WecomClientError("文本与图文附件不能同时为空", 400);
}
const results: GroupPushResult[] = [];
const groups = requested.map((id) => byId.get(id)!);
for (const { ownerUserId, chatIds } of groupChatsByOwner(groups)) {
for (
let index = 0;
index < chatIds.length;
index += WECOM_GROUP_BATCH_LIMIT
) {
const batch = chatIds.slice(index, index + WECOM_GROUP_BATCH_LIMIT);
const sent = await createGroupMsgTemplate(
config,
{ sender: ownerUserId, chatIdList: batch, text, link: link ?? undefined },
fetchImpl,
);
await db
.prepare(
`INSERT INTO wecom_group_pushes
(id, task_id, msgid, sender, chat_ids, text_content, link_url, fail_list)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
`wgp-${crypto.randomUUID().slice(0, 12)}`,
taskId,
sent.msgid,
ownerUserId,
JSON.stringify(batch),
text,
link?.url ?? "",
JSON.stringify(sent.failList),
)
.run();
results.push({
sender: ownerUserId,
msgid: sent.msgid,
chatCount: batch.length,
failList: sent.failList,
});
}
}
return { results, linkUrl: link?.url ?? "" };
}
function safeParseArray(value: string) {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
} catch {
return [];
}
}