@heylemon/lemonade 0.0.1 → 0.0.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/dist/agents/system-prompt.js +4 -0
- package/dist/build-info.json +3 -3
- package/dist/canvas-host/a2ui/.bundle.hash +1 -1
- package/dist/cli/pairing-cli.js +27 -1
- package/dist/cron/service/ops.js +14 -1
- package/dist/cron/service/state.js +2 -0
- package/dist/cron/service/store.js +41 -0
- package/dist/pairing/pairing-messages.js +3 -4
- package/dist/pairing/pairing-store.js +28 -0
- package/dist/slack/monitor/message-handler/prepare.js +10 -18
- package/dist/web/inbound/access-control.js +11 -19
- package/package.json +1 -1
|
@@ -349,6 +349,10 @@ export function buildAgentSystemPrompt(params) {
|
|
|
349
349
|
"- If files must be written, use `mktemp -d` and clean up after (`rm -rf $TMPDIR`).",
|
|
350
350
|
"- Skill-provided scripts (docx, xlsx, etc.) are fine to use as-is.",
|
|
351
351
|
"",
|
|
352
|
+
"## Cron / Reminders",
|
|
353
|
+
"ALWAYS use the `cron` tool (action: add/update/remove/list/run/status) for managing cron jobs and reminders.",
|
|
354
|
+
"NEVER use `exec` or shell commands to read/write cron files (e.g. ~/.lemonade/cron/jobs.json) directly — the gateway cron service will not detect the change.",
|
|
355
|
+
"",
|
|
352
356
|
"## Lemonade CLI Quick Reference",
|
|
353
357
|
"Lemonade is controlled via subcommands. Do not invent commands.",
|
|
354
358
|
"To manage the Gateway daemon service (start/stop/restart):",
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
5ec7ab3a52d725f743100ac38ce0d8c9672f04988280eeecd179252ba58b11dd
|
package/dist/cli/pairing-cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import { listPairingChannels, notifyPairingApproved } from "../channels/plugins/
|
|
|
2
2
|
import { normalizeChannelId } from "../channels/plugins/index.js";
|
|
3
3
|
import { loadConfig } from "../config/config.js";
|
|
4
4
|
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
|
|
5
|
-
import { approveChannelPairingCode, listChannelPairingRequests, } from "../pairing/pairing-store.js";
|
|
5
|
+
import { approveChannelPairingCode, listChannelPairingRequests, rejectChannelPairingCode, } from "../pairing/pairing-store.js";
|
|
6
6
|
import { defaultRuntime } from "../runtime.js";
|
|
7
7
|
import { formatDocsLink } from "../terminal/links.js";
|
|
8
8
|
import { renderTable } from "../terminal/table.js";
|
|
@@ -116,4 +116,30 @@ export function registerPairingCli(program) {
|
|
|
116
116
|
defaultRuntime.log(theme.warn(`Failed to notify requester: ${String(err)}`));
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
|
+
pairing
|
|
120
|
+
.command("reject")
|
|
121
|
+
.alias("decline")
|
|
122
|
+
.description("Reject a pairing request (removes it without granting access)")
|
|
123
|
+
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
|
|
124
|
+
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
|
|
125
|
+
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
|
|
126
|
+
.action(async (codeOrChannel, code, opts) => {
|
|
127
|
+
const channelRaw = opts.channel ?? codeOrChannel;
|
|
128
|
+
const resolvedCode = opts.channel ? codeOrChannel : code;
|
|
129
|
+
if (!opts.channel && !code) {
|
|
130
|
+
throw new Error(`Usage: ${formatCliCommand("lemonade pairing reject <channel> <code>")} (or: ${formatCliCommand("lemonade pairing reject --channel <channel> <code>")})`);
|
|
131
|
+
}
|
|
132
|
+
if (opts.channel && code != null) {
|
|
133
|
+
throw new Error(`Too many arguments. Use: ${formatCliCommand("lemonade pairing reject --channel <channel> <code>")}`);
|
|
134
|
+
}
|
|
135
|
+
const channel = parseChannel(channelRaw, channels);
|
|
136
|
+
const rejected = await rejectChannelPairingCode({
|
|
137
|
+
channel,
|
|
138
|
+
code: String(resolvedCode),
|
|
139
|
+
});
|
|
140
|
+
if (!rejected) {
|
|
141
|
+
throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`);
|
|
142
|
+
}
|
|
143
|
+
defaultRuntime.log(`${theme.success("Rejected")} ${theme.muted(channel)} request from ${theme.command(rejected.id)}.`);
|
|
144
|
+
});
|
|
119
145
|
}
|
package/dist/cron/service/ops.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { applyJobPatch, computeJobNextRunAtMs, createJob, findJobOrThrow, isJobDue, nextWakeAtMs, recomputeNextRuns, } from "./jobs.js";
|
|
2
2
|
import { locked } from "./locked.js";
|
|
3
|
-
import { ensureLoaded, persist, warnIfDisabled } from "./store.js";
|
|
3
|
+
import { ensureLoaded, forceReload, persist, startFileWatcher, stopFileWatcher, warnIfDisabled, } from "./store.js";
|
|
4
4
|
import { armTimer, emit, executeJob, stopTimer, wake } from "./timer.js";
|
|
5
5
|
export async function start(state) {
|
|
6
6
|
await locked(state, async () => {
|
|
@@ -12,6 +12,18 @@ export async function start(state) {
|
|
|
12
12
|
recomputeNextRuns(state);
|
|
13
13
|
await persist(state);
|
|
14
14
|
armTimer(state);
|
|
15
|
+
startFileWatcher(state, () => {
|
|
16
|
+
state.deps.log.info({}, "cron: external file change detected, reloading");
|
|
17
|
+
void locked(state, async () => {
|
|
18
|
+
await forceReload(state);
|
|
19
|
+
recomputeNextRuns(state);
|
|
20
|
+
await persist(state);
|
|
21
|
+
armTimer(state);
|
|
22
|
+
state.deps.log.info({ jobs: state.store?.jobs.length ?? 0, nextWakeAtMs: nextWakeAtMs(state) ?? null }, "cron: reloaded from disk");
|
|
23
|
+
}).catch((err) => {
|
|
24
|
+
state.deps.log.error({ err: String(err) }, "cron: reload after file change failed");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
15
27
|
state.deps.log.info({
|
|
16
28
|
enabled: true,
|
|
17
29
|
jobs: state.store?.jobs.length ?? 0,
|
|
@@ -21,6 +33,7 @@ export async function start(state) {
|
|
|
21
33
|
}
|
|
22
34
|
export function stop(state) {
|
|
23
35
|
stopTimer(state);
|
|
36
|
+
stopFileWatcher(state);
|
|
24
37
|
}
|
|
25
38
|
export async function status(state) {
|
|
26
39
|
return await locked(state, async () => {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { migrateLegacyCronPayload } from "../payload-migration.js";
|
|
2
4
|
import { loadCronStore, saveCronStore } from "../store.js";
|
|
3
5
|
import { inferLegacyName, normalizeOptionalText } from "./normalize.js";
|
|
@@ -53,5 +55,44 @@ export function warnIfDisabled(state, action) {
|
|
|
53
55
|
export async function persist(state) {
|
|
54
56
|
if (!state.store)
|
|
55
57
|
return;
|
|
58
|
+
state.lastPersistTs = Date.now();
|
|
56
59
|
await saveCronStore(state.deps.storePath, state.store);
|
|
57
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Force-reload the cron store from disk, discarding the in-memory cache.
|
|
63
|
+
* Used when the file is modified externally (e.g. by `exec`).
|
|
64
|
+
*/
|
|
65
|
+
export async function forceReload(state) {
|
|
66
|
+
storeCache.delete(state.deps.storePath);
|
|
67
|
+
state.store = null;
|
|
68
|
+
await ensureLoaded(state);
|
|
69
|
+
}
|
|
70
|
+
const EXTERNAL_CHANGE_GRACE_MS = 2000;
|
|
71
|
+
export function startFileWatcher(state, onExternalChange) {
|
|
72
|
+
stopFileWatcher(state);
|
|
73
|
+
const dir = path.dirname(state.deps.storePath);
|
|
74
|
+
const basename = path.basename(state.deps.storePath);
|
|
75
|
+
try {
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
const watcher = fs.watch(dir, (eventType, filename) => {
|
|
78
|
+
if (filename !== basename)
|
|
79
|
+
return;
|
|
80
|
+
if (Date.now() - state.lastPersistTs < EXTERNAL_CHANGE_GRACE_MS)
|
|
81
|
+
return;
|
|
82
|
+
onExternalChange();
|
|
83
|
+
});
|
|
84
|
+
watcher.on("error", () => {
|
|
85
|
+
// Non-fatal; the watcher may fail on some file systems
|
|
86
|
+
});
|
|
87
|
+
state.fileWatcher = watcher;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Watching is best-effort
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function stopFileWatcher(state) {
|
|
94
|
+
if (state.fileWatcher) {
|
|
95
|
+
state.fileWatcher.close();
|
|
96
|
+
state.fileWatcher = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
export function buildPairingReply(params) {
|
|
2
|
-
const { channel, idLine, code } = params;
|
|
3
2
|
return [
|
|
4
|
-
"Hey! I'
|
|
3
|
+
"Hey! I don't have permission to chat with you yet.",
|
|
5
4
|
"",
|
|
6
|
-
"
|
|
5
|
+
"The administrator has been notified of your request. They can grant you access from the Lemon app.",
|
|
7
6
|
"",
|
|
8
|
-
"
|
|
7
|
+
"If you know the administrator, please reach out to them directly to speed things up.",
|
|
9
8
|
].join("\n");
|
|
10
9
|
}
|
|
@@ -355,6 +355,34 @@ export async function upsertChannelPairingRequest(params) {
|
|
|
355
355
|
return { code, created: true };
|
|
356
356
|
});
|
|
357
357
|
}
|
|
358
|
+
export async function rejectChannelPairingCode(params) {
|
|
359
|
+
const env = params.env ?? process.env;
|
|
360
|
+
const code = params.code.trim().toUpperCase();
|
|
361
|
+
if (!code)
|
|
362
|
+
return null;
|
|
363
|
+
const filePath = resolvePairingPath(params.channel, env);
|
|
364
|
+
return await withFileLock(filePath, { version: 1, requests: [] }, async () => {
|
|
365
|
+
const { value } = await readJsonFile(filePath, {
|
|
366
|
+
version: 1,
|
|
367
|
+
requests: [],
|
|
368
|
+
});
|
|
369
|
+
const reqs = Array.isArray(value.requests) ? value.requests : [];
|
|
370
|
+
const nowMs = Date.now();
|
|
371
|
+
const { requests: pruned } = pruneExpiredRequests(reqs, nowMs);
|
|
372
|
+
const idx = pruned.findIndex((r) => String(r.code ?? "").toUpperCase() === code);
|
|
373
|
+
if (idx < 0)
|
|
374
|
+
return null;
|
|
375
|
+
const entry = pruned[idx];
|
|
376
|
+
if (!entry)
|
|
377
|
+
return null;
|
|
378
|
+
pruned.splice(idx, 1);
|
|
379
|
+
await writeJsonFile(filePath, {
|
|
380
|
+
version: 1,
|
|
381
|
+
requests: pruned,
|
|
382
|
+
});
|
|
383
|
+
return { id: entry.id, entry };
|
|
384
|
+
});
|
|
385
|
+
}
|
|
358
386
|
export async function approveChannelPairingCode(params) {
|
|
359
387
|
const env = params.env ?? process.env;
|
|
360
388
|
const code = params.code.trim().toUpperCase();
|
|
@@ -111,57 +111,49 @@ export async function prepareSlackMessage(params) {
|
|
|
111
111
|
isGuestSender = true;
|
|
112
112
|
logVerbose(`slack: guest sender ${directUserId} allowed (responses only, no tools)`);
|
|
113
113
|
}
|
|
114
|
-
else if (ctx.dmPolicy === "pairing") {
|
|
114
|
+
else if (ctx.dmPolicy === "pairing" || ctx.dmPolicy === "allowlist") {
|
|
115
115
|
const sender = await ctx.resolveUserName(directUserId);
|
|
116
116
|
const senderName = sender?.name ?? undefined;
|
|
117
|
-
const {
|
|
117
|
+
const { created } = await upsertChannelPairingRequest({
|
|
118
118
|
channel: "slack",
|
|
119
119
|
id: directUserId,
|
|
120
120
|
meta: { name: senderName },
|
|
121
121
|
});
|
|
122
122
|
if (created) {
|
|
123
|
-
logVerbose(`slack
|
|
123
|
+
logVerbose(`slack access request sender=${directUserId} name=${senderName ?? "unknown"} (${allowMatchMeta})`);
|
|
124
124
|
const displayName = senderName ?? directUserId;
|
|
125
|
-
// Notify frontend via SSE (reliable — works regardless of gateway context)
|
|
126
125
|
emitAgentEvent({
|
|
127
126
|
runId: `pairing-slack-${directUserId}-${Date.now()}`,
|
|
128
127
|
stream: "notification",
|
|
129
128
|
data: {
|
|
130
129
|
type: "pairing",
|
|
131
130
|
channel: "slack",
|
|
132
|
-
title: "Access Request",
|
|
133
|
-
body: `${displayName}
|
|
134
|
-
code,
|
|
131
|
+
title: "Lemonade Access Request",
|
|
132
|
+
body: `${displayName} is requesting access to your Lemon via Slack.`,
|
|
135
133
|
name: displayName,
|
|
136
134
|
senderId: directUserId,
|
|
137
135
|
},
|
|
138
136
|
});
|
|
139
|
-
// Also notify via URL scheme (fallback for when SSE isn't connected)
|
|
140
137
|
try {
|
|
141
|
-
const title = encodeURIComponent("Access Request");
|
|
142
|
-
const body = encodeURIComponent(`${displayName}
|
|
138
|
+
const title = encodeURIComponent("Lemonade Access Request");
|
|
139
|
+
const body = encodeURIComponent(`${displayName} is requesting access to your Lemon via Slack.`);
|
|
143
140
|
const channel = encodeURIComponent("slack");
|
|
144
|
-
const encodedCode = encodeURIComponent(code);
|
|
145
141
|
const encodedName = encodeURIComponent(displayName);
|
|
146
142
|
const { exec } = await import("node:child_process");
|
|
147
|
-
exec(`open "lemon://notify?title=${title}&body=${body}&type=pairing&channel=${channel}&
|
|
143
|
+
exec(`open "lemon://notify?title=${title}&body=${body}&type=pairing&channel=${channel}&name=${encodedName}"`);
|
|
148
144
|
}
|
|
149
145
|
catch {
|
|
150
146
|
// Notification is best-effort
|
|
151
147
|
}
|
|
152
148
|
try {
|
|
153
|
-
await sendMessageSlack(message.channel, buildPairingReply({
|
|
154
|
-
channel: "slack",
|
|
155
|
-
idLine: `Your Slack user id: ${directUserId}`,
|
|
156
|
-
code,
|
|
157
|
-
}), {
|
|
149
|
+
await sendMessageSlack(message.channel, buildPairingReply({ channel: "slack" }), {
|
|
158
150
|
token: ctx.botToken,
|
|
159
151
|
client: ctx.app.client,
|
|
160
152
|
accountId: account.accountId,
|
|
161
153
|
});
|
|
162
154
|
}
|
|
163
155
|
catch (err) {
|
|
164
|
-
logVerbose(`slack
|
|
156
|
+
logVerbose(`slack access reply failed for ${message.user}: ${String(err)}`);
|
|
165
157
|
}
|
|
166
158
|
}
|
|
167
159
|
return null;
|
|
@@ -100,58 +100,50 @@ export async function checkInboundAccessControl(params) {
|
|
|
100
100
|
const allowed = dmHasWildcard ||
|
|
101
101
|
(normalizedAllowFrom.length > 0 && normalizedAllowFrom.includes(candidate));
|
|
102
102
|
if (!allowed) {
|
|
103
|
-
if (dmPolicy === "pairing") {
|
|
103
|
+
if (dmPolicy === "pairing" || dmPolicy === "allowlist") {
|
|
104
104
|
if (suppressPairingReply) {
|
|
105
|
-
logVerbose(`Skipping
|
|
105
|
+
logVerbose(`Skipping access reply for historical DM from ${candidate}.`);
|
|
106
106
|
}
|
|
107
107
|
else {
|
|
108
|
-
const {
|
|
108
|
+
const { created } = await upsertChannelPairingRequest({
|
|
109
109
|
channel: "whatsapp",
|
|
110
110
|
id: candidate,
|
|
111
111
|
meta: { name: (params.pushName ?? "").trim() || undefined },
|
|
112
112
|
});
|
|
113
113
|
if (created) {
|
|
114
114
|
const pushName = (params.pushName ?? "").trim() || "unknown";
|
|
115
|
-
logVerbose(`whatsapp
|
|
115
|
+
logVerbose(`whatsapp access request sender=${candidate} name=${pushName}`);
|
|
116
116
|
const displayName = pushName !== "unknown" ? pushName : candidate;
|
|
117
|
-
// Notify frontend via SSE (reliable — works regardless of gateway context)
|
|
118
117
|
emitAgentEvent({
|
|
119
118
|
runId: `pairing-whatsapp-${candidate}-${Date.now()}`,
|
|
120
119
|
stream: "notification",
|
|
121
120
|
data: {
|
|
122
121
|
type: "pairing",
|
|
123
122
|
channel: "whatsapp",
|
|
124
|
-
title: "Access Request",
|
|
125
|
-
body: `${displayName}
|
|
126
|
-
code,
|
|
123
|
+
title: "Lemonade Access Request",
|
|
124
|
+
body: `${displayName} is requesting access to your Lemon via WhatsApp.`,
|
|
127
125
|
name: displayName,
|
|
128
126
|
senderId: candidate,
|
|
129
127
|
},
|
|
130
128
|
});
|
|
131
|
-
// Also notify via URL scheme (fallback for when SSE isn't connected)
|
|
132
129
|
try {
|
|
133
|
-
const title = encodeURIComponent("Access Request");
|
|
134
|
-
const body = encodeURIComponent(`${displayName}
|
|
130
|
+
const title = encodeURIComponent("Lemonade Access Request");
|
|
131
|
+
const body = encodeURIComponent(`${displayName} is requesting access to your Lemon via WhatsApp.`);
|
|
135
132
|
const channel = encodeURIComponent("whatsapp");
|
|
136
|
-
const encodedCode = encodeURIComponent(code);
|
|
137
133
|
const encodedName = encodeURIComponent(displayName);
|
|
138
134
|
const { exec } = await import("node:child_process");
|
|
139
|
-
exec(`open "lemon://notify?title=${title}&body=${body}&type=pairing&channel=${channel}&
|
|
135
|
+
exec(`open "lemon://notify?title=${title}&body=${body}&type=pairing&channel=${channel}&name=${encodedName}"`);
|
|
140
136
|
}
|
|
141
137
|
catch {
|
|
142
138
|
// Notification is best-effort
|
|
143
139
|
}
|
|
144
140
|
try {
|
|
145
141
|
await params.sock.sendMessage(params.remoteJid, {
|
|
146
|
-
text: buildPairingReply({
|
|
147
|
-
channel: "whatsapp",
|
|
148
|
-
idLine: `Your WhatsApp phone number: ${candidate}`,
|
|
149
|
-
code,
|
|
150
|
-
}),
|
|
142
|
+
text: buildPairingReply({ channel: "whatsapp" }),
|
|
151
143
|
});
|
|
152
144
|
}
|
|
153
145
|
catch (err) {
|
|
154
|
-
logVerbose(`whatsapp
|
|
146
|
+
logVerbose(`whatsapp access reply failed for ${candidate}: ${String(err)}`);
|
|
155
147
|
}
|
|
156
148
|
}
|
|
157
149
|
}
|