feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API

单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-30 02:02:58 +08:00
parent 06eb129a28
commit 2c91689692
20 changed files with 2453 additions and 452 deletions
+251 -12
View File
@@ -149,6 +149,42 @@ 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"
} = {}) {
const local = String(localString ?? "").trim();
if (local) return parseLocalDateTimeString(local, timezone);
if (epochMs == null || epochMs === "") return null;
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`);
}
return safe;
}
// schedule-service.mjs
var DEFAULT_TIMEZONE2 = "Asia/Shanghai";
@@ -227,6 +263,18 @@ function rowToReminder(row) {
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") {
@@ -387,6 +435,65 @@ function createScheduleService(pool, options = {}) {
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')
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 createReminder = async ({
userId,
itemId,
@@ -610,6 +717,88 @@ function createScheduleService(pool, options = {}) {
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 *
@@ -712,14 +901,24 @@ function createScheduleService(pool, options = {}) {
[status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id]
);
};
const logDelivery = async ({ subscriptionId, userId, channel = "wechat", status, providerMessageId = null, errorCode = null, errorMessage = null }) => {
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 (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
crypto.randomUUID(),
reminderId,
subscriptionId,
userId,
channel,
@@ -835,13 +1034,22 @@ function createScheduleService(pool, options = {}) {
};
return {
createItem,
getItem,
createReminder,
listItems,
listItemsBySourceMessage,
listTodayTodoItems,
listUpcomingItems,
listUpcomingReminders,
listDigestSubscriptions,
createDailyTodoDigest,
createBalanceLowAlert,
listDueReminders,
lockReminder,
markReminderSent,
markReminderCancelled,
markReminderFailed,
buildReminderText,
listDueDigestSubscriptions,
lockDigestSubscription,
markDigestSent,
@@ -1031,9 +1239,12 @@ if (isScheduleConfigured()) {
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" },
endAt: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
dueAt: { type: "number", description: "\u622A\u6B62\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\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" },
@@ -1050,11 +1261,12 @@ if (isScheduleConfigured()) {
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" },
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", "remindAt"]
required: ["itemId"]
}
},
{
@@ -1303,16 +1515,35 @@ async function callTool(name, args) {
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: args.startAt ?? null,
endAt: args.endAt ?? null,
dueAt: args.dueAt ?? null,
startAt,
endAt,
dueAt,
allDay: Boolean(args.allDay),
timezone: args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai",
timezone,
location: args.location ?? null,
sourceChannel: "agent",
sourceMessageId: args.sourceMessageId ?? null,
@@ -1322,10 +1553,18 @@ async function callTool(name, args) {
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: args.remindAt,
remindAt,
offsetMinutes: args.offsetMinutes ?? null,
channel: args.channel ?? "wechat"
});
+998 -364
View File
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,14 @@ description: 处理待办、提醒、日程类消息;先澄清缺失时间,
- `schedule_create_reminder`
- `schedule_list_items`
## 时间写入(必守)
1. **禁止**自行估算 Unix 毫秒时间戳;模型算 epoch 极易出错。
2. 创建事项时用 `startLocal` / `endLocal` / `dueLocal`,格式 **`YYYY-MM-DD HH:mm`**(用户时区下的墙上时钟)。
3. 创建提醒时用 `remindLocal`,格式同上;不要只传 `remindAt`
4. 用户说「明天 / 后天」时,必须结合会话里的「当前日期」锚点推算具体日期后再写入 local 字段。
5. 写入后可用 `schedule_list_items` 核对返回的时间是否正确。
## 工作规则
1. 先判断用户是在创建、查询,还是补充提醒。