@ottttto/dsh-scheduled-send 0.2.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.
@@ -0,0 +1,225 @@
1
+ // Client-layer core for dsh-scheduled-send (browser side, framework-free and
2
+ // testable in Node). The lib/client.js bundle wraps this with React/slots.
3
+
4
+ /** Media query marking the mobile layout (FIX 5). */
5
+ export const MOBILE_QUERY = '(max-width: 480px)';
6
+
7
+ /**
8
+ * Grace window during which a freshly POSTed task survives a refresh whose
9
+ * server snapshot was taken BEFORE the task existed (FIX 4 race).
10
+ */
11
+ export const LOCAL_ADD_GRACE_MS = 10_000;
12
+
13
+ /** Safe matchMedia probe: never throws, false when unavailable. */
14
+ export function isMobileViewport(matchMedia) {
15
+ try {
16
+ if (typeof matchMedia !== 'function') return false;
17
+ return !!matchMedia(MOBILE_QUERY)?.matches;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ /** Sort a task list by sendAt ascending (display order). */
24
+ export function sortTasks(tasks) {
25
+ return [...(tasks || [])].sort((a, b) => (a.sendAt || 0) - (b.sendAt || 0));
26
+ }
27
+
28
+ /**
29
+ * Collapse rule for the dock (FIX 2):
30
+ * - default (desktop): >1 entry → show ONLY the soonest (sendAt-min) entry
31
+ * plus a 「其余 N 条定时任务 ⌄」 summary toggle; ≤1 entry → expanded.
32
+ * - default (mobile): fully collapsed (summary only).
33
+ * - user 'expanded' → all entries; user 'collapsed' → nothing but the
34
+ * summary (manual collapse-all works for ANY count, including 1).
35
+ * @param {Array} tasks already sorted ascending by sendAt
36
+ * @param {{user?: 'expanded'|'collapsed'|null, mobile?: boolean}} [opts]
37
+ */
38
+ export function collapseState(tasks, { user = null, mobile = false } = {}) {
39
+ const list = tasks || [];
40
+ if (!list.length) {
41
+ return { display: 'expanded', visibleCount: 0, hiddenCount: 0, summary: null, collapsed: false };
42
+ }
43
+ let display = mobile ? 'summary' : (list.length > 1 ? 'one' : 'expanded');
44
+ if (user === 'expanded') display = 'expanded';
45
+ else if (user === 'collapsed') display = list.length > 1 ? 'one' : 'summary';
46
+ if (display === 'one') {
47
+ return { display, visibleCount: 1, hiddenCount: list.length - 1, summary: `其余 ${list.length - 1} 条定时任务 ⌄`, collapsed: true };
48
+ }
49
+ if (display === 'summary') {
50
+ return { display, visibleCount: 0, hiddenCount: list.length, summary: `${list.length} 条定时任务 ⌄`, collapsed: true };
51
+ }
52
+ return { display: 'expanded', visibleCount: list.length, hiddenCount: 0, summary: '收起 ⌃', collapsed: false };
53
+ }
54
+
55
+ /** Local-timezone "YYYY-MM-DD HH:mm" for a planned send time. */
56
+ export function formatLocalTime(ms) {
57
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';
58
+ const d = new Date(ms);
59
+ const pad = (n) => String(n).padStart(2, '0');
60
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
61
+ }
62
+
63
+ /** Countdown until sendAt ("N分N秒后" / "N小时N分后" / "即将发送"). */
64
+ export function formatCountdown(sendAt, now) {
65
+ const ms = sendAt - now;
66
+ if (ms <= 0) return '即将发送';
67
+ const totalSec = Math.floor(ms / 1000);
68
+ const h = Math.floor(totalSec / 3600);
69
+ const m = Math.floor((totalSec % 3600) / 60);
70
+ const s = totalSec % 60;
71
+ if (h > 0) return `${h}小时${m}分后`;
72
+ if (m > 0) return `${m}分${s}秒后`;
73
+ return `${s}秒后`;
74
+ }
75
+
76
+ /** Default confirmation time = now + 5 minutes. */
77
+ export function defaultSendAt(now = Date.now(), offsetMs = 5 * 60_000) {
78
+ return now + offsetMs;
79
+ }
80
+
81
+ /**
82
+ * Stateful client controller: polls the host state route, holds the visible
83
+ * task list (extended with the server-confirmed task right after POST so new
84
+ * entries show WITHOUT a refresh), and exposes cancel.
85
+ *
86
+ * FIX 1: setSession() reports session changes so the view can refresh
87
+ * immediately; refresh failures keep the LAST data and surface an error
88
+ * instead of flashing empty.
89
+ *
90
+ * FIX 4: refresh() merges by id — a recently POSTed task (within
91
+ * LOCAL_ADD_GRACE_MS) survives a stale server snapshot that predates it.
92
+ *
93
+ * @param {object} deps
94
+ * @param {() => Promise<object>} deps.fetchState GET the host state route
95
+ * @param {(payload:object)=>Promise<{task:object}>} deps.postSchedule
96
+ * @param {(id:string)=>Promise<boolean>} deps.cancelSchedule
97
+ * @param {() => number} [deps.now]
98
+ */
99
+ export function createScheduledClientState({ fetchState, postSchedule, cancelSchedule, now = () => Date.now() } = {}) {
100
+ let tasks = [];
101
+ let error = null;
102
+ let timer = null;
103
+ let stopped = false;
104
+ let sessionId = null; // this dock belongs to exactly one conversation
105
+ const recentAdds = new Map(); // id → addedAt (FIX 4 grace window)
106
+ // per-conversation cache: switching sessions shows the cached list
107
+ // INSTANTLY (no ~1s wait for the network), then refresh() revalidates.
108
+ const sessionCache = new Map(); // sid → task[]
109
+
110
+ // strict conversation filter: with a session bound, only entries belonging
111
+ // to THIS conversation are ever visible; other-session tasks are dropped
112
+ // from the local list on every refresh.
113
+ const own = (it) => !sessionId || !it?.conversationId || it.conversationId === sessionId;
114
+ const dedupeById = (list) => {
115
+ const seen = new Set();
116
+ return list.filter((t) => (t?.id && !seen.has(t.id) ? (seen.add(t.id), true) : false));
117
+ };
118
+
119
+ return {
120
+ /**
121
+ * Bind this client to one conversation. Returns true when the session
122
+ * actually CHANGED (the view refreshes immediately in that case — FIX 1).
123
+ */
124
+ setSession(sid) {
125
+ const next = sid || null;
126
+ const changed = next !== sessionId;
127
+ if (sessionId !== null) sessionCache.set(sessionId, tasks.filter(own));
128
+ sessionId = next;
129
+ if (changed) {
130
+ // instant swap: show the cached list for the target session first
131
+ tasks = sessionId !== null && sessionCache.has(sessionId)
132
+ ? [...sessionCache.get(sessionId)]
133
+ : [];
134
+ error = null;
135
+ }
136
+ return changed;
137
+ },
138
+
139
+ /** Session this client is currently bound to (view fetch uses it). */
140
+ currentSession() {
141
+ return sessionId;
142
+ },
143
+
144
+ /** Pending tasks sorted ascending by sendAt, own session only. */
145
+ visibleTasks() {
146
+ return sortTasks(tasks).filter(own);
147
+ },
148
+ /** Last refresh error, or null (view shows old data + this line). */
149
+ lastError() {
150
+ return error;
151
+ },
152
+ snapshot() {
153
+ return { tasks: sortTasks(tasks).filter(own), error, fetchedAt: now() };
154
+ },
155
+
156
+ async refresh() {
157
+ let s;
158
+ try {
159
+ s = await fetchState();
160
+ } catch (err) {
161
+ // FIX 1 failure mode: keep the previous list, surface the error —
162
+ // never flash an empty dock on a transient failure.
163
+ error = String(err?.message || err);
164
+ return this.snapshot();
165
+ }
166
+ error = null;
167
+ const server = (s?.tasks ?? []).filter(own);
168
+ // FIX 4: keep freshly POSTed tasks whose id the (possibly stale) server
169
+ // snapshot does not know yet; the server list stays authoritative for
170
+ // everything else (cancellations included).
171
+ const t = now();
172
+ const freshLocal = tasks.filter(
173
+ (it) => it?.id && !server.some((x) => x.id === it.id) && own(it) && t - (recentAdds.get(it.id) ?? -Infinity) < LOCAL_ADD_GRACE_MS,
174
+ );
175
+ for (const id of [...recentAdds.keys()]) {
176
+ if (t - recentAdds.get(id) >= LOCAL_ADD_GRACE_MS || server.some((x) => x.id === id)) recentAdds.delete(id);
177
+ }
178
+ tasks = dedupeById([...server, ...freshLocal]);
179
+ if (sessionId !== null) sessionCache.set(sessionId, tasks); // keep the instant-swap cache fresh
180
+ return this.snapshot();
181
+ },
182
+
183
+ /**
184
+ * User-facing schedule entry: POST to the host route and locally enqueue
185
+ * the SERVER-RETURNED task so it is visible IMMEDIATELY (无需刷新立即显示,
186
+ * id-deduped). Nothing is added on failure, so the view keeps the form
187
+ * with its content intact (失败回滚保留表单).
188
+ */
189
+ async scheduleMessage(payload) {
190
+ if (!postSchedule) throw new Error('postSchedule 未配置');
191
+ const result = await postSchedule(payload);
192
+ const task = result?.task ?? result;
193
+ if (task?.id && own(task)) {
194
+ tasks = dedupeById([...tasks, task]);
195
+ recentAdds.set(task.id, now());
196
+ }
197
+ return task;
198
+ },
199
+
200
+ /** Cancel a pending task: DELETE on the host + drop locally. */
201
+ async cancelTask(id) {
202
+ if (cancelSchedule) await cancelSchedule(id);
203
+ tasks = tasks.filter((t) => t.id !== id);
204
+ recentAdds.delete(id);
205
+ },
206
+
207
+ start(intervalMs = 4_000) {
208
+ if (timer) return;
209
+ stopped = false;
210
+ const loop = async () => {
211
+ if (stopped) return;
212
+ await this.refresh().catch(() => {});
213
+ timer = setTimeout(loop, intervalMs);
214
+ timer.unref?.();
215
+ };
216
+ void loop();
217
+ },
218
+
219
+ stop() {
220
+ stopped = true;
221
+ clearTimeout(timer);
222
+ timer = null;
223
+ },
224
+ };
225
+ }
@@ -0,0 +1,291 @@
1
+ // Browser view layer for dsh-scheduled-send.
2
+ // This file is CONCATENATED into lib/client.js by scripts/build-client.mjs
3
+ // together with src/client-core.js — it must stay import/export-free plain JS.
4
+ // Inside the bundle it references: React (platform module) and the client-core
5
+ // helpers (sortTasks/collapseState/formatLocalTime/formatCountdown/…).
6
+
7
+ /* ==== view ==== */
8
+ function createClientPluginBody(React) {
9
+ const h = React.createElement;
10
+ const TOUCH_MIN = 40; // FIX 5: touch target height on mobile
11
+
12
+ function pad2(n) { return String(n).padStart(2, "0"); }
13
+
14
+ function toDatetimeLocalValue(ms) {
15
+ const d = new Date(ms);
16
+ return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate())
17
+ + "T" + pad2(d.getHours()) + ":" + pad2(d.getMinutes());
18
+ }
19
+
20
+ function isMobile() {
21
+ return isMobileViewport(typeof window !== "undefined" ? window.matchMedia : null);
22
+ }
23
+
24
+ /* --- ⏰ composer button (conversation.input.right) -------------------- */
25
+ function ScheduleButton(props) {
26
+ const core = props.core;
27
+ const [open, setOpen] = React.useState(false);
28
+ const [timeValue, setTimeValue] = React.useState("");
29
+ const [busy, setBusy] = React.useState(false);
30
+ const [error, setError] = React.useState(null);
31
+ // standard session props: useInput(s => s.draft) reads the composer draft
32
+ const draft = typeof props.useInput === "function"
33
+ ? (props.useInput((s) => (s == null ? "" : s.draft)) ?? "")
34
+ : "";
35
+ const mobile = isMobile();
36
+
37
+ const openPopover = () => {
38
+ setTimeValue(toDatetimeLocalValue(typeof core.defaultSendAt === "function" ? core.defaultSendAt() : Date.now() + 5 * 60_000));
39
+ setError(null);
40
+ setOpen(true);
41
+ };
42
+
43
+ const submit = async () => {
44
+ if (busy) return;
45
+ const content = String(draft ?? "");
46
+ if (!content.trim()) { setError("输入框内容为空,请先输入要定时发送的内容"); return; }
47
+ const at = timeValue ? new Date(timeValue).getTime() : NaN;
48
+ if (!Number.isFinite(at) || at <= Date.now()) { setError("发送时间必须是未来时间"); return; }
49
+ setBusy(true);
50
+ setError(null);
51
+ try {
52
+ const payload = { content, sendAt: at, conversationId: props.sessionId };
53
+ await core.scheduleMessage(payload);
54
+ // spec: 内容转为定时任务并清空输入框 — 绝不立即发送(不调 submit)
55
+ if (props.inputActions && typeof props.inputActions.setDraft === "function") props.inputActions.setDraft("");
56
+ setOpen(false);
57
+ } catch (err) {
58
+ // FIX 4 failure mode: nothing was enqueued — keep the popover open
59
+ // with the form (time + draft) intact and surface the error.
60
+ setError(String(err?.message || err));
61
+ } finally {
62
+ setBusy(false);
63
+ }
64
+ };
65
+
66
+ const btnStyle = {
67
+ cursor: "pointer", flexShrink: 0, border: "1px solid " + (open ? "#3b82f6" : "transparent"),
68
+ background: open ? "rgba(59,130,246,.12)" : "transparent",
69
+ color: open ? "#3b82f6" : "inherit",
70
+ borderRadius: 999, fontSize: 12, fontWeight: 600, lineHeight: "18px", padding: "1px 8px",
71
+ display: "inline-flex", alignItems: "center", gap: 3,
72
+ opacity: open ? 1 : 0.8,
73
+ ...(mobile ? { minHeight: TOUCH_MIN } : {}),
74
+ };
75
+ const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
76
+ // FIX 5: on ≤480px the popover spans (nearly) the full viewport width.
77
+ const popCard = {
78
+ position: "absolute", bottom: "100%", right: 0, marginBottom: 6, zIndex: 30,
79
+ minWidth: 280, padding: "10px 12px", fontSize: 12,
80
+ borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
81
+ background: isDark ? "#1c1c1e" : "#fff",
82
+ boxShadow: "0 8px 24px rgba(0,0,0,.18)",
83
+ color: isDark ? "#eee" : "#111",
84
+ ...(mobile ? { left: 0, right: 0, minWidth: 0, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)", boxSizing: "border-box" } : {}),
85
+ };
86
+ const fieldLbl = { display: "block", fontSize: 11, opacity: .65, margin: "6px 0 3px" };
87
+ const fieldIn = {
88
+ width: "100%", boxSizing: "border-box",
89
+ border: "1px solid " + (isDark ? "rgba(255,255,255,.2)" : "rgba(0,0,0,.18)"),
90
+ borderRadius: 8, padding: "5px 8px", fontSize: 12,
91
+ background: isDark ? "rgba(255,255,255,.05)" : "#fff", color: "inherit",
92
+ ...(mobile ? { minHeight: TOUCH_MIN } : {}),
93
+ };
94
+ const actionBtn = (extra) => ({
95
+ cursor: "pointer", borderRadius: 999, padding: "5px 16px", fontSize: 12, ...(extra || {}),
96
+ ...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
97
+ });
98
+
99
+ return h("div", { "data-plugin": "dsh-scheduled-send-button", style: { position: "relative", display: "inline-flex", alignItems: "center", gap: 6 } }, [
100
+ open
101
+ ? h("span", { key: "badge", style: { flexShrink: 0 } },
102
+ h("span", { style: {
103
+ display: "inline-flex", alignItems: "center", borderRadius: 999,
104
+ padding: "1px 8px", fontSize: 11, fontWeight: 700,
105
+ background: "rgba(59,130,246,.16)", color: "#3b82f6",
106
+ } }, "⏳ 定时发送中"))
107
+ : null,
108
+ h("button", {
109
+ key: "alarm", type: "button", onClick: open ? () => setOpen(false) : openPopover,
110
+ title: "定时发送", "aria-label": "定时发送", style: btnStyle,
111
+ }, "⏰ 定时"),
112
+ open
113
+ ? h("div", { key: "pop", style: popCard }, [
114
+ h("div", { key: "title", style: { fontWeight: 700, fontSize: 13 } }, "定时发送"),
115
+ h("label", { key: "lt", style: fieldLbl }, "发送时间(默认当前 +5 分钟)"),
116
+ h("input", {
117
+ key: "t", type: "datetime-local", value: timeValue,
118
+ onChange: (e) => setTimeValue(e?.target?.value ?? e), style: fieldIn,
119
+ }),
120
+ h("div", { key: "act", style: { display: "flex", gap: 8, marginTop: 10, alignItems: "center", flexWrap: "wrap" } }, [
121
+ h("button", {
122
+ key: "ok", type: "button", disabled: busy, onClick: submit,
123
+ style: actionBtn({ border: "none", fontWeight: 600, color: "#fff", background: "#3b82f6", opacity: busy ? .6 : 1, cursor: busy ? "wait" : "pointer" }),
124
+ }, busy ? "提交中…" : "确认"),
125
+ h("button", {
126
+ key: "no", type: "button", onClick: () => setOpen(false),
127
+ style: actionBtn({ border: "1px solid " + (isDark ? "rgba(255,255,255,.2)" : "rgba(0,0,0,.18)"), background: "transparent", color: "inherit", cursor: "pointer" }),
128
+ }, "取消"),
129
+ error ? h("span", { key: "e", style: { color: "#dc2626", wordBreak: "break-word" } }, error) : null,
130
+ ]),
131
+ ])
132
+ : null,
133
+ ]);
134
+ }
135
+
136
+ /* --- pending-task dock (conversation.input.dock) ---------------------- */
137
+ function ScheduledDock(props) {
138
+ const core = props.core;
139
+ const [, setTick] = React.useState(0);
140
+ // FIX 2: tri-state user override — null = default policy, 'expanded',
141
+ // 'collapsed' (manual collapse-all works for ANY count).
142
+ const [user, setUser] = React.useState(null);
143
+ const mobile = isMobile();
144
+
145
+ // FIX 1: react to sessionId changes — bind the core to the NEW session
146
+ // and refresh IMMEDIATELY (no waiting for the next poll tick).
147
+ React.useEffect(() => {
148
+ let stopped = false;
149
+ let timer = null;
150
+ core.setSession(props.sessionId);
151
+ const loop = async () => {
152
+ if (stopped) return;
153
+ await core.refresh().catch(() => {});
154
+ if (!stopped) setTick((n) => n + 1);
155
+ if (!stopped) { timer = setTimeout(loop, 3000); timer.unref?.(); }
156
+ };
157
+ void loop();
158
+ return () => {
159
+ stopped = true;
160
+ clearTimeout(timer);
161
+ };
162
+ }, [props.sessionId]);
163
+ // countdown ticker
164
+ React.useEffect(() => {
165
+ const t = setInterval(() => setTick((n) => n + 1), 1000);
166
+ t.unref?.(); // never hold the host loop for a UI countdown
167
+ return () => clearInterval(t);
168
+ }, []);
169
+
170
+ const tasks = core.visibleTasks();
171
+ const err = core.lastError();
172
+ const st = collapseState(tasks, { user, mobile });
173
+ const visible = tasks.slice(0, st.visibleCount);
174
+ const showToggle = tasks.length > 0;
175
+ if (!tasks.length && !err) return null;
176
+
177
+ const rowStyle = {
178
+ display: "flex", flexDirection: "column", gap: 2, width: "100%", maxWidth: "48rem",
179
+ margin: "2px auto 0", fontSize: 12, lineHeight: "18px",
180
+ boxSizing: "border-box",
181
+ };
182
+ const entryStyle = {
183
+ display: "flex", flexDirection: "column", gap: 2, padding: "4px 8px", borderRadius: 8,
184
+ background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
185
+ maxWidth: "100%", width: "100%", boxSizing: "border-box", overflowWrap: "break-word",
186
+ };
187
+ const srcStyle = {
188
+ margin: 0, whiteSpace: "pre-wrap", fontFamily: "monospace", fontSize: 12,
189
+ wordBreak: "break-word", maxWidth: "100%",
190
+ };
191
+ const metaStyle = { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap", maxWidth: "100%" };
192
+ const cancelBtn = (id) => h("button", {
193
+ key: "x", type: "button", onClick: () => { core.cancelTask(id); },
194
+ style: {
195
+ cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999, padding: "0 8px",
196
+ fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
197
+ ...(mobile ? { minHeight: TOUCH_MIN } : {}),
198
+ },
199
+ }, "取消");
200
+
201
+ return h("div", { "data-plugin": "dsh-scheduled-send-dock", style: rowStyle }, [
202
+ err
203
+ ? h("div", {
204
+ key: "err",
205
+ style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
206
+ }, "⚠ 加载失败,显示上一次列表:" + err)
207
+ : null,
208
+ showToggle && st.summary
209
+ ? h("button", {
210
+ key: "sum", type: "button",
211
+ onClick: () => { setUser(st.display === "expanded" ? "collapsed" : "expanded"); setTick((n) => n + 1); },
212
+ style: {
213
+ cursor: "pointer", alignSelf: "center", border: "1px solid rgba(59,130,246,.35)", borderRadius: 999,
214
+ padding: "1px 10px", fontSize: 11, fontWeight: 600, background: "rgba(59,130,246,.10)", color: "inherit",
215
+ ...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
216
+ },
217
+ }, st.summary)
218
+ : null,
219
+ visible.map((t) => h("div", { key: t.id, style: entryStyle }, [
220
+ h("div", { key: "src", style: srcStyle }, t.content),
221
+ h("div", { key: "meta", style: metaStyle }, [
222
+ h("span", { key: "at" }, formatLocalTime(t.sendAt)),
223
+ h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
224
+ h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t.id)),
225
+ ]),
226
+ ])),
227
+ ]);
228
+ }
229
+
230
+ /** Client plugin body. Returns the cordis plugin ({inject, apply}). */
231
+ return function buildPlugin({ stateRoutePath, fetchImpl }) {
232
+ const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
233
+ const doFetch = fetchImpl || ((...a) => fetch(...a));
234
+
235
+ let currentSessionId = null;
236
+
237
+ const core = createScheduledClientState({
238
+ fetchState: async () => {
239
+ // ask the host for THIS conversation's view (the core's bound session
240
+ // is authoritative — the view binds it before every refresh); the
241
+ // core still filters strictly client-side against stale caches.
242
+ const sid = core.currentSession();
243
+ const q = sid ? "?conversationId=" + encodeURIComponent(sid) : "";
244
+ const res = await doFetch(stateRoutePath + q, { headers: { accept: "application/json" } });
245
+ if (!res.ok) throw new Error("state HTTP " + res.status);
246
+ return res.json();
247
+ },
248
+ postSchedule: async (payload) => {
249
+ const res = await doFetch(schedulePath, {
250
+ method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
251
+ body: JSON.stringify(payload),
252
+ });
253
+ const body = await res.json().catch(() => ({}));
254
+ if (!res.ok) throw new Error(body.error || ("HTTP " + res.status));
255
+ return body;
256
+ },
257
+ cancelSchedule: async (id) => {
258
+ const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
259
+ return res.ok;
260
+ },
261
+ });
262
+ core.defaultSendAt = () => defaultSendAt();
263
+
264
+ const inject = ["slots"];
265
+ function apply(ctx) {
266
+ ctx.inject(inject, (scope) => {
267
+ scope.slots.inject("conversation.input.right", () => scope.slots.register({
268
+ name: "conversation.input.right",
269
+ id: "dsh-scheduled-send",
270
+ order: 100,
271
+ inject: (sessionId) => {
272
+ currentSessionId = sessionId;
273
+ core.setSession(sessionId); // dock/button are conversation-scoped
274
+ return { sessionId, core };
275
+ },
276
+ }, ScheduleButton));
277
+ scope.slots.inject("conversation.input.dock", () => scope.slots.register({
278
+ name: "conversation.input.dock",
279
+ id: "dsh-scheduled-send",
280
+ order: 30,
281
+ inject: (sessionId) => {
282
+ currentSessionId = sessionId;
283
+ core.setSession(sessionId);
284
+ return { sessionId, core };
285
+ },
286
+ }, ScheduledDock));
287
+ });
288
+ }
289
+ return { core, inject, apply };
290
+ };
291
+ }
@@ -0,0 +1,101 @@
1
+ // Real injection channel for scheduled tasks. When a task comes due, we
2
+ // build a REAL user message (role 'user', fresh id, source kind 'user' with a
3
+ // `via` tag — NOT kind 'plugin', which the UI renders as an injected line
4
+ // instead of a normal user bubble) and hand it to the live agent of the task's
5
+ // ORIGINAL conversation via agent.runMaintenance(() => agent.followup(msg)).
6
+ // This mirrors the host's real prompt path
7
+ // (@deepseek-ai/dsh-api-session-controller prompt(): createUserMessage with
8
+ // kind:"user" source + followup). If the session is not live or the agent is
9
+ // busy, delivery throws so the scheduler keeps the task queued and retries.
10
+ //
11
+ // Model switching has been REMOVED. Legacy tasks may still carry a `model`
12
+ // field (written by earlier versions); it is silently ignored — the task
13
+ // delivers on the conversation's current model.
14
+
15
+ export const PLUGIN_NAME = 'dsh-scheduled-send';
16
+
17
+ /**
18
+ * Inline equivalent of @deepseek-ai/dsh-llm createUserMessage: completes the
19
+ * message with role 'user' and a fresh stable UUID id, then freezes it before
20
+ * publication (same contract as the host's createUserMessage, which is not
21
+ * resolvable from a plugin directory).
22
+ */
23
+ export function defaultCreateUserMessage(spec) {
24
+ const message = {
25
+ id: crypto.randomUUID(),
26
+ role: 'user',
27
+ content: spec.content,
28
+ source: spec.source,
29
+ };
30
+ return Object.freeze(message);
31
+ }
32
+
33
+ /**
34
+ * Track root agents published after plugin load (same install rule as
35
+ * dsh-schedule: agents live at load time are not adopted). agent.id IS the
36
+ * conversation/session id (dsh-agent: agent id must equal session id), so
37
+ * tasks bind back to their original conversation after restarts.
38
+ * @param {object} ctx host plugin context exposing ctx.on
39
+ * @returns {{agents:()=>any[], live:()=>any[], dispose:()=>void}}
40
+ */
41
+ export function installAgentTracking(ctx) {
42
+ const agents = new Set();
43
+ let stopped = false;
44
+ const offCreated = ctx?.on?.('agent/created', ({ agent }) => {
45
+ if (stopped || !agent || typeof agent.followup !== 'function') return;
46
+ agents.add(agent);
47
+ });
48
+ const offDisposed = ctx?.on?.('agent/disposed', ({ agent }) => {
49
+ agents.delete(agent);
50
+ });
51
+ return {
52
+ agents: () => [...agents],
53
+ live: () => [...agents].filter((a) => typeof a.followup === 'function'),
54
+ dispose: () => {
55
+ stopped = true; // stop accepting new ones
56
+ if (typeof offCreated === 'function') offCreated();
57
+ if (typeof offDisposed === 'function') offDisposed();
58
+ },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Build the delivery callback for the scheduler: inject one due task into the
64
+ * live session of its conversation as a follow-up user message.
65
+ * @param {object} p
66
+ * @param {{live:()=>any[]}} p.tracking agent tracking from installAgentTracking
67
+ * @param {(spec:object)=>object} [p.createUserMessage] message factory (injectable)
68
+ * @param {string} [p.pluginName]
69
+ * @returns {(item:{id:string, content:string, conversationId?:string, model?:object}) => Promise<true>}
70
+ */
71
+ export function createFollowupDelivery({
72
+ tracking,
73
+ createUserMessage = defaultCreateUserMessage,
74
+ pluginName = PLUGIN_NAME,
75
+ } = {}) {
76
+ return async function deliver(item) {
77
+ const live = tracking.live();
78
+ // bind to the task's conversation (agent.id === conversationId): a task
79
+ // bound to a conversation NEVER falls through to another live session
80
+ // (会话绑定); only legacy tasks without a conversationId may use any agent.
81
+ const agent = item.conversationId
82
+ ? live.find((a) => a.id === item.conversationId) ?? null
83
+ : live[0] ?? null;
84
+ if (!agent) {
85
+ const err = new Error('当前无活跃会话代理,任务保留在队列等待会话恢复后补发');
86
+ err.code = 'NO_LIVE_AGENT';
87
+ throw err;
88
+ }
89
+ const message = createUserMessage({
90
+ content: [{ type: 'text', text: item.content }],
91
+ source: { kind: 'user', via: pluginName },
92
+ });
93
+ // runMaintenance throws when the agent is mid-turn (busy) — the throw
94
+ // propagates so the scheduler re-queues with backoff.
95
+ await agent.runMaintenance(async () => {
96
+ agent.followup(message);
97
+ return true;
98
+ });
99
+ return true;
100
+ };
101
+ }
package/src/deps.js ADDED
@@ -0,0 +1,2 @@
1
+ // Node built-ins re-exported so modules stay testable without mocking loaders.
2
+ export { promises as fs } from 'node:fs';