clawless 0.1.2

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.
@@ -0,0 +1,109 @@
1
+ import { Telegraf } from 'telegraf';
2
+ function splitTextIntoChunks(text, maxMessageLength) {
3
+ const normalizedText = String(text || '');
4
+ if (!normalizedText) {
5
+ return [''];
6
+ }
7
+ if (normalizedText.length <= maxMessageLength) {
8
+ return [normalizedText];
9
+ }
10
+ const chunks = [];
11
+ let start = 0;
12
+ while (start < normalizedText.length) {
13
+ const end = Math.min(start + maxMessageLength, normalizedText.length);
14
+ chunks.push(normalizedText.slice(start, end));
15
+ start = end;
16
+ }
17
+ return chunks;
18
+ }
19
+ class TelegramMessageContext {
20
+ ctx;
21
+ typingIntervalMs;
22
+ maxMessageLength;
23
+ text;
24
+ chatId;
25
+ constructor(ctx, typingIntervalMs, maxMessageLength) {
26
+ this.ctx = ctx;
27
+ this.typingIntervalMs = typingIntervalMs;
28
+ this.maxMessageLength = maxMessageLength;
29
+ this.text = ctx.message?.text || '';
30
+ this.chatId = ctx.chat?.id;
31
+ }
32
+ startTyping() {
33
+ this.ctx.telegram.sendChatAction(this.ctx.chat.id, 'typing').catch(() => { });
34
+ const intervalId = setInterval(() => {
35
+ this.ctx.telegram.sendChatAction(this.ctx.chat.id, 'typing').catch(() => { });
36
+ }, this.typingIntervalMs);
37
+ return () => clearInterval(intervalId);
38
+ }
39
+ async sendText(text) {
40
+ const chunks = splitTextIntoChunks(text, this.maxMessageLength);
41
+ for (const chunk of chunks) {
42
+ await this.ctx.reply(chunk);
43
+ }
44
+ }
45
+ async startLiveMessage(initialText = '…') {
46
+ const sent = await this.ctx.reply(initialText);
47
+ return sent?.message_id;
48
+ }
49
+ async updateLiveMessage(messageId, text) {
50
+ await this.ctx.telegram.editMessageText(this.ctx.chat.id, messageId, undefined, text || '…');
51
+ }
52
+ async finalizeLiveMessage(messageId, text) {
53
+ const finalText = text || 'No response received.';
54
+ const chunks = splitTextIntoChunks(finalText, this.maxMessageLength);
55
+ try {
56
+ await this.updateLiveMessage(messageId, chunks[0] || 'No response received.');
57
+ }
58
+ catch (error) {
59
+ const errorMessage = String(error?.message || '').toLowerCase();
60
+ if (!errorMessage.includes('message is not modified')) {
61
+ throw error;
62
+ }
63
+ }
64
+ for (let index = 1; index < chunks.length; index += 1) {
65
+ await this.ctx.reply(chunks[index]);
66
+ }
67
+ }
68
+ async removeMessage(messageId) {
69
+ await this.ctx.telegram.deleteMessage(this.ctx.chat.id, messageId);
70
+ }
71
+ }
72
+ export class TelegramMessagingClient {
73
+ bot;
74
+ typingIntervalMs;
75
+ maxMessageLength;
76
+ constructor({ token, typingIntervalMs, maxMessageLength }) {
77
+ this.bot = new Telegraf(token);
78
+ this.typingIntervalMs = typingIntervalMs;
79
+ this.maxMessageLength = maxMessageLength;
80
+ }
81
+ onTextMessage(handler) {
82
+ this.bot.on('text', (ctx) => {
83
+ const messageContext = new TelegramMessageContext(ctx, this.typingIntervalMs, this.maxMessageLength);
84
+ Promise.resolve(handler(messageContext)).catch((error) => {
85
+ console.error('Text message handler failed:', error);
86
+ });
87
+ });
88
+ }
89
+ onError(handler) {
90
+ this.bot.catch((error, ctx) => {
91
+ const messageContext = ctx?.chat
92
+ ? new TelegramMessageContext(ctx, this.typingIntervalMs, this.maxMessageLength)
93
+ : null;
94
+ handler(error, messageContext);
95
+ });
96
+ }
97
+ async launch() {
98
+ await this.bot.launch();
99
+ }
100
+ async sendTextToChat(chatId, text) {
101
+ const chunks = splitTextIntoChunks(text, this.maxMessageLength);
102
+ for (const chunk of chunks) {
103
+ await this.bot.telegram.sendMessage(chatId, chunk);
104
+ }
105
+ }
106
+ stop(reason) {
107
+ this.bot.stop(reason);
108
+ }
109
+ }
@@ -0,0 +1,272 @@
1
+ import cron from 'node-cron';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getErrorMessage } from '../utils/error.js';
5
+ export class CronScheduler {
6
+ jobs = new Map();
7
+ jobCallback;
8
+ persistenceFilePath;
9
+ timezone;
10
+ logInfo;
11
+ constructor(callback, options = {}) {
12
+ this.jobCallback = callback;
13
+ this.persistenceFilePath = options.persistenceFilePath || null;
14
+ this.timezone = options.timezone || process.env.TZ || 'UTC';
15
+ this.logInfo = options.logInfo || ((message, details) => {
16
+ if (details !== undefined) {
17
+ console.log(`[CronScheduler] ${message}`, details);
18
+ }
19
+ else {
20
+ console.log(`[CronScheduler] ${message}`);
21
+ }
22
+ });
23
+ this.loadPersistedSchedules();
24
+ }
25
+ validateScheduleConfig(config) {
26
+ if (config.oneTime) {
27
+ if (!config.runAt) {
28
+ throw new Error('One-time schedules require a runAt date');
29
+ }
30
+ if (config.runAt <= new Date()) {
31
+ throw new Error('One-time schedule runAt must be in the future');
32
+ }
33
+ return;
34
+ }
35
+ if (!config.cronExpression) {
36
+ throw new Error('Recurring schedules require a cronExpression');
37
+ }
38
+ if (!cron.validate(config.cronExpression)) {
39
+ throw new Error('Invalid cron expression');
40
+ }
41
+ }
42
+ configureRuntimeForJob(job) {
43
+ // Clean existing runtime handles first (used when toggling/reloading)
44
+ if (job.task) {
45
+ job.task.stop();
46
+ job.task = undefined;
47
+ }
48
+ if (job.timeout) {
49
+ clearTimeout(job.timeout);
50
+ job.timeout = undefined;
51
+ }
52
+ const { config } = job;
53
+ if (config.oneTime && config.runAt) {
54
+ if (!config.active) {
55
+ return;
56
+ }
57
+ const delay = config.runAt.getTime() - Date.now();
58
+ if (delay <= 0) {
59
+ return;
60
+ }
61
+ job.timeout = setTimeout(async () => {
62
+ await this.executeJob(config.id);
63
+ this.removeSchedule(config.id);
64
+ }, delay);
65
+ return;
66
+ }
67
+ if (config.cronExpression) {
68
+ job.task = cron.schedule(config.cronExpression, async () => {
69
+ await this.executeJob(config.id);
70
+ }, {
71
+ timezone: this.timezone,
72
+ });
73
+ if (!config.active) {
74
+ job.task.stop();
75
+ }
76
+ }
77
+ }
78
+ toPersistedSchedule(config) {
79
+ return {
80
+ id: config.id,
81
+ cronExpression: config.cronExpression,
82
+ oneTime: config.oneTime,
83
+ runAt: config.runAt ? config.runAt.toISOString() : undefined,
84
+ message: config.message,
85
+ description: config.description,
86
+ createdAt: config.createdAt.toISOString(),
87
+ lastRun: config.lastRun ? config.lastRun.toISOString() : undefined,
88
+ active: config.active,
89
+ };
90
+ }
91
+ parsePersistedSchedule(config) {
92
+ return {
93
+ id: config.id,
94
+ cronExpression: config.cronExpression,
95
+ oneTime: config.oneTime,
96
+ runAt: config.runAt ? new Date(config.runAt) : undefined,
97
+ message: config.message,
98
+ description: config.description,
99
+ createdAt: new Date(config.createdAt),
100
+ lastRun: config.lastRun ? new Date(config.lastRun) : undefined,
101
+ active: config.active,
102
+ };
103
+ }
104
+ persistSchedules() {
105
+ if (!this.persistenceFilePath) {
106
+ return;
107
+ }
108
+ try {
109
+ fs.mkdirSync(path.dirname(this.persistenceFilePath), { recursive: true });
110
+ const schedules = Array.from(this.jobs.values()).map((job) => this.toPersistedSchedule(job.config));
111
+ fs.writeFileSync(this.persistenceFilePath, `${JSON.stringify({ schedules }, null, 2)}\n`, 'utf8');
112
+ }
113
+ catch (error) {
114
+ this.logInfo('Failed to persist schedules', {
115
+ persistenceFilePath: this.persistenceFilePath,
116
+ error: getErrorMessage(error),
117
+ });
118
+ }
119
+ }
120
+ loadPersistedSchedules() {
121
+ if (!this.persistenceFilePath) {
122
+ return;
123
+ }
124
+ try {
125
+ if (!fs.existsSync(this.persistenceFilePath)) {
126
+ return;
127
+ }
128
+ const raw = fs.readFileSync(this.persistenceFilePath, 'utf8');
129
+ const parsed = JSON.parse(raw);
130
+ const schedules = Array.isArray(parsed?.schedules) ? parsed.schedules : [];
131
+ const now = new Date();
132
+ for (const persisted of schedules) {
133
+ const config = this.parsePersistedSchedule(persisted);
134
+ // Drop one-time schedules that are already past due on restart
135
+ if (config.oneTime && config.runAt && config.runAt <= now) {
136
+ continue;
137
+ }
138
+ if (!config.oneTime && (!config.cronExpression || !cron.validate(config.cronExpression))) {
139
+ this.logInfo('Skipping invalid persisted recurring schedule', { scheduleId: config.id });
140
+ continue;
141
+ }
142
+ const job = { config };
143
+ this.configureRuntimeForJob(job);
144
+ this.jobs.set(config.id, job);
145
+ }
146
+ this.logInfo('Loaded persisted schedules', {
147
+ count: this.jobs.size,
148
+ persistenceFilePath: this.persistenceFilePath,
149
+ });
150
+ // Clean up persisted file if we dropped stale entries
151
+ this.persistSchedules();
152
+ }
153
+ catch (error) {
154
+ this.logInfo('Failed to load persisted schedules', {
155
+ persistenceFilePath: this.persistenceFilePath,
156
+ error: getErrorMessage(error),
157
+ });
158
+ }
159
+ }
160
+ /**
161
+ * Create a new schedule
162
+ */
163
+ createSchedule(config) {
164
+ const scheduleConfig = {
165
+ ...config,
166
+ id: this.generateId(),
167
+ createdAt: new Date(),
168
+ active: true,
169
+ };
170
+ this.validateScheduleConfig(scheduleConfig);
171
+ const job = {
172
+ config: scheduleConfig,
173
+ };
174
+ this.configureRuntimeForJob(job);
175
+ this.jobs.set(scheduleConfig.id, job);
176
+ this.persistSchedules();
177
+ return scheduleConfig;
178
+ }
179
+ /**
180
+ * Execute a scheduled job
181
+ */
182
+ async executeJob(scheduleId) {
183
+ const job = this.jobs.get(scheduleId);
184
+ if (!job || !job.config.active) {
185
+ return;
186
+ }
187
+ // Skip if job is already in flight (prevents overlapping executions)
188
+ if (job.inFlight) {
189
+ console.warn(`[CronScheduler] Job ${scheduleId} skipped - previous execution still in progress`);
190
+ return;
191
+ }
192
+ job.inFlight = true;
193
+ job.config.lastRun = new Date();
194
+ this.persistSchedules();
195
+ try {
196
+ await this.jobCallback(job.config);
197
+ }
198
+ catch (error) {
199
+ console.error(`[CronScheduler] Job ${scheduleId} execution failed: ${getErrorMessage(error)}`);
200
+ }
201
+ finally {
202
+ job.inFlight = false;
203
+ }
204
+ }
205
+ /**
206
+ * Get a schedule by ID
207
+ */
208
+ getSchedule(scheduleId) {
209
+ const job = this.jobs.get(scheduleId);
210
+ return job ? job.config : null;
211
+ }
212
+ /**
213
+ * List all schedules
214
+ */
215
+ listSchedules() {
216
+ return Array.from(this.jobs.values()).map(job => job.config);
217
+ }
218
+ /**
219
+ * Remove a schedule
220
+ */
221
+ removeSchedule(scheduleId) {
222
+ const job = this.jobs.get(scheduleId);
223
+ if (!job) {
224
+ return false;
225
+ }
226
+ // Stop the cron task if it exists
227
+ if (job.task) {
228
+ job.task.stop();
229
+ }
230
+ // Clear timeout if it exists (for one-time jobs)
231
+ if (job.timeout) {
232
+ clearTimeout(job.timeout);
233
+ }
234
+ this.jobs.delete(scheduleId);
235
+ this.persistSchedules();
236
+ return true;
237
+ }
238
+ /**
239
+ * Pause/resume a schedule
240
+ */
241
+ toggleSchedule(scheduleId, active) {
242
+ const job = this.jobs.get(scheduleId);
243
+ if (!job) {
244
+ return false;
245
+ }
246
+ job.config.active = active;
247
+ if (job.task) {
248
+ if (active) {
249
+ job.task.start();
250
+ }
251
+ else {
252
+ job.task.stop();
253
+ }
254
+ }
255
+ this.persistSchedules();
256
+ return true;
257
+ }
258
+ /**
259
+ * Generate a unique ID for a schedule
260
+ */
261
+ generateId() {
262
+ return `schedule_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
263
+ }
264
+ /**
265
+ * Shutdown all scheduled jobs
266
+ */
267
+ shutdown() {
268
+ for (const [scheduleId] of this.jobs) {
269
+ this.removeSchedule(scheduleId);
270
+ }
271
+ }
272
+ }
@@ -0,0 +1,34 @@
1
+ import { getErrorMessage } from '../utils/error.js';
2
+ export function createScheduledJobHandler(deps) {
3
+ const { logInfo, buildPromptWithMemory, runScheduledPromptWithTempAcp, resolveTargetChatId, sendTextToChat, normalizeOutgoingText, } = deps;
4
+ return async function handleScheduledJob(schedule) {
5
+ logInfo('Executing scheduled job', { scheduleId: schedule.id, message: schedule.message });
6
+ try {
7
+ const promptForGemini = buildPromptWithMemory(schedule.message);
8
+ logInfo('Scheduler prompt payload sent to Gemini', {
9
+ scheduleId: schedule.id,
10
+ prompt: promptForGemini,
11
+ });
12
+ const response = await runScheduledPromptWithTempAcp(promptForGemini, schedule.id);
13
+ const targetChatId = resolveTargetChatId();
14
+ if (targetChatId) {
15
+ await sendTextToChat(targetChatId, normalizeOutgoingText(response));
16
+ logInfo('Scheduled job result sent to Telegram', { scheduleId: schedule.id, chatId: targetChatId });
17
+ }
18
+ else {
19
+ logInfo('No target chat available for scheduled job result', { scheduleId: schedule.id });
20
+ }
21
+ }
22
+ catch (error) {
23
+ logInfo('Scheduled job execution failed', {
24
+ scheduleId: schedule.id,
25
+ error: getErrorMessage(error),
26
+ });
27
+ const targetChatId = resolveTargetChatId();
28
+ if (targetChatId) {
29
+ const errorMessage = `❌ Scheduled task failed: ${schedule.description || schedule.message}\n\nError: ${getErrorMessage(error)}`;
30
+ await sendTextToChat(targetChatId, normalizeOutgoingText(errorMessage));
31
+ }
32
+ }
33
+ };
34
+ }
@@ -0,0 +1,12 @@
1
+ export function getErrorMessage(error, fallback = 'Unknown error') {
2
+ if (error instanceof Error) {
3
+ return error.message;
4
+ }
5
+ if (typeof error === 'string') {
6
+ return error;
7
+ }
8
+ if (error === null || error === undefined) {
9
+ return fallback;
10
+ }
11
+ return String(error);
12
+ }
@@ -0,0 +1,128 @@
1
+ import { marked } from 'marked';
2
+ function safeHref(href) {
3
+ const value = String(href || '').trim();
4
+ return /^https?:\/\//i.test(value) || /^mailto:/i.test(value) ? value : '';
5
+ }
6
+ function stripAnsiAndControl(text) {
7
+ return String(text || '')
8
+ .replace(/\u001b\[[0-9;]*m/g, '')
9
+ .replace(/\u001b\][^\u0007]*\u0007/g, '')
10
+ .replace(/\r/g, '')
11
+ .replace(/[\u200B-\u200D\uFEFF]/g, '');
12
+ }
13
+ function stripHtmlTags(text) {
14
+ return text.replace(/<[^>]+>/g, '');
15
+ }
16
+ function normalizeWhitespace(text) {
17
+ return String(text || '').replace(/\n{3,}/g, '\n\n').trim();
18
+ }
19
+ function simplifyHtmlForTelegram(markdown) {
20
+ let html = marked.parse(markdown, {
21
+ async: false,
22
+ gfm: true,
23
+ breaks: true,
24
+ });
25
+ html = html
26
+ .replace(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi, '<b>$1</b>\n\n')
27
+ .replace(/<p>([\s\S]*?)<\/p>/gi, '$1\n\n')
28
+ .replace(/<strong>([\s\S]*?)<\/strong>/gi, '<b>$1</b>')
29
+ .replace(/<em>([\s\S]*?)<\/em>/gi, '<i>$1</i>')
30
+ .replace(/<del>([\s\S]*?)<\/del>/gi, '<s>$1</s>')
31
+ .replace(/<li>([\s\S]*?)<\/li>/gi, '• $1\n')
32
+ .replace(/<\/?(ul|ol)>/gi, '')
33
+ .replace(/<br\s*\/?>/gi, '\n')
34
+ .replace(/<hr\s*\/?>/gi, '────────\n\n')
35
+ .replace(/<pre><code[^>]*>/gi, '<pre><code>')
36
+ .replace(/<code[^>]*>/gi, '<code>');
37
+ html = html.replace(/<a\s+href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_m, href, label) => {
38
+ const safe = safeHref(href);
39
+ return safe ? `<a href="${safe}">${label}</a>` : label;
40
+ });
41
+ const allowed = new Set(['b', 'i', 's', 'code', 'pre', 'a', 'blockquote']);
42
+ html = html.replace(/<\/?([a-z0-9]+)(\s[^>]*)?>/gi, (full, tag) => {
43
+ const name = String(tag || '').toLowerCase();
44
+ if (!allowed.has(name)) {
45
+ return '';
46
+ }
47
+ if (name === 'a') {
48
+ if (full.startsWith('</')) {
49
+ return '</a>';
50
+ }
51
+ const hrefMatch = full.match(/href="([^"]+)"/i);
52
+ const safe = safeHref(hrefMatch?.[1] || '');
53
+ return safe ? `<a href="${safe}">` : '';
54
+ }
55
+ return full.startsWith('</') ? `</${name}>` : `<${name}>`;
56
+ });
57
+ return normalizeWhitespace(html);
58
+ }
59
+ function splitPlainText(text, maxLength) {
60
+ const value = String(text || '');
61
+ if (!value) {
62
+ return [''];
63
+ }
64
+ if (value.length <= maxLength) {
65
+ return [value];
66
+ }
67
+ const chunks = [];
68
+ let start = 0;
69
+ while (start < value.length) {
70
+ const end = Math.min(start + maxLength, value.length);
71
+ chunks.push(value.slice(start, end));
72
+ start = end;
73
+ }
74
+ return chunks;
75
+ }
76
+ export function formatMarkdownForTelegram(markdown, maxMessageLength) {
77
+ const cleaned = stripAnsiAndControl(markdown);
78
+ const rendered = simplifyHtmlForTelegram(cleaned);
79
+ const sections = rendered
80
+ .split(/\n\n+/)
81
+ .map((section) => section.trim())
82
+ .filter(Boolean);
83
+ if (sections.length === 0) {
84
+ return {
85
+ chunks: ['No response received.'],
86
+ parseMode: 'HTML',
87
+ };
88
+ }
89
+ const chunks = [];
90
+ let current = '';
91
+ const appendCurrent = () => {
92
+ if (current.trim()) {
93
+ chunks.push(current.trim());
94
+ current = '';
95
+ }
96
+ };
97
+ for (const section of sections) {
98
+ const candidate = current ? `${current}\n\n${section}` : section;
99
+ if (candidate.length <= maxMessageLength) {
100
+ current = candidate;
101
+ continue;
102
+ }
103
+ appendCurrent();
104
+ if (section.length <= maxMessageLength) {
105
+ current = section;
106
+ continue;
107
+ }
108
+ const plainParts = splitPlainText(stripHtmlTags(section), maxMessageLength);
109
+ chunks.push(...plainParts);
110
+ }
111
+ appendCurrent();
112
+ return {
113
+ chunks: chunks.length > 0 ? chunks : ['No response received.'],
114
+ parseMode: 'HTML',
115
+ };
116
+ }
117
+ export function formatPreviewText(text, maxLength) {
118
+ const cleaned = normalizeWhitespace(stripAnsiAndControl(text));
119
+ if (!cleaned) {
120
+ return '';
121
+ }
122
+ const preview = cleaned.length <= maxLength ? cleaned : `${cleaned.slice(0, maxLength - 1)}…`;
123
+ return preview
124
+ .replace(/^#{1,6}\s+/gm, '')
125
+ .replace(/\*\*(.*?)\*\*/g, '$1')
126
+ .replace(/__(.*?)__/g, '$1')
127
+ .replace(/`([^`]+)`/g, '$1');
128
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "apps": [
3
+ {
4
+ "name": "clawless",
5
+ "script": "clawless",
6
+ "interpreter": "none",
7
+ "instances": 1,
8
+ "exec_mode": "fork",
9
+ "watch": false,
10
+ "autorestart": true,
11
+ "max_restarts": 10,
12
+ "min_uptime": "10s",
13
+ "max_memory_restart": "500M",
14
+ "env": {
15
+ "NODE_ENV": "production"
16
+ },
17
+ "error_file": "./logs/err.log",
18
+ "out_file": "./logs/out.log",
19
+ "log_date_format": "YYYY-MM-DD HH:mm:ss Z",
20
+ "merge_logs": true
21
+ }
22
+ ]
23
+ }