import type { DatabaseClient } from "./database"; import { effectiveProjectMembers, ensureProjectMainChat, findOrCreateGroupOwnerPartner, } from "./project-service"; import { createAppChat, getAppChat, hasAppCredentials, sanitizeChatid, sendAppChatMessage, updateAppChat, type WecomConfig, } from "./wecom-client"; type FetchLike = typeof fetch; export type TaskChatEnsureResult = { chatId: string | null; created: boolean; reason?: "no_task" | "no_owner" | "not_enough_members" | "no_credentials"; }; type TaskChatRow = { id: string; name: string; task_chat_id: string | null; task_owner_userid: string | null; project_owner_userid: string | null; }; function text(value: unknown) { return String(value ?? "").trim(); } export function taskChatId(taskId: string) { return `tgc${sanitizeChatid(text(taskId))}`; } function chatNameForTask(taskName: string) { return `【任务】${text(taskName)}`.slice(0, 20); } async function syncMembers( config: WecomConfig, chatid: string, members: string[], fetchImpl: FetchLike, ) { const info = await getAppChat(config, chatid, fetchImpl); if (!info) { // appchat/get 要求应用可见范围为根部门,查不到时降级为只增同步(重复添加成员企微按成功处理) await updateAppChat(config, { chatid, addUserList: members }, fetchImpl); return; } const existing = new Set(info.userlist); const missing = members.filter((userid) => !existing.has(userid)); if (missing.length > 0) { await updateAppChat(config, { chatid, addUserList: missing }, fetchImpl); } } export async function ensureTaskChatForMembers( db: DatabaseClient, params: { taskId: string; addUserids?: string[] }, config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { const taskId = text(params.taskId); if (!taskId) return { chatId: null, created: false, reason: "no_task" }; if (!hasAppCredentials(config)) { return { chatId: null, created: false, reason: "no_credentials" }; } const task = await db .prepare( `SELECT t.id, t.name, t.task_chat_id, t.owner_userid AS task_owner_userid, p.owner_userid AS project_owner_userid FROM tasks t LEFT JOIN projects p ON p.id = t.project_id WHERE t.id = ?`, ) .bind(taskId) .first(); if (!task) return { chatId: null, created: false, reason: "no_task" }; const ownerUserid = text(task.project_owner_userid); const taskOwnerUserid = text(task.task_owner_userid); if (!ownerUserid && !taskOwnerUserid) { return { chatId: null, created: false, reason: "no_owner" }; } const members = [ ...new Set( [ ownerUserid, taskOwnerUserid, ...(params.addUserids ?? []).map((id) => text(id)), ].filter(Boolean), ), ]; let chatId = text(task.task_chat_id); let createdNow = false; if (!chatId) { chatId = taskChatId(taskId); const existing = await getAppChat(config, chatId, fetchImpl); if (!existing) { if (members.length < 2) { return { chatId: null, created: false, reason: "not_enough_members" }; } const created = await createAppChat( config, { name: chatNameForTask(task.name), userlist: members, chatid: chatId }, fetchImpl, ); chatId = created.chatid; createdNow = true; } await db .prepare("UPDATE tasks SET task_chat_id = ? WHERE id = ?") .bind(chatId, taskId) .run(); } await syncMembers(config, chatId, members, fetchImpl); return { chatId, created: createdNow }; } export type TaskOwnerAssignment = { ownerUserid: string; partnerId: string; chatId: string | null; chatCreated: boolean; warning?: string; }; export async function assignTaskOwner( db: DatabaseClient, params: { taskId: string; ownerUserid: string }, config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { const taskId = text(params.taskId); const ownerUserid = text(params.ownerUserid).slice(0, 64); if (!taskId) throw new Error("缺少 taskId"); if (!ownerUserid) throw new Error("请填写任务负责人"); const task = await db .prepare( "SELECT id, name, brand, quantity, due_at, project_id FROM tasks WHERE id = ?", ) .bind(taskId) .first<{ id: string; name: string; brand: string; quantity: number; due_at: string; project_id: string; }>(); if (!task) throw new Error("任务不存在"); await db .prepare("UPDATE tasks SET owner_userid = ? WHERE id = ?") .bind(ownerUserid, taskId) .run(); const partner = await findOrCreateGroupOwnerPartner(db, ownerUserid); const warnings: string[] = []; const projectId = text(task.project_id); if (projectId) { const project = await db .prepare( "SELECT main_group_member_ids, owner_userid FROM projects WHERE id = ?", ) .bind(projectId) .first<{ main_group_member_ids: string; owner_userid: string }>(); if (project) { // 负责人不在成员列表则并入,保持「负责人都在总群」 const stored = effectiveProjectMembers(project.main_group_member_ids, ""); if (!stored.includes(ownerUserid)) { await db .prepare("UPDATE projects SET main_group_member_ids = ? WHERE id = ?") .bind(JSON.stringify([...stored, ownerUserid]), projectId) .run(); } const mainChat = await ensureProjectMainChat(db, projectId, config, fetchImpl); if (mainChat.warning) warnings.push(mainChat.warning); } } let ensure: TaskChatEnsureResult = { chatId: null, created: false }; try { ensure = await ensureTaskChatForMembers(db, { taskId }, config, fetchImpl); if (ensure.chatId && ensure.created) { await sendTaskChatMessage( db, taskId, taskChatIntroText({ name: task.name, brand: task.brand, quantity: task.quantity, dueAt: task.due_at, }), config, fetchImpl, ); } } catch (error) { warnings.push(`任务群创建失败:${error instanceof Error ? error.message : String(error)}`); } return { ownerUserid, partnerId: partner.id, chatId: ensure.chatId, chatCreated: ensure.created, warning: warnings.length > 0 ? warnings.join(";") : undefined, }; } export async function sendTaskChatMessage( db: DatabaseClient, taskId: string, message: string, config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { try { const content = text(message); if (!content) return false; const row = await db .prepare("SELECT task_chat_id FROM tasks WHERE id = ?") .bind(text(taskId)) .first<{ task_chat_id: string | null }>(); const chatId = text(row?.task_chat_id); if (!chatId) return false; await sendAppChatMessage(config, { chatid: chatId, text: content }, fetchImpl); return true; } catch (error) { console.error( `[KOC LOOP] 任务群消息发送失败(task=${taskId})`, error instanceof Error ? error.message : error, ); return false; } } export function taskClaimBroadcastText( taskName: string, claimantName: string, quantity: number, ) { return `【领取】${text(claimantName)} 认领 ${quantity} 份 · ${text(taskName)}`; } export function taskChatIntroText(task: { name: string; brand: string; quantity: number; dueAt: string; }) { return [ `【新任务】${text(task.name)}`, `品牌:${text(task.brand)}|数量:${task.quantity} 份|截止:${text(task.dueAt)}`, ].join("\n"); }