1822 lines
68 KiB
JavaScript
Executable File
1822 lines
68 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
|
|
|
|
// mindspace-sandbox-mcp.mjs
|
|
import path2 from "node:path";
|
|
import fs2 from "node:fs";
|
|
import readline from "node:readline";
|
|
import { execFileSync } from "node:child_process";
|
|
import mysql from "mysql2/promise";
|
|
|
|
// schedule-service.mjs
|
|
import crypto from "node:crypto";
|
|
|
|
// schedule-time.mjs
|
|
var DEFAULT_TIMEZONE = "Asia/Shanghai";
|
|
function pad2(value) {
|
|
return String(value).padStart(2, "0");
|
|
}
|
|
function normalizeTimezone(timezone) {
|
|
return String(timezone || process.env.H5_DEFAULT_TIMEZONE || DEFAULT_TIMEZONE).trim() || DEFAULT_TIMEZONE;
|
|
}
|
|
function getLocalParts(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
|
|
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
timeZone: normalizeTimezone(timezone),
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: false
|
|
});
|
|
const parts = Object.fromEntries(
|
|
formatter.formatToParts(new Date(epochMs)).map((part) => [part.type, part.value])
|
|
);
|
|
return {
|
|
year: Number(parts.year),
|
|
month: Number(parts.month),
|
|
day: Number(parts.day),
|
|
hour: Number(parts.hour === "24" ? 0 : parts.hour),
|
|
minute: Number(parts.minute),
|
|
second: Number(parts.second)
|
|
};
|
|
}
|
|
function localDateLabel(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
|
|
const parts = getLocalParts(epochMs, timezone);
|
|
return `${parts.month}\u6708${parts.day}\u65E5`;
|
|
}
|
|
function startOfLocalDay(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
|
|
const parts = getLocalParts(epochMs, timezone);
|
|
return zonedTimeToEpochMs(
|
|
{
|
|
year: parts.year,
|
|
month: parts.month,
|
|
day: parts.day,
|
|
hour: 0,
|
|
minute: 0,
|
|
second: 0
|
|
},
|
|
timezone
|
|
);
|
|
}
|
|
function addLocalDays(epochMs, days, timezone = DEFAULT_TIMEZONE) {
|
|
const parts = getLocalParts(epochMs, timezone);
|
|
const utc = Date.UTC(parts.year, parts.month - 1, parts.day + Number(days || 0), parts.hour, parts.minute, parts.second);
|
|
const shifted = new Date(utc);
|
|
return zonedTimeToEpochMs(
|
|
{
|
|
year: shifted.getUTCFullYear(),
|
|
month: shifted.getUTCMonth() + 1,
|
|
day: shifted.getUTCDate(),
|
|
hour: shifted.getUTCHours(),
|
|
minute: shifted.getUTCMinutes(),
|
|
second: shifted.getUTCSeconds()
|
|
},
|
|
timezone
|
|
);
|
|
}
|
|
function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE) {
|
|
const tz = normalizeTimezone(timezone);
|
|
let guess = Date.UTC(
|
|
Number(parts.year),
|
|
Number(parts.month) - 1,
|
|
Number(parts.day),
|
|
Number(parts.hour ?? 0),
|
|
Number(parts.minute ?? 0),
|
|
Number(parts.second ?? 0),
|
|
0
|
|
);
|
|
for (let i = 0; i < 3; i += 1) {
|
|
const actual = getLocalParts(guess, tz);
|
|
const actualAsUtc = Date.UTC(
|
|
actual.year,
|
|
actual.month - 1,
|
|
actual.day,
|
|
actual.hour,
|
|
actual.minute,
|
|
actual.second,
|
|
0
|
|
);
|
|
const desiredAsUtc = Date.UTC(
|
|
Number(parts.year),
|
|
Number(parts.month) - 1,
|
|
Number(parts.day),
|
|
Number(parts.hour ?? 0),
|
|
Number(parts.minute ?? 0),
|
|
Number(parts.second ?? 0),
|
|
0
|
|
);
|
|
const delta = actualAsUtc - desiredAsUtc;
|
|
if (delta === 0) break;
|
|
guess -= delta;
|
|
}
|
|
return guess;
|
|
}
|
|
function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE, now = Date.now() }) {
|
|
const tz = normalizeTimezone(timezone);
|
|
const todayStart = startOfLocalDay(now, tz);
|
|
const todayParts = getLocalParts(todayStart, tz);
|
|
let runAt = zonedTimeToEpochMs(
|
|
{
|
|
year: todayParts.year,
|
|
month: todayParts.month,
|
|
day: todayParts.day,
|
|
hour: Number(hour),
|
|
minute: Number(minute),
|
|
second: 0
|
|
},
|
|
tz
|
|
);
|
|
if (runAt <= now) {
|
|
const tomorrowStart = addLocalDays(todayStart, 1, tz);
|
|
const tomorrowParts = getLocalParts(tomorrowStart, tz);
|
|
runAt = zonedTimeToEpochMs(
|
|
{
|
|
year: tomorrowParts.year,
|
|
month: tomorrowParts.month,
|
|
day: tomorrowParts.day,
|
|
hour: Number(hour),
|
|
minute: Number(minute),
|
|
second: 0
|
|
},
|
|
tz
|
|
);
|
|
}
|
|
return runAt;
|
|
}
|
|
function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) {
|
|
const parts = getLocalParts(epochMs, timezone);
|
|
return `${pad2(parts.hour)}:${pad2(parts.minute)}`;
|
|
}
|
|
function parseLocalDateTimeString(value, timezone = DEFAULT_TIMEZONE) {
|
|
const raw = String(value ?? "").trim();
|
|
if (!raw) return null;
|
|
const match = raw.match(
|
|
/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/
|
|
);
|
|
if (!match) {
|
|
throw new Error(`\u65E0\u6CD5\u89E3\u6790\u672C\u5730\u65F6\u95F4\u300C${raw}\u300D\uFF0C\u8BF7\u4F7F\u7528 YYYY-MM-DD HH:mm`);
|
|
}
|
|
return zonedTimeToEpochMs(
|
|
{
|
|
year: Number(match[1]),
|
|
month: Number(match[2]),
|
|
day: Number(match[3]),
|
|
hour: Number(match[4] ?? 0),
|
|
minute: Number(match[5] ?? 0),
|
|
second: Number(match[6] ?? 0)
|
|
},
|
|
timezone
|
|
);
|
|
}
|
|
function resolveScheduleTimestamp({
|
|
epochMs = null,
|
|
localString = null,
|
|
timezone = DEFAULT_TIMEZONE,
|
|
fieldName = "\u65F6\u95F4",
|
|
now = Date.now()
|
|
} = {}) {
|
|
const local = String(localString ?? "").trim();
|
|
if (local) return parseLocalDateTimeString(local, timezone);
|
|
if (epochMs == null || epochMs === "") return null;
|
|
return assertReasonableScheduleEpoch(epochMs, { now, fieldName });
|
|
}
|
|
function assertReasonableScheduleEpoch(epochMs, { now = Date.now(), fieldName = "\u65F6\u95F4", maxPastDays = 2, maxFutureDays = 400 } = {}) {
|
|
const safe = Number(epochMs);
|
|
if (!Number.isFinite(safe) || safe <= 0) {
|
|
throw new Error(`${fieldName}\u65E0\u6548\uFF0C\u8BF7\u6539\u7528 YYYY-MM-DD HH:mm \u7684 local \u5B57\u6BB5`);
|
|
}
|
|
const min = now - maxPastDays * 864e5;
|
|
const max = now + maxFutureDays * 864e5;
|
|
if (safe < min || safe > max) {
|
|
const label = new Intl.DateTimeFormat("zh-CN", {
|
|
timeZone: normalizeTimezone(process.env.H5_DEFAULT_TIMEZONE),
|
|
dateStyle: "short",
|
|
timeStyle: "short"
|
|
}).format(new Date(safe));
|
|
throw new Error(
|
|
`${fieldName}\uFF08${label}\uFF09\u8D85\u51FA\u5408\u7406\u8303\u56F4\u3002\u8BF7\u6539\u7528 startLocal/remindLocal\uFF08YYYY-MM-DD HH:mm\uFF0C\u7528\u6237\u65F6\u533A\uFF09\uFF0C\u7981\u6B62\u81EA\u884C\u4F30\u7B97 Unix \u6BEB\u79D2\u3002`
|
|
);
|
|
}
|
|
return safe;
|
|
}
|
|
|
|
// schedule-service.mjs
|
|
var DEFAULT_TIMEZONE2 = "Asia/Shanghai";
|
|
function nowMs() {
|
|
return Date.now();
|
|
}
|
|
function rowToItem(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
kind: row.kind,
|
|
title: row.title,
|
|
description: row.description ?? null,
|
|
status: row.status,
|
|
startAt: row.start_at == null ? null : Number(row.start_at),
|
|
endAt: row.end_at == null ? null : Number(row.end_at),
|
|
dueAt: row.due_at == null ? null : Number(row.due_at),
|
|
allDay: Boolean(row.all_day),
|
|
timezone: row.timezone || DEFAULT_TIMEZONE2,
|
|
location: row.location ?? null,
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at)
|
|
};
|
|
}
|
|
function rowToDigest(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
hour: Number(row.hour),
|
|
minute: Number(row.minute),
|
|
timezone: row.timezone || DEFAULT_TIMEZONE2,
|
|
channel: row.channel || "wechat",
|
|
status: row.status,
|
|
nextRunAt: Number(row.next_run_at),
|
|
lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at),
|
|
attempts: Number(row.attempts ?? 0)
|
|
};
|
|
}
|
|
function rowToBalanceAlert(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
thresholdCents: Number(row.threshold_cents),
|
|
channel: row.channel || "wechat",
|
|
status: row.status,
|
|
nextRunAt: Number(row.next_run_at),
|
|
lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at),
|
|
lastNotifiedBalanceCents: row.last_notified_balance_cents == null ? null : Number(row.last_notified_balance_cents),
|
|
attempts: Number(row.attempts ?? 0),
|
|
lastError: row.last_error ?? null,
|
|
lockedUntil: row.locked_until == null ? null : Number(row.locked_until),
|
|
sourceChannel: row.source_channel ?? null,
|
|
sourceSessionId: row.source_session_id ?? null,
|
|
sourceMessageId: row.source_message_id ?? null,
|
|
sourceText: row.source_text ?? null
|
|
};
|
|
}
|
|
function rowToReminder(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
itemId: row.item_id,
|
|
remindAt: Number(row.remind_at),
|
|
offsetMinutes: row.offset_minutes == null ? null : Number(row.offset_minutes),
|
|
channel: row.channel || "wechat",
|
|
status: row.status,
|
|
attempts: Number(row.attempts ?? 0),
|
|
lastError: row.last_error ?? null,
|
|
lockedUntil: row.locked_until == null ? null : Number(row.locked_until),
|
|
sentAt: row.sent_at == null ? null : Number(row.sent_at),
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at)
|
|
};
|
|
}
|
|
function rowToReminderWithItem(row) {
|
|
const reminder = rowToReminder(row);
|
|
if (!reminder) return null;
|
|
return {
|
|
...reminder,
|
|
itemTitle: String(row.item_title ?? "").trim(),
|
|
itemKind: row.item_kind === "event" ? "event" : "task",
|
|
itemTimezone: row.item_timezone || DEFAULT_TIMEZONE2,
|
|
itemStartAt: row.item_start_at == null ? null : Number(row.item_start_at),
|
|
itemEndAt: row.item_end_at == null ? null : Number(row.item_end_at)
|
|
};
|
|
}
|
|
function parseJsonColumn(value) {
|
|
if (value == null || value === "") return null;
|
|
if (typeof value === "string") {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (typeof value === "object") return value;
|
|
return null;
|
|
}
|
|
function rowToUserNotification(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
channel: row.channel,
|
|
notificationType: row.notification_type,
|
|
title: row.title,
|
|
body: row.body,
|
|
data: parseJsonColumn(row.data_json),
|
|
status: row.status,
|
|
readAt: row.read_at == null ? null : Number(row.read_at),
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at)
|
|
};
|
|
}
|
|
function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE2 } = {}) {
|
|
const date = localDateLabel(now, timezone);
|
|
if (!items.length) {
|
|
return `${date} \u5F85\u529E\u8BB0\u5F55
|
|
|
|
\u4ECA\u5929\u6682\u65F6\u6CA1\u6709\u5F85\u529E\u3002`;
|
|
}
|
|
const lines = [`${date} \u5F85\u529E\u8BB0\u5F55`, ""];
|
|
items.forEach((item, index) => {
|
|
const time = item.dueAt || item.startAt ? `\uFF08${formatLocalTime(item.dueAt || item.startAt, timezone)}\uFF09` : "";
|
|
lines.push(`${index + 1}. ${item.title}${time}`);
|
|
});
|
|
return lines.join("\n");
|
|
}
|
|
function createScheduleService(pool, options = {}) {
|
|
const defaultTimezone = normalizeTimezone(options.defaultTimezone || process.env.H5_DEFAULT_TIMEZONE);
|
|
const clock = options.clock || { now: nowMs };
|
|
const createItem = async ({
|
|
userId,
|
|
kind = "task",
|
|
title,
|
|
description = null,
|
|
startAt = null,
|
|
endAt = null,
|
|
dueAt = null,
|
|
allDay = false,
|
|
timezone = defaultTimezone,
|
|
location = null,
|
|
sourceChannel = "agent",
|
|
sourceSessionId = null,
|
|
sourceMessageId = null,
|
|
sourceText = null,
|
|
metadata = null
|
|
}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const cleanTitle = String(title ?? "").trim();
|
|
if (!cleanTitle) throw new Error("\u7F3A\u5C11\u5F85\u529E\u6807\u9898");
|
|
const safeKind = kind === "event" ? "event" : "task";
|
|
const id = crypto.randomUUID();
|
|
const ts = clock.now();
|
|
await pool.query(
|
|
`INSERT INTO h5_schedule_items
|
|
(id, user_id, kind, title, description, status, start_at, end_at, due_at, all_day,
|
|
timezone, location, source_channel, source_session_id, source_message_id, source_text,
|
|
metadata_json, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
id,
|
|
userId,
|
|
safeKind,
|
|
cleanTitle,
|
|
description,
|
|
startAt,
|
|
endAt,
|
|
dueAt,
|
|
allDay ? 1 : 0,
|
|
normalizeTimezone(timezone),
|
|
location,
|
|
sourceChannel,
|
|
sourceSessionId,
|
|
sourceMessageId,
|
|
sourceText,
|
|
metadata ? JSON.stringify(metadata) : null,
|
|
ts,
|
|
ts
|
|
]
|
|
);
|
|
return {
|
|
id,
|
|
userId,
|
|
kind: safeKind,
|
|
title: cleanTitle,
|
|
startAt,
|
|
endAt,
|
|
dueAt,
|
|
allDay: Boolean(allDay),
|
|
timezone: normalizeTimezone(timezone),
|
|
status: "active",
|
|
createdAt: ts,
|
|
updatedAt: ts
|
|
};
|
|
};
|
|
const listItems = async ({ userId, from = null, to = null, status = "active", limit = 100 } = {}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const clauses = ["user_id = ?", "deleted_at IS NULL"];
|
|
const params = [userId];
|
|
if (status) {
|
|
clauses.push("status = ?");
|
|
params.push(status);
|
|
}
|
|
if (from != null && to != null) {
|
|
clauses.push(
|
|
`(
|
|
(start_at IS NOT NULL AND start_at >= ? AND start_at < ?)
|
|
OR (due_at IS NOT NULL AND due_at >= ? AND due_at < ?)
|
|
OR (start_at IS NULL AND due_at IS NULL)
|
|
)`
|
|
);
|
|
params.push(from, to, from, to);
|
|
}
|
|
params.push(Math.max(1, Math.min(500, Number(limit) || 100)));
|
|
const [rows] = await pool.query(
|
|
`SELECT *
|
|
FROM h5_schedule_items
|
|
WHERE ${clauses.join(" AND ")}
|
|
ORDER BY COALESCE(start_at, due_at, 9223372036854775807), created_at
|
|
LIMIT ?`,
|
|
params
|
|
);
|
|
return rows.map(rowToItem);
|
|
};
|
|
const listItemsBySourceMessage = async ({ userId, sourceMessageId, limit = 20 } = {}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const cleanSourceMessageId = String(sourceMessageId ?? "").trim();
|
|
if (!cleanSourceMessageId) return [];
|
|
const [rows] = await pool.query(
|
|
`SELECT *
|
|
FROM h5_schedule_items
|
|
WHERE user_id = ?
|
|
AND source_message_id = ?
|
|
AND deleted_at IS NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`,
|
|
[userId, cleanSourceMessageId, Math.max(1, Math.min(100, Number(limit) || 20))]
|
|
);
|
|
return rows.map(rowToItem);
|
|
};
|
|
const listTodayTodoItems = async ({ userId, timezone = defaultTimezone, now = clock.now() } = {}) => {
|
|
const start = startOfLocalDay(now, timezone);
|
|
const end = addLocalDays(start, 1, timezone);
|
|
return listItems({ userId, from: start, to: end, status: "active", limit: 200 });
|
|
};
|
|
const listUpcomingItems = async ({
|
|
userId,
|
|
timezone = defaultTimezone,
|
|
days = 7,
|
|
now = clock.now()
|
|
} = {}) => {
|
|
const safeDays = Math.max(1, Math.min(30, Number(days) || 7));
|
|
const start = startOfLocalDay(now, timezone);
|
|
const end = addLocalDays(start, safeDays, timezone);
|
|
return listItems({ userId, from: start, to: end, status: "active", limit: 200 });
|
|
};
|
|
const listUpcomingReminders = async ({
|
|
userId,
|
|
timezone = defaultTimezone,
|
|
days = 7,
|
|
now = clock.now(),
|
|
limit = 100
|
|
} = {}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const safeDays = Math.max(1, Math.min(30, Number(days) || 7));
|
|
const start = startOfLocalDay(now, timezone);
|
|
const end = addLocalDays(start, safeDays, timezone);
|
|
const [rows] = await pool.query(
|
|
`SELECT r.*,
|
|
i.title AS item_title,
|
|
i.kind AS item_kind,
|
|
i.timezone AS item_timezone,
|
|
i.start_at AS item_start_at,
|
|
i.end_at AS item_end_at
|
|
FROM h5_schedule_reminders r
|
|
INNER JOIN h5_schedule_items i ON i.id = r.item_id
|
|
WHERE r.user_id = ?
|
|
AND r.status IN ('pending', 'locked', 'sent')
|
|
AND r.remind_at >= ?
|
|
AND r.remind_at < ?
|
|
AND i.deleted_at IS NULL
|
|
AND i.status = 'active'
|
|
ORDER BY r.remind_at ASC
|
|
LIMIT ?`,
|
|
[
|
|
userId,
|
|
start,
|
|
end,
|
|
Math.max(1, Math.min(200, Number(limit) || 100))
|
|
]
|
|
);
|
|
return rows.map(rowToReminderWithItem);
|
|
};
|
|
const getItem = async ({ userId, itemId }) => {
|
|
if (!userId || !itemId) throw new Error("\u7F3A\u5C11\u4E8B\u9879\u53C2\u6570");
|
|
const [rows] = await pool.query(
|
|
`SELECT *
|
|
FROM h5_schedule_items
|
|
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[itemId, userId]
|
|
);
|
|
return rowToItem(rows[0]);
|
|
};
|
|
const getReminder = async ({ userId, reminderId }) => {
|
|
if (!userId || !reminderId) throw new Error("\u7F3A\u5C11\u63D0\u9192\u53C2\u6570");
|
|
const [rows] = await pool.query(
|
|
`SELECT r.*,
|
|
i.title AS item_title,
|
|
i.kind AS item_kind,
|
|
i.timezone AS item_timezone,
|
|
i.start_at AS item_start_at,
|
|
i.end_at AS item_end_at
|
|
FROM h5_schedule_reminders r
|
|
INNER JOIN h5_schedule_items i ON i.id = r.item_id
|
|
WHERE r.id = ? AND r.user_id = ? AND i.deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[reminderId, userId]
|
|
);
|
|
return rowToReminderWithItem(rows[0]);
|
|
};
|
|
const cancelReminder = async ({ userId, reminderId, reason = "\u7528\u6237\u5FFD\u7565" } = {}) => {
|
|
const reminder = await getReminder({ userId, reminderId });
|
|
if (!reminder) throw new Error("\u63D0\u9192\u4E0D\u5B58\u5728\u6216\u65E0\u6743\u8BBF\u95EE");
|
|
if (reminder.status === "cancelled") return reminder;
|
|
if (reminder.status === "sent") throw new Error("\u5DF2\u901A\u77E5\u7684\u63D0\u9192\u4E0D\u80FD\u5FFD\u7565");
|
|
return markReminderCancelled(reminder, reason);
|
|
};
|
|
const deleteReminder = async ({ userId, reminderId }) => {
|
|
if (!userId || !reminderId) throw new Error("\u7F3A\u5C11\u63D0\u9192\u53C2\u6570");
|
|
const [result] = await pool.query(
|
|
`DELETE FROM h5_schedule_reminders WHERE id = ? AND user_id = ?`,
|
|
[reminderId, userId]
|
|
);
|
|
return Number(result?.affectedRows ?? 0) > 0;
|
|
};
|
|
const deleteReminders = async ({ userId, reminderIds }) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const ids = [...new Set((reminderIds ?? []).map((id) => String(id ?? "").trim()).filter(Boolean))];
|
|
if (ids.length === 0) return 0;
|
|
const [result] = await pool.query(
|
|
`DELETE FROM h5_schedule_reminders WHERE user_id = ? AND id IN (${ids.map(() => "?").join(", ")})`,
|
|
[userId, ...ids]
|
|
);
|
|
return Number(result?.affectedRows ?? 0);
|
|
};
|
|
const createReminder = async ({
|
|
userId,
|
|
itemId,
|
|
remindAt,
|
|
offsetMinutes = null,
|
|
channel = "wechat"
|
|
}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
if (!itemId) throw new Error("\u7F3A\u5C11\u4E8B\u9879");
|
|
const safeRemindAt = Number(remindAt);
|
|
if (!Number.isFinite(safeRemindAt) || safeRemindAt <= 0) {
|
|
throw new Error("\u63D0\u9192\u65F6\u95F4\u65E0\u6548");
|
|
}
|
|
const safeOffsetMinutes = offsetMinutes == null || offsetMinutes === "" ? null : Number(offsetMinutes);
|
|
if (safeOffsetMinutes != null && !Number.isInteger(safeOffsetMinutes)) {
|
|
throw new Error("\u63D0\u9192\u504F\u79FB\u5206\u949F\u65E0\u6548");
|
|
}
|
|
const safeChannel = channel === "in_app" ? "in_app" : "wechat";
|
|
const [itemRows] = await pool.query(
|
|
`SELECT id
|
|
FROM h5_schedule_items
|
|
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[itemId, userId]
|
|
);
|
|
if (!itemRows[0]) throw new Error("\u4E8B\u9879\u4E0D\u5B58\u5728\u6216\u65E0\u6743\u8BBF\u95EE");
|
|
const id = crypto.randomUUID();
|
|
const ts = clock.now();
|
|
await pool.query(
|
|
`INSERT INTO h5_schedule_reminders
|
|
(id, user_id, item_id, remind_at, offset_minutes, channel, status, attempts,
|
|
last_error, locked_until, sent_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, NULL, NULL, NULL, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
offset_minutes = VALUES(offset_minutes),
|
|
status = 'pending',
|
|
last_error = NULL,
|
|
locked_until = NULL,
|
|
updated_at = VALUES(updated_at)`,
|
|
[id, userId, itemId, safeRemindAt, safeOffsetMinutes, safeChannel, ts, ts]
|
|
);
|
|
const [rows] = await pool.query(
|
|
`SELECT *
|
|
FROM h5_schedule_reminders
|
|
WHERE item_id = ? AND remind_at = ? AND channel = ?
|
|
LIMIT 1`,
|
|
[itemId, safeRemindAt, safeChannel]
|
|
);
|
|
return rowToReminder(rows[0]) || {
|
|
id,
|
|
userId,
|
|
itemId,
|
|
remindAt: safeRemindAt,
|
|
offsetMinutes: safeOffsetMinutes,
|
|
channel: safeChannel,
|
|
status: "pending",
|
|
attempts: 0,
|
|
lastError: null,
|
|
lockedUntil: null,
|
|
sentAt: null,
|
|
createdAt: ts,
|
|
updatedAt: ts
|
|
};
|
|
};
|
|
const listDigestSubscriptions = async ({
|
|
userId,
|
|
digestType = "todo_day",
|
|
channel = null,
|
|
status = null,
|
|
limit = 20
|
|
} = {}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const clauses = ["user_id = ?", "digest_type = ?"];
|
|
const params = [userId, digestType];
|
|
if (channel) {
|
|
clauses.push("channel = ?");
|
|
params.push(channel);
|
|
}
|
|
if (status) {
|
|
const statuses = Array.isArray(status) ? status : [status];
|
|
clauses.push(`status IN (${statuses.map(() => "?").join(", ")})`);
|
|
params.push(...statuses);
|
|
}
|
|
params.push(Math.max(1, Math.min(200, Number(limit) || 20)));
|
|
const [rows] = await pool.query(
|
|
`SELECT *
|
|
FROM h5_schedule_digest_subscriptions
|
|
WHERE ${clauses.join(" AND ")}
|
|
ORDER BY next_run_at ASC, created_at DESC
|
|
LIMIT ?`,
|
|
params
|
|
);
|
|
return rows.map(rowToDigest);
|
|
};
|
|
const createDailyTodoDigest = async ({
|
|
userId,
|
|
hour,
|
|
minute = 0,
|
|
timezone = defaultTimezone,
|
|
channel = "wechat",
|
|
sourceChannel = "agent",
|
|
sourceSessionId = null,
|
|
sourceMessageId = null,
|
|
sourceText = null
|
|
}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const safeHour = Number(hour);
|
|
const safeMinute = Number(minute);
|
|
if (!Number.isInteger(safeHour) || safeHour < 0 || safeHour > 23) {
|
|
throw new Error("\u63D0\u9192\u5C0F\u65F6\u65E0\u6548");
|
|
}
|
|
if (!Number.isInteger(safeMinute) || safeMinute < 0 || safeMinute > 59) {
|
|
throw new Error("\u63D0\u9192\u5206\u949F\u65E0\u6548");
|
|
}
|
|
const tz = normalizeTimezone(timezone);
|
|
const nextRunAt = nextDailyRunAt({
|
|
hour: safeHour,
|
|
minute: safeMinute,
|
|
timezone: tz,
|
|
now: clock.now()
|
|
});
|
|
const id = crypto.randomUUID();
|
|
const ts = clock.now();
|
|
await pool.query(
|
|
`INSERT INTO h5_schedule_digest_subscriptions
|
|
(id, user_id, digest_type, hour, minute, timezone, channel, status, next_run_at,
|
|
source_channel, source_session_id, source_message_id, source_text, created_at, updated_at)
|
|
VALUES (?, ?, 'todo_day', ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
hour = VALUES(hour),
|
|
minute = VALUES(minute),
|
|
timezone = VALUES(timezone),
|
|
channel = VALUES(channel),
|
|
status = 'active',
|
|
next_run_at = VALUES(next_run_at),
|
|
source_channel = VALUES(source_channel),
|
|
source_session_id = VALUES(source_session_id),
|
|
source_message_id = VALUES(source_message_id),
|
|
source_text = VALUES(source_text),
|
|
updated_at = VALUES(updated_at)`,
|
|
[
|
|
id,
|
|
userId,
|
|
safeHour,
|
|
safeMinute,
|
|
tz,
|
|
channel,
|
|
nextRunAt,
|
|
sourceChannel,
|
|
sourceSessionId,
|
|
sourceMessageId,
|
|
sourceText,
|
|
ts,
|
|
ts
|
|
]
|
|
);
|
|
const [rows] = await pool.query(
|
|
`SELECT * FROM h5_schedule_digest_subscriptions
|
|
WHERE user_id = ? AND digest_type = 'todo_day' AND channel = ?
|
|
LIMIT 1`,
|
|
[userId, channel]
|
|
);
|
|
return rowToDigest(rows[0]) || {
|
|
id,
|
|
userId,
|
|
hour: safeHour,
|
|
minute: safeMinute,
|
|
timezone: tz,
|
|
channel,
|
|
status: "active",
|
|
nextRunAt,
|
|
lastRunAt: null,
|
|
attempts: 0
|
|
};
|
|
};
|
|
const createBalanceLowAlert = async ({
|
|
userId,
|
|
thresholdCents,
|
|
channel = "wechat",
|
|
sourceChannel = "agent",
|
|
sourceSessionId = null,
|
|
sourceMessageId = null,
|
|
sourceText = null
|
|
} = {}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const safeThreshold = Number(thresholdCents);
|
|
if (!Number.isFinite(safeThreshold) || safeThreshold < 0) {
|
|
throw new Error("\u4F59\u989D\u9608\u503C\u65E0\u6548");
|
|
}
|
|
const now = clock.now();
|
|
const id = crypto.randomUUID();
|
|
await pool.query(
|
|
`INSERT INTO h5_balance_alert_subscriptions
|
|
(id, user_id, threshold_cents, channel, status, next_run_at, last_run_at,
|
|
last_notified_balance_cents, attempts, locked_until, last_error,
|
|
source_channel, source_session_id, source_message_id, source_text, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 'active', ?, NULL, NULL, 0, NULL, NULL, ?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
threshold_cents = VALUES(threshold_cents),
|
|
channel = VALUES(channel),
|
|
status = 'active',
|
|
next_run_at = VALUES(next_run_at),
|
|
last_error = NULL,
|
|
locked_until = NULL,
|
|
updated_at = VALUES(updated_at)`,
|
|
[id, userId, safeThreshold, channel, now, sourceChannel, sourceSessionId, sourceMessageId, sourceText, now, now]
|
|
);
|
|
const [rows] = await pool.query(
|
|
`SELECT * FROM h5_balance_alert_subscriptions WHERE user_id = ? AND channel = ? LIMIT 1`,
|
|
[userId, channel]
|
|
);
|
|
return rowToBalanceAlert(rows[0]) || {
|
|
id,
|
|
userId,
|
|
thresholdCents: safeThreshold,
|
|
channel,
|
|
status: "active",
|
|
nextRunAt: now,
|
|
lastRunAt: null,
|
|
lastNotifiedBalanceCents: null,
|
|
attempts: 0
|
|
};
|
|
};
|
|
const listDueReminders = async ({ now = clock.now(), limit = 50 } = {}) => {
|
|
const [rows] = await pool.query(
|
|
`SELECT r.*
|
|
FROM h5_schedule_reminders r
|
|
INNER JOIN h5_schedule_items i ON i.id = r.item_id
|
|
WHERE r.status = 'pending'
|
|
AND r.remind_at <= ?
|
|
AND (r.locked_until IS NULL OR r.locked_until <= ?)
|
|
AND i.deleted_at IS NULL
|
|
AND i.status = 'active'
|
|
ORDER BY r.remind_at ASC
|
|
LIMIT ?`,
|
|
[now, now, Math.max(1, Math.min(200, Number(limit) || 50))]
|
|
);
|
|
return rows.map(rowToReminder);
|
|
};
|
|
const lockReminder = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => {
|
|
const lockedUntil = now + lockMs;
|
|
const [result] = await pool.query(
|
|
`UPDATE h5_schedule_reminders
|
|
SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ?
|
|
WHERE id = ? AND status = 'pending' AND remind_at <= ?`,
|
|
[lockedUntil, now, id, now]
|
|
);
|
|
if (Number(result?.affectedRows ?? 0) !== 1) return null;
|
|
const [rows] = await pool.query(
|
|
`SELECT * FROM h5_schedule_reminders WHERE id = ? LIMIT 1`,
|
|
[id]
|
|
);
|
|
return rowToReminder(rows[0]);
|
|
};
|
|
const markReminderSent = async (reminder, { now = clock.now() } = {}) => {
|
|
await pool.query(
|
|
`UPDATE h5_schedule_reminders
|
|
SET status = 'sent', sent_at = ?, locked_until = NULL, last_error = NULL, updated_at = ?
|
|
WHERE id = ?`,
|
|
[now, now, reminder.id]
|
|
);
|
|
return { ...reminder, status: "sent", sentAt: now, lockedUntil: null };
|
|
};
|
|
const markReminderCancelled = async (reminder, reason, { now = clock.now() } = {}) => {
|
|
const lastError = String(reason ?? "\u5DF2\u53D6\u6D88").slice(0, 500);
|
|
await pool.query(
|
|
`UPDATE h5_schedule_reminders
|
|
SET status = 'cancelled', locked_until = NULL, last_error = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[lastError, now, reminder.id]
|
|
);
|
|
return { ...reminder, status: "cancelled", lastError };
|
|
};
|
|
const markReminderFailed = async (reminder, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => {
|
|
const attempts = Number(reminder.attempts ?? 0);
|
|
const status = attempts >= maxAttempts ? "failed" : "pending";
|
|
const lockedUntil = status === "pending" ? now + retryMs : null;
|
|
await pool.query(
|
|
`UPDATE h5_schedule_reminders
|
|
SET status = ?, locked_until = ?, last_error = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[
|
|
status,
|
|
lockedUntil,
|
|
String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500),
|
|
now,
|
|
reminder.id
|
|
]
|
|
);
|
|
};
|
|
const buildReminderText = async (reminder) => {
|
|
const item = await getItem({ userId: reminder.userId, itemId: reminder.itemId });
|
|
if (!item || item.status !== "active") return null;
|
|
const timezone = item.timezone || defaultTimezone;
|
|
const remindLabel = formatLocalTime(reminder.remindAt, timezone);
|
|
const eventAt = item.startAt ?? item.dueAt ?? null;
|
|
const eventLabel = eventAt ? formatLocalTime(eventAt, timezone) : null;
|
|
const lines = [`\u3010\u5F85\u529E\u63D0\u9192\u3011${item.title}`];
|
|
if (eventLabel && eventLabel !== remindLabel) {
|
|
lines.push(`\u4E8B\u9879\u65F6\u95F4\uFF1A${eventLabel}`);
|
|
}
|
|
lines.push(`\u63D0\u9192\u65F6\u95F4\uFF1A${remindLabel}`);
|
|
if (item.location) lines.push(`\u5730\u70B9\uFF1A${item.location}`);
|
|
return lines.join("\n");
|
|
};
|
|
const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => {
|
|
const [rows] = await pool.query(
|
|
`SELECT *
|
|
FROM h5_balance_alert_subscriptions
|
|
WHERE next_run_at <= ?
|
|
AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))
|
|
ORDER BY next_run_at ASC
|
|
LIMIT ?`,
|
|
[now, now, Math.max(1, Math.min(200, Number(limit) || 50))]
|
|
);
|
|
return rows.map(rowToBalanceAlert);
|
|
};
|
|
const lockBalanceAlert = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => {
|
|
const lockedUntil = now + lockMs;
|
|
const [result] = await pool.query(
|
|
`UPDATE h5_balance_alert_subscriptions
|
|
SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ?
|
|
WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`,
|
|
[lockedUntil, now, id, now]
|
|
);
|
|
if (Number(result?.affectedRows ?? 0) !== 1) return null;
|
|
const [rows] = await pool.query(
|
|
`SELECT * FROM h5_balance_alert_subscriptions WHERE id = ? LIMIT 1`,
|
|
[id]
|
|
);
|
|
return rowToBalanceAlert(rows[0]);
|
|
};
|
|
const markBalanceAlertSent = async (subscription, { now = clock.now() } = {}) => {
|
|
await pool.query(
|
|
`UPDATE h5_balance_alert_subscriptions
|
|
SET status = 'active', next_run_at = ?, last_run_at = ?, last_notified_balance_cents = ?,
|
|
locked_until = NULL, last_error = NULL, updated_at = ?
|
|
WHERE id = ?`,
|
|
[now + 5 * 60 * 1e3, now, subscription.lastNotifiedBalanceCents ?? null, now, subscription.id]
|
|
);
|
|
return { ...subscription, status: "active", lastRunAt: now };
|
|
};
|
|
const markBalanceAlertFailed = async (subscription, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => {
|
|
const attempts = Number(subscription.attempts ?? 0);
|
|
const status = attempts >= maxAttempts ? "failed" : "active";
|
|
const nextRunAt = status === "active" ? now + retryMs : subscription.nextRunAt;
|
|
await pool.query(
|
|
`UPDATE h5_balance_alert_subscriptions
|
|
SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id]
|
|
);
|
|
};
|
|
const listDueDigestSubscriptions = async ({ now = clock.now(), limit = 50 } = {}) => {
|
|
const [rows] = await pool.query(
|
|
`SELECT *
|
|
FROM h5_schedule_digest_subscriptions
|
|
WHERE next_run_at <= ?
|
|
AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))
|
|
ORDER BY next_run_at ASC
|
|
LIMIT ?`,
|
|
[now, now, Math.max(1, Math.min(200, Number(limit) || 50))]
|
|
);
|
|
return rows.map(rowToDigest);
|
|
};
|
|
const lockDigestSubscription = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => {
|
|
const lockedUntil = now + lockMs;
|
|
const [result] = await pool.query(
|
|
`UPDATE h5_schedule_digest_subscriptions
|
|
SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ?
|
|
WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`,
|
|
[lockedUntil, now, id, now]
|
|
);
|
|
if (Number(result?.affectedRows ?? 0) !== 1) return null;
|
|
const [rows] = await pool.query(
|
|
`SELECT * FROM h5_schedule_digest_subscriptions WHERE id = ? LIMIT 1`,
|
|
[id]
|
|
);
|
|
return rowToDigest(rows[0]);
|
|
};
|
|
const markDigestSent = async (subscription, { now = clock.now() } = {}) => {
|
|
const nextRunAt = nextDailyRunAt({
|
|
hour: subscription.hour,
|
|
minute: subscription.minute,
|
|
timezone: subscription.timezone,
|
|
now: now + 1e3
|
|
});
|
|
await pool.query(
|
|
`UPDATE h5_schedule_digest_subscriptions
|
|
SET status = 'active', next_run_at = ?, last_run_at = ?, locked_until = NULL,
|
|
last_error = NULL, updated_at = ?
|
|
WHERE id = ?`,
|
|
[nextRunAt, now, now, subscription.id]
|
|
);
|
|
return { ...subscription, status: "active", nextRunAt, lastRunAt: now };
|
|
};
|
|
const markDigestFailed = async (subscription, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => {
|
|
const attempts = Number(subscription.attempts ?? 0);
|
|
const status = attempts >= maxAttempts ? "failed" : "active";
|
|
const nextRunAt = status === "active" ? now + retryMs : subscription.nextRunAt;
|
|
await pool.query(
|
|
`UPDATE h5_schedule_digest_subscriptions
|
|
SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id]
|
|
);
|
|
};
|
|
const logDelivery = async ({
|
|
reminderId = null,
|
|
subscriptionId = null,
|
|
userId,
|
|
channel = "wechat",
|
|
status,
|
|
providerMessageId = null,
|
|
errorCode = null,
|
|
errorMessage = null
|
|
}) => {
|
|
await pool.query(
|
|
`INSERT INTO h5_schedule_delivery_logs
|
|
(id, reminder_id, subscription_id, user_id, channel, status, provider_message_id,
|
|
error_code, error_message, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
crypto.randomUUID(),
|
|
reminderId,
|
|
subscriptionId,
|
|
userId,
|
|
channel,
|
|
status,
|
|
providerMessageId,
|
|
errorCode,
|
|
errorMessage ? String(errorMessage).slice(0, 500) : null,
|
|
clock.now()
|
|
]
|
|
);
|
|
};
|
|
const buildTodoDigestText = async ({ userId, timezone = defaultTimezone, now = clock.now() }) => {
|
|
const items = await listTodayTodoItems({ userId, timezone, now });
|
|
return formatTodoDigest(items, { now, timezone });
|
|
};
|
|
const getUserWalletSnapshot = async (userId) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const [rows] = await pool.query(
|
|
`SELECT balance_cents AS balanceCents, tokens_used AS tokensUsed
|
|
FROM h5_user_wallets
|
|
WHERE user_id = ?
|
|
LIMIT 1`,
|
|
[userId]
|
|
);
|
|
return rows[0] ?? null;
|
|
};
|
|
const createUserNotification = async ({
|
|
userId,
|
|
channel = "web",
|
|
notificationType,
|
|
title,
|
|
body,
|
|
data = null
|
|
}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
if (!notificationType) throw new Error("\u7F3A\u5C11\u901A\u77E5\u7C7B\u578B");
|
|
if (!title) throw new Error("\u7F3A\u5C11\u901A\u77E5\u6807\u9898");
|
|
if (!body) throw new Error("\u7F3A\u5C11\u901A\u77E5\u5185\u5BB9");
|
|
const now = clock.now();
|
|
const id = crypto.randomUUID();
|
|
await pool.query(
|
|
`INSERT INTO h5_user_notifications
|
|
(id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'unread', NULL, ?, ?)`,
|
|
[id, userId, channel, notificationType, title, body, data ? JSON.stringify(data) : null, now, now]
|
|
);
|
|
return { id, userId, channel, notificationType, title, body, data, status: "unread", readAt: null, createdAt: now, updatedAt: now };
|
|
};
|
|
const listUserNotifications = async ({ userId, status = "unread", limit = 20 } = {}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const clauses = ["user_id = ?"];
|
|
const params = [userId];
|
|
if (status && status !== "all") {
|
|
clauses.push("status = ?");
|
|
params.push(status);
|
|
}
|
|
params.push(Math.max(1, Math.min(100, Number(limit) || 20)));
|
|
const [rows] = await pool.query(
|
|
`SELECT *
|
|
FROM h5_user_notifications
|
|
WHERE ${clauses.join(" AND ")}
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`,
|
|
params
|
|
);
|
|
return rows.map(rowToUserNotification);
|
|
};
|
|
const markUserNotificationRead = async ({ userId, notificationId }) => {
|
|
if (!userId || !notificationId) throw new Error("\u7F3A\u5C11\u901A\u77E5\u53C2\u6570");
|
|
const now = clock.now();
|
|
const [result] = await pool.query(
|
|
`UPDATE h5_user_notifications
|
|
SET status = 'read', read_at = ?, updated_at = ?
|
|
WHERE id = ? AND user_id = ? AND status <> 'read'`,
|
|
[now, now, notificationId, userId]
|
|
);
|
|
return Number(result?.affectedRows ?? 0) > 0;
|
|
};
|
|
const markAllUserNotificationsRead = async ({ userId } = {}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const now = clock.now();
|
|
const [result] = await pool.query(
|
|
`UPDATE h5_user_notifications
|
|
SET status = 'read', read_at = ?, updated_at = ?
|
|
WHERE user_id = ? AND status <> 'read'`,
|
|
[now, now, userId]
|
|
);
|
|
return Number(result?.affectedRows ?? 0);
|
|
};
|
|
const deleteUserNotification = async ({ userId, notificationId } = {}) => {
|
|
if (!userId || !notificationId) throw new Error("\u7F3A\u5C11\u901A\u77E5\u53C2\u6570");
|
|
const [result] = await pool.query(
|
|
`DELETE FROM h5_user_notifications
|
|
WHERE id = ? AND user_id = ?`,
|
|
[notificationId, userId]
|
|
);
|
|
return Number(result?.affectedRows ?? 0) > 0;
|
|
};
|
|
const clearUserNotifications = async ({ userId, status = "all" } = {}) => {
|
|
if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
|
|
const clauses = ["user_id = ?"];
|
|
const params = [userId];
|
|
if (status && status !== "all") {
|
|
clauses.push("status = ?");
|
|
params.push(status);
|
|
}
|
|
const [result] = await pool.query(
|
|
`DELETE FROM h5_user_notifications
|
|
WHERE ${clauses.join(" AND ")}`,
|
|
params
|
|
);
|
|
return Number(result?.affectedRows ?? 0);
|
|
};
|
|
return {
|
|
createItem,
|
|
getItem,
|
|
createReminder,
|
|
listItems,
|
|
listItemsBySourceMessage,
|
|
listTodayTodoItems,
|
|
listUpcomingItems,
|
|
listUpcomingReminders,
|
|
getReminder,
|
|
cancelReminder,
|
|
deleteReminder,
|
|
deleteReminders,
|
|
listDigestSubscriptions,
|
|
createDailyTodoDigest,
|
|
createBalanceLowAlert,
|
|
listDueReminders,
|
|
lockReminder,
|
|
markReminderSent,
|
|
markReminderCancelled,
|
|
markReminderFailed,
|
|
buildReminderText,
|
|
listDueDigestSubscriptions,
|
|
lockDigestSubscription,
|
|
markDigestSent,
|
|
markDigestFailed,
|
|
listDueBalanceAlerts,
|
|
lockBalanceAlert,
|
|
markBalanceAlertSent,
|
|
markBalanceAlertFailed,
|
|
logDelivery,
|
|
buildTodoDigestText,
|
|
getUserWalletSnapshot,
|
|
createUserNotification,
|
|
listUserNotifications,
|
|
markUserNotificationRead,
|
|
markAllUserNotificationsRead,
|
|
deleteUserNotification,
|
|
clearUserNotifications
|
|
};
|
|
}
|
|
|
|
// mindspace-long-image.mjs
|
|
import fs from "node:fs";
|
|
import fsPromises from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
var DEFAULT_VIEWPORT_WIDTH = 1280;
|
|
var DEFAULT_VIEWPORT_HEIGHT = 720;
|
|
var MAX_LONG_IMAGE_HEIGHT = 2e4;
|
|
function longImagePathForHtml(htmlPath, outputPath = null) {
|
|
return outputPath || String(htmlPath).replace(/\.html$/i, ".long.png");
|
|
}
|
|
function clampDimension(value, fallback, max) {
|
|
const number = Math.ceil(Number(value) || fallback);
|
|
return Math.max(320, Math.min(number, max));
|
|
}
|
|
async function launchChromium(chromium) {
|
|
const base = {
|
|
headless: true,
|
|
args: ["--disable-dev-shm-usage", "--hide-scrollbars"]
|
|
};
|
|
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
|
|
return chromium.launch({
|
|
...base,
|
|
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
|
});
|
|
}
|
|
if (process.env.PLAYWRIGHT_CHROMIUM_CHANNEL) {
|
|
return chromium.launch({ ...base, channel: process.env.PLAYWRIGHT_CHROMIUM_CHANNEL });
|
|
}
|
|
try {
|
|
return await chromium.launch(base);
|
|
} catch (error) {
|
|
if (process.platform === "darwin") {
|
|
return chromium.launch({ ...base, channel: "chrome" });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
async function renderLongImage({
|
|
htmlPath = null,
|
|
url = null,
|
|
outputPath = null,
|
|
viewportWidth = DEFAULT_VIEWPORT_WIDTH,
|
|
viewportHeight = DEFAULT_VIEWPORT_HEIGHT
|
|
} = {}) {
|
|
if (!htmlPath && !url) throw new Error("\u7F3A\u5C11 htmlPath \u6216 url");
|
|
const targetUrl = url || pathToFileURL(path.resolve(htmlPath)).toString();
|
|
const destination = outputPath ? path.resolve(outputPath) : longImagePathForHtml(path.resolve(htmlPath));
|
|
const { chromium } = await import("playwright");
|
|
let browser = null;
|
|
try {
|
|
browser = await launchChromium(chromium);
|
|
const page = await browser.newPage({
|
|
viewport: {
|
|
width: clampDimension(viewportWidth, DEFAULT_VIEWPORT_WIDTH, 2400),
|
|
height: clampDimension(viewportHeight, DEFAULT_VIEWPORT_HEIGHT, MAX_LONG_IMAGE_HEIGHT)
|
|
},
|
|
deviceScaleFactor: 2
|
|
});
|
|
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 3e4 });
|
|
await page.evaluate(() => document.fonts?.ready).catch(() => null);
|
|
const size = await page.evaluate(() => ({
|
|
width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 320),
|
|
height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 320)
|
|
}));
|
|
await page.setViewportSize({
|
|
width: clampDimension(size.width, DEFAULT_VIEWPORT_WIDTH, 2400),
|
|
height: Math.min(clampDimension(size.height, DEFAULT_VIEWPORT_HEIGHT, MAX_LONG_IMAGE_HEIGHT), 2400)
|
|
});
|
|
await fsPromises.mkdir(path.dirname(destination), { recursive: true });
|
|
await page.screenshot({
|
|
path: destination,
|
|
fullPage: true,
|
|
type: "png",
|
|
animations: "disabled",
|
|
caret: "hide"
|
|
});
|
|
return {
|
|
outputPath: destination,
|
|
bytes: fs.statSync(destination).size
|
|
};
|
|
} finally {
|
|
await browser?.close().catch(() => {
|
|
});
|
|
}
|
|
}
|
|
|
|
// mindspace-sandbox-mcp.mjs
|
|
var SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
|
|
if (!SANDBOX_ROOT) {
|
|
process.stderr.write("[mindspace-sandbox-mcp] SANDBOX_ROOT is not set \u2014 refusing to start\n");
|
|
process.exit(1);
|
|
}
|
|
var SANDBOX = path2.resolve(SANDBOX_ROOT);
|
|
var PRIVATE_DATA_DIR = path2.join(SANDBOX, ".mindspace");
|
|
var PRIVATE_DATA_DB = path2.join(PRIVATE_DATA_DIR, "private-data.sqlite");
|
|
var SQLITE_BIN = process.env.SQLITE_BIN?.trim() || "sqlite3";
|
|
var PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * 1024 * 1024);
|
|
var PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5e3);
|
|
var PRIVATE_DATA_MAX_ROWS = Number(process.env.PRIVATE_DATA_MAX_ROWS ?? 200);
|
|
var PRIVATE_DATA_USER_ID = process.env.PRIVATE_DATA_USER_ID?.trim();
|
|
var allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim();
|
|
var ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(",").map((s) => s.trim())) : null;
|
|
function resolveSandboxed(p) {
|
|
if (!p || typeof p !== "string") throw new Error("\u8DEF\u5F84\u53C2\u6570\u65E0\u6548");
|
|
const resolved = path2.isAbsolute(p) ? path2.resolve(p) : path2.resolve(SANDBOX, p);
|
|
if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path2.sep)) {
|
|
throw Object.assign(new Error(`\u8DEF\u5F84\u8D8A\u754C\uFF1A${p} \u4E0D\u5728\u5F53\u524D\u5DE5\u4F5C\u533A\u5185`), { code: "EACCES" });
|
|
}
|
|
if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path2.sep)) {
|
|
throw Object.assign(new Error("\u7981\u6B62\u76F4\u63A5\u8BBF\u95EE\u79C1\u6709\u6570\u636E\u76EE\u5F55\uFF0C\u8BF7\u4F7F\u7528 private_data_* \u5DE5\u5177"), { code: "EACCES" });
|
|
}
|
|
return resolved;
|
|
}
|
|
var ALL_TOOLS = [
|
|
{
|
|
name: "read_file",
|
|
description: "\u8BFB\u53D6\u6587\u4EF6\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u6587\u4EF6\uFF09",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "\u6587\u4EF6\u8DEF\u5F84\uFF08\u76F8\u5BF9\u5DE5\u4F5C\u533A\u6216\u7EDD\u5BF9\u8DEF\u5F84\uFF09" }
|
|
},
|
|
required: ["path"]
|
|
}
|
|
},
|
|
{
|
|
name: "write_file",
|
|
description: "\u5199\u5165\u6587\u4EF6\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u6587\u4EF6\uFF1B\u4E0D\u5B58\u5728\u5219\u521B\u5EFA\uFF0C\u5DF2\u5B58\u5728\u5219\u8986\u76D6\uFF09",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "\u6587\u4EF6\u8DEF\u5F84" },
|
|
content: { type: "string", description: "\u6587\u4EF6\u5185\u5BB9" }
|
|
},
|
|
required: ["path", "content"]
|
|
}
|
|
},
|
|
{
|
|
name: "edit_file",
|
|
description: "\u5C06\u6587\u4EF6\u4E2D\u7684\u65E7\u5185\u5BB9\u66FF\u6362\u4E3A\u65B0\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u6587\u4EF6\uFF09",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "\u6587\u4EF6\u8DEF\u5F84" },
|
|
old_str: { type: "string", description: "\u8981\u66FF\u6362\u7684\u539F\u59CB\u5185\u5BB9\uFF08\u5FC5\u987B\u5728\u6587\u4EF6\u4E2D\u552F\u4E00\uFF09" },
|
|
new_str: { type: "string", description: "\u66FF\u6362\u540E\u7684\u65B0\u5185\u5BB9" }
|
|
},
|
|
required: ["path", "old_str", "new_str"]
|
|
}
|
|
},
|
|
{
|
|
name: "list_dir",
|
|
description: "\u5217\u51FA\u76EE\u5F55\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u76EE\u5F55\uFF09",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
path: {
|
|
type: "string",
|
|
description: "\u76EE\u5F55\u8DEF\u5F84\uFF08\u7701\u7565\u5219\u5217\u51FA\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55\uFF09"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: "create_dir",
|
|
description: "\u521B\u5EFA\u76EE\u5F55\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\uFF1B\u5DF2\u5B58\u5728\u4E0D\u62A5\u9519\uFF09",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "\u76EE\u5F55\u8DEF\u5F84" }
|
|
},
|
|
required: ["path"]
|
|
}
|
|
},
|
|
{
|
|
name: "generate_long_image",
|
|
description: "\u7528 Playwright \u5C06\u5DE5\u4F5C\u533A\u5185\u7684 HTML \u9875\u9762\u6E32\u67D3\u4E3A\u6574\u9875 PNG \u957F\u56FE\u3002\u8F93\u51FA\u6587\u4EF6\u901A\u5E38\u4E3A public/<\u9875\u9762\u540D>.long.png\uFF0C\u5FC5\u987B\u5728\u5DE5\u4F5C\u533A\u5185\u3002",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
html_path: { type: "string", description: "HTML \u6587\u4EF6\u8DEF\u5F84\uFF0C\u5982 public/report.html" },
|
|
output_path: { type: "string", description: "\u8F93\u51FA PNG \u8DEF\u5F84\uFF0C\u5982 public/report.long.png\uFF1B\u53EF\u9009" }
|
|
},
|
|
required: ["html_path"]
|
|
}
|
|
},
|
|
{
|
|
name: "private_data_info",
|
|
description: "\u67E5\u770B\u5F53\u524D\u7528\u6237\u201C\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u201D\u7684\u72B6\u6001\u3002\u6BCF\u4E2A\u7528\u6237\u53EA\u6709\u4E00\u4E2A\u79C1\u6709 SQLite \u6570\u636E\u5E93\uFF0C\u9002\u5408\u4FDD\u5B58\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868\uFF1B\u4E0D\u8981\u521B\u5EFA\u989D\u5916 SQLite \u6587\u4EF6\u3002",
|
|
inputSchema: { type: "object", properties: {} }
|
|
},
|
|
{
|
|
name: "private_data_schema",
|
|
description: "\u67E5\u770B\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u4E2D\u7684\u8868\u548C\u5B57\u6BB5\u3002\u7528\u4E8E\u4E86\u89E3\u5F53\u524D\u7528\u6237\u81EA\u5DF1\u7684 SQLite \u8868\u7ED3\u6784\u3002",
|
|
inputSchema: { type: "object", properties: {} }
|
|
},
|
|
{
|
|
name: "private_data_query",
|
|
description: "\u53EA\u8BFB\u67E5\u8BE2\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u3002\u4EC5\u5141\u8BB8 SELECT/WITH\uFF0C\u9ED8\u8BA4\u6700\u591A\u8FD4\u56DE 200 \u884C\uFF0C\u9002\u5408\u7EDF\u8BA1\u95EE\u5377\u56DE\u7B54\u3001\u7B5B\u9009\u8868\u5355\u6570\u636E\u3001\u5206\u6790\u7528\u6237\u79C1\u6709\u6570\u636E\u3002",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
sql: { type: "string", description: "\u53EA\u8BFB SQL\uFF0C\u5FC5\u987B\u662F SELECT/WITH" }
|
|
},
|
|
required: ["sql"]
|
|
}
|
|
},
|
|
{
|
|
name: "private_data_execute",
|
|
description: "\u5199\u5165\u6216\u53D8\u66F4\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u3002\u53EF\u7528\u4E8E\u521B\u5EFA\u95EE\u5377/\u8868\u5355/\u6E05\u5355\u7B49\u79C1\u6709\u8868\u5E76\u5199\u5165\u6570\u636E\uFF1B\u7981\u6B62 ATTACH\u3001load_extension\u3001PRAGMA writable_schema\u3001VACUUM INTO \u7B49\u8D8A\u754C\u6216\u5371\u9669\u64CD\u4F5C\u3002",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
sql: { type: "string", description: "\u8981\u6267\u884C\u7684 SQLite SQL" }
|
|
},
|
|
required: ["sql"]
|
|
}
|
|
}
|
|
];
|
|
var quotaPool = null;
|
|
var scheduleService = null;
|
|
function isQuotaSyncConfigured() {
|
|
return Boolean(
|
|
PRIVATE_DATA_USER_ID && (process.env.DATABASE_URL || process.env.MYSQL_HOST && process.env.MYSQL_DATABASE)
|
|
);
|
|
}
|
|
function getQuotaPool() {
|
|
if (!isQuotaSyncConfigured()) return null;
|
|
if (quotaPool) return quotaPool;
|
|
quotaPool = process.env.DATABASE_URL ? mysql.createPool(process.env.DATABASE_URL) : mysql.createPool({
|
|
host: process.env.MYSQL_HOST ?? "localhost",
|
|
port: Number(process.env.MYSQL_PORT ?? 3306),
|
|
user: process.env.MYSQL_USER ?? "boot",
|
|
password: process.env.MYSQL_PASSWORD ?? "",
|
|
database: process.env.MYSQL_DATABASE ?? "tkmind",
|
|
waitForConnections: true,
|
|
connectionLimit: 2
|
|
});
|
|
return quotaPool;
|
|
}
|
|
function isScheduleConfigured() {
|
|
return isQuotaSyncConfigured();
|
|
}
|
|
function getScheduleService() {
|
|
if (!PRIVATE_DATA_USER_ID) throw new Error("\u5F53\u524D\u4F1A\u8BDD\u6CA1\u6709\u7528\u6237\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u80FD\u64CD\u4F5C\u5F85\u529E");
|
|
const pool = getQuotaPool();
|
|
if (!pool || !isScheduleConfigured()) {
|
|
throw new Error("\u5F53\u524D\u73AF\u5883\u672A\u914D\u7F6E\u5F85\u529E\u6570\u636E\u5E93\u8FDE\u63A5");
|
|
}
|
|
if (!scheduleService) {
|
|
scheduleService = createScheduleService(pool, {
|
|
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || "Asia/Shanghai"
|
|
});
|
|
}
|
|
return scheduleService;
|
|
}
|
|
if (isScheduleConfigured()) {
|
|
ALL_TOOLS.push(
|
|
{
|
|
name: "schedule_create_item",
|
|
description: "\u4E3A\u5F53\u524D\u7528\u6237\u521B\u5EFA\u5F85\u529E\u6216\u65E5\u7A0B\u4E8B\u9879\u3002\u4EC5\u5199\u5165\u5F53\u524D\u7528\u6237\u81EA\u5DF1\u7684 h5_schedule_items\uFF0C\u4E0D\u53EF\u64CD\u4F5C\u5176\u4ED6\u7528\u6237\u6570\u636E\u3002",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
kind: { type: "string", description: "task \u6216 event\uFF0C\u9ED8\u8BA4 task" },
|
|
title: { type: "string", description: "\u4E8B\u9879\u6807\u9898" },
|
|
description: { type: "string", description: "\u4E8B\u9879\u63CF\u8FF0\uFF0C\u53EF\u9009" },
|
|
startAt: { type: "number", description: "\u5F00\u59CB\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009\uFF08\u4F18\u5148\u4F7F\u7528 startLocal\uFF09" },
|
|
startLocal: { type: "string", description: "\u672C\u5730\u5F00\u59CB\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u63A8\u8350" },
|
|
endAt: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009\uFF08\u4F18\u5148\u4F7F\u7528 endLocal\uFF09" },
|
|
endLocal: { type: "string", description: "\u672C\u5730\u7ED3\u675F\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u63A8\u8350" },
|
|
dueAt: { type: "number", description: "\u622A\u6B62\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009\uFF08\u4F18\u5148\u4F7F\u7528 dueLocal\uFF09" },
|
|
dueLocal: { type: "string", description: "\u672C\u5730\u622A\u6B62\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u53EF\u9009" },
|
|
allDay: { type: "boolean", description: "\u662F\u5426\u5168\u5929\u4E8B\u9879\uFF0C\u53EF\u9009" },
|
|
timezone: { type: "string", description: "\u65F6\u533A\uFF0C\u53EF\u9009" },
|
|
location: { type: "string", description: "\u5730\u70B9\uFF0C\u53EF\u9009" },
|
|
sourceMessageId: { type: "string", description: "\u670D\u52A1\u53F7\u6D88\u606F ID\uFF1B\u6709\u503C\u65F6\u5FC5\u987B\u539F\u6837\u4F20\u5165" },
|
|
sourceText: { type: "string", description: "\u539F\u59CB\u7528\u6237\u6587\u672C\uFF0C\u53EF\u9009" }
|
|
},
|
|
required: ["title"]
|
|
}
|
|
},
|
|
{
|
|
name: "schedule_create_reminder",
|
|
description: "\u4E3A\u5F53\u524D\u7528\u6237\u5DF2\u6709\u4E8B\u9879\u521B\u5EFA\u5355\u6B21\u63D0\u9192\u3002\u5FC5\u987B\u5148\u6709 itemId\uFF0C\u518D\u5199\u5165 h5_schedule_reminders\u3002",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
itemId: { type: "string", description: "\u4E8B\u9879 ID" },
|
|
remindAt: { type: "number", description: "\u63D0\u9192\u89E6\u53D1\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF08\u4F18\u5148\u4F7F\u7528 remindLocal\uFF09" },
|
|
remindLocal: { type: "string", description: "\u672C\u5730\u63D0\u9192\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u63A8\u8350" },
|
|
offsetMinutes: { type: "number", description: "\u76F8\u5BF9\u4E8B\u9879\u65F6\u95F4\u7684\u63D0\u524D\u5206\u949F\u6570\uFF0C\u53EF\u9009" },
|
|
channel: { type: "string", description: "\u63D0\u9192\u901A\u9053\uFF0C\u9ED8\u8BA4 wechat" }
|
|
},
|
|
required: ["itemId"]
|
|
}
|
|
},
|
|
{
|
|
name: "schedule_list_items",
|
|
description: "\u67E5\u8BE2\u5F53\u524D\u7528\u6237\u7684\u5F85\u529E/\u65E5\u7A0B\u4E8B\u9879\u5217\u8868\u3002\u4EC5\u8FD4\u56DE\u5F53\u524D\u7528\u6237\u6570\u636E\uFF0C\u53EF\u6309\u65F6\u95F4\u8303\u56F4\u8FC7\u6EE4\u3002",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
from: { type: "number", description: "\u8D77\u59CB\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
|
|
to: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
|
|
status: { type: "string", description: "\u4E8B\u9879\u72B6\u6001\uFF0C\u9ED8\u8BA4 active\uFF0C\u53EF\u9009" },
|
|
limit: { type: "number", description: "\u8FD4\u56DE\u6570\u91CF\uFF0C\u9ED8\u8BA4 50\uFF0C\u53EF\u9009" }
|
|
}
|
|
}
|
|
}
|
|
);
|
|
}
|
|
var TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS;
|
|
function ensurePrivateDataDb() {
|
|
fs2.mkdirSync(PRIVATE_DATA_DIR, { recursive: true });
|
|
if (!fs2.existsSync(PRIVATE_DATA_DB)) {
|
|
runSqlite(["-batch", PRIVATE_DATA_DB, "PRAGMA journal_mode=WAL; PRAGMA user_version = 1;"]);
|
|
}
|
|
return PRIVATE_DATA_DB;
|
|
}
|
|
function privateDataSize() {
|
|
let total = 0;
|
|
for (const file of fs2.existsSync(PRIVATE_DATA_DIR) ? fs2.readdirSync(PRIVATE_DATA_DIR) : []) {
|
|
if (file === "private-data.sqlite" || file.startsWith("private-data.sqlite-")) {
|
|
total += fs2.statSync(path2.join(PRIVATE_DATA_DIR, file)).size;
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
function runSqlite(args) {
|
|
return execFileSync(SQLITE_BIN, args, {
|
|
encoding: "utf8",
|
|
timeout: PRIVATE_DATA_QUERY_TIMEOUT_MS,
|
|
maxBuffer: 1024 * 1024
|
|
});
|
|
}
|
|
function sqliteScalar(sql) {
|
|
return Number(runSqlite(["-batch", "-noheader", PRIVATE_DATA_DB, sql]).trim() || 0);
|
|
}
|
|
async function getQuotaState({ forUpdate = false, conn = null } = {}) {
|
|
const pool = getQuotaPool();
|
|
if (!pool) return null;
|
|
const db = conn ?? pool;
|
|
const [rows] = await db.query(
|
|
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
|
|
FROM h5_user_spaces
|
|
WHERE user_id = ?
|
|
LIMIT 1 ${forUpdate ? "FOR UPDATE" : ""}`,
|
|
[PRIVATE_DATA_USER_ID]
|
|
);
|
|
const row = rows[0];
|
|
if (!row) return null;
|
|
const quotaBytes = Number(row.quota_bytes ?? 0);
|
|
const usedBytes = Number(row.used_bytes ?? 0);
|
|
const reservedBytes = Number(row.reserved_bytes ?? 0);
|
|
return {
|
|
id: row.id,
|
|
status: row.status,
|
|
quotaBytes,
|
|
usedBytes,
|
|
reservedBytes,
|
|
availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes)
|
|
};
|
|
}
|
|
async function sqliteMaxPagePragmaForQuota(currentBytes) {
|
|
const quota = await getQuotaState();
|
|
if (!quota) return "";
|
|
if (quota.status !== "active") throw new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u5199");
|
|
if (quota.availableBytes <= 0 && currentBytes >= PRIVATE_DATA_MAX_BYTES) {
|
|
throw new Error("\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u914D\u989D\u4E0D\u8DB3");
|
|
}
|
|
const allowedSize = Math.min(PRIVATE_DATA_MAX_BYTES, currentBytes + quota.availableBytes);
|
|
const pageSize = sqliteScalar("PRAGMA page_size;") || 4096;
|
|
const currentPages = sqliteScalar("PRAGMA page_count;") || 1;
|
|
const maxPages = Math.max(currentPages, Math.max(1, Math.floor(allowedSize / pageSize)));
|
|
return `PRAGMA max_page_count=${maxPages};`;
|
|
}
|
|
async function syncPrivateDataQuota(deltaBytes) {
|
|
const pool = getQuotaPool();
|
|
if (!pool || !deltaBytes) return null;
|
|
const conn = await pool.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
const quota = await getQuotaState({ forUpdate: true, conn });
|
|
if (!quota) {
|
|
await conn.rollback();
|
|
return null;
|
|
}
|
|
if (quota.status !== "active") throw new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u5199");
|
|
if (deltaBytes > quota.availableBytes) {
|
|
throw new Error(`\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u914D\u989D\u4E0D\u8DB3\uFF1A\u9700\u8981 ${deltaBytes} \u5B57\u8282\uFF0C\u53EF\u7528 ${quota.availableBytes} \u5B57\u8282`);
|
|
}
|
|
await conn.query(
|
|
`UPDATE h5_user_spaces
|
|
SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ?
|
|
WHERE id = ? AND user_id = ?`,
|
|
[deltaBytes, Date.now(), quota.id, PRIVATE_DATA_USER_ID]
|
|
);
|
|
await conn.commit();
|
|
return { deltaBytes };
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw err;
|
|
} finally {
|
|
conn.release();
|
|
}
|
|
}
|
|
function stripSqlComments(sql) {
|
|
return String(sql ?? "").replace(/--.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").trim();
|
|
}
|
|
function rejectDangerousSql(sql, { readonly = false } = {}) {
|
|
const cleaned = stripSqlComments(sql);
|
|
if (!cleaned) throw new Error("SQL \u4E0D\u80FD\u4E3A\u7A7A");
|
|
if (cleaned.length > 2e4) throw new Error("SQL \u8FC7\u957F");
|
|
if (/^\s*\./m.test(cleaned)) {
|
|
throw new Error("SQL \u4E0D\u5141\u8BB8\u4F7F\u7528 sqlite3 dot command");
|
|
}
|
|
if (/\b(ATTACH|DETACH|LOAD_EXTENSION|VACUUM\s+INTO)\b/i.test(cleaned)) {
|
|
throw new Error("SQL \u5305\u542B\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u4E0D\u5141\u8BB8\u7684\u64CD\u4F5C");
|
|
}
|
|
if (/\bPRAGMA\s+writable_schema\b/i.test(cleaned)) {
|
|
throw new Error("SQL \u5305\u542B\u4E0D\u5141\u8BB8\u7684 PRAGMA");
|
|
}
|
|
if (readonly && !/^\s*(SELECT|WITH)\b/i.test(cleaned)) {
|
|
throw new Error("private_data_query \u53EA\u5141\u8BB8 SELECT/WITH");
|
|
}
|
|
return cleaned;
|
|
}
|
|
async function queryPrivateData(sql) {
|
|
const db = ensurePrivateDataDb();
|
|
const cleaned = rejectDangerousSql(sql, { readonly: true });
|
|
const limited = `SELECT * FROM (${cleaned.replace(/;\s*$/, "")}) LIMIT ${PRIVATE_DATA_MAX_ROWS}`;
|
|
const output = runSqlite(["-json", db, limited]);
|
|
return output.trim() || "[]";
|
|
}
|
|
async function executePrivateData(sql) {
|
|
const db = ensurePrivateDataDb();
|
|
const before = privateDataSize();
|
|
if (before > PRIVATE_DATA_MAX_BYTES) throw new Error("\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u5DF2\u8D85\u8FC7\u5927\u5C0F\u9650\u5236");
|
|
const cleaned = rejectDangerousSql(sql);
|
|
const quotaPragma = await sqliteMaxPagePragmaForQuota(before);
|
|
runSqlite(["-batch", db, `${quotaPragma}
|
|
${cleaned}`]);
|
|
const after = privateDataSize();
|
|
if (after > PRIVATE_DATA_MAX_BYTES) {
|
|
throw new Error(`\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u8D85\u8FC7\u5927\u5C0F\u9650\u5236\uFF1A${after}/${PRIVATE_DATA_MAX_BYTES} \u5B57\u8282`);
|
|
}
|
|
const delta = after - before;
|
|
const quotaSync = await syncPrivateDataQuota(delta);
|
|
return `\u5DF2\u6267\u884C\u3002\u5F53\u524D\u6570\u636E\u7A7A\u95F4\u5927\u5C0F ${after} \u5B57\u8282${quotaSync ? `\uFF0C\u5DF2\u540C\u6B65\u7A7A\u95F4\u5360\u7528 ${delta} \u5B57\u8282` : ""}`;
|
|
}
|
|
async function privateDataSchema() {
|
|
const db = ensurePrivateDataDb();
|
|
const output = runSqlite([
|
|
"-json",
|
|
db,
|
|
`SELECT m.name AS table_name, p.cid, p.name AS column_name, p.type, p."notnull" AS not_null, p.pk
|
|
FROM sqlite_master m
|
|
JOIN pragma_table_info(m.name) p
|
|
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
|
|
ORDER BY m.name, p.cid`
|
|
]);
|
|
return output.trim() || "[]";
|
|
}
|
|
async function callTool(name, args) {
|
|
if (ALLOWED_TOOLS && !ALLOWED_TOOLS.has(name)) {
|
|
throw new Error(`\u5DE5\u5177 ${name} \u672A\u6388\u6743`);
|
|
}
|
|
switch (name) {
|
|
case "read_file": {
|
|
const abs = resolveSandboxed(args.path);
|
|
const content = fs2.readFileSync(abs, "utf8");
|
|
return [{ type: "text", text: content }];
|
|
}
|
|
case "write_file": {
|
|
const abs = resolveSandboxed(args.path);
|
|
fs2.mkdirSync(path2.dirname(abs), { recursive: true });
|
|
fs2.writeFileSync(abs, args.content ?? "", "utf8");
|
|
return [{ type: "text", text: `\u5DF2\u5199\u5165 ${args.path}\uFF08${(args.content ?? "").length} \u5B57\u8282\uFF09` }];
|
|
}
|
|
case "edit_file": {
|
|
const abs = resolveSandboxed(args.path);
|
|
const original = fs2.readFileSync(abs, "utf8");
|
|
const oldStr = args.old_str ?? "";
|
|
if (oldStr && !original.includes(oldStr)) {
|
|
throw new Error("edit_file: old_str \u5728\u6587\u4EF6\u4E2D\u4E0D\u5B58\u5728\uFF0C\u66FF\u6362\u5931\u8D25");
|
|
}
|
|
const updated = oldStr ? original.replace(oldStr, args.new_str ?? "") : args.new_str ?? "";
|
|
fs2.writeFileSync(abs, updated, "utf8");
|
|
return [{ type: "text", text: `\u5DF2\u7F16\u8F91 ${args.path}` }];
|
|
}
|
|
case "list_dir": {
|
|
const target = args?.path ?? ".";
|
|
const abs = resolveSandboxed(target);
|
|
const entries = fs2.readdirSync(abs, { withFileTypes: true });
|
|
const lines = entries.map((e) => `${e.isDirectory() ? "[\u76EE\u5F55]" : "[\u6587\u4EF6]"} ${e.name}`);
|
|
return [{ type: "text", text: lines.join("\n") || "\uFF08\u7A7A\u76EE\u5F55\uFF09" }];
|
|
}
|
|
case "create_dir": {
|
|
const abs = resolveSandboxed(args.path);
|
|
fs2.mkdirSync(abs, { recursive: true });
|
|
return [{ type: "text", text: `\u5DF2\u521B\u5EFA\u76EE\u5F55 ${args.path}` }];
|
|
}
|
|
case "generate_long_image": {
|
|
const htmlPath = String(args.html_path ?? args.path ?? "").trim();
|
|
if (!htmlPath.toLowerCase().endsWith(".html")) {
|
|
throw new Error("generate_long_image: html_path \u5FC5\u987B\u662F .html \u6587\u4EF6");
|
|
}
|
|
const htmlAbs = resolveSandboxed(htmlPath);
|
|
const outputPath = String(args.output_path ?? "").trim() || htmlPath.replace(/\.html$/i, ".long.png");
|
|
if (!outputPath.toLowerCase().endsWith(".png")) {
|
|
throw new Error("generate_long_image: output_path \u5FC5\u987B\u662F .png \u6587\u4EF6");
|
|
}
|
|
const outputAbs = resolveSandboxed(outputPath);
|
|
const result = await renderLongImage({ htmlPath: htmlAbs, outputPath: outputAbs });
|
|
return [
|
|
{
|
|
type: "text",
|
|
text: `\u5DF2\u751F\u6210\u957F\u56FE ${outputPath}\uFF08${result.bytes} \u5B57\u8282\uFF09`
|
|
}
|
|
];
|
|
}
|
|
case "private_data_info": {
|
|
ensurePrivateDataDb();
|
|
const size = privateDataSize();
|
|
const quota = await getQuotaState().catch(() => null);
|
|
return [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(
|
|
{
|
|
name: "\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4",
|
|
database: ".mindspace/private-data.sqlite",
|
|
maxBytes: PRIVATE_DATA_MAX_BYTES,
|
|
sizeBytes: size,
|
|
quotaSyncEnabled: Boolean(getQuotaPool()),
|
|
quota: quota ? {
|
|
status: quota.status,
|
|
quotaBytes: quota.quotaBytes,
|
|
usedBytes: quota.usedBytes,
|
|
reservedBytes: quota.reservedBytes,
|
|
availableBytes: quota.availableBytes
|
|
} : null,
|
|
rules: [
|
|
"\u6BCF\u4E2A\u7528\u6237\u53EA\u80FD\u4F7F\u7528\u8FD9\u4E2A\u552F\u4E00 SQLite \u6570\u636E\u5E93",
|
|
"\u9002\u5408\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868",
|
|
"\u4E0D\u8981\u5B58\u8D26\u53F7\u3001\u8BA1\u8D39\u3001\u6743\u9650\u3001\u5BA1\u8BA1\u3001\u516C\u5F00\u5E73\u53F0\u6570\u636E\u6216\u8DE8\u7528\u6237\u6570\u636E"
|
|
]
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
}
|
|
];
|
|
}
|
|
case "private_data_schema":
|
|
return [{ type: "text", text: await privateDataSchema() }];
|
|
case "private_data_query":
|
|
return [{ type: "text", text: await queryPrivateData(args.sql) }];
|
|
case "private_data_execute":
|
|
return [{ type: "text", text: await executePrivateData(args.sql) }];
|
|
case "schedule_create_item": {
|
|
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai";
|
|
const startAt = resolveScheduleTimestamp({
|
|
epochMs: args.startAt,
|
|
localString: args.startLocal,
|
|
timezone,
|
|
fieldName: "\u5F00\u59CB\u65F6\u95F4"
|
|
});
|
|
const endAt = resolveScheduleTimestamp({
|
|
epochMs: args.endAt,
|
|
localString: args.endLocal,
|
|
timezone,
|
|
fieldName: "\u7ED3\u675F\u65F6\u95F4"
|
|
});
|
|
const dueAt = resolveScheduleTimestamp({
|
|
epochMs: args.dueAt,
|
|
localString: args.dueLocal,
|
|
timezone,
|
|
fieldName: "\u622A\u6B62\u65F6\u95F4"
|
|
});
|
|
const item = await getScheduleService().createItem({
|
|
userId: PRIVATE_DATA_USER_ID,
|
|
kind: args.kind,
|
|
title: args.title,
|
|
description: args.description ?? null,
|
|
startAt,
|
|
endAt,
|
|
dueAt,
|
|
allDay: Boolean(args.allDay),
|
|
timezone,
|
|
location: args.location ?? null,
|
|
sourceChannel: "agent",
|
|
sourceMessageId: args.sourceMessageId ?? null,
|
|
sourceText: args.sourceText ?? null,
|
|
metadata: { source: "schedule_assistant_skill" }
|
|
});
|
|
return [{ type: "text", text: JSON.stringify(item, null, 2) }];
|
|
}
|
|
case "schedule_create_reminder": {
|
|
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai";
|
|
const remindAt = resolveScheduleTimestamp({
|
|
epochMs: args.remindAt,
|
|
localString: args.remindLocal,
|
|
timezone,
|
|
fieldName: "\u63D0\u9192\u65F6\u95F4"
|
|
});
|
|
if (remindAt == null) throw new Error("\u7F3A\u5C11\u63D0\u9192\u65F6\u95F4 remindLocal \u6216 remindAt");
|
|
const reminder = await getScheduleService().createReminder({
|
|
userId: PRIVATE_DATA_USER_ID,
|
|
itemId: args.itemId,
|
|
remindAt,
|
|
offsetMinutes: args.offsetMinutes ?? null,
|
|
channel: args.channel ?? "wechat"
|
|
});
|
|
return [{ type: "text", text: JSON.stringify(reminder, null, 2) }];
|
|
}
|
|
case "schedule_list_items": {
|
|
const items = await getScheduleService().listItems({
|
|
userId: PRIVATE_DATA_USER_ID,
|
|
from: args.from ?? null,
|
|
to: args.to ?? null,
|
|
status: args.status ?? "active",
|
|
limit: args.limit ?? 50
|
|
});
|
|
return [{ type: "text", text: JSON.stringify(items, null, 2) }];
|
|
}
|
|
default:
|
|
throw new Error(`\u672A\u77E5\u5DE5\u5177\uFF1A${name}`);
|
|
}
|
|
}
|
|
function send(obj) {
|
|
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
}
|
|
function respond(id, result) {
|
|
send({ jsonrpc: "2.0", id, result });
|
|
}
|
|
function respondError(id, message, code = -32603) {
|
|
send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
}
|
|
var rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
rl.on("line", async (raw) => {
|
|
const line = raw.trim();
|
|
if (!line) return;
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(line);
|
|
} catch {
|
|
process.stderr.write(`[mindspace-sandbox-mcp] invalid JSON: ${line}
|
|
`);
|
|
return;
|
|
}
|
|
const { id, method, params } = msg;
|
|
switch (method) {
|
|
case "initialize":
|
|
respond(id, {
|
|
protocolVersion: "2024-11-05",
|
|
serverInfo: { name: "mindspace-sandbox", version: "1.0.0" },
|
|
capabilities: { tools: {} }
|
|
});
|
|
break;
|
|
case "initialized":
|
|
break;
|
|
case "tools/list":
|
|
respond(id, { tools: TOOLS });
|
|
break;
|
|
case "tools/call": {
|
|
const { name, arguments: toolArgs } = params ?? {};
|
|
try {
|
|
const content = await callTool(name, toolArgs ?? {});
|
|
respond(id, { content, isError: false });
|
|
} catch (err) {
|
|
respond(id, {
|
|
content: [{ type: "text", text: err.message }],
|
|
isError: true
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
if (id !== void 0 && id !== null) {
|
|
respondError(id, `Method not found: ${method}`, -32601);
|
|
}
|
|
}
|
|
});
|
|
rl.on("close", () => process.exit(0));
|