@tuzelko/opencode-delayed-message 0.1.1 → 0.1.3
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/README.md +21 -5
- package/gui-experimental.cjs +3 -4
- package/index.ts +47 -18
- package/package.json +1 -1
- package/rpc.ts +45 -1
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ the text into the conversation early.
|
|
|
28
28
|
- 💾 **Durable** — jobs survive a service restart; timers re-arm from storage.
|
|
29
29
|
- 🪟 **Desktop pill** — pending count and time above the composer, with a preview and a
|
|
30
30
|
cancel button.
|
|
31
|
+
- ⏱️ **On time in the desktop app** — the app's main process keeps the clock, so an idle
|
|
32
|
+
window or an evicted location does not delay delivery.
|
|
31
33
|
- 🤫 **Zero model turns on schedule** — a successful schedule does not wake the model.
|
|
32
34
|
- 🌍 **Localized UI** — English, Russian, German, French, Chinese.
|
|
33
35
|
|
|
@@ -83,9 +85,13 @@ Above the composer, while a message is pending:
|
|
|
83
85
|
⏰ Scheduled · 10:18
|
|
84
86
|
```
|
|
85
87
|
|
|
86
|
-
Click it for the list: time, relative countdown, a preview of the text, and
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
Click it for the list: time, relative countdown, a preview of the text, and `✎` to edit
|
|
89
|
+
or `×` to cancel. Edit cancels the pending job and puts the whole command back into the
|
|
90
|
+
composer — `/delayed-message`, the scheduled time, and the full text — with the cursor in
|
|
91
|
+
the prompt. Adjust anything and submit to reschedule. The pill disappears when the
|
|
92
|
+
message is delivered or cancelled.
|
|
93
|
+
|
|
94
|
+
The terminal UI has no pill — there the transcript note is the only trace.
|
|
89
95
|
|
|
90
96
|
The pill follows the app language: English, Russian, German, French, and Chinese are
|
|
91
97
|
included. Transcript notes are English, because they are written for the model.
|
|
@@ -96,6 +102,13 @@ Jobs are kept in the plugin's storage, not only in memory. After `opencode reloa
|
|
|
96
102
|
service restart, timers are re-armed when the plugin loads for that directory. If the
|
|
97
103
|
service is down at the scheduled moment, the message is delivered on the next start.
|
|
98
104
|
|
|
105
|
+
OpenCode unloads a directory's services after 60 minutes without activity (the
|
|
106
|
+
`experimental.location_idle_timeout` setting), and plugin timers die with them. In the
|
|
107
|
+
desktop app that no longer delays a message: the app's main process keeps its own timer,
|
|
108
|
+
persists it across app restarts, and asks the server to deliver at the scheduled moment,
|
|
109
|
+
which wakes the directory if it was unloaded. Without the desktop app, delivery still
|
|
110
|
+
waits for the next time that directory loads.
|
|
111
|
+
|
|
99
112
|
## 🧠 How it works
|
|
100
113
|
|
|
101
114
|
- `ctx.command.transform` registers `/delayed-message`; the executor gets the raw text
|
|
@@ -104,8 +117,11 @@ service is down at the scheduled moment, the message is delivered on the next st
|
|
|
104
117
|
24.8-day timer cap.
|
|
105
118
|
- On fire: `ctx.session.prompt({ sessionID, text })` — a real user prompt, so the session
|
|
106
119
|
wakes.
|
|
107
|
-
- The GUI talks to the server through an RPC: `pending`, `cancel`,
|
|
108
|
-
`scheduled`, `fired`, `cancelled`.
|
|
120
|
+
- The GUI talks to the server through an RPC: `pending`, `cancel`, `edit`, `deliver`, and
|
|
121
|
+
the events `scheduled`, `fired`, `cancelled`.
|
|
122
|
+
- The GUI main process mirrors `scheduled` jobs into its own storage and arms Node timers.
|
|
123
|
+
When one is due it calls `deliver` with the job's directory, which boots the location
|
|
124
|
+
and delivers the message even though the server-side timer is gone.
|
|
109
125
|
|
|
110
126
|
## 🛠 Development
|
|
111
127
|
|
package/gui-experimental.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("solid-js"),n=require("@opencode/plugin/gui");var
|
|
1
|
+
Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("solid-js"),n=require("effect"),r=require("@opencode/plugin/gui");var i=require("@opencode/plugin/rpc").Rpc.define({id:`delayed-message`,methods:{pending:{input:{type:`object`,properties:{sessionID:{type:`string`}},required:[`sessionID`],additionalProperties:!1},output:{type:`object`,properties:{jobs:{type:`array`,items:{type:`object`,properties:{id:{type:`string`},fireAt:{type:`number`},preview:{type:`string`}},required:[`id`,`fireAt`,`preview`],additionalProperties:!1}}},required:[`jobs`],additionalProperties:!1}},cancel:{input:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1},output:{type:`object`,properties:{ok:{type:`boolean`}},required:[`ok`],additionalProperties:!1}},deliver:{input:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1},output:{type:`object`,properties:{status:{type:`string`}},required:[`status`],additionalProperties:!1}},edit:{input:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1},output:{type:`object`,properties:{ok:{type:`boolean`},text:{type:`string`},fireAt:{type:`number`}},required:[`ok`],additionalProperties:!1}}},events:{scheduled:{schema:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`},fireAt:{type:`number`},preview:{type:`string`},directory:{type:`string`}},required:[`sessionID`,`id`,`fireAt`,`preview`,`directory`],additionalProperties:!1}},fired:{schema:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1}},cancelled:{schema:{type:`object`,properties:{sessionID:{type:`string`},id:{type:`string`}},required:[`sessionID`,`id`],additionalProperties:!1}}}}),a=(0,e.template)(`<div class=dm>`),o=(0,e.template)(`<div class=dm-row><div class=dm-body><div class=dm-when><span></span><span class=dm-left></span></div><div class=dm-text></div></div><button class=dm-action>✎</button><button class=dm-action>×`),s=2**31-1,c=e=>String(e).padStart(2,`0`),l=e=>{let t=new Date(e),n=`${t.getFullYear()}-${c(t.getMonth()+1)}-${c(t.getDate())}`,r=`${c(t.getHours())}:${c(t.getMinutes())}`;return t.getSeconds()===0?`${n}T${r}`:`${n}T${r}:${c(t.getSeconds())}`},u=`
|
|
2
2
|
.dm { display: flex; flex-direction: column; min-width: 280px; max-width: 440px; }
|
|
3
3
|
.dm-row { display: flex; align-items: center; gap: 8px; padding: 8px 2px; }
|
|
4
4
|
.dm-row + .dm-row { border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
|
|
@@ -6,6 +6,5 @@ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{val
|
|
|
6
6
|
.dm-when { display: flex; align-items: baseline; gap: 8px; font-variant-numeric: tabular-nums; }
|
|
7
7
|
.dm-left { opacity: .55; font-size: 12px; white-space: nowrap; }
|
|
8
8
|
.dm-text { opacity: .72; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
9
|
-
.dm-
|
|
10
|
-
.dm-
|
|
11
|
-
`,s=n.Plugin.define({i18n:{en:{"pill.one":`Scheduled · {{time}}`,"pill.many":`Scheduled: {{count}}`,"menu.title":`Scheduled messages`,"row.cancel":`Cancel`,"left.now":`now`,"left.days":`in {{days}}d {{hours}}h`,"left.hours":`in {{hours}}h {{minutes}}m`,"left.minutes":`in {{minutes}}m`},ru:{"pill.one":`Отложенное · {{time}}`,"pill.many":`Отложенных: {{count}}`,"menu.title":`Отложенные сообщения`,"row.cancel":`Отменить`,"left.now":`сейчас`,"left.days":`через {{days}} д {{hours}} ч`,"left.hours":`через {{hours}} ч {{minutes}} мин`,"left.minutes":`через {{minutes}} мин`},de:{"pill.one":`Geplant · {{time}}`,"pill.many":`Geplant: {{count}}`,"menu.title":`Geplante Nachrichten`,"row.cancel":`Abbrechen`,"left.now":`jetzt`,"left.days":`in {{days}} T {{hours}} Std.`,"left.hours":`in {{hours}} Std. {{minutes}} Min.`,"left.minutes":`in {{minutes}} Min.`},fr:{"pill.one":`Programmé · {{time}}`,"pill.many":`Programmés : {{count}}`,"menu.title":`Messages programmés`,"row.cancel":`Annuler`,"left.now":`maintenant`,"left.days":`dans {{days}} j {{hours}} h`,"left.hours":`dans {{hours}} h {{minutes}} min`,"left.minutes":`dans {{minutes}} min`},zh:{"pill.one":`已安排 · {{time}}`,"pill.many":`已安排:{{count}}`,"menu.title":`已安排的消息`,"row.cancel":`取消`,"left.now":`现在`,"left.days":`还有 {{days}} 天 {{hours}} 小时`,"left.hours":`还有 {{hours}} 小时 {{minutes}} 分钟`,"left.minutes":`还有 {{minutes}} 分钟`}},setup(s){let[c,l]=(0,t.createSignal)({}),u=(e,t)=>l(n=>({...n,[e]:[...(n[e]??[]).filter(e=>e.id!==t.id),t].sort((e,t)=>e.fireAt-t.fireAt)})),d=(e,t)=>l(n=>({...n,[e]:(n[e]??[]).filter(e=>e.id!==t)})),f=e=>new Date(e).toLocaleTimeString(s.locale.locale(),{hour:`2-digit`,minute:`2-digit`}),p=e=>new Date(e).toLocaleDateString(s.locale.locale(),{day:`2-digit`,month:`2-digit`}),m=e=>{let t=new Date(e),n=new Date,r=f(e);return t.toDateString()===n.toDateString()?r:`${p(e)} ${r}`},h=e=>{let t=e-Date.now();if(t<=0)return s.t(`left.now`);let n=Math.ceil(t/6e4),r=Math.floor(n/60),i=Math.floor(r/24);return i>0?s.t(`left.days`,{days:i,hours:r%24}):r>0?s.t(`left.hours`,{hours:r,minutes:n%60}):s.t(`left.minutes`,{minutes:n})},g=s.client.rpc(r);g.events.on(`scheduled`,e=>{u(e.data.sessionID,{id:e.data.id,fireAt:e.data.fireAt,preview:e.data.preview})}),g.events.on(`fired`,e=>{d(e.data.sessionID,e.data.id)}),g.events.on(`cancelled`,e=>{d(e.data.sessionID,e.data.id)});let _=async(e,t)=>{try{let{ok:n}=await g.cancel({sessionID:e,id:t});n&&d(e,t)}catch{}};(0,n.createKeyed)(()=>s.sessions.current()?.id,e=>{g.pending({sessionID:e}).then(({jobs:t})=>l(n=>({...n,[e]:t}))).catch(()=>{})}),s.add(n.Style,o),s.add(n.ComposerPill,{id:`pending`,show(e){let t=c()[e.session.id]??[];if(t.length!==0)return{label:t.length===1?s.t(`pill.one`,{time:f(t[0].fireAt)}):s.t(`pill.many`,{count:t.length}),icon:`notifications`,title:s.t(`menu.title`)}},menu:n=>(()=>{var r=i();return(0,e.insert)(r,(0,e.createComponent)(t.For,{get each(){return c()[n.session.id]??[]},children:t=>(()=>{var r=a(),i=r.firstChild,o=i.firstChild,c=o.firstChild,l=c.nextSibling,u=o.nextSibling,d=i.nextSibling;return(0,e.insert)(c,()=>m(t.fireAt)),(0,e.insert)(l,()=>h(t.fireAt)),(0,e.insert)(u,()=>t.preview),d.$$click=()=>void _(n.session.id,t.id),(0,e.effect)(t=>{var n=s.t(`row.cancel`),r=s.t(`row.cancel`);return n!==t.e&&(0,e.setAttribute)(d,`title`,t.e=n),r!==t.t&&(0,e.setAttribute)(d,`aria-label`,t.t=r),t},{e:void 0,t:void 0}),r})()})),r})()})}});(0,e.delegateEvents)([`click`]),exports.default=s;
|
|
9
|
+
.dm-action { flex: none; width: 22px; height: 22px; border: 0; border-radius: 6px; background: transparent; color: inherit; opacity: .45; cursor: pointer; font: inherit; line-height: 1; }
|
|
10
|
+
.dm-action:hover { opacity: 1; background: color-mix(in srgb, currentColor 14%, transparent); }`,d=r.Plugin.define({i18n:{en:{"pill.one":`Scheduled · {{time}}`,"pill.many":`Scheduled: {{count}}`,"menu.title":`Scheduled messages`,"row.cancel":`Cancel`,"row.edit":`Edit`,"left.now":`now`,"left.days":`in {{days}}d {{hours}}h`,"left.hours":`in {{hours}}h {{minutes}}m`,"left.minutes":`in {{minutes}}m`},ru:{"pill.one":`Отложенное · {{time}}`,"pill.many":`Отложенных: {{count}}`,"menu.title":`Отложенные сообщения`,"row.cancel":`Отменить`,"row.edit":`Изменить`,"left.now":`сейчас`,"left.days":`через {{days}} д {{hours}} ч`,"left.hours":`через {{hours}} ч {{minutes}} мин`,"left.minutes":`через {{minutes}} мин`},de:{"pill.one":`Geplant · {{time}}`,"pill.many":`Geplant: {{count}}`,"menu.title":`Geplante Nachrichten`,"row.cancel":`Abbrechen`,"row.edit":`Bearbeiten`,"left.now":`jetzt`,"left.days":`in {{days}} T {{hours}} Std.`,"left.hours":`in {{hours}} Std. {{minutes}} Min.`,"left.minutes":`in {{minutes}} Min.`},fr:{"pill.one":`Programmé · {{time}}`,"pill.many":`Programmés : {{count}}`,"menu.title":`Messages programmés`,"row.cancel":`Annuler`,"row.edit":`Modifier`,"left.now":`maintenant`,"left.days":`dans {{days}} j {{hours}} h`,"left.hours":`dans {{hours}} h {{minutes}} min`,"left.minutes":`dans {{minutes}} min`},zh:{"pill.one":`已安排 · {{time}}`,"pill.many":`已安排:{{count}}`,"menu.title":`已安排的消息`,"row.cancel":`取消`,"row.edit":`编辑`,"left.now":`现在`,"left.days":`还有 {{days}} 天 {{hours}} 小时`,"left.hours":`还有 {{hours}} 小时 {{minutes}} 分钟`,"left.minutes":`还有 {{minutes}} 分钟`}},setup(n){let[s,c]=(0,t.createSignal)({}),d=(e,t)=>c(n=>({...n,[e]:[...(n[e]??[]).filter(e=>e.id!==t.id),t].sort((e,t)=>e.fireAt-t.fireAt)})),f=(e,t)=>c(n=>({...n,[e]:(n[e]??[]).filter(e=>e.id!==t)})),p=e=>new Date(e).toLocaleTimeString(n.locale.locale(),{hour:`2-digit`,minute:`2-digit`}),m=e=>new Date(e).toLocaleDateString(n.locale.locale(),{day:`2-digit`,month:`2-digit`}),h=e=>{let t=new Date(e),n=new Date,r=p(e);return t.toDateString()===n.toDateString()?r:`${m(e)} ${r}`},g=e=>{let t=e-Date.now();if(t<=0)return n.t(`left.now`);let r=Math.ceil(t/6e4),i=Math.floor(r/60),a=Math.floor(i/24);return a>0?n.t(`left.days`,{days:a,hours:i%24}):i>0?n.t(`left.hours`,{hours:i,minutes:r%60}):n.t(`left.minutes`,{minutes:r})},_=n.client.rpc(i);_.events.on(`scheduled`,e=>{d(e.data.sessionID,{id:e.data.id,fireAt:e.data.fireAt,preview:e.data.preview})}),_.events.on(`fired`,e=>{f(e.data.sessionID,e.data.id)}),_.events.on(`cancelled`,e=>{f(e.data.sessionID,e.data.id)});let v=async(e,t)=>{try{let{ok:n}=await _.cancel({sessionID:e,id:t});n&&f(e,t)}catch{}};(0,r.createKeyed)(()=>n.sessions.current()?.id,e=>{_.pending({sessionID:e}).then(({jobs:t})=>c(n=>({...n,[e]:t}))).catch(()=>{})}),n.add(r.Style,u),n.add(r.ComposerPill,{id:`pending`,show(e){let t=s()[e.session.id]??[];if(t.length!==0)return{label:t.length===1?n.t(`pill.one`,{time:p(t[0].fireAt)}):n.t(`pill.many`,{count:t.length}),icon:`notifications`,title:n.t(`menu.title`)}},menu:r=>(()=>{var i=a();return(0,e.insert)(i,(0,e.createComponent)(t.For,{get each(){return s()[r.session.id]??[]},children:t=>(()=>{var i=o(),a=i.firstChild,s=a.firstChild,c=s.firstChild,u=c.nextSibling,d=s.nextSibling,p=a.nextSibling,m=p.nextSibling;return(0,e.insert)(c,()=>h(t.fireAt)),(0,e.insert)(u,()=>g(t.fireAt)),(0,e.insert)(d,()=>t.preview),p.$$click=()=>{_.edit({sessionID:r.session.id,id:t.id}).then(({ok:e,text:n,fireAt:i})=>{e&&n!==void 0&&i!==void 0&&(f(r.session.id,t.id),r.screen.composer.setText(`/delayed-message ${l(i)} ${n}`),r.close(),r.screen.composer.focus())}).catch(()=>{})},m.$$click=()=>void v(r.session.id,t.id),(0,e.effect)(t=>{var r=n.t(`row.edit`),i=n.t(`row.edit`),a=n.t(`row.cancel`),o=n.t(`row.cancel`);return r!==t.e&&(0,e.setAttribute)(p,`title`,t.e=r),i!==t.t&&(0,e.setAttribute)(p,`aria-label`,t.t=i),a!==t.a&&(0,e.setAttribute)(m,`title`,t.a=a),o!==t.o&&(0,e.setAttribute)(m,`aria-label`,t.o=o),t},{e:void 0,t:void 0,a:void 0,o:void 0}),i})()})),i})()})},main(e){let t=n.Schema.Struct({id:n.Schema.String,sessionID:n.Schema.String,directory:n.Schema.String,fireAt:n.Schema.Number}),r=e.storage.store(`pending`,{schema:n.Schema.Array(t),initial:[]}),a=new Map,o=new Map,c=e=>{let t=a.get(e);t&&clearTimeout(t),a.delete(e)},l=e=>{c(e),o.delete(e),r.value.some(t=>t.id===e)&&r.set(r.value.filter(t=>t.id!==e))},u=e=>{c(e.id);let t=e.fireAt-Date.now();t>s-1?a.set(e.id,setTimeout(()=>u(e),s-1)):a.set(e.id,setTimeout(()=>void d(e),Math.max(0,t)))},d=async t=>{if(c(t.id),Date.now()<t.fireAt)u(t);else try{let{status:n}=await e.client.rpc(i).deliver({sessionID:t.sessionID,id:t.id},{location:{directory:t.directory}});if(n===`delivered`||n===`missing`){o.delete(t.id),l(t.id);return}if(n===`failed`){let e=(o.get(t.id)??0)+1;if(e>=5){o.delete(t.id),l(t.id);return}o.set(t.id,e)}a.set(t.id,setTimeout(()=>void d(t),n===`busy`?2e3:3e4))}catch{a.set(t.id,setTimeout(()=>void d(t),15e3))}},f=e=>{r.set([...r.value.filter(t=>t.id!==e.id),e]),u(e)},p=e=>{if(e.type===`rpc.delayed-message.scheduled`){let t=e.data;if(!t?.id||!t.sessionID||!t.fireAt||!t.directory)return;f({id:t.id,sessionID:t.sessionID,directory:t.directory,fireAt:t.fireAt})}else if(e.type===`rpc.delayed-message.fired`||e.type===`rpc.delayed-message.cancelled`){let t=e.data;t?.id&&l(t.id)}};for(let e of r.value)u(e);(async()=>{for(;!e.signal.aborted;){try{for await(let t of e.client.event.subscribe({signal:e.signal}))p(t)}catch{}if(e.signal.aborted)break;await new Promise(e=>setTimeout(e,3e3))}})(),e.onCleanup(()=>{for(let e of a.values())clearTimeout(e);a.clear()})}});(0,e.delegateEvents)([`click`]),exports.default=d;
|
package/index.ts
CHANGED
|
@@ -20,6 +20,10 @@ const UNIT_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 8
|
|
|
20
20
|
|
|
21
21
|
// Setup can run more than once per directory; only the first timer may deliver.
|
|
22
22
|
const claimed = new Set<string>()
|
|
23
|
+
// Guards concurrent deliveries of one job while the prompt is in flight.
|
|
24
|
+
const delivering = new Set<string>()
|
|
25
|
+
|
|
26
|
+
type Delivery = "delivered" | "busy" | "failed"
|
|
23
27
|
|
|
24
28
|
const key = (id: string) => `pending/${id}`
|
|
25
29
|
|
|
@@ -83,34 +87,58 @@ export default Plugin.define({
|
|
|
83
87
|
},
|
|
84
88
|
cancel: async (input) => {
|
|
85
89
|
const { sessionID, id } = input as { sessionID: string; id: string }
|
|
86
|
-
|
|
90
|
+
return { ok: (await withdraw(sessionID, id)) !== undefined }
|
|
91
|
+
},
|
|
92
|
+
edit: async (input) => {
|
|
93
|
+
const { sessionID, id } = input as { sessionID: string; id: string }
|
|
94
|
+
const job = await withdraw(sessionID, id)
|
|
95
|
+
if (!job) return { ok: false }
|
|
96
|
+
return { ok: true, text: job.message, fireAt: job.fireAt }
|
|
97
|
+
},
|
|
98
|
+
deliver: async (input) => {
|
|
99
|
+
const { sessionID, id } = input as { sessionID: string; id: string }
|
|
100
|
+
if (claimed.has(id)) return { status: "delivered" }
|
|
101
|
+
if (delivering.has(id)) return { status: "busy" }
|
|
87
102
|
const stored = (await ctx.storage.get(key(id))) as Job | undefined
|
|
88
|
-
if (!stored || stored.sessionID !== sessionID) return {
|
|
89
|
-
|
|
90
|
-
const timer = timers.get(id)
|
|
91
|
-
if (timer) {
|
|
92
|
-
clearTimeout(timer)
|
|
93
|
-
timers.delete(id)
|
|
94
|
-
}
|
|
95
|
-
await ctx.storage.remove(key(id))
|
|
96
|
-
await rpc.events.emit("cancelled", { sessionID, id }).catch(() => {})
|
|
97
|
-
return { ok: true }
|
|
103
|
+
if (!stored || stored.sessionID !== sessionID) return { status: "missing" }
|
|
104
|
+
return { status: await deliver(stored) }
|
|
98
105
|
},
|
|
99
106
|
})
|
|
100
107
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
const withdraw = async (sessionID: string, id: string): Promise<Job | undefined> => {
|
|
109
|
+
if (claimed.has(id) || delivering.has(id)) return undefined
|
|
110
|
+
const stored = (await ctx.storage.get(key(id))) as Job | undefined
|
|
111
|
+
if (!stored || stored.sessionID !== sessionID) return undefined
|
|
112
|
+
claimed.add(id)
|
|
113
|
+
const timer = timers.get(id)
|
|
114
|
+
if (timer) {
|
|
115
|
+
clearTimeout(timer)
|
|
116
|
+
timers.delete(id)
|
|
117
|
+
}
|
|
118
|
+
await ctx.storage.remove(key(id))
|
|
119
|
+
await rpc.events.emit("cancelled", { sessionID, id }).catch(() => {})
|
|
120
|
+
return stored
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const deliver = async (job: Job): Promise<Delivery> => {
|
|
124
|
+
if (claimed.has(job.id)) return "delivered"
|
|
125
|
+
if (delivering.has(job.id)) return "busy"
|
|
126
|
+
delivering.add(job.id)
|
|
105
127
|
try {
|
|
106
|
-
if (!(await ctx.storage.get(key(job.id)))) return
|
|
107
|
-
await ctx.storage.remove(key(job.id))
|
|
108
128
|
await ctx.session.prompt({ sessionID: job.sessionID, text: job.message })
|
|
109
129
|
} catch (error) {
|
|
110
130
|
console.error(`[delayed-message] delivery failed for ${job.id}:`, error)
|
|
131
|
+
return "failed"
|
|
111
132
|
} finally {
|
|
112
|
-
|
|
133
|
+
delivering.delete(job.id)
|
|
113
134
|
}
|
|
135
|
+
// Claim only after the prompt landed: a failure keeps the job in storage
|
|
136
|
+
// for the next boot or for the desktop main process to retry.
|
|
137
|
+
claimed.add(job.id)
|
|
138
|
+
timers.delete(job.id)
|
|
139
|
+
await ctx.storage.remove(key(job.id)).catch(() => {})
|
|
140
|
+
await rpc.events.emit("fired", { sessionID: job.sessionID, id: job.id }).catch(() => {})
|
|
141
|
+
return "delivered"
|
|
114
142
|
}
|
|
115
143
|
|
|
116
144
|
// setTimeout caps at MAX_TIMER, so long waits are split into hops.
|
|
@@ -185,6 +213,7 @@ export default Plugin.define({
|
|
|
185
213
|
id: job.id,
|
|
186
214
|
fireAt: at,
|
|
187
215
|
preview: previewOf(message),
|
|
216
|
+
directory: job.directory,
|
|
188
217
|
}).catch(() => {})
|
|
189
218
|
await note(sessionID, `Delayed message scheduled for ${stamp(at)} (in ${humanize(at - now)}).`)
|
|
190
219
|
} catch (error) {
|
package/package.json
CHANGED
package/rpc.ts
CHANGED
|
@@ -50,6 +50,49 @@ export const Delayed = Rpc.define({
|
|
|
50
50
|
additionalProperties: false,
|
|
51
51
|
},
|
|
52
52
|
},
|
|
53
|
+
// The desktop app's main process owns a timer outside the location, so it
|
|
54
|
+
// can ask for delivery after the location that scheduled the job was
|
|
55
|
+
// evicted. Status is "delivered" | "missing" | "busy" | "failed".
|
|
56
|
+
deliver: {
|
|
57
|
+
input: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
sessionID: { type: "string" },
|
|
61
|
+
id: { type: "string" },
|
|
62
|
+
},
|
|
63
|
+
required: ["sessionID", "id"],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
output: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: { status: { type: "string" } },
|
|
69
|
+
required: ["status"],
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
// Cancels the job and hands its full text back so the pill can put the
|
|
74
|
+
// command into the composer for editing.
|
|
75
|
+
edit: {
|
|
76
|
+
input: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {
|
|
79
|
+
sessionID: { type: "string" },
|
|
80
|
+
id: { type: "string" },
|
|
81
|
+
},
|
|
82
|
+
required: ["sessionID", "id"],
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
},
|
|
85
|
+
output: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {
|
|
88
|
+
ok: { type: "boolean" },
|
|
89
|
+
text: { type: "string" },
|
|
90
|
+
fireAt: { type: "number" },
|
|
91
|
+
},
|
|
92
|
+
required: ["ok"],
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
53
96
|
},
|
|
54
97
|
events: {
|
|
55
98
|
scheduled: {
|
|
@@ -60,8 +103,9 @@ export const Delayed = Rpc.define({
|
|
|
60
103
|
id: { type: "string" },
|
|
61
104
|
fireAt: { type: "number" },
|
|
62
105
|
preview: { type: "string" },
|
|
106
|
+
directory: { type: "string" },
|
|
63
107
|
},
|
|
64
|
-
required: ["sessionID", "id", "fireAt", "preview"],
|
|
108
|
+
required: ["sessionID", "id", "fireAt", "preview", "directory"],
|
|
65
109
|
additionalProperties: false,
|
|
66
110
|
},
|
|
67
111
|
},
|