mercury-agent 0.4.6 → 0.4.7
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/package.json +1 -1
- package/src/adapters/whatsapp.ts +5 -2
- package/src/bridges/discord.ts +19 -12
- package/src/bridges/slack.ts +2 -0
- package/src/bridges/teams.ts +4 -2
- package/src/bridges/telegram.ts +9 -1
- package/src/cli/mercury.ts +16 -11
- package/src/core/permissions.ts +4 -0
- package/src/core/router.ts +1 -1
- package/src/core/routes/chat.ts +7 -4
- package/src/core/routes/messages.ts +1 -1
- package/src/core/routes/mutes.ts +7 -1
- package/src/core/routes/roles.ts +10 -0
- package/src/core/task-scheduler.ts +11 -4
- package/src/extensions/hooks.ts +5 -0
- package/src/extensions/loader.ts +1 -1
- package/src/storage/db.ts +1 -0
package/package.json
CHANGED
package/src/adapters/whatsapp.ts
CHANGED
|
@@ -147,7 +147,7 @@ export class WhatsAppBaileysAdapter
|
|
|
147
147
|
private readonly outgoingQueue: Array<{ jid: string; text: string }> = [];
|
|
148
148
|
private flushing = false;
|
|
149
149
|
private connectedAtMs = 0;
|
|
150
|
-
private
|
|
150
|
+
private seenMessageIds = new Set<string>();
|
|
151
151
|
private reconnectAttempt = 0;
|
|
152
152
|
private readonly pushNames = new Map<string, string>();
|
|
153
153
|
private currentQr: string | null = null;
|
|
@@ -474,7 +474,10 @@ export class WhatsAppBaileysAdapter
|
|
|
474
474
|
if (messageId) {
|
|
475
475
|
if (this.seenMessageIds.has(messageId)) return;
|
|
476
476
|
this.seenMessageIds.add(messageId);
|
|
477
|
-
if (this.seenMessageIds.size > 5000)
|
|
477
|
+
if (this.seenMessageIds.size > 5000) {
|
|
478
|
+
const ids = [...this.seenMessageIds];
|
|
479
|
+
this.seenMessageIds = new Set(ids.slice(ids.length - 2500));
|
|
480
|
+
}
|
|
478
481
|
}
|
|
479
482
|
|
|
480
483
|
const tsMs = Number(msg.messageTimestamp ?? 0) * 1000;
|
package/src/bridges/discord.ts
CHANGED
|
@@ -62,18 +62,25 @@ export class DiscordBridge implements PlatformBridge {
|
|
|
62
62
|
const inboxDir = path.join(workspace, "inbox");
|
|
63
63
|
for (const att of rawAttachments) {
|
|
64
64
|
if (!att.url) continue;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
65
|
+
try {
|
|
66
|
+
const type = mimeToMediaType(
|
|
67
|
+
att.mimeType || "application/octet-stream",
|
|
68
|
+
);
|
|
69
|
+
const result = await downloadMediaFromUrl(att.url, {
|
|
70
|
+
type,
|
|
71
|
+
mimeType: att.mimeType || "application/octet-stream",
|
|
72
|
+
filename: att.name,
|
|
73
|
+
expectedSizeBytes: att.size,
|
|
74
|
+
maxSizeBytes: ctx.media.maxSizeBytes,
|
|
75
|
+
outputDir: inboxDir,
|
|
76
|
+
});
|
|
77
|
+
if (result) attachments.push(result);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
logger.warn("Discord attachment download failed", {
|
|
80
|
+
filename: att.name,
|
|
81
|
+
error: err instanceof Error ? err.message : String(err),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
}
|
package/src/bridges/slack.ts
CHANGED
|
@@ -133,12 +133,14 @@ export class SlackBridge implements PlatformBridge {
|
|
|
133
133
|
): Promise<void> {
|
|
134
134
|
const parts = threadId.split(":");
|
|
135
135
|
const channelId = parts.length >= 2 ? parts[1] : threadId;
|
|
136
|
+
const threadTs = parts.length >= 3 ? parts[2] : undefined;
|
|
136
137
|
|
|
137
138
|
for (const file of files) {
|
|
138
139
|
try {
|
|
139
140
|
const buffer = fs.readFileSync(file.path);
|
|
140
141
|
const form = new FormData();
|
|
141
142
|
form.append("channel_id", channelId);
|
|
143
|
+
if (threadTs) form.append("thread_ts", threadTs);
|
|
142
144
|
form.append("filename", file.filename);
|
|
143
145
|
form.append(
|
|
144
146
|
"file",
|
package/src/bridges/teams.ts
CHANGED
|
@@ -73,9 +73,11 @@ export class TeamsBridge implements PlatformBridge {
|
|
|
73
73
|
|
|
74
74
|
const { externalId, isDM } = this.parseThread(threadId);
|
|
75
75
|
|
|
76
|
-
//
|
|
76
|
+
// Reply-to-bot detection: in DMs every reply is to the bot;
|
|
77
|
+
// in channels we cannot reliably determine the target, so default to false
|
|
78
|
+
// to avoid responding to conversations not directed at us.
|
|
77
79
|
const raw = msg.raw as { replyToId?: string; id?: string } | undefined;
|
|
78
|
-
const isReplyToBot = Boolean(raw?.replyToId);
|
|
80
|
+
const isReplyToBot = isDM && Boolean(raw?.replyToId);
|
|
79
81
|
|
|
80
82
|
return {
|
|
81
83
|
platform: "teams",
|
package/src/bridges/telegram.ts
CHANGED
|
@@ -479,13 +479,21 @@ export class TelegramBridge implements PlatformBridge {
|
|
|
479
479
|
const { chatId } = this.parseThreadId(threadId);
|
|
480
480
|
const apiUrl = `${TELEGRAM_API_BASE}/bot${this.botToken}/editMessageText`;
|
|
481
481
|
try {
|
|
482
|
+
let formatted: string;
|
|
483
|
+
try {
|
|
484
|
+
formatted = markdownToTelegramHtml(text);
|
|
485
|
+
} catch {
|
|
486
|
+
formatted = escapeHtml(text);
|
|
487
|
+
}
|
|
488
|
+
const truncated = truncateTelegramHtml(formatted, TELEGRAM_MESSAGE_LIMIT);
|
|
482
489
|
const resp = await fetch(apiUrl, {
|
|
483
490
|
method: "POST",
|
|
484
491
|
headers: { "Content-Type": "application/json" },
|
|
485
492
|
body: JSON.stringify({
|
|
486
493
|
chat_id: chatId,
|
|
487
494
|
message_id: Number(messageId),
|
|
488
|
-
text: applyRtlDirection(
|
|
495
|
+
text: applyRtlDirection(truncated),
|
|
496
|
+
parse_mode: "HTML",
|
|
489
497
|
}),
|
|
490
498
|
});
|
|
491
499
|
if (!resp.ok) return false;
|
package/src/cli/mercury.ts
CHANGED
|
@@ -70,7 +70,14 @@ function loadEnvFile(envPath: string): Record<string, string> {
|
|
|
70
70
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
71
71
|
const match = trimmed.match(/^([^=]+)=(.*)$/);
|
|
72
72
|
if (match) {
|
|
73
|
-
|
|
73
|
+
let value = match[2];
|
|
74
|
+
if (
|
|
75
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
76
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
77
|
+
) {
|
|
78
|
+
value = value.slice(1, -1);
|
|
79
|
+
}
|
|
80
|
+
vars[match[1]] = value;
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
return vars;
|
|
@@ -266,16 +273,14 @@ function statusAction(): void {
|
|
|
266
273
|
`Configuration: ${hasEnv ? "✓ .env exists" : "✗ .env missing (run 'mercury init')"}`,
|
|
267
274
|
);
|
|
268
275
|
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
},
|
|
275
|
-
);
|
|
276
|
+
const cfg = loadConfig();
|
|
277
|
+
const imageName = cfg.agentContainerImage;
|
|
278
|
+
const imageCheck = spawnSync("docker", ["image", "inspect", imageName], {
|
|
279
|
+
stdio: "pipe",
|
|
280
|
+
});
|
|
276
281
|
const hasImage = imageCheck.status === 0;
|
|
277
282
|
console.log(
|
|
278
|
-
`Container image: ${hasImage ?
|
|
283
|
+
`Container image: ${hasImage ? `✓ ${imageName}` : `✗ not available (run 'mercury build' or pull '${imageName}')`}`,
|
|
279
284
|
);
|
|
280
285
|
|
|
281
286
|
if (hasEnv) {
|
|
@@ -1747,7 +1752,7 @@ async function addAction(source: string): Promise<void> {
|
|
|
1747
1752
|
}
|
|
1748
1753
|
|
|
1749
1754
|
console.log("\nRestart mercury to activate:");
|
|
1750
|
-
console.log(" mercury service
|
|
1755
|
+
console.log(" mercury service uninstall && mercury service install");
|
|
1751
1756
|
} finally {
|
|
1752
1757
|
cleanup();
|
|
1753
1758
|
}
|
|
@@ -1762,7 +1767,7 @@ function removeAction(name: string): void {
|
|
|
1762
1767
|
|
|
1763
1768
|
console.log(`✓ Extension "${name}" removed`);
|
|
1764
1769
|
console.log("\nRestart mercury to apply:");
|
|
1765
|
-
console.log(" mercury service
|
|
1770
|
+
console.log(" mercury service uninstall && mercury service install");
|
|
1766
1771
|
}
|
|
1767
1772
|
|
|
1768
1773
|
function extensionsListAction(): void {
|
package/src/core/permissions.ts
CHANGED
|
@@ -30,6 +30,10 @@ const BUILT_IN_PERMISSIONS = new Set([
|
|
|
30
30
|
"media.purge",
|
|
31
31
|
/** Host Text-to-Speech (/api/tts); admin-only by default. */
|
|
32
32
|
"tts.synthesize",
|
|
33
|
+
/** Mute/unmute users and list mutes; admin-only by default. */
|
|
34
|
+
"mutes.list",
|
|
35
|
+
"mutes.mute",
|
|
36
|
+
"mutes.unmute",
|
|
33
37
|
]);
|
|
34
38
|
|
|
35
39
|
// ---------------------------------------------------------------------------
|
package/src/core/router.ts
CHANGED
|
@@ -106,7 +106,7 @@ export function routeInput(input: {
|
|
|
106
106
|
.split(/\s+/);
|
|
107
107
|
const category = rawCategory.toLowerCase();
|
|
108
108
|
const verb = rawVerb?.toLowerCase() || undefined;
|
|
109
|
-
const arg = argParts.join(" ").trim()
|
|
109
|
+
const arg = argParts.join(" ").trim() || undefined;
|
|
110
110
|
if (SLASH_COMMANDS.some((c) => c.name === category)) {
|
|
111
111
|
return gateSlashCommand(
|
|
112
112
|
input.db,
|
package/src/core/routes/chat.ts
CHANGED
|
@@ -62,9 +62,16 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
|
|
|
62
62
|
const authorName =
|
|
63
63
|
typeof body.authorName === "string" ? body.authorName.trim() : undefined;
|
|
64
64
|
|
|
65
|
+
if (!core.db.getSpace(spaceId)) {
|
|
66
|
+
return c.json({ error: "Space not found" }, 404);
|
|
67
|
+
}
|
|
68
|
+
|
|
65
69
|
// Save incoming files to inbox/
|
|
66
70
|
const attachments: MessageAttachment[] = [];
|
|
67
71
|
if (Array.isArray(body.files)) {
|
|
72
|
+
if (body.files.length > 20) {
|
|
73
|
+
return c.json({ error: "Too many files (max 20)" }, 400);
|
|
74
|
+
}
|
|
68
75
|
if (await isOverQuota(core.config)) {
|
|
69
76
|
return c.json({ error: "Storage quota exceeded" }, 413);
|
|
70
77
|
}
|
|
@@ -100,10 +107,6 @@ export function createChatRoute(core: MercuryCoreRuntime): Hono {
|
|
|
100
107
|
}
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
if (!core.db.getSpace(spaceId)) {
|
|
104
|
-
return c.json({ error: "Space not found" }, 404);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
110
|
if (authenticated) {
|
|
108
111
|
core.db.seedAdmins(spaceId, [callerId]);
|
|
109
112
|
}
|
package/src/core/routes/mutes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { Env } from "../api-types.js";
|
|
3
|
-
import { getApiCtx, getAuth } from "../api-types.js";
|
|
3
|
+
import { checkPerm, getApiCtx, getAuth } from "../api-types.js";
|
|
4
4
|
import { parseMuteDuration } from "../mute-duration.js";
|
|
5
5
|
|
|
6
6
|
export const mutes = new Hono<Env>();
|
|
@@ -8,6 +8,8 @@ export const mutes = new Hono<Env>();
|
|
|
8
8
|
// ─── List mutes ─────────────────────────────────────────────────────────
|
|
9
9
|
|
|
10
10
|
mutes.get("/", (c) => {
|
|
11
|
+
const denied = checkPerm(c, "mutes.list");
|
|
12
|
+
if (denied) return denied;
|
|
11
13
|
const { spaceId } = getAuth(c);
|
|
12
14
|
const { db } = getApiCtx(c);
|
|
13
15
|
return c.json({ mutes: db.listMutes(spaceId) });
|
|
@@ -16,6 +18,8 @@ mutes.get("/", (c) => {
|
|
|
16
18
|
// ─── Mute a user ────────────────────────────────────────────────────────
|
|
17
19
|
|
|
18
20
|
mutes.post("/", async (c) => {
|
|
21
|
+
const denied = checkPerm(c, "mutes.mute");
|
|
22
|
+
if (denied) return denied;
|
|
19
23
|
const { spaceId, callerId } = getAuth(c);
|
|
20
24
|
const { db } = getApiCtx(c);
|
|
21
25
|
const body = await c.req.json<{
|
|
@@ -76,6 +80,8 @@ mutes.post("/", async (c) => {
|
|
|
76
80
|
// ─── Unmute a user ──────────────────────────────────────────────────────
|
|
77
81
|
|
|
78
82
|
mutes.delete("/:userId", (c) => {
|
|
83
|
+
const denied = checkPerm(c, "mutes.unmute");
|
|
84
|
+
if (denied) return denied;
|
|
79
85
|
const { spaceId } = getAuth(c);
|
|
80
86
|
const { db } = getApiCtx(c);
|
|
81
87
|
const targetUserId = decodeURIComponent(c.req.param("userId"));
|
package/src/core/routes/roles.ts
CHANGED
|
@@ -33,6 +33,16 @@ roles.post("/", async (c) => {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const targetRole = body.role ?? "admin";
|
|
36
|
+
const VALID_ROLES = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
37
|
+
if (!VALID_ROLES.test(targetRole)) {
|
|
38
|
+
return c.json(
|
|
39
|
+
{
|
|
40
|
+
error:
|
|
41
|
+
"Invalid role name. Use lowercase alphanumeric, hyphens, underscores (max 32 chars).",
|
|
42
|
+
},
|
|
43
|
+
400,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
36
46
|
db.setRole(spaceId, body.platformUserId, targetRole, callerId);
|
|
37
47
|
|
|
38
48
|
return c.json({
|
|
@@ -101,10 +101,17 @@ export class TaskScheduler {
|
|
|
101
101
|
let updated = 0;
|
|
102
102
|
for (const task of tasks) {
|
|
103
103
|
if (!task.active || !task.cron) continue;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
task.timezone ?? undefined
|
|
107
|
-
)
|
|
104
|
+
let correct: number;
|
|
105
|
+
try {
|
|
106
|
+
correct = this.computeNextRun(task.cron, task.timezone ?? undefined);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logger.warn("Skipping task with invalid cron expression", {
|
|
109
|
+
taskId: task.id,
|
|
110
|
+
cron: task.cron,
|
|
111
|
+
error: err instanceof Error ? err.message : String(err),
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
108
115
|
if (correct !== task.nextRunAt) {
|
|
109
116
|
this.db.updateTaskNextRun(task.id, correct);
|
|
110
117
|
updated++;
|
package/src/extensions/hooks.ts
CHANGED
|
@@ -86,6 +86,11 @@ export class HookDispatcher {
|
|
|
86
86
|
this.log.error(
|
|
87
87
|
`Hook "before_container" handler failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
88
88
|
);
|
|
89
|
+
return {
|
|
90
|
+
block: {
|
|
91
|
+
reason: `Extension hook failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
|
package/src/extensions/loader.ts
CHANGED
package/src/storage/db.ts
CHANGED