@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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.2",
3
- "commit": "b1e752b7e12e9b825bce88e0633a044f282ddbd8",
4
- "builtAt": "2026-02-20T04:45:33.293Z"
2
+ "version": "0.0.4",
3
+ "commit": "a26b38261e6a55844b0eed17963361b8374e9742",
4
+ "builtAt": "2026-02-20T05:21:43.349Z"
5
5
  }
@@ -1 +1 @@
1
- c77230f007691fa7cb97f258b75674d6a4a6be4b9afdfd0bc34c2edcf7e36e7b
1
+ d27c866a9f91a1bf3b7a29bef001583ee57f61540140511ed6840c4b5dfcd5eb
@@ -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
  }
@@ -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'm not able to chat with you yet.",
3
+ "Hey! I don't have permission to chat with you yet.",
5
4
  "",
6
- "Your request has been sent to the admin. Once they accept it, I'll be ready to help you.",
5
+ "The administrator has been notified of your request. They can grant you access from the Lemon app.",
7
6
  "",
8
- "Please ask the admin to check their Lemon app to approve your access.",
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 entry = { name };
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 sender = await ctx.resolveUserName(directUserId);
116
- const senderName = sender?.name ?? undefined;
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 pairing request sender=${directUserId} name=${senderName ?? "unknown"} (${allowMatchMeta})`);
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} wants to message you on Slack.`,
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} wants to message you on Slack.`);
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}&code=${encodedCode}&name=${encodedName}"`);
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 pairing reply failed for ${message.user}: ${String(err)}`);
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 pairing reply for historical DM from ${candidate}.`);
105
+ logVerbose(`Skipping access reply for historical DM from ${candidate}.`);
106
106
  }
107
107
  else {
108
- const { code, created } = await upsertChannelPairingRequest({
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 pairing request sender=${candidate} name=${pushName}`);
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} wants to message you on WhatsApp.`,
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} wants to message you on WhatsApp.`);
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}&code=${encodedCode}&name=${encodedName}"`);
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 pairing reply failed for ${candidate}: ${String(err)}`);
146
+ logVerbose(`whatsapp access reply failed for ${candidate}: ${String(err)}`);
155
147
  }
156
148
  }
157
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heylemon/lemonade",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "AI gateway CLI for Lemon - local AI assistant with integrations",
5
5
  "publishConfig": {
6
6
  "access": "restricted"