dsh-later 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.en.md +88 -3
- package/README.md +86 -3
- package/lib/client.js +828 -294
- package/lib/commands.js +486 -51
- package/lib/index.js +20 -2
- package/lib/ownership-store.js +50 -3
- package/lib/presence.js +72 -2
- package/lib/runtime.js +39 -5
- package/lib/schedule-summary-store.js +160 -0
- package/lib/schedule-summary.js +113 -0
- package/lib/smart-window.js +28 -3
- package/lib/types/commands.d.ts +60 -16
- package/lib/types/index.d.ts +2 -0
- package/lib/types/ownership-store.d.ts +30 -1
- package/lib/types/presence.d.ts +32 -0
- package/lib/types/runtime.d.ts +10 -1
- package/lib/types/schedule-summary-store.d.ts +75 -0
- package/lib/types/schedule-summary.d.ts +92 -0
- package/lib/types/user-tools.d.ts +9 -1
- package/lib/user-tools.js +13 -4
- package/package.json +5 -4
package/lib/commands.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { userScheduleCreate, userScheduleDelete, userScheduleEditPrompt, userScheduleList, userSchedulePause, userScheduleResume, resolvePromptLimits, } from './user-tools.js';
|
|
2
2
|
import { DEFAULT_MAX_SCHEDULES } from './user-tools.js';
|
|
3
3
|
import { epochFromLocal, localFieldsOf } from './smart-window.js';
|
|
4
|
-
import { detectTimeZone, formatAbsolute, formatRelative } from './time-utils.js';
|
|
4
|
+
import { detectTimeZone, formatAbsolute, formatHhmm, formatRelative } from './time-utils.js';
|
|
5
5
|
/** 命令名前缀(避免与其他插件命令冲突)。 */
|
|
6
6
|
const PREFIX = 'user-schedule';
|
|
7
7
|
/** 把任意异常稳定渲染为命令错误文本。 */
|
|
@@ -24,6 +24,13 @@ function parsePayload(rawInput) {
|
|
|
24
24
|
return { ok: false, text: 'JSON 载荷解析失败。' };
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
/** 关闭高级语法时的统一提示:说清去哪儿开,而不是只说"不支持"。 */
|
|
28
|
+
export const ADVANCED_SYNTAX_DISABLED = '多次写法(+1h*3、+1h+1h+0.5h、15:32+16:32)属于 Beta 高级语法,当前已在设置中关闭。' +
|
|
29
|
+
'开启路径:设置 → 插件 → 定时提醒 → 高级语法。';
|
|
30
|
+
/** 该时间写法是否属于高级语法(等间隔重复 / 相对序列 / 绝对锚点序列)。 */
|
|
31
|
+
function isMultiForm(target) {
|
|
32
|
+
return target.kind === 'repeats' || target.kind === 'sequence' || target.kind === 'chain';
|
|
33
|
+
}
|
|
27
34
|
/** 中文数字 → 数值(支持 十/两/半 由调用方特判)。 */
|
|
28
35
|
const CN_DIGIT = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
|
|
29
36
|
function cnToNumber(text) {
|
|
@@ -66,23 +73,248 @@ function normalizeInput(raw) {
|
|
|
66
73
|
const CN_UNIT_SECONDS = {
|
|
67
74
|
秒: 1, 秒钟: 1, 分: 60, 分钟: 60, 小时: 3600, 个小时: 3600, 钟头: 3600, 天: 86400, 日: 86400, 周: 604800,
|
|
68
75
|
};
|
|
76
|
+
/** 一次命令最多展开出的任务条数(`+1h*11`、11 段序列一律拒绝)。 */
|
|
77
|
+
export const MAX_OCCURRENCES = 10;
|
|
78
|
+
/**
|
|
79
|
+
* 多次写法下相邻两次之间的最小间隔(秒)。
|
|
80
|
+
*
|
|
81
|
+
* 只约束「多次」:单条 `/later +5s` 是既有合法用法(README 演示与测试都依赖它),
|
|
82
|
+
* 不因这道地板失效。`every` 记录在宿主侧另有 ≥300s 口径;多次写法走的是
|
|
83
|
+
* 「展开成 N 条 one-shot」,不经过 `every`,所以这里自带一道 60s 防护,
|
|
84
|
+
* 免得 `+1s*10` 一行命令把 dock 刷成瀑布。
|
|
85
|
+
*/
|
|
86
|
+
export const MIN_OCCURRENCE_INTERVAL_SECONDS = 60;
|
|
87
|
+
/**
|
|
88
|
+
* 单个「时长段」:段内可多单位连写(`1h30m` = 90 分钟),段内累加。
|
|
89
|
+
* 尾部的 `s*` 会吃掉紧跟的空格,段与段之间靠 `+` 分隔。
|
|
90
|
+
*/
|
|
91
|
+
const LATIN_SEGMENT_RE = /^(?:\d+(?:\.\d+)?\s*[smhdw]\s*)+/i;
|
|
92
|
+
/**
|
|
93
|
+
* 重复次数后缀:`*3` / `×3` / `x3`,可带中文量词(`*3次`)。
|
|
94
|
+
*
|
|
95
|
+
* `(?=\s|$)` 是刻意的:`+1h*3号楼见` 里次数没有以空格收尾,说明 `*3` 属于正文,
|
|
96
|
+
* 宁可报「写法未生效」也不要猜成 3 次——猜错的代价是用户以为设了 3 条任务。
|
|
97
|
+
*/
|
|
98
|
+
const REPEAT_SUFFIX_RE = /^\s*[*×xX]\s*(\d+)\s*(?:次|回|遍)?(?=\s|$)/;
|
|
99
|
+
/**
|
|
100
|
+
* 时间表达式之后的「疑似时间残片」。
|
|
101
|
+
*
|
|
102
|
+
* 旧版正则的 `(?![0-9a-z])` 放过了 `*` 与 `+`,于是 `/later +1h*3 x` 被静默解析成
|
|
103
|
+
* 「1 小时后发一条、内容写成 `*3 x`」。这类形态现在一律收口成显式报错。
|
|
104
|
+
*
|
|
105
|
+
* `\.{2,3}\s*\d` / `…\s*\d` 是同一个洞的另一个入口(`+1h..3h` → 内容 `..3h`)。
|
|
106
|
+
* 要求点后**必须跟数字**才判为残片:正文以省略号开头(`+1h … 记得带伞`)不受影响。
|
|
107
|
+
*/
|
|
108
|
+
const DANGLING_TIME_RE = /^\s*(?:[*×]\s*\d|[xX]\s*\d(?![0-9])|\+\s*\d|(?:\.{2,}|…)\s*\d)/;
|
|
109
|
+
/** 时长段 → 秒(`1h30m` → 5400);无有效单位或总值 ≤0 返回 undefined。 */
|
|
110
|
+
function latinSegmentSeconds(segment) {
|
|
111
|
+
let total = 0;
|
|
112
|
+
const unitRe = /(\d+(?:\.\d+)?)\s*([smhdw])/gi;
|
|
113
|
+
for (const match of segment.matchAll(unitRe)) {
|
|
114
|
+
const n = Number(match[1]);
|
|
115
|
+
const u = (match[2] ?? 'm').toLowerCase();
|
|
116
|
+
total += n * (u === 's' ? 1 : u === 'm' ? 60 : u === 'h' ? 3600 : u === 'd' ? 86400 : 604800);
|
|
117
|
+
}
|
|
118
|
+
return total > 0 ? Math.round(total) : undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 从 `from` 起扫描「`+` 分隔的时长段序列」:`1h`、`1h30m`、`1h+30m+15m`。
|
|
122
|
+
* 至少匹配一段才返回;`consumed` 相对 `from`。段数不设上限,交由校验层拒绝过长序列。
|
|
123
|
+
*
|
|
124
|
+
* 段尾空白**不算进 consumed**(等价于旧版正则的回溯行为):`+90s x` 里那个空格必须
|
|
125
|
+
* 留给边界判定,否则紧跟正文的 `x`/数字会被误当成「时间写法被截断」而整体拒收。
|
|
126
|
+
*/
|
|
127
|
+
function scanLatinSegments(s, from) {
|
|
128
|
+
let i = from;
|
|
129
|
+
let lastEnd = from; // 最后一个**完整时长段**结束的位置
|
|
130
|
+
const segments = [];
|
|
131
|
+
for (;;) {
|
|
132
|
+
const match = LATIN_SEGMENT_RE.exec(s.slice(i));
|
|
133
|
+
if (match === null)
|
|
134
|
+
break;
|
|
135
|
+
const seconds = latinSegmentSeconds(match[0] ?? '');
|
|
136
|
+
if (seconds === undefined)
|
|
137
|
+
break;
|
|
138
|
+
segments.push(seconds);
|
|
139
|
+
i += match[0].length;
|
|
140
|
+
lastEnd = trimTrailingSpace(s, i, from);
|
|
141
|
+
const gap = /^\s*\+/.exec(s.slice(i));
|
|
142
|
+
if (gap === null)
|
|
143
|
+
return { consumed: lastEnd - from, segments };
|
|
144
|
+
i += gap[0].length;
|
|
145
|
+
}
|
|
146
|
+
// 断在 `+` 之后时回退到上一个完整段:让残片守卫看见那个 `+`,
|
|
147
|
+
// 于是 `+1h+15:32`(相对段后面接绝对时刻)报「时间写法没生效」,
|
|
148
|
+
// 而不是含糊的「无法识别的时间格式」——前者告诉用户改哪里,后者不会。
|
|
149
|
+
return segments.length > 0 ? { consumed: lastEnd - from, segments } : undefined;
|
|
150
|
+
}
|
|
151
|
+
/** 把已落定的目标折算成 epoch 毫秒(after 相对 `now`,at 直接取解析出的时刻)。 */
|
|
152
|
+
function epochOfTarget(target, now) {
|
|
153
|
+
return target.kind === 'after' ? now + target.afterSeconds * 1000 : target.epoch;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 校验并展开「重复 / 序列」写法;不是多次写法时返回 undefined(走原单条路径)。
|
|
157
|
+
*
|
|
158
|
+
* 语义(产品决策):
|
|
159
|
+
* - 首次落在第一个间隔之后:`+1h*3` → t+1h、t+2h、t+3h;
|
|
160
|
+
* - 序列**链式累加**:`+1h+1h+0.5h` → t+1h、t+2h、t+2.5h(表达「提前 2h→1h→30m」的梯度催办);
|
|
161
|
+
* - 展开成 N 条 one-shot(`after`),而不是 1 条 `every`:宿主 `EveryScheduleRecord`
|
|
162
|
+
* 没有次数字段,而 one-shot 天然带 pause/resume、行内编辑、单条取消、到点自动清理,
|
|
163
|
+
* 且时刻锚定可预测,不会因会话忙碌而漂移。
|
|
164
|
+
*/
|
|
165
|
+
function expandOccurrences(target) {
|
|
166
|
+
if (target.kind !== 'repeats' && target.kind !== 'sequence')
|
|
167
|
+
return undefined;
|
|
168
|
+
const tooMany = `一次最多 ${MAX_OCCURRENCES} 条(多次写法会展开成同等数量的一次性任务,并受会话总额度约束)。`;
|
|
169
|
+
const tooTight = `多次写法的间隔至少 ${MIN_OCCURRENCE_INTERVAL_SECONDS} 秒;只想发一条可以不带次数。`;
|
|
170
|
+
if (target.kind === 'repeats') {
|
|
171
|
+
const { baseSeconds, count } = target;
|
|
172
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
173
|
+
return { ok: false, error: '重复次数必须是正整数,如 +1h*3、+30m×2。' };
|
|
174
|
+
}
|
|
175
|
+
if (count > MAX_OCCURRENCES)
|
|
176
|
+
return { ok: false, error: `重复次数过多(${count} 次)。${tooMany}` };
|
|
177
|
+
if (count > 1 && baseSeconds < MIN_OCCURRENCE_INTERVAL_SECONDS)
|
|
178
|
+
return { ok: false, error: tooTight };
|
|
179
|
+
return { ok: true, segments: Array.from({ length: count }, () => baseSeconds) };
|
|
180
|
+
}
|
|
181
|
+
const { segments } = target;
|
|
182
|
+
if (segments.length > MAX_OCCURRENCES)
|
|
183
|
+
return { ok: false, error: `时间序列过长(${segments.length} 段)。${tooMany}` };
|
|
184
|
+
if (segments.some((seconds) => seconds < MIN_OCCURRENCE_INTERVAL_SECONDS))
|
|
185
|
+
return { ok: false, error: tooTight };
|
|
186
|
+
return { ok: true, segments };
|
|
187
|
+
}
|
|
188
|
+
/** 去掉 `[0, i)` 尾部空白,返回新的长度(不小于 `from`)。 */
|
|
189
|
+
function trimTrailingSpace(s, i, from) {
|
|
190
|
+
let j = i;
|
|
191
|
+
while (j > from && /\s/.test(s[j - 1] ?? ''))
|
|
192
|
+
j -= 1;
|
|
193
|
+
return j;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* 把一个拉丁时长段解析成锚点序列的一步:`1d` → 墙钟加一天;`2h30m` → 瞬时加 9000 秒。
|
|
197
|
+
* 全零或不含单位返回 undefined(调用方会继续尝试把它当绝对时刻解析)。
|
|
198
|
+
*/
|
|
199
|
+
function latinDurationStep(segment) {
|
|
200
|
+
let days = 0;
|
|
201
|
+
let seconds = 0;
|
|
202
|
+
const unitRe = /(\d+(?:\.\d+)?)\s*([smhdw])/gi;
|
|
203
|
+
for (const match of segment.matchAll(unitRe)) {
|
|
204
|
+
const n = Number(match[1]);
|
|
205
|
+
const u = (match[2] ?? 'm').toLowerCase();
|
|
206
|
+
if (u === 'd')
|
|
207
|
+
days += n;
|
|
208
|
+
else if (u === 'w')
|
|
209
|
+
days += n * 7;
|
|
210
|
+
else
|
|
211
|
+
seconds += n * (u === 's' ? 1 : u === 'm' ? 60 : 3600);
|
|
212
|
+
}
|
|
213
|
+
// 小数天拆成「整天 + 瞬时尾巴」,避免把 0.5d 四舍五入成 1 天
|
|
214
|
+
const wholeDays = Math.floor(days);
|
|
215
|
+
seconds += (days - wholeDays) * 86400;
|
|
216
|
+
const roundedSeconds = Math.round(seconds);
|
|
217
|
+
if (wholeDays === 0 && roundedSeconds === 0)
|
|
218
|
+
return undefined;
|
|
219
|
+
return { kind: 'duration', days: wholeDays, seconds: roundedSeconds };
|
|
220
|
+
}
|
|
221
|
+
/** 本地日期字符串 `YYYY-MM-DD`。 */
|
|
222
|
+
function localDateOf(epoch, timeZone) {
|
|
223
|
+
const f = localFieldsOf(epoch, timeZone);
|
|
224
|
+
return `${f.year}-${String(f.month).padStart(2, '0')}-${String(f.day).padStart(2, '0')}`;
|
|
225
|
+
}
|
|
226
|
+
/** 纯日历日加减(用 UTC 字段做日期运算,与时区无关,不受夏令时切日影响)。 */
|
|
227
|
+
function addLocalDays(date, days) {
|
|
228
|
+
const [year, month, day] = date.split('-').map(Number);
|
|
229
|
+
const shifted = new Date(Date.UTC((year ?? 1970), (month ?? 1) - 1, (day ?? 1) + days));
|
|
230
|
+
return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String(shifted.getUTCDate()).padStart(2, '0')}`;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 把绝对时刻锚点向右扩展成 `+` 序列:`15:32+16:32+17:32`、`9:00+1d+1d`、`明天9点+1h`。
|
|
234
|
+
*
|
|
235
|
+
* 只允许「绝对锚点 → 往后累加」这一个方向。`+1h+15:32`(相对段后面接绝对时刻)
|
|
236
|
+
* 故意不支持:那样会出现 `16:32+1h+15:00` 这种要猜意图的来回跳,宁可在残片守卫里报错。
|
|
237
|
+
*/
|
|
238
|
+
function extendAnchorChain(s, base) {
|
|
239
|
+
const anchor = base.target;
|
|
240
|
+
if (anchor.kind !== 'at')
|
|
241
|
+
return undefined;
|
|
242
|
+
const steps = [];
|
|
243
|
+
let i = base.consumed;
|
|
244
|
+
for (;;) {
|
|
245
|
+
const gap = /^\s*\+/.exec(s.slice(i));
|
|
246
|
+
if (gap === null)
|
|
247
|
+
break;
|
|
248
|
+
const from = i + gap[0].length;
|
|
249
|
+
// a) 拉丁时长步:+1d / +2h / +1d12h(段内混合单位合成一步:先墙钟日后瞬时)
|
|
250
|
+
const dur = LATIN_SEGMENT_RE.exec(s.slice(from));
|
|
251
|
+
const durationStep = dur === null ? undefined : latinDurationStep(dur[0] ?? '');
|
|
252
|
+
if (dur !== null && durationStep !== undefined) {
|
|
253
|
+
// 时长段末尾**未去空白**的位置:紧跟数字/字母(`+1h30`)说明这一步不是干净时长,
|
|
254
|
+
// 停下来交给残片守卫报错,而不是猜半个时长。
|
|
255
|
+
const rawEnd = from + dur[0].length;
|
|
256
|
+
if (/[0-9a-z]/i.test(s[rawEnd] ?? ''))
|
|
257
|
+
break;
|
|
258
|
+
steps.push(durationStep);
|
|
259
|
+
i = trimTrailingSpace(s, rawEnd, from);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// b) 绝对时刻步:+16:32 / +1732 / +9点半 / +8月22日 10点
|
|
263
|
+
const stepMatch = matchSingleTimeExpr(s.slice(from));
|
|
264
|
+
if (stepMatch === undefined || stepMatch.target.kind !== 'at')
|
|
265
|
+
break;
|
|
266
|
+
steps.push({ kind: 'at', target: stepMatch.target });
|
|
267
|
+
i = trimTrailingSpace(s, from + stepMatch.consumed, from);
|
|
268
|
+
}
|
|
269
|
+
if (steps.length === 0)
|
|
270
|
+
return undefined;
|
|
271
|
+
return { consumed: i, target: { kind: 'chain', anchor, steps } };
|
|
272
|
+
}
|
|
69
273
|
/** 在归一化文本开头尝试匹配一个时间表达式;失败返回 undefined。 */
|
|
70
274
|
function matchTimeExpr(s) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
275
|
+
const single = matchSingleTimeExpr(s);
|
|
276
|
+
if (single === undefined)
|
|
277
|
+
return undefined;
|
|
278
|
+
if (single.target.kind !== 'at')
|
|
279
|
+
return single;
|
|
280
|
+
// 绝对时刻可以单独用(历史行为),也可以作锚点向右串 `+` 序列
|
|
281
|
+
return extendAnchorChain(s, single) ?? single;
|
|
282
|
+
}
|
|
283
|
+
/** 单个时间表达式的匹配(不含锚点序列扩展)。 */
|
|
284
|
+
function matchSingleTimeExpr(s) {
|
|
285
|
+
// 1. 拉丁相对时长:+30m / +1h30m / +90s / +1w / +2.5h,
|
|
286
|
+
// 可带重复次数 +1h*3(等间隔 ×N)或序列 +1h+1h+0.5h(链式累加)。
|
|
287
|
+
if (s.startsWith('+')) {
|
|
288
|
+
const scan = scanLatinSegments(s, 1);
|
|
289
|
+
if (scan !== undefined && scan.segments.length > 0) {
|
|
290
|
+
let consumed = 1 + scan.consumed;
|
|
291
|
+
let target;
|
|
292
|
+
if (scan.segments.length === 1) {
|
|
293
|
+
// 单段:尝试吞掉紧跟的 *N / ×N / xN(`+1h*3`、`+1h30m×2`、`+1h x2次`)
|
|
294
|
+
const suffix = REPEAT_SUFFIX_RE.exec(s.slice(consumed));
|
|
295
|
+
const baseSeconds = scan.segments[0];
|
|
296
|
+
if (suffix !== null) {
|
|
297
|
+
consumed += suffix[0].length;
|
|
298
|
+
target = { kind: 'repeats', baseSeconds, count: Number(suffix[1]) };
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
target = { kind: 'after', afterSeconds: baseSeconds };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
target = { kind: 'sequence', segments: scan.segments };
|
|
306
|
+
}
|
|
307
|
+
// 边界:时间写法后面不能紧跟数字/字母(`+1h3`、`+1hxyz` 一律不认)——
|
|
308
|
+
// 旧版用 `(?![0-9a-z])` 表达同一条规则,这里原样保留。次数后缀自带
|
|
309
|
+
// `(?=\s|$)`,所以吞掉 `*3` 后必然落在空白/结尾上,不会被这条误杀。
|
|
310
|
+
const boundary = s[consumed];
|
|
311
|
+
if (boundary !== undefined && /[0-9a-z]/i.test(boundary))
|
|
312
|
+
return undefined;
|
|
313
|
+
return { consumed, target };
|
|
80
314
|
}
|
|
81
|
-
if (total <= 0)
|
|
82
|
-
return undefined;
|
|
83
|
-
return { consumed: latinRel[0].length, target: { kind: 'after', afterSeconds: total } };
|
|
84
315
|
}
|
|
85
|
-
// 2. 中文相对时长:30分钟后 / 半小时后 / 两小时后 / 1天以后 / 2.5
|
|
316
|
+
// 2. 中文相对时长:30分钟后 / 半小时后 / 两小时后 / 1天以后 / 2.5小时后,
|
|
317
|
+
// 同样支持次数后缀(半小时后×3)
|
|
86
318
|
const cnRel = /^(半|\d+(?:\.\d+)?|[零一二两三四五六七八九十]+)\s*(个\s*小时|秒钟?|分钟?|小时|钟头|天|日|周)\s*(?:之)?后?/.exec(s);
|
|
87
319
|
if (cnRel !== null) {
|
|
88
320
|
const value = cnRel[1] === '半' ? 0.5 : cnToNumber(cnRel[1] ?? '');
|
|
@@ -90,10 +322,14 @@ function matchTimeExpr(s) {
|
|
|
90
322
|
const unitSeconds = CN_UNIT_SECONDS[unitKey];
|
|
91
323
|
if (value === undefined || value <= 0 || unitSeconds === undefined)
|
|
92
324
|
return undefined;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
325
|
+
const baseSeconds = Math.round(value * unitSeconds);
|
|
326
|
+
let consumed = cnRel[0].length;
|
|
327
|
+
const suffix = REPEAT_SUFFIX_RE.exec(s.slice(consumed));
|
|
328
|
+
if (suffix !== null) {
|
|
329
|
+
consumed += suffix[0].length;
|
|
330
|
+
return { consumed, target: { kind: 'repeats', baseSeconds, count: Number(suffix[1]) } };
|
|
331
|
+
}
|
|
332
|
+
return { consumed, target: { kind: 'after', afterSeconds: baseSeconds } };
|
|
97
333
|
}
|
|
98
334
|
// 3. 紧凑日期:YYYYMMDD-HHMM / MMDD-HHMM(显式日期,不自动顺延)
|
|
99
335
|
const compactFull = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(?!\d)/.exec(s);
|
|
@@ -178,8 +414,8 @@ function matchTimeExpr(s) {
|
|
|
178
414
|
timeLen = cnTime[0].length;
|
|
179
415
|
}
|
|
180
416
|
else if (dayOffset === undefined && year === undefined && month === undefined) {
|
|
181
|
-
// 裸 HHMM
|
|
182
|
-
const bare = /^(\d{2})(\d{2})(?!\d)(?=\s
|
|
417
|
+
// 裸 HHMM(仅当无任何日期成分;后面必须是空白、`+`(锚点序列的分隔符)或结尾)
|
|
418
|
+
const bare = /^(\d{2})(\d{2})(?!\d)(?=\s|\+|$)/.exec(rest2);
|
|
183
419
|
if (bare !== null) {
|
|
184
420
|
hour = Number(bare[1]);
|
|
185
421
|
minute = Number(bare[2]);
|
|
@@ -206,6 +442,11 @@ function resolveTarget(matched, now, timeZone) {
|
|
|
206
442
|
if (t.kind === 'after') {
|
|
207
443
|
return { status: 'ok', target: { kind: 'after', afterSeconds: t.afterSeconds } };
|
|
208
444
|
}
|
|
445
|
+
// 多次写法在 parseScheduleInput 里已展开成 N 个目标,不该走到这里;
|
|
446
|
+
// 这条分支只为收窄下面的 `at` 联合(真到了这里就是调用顺序写错了)。
|
|
447
|
+
if (t.kind === 'repeats' || t.kind === 'sequence' || t.kind === 'chain') {
|
|
448
|
+
return { status: 'error', error: '内部错误:多次写法未经展开处理。' };
|
|
449
|
+
}
|
|
209
450
|
// 范围校验
|
|
210
451
|
if ((t.month !== undefined && (t.month < 1 || t.month > 12)) ||
|
|
211
452
|
(t.day !== undefined && (t.day < 1 || t.day > 31)) ||
|
|
@@ -263,7 +504,62 @@ function resolveTarget(matched, now, timeZone) {
|
|
|
263
504
|
return { status: 'error', error: '无法解析目标时间。' };
|
|
264
505
|
}
|
|
265
506
|
/**
|
|
266
|
-
*
|
|
507
|
+
* 把锚点序列落定成 N 个**绝对墙钟时刻**(`at` 目标)。
|
|
508
|
+
*
|
|
509
|
+
* 落点用 `at` 而不是 `after` 秒数是刻意的:`9:00+1d+1d` 必须三天都落在 9:00,
|
|
510
|
+
* 而按 86400 秒累加在 `America/Los_Angeles`、`Europe/London` 这类有夏令时的时区
|
|
511
|
+
* 会漂成 8:00/10:00。代价是 `at` 任务不支持面板的「暂停」(暂停只认 after),
|
|
512
|
+
* 单条取消、行内编辑、到点自动清理照常可用。
|
|
513
|
+
*/
|
|
514
|
+
function resolveChain(chain, now, timeZone) {
|
|
515
|
+
const landed = resolveTarget({ consumed: 0, target: chain.anchor }, now, timeZone);
|
|
516
|
+
if (landed.status === 'error')
|
|
517
|
+
return { ok: false, error: landed.error };
|
|
518
|
+
if (landed.target.kind !== 'at')
|
|
519
|
+
return { ok: false, error: '锚点必须是绝对时刻。' };
|
|
520
|
+
const targets = [landed.target];
|
|
521
|
+
let date = landed.target.at.date;
|
|
522
|
+
let time = landed.target.at.time;
|
|
523
|
+
let epoch = landed.target.epoch;
|
|
524
|
+
for (const step of chain.steps) {
|
|
525
|
+
if (step.kind === 'duration') {
|
|
526
|
+
if (step.days > 0) {
|
|
527
|
+
date = addLocalDays(date, step.days);
|
|
528
|
+
const shifted = epochFromLocal(date, time, timeZone);
|
|
529
|
+
if (shifted === undefined) {
|
|
530
|
+
return {
|
|
531
|
+
ok: false,
|
|
532
|
+
error: `${date} 的 ${time.slice(0, 5)} 在 ${timeZone} 不存在(夏令时切换跳过了这个时刻),请换一个时刻或时区。`,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
epoch = shifted;
|
|
536
|
+
}
|
|
537
|
+
if (step.seconds > 0) {
|
|
538
|
+
// 瞬时累加:「一小时后」就是 3600 秒后,墙钟跟着走——跨夏令时时这正是预期
|
|
539
|
+
epoch += step.seconds * 1000;
|
|
540
|
+
date = localDateOf(epoch, timeZone);
|
|
541
|
+
const f = localFieldsOf(epoch, timeZone);
|
|
542
|
+
time = `${String(f.hour).padStart(2, '0')}:${String(f.minute).padStart(2, '0')}:00`;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
// 绝对时刻步:把上一次落点当 now 交给 resolveTarget,于是「该时刻今天已过就顺延到
|
|
547
|
+
// 明天」这条既有规则自动作用在每一步上(23:00+01:00 → 次日 01:00)。
|
|
548
|
+
const next = resolveTarget({ consumed: 0, target: step.target }, epoch, timeZone);
|
|
549
|
+
if (next.status === 'error')
|
|
550
|
+
return { ok: false, error: next.error };
|
|
551
|
+
if (next.target.kind !== 'at')
|
|
552
|
+
return { ok: false, error: '序列里的时刻写法无效。' };
|
|
553
|
+
date = next.target.at.date;
|
|
554
|
+
time = next.target.at.time;
|
|
555
|
+
epoch = next.target.epoch;
|
|
556
|
+
}
|
|
557
|
+
targets.push({ kind: 'at', at: { date, time, time_zone: timeZone }, epoch });
|
|
558
|
+
}
|
|
559
|
+
return { ok: true, targets };
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* 解析 `/schedule` 与 `/later` 的完整输入:开头的时间表达式 + 剩余内容。
|
|
267
563
|
*
|
|
268
564
|
* 支持格式(示例):
|
|
269
565
|
* - `+30m` `+1h30m` `+90s` `+1w`
|
|
@@ -272,31 +568,95 @@ function resolveTarget(matched, now, timeZone) {
|
|
|
272
568
|
* - `明天 15:32` `后天9点` `大后天 10:00`
|
|
273
569
|
* - `8月21日 15:32` `08-21 15:32`
|
|
274
570
|
* - `2026-08-21 15:32` `0821-1532` `20260821-1532`
|
|
571
|
+
* - 等间隔重复:`+1h*3` `+30m×2` `两小时后x3次` → t+1h、t+2h、t+3h
|
|
572
|
+
* - 链式序列:`+1h+1h+0.5h` → t+1h、t+2h、t+2.5h(梯度催办)
|
|
573
|
+
*
|
|
574
|
+
* 多次写法一律展开成 N 条 `after` 一次性任务(不是 1 条 `every`),理由见
|
|
575
|
+
* {@link expandOccurrences}。上限 `MAX_OCCURRENCES`,多次写法的最小间隔
|
|
576
|
+
* `MIN_OCCURRENCE_INTERVAL_SECONDS`。
|
|
275
577
|
*
|
|
276
578
|
* `limits` 可选:未传时回落到 `DEFAULT_MAX_PROMPT_CHARS=1000`,错误文案里
|
|
277
579
|
* 的字符数与 settings 实际生效值一致。
|
|
278
580
|
*/
|
|
279
|
-
export function parseScheduleInput(raw, now, timeZone, limits = { allowLong: false, maxChars: 1000 }) {
|
|
581
|
+
export function parseScheduleInput(raw, now, timeZone, limits = { allowLong: false, maxChars: 1000 }, options = { advancedSyntax: true }) {
|
|
280
582
|
const s = normalizeInput(raw);
|
|
281
583
|
if (s.length === 0)
|
|
282
584
|
return { ok: false, error: '用法:/schedule <时间> <内容>,如 /schedule 1532 检查构建结果' };
|
|
283
585
|
const matched = matchTimeExpr(s);
|
|
284
586
|
if (matched === undefined) {
|
|
285
|
-
return {
|
|
587
|
+
return {
|
|
588
|
+
ok: false,
|
|
589
|
+
error: '无法识别的时间格式。支持:+30m、1532、15:32、30分钟后、明天9点、2026-08-21 15:32;' +
|
|
590
|
+
'重复 +1h*3;序列 +1h+1h+0.5h。',
|
|
591
|
+
};
|
|
286
592
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
593
|
+
// 残片守卫:时间写法「没被吞干净」的形态(`15:32*3`、`+1h+1h*2`、`+1h*3号楼`)。
|
|
594
|
+
// 旧版正则的负向断言放过了 `*` 与 `+`,于是这类输入被静默解析成「单条 +1h,
|
|
595
|
+
// 内容里带着 *3」——用户以为设了 3 次,实际只收到一条乱码提醒。宁可报错。
|
|
596
|
+
const rest = s.slice(matched.consumed);
|
|
597
|
+
if (DANGLING_TIME_RE.test(rest)) {
|
|
598
|
+
return {
|
|
599
|
+
ok: false,
|
|
600
|
+
error: '时间写法后面还跟着一段时长/次数,没有生效。多时刻请写成 +1h*3(等间隔 3 次)或 15:32+16:32、9:00+1d(序列,用 + 分隔);' +
|
|
601
|
+
'次数后面要留一个空格。若那部分是正文,请换个说法。',
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
// Beta 开关:多次写法可关。**关闭 ≠ 回到旧行为**——旧行为是静默建成一条乱码任务,
|
|
605
|
+
// 那是 bug,不在可回退的功能面里;这里只把它从"展开成 N 条"降级为"明确拒绝"。
|
|
606
|
+
if (options.advancedSyntax === false && isMultiForm(matched.target)) {
|
|
607
|
+
return { ok: false, error: ADVANCED_SYNTAX_DISABLED };
|
|
608
|
+
}
|
|
609
|
+
const occurrences = expandOccurrences(matched.target);
|
|
610
|
+
if (occurrences !== undefined && !occurrences.ok)
|
|
611
|
+
return { ok: false, error: occurrences.error };
|
|
612
|
+
let targets;
|
|
613
|
+
if (matched.target.kind === 'chain') {
|
|
614
|
+
const { steps } = matched.target;
|
|
615
|
+
if (steps.length + 1 > MAX_OCCURRENCES) {
|
|
616
|
+
return { ok: false, error: `时间序列过长(${steps.length + 1} 个时刻)。${MAX_OCCURRENCES} 个为上限。` };
|
|
617
|
+
}
|
|
618
|
+
const chain = resolveChain(matched.target, now, timeZone);
|
|
619
|
+
if (!chain.ok)
|
|
620
|
+
return { ok: false, error: chain.error };
|
|
621
|
+
// 相邻落点的最小间隔:`9:00+1s` 这类瞬时步会绕过时长段的地板,在这里兜住
|
|
622
|
+
for (let index = 1; index < chain.targets.length; index += 1) {
|
|
623
|
+
const prev = chain.targets[index - 1];
|
|
624
|
+
const cur = chain.targets[index];
|
|
625
|
+
const gapSeconds = (epochOfTarget(cur, now) - epochOfTarget(prev, now)) / 1000;
|
|
626
|
+
if (gapSeconds < MIN_OCCURRENCE_INTERVAL_SECONDS) {
|
|
627
|
+
return { ok: false, error: `相邻两个时刻相差 ${Math.round(gapSeconds)} 秒,多次写法至少要隔 ${MIN_OCCURRENCE_INTERVAL_SECONDS} 秒。` };
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
targets = chain.targets;
|
|
631
|
+
}
|
|
632
|
+
else if (occurrences !== undefined) {
|
|
633
|
+
const expanded = [];
|
|
634
|
+
let cumulative = 0;
|
|
635
|
+
for (const seconds of occurrences.segments) {
|
|
636
|
+
cumulative += seconds;
|
|
637
|
+
expanded.push({ kind: 'after', afterSeconds: cumulative });
|
|
638
|
+
}
|
|
639
|
+
targets = expanded;
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
const resolved = resolveTarget(matched, now, timeZone);
|
|
643
|
+
if (resolved.status === 'error')
|
|
644
|
+
return { ok: false, error: resolved.error };
|
|
645
|
+
targets = [resolved.target];
|
|
646
|
+
}
|
|
647
|
+
const content = rest.trim();
|
|
291
648
|
if (content.length === 0)
|
|
292
649
|
return { ok: false, error: '提醒内容不能为空。' };
|
|
293
650
|
if (content.length > limits.maxChars) {
|
|
294
651
|
return { ok: false, error: `提醒内容不能超过 ${limits.maxChars} 字符。` };
|
|
295
652
|
}
|
|
296
|
-
return { ok: true, target:
|
|
653
|
+
return { ok: true, target: targets[0], targets, content };
|
|
297
654
|
}
|
|
298
655
|
/**
|
|
299
|
-
*
|
|
656
|
+
* 兼容旧签名:解析纯时间描述(不含内容),**只支持单条**。
|
|
657
|
+
*
|
|
658
|
+
* 多次/序列写法会被明确拒绝:本函数的消费方(GUI 单次通道)一次只能落一条记录,
|
|
659
|
+
* 静默取第一条等于把用户的「3 次」吃掉。
|
|
300
660
|
*/
|
|
301
661
|
export function parseScheduleSpec(spec, now, timeZone) {
|
|
302
662
|
const s = normalizeInput(spec);
|
|
@@ -304,6 +664,21 @@ export function parseScheduleSpec(spec, now, timeZone) {
|
|
|
304
664
|
if (matched === undefined) {
|
|
305
665
|
return { ok: false, error: '无法识别的时间格式。支持:+30m、1532、15:32、30分钟后、明天9点 等。' };
|
|
306
666
|
}
|
|
667
|
+
const occurrences = expandOccurrences(matched.target);
|
|
668
|
+
if (occurrences !== undefined && !occurrences.ok)
|
|
669
|
+
return { ok: false, error: occurrences.error };
|
|
670
|
+
if (matched.target.kind === 'chain') {
|
|
671
|
+
return {
|
|
672
|
+
ok: false,
|
|
673
|
+
error: '锚点序列会展开成多条任务,仅 /schedule 与 /later 支持;单条写法请只写一个时刻。',
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
if (occurrences !== undefined && occurrences.segments.length > 1) {
|
|
677
|
+
return {
|
|
678
|
+
ok: false,
|
|
679
|
+
error: '多次/序列写法会展开成多条任务,仅 /schedule 与 /later 支持;单条写法请用 +1h 这类单一时间。',
|
|
680
|
+
};
|
|
681
|
+
}
|
|
307
682
|
const resolved = resolveTarget(matched, now, timeZone);
|
|
308
683
|
if (resolved.status === 'error')
|
|
309
684
|
return { ok: false, error: resolved.error };
|
|
@@ -315,6 +690,26 @@ export function parseScheduleSpec(spec, now, timeZone) {
|
|
|
315
690
|
}
|
|
316
691
|
return { ok: true, kind: 'at', at: resolved.target.at, epoch: resolved.target.epoch };
|
|
317
692
|
}
|
|
693
|
+
/**
|
|
694
|
+
* 多次写法的时刻列表文案:首条给完整日期,同一天的后续条只给 `HH:mm`。
|
|
695
|
+
*
|
|
696
|
+
* 跨天的序列(`+1d+1d*3` 之类)会退回到完整日期形式——只看 `HH:mm` 会丢「哪一天」。
|
|
697
|
+
*/
|
|
698
|
+
function describeEpochs(epochs, timeZone) {
|
|
699
|
+
const first = epochs[0];
|
|
700
|
+
if (first === undefined)
|
|
701
|
+
return '';
|
|
702
|
+
const base = localFieldsOf(first, timeZone);
|
|
703
|
+
return epochs
|
|
704
|
+
.map((epoch, index) => {
|
|
705
|
+
if (index === 0)
|
|
706
|
+
return formatAbsolute(epoch, timeZone);
|
|
707
|
+
const f = localFieldsOf(epoch, timeZone);
|
|
708
|
+
const sameDate = f.year === base.year && f.month === base.month && f.day === base.day;
|
|
709
|
+
return sameDate ? formatHhmm(epoch, timeZone) : formatAbsolute(epoch, timeZone);
|
|
710
|
+
})
|
|
711
|
+
.join('、');
|
|
712
|
+
}
|
|
318
713
|
/** 命令工厂:返回三个命令定义。
|
|
319
714
|
*
|
|
320
715
|
* `getSettings` 支持传数字(静态,兼容旧签名)、返回 `{maxSchedules}` 的 getter,
|
|
@@ -327,16 +722,16 @@ export function userScheduleCommands(ctx, getSettings = DEFAULT_MAX_SCHEDULES, o
|
|
|
327
722
|
if (typeof getSettings === 'function') {
|
|
328
723
|
const value = getSettings();
|
|
329
724
|
if (typeof value === 'number') {
|
|
330
|
-
return { maxSchedules: value, limits: { allowLong: false, maxChars: 1000 } };
|
|
725
|
+
return { maxSchedules: value, limits: { allowLong: false, maxChars: 1000 }, advancedSyntax: true };
|
|
331
726
|
}
|
|
332
727
|
const maxSchedules = value?.maxSchedules ?? DEFAULT_MAX_SCHEDULES;
|
|
333
728
|
const limits = resolvePromptLimits({
|
|
334
729
|
allowLongPrompts: value?.allowLongPrompts,
|
|
335
730
|
maxPromptChars: value?.maxPromptChars,
|
|
336
731
|
});
|
|
337
|
-
return { maxSchedules, limits };
|
|
732
|
+
return { maxSchedules, limits, advancedSyntax: value?.advancedSyntax !== false };
|
|
338
733
|
}
|
|
339
|
-
return { maxSchedules: getSettings, limits: { allowLong: false, maxChars: 1000 } };
|
|
734
|
+
return { maxSchedules: getSettings, limits: { allowLong: false, maxChars: 1000 }, advancedSyntax: true };
|
|
340
735
|
};
|
|
341
736
|
const create = {
|
|
342
737
|
name: `${PREFIX}-create`,
|
|
@@ -502,46 +897,77 @@ export function userScheduleCommands(ctx, getSettings = DEFAULT_MAX_SCHEDULES, o
|
|
|
502
897
|
// 人类友好快捷命令:/schedule <时间> <内容>(到点以「上下文注入」提醒注入)与
|
|
503
898
|
// /later <时间> <内容>(到点以「我」的身份发送 = 延迟发送语义,产品取舍见
|
|
504
899
|
// 下方注释)。两者共享同一解析与创建逻辑,仅 delivery 不同。
|
|
900
|
+
//
|
|
901
|
+
// 附件(`invocation.attachments`)只在 user 路径透传:/later 声明了
|
|
902
|
+
// `input.attachments`,命令框架已把 composer 附件做耐久化准入;/schedule
|
|
903
|
+
// 未声明,宿主会在 handler 之前就拒绝带附件的调用,故 context 路径不接附件。
|
|
505
904
|
const buildQuickHandler = (delivery) => {
|
|
506
905
|
return async (invocation) => {
|
|
507
906
|
const timeZone = detectTimeZone();
|
|
508
|
-
const { maxSchedules, limits } = resolveSettings();
|
|
509
|
-
|
|
907
|
+
const { maxSchedules, limits, advancedSyntax } = resolveSettings();
|
|
908
|
+
// 一次取 now:既喂给解析器,也作为成功文案的基准时刻,避免两次 Date.now()
|
|
909
|
+
// 让「约 1 小时后」与实际 scheduledAt 差出一个毫秒级的心跳。
|
|
910
|
+
const now = Date.now();
|
|
911
|
+
const parsed = parseScheduleInput(invocation.rawInput, now, timeZone, limits, { advancedSyntax });
|
|
510
912
|
if (!parsed.ok)
|
|
511
913
|
return renderError(parsed.error);
|
|
512
914
|
const content = parsed.content;
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
: { prompt: content, at: parsed.target.at, time_zone: timeZone };
|
|
516
|
-
// P0-1:delivery 只能经可信通道参数显式声明(人类输入的 /later),
|
|
915
|
+
const attachments = delivery === 'user' ? invocation.attachments : [];
|
|
916
|
+
// P0-1:delivery/attachments 只能经可信通道参数显式声明(人类输入的 /later),
|
|
517
917
|
// 不再注入原始载荷——通用载荷字段一律无效,防止任何通道夹带绕过注入防护。
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
918
|
+
//
|
|
919
|
+
// 多次写法(+1h*3 / +1h+1h+0.5h)逐条创建:内容与附件每条都相同(「每条都带
|
|
920
|
+
// 同一份附件引用」的产品决策),dock 上是 N 行独立任务,可单条取消/编辑/暂停。
|
|
921
|
+
// 中途名额耗尽保留已建成的部分并如实报出——回滚已完成的事务反而更难解释。
|
|
922
|
+
const epochs = [];
|
|
923
|
+
let failure;
|
|
924
|
+
for (const target of parsed.targets) {
|
|
925
|
+
const input = target.kind === 'after'
|
|
926
|
+
? { prompt: content, after_seconds: target.afterSeconds, time_zone: timeZone }
|
|
927
|
+
: { prompt: content, at: target.at, time_zone: timeZone };
|
|
928
|
+
const result = await userScheduleCreate(input, invocation.agent, ctx, maxSchedules, { trustedDelivery: delivery, attachments }, limits);
|
|
929
|
+
if (!result.ok) {
|
|
930
|
+
failure = `${result.code}: ${result.message}`;
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
epochs.push(target.kind === 'after' ? now + target.afterSeconds * 1000 : target.epoch);
|
|
521
934
|
}
|
|
935
|
+
if (epochs.length === 0)
|
|
936
|
+
return renderError(failure ?? '创建失败。');
|
|
522
937
|
try {
|
|
523
938
|
onUserChange?.(invocation.agent);
|
|
524
939
|
}
|
|
525
940
|
catch {
|
|
526
941
|
/* 通知失败不影响命令成功结果 */
|
|
527
942
|
}
|
|
528
|
-
const epoch = parsed.target.kind === 'after' ? Date.now() + parsed.target.afterSeconds * 1000 : parsed.target.epoch;
|
|
529
943
|
const verb = delivery === 'user' ? '将于' : '已设定提醒:';
|
|
530
|
-
const hint =
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
944
|
+
const hint = epochs.length === 1
|
|
945
|
+
? delivery === 'user'
|
|
946
|
+
? '(到点会以你的身份发出)'
|
|
947
|
+
: '(输入框上方蓝色待发条可随时取消)'
|
|
948
|
+
: delivery === 'user'
|
|
949
|
+
? '(到点逐条以你的身份发出,待发条里可单独取消)'
|
|
950
|
+
: '(每条可在输入框上方待发条单独取消)';
|
|
951
|
+
const attachNote = attachments.length > 0 ? `(含 ${attachments.length} 个附件)` : '';
|
|
952
|
+
const times = epochs.length === 1
|
|
953
|
+
? `${formatAbsolute(epochs[0], timeZone)}(${formatRelative(epochs[0], now)})`
|
|
954
|
+
: describeEpochs(epochs, timeZone);
|
|
955
|
+
const head = epochs.length === 1 ? `${verb}${times}` : `已展开为 ${epochs.length} 条任务:${times}`;
|
|
956
|
+
const partial = failure !== undefined && epochs.length < parsed.targets.length
|
|
957
|
+
? `|注意:只建成 ${epochs.length}/${parsed.targets.length} 条,${failure}`
|
|
958
|
+
: '';
|
|
959
|
+
return { kind: 'success', text: `${head}「${content}」${attachNote}${hint}${partial}` };
|
|
535
960
|
};
|
|
536
961
|
};
|
|
537
962
|
const quick = {
|
|
538
963
|
name: 'schedule',
|
|
539
|
-
description: '定时提醒快捷命令:/schedule <时间> <内容>。时间宽容识别:+30m、+1h30m、30分钟后、1532、15:32、9点半、明天9点、后天 10:00、8月21日 15:32、0821-1532、2026-08-21 15:32。'
|
|
964
|
+
description: '定时提醒快捷命令:/schedule <时间> <内容>。时间宽容识别:+30m、+1h30m、30分钟后、1532、15:32、9点半、明天9点、后天 10:00、8月21日 15:32、0821-1532、2026-08-21 15:32。' +
|
|
965
|
+
'多次写法:+1h*3(等间隔 3 次)与 +1h+1h+0.5h(链式序列,累计时刻),各自展开成独立任务,最多 10 条、多次时间隔 ≥60 秒。',
|
|
540
966
|
// 声明 input → DSH 命令系统在输入框「等待补全」:选中/回车 /schedule 后
|
|
541
967
|
// 保留 `/schedule ` token + hint 提示,等你写完参数回车才执行;带参整行
|
|
542
968
|
// 回车也走命令系统(不会当普通用户消息直接发给模型)。
|
|
543
969
|
input: {
|
|
544
|
-
hint: '<时间> <内容>,如 1532 检查构建结果;也支持 +30m、明天9
|
|
970
|
+
hint: '<时间> <内容>,如 1532 检查构建结果;也支持 +30m、明天9点、+1h*3、+1h+30m',
|
|
545
971
|
},
|
|
546
972
|
recordInput: false,
|
|
547
973
|
handler: buildQuickHandler('context'),
|
|
@@ -550,11 +976,20 @@ export function userScheduleCommands(ctx, getSettings = DEFAULT_MAX_SCHEDULES, o
|
|
|
550
976
|
// 产品取舍(有意为之,与 dsh-sleep-send 对齐):它会让无人真实键入的内容
|
|
551
977
|
// 以用户气泡出现,绕过核心的「注入 vs 人键」信任边界;因此只保留在人类
|
|
552
978
|
// 显式输入 `/later` 的路径,工具/面板一律走安全的 `context`(notice 注入)。
|
|
979
|
+
//
|
|
980
|
+
// `attachments: true`(0.2.0 命令契约):声明本命令接受 composer 附件。
|
|
981
|
+
// 缺它时带图提交会被内核三层拦截(输入框提交机 / 命令仲裁 / 宿主注册表
|
|
982
|
+
// 都按 `input.attachments` 放行),表现为「有图片时 /later 无法发送」。
|
|
983
|
+
// 声明后宿主先做耐久化准入,handler 从 `invocation.attachments` 拿到
|
|
984
|
+
// 耐久引用(ImageBlock/FileBlock),存 sidecar,到点代发时拼进消息 content。
|
|
553
985
|
const later = {
|
|
554
986
|
name: 'later',
|
|
555
|
-
description: '延迟发送:/later <时间>
|
|
987
|
+
description: '延迟发送:/later <时间> <内容>,到点以你的身份发出该内容(不是提醒,是代发);可附带图片/文件,到点随内容一起发出。时间格式同 /schedule,' +
|
|
988
|
+
'含多次写法:+1h*3(等间隔 N 次)、+1h+1h+0.5h(相对序列)、15:32+16:32 / 9:00+1d(锚点序列)会展开成多条代发任务(同一内容与附件逐条带上)。' +
|
|
989
|
+
'多次写法为 Beta 语法,可在插件设置「高级语法」中关闭。',
|
|
556
990
|
input: {
|
|
557
|
-
hint: '<时间> <内容>,如 1532
|
|
991
|
+
hint: '<时间> <内容>,如 1532 检查构建结果;可附带图片/文件;多条用 +1h*3 或 +1h+30m;到点会以你的身份发出',
|
|
992
|
+
attachments: true,
|
|
558
993
|
},
|
|
559
994
|
recordInput: false,
|
|
560
995
|
handler: buildQuickHandler('user'),
|