haixunMaster/lib/automation/engine.ts

587 lines
18 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
import { existsSync } from "fs";
import { getActiveAccountProfile, setWorkerActiveAccountId } from "@/lib/account-context";
import { prisma, resolvePersona } from "@/lib/db";
import { getActiveAccountConnectionSettings } from "@/lib/account-connection-settings";
import { getOrCreateSettings } from "@/lib/user-settings";
import { parseProviderApiKeys } from "@/lib/ai/keys";
import { generateInboundReplyDrafts } from "@/lib/ai/generate-replies";
import { runScanForAllActiveTopics } from "@/lib/services/scan";
import { generateDraftsForScan } from "@/lib/services/generate";
import { generateOutreachForScanItem } from "@/lib/services/outreach";
import { syncThreadsOwnPostsAndInsights } from "@/lib/services/threads-api-sync";
import { getActiveThreadsCredentials } from "@/lib/services/threads-credentials";
import { publishViaThreadsApi, replyViaThreadsApi } from "@/lib/threads-api";
import { ensureActiveSession, publish } from "@/lib/threads-browser";
import {
deleteDraftImages,
draftImageAbsolutePath,
parseDraftImagePaths,
} from "@/lib/drafts/images";
import { humanDelay } from "@/lib/utils";
import { cronMatches } from "./cron-match";
import { logAction, countTodaySuccess } from "./log";
import { getRulesForAccount } from "./rules";
import type { AutomationMode, AutomationTaskType } from "./types";
const OUTREACH_GENERATE_LIMIT = 8;
async function setActiveAccount(accountId: string) {
setWorkerActiveAccountId(accountId);
}
export interface TaskRunResult {
taskType: AutomationTaskType;
ok: boolean;
summary: string;
count?: number;
}
/** 對某帳號執行某項任務。triggeredBy=auto 代表排程觸發、會記入配額manual 代表使用者按「立即執行」。 */
export async function runTaskForAccount(
accountId: string,
taskType: AutomationTaskType,
opts: { triggeredBy: "auto" | "manual"; mode?: AutomationMode }
): Promise<TaskRunResult> {
const previousActive = (await getActiveAccountProfile())?.id ?? null;
await setActiveAccount(accountId);
try {
const rule = (await getRulesForAccount(accountId)).find((r) => r.taskType === taskType);
const mode: AutomationMode = opts.mode ?? (rule?.mode as AutomationMode) ?? "manual";
const dailyCap = rule?.dailyCap ?? 10;
switch (taskType) {
case "scan":
return await runScanTask(accountId, opts.triggeredBy);
case "generate":
return await runGenerateTask(accountId, opts.triggeredBy);
case "publish":
return await runPublishTask(accountId, mode, dailyCap, opts.triggeredBy);
case "outreach":
return await runOutreachTask(accountId, mode, dailyCap, opts.triggeredBy);
case "engagement":
return await runEngagementTask(accountId, mode, dailyCap, opts.triggeredBy);
default:
return { taskType, ok: false, summary: "未知任務" };
}
} catch (error) {
const message = error instanceof Error ? error.message : "任務失敗";
await logAction({
accountId,
taskType,
action: taskType,
mode: opts.triggeredBy,
status: "failed",
error: message,
});
return { taskType, ok: false, summary: message };
} finally {
if (previousActive && previousActive !== accountId) {
await setActiveAccount(previousActive);
} else {
setWorkerActiveAccountId(null);
}
}
}
async function runScanTask(
accountId: string,
triggeredBy: "auto" | "manual"
): Promise<TaskRunResult> {
const scans = await runScanForAllActiveTopics(accountId);
const valid = scans.filter(Boolean);
await logAction({
accountId,
taskType: "scan",
action: "scan",
mode: triggeredBy,
status: "success",
detail: `海巡 ${valid.length} 個主題`,
});
return { taskType: "scan", ok: true, summary: `海巡完成,共 ${valid.length} 個主題`, count: valid.length };
}
async function runGenerateTask(
accountId: string,
triggeredBy: "auto" | "manual"
): Promise<TaskRunResult> {
// 找近 24h、屬於本帳號、尚未生成草稿的海巡
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
const scans = await prisma.scan.findMany({
where: { accountId, createdAt: { gte: since } },
orderBy: { createdAt: "desc" },
include: { _count: { select: { items: true } } },
});
let generated = 0;
for (const scan of scans) {
if (scan._count.items === 0) continue;
const already = await prisma.draft.count({
where: { topicId: scan.topicId, createdAt: { gte: scan.createdAt } },
});
if (already > 0) continue;
try {
const drafts = await generateDraftsForScan(scan.id);
generated += drafts.length;
} catch {
// 跳過個別失敗
}
}
await logAction({
accountId,
taskType: "generate",
action: "generate",
mode: triggeredBy,
status: "success",
detail: `生成 ${generated} 篇草稿`,
});
return { taskType: "generate", ok: true, summary: `生成 ${generated} 篇草稿`, count: generated };
}
async function autoPublishDraft(draft: {
id: string;
text: string;
angle: string | null;
hook: string | null;
rationale: string | null;
draftType: string | null;
accountId: string | null;
topicId: string | null;
imagePath?: string | null;
imagePaths?: string | null;
}): Promise<{ success: boolean; permalink?: string; error?: string }> {
const connection = await getActiveAccountConnectionSettings();
const images = parseDraftImagePaths(draft).filter((p) => existsSync(draftImageAbsolutePath(p)));
const credentials = connection.publishViaApi ? await getActiveThreadsCredentials() : null;
let result: { success: boolean; permalink?: string; error?: string } | null = null;
// API 優先(純文字):有圖時 API 需公開網址,直接走瀏覽器以保留配圖。
if (credentials && images.length === 0) {
try {
const apiResult = await publishViaThreadsApi(credentials, { text: draft.text });
result = apiResult.success
? { success: true, permalink: apiResult.permalink }
: { success: false, error: apiResult.error };
} catch (error) {
result = { success: false, error: error instanceof Error ? error.message : "API 發布失敗" };
}
}
// API 未連線、有配圖、或 API 發布失敗 → 退回瀏覽器
if (!result || !result.success) {
try {
const session = await ensureActiveSession();
const browserResult = await publish(
session,
draft.text,
images.map((p) => draftImageAbsolutePath(p))
);
result = {
success: browserResult.success,
permalink: browserResult.permalink,
error: browserResult.error,
};
} catch (error) {
result = result ?? {
success: false,
error: error instanceof Error ? error.message : "發布失敗",
};
}
}
if (!result.success) return result;
await prisma.$transaction(async (tx) => {
await tx.published.create({
data: {
accountId: draft.accountId,
topicId: draft.topicId,
text: draft.text,
angle: draft.angle,
hook: draft.hook,
rationale: draft.rationale,
draftType: draft.draftType,
permalink: result.permalink,
},
});
await deleteDraftImages(images);
await tx.draft.delete({ where: { id: draft.id } });
});
return result;
}
async function runPublishTask(
accountId: string,
mode: AutomationMode,
dailyCap: number,
triggeredBy: "auto" | "manual"
): Promise<TaskRunResult> {
if (mode !== "auto") {
return {
taskType: "publish",
ok: true,
summary: "publish 為 manual 模式,草稿保留待人工審核",
count: 0,
};
}
const used = await countTodaySuccess(accountId, "publish");
const remaining = Math.max(0, dailyCap - used);
if (remaining === 0) {
return { taskType: "publish", ok: true, summary: "今日發文已達上限", count: 0 };
}
const drafts = await prisma.draft.findMany({
where: { accountId, status: "PENDING" },
orderBy: { createdAt: "asc" },
take: remaining,
});
let published = 0;
for (const draft of drafts) {
const result = await autoPublishDraft(draft);
if (result.success) {
published += 1;
await logAction({
accountId,
taskType: "publish",
action: "publish-post",
mode: triggeredBy,
status: "success",
targetId: draft.id,
permalink: result.permalink,
detail: draft.text.slice(0, 60),
});
await humanDelay(8000, 20000);
} else {
await logAction({
accountId,
taskType: "publish",
action: "publish-post",
mode: triggeredBy,
status: "failed",
targetId: draft.id,
error: result.error,
});
}
}
return { taskType: "publish", ok: true, summary: `自動發布 ${published}`, count: published };
}
async function runOutreachTask(
accountId: string,
mode: AutomationMode,
dailyCap: number,
triggeredBy: "auto" | "manual"
): Promise<TaskRunResult> {
// Step 1為近期、相關、尚未生成獲客留言的貼文生成草稿
const since = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
const candidates = await prisma.scanItem.findMany({
where: {
externalId: { not: null },
2026-06-21 16:28:26 +00:00
OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }],
2026-06-21 12:50:31 +00:00
outreachTargets: { none: {} },
scan: { accountId, scanGoal: "placement", createdAt: { gte: since } },
},
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
take: OUTREACH_GENERATE_LIMIT,
});
let drafted = 0;
for (const item of candidates) {
try {
await generateOutreachForScanItem(item.id);
drafted += 1;
} catch {
// 略過個別失敗
}
}
if (mode !== "auto") {
await logAction({
accountId,
taskType: "outreach",
action: "outreach-generate",
mode: triggeredBy,
status: "success",
detail: `生成 ${drafted} 則獲客留言草稿(待審核)`,
});
return {
taskType: "outreach",
ok: true,
summary: `生成 ${drafted} 則留言草稿,待人工審核後留言`,
count: drafted,
};
}
// Step 2auto 模式 → 直接留言推廣(受配額限制)
const used = await countTodaySuccess(accountId, "outreach");
const remaining = Math.max(0, dailyCap - used);
let commented = 0;
if (remaining > 0) {
const credentials = await getActiveThreadsCredentials();
if (!credentials) {
return {
taskType: "outreach",
ok: false,
summary: `生成 ${drafted} 則草稿,但 Threads API 未連線,無法自動留言`,
count: drafted,
};
}
const readyDrafts = await prisma.outreachDraft.findMany({
where: {
status: "PENDING",
outreachTarget: {
status: "DRAFTED",
scanItem: {
externalId: { not: null },
scan: { accountId, scanGoal: "placement" },
},
},
},
orderBy: { createdAt: "asc" },
take: remaining,
include: { outreachTarget: { include: { scanItem: true } } },
});
for (const draft of readyDrafts) {
const externalId = draft.outreachTarget.scanItem.externalId;
if (!externalId) continue;
const result = await replyViaThreadsApi(credentials, {
replyToId: externalId,
text: draft.text,
});
if (result.success) {
commented += 1;
await prisma.$transaction([
prisma.outreachDraft.update({
where: { id: draft.id },
data: { status: "PUBLISHED", publishedAt: new Date() },
}),
prisma.outreachTarget.update({
where: { id: draft.outreachTargetId },
data: { status: "COMMENTED" },
}),
]);
await logAction({
accountId,
taskType: "outreach",
action: "reply-outreach",
mode: triggeredBy,
status: "success",
targetId: draft.id,
externalId,
detail: draft.text.slice(0, 60),
});
await humanDelay(10000, 25000);
} else {
await logAction({
accountId,
taskType: "outreach",
action: "reply-outreach",
mode: triggeredBy,
status: "failed",
targetId: draft.id,
externalId,
error: result.error,
});
}
}
}
return {
taskType: "outreach",
ok: true,
summary: `生成 ${drafted} 則草稿,自動留言 ${commented}`,
count: commented,
};
}
async function runEngagementTask(
accountId: string,
mode: AutomationMode,
dailyCap: number,
triggeredBy: "auto" | "manual"
): Promise<TaskRunResult> {
// Step 1同步自己貼文與底下新留言
try {
await syncThreadsOwnPostsAndInsights({ postsLimit: 25, repliesLimit: 25 });
} catch (error) {
return {
taskType: "engagement",
ok: false,
summary: error instanceof Error ? error.message : "同步留言失敗",
};
}
// Step 2為 NEW 留言生成回覆草稿
const settings = await getOrCreateSettings();
const account = await getActiveAccountProfile();
const apiKeys = parseProviderApiKeys(settings.providerApiKeys);
const newReplies = await prisma.inboundReply.findMany({
where: { status: "NEW", published: { accountId } },
orderBy: { createdAt: "desc" },
take: 20,
include: { published: true },
});
let drafted = 0;
for (const inbound of newReplies) {
try {
const generated = await generateInboundReplyDrafts({
persona: resolvePersona(settings, account),
aiProvider: settings.aiProvider,
aiModel: settings.aiModel,
apiKeys,
publishedText: inbound.published?.text,
replyText: inbound.text,
authorName: inbound.authorName,
count: 2,
});
await prisma.inboundReply.update({
where: { id: inbound.id },
data: { sentiment: generated.sentiment, intent: generated.intent, status: "DRAFTED" },
});
for (const draft of generated.drafts) {
await prisma.replyDraft.create({
data: { inboundReplyId: inbound.id, text: draft.text, rationale: draft.rationale },
});
}
drafted += 1;
} catch {
// 略過個別失敗
}
}
if (mode !== "auto") {
await logAction({
accountId,
taskType: "engagement",
action: "engagement-generate",
mode: triggeredBy,
status: "success",
detail: `生成 ${drafted} 則回覆草稿(待審核)`,
});
return {
taskType: "engagement",
ok: true,
summary: `生成 ${drafted} 則回覆草稿,待人工審核後回覆`,
count: drafted,
};
}
// Step 3auto 模式 → 直接回覆(受配額限制)
const used = await countTodaySuccess(accountId, "engagement");
const remaining = Math.max(0, dailyCap - used);
let replied = 0;
if (remaining > 0) {
const credentials = await getActiveThreadsCredentials();
if (credentials) {
const readyDrafts = await prisma.replyDraft.findMany({
where: {
status: "PENDING",
inboundReply: { externalId: { not: null }, published: { accountId } },
},
orderBy: { createdAt: "asc" },
take: remaining,
include: { inboundReply: true },
});
for (const draft of readyDrafts) {
const externalId = draft.inboundReply.externalId;
if (!externalId) continue;
const result = await replyViaThreadsApi(credentials, {
replyToId: externalId,
text: draft.text,
});
if (result.success) {
replied += 1;
await prisma.$transaction([
prisma.replyDraft.update({
where: { id: draft.id },
data: { status: "PUBLISHED", publishedAt: new Date() },
}),
prisma.inboundReply.update({
where: { id: draft.inboundReplyId },
data: { status: "REPLIED" },
}),
]);
await logAction({
accountId,
taskType: "engagement",
action: "reply-inbound",
mode: triggeredBy,
status: "success",
targetId: draft.id,
externalId,
detail: draft.text.slice(0, 60),
});
await humanDelay(8000, 18000);
} else {
await logAction({
accountId,
taskType: "engagement",
action: "reply-inbound",
mode: triggeredBy,
status: "failed",
targetId: draft.id,
externalId,
error: result.error,
});
}
}
}
}
return {
taskType: "engagement",
ok: true,
summary: `生成 ${drafted} 則草稿,自動回覆 ${replied}`,
count: replied,
};
}
/** 每分鐘輪詢:跑所有「總開關開啟」帳號中「已啟用且排程到點」的規則。 */
export async function runDueAutomationTick(now: Date = new Date()): Promise<TaskRunResult[]> {
const accounts = await prisma.account.findMany({
where: { automationEnabled: true, userId: { not: null } },
});
const results: TaskRunResult[] = [];
for (const account of accounts) {
const rules = await getRulesForAccount(account.id);
for (const rule of rules) {
if (!rule.enabled) continue;
if (!cronMatches(rule.schedule, now)) continue;
const result = await runTaskForAccount(account.id, rule.taskType as AutomationTaskType, {
triggeredBy: "auto",
});
results.push(result);
await prisma.automationRule.update({
where: { id: rule.id },
data: { lastRunAt: now },
});
}
}
return results;
}
/** 總殺停:關掉所有帳號的自動化總開關。 */
export async function killAllAutomation(): Promise<number> {
const result = await prisma.account.updateMany({
data: { automationEnabled: false },
});
return result.count;
}