2 Commits

Author SHA1 Message Date
ABAPPLO
f2ac751c4c feat: 接入企业微信通知(临期催办 + 群机器人汇总)
新增 lib/wecom-client.ts 与 lib/wecom-notifier-service.ts,支持
群机器人 webhook 与应用消息双通道;scheduler 加入临期 N 天催办
与每日管理员汇总;action 路由补 bind_wecom_external_id 与
send_test_wecom 两个管理端动作,配套测试。
2026-08-18 17:05:16 +08:00
巫凤萍
cac6c5e83b fix: 修复回填更新与截图刷新 2026-08-16 22:22:50 +08:00
13 changed files with 763 additions and 11 deletions

View File

@@ -24,6 +24,15 @@ FEISHU_APP_SECRET=
AI_TOOL_CENTER_MCP_URL= AI_TOOL_CENTER_MCP_URL=
AI_TOOL_CENTER_MCP_KEY= AI_TOOL_CENTER_MCP_KEY=
# 企业微信通知(临期催办 + 管理员汇总)。群机器人只需 webhookKOC 侧催办还需 corp/agent/secret
# 并在后台 partners 编辑里把 wecom_external_user_id 填好。
WECOM_ROBOT_WEBHOOK=
WECOM_CORP_ID=
WECOM_AGENT_ID=
WECOM_SECRET=
WECOM_NOTIFY_DUE_DAYS=3
WECOM_NOTIFY_ENABLED=true
# 每天北京时间 09:00 自动执行采集计划。 # 每天北京时间 09:00 自动执行采集计划。
ENABLE_SCHEDULER=true ENABLE_SCHEDULER=true
SEED_DEMO_DATA=false SEED_DEMO_DATA=false

View File

@@ -37,6 +37,13 @@ import {
DistributionReleaseError, DistributionReleaseError,
releaseUnfinishedDistribution, releaseUnfinishedDistribution,
} from "../../../lib/distribution-release-service"; } from "../../../lib/distribution-release-service";
import {
resolveWecomConfig,
sendWecomAppMessage,
sendWecomRobotMessage,
WecomClientError,
type WecomBindings,
} from "../../../lib/wecom-client";
import { isManagerRequest } from "../../../lib/user-auth"; import { isManagerRequest } from "../../../lib/user-auth";
import { extractPublishUrl } from "../../../lib/publish-url"; import { extractPublishUrl } from "../../../lib/publish-url";
@@ -534,6 +541,66 @@ export async function POST(request: Request) {
) )
.bind(exposure, views, distributionId) .bind(exposure, views, distributionId)
.run(); .run();
} else if (body.action === "bind_wecom_external_id") {
const partnerId = String(body.partnerId ?? "").trim().slice(0, 80);
const externalId = String(body.wecomExternalUserId ?? "")
.trim()
.slice(0, 128);
if (!partnerId) {
return Response.json(
{ error: "缺少 partnerId" },
{ status: 400 },
);
}
await db
.prepare(
"UPDATE partners SET wecom_external_user_id = ? WHERE id = ?",
)
.bind(externalId || null, partnerId)
.run();
return Response.json({
partnerId,
wecomExternalUserId: externalId || null,
});
} else if (body.action === "send_test_wecom") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const partnerId = String(body.partnerId ?? "").trim();
let partnerExternalId: string | null = null;
if (partnerId) {
const row = await db
.prepare(
"SELECT wecom_external_user_id FROM partners WHERE id = ?",
)
.bind(partnerId)
.first<{ wecom_external_user_id: string | null }>();
partnerExternalId = row?.wecom_external_user_id ?? null;
}
const testContent = `[KOC LOOP 测试] 群机器人连通性正常,时间 ${new Date().toISOString()}`;
let robotStatus: "ok" | "skipped" = "skipped";
if (wecomConfig.robotWebhook) {
await sendWecomRobotMessage(testContent, wecomConfig);
robotStatus = "ok";
}
let appStatus: "ok" | "skipped" | "failed" = "skipped";
if (
partnerExternalId &&
wecomConfig.corpId &&
wecomConfig.agentId &&
wecomConfig.secret
) {
const result = await sendWecomAppMessage(
[partnerExternalId],
testContent,
wecomConfig,
);
appStatus = result.failed > 0 ? "failed" : "ok";
}
return Response.json({
robot: robotStatus,
app: appStatus,
});
} else { } else {
return Response.json({ error: "不支持的操作" }, { status: 400 }); return Response.json({ error: "不支持的操作" }, { status: 400 });
} }
@@ -545,7 +612,8 @@ export async function POST(request: Request) {
{ {
status: status:
error instanceof FeishuSourceError || error instanceof FeishuSourceError ||
error instanceof DistributionReleaseError error instanceof DistributionReleaseError ||
error instanceof WecomClientError
? error.status ? error.status
: 500, : 500,
}, },

View File

@@ -186,7 +186,12 @@ async function handleGet(request: Request) {
} }
const headers = new Headers(); const headers = new Headers();
object.writeHttpMetadata(headers); object.writeHttpMetadata(headers);
headers.set("Cache-Control", "private, max-age=3600"); const isMutableEvidence =
imageKind === "publish" || imageKind === "creator";
headers.set(
"Cache-Control",
isMutableEvidence ? "private, no-store" : "private, max-age=3600",
);
if (imageKind === "video") { if (imageKind === "video") {
headers.set("Content-Type", "video/mp4"); headers.set("Content-Type", "video/mp4");
} }

View File

@@ -1792,6 +1792,11 @@ footer {
font-weight: 620; font-weight: 620;
} }
.toast.success {
color: var(--green-deep);
font-weight: 720;
}
.loading-shell { .loading-shell {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -127,6 +127,11 @@ function statusLabel(item: Assignment, taskType = "content_publish") {
} }
const MAX_TASK_RESULT_SCREENSHOTS = 9; const MAX_TASK_RESULT_SCREENSHOTS = 9;
const PUBLISH_BACKFILL_SUCCESS = "这篇笔记的发布记录回填成功啦~";
function toastClassName(message: string) {
return message === PUBLISH_BACKFILL_SUCCESS ? "toast success" : "toast";
}
function resultScreenshotKeys(value: string | null) { function resultScreenshotKeys(value: string | null) {
const text = String(value ?? "").trim(); const text = String(value ?? "").trim();
@@ -594,6 +599,11 @@ export default function Home() {
index: "1", index: "1",
kind, kind,
}); });
const evidenceKey =
kind === "publish"
? item.publish_screenshot_key
: item.creator_screenshot_key;
if (evidenceKey) params.set("v", evidenceKey);
if (delegationToken) params.set("share", delegationToken); if (delegationToken) params.set("share", delegationToken);
else { else {
params.set("task", taskToken); params.set("task", taskToken);
@@ -1013,7 +1023,7 @@ export default function Home() {
}); });
}, 120); }, 120);
} }
setToast("这篇笔记已回填,不会与其他笔记错配"); setToast(PUBLISH_BACKFILL_SUCCESS);
} catch (reason) { } catch (reason) {
setToast(reason instanceof Error ? reason.message : "回填失败"); setToast(reason instanceof Error ? reason.message : "回填失败");
} finally { } finally {
@@ -1283,7 +1293,7 @@ export default function Home() {
</form> </form>
</div> </div>
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} /> <ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
{toast && <div className="toast">{toast}</div>} {toast && <div className={toastClassName(toast)}>{toast}</div>}
</main> </main>
); );
} }
@@ -1610,7 +1620,6 @@ export default function Home() {
value={creatorExposure} value={creatorExposure}
onChange={(event) => setCreatorExposure(event.target.value)} onChange={(event) => setCreatorExposure(event.target.value)}
placeholder="填写截图中的曝光量" placeholder="填写截图中的曝光量"
required
/> />
</label> </label>
<label> <label>
@@ -1624,7 +1633,6 @@ export default function Home() {
value={creatorViews} value={creatorViews}
onChange={(event) => setCreatorViews(event.target.value)} onChange={(event) => setCreatorViews(event.target.value)}
placeholder="填写截图中的阅读量" placeholder="填写截图中的阅读量"
required
/> />
</label> </label>
</div> </div>
@@ -1650,7 +1658,7 @@ export default function Home() {
</form> </form>
</div> </div>
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} /> <ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
{toast && <div className="toast">{toast}</div>} {toast && <div className={toastClassName(toast)}>{toast}</div>}
</main> </main>
); );
} }
@@ -1957,7 +1965,7 @@ export default function Home() {
? "请保存当前分享链接;完成任务后通过此链接上传截图即可。" ? "请保存当前分享链接;完成任务后通过此链接上传截图即可。"
: "请保存当前分享链接发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"} : "请保存当前分享链接发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
</div> </div>
{toast && <div className="toast">{toast}</div>} {toast && <div className={toastClassName(toast)}>{toast}</div>}
</main> </main>
); );
} }
@@ -2104,7 +2112,7 @@ export default function Home() {
<div><span>3</span><strong>{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}</strong><p>{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}</p></div> <div><span>3</span><strong>{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}</strong><p>{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}</p></div>
</section> </section>
<footer> KOC LOOP </footer> <footer> KOC LOOP </footer>
{toast && <div className="toast">{toast}</div>} {toast && <div className={toastClassName(toast)}>{toast}</div>}
</main> </main>
); );
} }

View File

@@ -70,7 +70,9 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
assert.match(page, /找回领取记录/); assert.match(page, /找回领取记录/);
assert.match(page, /action:\s*"recover"/); assert.match(page, /action:\s*"recover"/);
assert.match(page, /同一任务多次领取会分批展示/); assert.match(page, /同一任务多次领取会分批展示/);
assert.match(page, /这篇笔记已回填,不会与其他笔记错配/); assert.match(page, /这篇笔记的发布记录回填成功啦~/);
assert.match(page, /toastClassName\(toast\)/);
assert.match(styles, /\.toast\.success/);
assert.match(page, /笔记内容已收起/); assert.match(page, /笔记内容已收起/);
assert.match(page, /展开笔记内容/); assert.match(page, /展开笔记内容/);
assert.match(page, /收起笔记内容/); assert.match(page, /收起笔记内容/);
@@ -92,6 +94,7 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
assert.match(page, /creatorViews/); assert.match(page, /creatorViews/);
assert.match(page, /截图仅用于运营核对不再自动OCR/); assert.match(page, /截图仅用于运营核对不再自动OCR/);
assert.match(page, /evidenceImageUrl/); assert.match(page, /evidenceImageUrl/);
assert.match(page, /params\.set\("v", evidenceKey\)/);
assert.match(page, /evidenceImageUrl\(selected, "publish"\)/); assert.match(page, /evidenceImageUrl\(selected, "publish"\)/);
assert.match(page, /evidenceImageUrl\(selected, "creator"\)/); assert.match(page, /evidenceImageUrl\(selected, "creator"\)/);
assert.match(page, /ImageLightbox/); assert.match(page, /ImageLightbox/);
@@ -100,6 +103,8 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
assert.match(page, /creatorScreenshotPreview/); assert.match(page, /creatorScreenshotPreview/);
assert.match(page, /曝光量/); assert.match(page, /曝光量/);
assert.match(page, /阅读量/); assert.match(page, /阅读量/);
assert.match(page, /placeholder="填写截图中的曝光量"\s*\/>/);
assert.match(page, /placeholder="填写截图中的阅读量"\s*\/>/);
assert.doesNotMatch(page, /recognizeCreatorMetrics/); assert.doesNotMatch(page, /recognizeCreatorMetrics/);
assert.match(page, /note-index \$\{\(isScreenshotTask \? item\.result_submitted_at : item\.publish_url\) \? "done" : ""\}/); assert.match(page, /note-index \$\{\(isScreenshotTask \? item\.result_submitted_at : item\.publish_url\) \? "done" : ""\}/);
assert.match(packageJson, /"fflate":\s*"0\.7\.4"/); assert.match(packageJson, /"fflate":\s*"0\.7\.4"/);

View File

@@ -20,6 +20,12 @@ export type RuntimeEnv = {
AI_TOOL_CENTER_MCP_KEY?: string; AI_TOOL_CENTER_MCP_KEY?: string;
COLLECTION_MCP_URL?: string; COLLECTION_MCP_URL?: string;
COLLECTION_MCP_KEY?: string; COLLECTION_MCP_KEY?: string;
WECOM_CORP_ID?: string;
WECOM_AGENT_ID?: string;
WECOM_SECRET?: string;
WECOM_ROBOT_WEBHOOK?: string;
WECOM_NOTIFY_DUE_DAYS?: string;
WECOM_NOTIFY_ENABLED?: string;
SEED_DEMO_DATA?: string; SEED_DEMO_DATA?: string;
ENABLE_SCHEDULER?: string; ENABLE_SCHEDULER?: string;
}; };

View File

@@ -8,6 +8,11 @@ import {
} from "./mcp-collection-client"; } from "./mcp-collection-client";
import { ensureSchema, getRawDb } from "./mvp-db"; import { ensureSchema, getRawDb } from "./mvp-db";
import { getRuntimeEnv, isEnabled } from "./runtime-env"; import { getRuntimeEnv, isEnabled } from "./runtime-env";
import {
resolveWecomConfig,
type WecomBindings,
} from "./wecom-client";
import { runDueSoonWecomNotifications } from "./wecom-notifier-service";
declare global { declare global {
var __kocLoopScheduler: ScheduledTask | undefined; var __kocLoopScheduler: ScheduledTask | undefined;
@@ -17,14 +22,31 @@ async function runDailyJob() {
await withDatabaseLock("koc-loop-daily-collection", 0, async () => { await withDatabaseLock("koc-loop-daily-collection", 0, async () => {
await ensureSchema(); await ensureSchema();
const db = getRawDb(); const db = getRawDb();
const env = getRuntimeEnv();
const config = resolveCollectionMcpConfig( const config = resolveCollectionMcpConfig(
getRuntimeEnv() as unknown as CollectionMcpBindings, env as unknown as CollectionMcpBindings,
); );
const collections = await runScheduledCollections(db, Date.now(), config); const collections = await runScheduledCollections(db, Date.now(), config);
const accounts = await backfillAccountProfiles(db, config, 10); const accounts = await backfillAccountProfiles(db, config, 10);
let wecom: Awaited<ReturnType<typeof runDueSoonWecomNotifications>> | null =
null;
if (isEnabled(env.WECOM_NOTIFY_ENABLED, true)) {
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
if (
wecomConfig.robotWebhook ||
(wecomConfig.corpId && wecomConfig.agentId && wecomConfig.secret)
) {
try {
wecom = await runDueSoonWecomNotifications(db, wecomConfig);
} catch (error) {
console.error("[KOC LOOP] wecom notify failed", error);
}
}
}
console.info("[KOC LOOP] daily scheduler completed", { console.info("[KOC LOOP] daily scheduler completed", {
collections, collections,
accounts, accounts,
wecom,
}); });
}); });
} }

219
lib/wecom-client.ts Normal file
View File

@@ -0,0 +1,219 @@
const WECOM_API_ORIGIN = "https://qyapi.weixin.qq.com";
const DEFAULT_DUE_DAYS = 3;
export type WecomBindings = {
WECOM_CORP_ID?: string;
WECOM_AGENT_ID?: string;
WECOM_SECRET?: string;
WECOM_ROBOT_WEBHOOK?: string;
WECOM_NOTIFY_DUE_DAYS?: string;
};
export type WecomConfig = {
corpId: string;
agentId: string;
secret: string;
robotWebhook: string;
dueDays: number;
};
type FetchLike = typeof fetch;
type WecomEnvelope = {
errcode?: number;
errmsg?: string;
access_token?: string;
expires_in?: number;
invaliduser?: string;
};
type CachedAccessToken = {
corpId: string;
secret: string;
token: string;
expiresAt: number;
};
let cachedAccessToken: CachedAccessToken | null = null;
export class WecomClientError extends Error {
status: number;
constructor(message: string, status = 502) {
super(message);
this.name = "WecomClientError";
this.status = status;
}
}
function bindingValue(value: unknown) {
return String(value ?? "").trim();
}
function safeMessage(value: unknown) {
return String(value ?? "").trim().slice(0, 240);
}
export function resolveWecomConfig(bindings: WecomBindings): WecomConfig {
return {
corpId: bindingValue(bindings.WECOM_CORP_ID),
agentId: bindingValue(bindings.WECOM_AGENT_ID),
secret: bindingValue(bindings.WECOM_SECRET),
robotWebhook: bindingValue(bindings.WECOM_ROBOT_WEBHOOK),
dueDays: parseDueDays(bindings.WECOM_NOTIFY_DUE_DAYS),
};
}
function parseDueDays(value: string | undefined) {
const parsed = Number(bindingValue(value));
if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_DUE_DAYS;
return Math.min(30, Math.floor(parsed));
}
function hasAppCredentials(config: WecomConfig) {
return Boolean(config.corpId && config.agentId && config.secret);
}
async function readEnvelope(
response: Response,
fallbackMessage: string,
): Promise<WecomEnvelope> {
const text = await response.text();
try {
return JSON.parse(text) as WecomEnvelope;
} catch {
throw new WecomClientError(
`${fallbackMessage}(企业微信返回了非 JSON 响应)`,
502,
);
}
}
function ensureOk(
payload: WecomEnvelope,
fallbackMessage: string,
) {
const code = Number(payload.errcode ?? 0);
if (code === 0) return;
const message = safeMessage(payload.errmsg) || fallbackMessage;
if (code === 40014 || code === 42001) {
throw new WecomClientError(`企业微信 access_token 无效:${message}`, 401);
}
throw new WecomClientError(`${fallbackMessage}${message}`, 502);
}
async function fetchAccessToken(
config: WecomConfig,
fetchImpl: FetchLike,
) {
if (
cachedAccessToken?.corpId === config.corpId &&
cachedAccessToken?.secret === config.secret &&
cachedAccessToken.expiresAt > Date.now() + 60_000
) {
return cachedAccessToken.token;
}
const url = new URL(`${WECOM_API_ORIGIN}/cgi-bin/gettoken`);
url.searchParams.set("corpid", config.corpId);
url.searchParams.set("corpsecret", config.secret);
const response = await fetchImpl(url.toString(), {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "获取企业微信 access_token 失败");
if (!response.ok) {
throw new WecomClientError(
`获取企业微信 access_token 失败HTTP ${response.status}`,
502,
);
}
ensureOk(payload, "获取企业微信 access_token 失败");
const token = bindingValue(payload.access_token);
if (!token) {
throw new WecomClientError("企业微信未返回有效 access_token", 502);
}
cachedAccessToken = {
corpId: config.corpId,
secret: config.secret,
token,
expiresAt:
Date.now() + Math.max(300, Number(payload.expires_in) || 7_200) * 1_000,
};
return token;
}
export async function sendWecomRobotMessage(
content: string,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<void> {
if (!config.robotWebhook) {
console.warn("[KOC LOOP] wecom robot webhook not configured, skipping");
return;
}
const response = await fetchImpl(config.robotWebhook, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
msgtype: "text",
text: { content },
}),
signal: AbortSignal.timeout(10_000),
});
const payload = await readEnvelope(response, "企业微信群机器人推送失败");
if (!response.ok) {
throw new WecomClientError(
`企业微信群机器人推送失败HTTP ${response.status}`,
502,
);
}
ensureOk(payload, "企业微信群机器人推送失败");
}
export async function sendWecomAppMessage(
externalUserIds: string[],
content: string,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<{ sent: number; failed: number; skipped: boolean }> {
const normalized = externalUserIds
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (normalized.length === 0) {
return { sent: 0, failed: 0, skipped: true };
}
if (!hasAppCredentials(config)) {
return { sent: 0, failed: 0, skipped: true };
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
touser: normalized.join("|"),
msgtype: "text",
agentid: Number(config.agentId),
text: { content },
}),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "企业微信应用消息推送失败");
if (!response.ok) {
throw new WecomClientError(
`企业微信应用消息推送失败HTTP ${response.status}`,
502,
);
}
ensureOk(payload, "企业微信应用消息推送失败");
const invalid = bindingValue(payload.invaliduser).split("|").filter(Boolean);
return {
sent: Math.max(0, normalized.length - invalid.length),
failed: invalid.length,
skipped: false,
};
}
export function clearWecomAccessTokenCacheForTests() {
cachedAccessToken = null;
}

View File

@@ -0,0 +1,171 @@
import type { DatabaseClient } from "./database";
import { shanghaiDateFromTimestamp } from "./collection-service";
import {
sendWecomAppMessage,
sendWecomRobotMessage,
type WecomConfig,
} from "./wecom-client";
import { getRuntimeEnv } from "./runtime-env";
type FetchLike = typeof fetch;
export type WecomNotifySummary = {
dueSoonAttempted: number;
dueSoonSent: number;
dueSoonFailed: number;
dueSoonSkipped: number;
digestSent: boolean;
};
type DueSoonRow = {
distribution_id: string;
partner_id: string;
partner_name: string;
wecom_external_user_id: string | null;
task_name: string;
due_at: string;
content_title: string;
};
export function computeDueCutoff(today: string, dueDays: number) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today);
if (!match) return today;
const [, y, m, d] = match;
const date = new Date(
Date.UTC(Number(y), Number(m) - 1, Number(d)) + dueDays * 24 * 60 * 60 * 1_000,
);
return date.toISOString().slice(0, 10);
}
export async function runDueSoonWecomNotifications(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
): Promise<WecomNotifySummary> {
const today = shanghaiDateFromTimestamp(now);
const cutoff = computeDueCutoff(today, config.dueDays);
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
const result = await db
.prepare(
`SELECT
d.id AS distribution_id,
d.partner_id,
p.name AS partner_name,
p.wecom_external_user_id,
t.name AS task_name,
t.due_at,
c.title AS content_title
FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id
JOIN contents c ON c.id = d.content_id
WHERE (d.publish_url IS NULL OR d.publish_url = '')
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at <= ?
ORDER BY t.due_at ASC, p.name ASC`,
)
.bind(cutoff)
.all<DueSoonRow>();
const grouped = new Map<
string,
{
partnerName: string;
externalUserId: string | null;
rows: DueSoonRow[];
}
>();
for (const row of result.results) {
const entry = grouped.get(row.partner_id) ?? {
partnerName: row.partner_name,
externalUserId: row.wecom_external_user_id,
rows: [],
};
entry.rows.push(row);
if (!entry.externalUserId && row.wecom_external_user_id) {
entry.externalUserId = row.wecom_external_user_id;
}
grouped.set(row.partner_id, entry);
}
let dueSoonAttempted = 0;
let dueSoonSent = 0;
let dueSoonFailed = 0;
let dueSoonSkipped = 0;
const digestTasks: string[] = [];
for (const [, entry] of grouped) {
dueSoonAttempted += 1;
const external = entry.externalUserId
? [entry.externalUserId]
: [];
const lines = entry.rows.slice(0, 5).map((row) => {
return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(截止 ${row.due_at}`;
});
const overflow =
entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5}` : "";
const link = portalUrl ? `\n领取链接${portalUrl}` : "";
const content =
`${entry.partnerName},你有 ${entry.rows.length} 条内容待发布:\n${lines.join("\n")}${overflow}${link}`;
const appResult = await sendWecomAppMessage(
external,
content,
config,
fetchImpl,
).catch((error: unknown) => {
console.warn(
"[KOC LOOP] wecom app message failed",
{ partner: entry.partnerName, error: safeError(error) },
);
return null;
});
if (appResult === null) {
dueSoonFailed += 1;
} else if (appResult.skipped) {
dueSoonSkipped += 1;
} else {
dueSoonSent += 1;
}
const earliestDue = entry.rows[0]?.due_at ?? "";
digestTasks.push(
`· ${entry.partnerName}${entry.rows.length} 条,最近截止 ${earliestDue}`,
);
}
let digestSent = false;
if (config.robotWebhook && digestTasks.length > 0) {
const digest =
`今日待发布催办(${today},截止 ≤ ${cutoff}\n${digestTasks.join("\n")}`;
try {
await sendWecomRobotMessage(digest, config, fetchImpl);
digestSent = true;
} catch (error) {
console.warn(
"[KOC LOOP] wecom robot digest failed",
{ error: safeError(error) },
);
}
}
return {
dueSoonAttempted,
dueSoonSent,
dueSoonFailed,
dueSoonSkipped,
digestSent,
};
}
function bindingValue(value: unknown) {
return String(value ?? "").trim();
}
function truncate(value: string, max: number) {
return value.length > max ? `${value.slice(0, max)}` : value;
}
function safeError(error: unknown) {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -200,6 +200,8 @@ test("issues external task links and supports one-to-one note submissions", asyn
assert.match(imageRoute, /creator-center\//); assert.match(imageRoute, /creator-center\//);
assert.match(imageRoute, /imageKind === "publish"/); assert.match(imageRoute, /imageKind === "publish"/);
assert.match(imageRoute, /imageKind === "creator"/); assert.match(imageRoute, /imageKind === "creator"/);
assert.match(imageRoute, /isMutableEvidence/);
assert.match(imageRoute, /"private, no-store"/);
assert.match(imageRoute, /Content-Type", "video\/mp4"/); assert.match(imageRoute, /Content-Type", "video\/mp4"/);
assert.match(imageRoute, /video-\$\{imageIndex\}\.mp4/); assert.match(imageRoute, /video-\$\{imageIndex\}\.mp4/);
assert.match(imageRoute, /downloadRequested \? "attachment" : "inline"/); assert.match(imageRoute, /downloadRequested \? "attachment" : "inline"/);

217
tests/wecom-client.test.mjs Normal file
View File

@@ -0,0 +1,217 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearWecomAccessTokenCacheForTests,
resolveWecomConfig,
sendWecomAppMessage,
sendWecomRobotMessage,
WecomClientError,
} from "../lib/wecom-client.ts";
const baseBindings = {
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
WECOM_ROBOT_WEBHOOK:
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=robot-test",
WECOM_NOTIFY_DUE_DAYS: "3",
};
function fakeWecom(options = {}) {
const calls = [];
const fetchImpl = async (input, init = {}) => {
const url = new URL(String(input));
calls.push({ url, init });
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
return Response.json({
errcode: 0,
errmsg: "ok",
access_token: "token-test",
expires_in: 7200,
});
}
if (url.pathname.endsWith("/cgi-bin/webhook/send")) {
return Response.json({
errcode: options.robotErrcode ?? 0,
errmsg: options.robotErrmsg ?? "ok",
});
}
if (url.pathname.endsWith("/cgi-bin/message/send")) {
const body = JSON.parse(String(init.body ?? "{}"));
const invalid = options.invalidUser
? body.touser.split("|").filter((u) => u === options.invalidUser)
: [];
return Response.json({
errcode: 0,
errmsg: "ok",
invaliduser: invalid.join("|"),
});
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl };
}
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
test("resolveWecomConfig applies dueDays and trims values", () => {
const config = resolveWecomConfig({
WECOM_CORP_ID: " corp ",
WECOM_AGENT_ID: " 10 ",
WECOM_SECRET: " s ",
WECOM_ROBOT_WEBHOOK: " https://hook ",
WECOM_NOTIFY_DUE_DAYS: "5",
});
assert.equal(config.corpId, "corp");
assert.equal(config.agentId, "10");
assert.equal(config.secret, "s");
assert.equal(config.robotWebhook, "https://hook");
assert.equal(config.dueDays, 5);
});
test("resolveWecomConfig falls back to default dueDays for bad input", () => {
assert.equal(resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "0" }).dueDays, 3);
assert.equal(resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "abc" }).dueDays, 3);
assert.equal(
resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "100" }).dueDays,
30,
);
});
test("sendWecomRobotMessage posts text payload and returns void", async () => {
const { calls, fetchImpl } = fakeWecom();
await sendWecomRobotMessage(
"hello",
resolveWecomConfig(baseBindings),
fetchImpl,
);
const hookCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/webhook/send"),
);
assert.ok(hookCall, "robot webhook was called");
const body = JSON.parse(String(hookCall.init.body));
assert.equal(body.msgtype, "text");
assert.equal(body.text.content, "hello");
});
test("sendWecomRobotMessage skips silently when webhook is empty", async () => {
const { calls, fetchImpl } = fakeWecom();
const config = resolveWecomConfig({ ...baseBindings, WECOM_ROBOT_WEBHOOK: "" });
await sendWecomRobotMessage("hello", config, fetchImpl);
assert.equal(
calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/webhook/send"),
).length,
0,
);
});
test("sendWecomRobotMessage throws WecomClientError on provider error", async () => {
const { fetchImpl } = fakeWecom({
robotErrcode: 93000,
robotErrmsg: "invalid webhook url",
});
await assert.rejects(
() =>
sendWecomRobotMessage(
"hello",
resolveWecomConfig(baseBindings),
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 502);
return true;
},
);
});
test("sendWecomAppMessage fetches access_token then sends to message/send", async () => {
const { calls, fetchImpl } = fakeWecom();
const result = await sendWecomAppMessage(
["user-a", "user-b"],
"催办内容",
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(result.sent, 2);
assert.equal(result.failed, 0);
assert.equal(result.skipped, false);
const tokenCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/gettoken"),
);
assert.ok(tokenCall, "gettoken was called");
const sendCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/message/send"),
);
assert.ok(sendCall, "message/send was called");
assert.equal(sendCall.url.searchParams.get("access_token"), "token-test");
const body = JSON.parse(String(sendCall.init.body));
assert.equal(body.touser, "user-a|user-b");
assert.equal(body.agentid, 1000001);
assert.equal(body.text.content, "催办内容");
});
test("sendWecomAppMessage skips when no external user ids", async () => {
const { calls, fetchImpl } = fakeWecom();
const result = await sendWecomAppMessage(
[],
"催办",
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(result.skipped, true);
assert.equal(
calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/message/send"),
).length,
0,
);
});
test("sendWecomAppMessage skips when corp credentials are missing", async () => {
const { calls, fetchImpl } = fakeWecom();
const result = await sendWecomAppMessage(
["user-a"],
"催办",
resolveWecomConfig({
...baseBindings,
WECOM_CORP_ID: "",
WECOM_AGENT_ID: "",
WECOM_SECRET: "",
}),
fetchImpl,
);
assert.equal(result.skipped, true);
assert.equal(
calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/gettoken"),
).length,
0,
);
});
test("sendWecomAppMessage reports failed when provider marks invaliduser", async () => {
const { fetchImpl } = fakeWecom({ invalidUser: "user-a" });
const result = await sendWecomAppMessage(
["user-a", "user-b"],
"催办",
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(result.sent, 1);
assert.equal(result.failed, 1);
});
test("access_token is cached across calls", async () => {
const { calls, fetchImpl } = fakeWecom();
const config = resolveWecomConfig(baseBindings);
await sendWecomAppMessage(["u1"], "a", config, fetchImpl);
await sendWecomAppMessage(["u2"], "b", config, fetchImpl);
const tokenCalls = calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/gettoken"),
);
assert.equal(tokenCalls.length, 1, "access_token cached for second call");
});

View File

@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import { computeDueCutoff } from "../lib/wecom-notifier-service.ts";
test("computeDueCutoff advances by dueDays and crosses month/year", () => {
assert.equal(computeDueCutoff("2026-08-18", 3), "2026-08-21");
assert.equal(computeDueCutoff("2026-08-30", 3), "2026-09-02");
assert.equal(computeDueCutoff("2026-12-30", 3), "2027-01-02");
assert.equal(computeDueCutoff("2026-08-18", 0), "2026-08-18");
assert.equal(computeDueCutoff("2026-08-18", 10), "2026-08-28");
});
test("computeDueCutoff returns input unchanged when malformed", () => {
assert.equal(computeDueCutoff("not-a-date", 3), "not-a-date");
});