feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API
单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
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. 先判断用户是在创建、查询,还是补充提醒。
|
||||
|
||||
Reference in New Issue
Block a user