@heylemon/lemonade 0.0.2 → 0.0.4
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/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 +1 -1
- package/dist/pairing/pairing-messages.js +3 -4
- package/dist/pairing/pairing-store.js +28 -0
- package/dist/slack/monitor/allow-list.js +2 -0
- package/dist/slack/monitor/context.js +2 -1
- package/dist/slack/monitor/message-handler/prepare.js +16 -21
- package/dist/web/inbound/access-control.js +11 -19
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
d27c866a9f91a1bf3b7a29bef001583ee57f61540140511ed6840c4b5dfcd5eb
|
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, forceReload, persist, startFileWatcher, stopFileWatcher, 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 () => {
|
|
@@ -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();
|
|
@@ -21,6 +21,7 @@ export function resolveSlackAllowListMatch(params) {
|
|
|
21
21
|
}
|
|
22
22
|
const id = params.id?.toLowerCase();
|
|
23
23
|
const name = params.name?.toLowerCase();
|
|
24
|
+
const email = params.email?.toLowerCase();
|
|
24
25
|
const slug = normalizeSlackSlug(name);
|
|
25
26
|
const candidates = [
|
|
26
27
|
{ value: id, source: "id" },
|
|
@@ -29,6 +30,7 @@ export function resolveSlackAllowListMatch(params) {
|
|
|
29
30
|
{ value: name, source: "name" },
|
|
30
31
|
{ value: name ? `slack:${name}` : undefined, source: "prefixed-name" },
|
|
31
32
|
{ value: slug, source: "slug" },
|
|
33
|
+
{ value: email, source: "email" },
|
|
32
34
|
];
|
|
33
35
|
for (const candidate of candidates) {
|
|
34
36
|
if (!candidate.value)
|
|
@@ -98,7 +98,8 @@ export function createSlackMonitorContext(params) {
|
|
|
98
98
|
});
|
|
99
99
|
const profile = info.user?.profile;
|
|
100
100
|
const name = profile?.display_name || profile?.real_name || info.user?.name || undefined;
|
|
101
|
-
const
|
|
101
|
+
const email = profile?.email || undefined;
|
|
102
|
+
const entry = { name, email };
|
|
102
103
|
userCache.set(userId, entry);
|
|
103
104
|
return entry;
|
|
104
105
|
}
|
|
@@ -96,72 +96,66 @@ export async function prepareSlackMessage(params) {
|
|
|
96
96
|
return null;
|
|
97
97
|
}
|
|
98
98
|
if (ctx.dmPolicy !== "open") {
|
|
99
|
+
const dmSender = await ctx.resolveUserName(directUserId);
|
|
100
|
+
const dmSenderEmail = dmSender?.email ?? undefined;
|
|
99
101
|
const allowMatch = resolveSlackAllowListMatch({
|
|
100
102
|
allowList: allowFromLower,
|
|
101
103
|
id: directUserId,
|
|
104
|
+
email: dmSenderEmail,
|
|
102
105
|
});
|
|
103
106
|
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
|
104
107
|
if (!allowMatch.allowed) {
|
|
105
|
-
// Check if sender is an approved guest (responses only, no tools)
|
|
106
108
|
const guestMatch = resolveSlackAllowListMatch({
|
|
107
109
|
allowList: guestFromLower,
|
|
108
110
|
id: directUserId,
|
|
111
|
+
email: dmSenderEmail,
|
|
109
112
|
});
|
|
110
113
|
if (guestMatch.allowed) {
|
|
111
114
|
isGuestSender = true;
|
|
112
115
|
logVerbose(`slack: guest sender ${directUserId} allowed (responses only, no tools)`);
|
|
113
116
|
}
|
|
114
|
-
else if (ctx.dmPolicy === "pairing") {
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const { code, created } = await upsertChannelPairingRequest({
|
|
117
|
+
else if (ctx.dmPolicy === "pairing" || ctx.dmPolicy === "allowlist") {
|
|
118
|
+
const senderName = dmSender?.name ?? undefined;
|
|
119
|
+
const { created } = await upsertChannelPairingRequest({
|
|
118
120
|
channel: "slack",
|
|
119
121
|
id: directUserId,
|
|
120
122
|
meta: { name: senderName },
|
|
121
123
|
});
|
|
122
124
|
if (created) {
|
|
123
|
-
logVerbose(`slack
|
|
125
|
+
logVerbose(`slack access request sender=${directUserId} name=${senderName ?? "unknown"} (${allowMatchMeta})`);
|
|
124
126
|
const displayName = senderName ?? directUserId;
|
|
125
|
-
// Notify frontend via SSE (reliable — works regardless of gateway context)
|
|
126
127
|
emitAgentEvent({
|
|
127
128
|
runId: `pairing-slack-${directUserId}-${Date.now()}`,
|
|
128
129
|
stream: "notification",
|
|
129
130
|
data: {
|
|
130
131
|
type: "pairing",
|
|
131
132
|
channel: "slack",
|
|
132
|
-
title: "Access Request",
|
|
133
|
-
body: `${displayName}
|
|
134
|
-
code,
|
|
133
|
+
title: "Lemonade Access Request",
|
|
134
|
+
body: `${displayName} is requesting access to your Lemon via Slack.`,
|
|
135
135
|
name: displayName,
|
|
136
136
|
senderId: directUserId,
|
|
137
137
|
},
|
|
138
138
|
});
|
|
139
|
-
// Also notify via URL scheme (fallback for when SSE isn't connected)
|
|
140
139
|
try {
|
|
141
|
-
const title = encodeURIComponent("Access Request");
|
|
142
|
-
const body = encodeURIComponent(`${displayName}
|
|
140
|
+
const title = encodeURIComponent("Lemonade Access Request");
|
|
141
|
+
const body = encodeURIComponent(`${displayName} is requesting access to your Lemon via Slack.`);
|
|
143
142
|
const channel = encodeURIComponent("slack");
|
|
144
|
-
const encodedCode = encodeURIComponent(code);
|
|
145
143
|
const encodedName = encodeURIComponent(displayName);
|
|
146
144
|
const { exec } = await import("node:child_process");
|
|
147
|
-
exec(`open "lemon://notify?title=${title}&body=${body}&type=pairing&channel=${channel}&
|
|
145
|
+
exec(`open "lemon://notify?title=${title}&body=${body}&type=pairing&channel=${channel}&name=${encodedName}"`);
|
|
148
146
|
}
|
|
149
147
|
catch {
|
|
150
148
|
// Notification is best-effort
|
|
151
149
|
}
|
|
152
150
|
try {
|
|
153
|
-
await sendMessageSlack(message.channel, buildPairingReply({
|
|
154
|
-
channel: "slack",
|
|
155
|
-
idLine: `Your Slack user id: ${directUserId}`,
|
|
156
|
-
code,
|
|
157
|
-
}), {
|
|
151
|
+
await sendMessageSlack(message.channel, buildPairingReply({ channel: "slack" }), {
|
|
158
152
|
token: ctx.botToken,
|
|
159
153
|
client: ctx.app.client,
|
|
160
154
|
accountId: account.accountId,
|
|
161
155
|
});
|
|
162
156
|
}
|
|
163
157
|
catch (err) {
|
|
164
|
-
logVerbose(`slack
|
|
158
|
+
logVerbose(`slack access reply failed for ${message.user}: ${String(err)}`);
|
|
165
159
|
}
|
|
166
160
|
}
|
|
167
161
|
return null;
|
|
@@ -234,6 +228,7 @@ export async function prepareSlackMessage(params) {
|
|
|
234
228
|
allowList: allowFromLower,
|
|
235
229
|
id: senderId,
|
|
236
230
|
name: senderName,
|
|
231
|
+
email: sender?.email,
|
|
237
232
|
}).allowed;
|
|
238
233
|
const channelUsersAllowlistConfigured = isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
|
|
239
234
|
const channelCommandAuthorized = isRoom && channelUsersAllowlistConfigured
|
|
@@ -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
|
}
|