macroclaw 0.0.0-dev
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/LICENSE +21 -0
- package/README.md +114 -0
- package/bin/macroclaw.js +2 -0
- package/package.json +41 -0
- package/src/app.test.ts +699 -0
- package/src/app.ts +164 -0
- package/src/claude.integration-test.ts +108 -0
- package/src/claude.test.ts +247 -0
- package/src/claude.ts +136 -0
- package/src/cron.test.ts +265 -0
- package/src/cron.ts +108 -0
- package/src/history.test.ts +92 -0
- package/src/history.ts +37 -0
- package/src/index.ts +42 -0
- package/src/logger.test.ts +33 -0
- package/src/logger.ts +28 -0
- package/src/orchestrator.test.ts +631 -0
- package/src/orchestrator.ts +396 -0
- package/src/prompts.test.ts +43 -0
- package/src/prompts.ts +48 -0
- package/src/queue.test.ts +150 -0
- package/src/queue.ts +42 -0
- package/src/settings.test.ts +55 -0
- package/src/settings.ts +36 -0
- package/src/stt.ts +31 -0
- package/src/telegram.test.ts +283 -0
- package/src/telegram.ts +121 -0
- package/src/test-setup.ts +1 -0
- package/workspace-template/.claude/hooks/pre-compact.sh +2 -0
- package/workspace-template/.claude/settings.json +19 -0
- package/workspace-template/.claude/skills/add-cronjob/SKILL.md +77 -0
- package/workspace-template/.macroclaw/cron.json +16 -0
- package/workspace-template/CLAUDE.md +97 -0
- package/workspace-template/MEMORY.md +3 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { loadSettings, newSessionId, saveSettings } from "./settings";
|
|
5
|
+
|
|
6
|
+
const tmpDir = "/tmp/macroclaw-settings-test";
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("loadSettings", () => {
|
|
13
|
+
it("returns empty object when dir does not exist", () => {
|
|
14
|
+
expect(loadSettings(tmpDir)).toEqual({});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns empty object when file does not exist", () => {
|
|
18
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
19
|
+
expect(loadSettings(tmpDir)).toEqual({});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("reads settings from file", () => {
|
|
23
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
24
|
+
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ sessionId: "abc-123" }));
|
|
25
|
+
expect(loadSettings(tmpDir)).toEqual({ sessionId: "abc-123" });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns empty object when file is corrupt", () => {
|
|
29
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
30
|
+
writeFileSync(join(tmpDir, "settings.json"), "not json");
|
|
31
|
+
expect(loadSettings(tmpDir)).toEqual({});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("saveSettings", () => {
|
|
36
|
+
it("creates directory and writes file", () => {
|
|
37
|
+
saveSettings({ sessionId: "new-id" }, tmpDir);
|
|
38
|
+
const saved = loadSettings(tmpDir);
|
|
39
|
+
expect(saved).toEqual({ sessionId: "new-id" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("overwrites existing file", () => {
|
|
43
|
+
saveSettings({ sessionId: "first" }, tmpDir);
|
|
44
|
+
saveSettings({ sessionId: "second" }, tmpDir);
|
|
45
|
+
expect(loadSettings(tmpDir)).toEqual({ sessionId: "second" });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("newSessionId", () => {
|
|
50
|
+
it("returns a valid UUID", () => {
|
|
51
|
+
const id = newSessionId();
|
|
52
|
+
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
53
|
+
expect(newSessionId()).not.toBe(id);
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { z } from "zod/v4";
|
|
5
|
+
import { createLogger } from "./logger";
|
|
6
|
+
|
|
7
|
+
const log = createLogger("settings");
|
|
8
|
+
|
|
9
|
+
const settingsSchema = z.object({
|
|
10
|
+
sessionId: z.string().optional(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type Settings = z.infer<typeof settingsSchema>;
|
|
14
|
+
|
|
15
|
+
const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
|
|
16
|
+
|
|
17
|
+
export function loadSettings(dir: string = defaultDir): Settings {
|
|
18
|
+
try {
|
|
19
|
+
const path = join(dir, "settings.json");
|
|
20
|
+
if (!existsSync(path)) return {};
|
|
21
|
+
const raw = readFileSync(path, "utf-8");
|
|
22
|
+
return settingsSchema.parse(JSON.parse(raw));
|
|
23
|
+
} catch (err) {
|
|
24
|
+
log.warn({ err }, "Failed to load settings.json");
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveSettings(settings: Settings, dir: string = defaultDir): void {
|
|
30
|
+
mkdirSync(dir, { recursive: true });
|
|
31
|
+
writeFileSync(join(dir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function newSessionId(): string {
|
|
35
|
+
return randomUUID();
|
|
36
|
+
}
|
package/src/stt.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import OpenAI from "openai";
|
|
4
|
+
import { createLogger } from "./logger";
|
|
5
|
+
|
|
6
|
+
const log = createLogger("stt");
|
|
7
|
+
|
|
8
|
+
let client: OpenAI | undefined;
|
|
9
|
+
|
|
10
|
+
function getClient(): OpenAI {
|
|
11
|
+
if (!client) client = new OpenAI();
|
|
12
|
+
return client;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isAvailable(): boolean {
|
|
16
|
+
return !!process.env.OPENAI_API_KEY;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function transcribe(filePath: string): Promise<string> {
|
|
20
|
+
const buffer = await readFile(filePath);
|
|
21
|
+
const file = new File([buffer], basename(filePath), { type: "audio/ogg" });
|
|
22
|
+
|
|
23
|
+
log.debug({ filePath }, "Transcribing audio");
|
|
24
|
+
const result = await getClient().audio.transcriptions.create({
|
|
25
|
+
model: "whisper-1",
|
|
26
|
+
file,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
log.debug({ text: result.text }, "Transcription complete");
|
|
30
|
+
return result.text;
|
|
31
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { buildInlineKeyboard, createBot, downloadFile, sendFile, sendResponse } from "./telegram";
|
|
5
|
+
|
|
6
|
+
// Mock bot API
|
|
7
|
+
function mockBot() {
|
|
8
|
+
const calls: { chatId: string; text: string; opts?: any }[] = [];
|
|
9
|
+
return {
|
|
10
|
+
api: {
|
|
11
|
+
sendMessage: mock(async (chatId: string, text: string, opts?: any) => {
|
|
12
|
+
calls.push({ chatId, text, opts });
|
|
13
|
+
}),
|
|
14
|
+
},
|
|
15
|
+
calls,
|
|
16
|
+
} as any;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("createBot", () => {
|
|
20
|
+
it("returns a Grammy Bot instance", () => {
|
|
21
|
+
// Can't fully test without a real token, but verify it returns an object
|
|
22
|
+
const bot = createBot("test-token");
|
|
23
|
+
expect(bot).toBeDefined();
|
|
24
|
+
expect(bot.api).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("sendResponse", () => {
|
|
29
|
+
it("sends short messages in a single call", async () => {
|
|
30
|
+
const bot = mockBot();
|
|
31
|
+
await sendResponse(bot, "123", "Hello");
|
|
32
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
|
|
33
|
+
expect(bot.calls[0]).toEqual({ chatId: "123", text: "Hello", opts: { parse_mode: "HTML" } });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("sends messages at exactly 4096 chars in a single call", async () => {
|
|
37
|
+
const bot = mockBot();
|
|
38
|
+
const text = "x".repeat(4096);
|
|
39
|
+
await sendResponse(bot, "123", text);
|
|
40
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("splits long messages at line boundaries", async () => {
|
|
44
|
+
const bot = mockBot();
|
|
45
|
+
// Create text with lines that force a split
|
|
46
|
+
const line = "a".repeat(2000);
|
|
47
|
+
const text = `${line}\n${line}\n${line}`; // 6002 chars total
|
|
48
|
+
await sendResponse(bot, "123", text);
|
|
49
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(2);
|
|
50
|
+
// First chunk: line1 + \n + line2 = 4001 chars
|
|
51
|
+
expect(bot.calls[0].text).toBe(`${line}\n${line}`);
|
|
52
|
+
// Second chunk: line3 = 2000 chars
|
|
53
|
+
expect(bot.calls[1].text).toBe(line);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("hard-splits single lines exceeding 4096 chars", async () => {
|
|
57
|
+
const bot = mockBot();
|
|
58
|
+
const longLine = "z".repeat(5000);
|
|
59
|
+
await sendResponse(bot, "123", longLine);
|
|
60
|
+
// Split into 4096 + 904
|
|
61
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(2);
|
|
62
|
+
expect(bot.calls[0].text).toBe("z".repeat(4096));
|
|
63
|
+
expect(bot.calls[1].text).toBe("z".repeat(904));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("handles long line preceded by a buffered chunk", async () => {
|
|
67
|
+
const bot = mockBot();
|
|
68
|
+
const shortLine = "short";
|
|
69
|
+
const longLine = "z".repeat(5000);
|
|
70
|
+
const text = `${shortLine}\n${longLine}`;
|
|
71
|
+
await sendResponse(bot, "123", text);
|
|
72
|
+
// 1: "short", 2: first 4096 of longLine, 3: remaining 904
|
|
73
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(3);
|
|
74
|
+
expect(bot.calls[0].text).toBe("short");
|
|
75
|
+
expect(bot.calls[1].text).toBe("z".repeat(4096));
|
|
76
|
+
expect(bot.calls[2].text).toBe("z".repeat(904));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("handles line that exactly causes overflow", async () => {
|
|
80
|
+
const bot = mockBot();
|
|
81
|
+
const line1 = "a".repeat(4000);
|
|
82
|
+
const line2 = "b".repeat(200); // 4000 + 1 (newline) + 200 = 4201 > 4096
|
|
83
|
+
const text = `${line1}\n${line2}`;
|
|
84
|
+
await sendResponse(bot, "123", text);
|
|
85
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(2);
|
|
86
|
+
expect(bot.calls[0].text).toBe(line1);
|
|
87
|
+
expect(bot.calls[1].text).toBe(line2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("sends empty trailing chunk correctly", async () => {
|
|
91
|
+
const bot = mockBot();
|
|
92
|
+
await sendResponse(bot, "123", "hello\nworld");
|
|
93
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(bot.calls[0].text).toBe("hello\nworld");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("attaches buttons to a single message", async () => {
|
|
98
|
+
const bot = mockBot();
|
|
99
|
+
const buttons = ["Yes", "No"];
|
|
100
|
+
await sendResponse(bot, "123", "Choose:", buttons);
|
|
101
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(bot.calls[0].opts.reply_markup).toBeDefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("attaches buttons to last chunk only when splitting", async () => {
|
|
106
|
+
const bot = mockBot();
|
|
107
|
+
const line = "a".repeat(2000);
|
|
108
|
+
const text = `${line}\n${line}\n${line}`;
|
|
109
|
+
const buttons = ["Ok"];
|
|
110
|
+
await sendResponse(bot, "123", text, buttons);
|
|
111
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(2);
|
|
112
|
+
// First chunk: no buttons
|
|
113
|
+
expect(bot.calls[0].opts.reply_markup).toBeUndefined();
|
|
114
|
+
// Last chunk: has buttons
|
|
115
|
+
expect(bot.calls[1].opts.reply_markup).toBeDefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("sends without keyboard when buttons is empty", async () => {
|
|
119
|
+
const bot = mockBot();
|
|
120
|
+
await sendResponse(bot, "123", "Hello", []);
|
|
121
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(bot.calls[0].opts.reply_markup).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("buildInlineKeyboard", () => {
|
|
127
|
+
it("builds keyboard with rows and buttons", () => {
|
|
128
|
+
const kb = buildInlineKeyboard(["A", "B", "C"]);
|
|
129
|
+
expect(kb).toBeDefined();
|
|
130
|
+
// InlineKeyboard from grammy — verify it's an object with inline_keyboard
|
|
131
|
+
expect(kb.inline_keyboard).toBeDefined();
|
|
132
|
+
expect(kb.inline_keyboard.length).toBe(3);
|
|
133
|
+
expect(kb.inline_keyboard[0].length).toBe(1);
|
|
134
|
+
const btn = kb.inline_keyboard[0][0] as any;
|
|
135
|
+
expect(btn.text).toBe("A");
|
|
136
|
+
expect(btn.callback_data).toBe("A");
|
|
137
|
+
expect((kb.inline_keyboard[2][0] as any).text).toBe("C");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("downloadFile", () => {
|
|
142
|
+
it("downloads file to /tmp/macroclaw/inbound/<uuid>/<name>", async () => {
|
|
143
|
+
const fileContent = new Uint8Array([1, 2, 3]);
|
|
144
|
+
const mockFetch = mock(() =>
|
|
145
|
+
Promise.resolve(new Response(fileContent, { status: 200 })),
|
|
146
|
+
);
|
|
147
|
+
const origFetch = globalThis.fetch;
|
|
148
|
+
globalThis.fetch = mockFetch as any;
|
|
149
|
+
|
|
150
|
+
const bot = {
|
|
151
|
+
api: {
|
|
152
|
+
getFile: mock(async () => ({ file_path: "photos/file_42.jpg" })),
|
|
153
|
+
},
|
|
154
|
+
} as any;
|
|
155
|
+
|
|
156
|
+
const path = await downloadFile(bot, "file-id-123", "123:ABC", "photo.jpg");
|
|
157
|
+
|
|
158
|
+
expect(path).toContain("/tmp/macroclaw/inbound/");
|
|
159
|
+
expect(path).toEndWith("/photo.jpg");
|
|
160
|
+
expect(existsSync(path)).toBe(true);
|
|
161
|
+
|
|
162
|
+
const contents = await readFile(path);
|
|
163
|
+
expect(new Uint8Array(contents)).toEqual(fileContent);
|
|
164
|
+
|
|
165
|
+
const fetchUrl = (mockFetch as any).mock.calls[0][0];
|
|
166
|
+
expect(fetchUrl).toBe("https://api.telegram.org/file/bot123:ABC/photos/file_42.jpg");
|
|
167
|
+
|
|
168
|
+
// Cleanup
|
|
169
|
+
await rm(path, { force: true });
|
|
170
|
+
globalThis.fetch = origFetch;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("uses file_path basename when no originalName given", async () => {
|
|
174
|
+
const origFetch = globalThis.fetch;
|
|
175
|
+
globalThis.fetch = mock(() =>
|
|
176
|
+
Promise.resolve(new Response("data", { status: 200 })),
|
|
177
|
+
) as any;
|
|
178
|
+
|
|
179
|
+
const bot = {
|
|
180
|
+
api: {
|
|
181
|
+
getFile: mock(async () => ({ file_path: "documents/report.pdf" })),
|
|
182
|
+
},
|
|
183
|
+
} as any;
|
|
184
|
+
|
|
185
|
+
const path = await downloadFile(bot, "file-id", "token");
|
|
186
|
+
expect(path).toEndWith("/report.pdf");
|
|
187
|
+
|
|
188
|
+
await rm(path, { force: true });
|
|
189
|
+
globalThis.fetch = origFetch;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("throws when Telegram returns no file_path", async () => {
|
|
193
|
+
const bot = {
|
|
194
|
+
api: { getFile: mock(async () => ({})) },
|
|
195
|
+
} as any;
|
|
196
|
+
|
|
197
|
+
expect(downloadFile(bot, "file-id", "token")).rejects.toThrow("no file_path");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("throws when download fails", async () => {
|
|
201
|
+
const origFetch = globalThis.fetch;
|
|
202
|
+
globalThis.fetch = mock(() =>
|
|
203
|
+
Promise.resolve(new Response("", { status: 404 })),
|
|
204
|
+
) as any;
|
|
205
|
+
|
|
206
|
+
const bot = {
|
|
207
|
+
api: { getFile: mock(async () => ({ file_path: "photos/x.jpg" })) },
|
|
208
|
+
} as any;
|
|
209
|
+
|
|
210
|
+
expect(downloadFile(bot, "file-id", "token")).rejects.toThrow("Download failed: 404");
|
|
211
|
+
globalThis.fetch = origFetch;
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("sendFile", () => {
|
|
216
|
+
it("sends image extensions as photos", async () => {
|
|
217
|
+
const tmpFile = `/tmp/macroclaw-test-${Date.now()}.png`;
|
|
218
|
+
await Bun.write(tmpFile, "fake png");
|
|
219
|
+
|
|
220
|
+
const bot = {
|
|
221
|
+
api: {
|
|
222
|
+
sendPhoto: mock(async () => {}),
|
|
223
|
+
sendDocument: mock(async () => {}),
|
|
224
|
+
},
|
|
225
|
+
} as any;
|
|
226
|
+
|
|
227
|
+
await sendFile(bot, "123", tmpFile);
|
|
228
|
+
expect(bot.api.sendPhoto).toHaveBeenCalledTimes(1);
|
|
229
|
+
expect(bot.api.sendDocument).not.toHaveBeenCalled();
|
|
230
|
+
|
|
231
|
+
await rm(tmpFile, { force: true });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("sends non-image extensions as documents", async () => {
|
|
235
|
+
const tmpFile = `/tmp/macroclaw-test-${Date.now()}.pdf`;
|
|
236
|
+
await Bun.write(tmpFile, "fake pdf");
|
|
237
|
+
|
|
238
|
+
const bot = {
|
|
239
|
+
api: {
|
|
240
|
+
sendPhoto: mock(async () => {}),
|
|
241
|
+
sendDocument: mock(async () => {}),
|
|
242
|
+
},
|
|
243
|
+
} as any;
|
|
244
|
+
|
|
245
|
+
await sendFile(bot, "123", tmpFile);
|
|
246
|
+
expect(bot.api.sendDocument).toHaveBeenCalledTimes(1);
|
|
247
|
+
expect(bot.api.sendPhoto).not.toHaveBeenCalled();
|
|
248
|
+
|
|
249
|
+
await rm(tmpFile, { force: true });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("skips missing files without sending", async () => {
|
|
253
|
+
const bot = {
|
|
254
|
+
api: {
|
|
255
|
+
sendPhoto: mock(async () => {}),
|
|
256
|
+
sendDocument: mock(async () => {}),
|
|
257
|
+
},
|
|
258
|
+
} as any;
|
|
259
|
+
|
|
260
|
+
await sendFile(bot, "123", "/tmp/nonexistent-file-xyz.txt");
|
|
261
|
+
expect(bot.api.sendPhoto).not.toHaveBeenCalled();
|
|
262
|
+
expect(bot.api.sendDocument).not.toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
for (const ext of [".jpg", ".jpeg", ".gif", ".webp"]) {
|
|
266
|
+
it(`treats ${ext} as image`, async () => {
|
|
267
|
+
const tmpFile = `/tmp/macroclaw-test-${Date.now()}${ext}`;
|
|
268
|
+
await Bun.write(tmpFile, "fake");
|
|
269
|
+
|
|
270
|
+
const bot = {
|
|
271
|
+
api: {
|
|
272
|
+
sendPhoto: mock(async () => {}),
|
|
273
|
+
sendDocument: mock(async () => {}),
|
|
274
|
+
},
|
|
275
|
+
} as any;
|
|
276
|
+
|
|
277
|
+
await sendFile(bot, "123", tmpFile);
|
|
278
|
+
expect(bot.api.sendPhoto).toHaveBeenCalledTimes(1);
|
|
279
|
+
|
|
280
|
+
await rm(tmpFile, { force: true });
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
});
|
package/src/telegram.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Bot, InlineKeyboard, InputFile } from "grammy";
|
|
6
|
+
import { createLogger } from "./logger";
|
|
7
|
+
|
|
8
|
+
const log = createLogger("telegram");
|
|
9
|
+
|
|
10
|
+
const MAX_LENGTH = 4096;
|
|
11
|
+
const INBOUND_DIR = "/tmp/macroclaw/inbound";
|
|
12
|
+
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
13
|
+
|
|
14
|
+
export function createBot(token: string) {
|
|
15
|
+
return new Bot(token);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildInlineKeyboard(buttons: string[]): InlineKeyboard {
|
|
19
|
+
const kb = new InlineKeyboard();
|
|
20
|
+
for (let i = 0; i < buttons.length; i++) {
|
|
21
|
+
if (i > 0) kb.row();
|
|
22
|
+
kb.text(buttons[i], buttons[i]);
|
|
23
|
+
}
|
|
24
|
+
return kb;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function sendResponse(
|
|
28
|
+
bot: Bot,
|
|
29
|
+
chatId: string,
|
|
30
|
+
text: string,
|
|
31
|
+
buttons?: string[],
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const opts = { parse_mode: "HTML" as const };
|
|
34
|
+
const replyMarkup = buttons?.length ? buildInlineKeyboard(buttons) : undefined;
|
|
35
|
+
|
|
36
|
+
if (text.length <= MAX_LENGTH) {
|
|
37
|
+
await bot.api.sendMessage(chatId, text, { ...opts, reply_markup: replyMarkup });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Split at line boundaries into chunks <= MAX_LENGTH
|
|
42
|
+
const lines = text.split("\n");
|
|
43
|
+
const chunks: string[] = [];
|
|
44
|
+
let chunk = "";
|
|
45
|
+
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
if (line.length > MAX_LENGTH) {
|
|
48
|
+
if (chunk) {
|
|
49
|
+
chunks.push(chunk);
|
|
50
|
+
chunk = "";
|
|
51
|
+
}
|
|
52
|
+
for (let i = 0; i < line.length; i += MAX_LENGTH) {
|
|
53
|
+
chunks.push(line.slice(i, i + MAX_LENGTH));
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const candidate = chunk ? `${chunk}\n${line}` : line;
|
|
59
|
+
if (candidate.length > MAX_LENGTH) {
|
|
60
|
+
chunks.push(chunk);
|
|
61
|
+
chunk = line;
|
|
62
|
+
} else {
|
|
63
|
+
chunk = candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (chunk) {
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Send all chunks; attach buttons to the last one only
|
|
72
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
73
|
+
const isLast = i === chunks.length - 1;
|
|
74
|
+
await bot.api.sendMessage(chatId, chunks[i], { ...opts, reply_markup: isLast ? replyMarkup : undefined });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function downloadFile(
|
|
79
|
+
bot: Bot,
|
|
80
|
+
fileId: string,
|
|
81
|
+
token: string,
|
|
82
|
+
originalName?: string,
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
const file = await bot.api.getFile(fileId);
|
|
85
|
+
const filePath = file.file_path;
|
|
86
|
+
if (!filePath) throw new Error("Telegram returned no file_path");
|
|
87
|
+
|
|
88
|
+
const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
|
|
89
|
+
const response = await fetch(url);
|
|
90
|
+
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
|
91
|
+
|
|
92
|
+
const dir = join(INBOUND_DIR, randomUUID());
|
|
93
|
+
await mkdir(dir, { recursive: true });
|
|
94
|
+
const name = originalName ?? filePath.split("/").pop() ?? "file";
|
|
95
|
+
const dest = join(dir, name);
|
|
96
|
+
await writeFile(dest, new Uint8Array(await response.arrayBuffer()));
|
|
97
|
+
return dest;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extName(path: string): string {
|
|
101
|
+
const dot = path.lastIndexOf(".");
|
|
102
|
+
return dot === -1 ? "" : path.slice(dot).toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function sendFile(
|
|
106
|
+
bot: Bot,
|
|
107
|
+
chatId: string,
|
|
108
|
+
filePath: string,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
if (!existsSync(filePath)) {
|
|
111
|
+
log.warn({ filePath }, "File not found, skipping");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ext = extName(filePath);
|
|
116
|
+
if (IMAGE_EXTENSIONS.has(ext)) {
|
|
117
|
+
await bot.api.sendPhoto(chatId, new InputFile(filePath));
|
|
118
|
+
} else {
|
|
119
|
+
await bot.api.sendDocument(chatId, new InputFile(filePath));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
process.env.LOG_LEVEL = "silent";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"autoMemoryEnabled": false,
|
|
3
|
+
"permissions": {
|
|
4
|
+
"defaultMode": "bypassPermissions"
|
|
5
|
+
},
|
|
6
|
+
"dangerouslySkipPermissions": true,
|
|
7
|
+
"hooks": {
|
|
8
|
+
"PreCompact": [
|
|
9
|
+
{
|
|
10
|
+
"hooks": [
|
|
11
|
+
{
|
|
12
|
+
"type": "command",
|
|
13
|
+
"command": ".claude/hooks/pre-compact.sh"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-cronjob
|
|
3
|
+
description: Add a new scheduled cron job. Use when the user wants to schedule a recurring prompt, add a periodic task, or set up automated messages.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Add a new cron job to `.macroclaw/cron.json` in this workspace.
|
|
7
|
+
|
|
8
|
+
## Steps
|
|
9
|
+
|
|
10
|
+
1. Read the current `.macroclaw/cron.json` file (create it if missing with `{"jobs": []}`)
|
|
11
|
+
2. Ask the user what prompt to run and when (if not already specified)
|
|
12
|
+
3. Build a standard cron expression for the schedule
|
|
13
|
+
4. Append the new job to the `jobs` array
|
|
14
|
+
5. Write the updated file
|
|
15
|
+
6. Confirm what was added and when it will next run
|
|
16
|
+
|
|
17
|
+
## cron.json format
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"jobs": [
|
|
22
|
+
{
|
|
23
|
+
"name": "morning-summary",
|
|
24
|
+
"cron": "0 9 * * *",
|
|
25
|
+
"prompt": "Give me a morning summary of my tasks"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "email-check",
|
|
29
|
+
"cron": "*/30 * * * *",
|
|
30
|
+
"prompt": "Check if any important emails arrived",
|
|
31
|
+
"model": "haiku"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "weekly-report",
|
|
35
|
+
"cron": "0 17 * * 5",
|
|
36
|
+
"prompt": "Generate a weekly report of completed tasks",
|
|
37
|
+
"recurring": false
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Job fields
|
|
44
|
+
|
|
45
|
+
| Field | Required | Description |
|
|
46
|
+
|-------|----------|-------------|
|
|
47
|
+
| `name` | yes | Short identifier for the job. Appears in the `[Tool: cron/<name>]` prefix so the agent knows which job triggered the prompt. Use kebab-case (e.g. `morning-summary`). |
|
|
48
|
+
| `cron` | yes | Standard cron expression defining when the job runs. See reference below. |
|
|
49
|
+
| `prompt` | yes | The message sent to Claude when the job fires. Write it as if you're typing a message in Telegram — the agent will receive and act on it. |
|
|
50
|
+
| `recurring` | no | Whether the job repeats. Defaults to `true`. Set to `false` for one-shot jobs that should fire once and be automatically removed from cron.json. Good for reminders ("remind me to call the dentist tomorrow at 10") or one-time scheduled events ("send the weekly report this Friday"). |
|
|
51
|
+
| `model` | no | Override the model for this specific job. Omit to use the default model (set via `MODEL` in `.env`), which is best for normal interactive tasks. Use `haiku` for cheap/fast routine checks (email polling, status pings). Use `opus` only when the task genuinely needs deeper reasoning. |
|
|
52
|
+
|
|
53
|
+
## Cron expression reference
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
┌───────── minute (0-59)
|
|
57
|
+
│ ┌─────── hour (0-23)
|
|
58
|
+
│ │ ┌───── day of month (1-31)
|
|
59
|
+
│ │ │ ┌─── month (1-12)
|
|
60
|
+
│ │ │ │ ┌─ day of week (0-7, 0 and 7 = Sunday)
|
|
61
|
+
│ │ │ │ │
|
|
62
|
+
* * * * *
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Common patterns:
|
|
66
|
+
- `0 9 * * *` — daily at 9:00
|
|
67
|
+
- `0 9 * * 1-5` — weekdays at 9:00
|
|
68
|
+
- `*/30 * * * *` — every 30 minutes
|
|
69
|
+
- `0 */2 * * *` — every 2 hours
|
|
70
|
+
- `0 9,18 * * *` — at 9:00 and 18:00
|
|
71
|
+
|
|
72
|
+
## Notes
|
|
73
|
+
|
|
74
|
+
- Changes are hot-reloaded — no restart needed
|
|
75
|
+
- File location: `<workspace>/.macroclaw/cron.json`
|
|
76
|
+
- Prompts are injected into the conversation with a `[Tool: cron/<name>]` prefix
|
|
77
|
+
- One-shot jobs (`recurring: false`) are cleaned up automatically after firing
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"jobs": [
|
|
3
|
+
{
|
|
4
|
+
"name": "memory-capture",
|
|
5
|
+
"cron": "0 */4 * * *",
|
|
6
|
+
"model": "haiku",
|
|
7
|
+
"prompt": "Append today's noteworthy events to memory/YYYY-MM-DD.md (create the file and directory if needed). Add a ## HH:MM heading with bullet points underneath. Capture: decisions made, facts learned, user preferences expressed, tasks completed or started, problems solved. Be factual and concise — one line per item. If the file already exists, APPEND only — never overwrite. If nothing meaningful happened since the last entry, respond silently."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"name": "memory-consolidate",
|
|
11
|
+
"cron": "0 3 * * *",
|
|
12
|
+
"model": "opus",
|
|
13
|
+
"prompt": "Read daily logs in memory/ from the past 3 days. Distill durable knowledge into MEMORY.md: confirmed facts, recurring patterns, stable preferences, key decisions, and important context. Remove entries from MEMORY.md that are outdated or contradicted by recent logs. Keep MEMORY.md concise and organized by topic (not chronologically). Do not duplicate entries. If daily logs contain new personal facts about the user (preferences, routines, family, work, interests), update USER.md accordingly. Respond silently."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|