2026-09-03 11:30:26 +08:00
|
|
|
|
import type { DatabaseClient } from "./database";
|
2026-09-04 18:03:46 +08:00
|
|
|
|
import {
|
|
|
|
|
|
effectiveProjectMembers,
|
|
|
|
|
|
ensureProjectMainChat,
|
|
|
|
|
|
findOrCreateGroupOwnerPartner,
|
|
|
|
|
|
} from "./project-service";
|
2026-09-03 11:30:26 +08:00
|
|
|
|
import {
|
|
|
|
|
|
createAppChat,
|
|
|
|
|
|
getAppChat,
|
|
|
|
|
|
hasAppCredentials,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
sanitizeChatid,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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;
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task_owner_userid: string | null;
|
|
|
|
|
|
project_owner_userid: string | null;
|
2026-09-03 11:30:26 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function text(value: unknown) {
|
|
|
|
|
|
return String(value ?? "").trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function taskChatId(taskId: string) {
|
2026-09-04 18:03:46 +08:00
|
|
|
|
return `tgc${sanitizeChatid(text(taskId))}`;
|
2026-09-03 11:30:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
if (!info) {
|
|
|
|
|
|
// appchat/get 要求应用可见范围为根部门,查不到时降级为只增同步(重复添加成员企微按成功处理)
|
|
|
|
|
|
await updateAppChat(config, { chatid, addUserList: members }, fetchImpl);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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<TaskChatEnsureResult> {
|
|
|
|
|
|
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(
|
2026-09-04 18:03:46 +08:00
|
|
|
|
`SELECT t.id, t.name, t.task_chat_id, t.owner_userid AS task_owner_userid,
|
|
|
|
|
|
p.owner_userid AS project_owner_userid
|
2026-09-03 11:30:26 +08:00
|
|
|
|
FROM tasks t
|
|
|
|
|
|
LEFT JOIN projects p ON p.id = t.project_id
|
|
|
|
|
|
WHERE t.id = ?`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(taskId)
|
|
|
|
|
|
.first<TaskChatRow>();
|
|
|
|
|
|
if (!task) return { chatId: null, created: false, reason: "no_task" };
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const ownerUserid = text(task.project_owner_userid);
|
|
|
|
|
|
const taskOwnerUserid = text(task.task_owner_userid);
|
|
|
|
|
|
if (!ownerUserid && !taskOwnerUserid) {
|
2026-09-03 11:30:26 +08:00
|
|
|
|
return { chatId: null, created: false, reason: "no_owner" };
|
|
|
|
|
|
}
|
|
|
|
|
|
const members = [
|
|
|
|
|
|
...new Set(
|
2026-09-04 18:03:46 +08:00
|
|
|
|
[
|
|
|
|
|
|
ownerUserid,
|
|
|
|
|
|
taskOwnerUserid,
|
|
|
|
|
|
...(params.addUserids ?? []).map((id) => text(id)),
|
|
|
|
|
|
].filter(Boolean),
|
2026-09-03 11:30:26 +08:00
|
|
|
|
),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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<TaskOwnerAssignment> {
|
|
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 11:30:26 +08:00
|
|
|
|
export async function sendTaskChatMessage(
|
|
|
|
|
|
db: DatabaseClient,
|
|
|
|
|
|
taskId: string,
|
|
|
|
|
|
message: string,
|
|
|
|
|
|
config: WecomConfig,
|
|
|
|
|
|
fetchImpl: FetchLike = fetch,
|
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|