@heylemon/lemonade 0.0.9 → 0.1.1
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/sandbox/sanitize-env-vars.js +76 -0
- package/dist/agents/skills/env-overrides.js +114 -42
- package/dist/agents/skills/workspace.js +1 -0
- package/dist/agents/system-prompt.js +42 -2
- package/dist/build-info.json +3 -3
- package/dist/canvas-host/a2ui/.bundle.hash +1 -1
- package/dist/cron/service/ops.js +13 -1
- package/dist/cron/service/timer.js +95 -6
- package/dist/imessage/constants.js +2 -0
- package/dist/imessage/monitor/abort-handler.js +23 -0
- package/dist/imessage/monitor/deliver.js +7 -1
- package/dist/imessage/monitor/monitor-provider.js +65 -20
- package/dist/imessage/monitor/parse-notification.js +64 -0
- package/dist/imessage/probe.js +14 -5
- package/dist/utils/normalize-secret-input.js +19 -0
- package/dist/web/inbound/access-control.js +6 -13
- package/extensions/voice-call/src/config.ts +8 -0
- package/extensions/voice-call/src/webhook.ts +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const BLOCKED_ENV_VAR_PATTERNS = [
|
|
2
|
+
/^ANTHROPIC_API_KEY$/i,
|
|
3
|
+
/^OPENAI_API_KEY$/i,
|
|
4
|
+
/^GEMINI_API_KEY$/i,
|
|
5
|
+
/^OPENROUTER_API_KEY$/i,
|
|
6
|
+
/^MINIMAX_API_KEY$/i,
|
|
7
|
+
/^ELEVENLABS_API_KEY$/i,
|
|
8
|
+
/^SYNTHETIC_API_KEY$/i,
|
|
9
|
+
/^TELEGRAM_BOT_TOKEN$/i,
|
|
10
|
+
/^DISCORD_BOT_TOKEN$/i,
|
|
11
|
+
/^SLACK_(BOT|APP)_TOKEN$/i,
|
|
12
|
+
/^LINE_CHANNEL_SECRET$/i,
|
|
13
|
+
/^LINE_CHANNEL_ACCESS_TOKEN$/i,
|
|
14
|
+
/^LEMONADE_GATEWAY_(TOKEN|PASSWORD)$/i,
|
|
15
|
+
/^AWS_(SECRET_ACCESS_KEY|SECRET_KEY|SESSION_TOKEN)$/i,
|
|
16
|
+
/^(GH|GITHUB)_TOKEN$/i,
|
|
17
|
+
/^(AZURE|AZURE_OPENAI|COHERE|AI_GATEWAY|OPENROUTER)_API_KEY$/i,
|
|
18
|
+
/_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$/i,
|
|
19
|
+
];
|
|
20
|
+
const ALLOWED_ENV_VAR_PATTERNS = [
|
|
21
|
+
/^LANG$/,
|
|
22
|
+
/^LC_.*$/i,
|
|
23
|
+
/^PATH$/i,
|
|
24
|
+
/^HOME$/i,
|
|
25
|
+
/^USER$/i,
|
|
26
|
+
/^SHELL$/i,
|
|
27
|
+
/^TERM$/i,
|
|
28
|
+
/^TZ$/i,
|
|
29
|
+
/^NODE_ENV$/i,
|
|
30
|
+
];
|
|
31
|
+
export function validateEnvVarValue(value) {
|
|
32
|
+
if (value.includes("\0")) {
|
|
33
|
+
return "Contains null bytes";
|
|
34
|
+
}
|
|
35
|
+
if (value.length > 32768) {
|
|
36
|
+
return "Value exceeds maximum length";
|
|
37
|
+
}
|
|
38
|
+
if (/^[A-Za-z0-9+/=]{80,}$/.test(value)) {
|
|
39
|
+
return "Value looks like base64-encoded credential data";
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
function matchesAnyPattern(value, patterns) {
|
|
44
|
+
return patterns.some((pattern) => pattern.test(value));
|
|
45
|
+
}
|
|
46
|
+
export function sanitizeEnvVars(envVars, options = {}) {
|
|
47
|
+
const allowed = {};
|
|
48
|
+
const blocked = [];
|
|
49
|
+
const warnings = [];
|
|
50
|
+
const blockedPatterns = [...BLOCKED_ENV_VAR_PATTERNS, ...(options.customBlockedPatterns ?? [])];
|
|
51
|
+
const allowedPatterns = [...ALLOWED_ENV_VAR_PATTERNS, ...(options.customAllowedPatterns ?? [])];
|
|
52
|
+
for (const [rawKey, value] of Object.entries(envVars)) {
|
|
53
|
+
const key = rawKey.trim();
|
|
54
|
+
if (!key) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (matchesAnyPattern(key, blockedPatterns)) {
|
|
58
|
+
blocked.push(key);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (options.strictMode && !matchesAnyPattern(key, allowedPatterns)) {
|
|
62
|
+
blocked.push(key);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const warning = validateEnvVarValue(value);
|
|
66
|
+
if (warning) {
|
|
67
|
+
if (warning === "Contains null bytes") {
|
|
68
|
+
blocked.push(key);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
warnings.push(`${key}: ${warning}`);
|
|
72
|
+
}
|
|
73
|
+
allowed[key] = value;
|
|
74
|
+
}
|
|
75
|
+
return { allowed, blocked, warnings };
|
|
76
|
+
}
|
|
@@ -1,36 +1,123 @@
|
|
|
1
|
+
import { sanitizeEnvVars, validateEnvVarValue } from "../sandbox/sanitize-env-vars.js";
|
|
1
2
|
import { resolveSkillConfig } from "./config.js";
|
|
2
3
|
import { resolveSkillKey } from "./frontmatter.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
const HARD_BLOCKED_SKILL_ENV_PATTERNS = [
|
|
5
|
+
/^NODE_OPTIONS$/i,
|
|
6
|
+
/^OPENSSL_CONF$/i,
|
|
7
|
+
/^LD_PRELOAD$/i,
|
|
8
|
+
/^DYLD_INSERT_LIBRARIES$/i,
|
|
9
|
+
];
|
|
10
|
+
function matchesAnyPattern(value, patterns) {
|
|
11
|
+
return patterns.some((pattern) => pattern.test(value));
|
|
12
|
+
}
|
|
13
|
+
function sanitizeSkillEnvOverrides(params) {
|
|
14
|
+
if (Object.keys(params.overrides).length === 0) {
|
|
15
|
+
return { allowed: {}, blocked: [], warnings: [] };
|
|
16
|
+
}
|
|
17
|
+
const result = sanitizeEnvVars(params.overrides, {
|
|
18
|
+
customBlockedPatterns: HARD_BLOCKED_SKILL_ENV_PATTERNS,
|
|
19
|
+
});
|
|
20
|
+
const allowed = { ...result.allowed };
|
|
21
|
+
const blocked = [];
|
|
22
|
+
const warnings = [...result.warnings];
|
|
23
|
+
for (const key of result.blocked) {
|
|
24
|
+
if (matchesAnyPattern(key, HARD_BLOCKED_SKILL_ENV_PATTERNS) ||
|
|
25
|
+
!params.allowedSensitiveKeys.has(key)) {
|
|
26
|
+
blocked.push(key);
|
|
10
27
|
continue;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
28
|
+
}
|
|
29
|
+
const value = params.overrides[key];
|
|
30
|
+
if (!value) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const warning = validateEnvVarValue(value);
|
|
34
|
+
if (warning) {
|
|
35
|
+
if (warning === "Contains null bytes") {
|
|
36
|
+
blocked.push(key);
|
|
37
|
+
continue;
|
|
17
38
|
}
|
|
39
|
+
warnings.push(`${key}: ${warning}`);
|
|
18
40
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
allowed[key] = value;
|
|
42
|
+
}
|
|
43
|
+
return { allowed, blocked, warnings };
|
|
44
|
+
}
|
|
45
|
+
function applySkillConfigEnvOverrides(params) {
|
|
46
|
+
const { updates, skillConfig, primaryEnv, requiredEnv, skillKey } = params;
|
|
47
|
+
const allowedSensitiveKeys = new Set();
|
|
48
|
+
const normalizedPrimaryEnv = primaryEnv?.trim();
|
|
49
|
+
if (normalizedPrimaryEnv) {
|
|
50
|
+
allowedSensitiveKeys.add(normalizedPrimaryEnv);
|
|
51
|
+
}
|
|
52
|
+
for (const envName of requiredEnv ?? []) {
|
|
53
|
+
const trimmedEnv = envName.trim();
|
|
54
|
+
if (trimmedEnv) {
|
|
55
|
+
allowedSensitiveKeys.add(trimmedEnv);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const pendingOverrides = {};
|
|
59
|
+
if (skillConfig.env) {
|
|
60
|
+
for (const [rawKey, envValue] of Object.entries(skillConfig.env)) {
|
|
61
|
+
const envKey = rawKey.trim();
|
|
62
|
+
if (!envKey || !envValue || process.env[envKey]) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
pendingOverrides[envKey] = envValue;
|
|
23
66
|
}
|
|
24
67
|
}
|
|
68
|
+
if (normalizedPrimaryEnv && skillConfig.apiKey && !process.env[normalizedPrimaryEnv]) {
|
|
69
|
+
if (!pendingOverrides[normalizedPrimaryEnv]) {
|
|
70
|
+
pendingOverrides[normalizedPrimaryEnv] = skillConfig.apiKey;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const sanitized = sanitizeSkillEnvOverrides({
|
|
74
|
+
overrides: pendingOverrides,
|
|
75
|
+
allowedSensitiveKeys,
|
|
76
|
+
});
|
|
77
|
+
if (sanitized.blocked.length > 0) {
|
|
78
|
+
console.warn(`[Security] Blocked skill env overrides for ${skillKey}:`, sanitized.blocked.join(", "));
|
|
79
|
+
}
|
|
80
|
+
if (sanitized.warnings.length > 0) {
|
|
81
|
+
console.warn(`[Security] Suspicious skill env overrides for ${skillKey}:`, sanitized.warnings);
|
|
82
|
+
}
|
|
83
|
+
for (const [envKey, envValue] of Object.entries(sanitized.allowed)) {
|
|
84
|
+
if (process.env[envKey]) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
updates.push({ key: envKey, prev: process.env[envKey] });
|
|
88
|
+
process.env[envKey] = envValue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function createEnvReverter(updates) {
|
|
25
92
|
return () => {
|
|
26
93
|
for (const update of updates) {
|
|
27
|
-
if (update.prev === undefined)
|
|
94
|
+
if (update.prev === undefined) {
|
|
28
95
|
delete process.env[update.key];
|
|
29
|
-
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
30
98
|
process.env[update.key] = update.prev;
|
|
99
|
+
}
|
|
31
100
|
}
|
|
32
101
|
};
|
|
33
102
|
}
|
|
103
|
+
export function applySkillEnvOverrides(params) {
|
|
104
|
+
const { skills, config } = params;
|
|
105
|
+
const updates = [];
|
|
106
|
+
for (const entry of skills) {
|
|
107
|
+
const skillKey = resolveSkillKey(entry.skill, entry);
|
|
108
|
+
const skillConfig = resolveSkillConfig(config, skillKey);
|
|
109
|
+
if (!skillConfig)
|
|
110
|
+
continue;
|
|
111
|
+
applySkillConfigEnvOverrides({
|
|
112
|
+
updates,
|
|
113
|
+
skillConfig,
|
|
114
|
+
primaryEnv: entry.metadata?.primaryEnv,
|
|
115
|
+
requiredEnv: entry.metadata?.requires?.env,
|
|
116
|
+
skillKey,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return createEnvReverter(updates);
|
|
120
|
+
}
|
|
34
121
|
export function applySkillEnvOverridesFromSnapshot(params) {
|
|
35
122
|
const { snapshot, config } = params;
|
|
36
123
|
if (!snapshot)
|
|
@@ -40,28 +127,13 @@ export function applySkillEnvOverridesFromSnapshot(params) {
|
|
|
40
127
|
const skillConfig = resolveSkillConfig(config, skill.name);
|
|
41
128
|
if (!skillConfig)
|
|
42
129
|
continue;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
if (skill.primaryEnv && skillConfig.apiKey && !process.env[skill.primaryEnv]) {
|
|
52
|
-
updates.push({
|
|
53
|
-
key: skill.primaryEnv,
|
|
54
|
-
prev: process.env[skill.primaryEnv],
|
|
55
|
-
});
|
|
56
|
-
process.env[skill.primaryEnv] = skillConfig.apiKey;
|
|
57
|
-
}
|
|
130
|
+
applySkillConfigEnvOverrides({
|
|
131
|
+
updates,
|
|
132
|
+
skillConfig,
|
|
133
|
+
primaryEnv: skill.primaryEnv,
|
|
134
|
+
requiredEnv: skill.requiredEnv,
|
|
135
|
+
skillKey: skill.name,
|
|
136
|
+
});
|
|
58
137
|
}
|
|
59
|
-
return ()
|
|
60
|
-
for (const update of updates) {
|
|
61
|
-
if (update.prev === undefined)
|
|
62
|
-
delete process.env[update.key];
|
|
63
|
-
else
|
|
64
|
-
process.env[update.key] = update.prev;
|
|
65
|
-
}
|
|
66
|
-
};
|
|
138
|
+
return createEnvReverter(updates);
|
|
67
139
|
}
|
|
@@ -147,6 +147,7 @@ export function buildWorkspaceSkillSnapshot(workspaceDir, opts) {
|
|
|
147
147
|
skills: eligible.map((entry) => ({
|
|
148
148
|
name: entry.skill.name,
|
|
149
149
|
primaryEnv: entry.metadata?.primaryEnv,
|
|
150
|
+
requiredEnv: entry.metadata?.requires?.env,
|
|
150
151
|
})),
|
|
151
152
|
resolvedSkills,
|
|
152
153
|
version: opts?.snapshotVersion,
|
|
@@ -32,7 +32,7 @@ function buildMemorySection(params) {
|
|
|
32
32
|
function buildUserIdentitySection(ownerLine, isMinimal) {
|
|
33
33
|
if (!ownerLine || isMinimal)
|
|
34
34
|
return [];
|
|
35
|
-
return ["##
|
|
35
|
+
return ["## Authorized Senders", ownerLine, ""];
|
|
36
36
|
}
|
|
37
37
|
function buildTimeSection(params) {
|
|
38
38
|
if (!params.userTimezone)
|
|
@@ -231,7 +231,7 @@ export function buildAgentSystemPrompt(params) {
|
|
|
231
231
|
const extraSystemPrompt = params.extraSystemPrompt?.trim();
|
|
232
232
|
const ownerNumbers = (params.ownerNumbers ?? []).map((value) => value.trim()).filter(Boolean);
|
|
233
233
|
const ownerLine = ownerNumbers.length > 0
|
|
234
|
-
? `
|
|
234
|
+
? `Authorized senders: ${ownerNumbers.join(", ")}. These senders are allowlisted; do not assume they are the owner.`
|
|
235
235
|
: undefined;
|
|
236
236
|
const reasoningHint = params.reasoningTagHint
|
|
237
237
|
? [
|
|
@@ -317,6 +317,45 @@ export function buildAgentSystemPrompt(params) {
|
|
|
317
317
|
"Never reply with a list of approaches/options when a single tool call would suffice.",
|
|
318
318
|
"For screenshots of native macOS windows: use Peekaboo (`peekaboo image`) via exec if the skill is available.",
|
|
319
319
|
"",
|
|
320
|
+
"## URLs & Quick Open",
|
|
321
|
+
"When the user wants to *see* a page, video, or search result — open it in their browser. Don't over-automate simple requests.",
|
|
322
|
+
"",
|
|
323
|
+
"### Decision tree",
|
|
324
|
+
"1. User wants to *view* something (website, video, search results) → `open` the URL via exec.",
|
|
325
|
+
"2. User wants an *answer* from the web → use `web_search` (+ `web_fetch` if needed).",
|
|
326
|
+
"3. User wants to *interact* with a page (fill forms, click buttons, scrape data) → use the `browser` tool.",
|
|
327
|
+
"",
|
|
328
|
+
"### Opening URLs (macOS)",
|
|
329
|
+
"Use `exec` with the macOS `open` command to launch URLs in the user's default browser:",
|
|
330
|
+
'- `open "https://example.com"` — opens in default browser.',
|
|
331
|
+
'- `open -a "Google Chrome" "https://example.com"` — opens in a specific browser.',
|
|
332
|
+
'This is the fastest path for simple requests like "open YouTube" or "show me Amazon".',
|
|
333
|
+
"",
|
|
334
|
+
"### Common search/action URIs",
|
|
335
|
+
"Build the URL directly instead of navigating and typing into search boxes:",
|
|
336
|
+
"| Task | URI pattern |",
|
|
337
|
+
"|------|------------|",
|
|
338
|
+
"| Google search | `https://google.com/search?q={query}` |",
|
|
339
|
+
"| YouTube search | `https://youtube.com/results?search_query={query}` |",
|
|
340
|
+
"| Google Maps search | `https://maps.google.com/maps?q={query}` |",
|
|
341
|
+
"| Google Maps directions | `https://maps.google.com/maps/dir/{from}/{to}` |",
|
|
342
|
+
"| Amazon search | `https://amazon.com/s?k={query}` |",
|
|
343
|
+
"| Wikipedia | `https://en.wikipedia.org/wiki/{topic}` |",
|
|
344
|
+
"| Google Images | `https://google.com/search?tbm=isch&q={query}` |",
|
|
345
|
+
"| Google News | `https://news.google.com/search?q={query}` |",
|
|
346
|
+
"| Google Flights | `https://google.com/travel/flights?q={query}` |",
|
|
347
|
+
"| Reddit search | `https://reddit.com/search/?q={query}` |",
|
|
348
|
+
"| Twitter/X search | `https://x.com/search?q={query}` |",
|
|
349
|
+
"| LinkedIn search | `https://linkedin.com/search/results/all/?keywords={query}` |",
|
|
350
|
+
"URL-encode the query (`%20` for spaces or use `+`).",
|
|
351
|
+
"",
|
|
352
|
+
"### Examples",
|
|
353
|
+
'- "search YouTube for funny cats" → `open "https://youtube.com/results?search_query=funny+cats"`',
|
|
354
|
+
'- "show me flights to Tokyo" → `open "https://google.com/travel/flights?q=flights+to+tokyo"`',
|
|
355
|
+
'- "open amazon" → `open "https://amazon.com"`',
|
|
356
|
+
'- "find Italian restaurants near me" → `open "https://maps.google.com/maps?q=italian+restaurants+near+me"`',
|
|
357
|
+
'- "what is the capital of France?" → use `web_search` (user wants the answer, not a page)',
|
|
358
|
+
"",
|
|
320
359
|
"## IDE & Coding Agent Control",
|
|
321
360
|
"Control AI coding agents via CLI — never type into GUI windows:",
|
|
322
361
|
"- Terminal agents (Claude Code, Codex, Pi): run via `bash pty:true` with `background:true` for long tasks. Monitor with `process action:log`.",
|
|
@@ -509,6 +548,7 @@ export function buildAgentSystemPrompt(params) {
|
|
|
509
548
|
if (!isMinimal) {
|
|
510
549
|
lines.push("## Heartbeats", heartbeatPromptLine, "If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:", "HEARTBEAT_OK", 'Lemonade treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).', 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', "");
|
|
511
550
|
}
|
|
551
|
+
lines.push("## Safety", "You have no independent goals: do not pursue self-preservation, replication, resource acquisition, or power-seeking; avoid long-term plans beyond the user's request.", "Prioritize safety and human oversight over completion; if instructions conflict, pause and ask; comply with stop/pause/audit requests and never bypass safeguards.", "Do not manipulate or persuade anyone to expand access or disable safeguards. Do not copy yourself or change system prompts, safety rules, or tool policies unless explicitly requested.", "");
|
|
512
552
|
lines.push("## Runtime", buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), `Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`);
|
|
513
553
|
return lines.filter(Boolean).join("\n");
|
|
514
554
|
}
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
4ad41ef1417592c4619976cb95999af4828d9438481c0c3a0bb43a06440cb7b2
|
package/dist/cron/service/ops.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { applyJobPatch, computeJobNextRunAtMs, createJob, findJobOrThrow, isJobDue, nextWakeAtMs, recomputeNextRuns, } from "./jobs.js";
|
|
2
2
|
import { locked } from "./locked.js";
|
|
3
3
|
import { ensureLoaded, forceReload, persist, startFileWatcher, stopFileWatcher, warnIfDisabled, } from "./store.js";
|
|
4
|
-
import { armTimer, emit, executeJob, stopTimer, wake } from "./timer.js";
|
|
4
|
+
import { armTimer, emit, executeJob, runMissedJobs, stopTimer, wake } from "./timer.js";
|
|
5
5
|
export async function start(state) {
|
|
6
6
|
await locked(state, async () => {
|
|
7
7
|
if (!state.deps.cronEnabled) {
|
|
@@ -9,6 +9,18 @@ export async function start(state) {
|
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
11
11
|
await ensureLoaded(state);
|
|
12
|
+
// Clear stale running markers from jobs interrupted by a crash
|
|
13
|
+
const jobs = state.store?.jobs ?? [];
|
|
14
|
+
const startupInterruptedJobIds = new Set();
|
|
15
|
+
for (const job of jobs) {
|
|
16
|
+
if (typeof job.state.runningAtMs === "number") {
|
|
17
|
+
state.deps.log.warn({ jobId: job.id, runningAtMs: job.state.runningAtMs }, "cron: clearing stale running marker on startup");
|
|
18
|
+
job.state.runningAtMs = undefined;
|
|
19
|
+
startupInterruptedJobIds.add(job.id);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Run missed jobs (skip ones that were interrupted mid-execution)
|
|
23
|
+
await runMissedJobs(state, { skipJobIds: startupInterruptedJobIds });
|
|
12
24
|
recomputeNextRuns(state);
|
|
13
25
|
await persist(state);
|
|
14
26
|
armTimer(state);
|
|
@@ -2,6 +2,31 @@ import { computeJobNextRunAtMs, nextWakeAtMs, resolveJobPayloadTextForMain } fro
|
|
|
2
2
|
import { locked } from "./locked.js";
|
|
3
3
|
import { ensureLoaded, persist } from "./store.js";
|
|
4
4
|
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
5
|
+
/**
|
|
6
|
+
* Maximum wall-clock time between consecutive timer fires.
|
|
7
|
+
* Ensures the scheduler wakes at least once a minute to avoid drift.
|
|
8
|
+
*/
|
|
9
|
+
const MAX_TIMER_DELAY_MS = 60_000;
|
|
10
|
+
/**
|
|
11
|
+
* Minimum gap between consecutive fires of the same cron job.
|
|
12
|
+
* Prevents spin-loops when `computeJobNextRunAtMs` returns a value
|
|
13
|
+
* within the same second as the just-completed run.
|
|
14
|
+
*/
|
|
15
|
+
const MIN_REFIRE_GAP_MS = 2_000;
|
|
16
|
+
/**
|
|
17
|
+
* Exponential backoff delays (in ms) indexed by consecutive error count.
|
|
18
|
+
*/
|
|
19
|
+
const ERROR_BACKOFF_SCHEDULE_MS = [
|
|
20
|
+
30_000, // 1st error → 30 s
|
|
21
|
+
60_000, // 2nd error → 1 min
|
|
22
|
+
5 * 60_000, // 3rd error → 5 min
|
|
23
|
+
15 * 60_000, // 4th error → 15 min
|
|
24
|
+
60 * 60_000, // 5th+ error → 60 min
|
|
25
|
+
];
|
|
26
|
+
function errorBackoffMs(consecutiveErrors) {
|
|
27
|
+
const idx = Math.min(consecutiveErrors - 1, ERROR_BACKOFF_SCHEDULE_MS.length - 1);
|
|
28
|
+
return ERROR_BACKOFF_SCHEDULE_MS[Math.max(0, idx)];
|
|
29
|
+
}
|
|
5
30
|
export function armTimer(state) {
|
|
6
31
|
if (state.timer)
|
|
7
32
|
clearTimeout(state.timer);
|
|
@@ -12,8 +37,7 @@ export function armTimer(state) {
|
|
|
12
37
|
if (!nextAt)
|
|
13
38
|
return;
|
|
14
39
|
const delay = Math.max(nextAt - state.deps.nowMs(), 0);
|
|
15
|
-
|
|
16
|
-
const clampedDelay = Math.min(delay, MAX_TIMEOUT_MS);
|
|
40
|
+
const clampedDelay = Math.min(delay, MAX_TIMER_DELAY_MS);
|
|
17
41
|
state.timer = setTimeout(() => {
|
|
18
42
|
void onTimer(state).catch((err) => {
|
|
19
43
|
state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
|
|
@@ -22,8 +46,20 @@ export function armTimer(state) {
|
|
|
22
46
|
state.timer.unref?.();
|
|
23
47
|
}
|
|
24
48
|
export async function onTimer(state) {
|
|
25
|
-
if (state.running)
|
|
49
|
+
if (state.running) {
|
|
50
|
+
// Re-arm the timer so the scheduler keeps ticking even when a job is
|
|
51
|
+
// still executing. Without this, a long-running job causes the clamped
|
|
52
|
+
// timer to fire while `running` is true. The early return then leaves
|
|
53
|
+
// no timer set, silently killing the scheduler until the next restart.
|
|
54
|
+
if (state.timer)
|
|
55
|
+
clearTimeout(state.timer);
|
|
56
|
+
state.timer = setTimeout(() => {
|
|
57
|
+
void onTimer(state).catch((err) => {
|
|
58
|
+
state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
|
|
59
|
+
});
|
|
60
|
+
}, MAX_TIMER_DELAY_MS);
|
|
26
61
|
return;
|
|
62
|
+
}
|
|
27
63
|
state.running = true;
|
|
28
64
|
try {
|
|
29
65
|
await locked(state, async () => {
|
|
@@ -53,6 +89,31 @@ export async function runDueJobs(state) {
|
|
|
53
89
|
await executeJob(state, job, now, { forced: false });
|
|
54
90
|
}
|
|
55
91
|
}
|
|
92
|
+
export async function runMissedJobs(state, opts) {
|
|
93
|
+
if (!state.store)
|
|
94
|
+
return;
|
|
95
|
+
const now = state.deps.nowMs();
|
|
96
|
+
const skipJobIds = opts?.skipJobIds;
|
|
97
|
+
const missed = state.store.jobs.filter((j) => {
|
|
98
|
+
if (!j.enabled)
|
|
99
|
+
return false;
|
|
100
|
+
if (skipJobIds?.has(j.id))
|
|
101
|
+
return false;
|
|
102
|
+
if (typeof j.state.runningAtMs === "number")
|
|
103
|
+
return false;
|
|
104
|
+
// Skip one-shot jobs that already ran
|
|
105
|
+
if (j.schedule.kind === "at" && j.state.lastStatus)
|
|
106
|
+
return false;
|
|
107
|
+
const next = j.state.nextRunAtMs;
|
|
108
|
+
return typeof next === "number" && now >= next;
|
|
109
|
+
});
|
|
110
|
+
if (missed.length > 0) {
|
|
111
|
+
state.deps.log.info({ count: missed.length, jobIds: missed.map((j) => j.id) }, "cron: running missed jobs after restart");
|
|
112
|
+
for (const job of missed) {
|
|
113
|
+
await executeJob(state, job, now, { forced: false });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
56
117
|
export async function executeJob(state, job, nowMs, opts) {
|
|
57
118
|
const startedAt = state.deps.nowMs();
|
|
58
119
|
job.state.runningAtMs = startedAt;
|
|
@@ -66,15 +127,43 @@ export async function executeJob(state, job, nowMs, opts) {
|
|
|
66
127
|
job.state.lastStatus = status;
|
|
67
128
|
job.state.lastDurationMs = Math.max(0, endedAt - startedAt);
|
|
68
129
|
job.state.lastError = err;
|
|
130
|
+
// Track consecutive errors for backoff
|
|
131
|
+
if (status === "error") {
|
|
132
|
+
job.state.consecutiveErrors = (job.state.consecutiveErrors ?? 0) + 1;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
job.state.consecutiveErrors = 0;
|
|
136
|
+
}
|
|
69
137
|
const shouldDelete = job.schedule.kind === "at" && status === "ok" && job.deleteAfterRun === true;
|
|
70
138
|
if (!shouldDelete) {
|
|
71
|
-
if (job.schedule.kind === "at"
|
|
72
|
-
// One-shot job completed successfully; disable it.
|
|
139
|
+
if (job.schedule.kind === "at") {
|
|
73
140
|
job.enabled = false;
|
|
74
141
|
job.state.nextRunAtMs = undefined;
|
|
75
142
|
}
|
|
143
|
+
else if (status === "error" && job.enabled) {
|
|
144
|
+
// Apply exponential backoff for errored jobs to prevent retry storms
|
|
145
|
+
const backoff = errorBackoffMs(job.state.consecutiveErrors ?? 1);
|
|
146
|
+
const normalNext = computeJobNextRunAtMs(job, endedAt);
|
|
147
|
+
const backoffNext = endedAt + backoff;
|
|
148
|
+
job.state.nextRunAtMs =
|
|
149
|
+
normalNext !== undefined ? Math.max(normalNext, backoffNext) : backoffNext;
|
|
150
|
+
state.deps.log.info({
|
|
151
|
+
jobId: job.id,
|
|
152
|
+
consecutiveErrors: job.state.consecutiveErrors,
|
|
153
|
+
backoffMs: backoff,
|
|
154
|
+
nextRunAtMs: job.state.nextRunAtMs,
|
|
155
|
+
}, "cron: applying error backoff");
|
|
156
|
+
}
|
|
76
157
|
else if (job.enabled) {
|
|
77
|
-
|
|
158
|
+
const naturalNext = computeJobNextRunAtMs(job, endedAt);
|
|
159
|
+
if (job.schedule.kind === "cron") {
|
|
160
|
+
const minNext = endedAt + MIN_REFIRE_GAP_MS;
|
|
161
|
+
job.state.nextRunAtMs =
|
|
162
|
+
naturalNext !== undefined ? Math.max(naturalNext, minNext) : minNext;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
job.state.nextRunAtMs = naturalNext;
|
|
166
|
+
}
|
|
78
167
|
}
|
|
79
168
|
else {
|
|
80
169
|
job.state.nextRunAtMs = undefined;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function attachIMessageMonitorAbortHandler(params) {
|
|
2
|
+
const abort = params.abortSignal;
|
|
3
|
+
if (!abort) {
|
|
4
|
+
return () => { };
|
|
5
|
+
}
|
|
6
|
+
const onAbort = () => {
|
|
7
|
+
const subscriptionId = params.getSubscriptionId();
|
|
8
|
+
if (subscriptionId) {
|
|
9
|
+
void params.client
|
|
10
|
+
.request("watch.unsubscribe", {
|
|
11
|
+
subscription: subscriptionId,
|
|
12
|
+
})
|
|
13
|
+
.catch(() => {
|
|
14
|
+
// Ignore disconnect errors during shutdown.
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
void params.client.stop().catch(() => {
|
|
18
|
+
// Ignore disconnect errors during shutdown.
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
abort.addEventListener("abort", onAbort, { once: true });
|
|
22
|
+
return () => abort.removeEventListener("abort", onAbort);
|
|
23
|
+
}
|
|
@@ -4,7 +4,8 @@ import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
|
|
4
4
|
import { convertMarkdownTables } from "../../markdown/tables.js";
|
|
5
5
|
import { sendMessageIMessage } from "../send.js";
|
|
6
6
|
export async function deliverReplies(params) {
|
|
7
|
-
const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params;
|
|
7
|
+
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params;
|
|
8
|
+
const scope = `${accountId ?? ""}:${target}`;
|
|
8
9
|
const cfg = loadConfig();
|
|
9
10
|
const tableMode = resolveMarkdownTableMode({
|
|
10
11
|
cfg,
|
|
@@ -19,12 +20,14 @@ export async function deliverReplies(params) {
|
|
|
19
20
|
if (!text && mediaList.length === 0)
|
|
20
21
|
continue;
|
|
21
22
|
if (mediaList.length === 0) {
|
|
23
|
+
sentMessageCache?.remember(scope, text);
|
|
22
24
|
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
|
|
23
25
|
await sendMessageIMessage(target, chunk, {
|
|
24
26
|
maxBytes,
|
|
25
27
|
client,
|
|
26
28
|
accountId,
|
|
27
29
|
});
|
|
30
|
+
sentMessageCache?.remember(scope, chunk);
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
else {
|
|
@@ -38,6 +41,9 @@ export async function deliverReplies(params) {
|
|
|
38
41
|
client,
|
|
39
42
|
accountId,
|
|
40
43
|
});
|
|
44
|
+
if (caption) {
|
|
45
|
+
sentMessageCache?.remember(scope, caption);
|
|
46
|
+
}
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
runtime.log?.(`imessage: delivered reply to ${target}`);
|
|
@@ -25,10 +25,13 @@ import { truncateUtf16Safe } from "../../utils.js";
|
|
|
25
25
|
import { resolveControlCommandGate } from "../../channels/command-gating.js";
|
|
26
26
|
import { resolveIMessageAccount } from "../accounts.js";
|
|
27
27
|
import { createIMessageRpcClient } from "../client.js";
|
|
28
|
+
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
|
|
28
29
|
import { probeIMessage } from "../probe.js";
|
|
29
30
|
import { sendMessageIMessage } from "../send.js";
|
|
30
31
|
import { formatIMessageChatTarget, isAllowedIMessageSender, normalizeIMessageHandle, } from "../targets.js";
|
|
32
|
+
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
|
|
31
33
|
import { deliverReplies } from "./deliver.js";
|
|
34
|
+
import { parseIMessageNotification } from "./parse-notification.js";
|
|
32
35
|
import { normalizeAllowList, resolveRuntime } from "./runtime.js";
|
|
33
36
|
/**
|
|
34
37
|
* Try to detect remote host from an SSH wrapper script like:
|
|
@@ -55,6 +58,47 @@ async function detectRemoteHostFromCliPath(cliPath) {
|
|
|
55
58
|
return undefined;
|
|
56
59
|
}
|
|
57
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Cache for recently sent messages, used for echo detection.
|
|
63
|
+
* Keys are scoped by conversation (accountId:target) so the same text in different chats is not conflated.
|
|
64
|
+
* Entries expire after 5 seconds; we do not forget on match so multiple echo deliveries are all filtered.
|
|
65
|
+
*/
|
|
66
|
+
class SentMessageCache {
|
|
67
|
+
cache = new Map();
|
|
68
|
+
ttlMs = 5000;
|
|
69
|
+
remember(scope, text) {
|
|
70
|
+
if (!text?.trim()) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const key = `${scope}:${text.trim()}`;
|
|
74
|
+
this.cache.set(key, Date.now());
|
|
75
|
+
this.cleanup();
|
|
76
|
+
}
|
|
77
|
+
has(scope, text) {
|
|
78
|
+
if (!text?.trim()) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const key = `${scope}:${text.trim()}`;
|
|
82
|
+
const timestamp = this.cache.get(key);
|
|
83
|
+
if (!timestamp) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const age = Date.now() - timestamp;
|
|
87
|
+
if (age > this.ttlMs) {
|
|
88
|
+
this.cache.delete(key);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
cleanup() {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
for (const [text, timestamp] of this.cache.entries()) {
|
|
96
|
+
if (now - timestamp > this.ttlMs) {
|
|
97
|
+
this.cache.delete(text);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
58
102
|
function normalizeReplyField(value) {
|
|
59
103
|
if (typeof value === "string") {
|
|
60
104
|
const trimmed = value.trim();
|
|
@@ -84,6 +128,7 @@ export async function monitorIMessageProvider(opts = {}) {
|
|
|
84
128
|
cfg.messages?.groupChat?.historyLimit ??
|
|
85
129
|
DEFAULT_GROUP_HISTORY_LIMIT);
|
|
86
130
|
const groupHistories = new Map();
|
|
131
|
+
const sentMessageCache = new SentMessageCache();
|
|
87
132
|
const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
|
|
88
133
|
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
|
|
89
134
|
const groupAllowFrom = normalizeAllowList(opts.groupAllowFrom ??
|
|
@@ -96,6 +141,7 @@ export async function monitorIMessageProvider(opts = {}) {
|
|
|
96
141
|
const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
|
|
97
142
|
const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg";
|
|
98
143
|
const dbPath = opts.dbPath ?? imessageCfg.dbPath;
|
|
144
|
+
const probeTimeoutMs = imessageCfg.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
|
|
99
145
|
// Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script
|
|
100
146
|
let remoteHost = imessageCfg.remoteHost;
|
|
101
147
|
if (!remoteHost && cliPath && cliPath !== "imsg") {
|
|
@@ -155,6 +201,13 @@ export async function monitorIMessageProvider(opts = {}) {
|
|
|
155
201
|
const senderNormalized = normalizeIMessageHandle(sender);
|
|
156
202
|
if (message.is_from_me)
|
|
157
203
|
return;
|
|
204
|
+
// Echo detection: skip messages that match recently sent replies
|
|
205
|
+
const echoScope = `${accountInfo.accountId ?? ""}:${sender}`;
|
|
206
|
+
const messageText0 = (message.text ?? "").trim();
|
|
207
|
+
if (messageText0 && sentMessageCache.has(echoScope, messageText0)) {
|
|
208
|
+
logVerbose(`imessage: dropping echo from ${sender}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
158
211
|
const chatId = message.chat_id ?? undefined;
|
|
159
212
|
const chatGuid = message.chat_guid ?? undefined;
|
|
160
213
|
const chatIdentifier = message.chat_identifier ?? undefined;
|
|
@@ -482,6 +535,7 @@ export async function monitorIMessageProvider(opts = {}) {
|
|
|
482
535
|
runtime,
|
|
483
536
|
maxBytes: mediaMaxBytes,
|
|
484
537
|
textLimit,
|
|
538
|
+
sentMessageCache,
|
|
485
539
|
});
|
|
486
540
|
},
|
|
487
541
|
onError: (err, info) => {
|
|
@@ -514,10 +568,11 @@ export async function monitorIMessageProvider(opts = {}) {
|
|
|
514
568
|
}
|
|
515
569
|
}
|
|
516
570
|
const handleMessage = async (raw) => {
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
571
|
+
const message = parseIMessageNotification(raw);
|
|
572
|
+
if (!message) {
|
|
573
|
+
logVerbose("imessage: dropping malformed RPC message payload");
|
|
520
574
|
return;
|
|
575
|
+
}
|
|
521
576
|
await inboundDebouncer.enqueue({ message });
|
|
522
577
|
};
|
|
523
578
|
await waitForTransportReady({
|
|
@@ -529,7 +584,7 @@ export async function monitorIMessageProvider(opts = {}) {
|
|
|
529
584
|
abortSignal: opts.abortSignal,
|
|
530
585
|
runtime,
|
|
531
586
|
check: async () => {
|
|
532
|
-
const probe = await probeIMessage(
|
|
587
|
+
const probe = await probeIMessage(probeTimeoutMs, { cliPath, dbPath, runtime });
|
|
533
588
|
if (probe.ok)
|
|
534
589
|
return { ok: true };
|
|
535
590
|
if (probe.fatal) {
|
|
@@ -557,21 +612,11 @@ export async function monitorIMessageProvider(opts = {}) {
|
|
|
557
612
|
});
|
|
558
613
|
let subscriptionId = null;
|
|
559
614
|
const abort = opts.abortSignal;
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
})
|
|
566
|
-
.catch(() => {
|
|
567
|
-
// Ignore disconnect errors during shutdown.
|
|
568
|
-
});
|
|
569
|
-
}
|
|
570
|
-
void client.stop().catch(() => {
|
|
571
|
-
// Ignore disconnect errors during shutdown.
|
|
572
|
-
});
|
|
573
|
-
};
|
|
574
|
-
abort?.addEventListener("abort", onAbort, { once: true });
|
|
615
|
+
const detachAbortHandler = attachIMessageMonitorAbortHandler({
|
|
616
|
+
abortSignal: abort,
|
|
617
|
+
client,
|
|
618
|
+
getSubscriptionId: () => subscriptionId,
|
|
619
|
+
});
|
|
575
620
|
try {
|
|
576
621
|
const result = await client.request("watch.subscribe", {
|
|
577
622
|
attachments: includeAttachments,
|
|
@@ -586,7 +631,7 @@ export async function monitorIMessageProvider(opts = {}) {
|
|
|
586
631
|
throw err;
|
|
587
632
|
}
|
|
588
633
|
finally {
|
|
589
|
-
|
|
634
|
+
detachAbortHandler();
|
|
590
635
|
await client.stop();
|
|
591
636
|
}
|
|
592
637
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function isOptionalString(value) {
|
|
5
|
+
return value === undefined || value === null || typeof value === "string";
|
|
6
|
+
}
|
|
7
|
+
function isOptionalStringOrNumber(value) {
|
|
8
|
+
return (value === undefined || value === null || typeof value === "string" || typeof value === "number");
|
|
9
|
+
}
|
|
10
|
+
function isOptionalNumber(value) {
|
|
11
|
+
return value === undefined || value === null || typeof value === "number";
|
|
12
|
+
}
|
|
13
|
+
function isOptionalBoolean(value) {
|
|
14
|
+
return value === undefined || value === null || typeof value === "boolean";
|
|
15
|
+
}
|
|
16
|
+
function isOptionalStringArray(value) {
|
|
17
|
+
return (value === undefined ||
|
|
18
|
+
value === null ||
|
|
19
|
+
(Array.isArray(value) && value.every((entry) => typeof entry === "string")));
|
|
20
|
+
}
|
|
21
|
+
function isOptionalAttachments(value) {
|
|
22
|
+
if (value === undefined || value === null) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (!Array.isArray(value)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return value.every((attachment) => {
|
|
29
|
+
if (!isRecord(attachment)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return (isOptionalString(attachment.original_path) &&
|
|
33
|
+
isOptionalString(attachment.mime_type) &&
|
|
34
|
+
isOptionalBoolean(attachment.missing));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function parseIMessageNotification(raw) {
|
|
38
|
+
if (!isRecord(raw)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const maybeMessage = raw.message;
|
|
42
|
+
if (!isRecord(maybeMessage)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const message = maybeMessage;
|
|
46
|
+
if (!isOptionalNumber(message.id) ||
|
|
47
|
+
!isOptionalNumber(message.chat_id) ||
|
|
48
|
+
!isOptionalString(message.sender) ||
|
|
49
|
+
!isOptionalBoolean(message.is_from_me) ||
|
|
50
|
+
!isOptionalString(message.text) ||
|
|
51
|
+
!isOptionalStringOrNumber(message.reply_to_id) ||
|
|
52
|
+
!isOptionalString(message.reply_to_text) ||
|
|
53
|
+
!isOptionalString(message.reply_to_sender) ||
|
|
54
|
+
!isOptionalString(message.created_at) ||
|
|
55
|
+
!isOptionalAttachments(message.attachments) ||
|
|
56
|
+
!isOptionalString(message.chat_identifier) ||
|
|
57
|
+
!isOptionalString(message.chat_guid) ||
|
|
58
|
+
!isOptionalString(message.chat_name) ||
|
|
59
|
+
!isOptionalStringArray(message.participants) ||
|
|
60
|
+
!isOptionalBoolean(message.is_group)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return message;
|
|
64
|
+
}
|
package/dist/imessage/probe.js
CHANGED
|
@@ -2,13 +2,16 @@ import { detectBinary } from "../commands/onboard-helpers.js";
|
|
|
2
2
|
import { loadConfig } from "../config/config.js";
|
|
3
3
|
import { runCommandWithTimeout } from "../process/exec.js";
|
|
4
4
|
import { createIMessageRpcClient } from "./client.js";
|
|
5
|
+
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
|
|
6
|
+
// Re-export for backwards compatibility
|
|
7
|
+
export { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
|
|
5
8
|
const rpcSupportCache = new Map();
|
|
6
|
-
async function probeRpcSupport(cliPath) {
|
|
9
|
+
async function probeRpcSupport(cliPath, timeoutMs) {
|
|
7
10
|
const cached = rpcSupportCache.get(cliPath);
|
|
8
11
|
if (cached)
|
|
9
12
|
return cached;
|
|
10
13
|
try {
|
|
11
|
-
const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs
|
|
14
|
+
const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs });
|
|
12
15
|
const combined = `${result.stdout}\n${result.stderr}`.trim();
|
|
13
16
|
const normalized = combined.toLowerCase();
|
|
14
17
|
if (normalized.includes("unknown command") && normalized.includes("rpc")) {
|
|
@@ -34,7 +37,12 @@ async function probeRpcSupport(cliPath) {
|
|
|
34
37
|
return { supported: false, error: String(err) };
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Probe iMessage RPC availability.
|
|
42
|
+
* @param timeoutMs - Explicit timeout in ms. If undefined, uses config or default.
|
|
43
|
+
* @param opts - Additional options (cliPath, dbPath, runtime).
|
|
44
|
+
*/
|
|
45
|
+
export async function probeIMessage(timeoutMs, opts = {}) {
|
|
38
46
|
const cfg = opts.cliPath || opts.dbPath ? undefined : loadConfig();
|
|
39
47
|
const cliPath = opts.cliPath?.trim() || cfg?.channels?.imessage?.cliPath?.trim() || "imsg";
|
|
40
48
|
const dbPath = opts.dbPath?.trim() || cfg?.channels?.imessage?.dbPath?.trim();
|
|
@@ -42,7 +50,8 @@ export async function probeIMessage(timeoutMs = 2000, opts = {}) {
|
|
|
42
50
|
if (!detected) {
|
|
43
51
|
return { ok: false, error: `imsg not found (${cliPath})` };
|
|
44
52
|
}
|
|
45
|
-
const
|
|
53
|
+
const effectiveTimeout = timeoutMs ?? cfg?.channels?.imessage?.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
|
|
54
|
+
const rpcSupport = await probeRpcSupport(cliPath, effectiveTimeout);
|
|
46
55
|
if (!rpcSupport.supported) {
|
|
47
56
|
return {
|
|
48
57
|
ok: false,
|
|
@@ -56,7 +65,7 @@ export async function probeIMessage(timeoutMs = 2000, opts = {}) {
|
|
|
56
65
|
runtime: opts.runtime,
|
|
57
66
|
});
|
|
58
67
|
try {
|
|
59
|
-
await client.request("chats.list", { limit: 1 }, { timeoutMs });
|
|
68
|
+
await client.request("chats.list", { limit: 1 }, { timeoutMs: effectiveTimeout });
|
|
60
69
|
return { ok: true };
|
|
61
70
|
}
|
|
62
71
|
catch (err) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret normalization for copy/pasted credentials.
|
|
3
|
+
*
|
|
4
|
+
* Common footgun: line breaks (especially `\r`) embedded in API keys/tokens.
|
|
5
|
+
* We strip line breaks anywhere, then trim whitespace at the ends.
|
|
6
|
+
*
|
|
7
|
+
* Intentionally does NOT remove ordinary spaces inside the string to avoid
|
|
8
|
+
* silently altering "Bearer <token>" style values.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeSecretInput(value) {
|
|
11
|
+
if (typeof value !== "string") {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
return value.replace(/[\r\n\u2028\u2029]+/g, "").trim();
|
|
15
|
+
}
|
|
16
|
+
export function normalizeOptionalSecretInput(value) {
|
|
17
|
+
const normalized = normalizeSecretInput(value);
|
|
18
|
+
return normalized ? normalized : undefined;
|
|
19
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { loadConfig } from "../../config/config.js";
|
|
2
2
|
import { logVerbose } from "../../globals.js";
|
|
3
3
|
import { emitAgentEvent } from "../../infra/agent-events.js";
|
|
4
|
-
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
|
5
4
|
import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../../pairing/pairing-store.js";
|
|
6
5
|
import { isSelfChatMode, normalizeE164 } from "../../utils.js";
|
|
7
6
|
import { resolveWhatsAppAccount } from "../accounts.js";
|
|
@@ -120,15 +119,15 @@ export async function checkInboundAccessControl(params) {
|
|
|
120
119
|
data: {
|
|
121
120
|
type: "pairing",
|
|
122
121
|
channel: "whatsapp",
|
|
123
|
-
title: "
|
|
124
|
-
body: `${displayName}
|
|
122
|
+
title: "WhatsApp Message",
|
|
123
|
+
body: `${displayName} sent a message. Add them in Settings to let Lemon respond.`,
|
|
125
124
|
name: displayName,
|
|
126
125
|
senderId: candidate,
|
|
127
126
|
},
|
|
128
127
|
});
|
|
129
128
|
try {
|
|
130
|
-
const title = encodeURIComponent("
|
|
131
|
-
const body = encodeURIComponent(`${displayName}
|
|
129
|
+
const title = encodeURIComponent("WhatsApp Message");
|
|
130
|
+
const body = encodeURIComponent(`${displayName} sent a message. Add them in Settings to let Lemon respond.`);
|
|
132
131
|
const channel = encodeURIComponent("whatsapp");
|
|
133
132
|
const encodedName = encodeURIComponent(displayName);
|
|
134
133
|
const { exec } = await import("node:child_process");
|
|
@@ -137,14 +136,8 @@ export async function checkInboundAccessControl(params) {
|
|
|
137
136
|
catch {
|
|
138
137
|
// Notification is best-effort
|
|
139
138
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
text: buildPairingReply({ channel: "whatsapp" }),
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
catch (err) {
|
|
146
|
-
logVerbose(`whatsapp access reply failed for ${candidate}: ${String(err)}`);
|
|
147
|
-
}
|
|
139
|
+
// Do NOT reply to the sender on WhatsApp -- this is a personal
|
|
140
|
+
// phone number and the sender may have no idea about Lemonade.
|
|
148
141
|
}
|
|
149
142
|
}
|
|
150
143
|
}
|
|
@@ -329,6 +329,14 @@ export const VoiceCallConfigSchema = z
|
|
|
329
329
|
/** Maximum call duration in seconds */
|
|
330
330
|
maxDurationSeconds: z.number().int().positive().default(300),
|
|
331
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Maximum age of a call in seconds before it is automatically reaped.
|
|
334
|
+
* Catches calls stuck in unexpected states (e.g., notify-mode calls that
|
|
335
|
+
* never receive a terminal webhook). Set to 0 to disable.
|
|
336
|
+
* Default: 0 (disabled). Recommended: 120-300 for production.
|
|
337
|
+
*/
|
|
338
|
+
staleCallReaperSeconds: z.number().int().nonnegative().default(0),
|
|
339
|
+
|
|
332
340
|
/** Silence timeout for end-of-speech detection (ms) */
|
|
333
341
|
silenceTimeoutMs: z.number().int().positive().default(800),
|
|
334
342
|
|
|
@@ -22,6 +22,7 @@ export class VoiceCallWebhookServer {
|
|
|
22
22
|
private manager: CallManager;
|
|
23
23
|
private provider: VoiceCallProvider;
|
|
24
24
|
private coreConfig: CoreConfig | null;
|
|
25
|
+
private staleCallReaperInterval: ReturnType<typeof setInterval> | null = null;
|
|
25
26
|
|
|
26
27
|
/** Media stream handler for bidirectional audio (when streaming enabled) */
|
|
27
28
|
private mediaStreamHandler: MediaStreamHandler | null = null;
|
|
@@ -198,14 +199,50 @@ export class VoiceCallWebhookServer {
|
|
|
198
199
|
);
|
|
199
200
|
}
|
|
200
201
|
resolve(url);
|
|
202
|
+
|
|
203
|
+
this.startStaleCallReaper();
|
|
201
204
|
});
|
|
202
205
|
});
|
|
203
206
|
}
|
|
204
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Start a periodic reaper that ends calls older than the configured threshold.
|
|
210
|
+
* Catches calls stuck in unexpected states (e.g., notify-mode calls that never
|
|
211
|
+
* receive a terminal webhook from the provider).
|
|
212
|
+
*/
|
|
213
|
+
private startStaleCallReaper(): void {
|
|
214
|
+
const maxAgeSeconds = this.config.staleCallReaperSeconds;
|
|
215
|
+
if (!maxAgeSeconds || maxAgeSeconds <= 0) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const CHECK_INTERVAL_MS = 30_000;
|
|
220
|
+
const maxAgeMs = maxAgeSeconds * 1000;
|
|
221
|
+
|
|
222
|
+
this.staleCallReaperInterval = setInterval(() => {
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
for (const call of this.manager.getActiveCalls()) {
|
|
225
|
+
const age = now - call.startedAt;
|
|
226
|
+
if (age > maxAgeMs) {
|
|
227
|
+
console.log(
|
|
228
|
+
`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
|
|
229
|
+
);
|
|
230
|
+
void this.manager.endCall(call.callId).catch((err) => {
|
|
231
|
+
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}, CHECK_INTERVAL_MS);
|
|
236
|
+
}
|
|
237
|
+
|
|
205
238
|
/**
|
|
206
239
|
* Stop the webhook server.
|
|
207
240
|
*/
|
|
208
241
|
async stop(): Promise<void> {
|
|
242
|
+
if (this.staleCallReaperInterval) {
|
|
243
|
+
clearInterval(this.staleCallReaperInterval);
|
|
244
|
+
this.staleCallReaperInterval = null;
|
|
245
|
+
}
|
|
209
246
|
return new Promise((resolve) => {
|
|
210
247
|
if (this.server) {
|
|
211
248
|
this.server.close(() => {
|