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,132 @@
1
+ import { Telegraf } from 'telegraf';
2
+
3
+ function splitTextIntoChunks(text: string, maxMessageLength: number): string[] {
4
+ const normalizedText = String(text || '');
5
+ if (!normalizedText) {
6
+ return [''];
7
+ }
8
+
9
+ if (normalizedText.length <= maxMessageLength) {
10
+ return [normalizedText];
11
+ }
12
+
13
+ const chunks: string[] = [];
14
+ let start = 0;
15
+
16
+ while (start < normalizedText.length) {
17
+ const end = Math.min(start + maxMessageLength, normalizedText.length);
18
+ chunks.push(normalizedText.slice(start, end));
19
+ start = end;
20
+ }
21
+
22
+ return chunks;
23
+ }
24
+
25
+ class TelegramMessageContext {
26
+ ctx: any;
27
+ typingIntervalMs: number;
28
+ maxMessageLength: number;
29
+ text: string;
30
+ chatId: string | number | undefined;
31
+
32
+ constructor(ctx: any, typingIntervalMs: number, maxMessageLength: number) {
33
+ this.ctx = ctx;
34
+ this.typingIntervalMs = typingIntervalMs;
35
+ this.maxMessageLength = maxMessageLength;
36
+ this.text = ctx.message?.text || '';
37
+ this.chatId = ctx.chat?.id;
38
+ }
39
+
40
+ startTyping() {
41
+ this.ctx.telegram.sendChatAction(this.ctx.chat.id, 'typing').catch(() => {});
42
+
43
+ const intervalId = setInterval(() => {
44
+ this.ctx.telegram.sendChatAction(this.ctx.chat.id, 'typing').catch(() => {});
45
+ }, this.typingIntervalMs);
46
+
47
+ return () => clearInterval(intervalId);
48
+ }
49
+
50
+ async sendText(text: string) {
51
+ const chunks = splitTextIntoChunks(text, this.maxMessageLength);
52
+ for (const chunk of chunks) {
53
+ await this.ctx.reply(chunk);
54
+ }
55
+ }
56
+
57
+ async startLiveMessage(initialText = '…') {
58
+ const sent = await this.ctx.reply(initialText);
59
+ return sent?.message_id as number | undefined;
60
+ }
61
+
62
+ async updateLiveMessage(messageId: number, text: string) {
63
+ await this.ctx.telegram.editMessageText(this.ctx.chat.id, messageId, undefined, text || '…');
64
+ }
65
+
66
+ async finalizeLiveMessage(messageId: number, text: string) {
67
+ const finalText = text || 'No response received.';
68
+ const chunks = splitTextIntoChunks(finalText, this.maxMessageLength);
69
+
70
+ try {
71
+ await this.updateLiveMessage(messageId, chunks[0] || 'No response received.');
72
+ } catch (error: any) {
73
+ const errorMessage = String(error?.message || '').toLowerCase();
74
+ if (!errorMessage.includes('message is not modified')) {
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ for (let index = 1; index < chunks.length; index += 1) {
80
+ await this.ctx.reply(chunks[index]);
81
+ }
82
+ }
83
+
84
+ async removeMessage(messageId: number) {
85
+ await this.ctx.telegram.deleteMessage(this.ctx.chat.id, messageId);
86
+ }
87
+ }
88
+
89
+ export class TelegramMessagingClient {
90
+ bot: Telegraf;
91
+ typingIntervalMs: number;
92
+ maxMessageLength: number;
93
+
94
+ constructor({ token, typingIntervalMs, maxMessageLength }: { token: string; typingIntervalMs: number; maxMessageLength: number }) {
95
+ this.bot = new Telegraf(token);
96
+ this.typingIntervalMs = typingIntervalMs;
97
+ this.maxMessageLength = maxMessageLength;
98
+ }
99
+
100
+ onTextMessage(handler: (messageContext: TelegramMessageContext) => Promise<void> | void) {
101
+ this.bot.on('text', (ctx) => {
102
+ const messageContext = new TelegramMessageContext(ctx, this.typingIntervalMs, this.maxMessageLength);
103
+ Promise.resolve(handler(messageContext)).catch((error) => {
104
+ console.error('Text message handler failed:', error);
105
+ });
106
+ });
107
+ }
108
+
109
+ onError(handler: (error: Error, messageContext: TelegramMessageContext | null) => void) {
110
+ this.bot.catch((error, ctx) => {
111
+ const messageContext = ctx?.chat
112
+ ? new TelegramMessageContext(ctx, this.typingIntervalMs, this.maxMessageLength)
113
+ : null;
114
+ handler(error as Error, messageContext);
115
+ });
116
+ }
117
+
118
+ async launch() {
119
+ await this.bot.launch();
120
+ }
121
+
122
+ async sendTextToChat(chatId: string | number, text: string) {
123
+ const chunks = splitTextIntoChunks(text, this.maxMessageLength);
124
+ for (const chunk of chunks) {
125
+ await this.bot.telegram.sendMessage(chatId, chunk);
126
+ }
127
+ }
128
+
129
+ stop(reason: string) {
130
+ this.bot.stop(reason);
131
+ }
132
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "clawless",
3
+ "version": "0.1.2",
4
+ "description": "A bridge connecting Telegram to Agent Gemini CLI using Agent Communication Protocol (ACP)",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "clawless": "./dist/bin/cli.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json",
12
+ "dev": "tsx watch ./bin/cli.ts",
13
+ "start": "tsx ./bin/cli.ts",
14
+ "cli": "tsx ./bin/cli.ts",
15
+ "callback:health": "./scripts/callback-health.sh",
16
+ "callback:test": "./scripts/callback-post.sh",
17
+ "callback:test:chat": "./scripts/callback-post-chat.sh"
18
+ },
19
+ "keywords": [
20
+ "telegram",
21
+ "gemini",
22
+ "acp",
23
+ "agent",
24
+ "ai"
25
+ ],
26
+ "author": "Hainan Zhao",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@agentclientprotocol/sdk": "^0.14.1",
30
+ "dotenv": "^16.4.5",
31
+ "node-cron": "^4.2.1",
32
+ "telegraf": "^4.16.3"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^24.5.2",
36
+ "@types/node-cron": "^3.0.11",
37
+ "tsx": "^4.21.0",
38
+ "typescript": "^5.9.2"
39
+ },
40
+ "engines": {
41
+ "node": ">=18.0.0"
42
+ }
43
+ }
@@ -0,0 +1,355 @@
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
+
6
+ export interface ScheduleConfig {
7
+ id: string;
8
+ cronExpression?: string;
9
+ oneTime?: boolean;
10
+ runAt?: Date;
11
+ message: string;
12
+ description?: string;
13
+ createdAt: Date;
14
+ lastRun?: Date;
15
+ active: boolean;
16
+ }
17
+
18
+ export interface ScheduleJob {
19
+ config: ScheduleConfig;
20
+ task?: cron.ScheduledTask;
21
+ timeout?: NodeJS.Timeout;
22
+ inFlight?: boolean;
23
+ }
24
+
25
+ interface PersistedScheduleConfig {
26
+ id: string;
27
+ cronExpression?: string;
28
+ oneTime?: boolean;
29
+ runAt?: string;
30
+ message: string;
31
+ description?: string;
32
+ createdAt: string;
33
+ lastRun?: string;
34
+ active: boolean;
35
+ }
36
+
37
+ export interface CronSchedulerOptions {
38
+ persistenceFilePath?: string;
39
+ timezone?: string;
40
+ logInfo?: (message: string, details?: unknown) => void;
41
+ }
42
+
43
+ export class CronScheduler {
44
+ private jobs: Map<string, ScheduleJob> = new Map();
45
+ private jobCallback: (schedule: ScheduleConfig) => Promise<void>;
46
+ private persistenceFilePath: string | null;
47
+ private timezone: string;
48
+ private logInfo: (message: string, details?: unknown) => void;
49
+
50
+ constructor(callback: (schedule: ScheduleConfig) => Promise<void>, options: CronSchedulerOptions = {}) {
51
+ this.jobCallback = callback;
52
+ this.persistenceFilePath = options.persistenceFilePath || null;
53
+ this.timezone = options.timezone || process.env.TZ || 'UTC';
54
+ this.logInfo = options.logInfo || ((message: string, details?: unknown) => {
55
+ if (details !== undefined) {
56
+ console.log(`[CronScheduler] ${message}`, details);
57
+ } else {
58
+ console.log(`[CronScheduler] ${message}`);
59
+ }
60
+ });
61
+
62
+ this.loadPersistedSchedules();
63
+ }
64
+
65
+ private validateScheduleConfig(config: ScheduleConfig): void {
66
+ if (config.oneTime) {
67
+ if (!config.runAt) {
68
+ throw new Error('One-time schedules require a runAt date');
69
+ }
70
+ if (config.runAt <= new Date()) {
71
+ throw new Error('One-time schedule runAt must be in the future');
72
+ }
73
+ return;
74
+ }
75
+
76
+ if (!config.cronExpression) {
77
+ throw new Error('Recurring schedules require a cronExpression');
78
+ }
79
+
80
+ if (!cron.validate(config.cronExpression)) {
81
+ throw new Error('Invalid cron expression');
82
+ }
83
+ }
84
+
85
+ private configureRuntimeForJob(job: ScheduleJob): void {
86
+ // Clean existing runtime handles first (used when toggling/reloading)
87
+ if (job.task) {
88
+ job.task.stop();
89
+ job.task = undefined;
90
+ }
91
+ if (job.timeout) {
92
+ clearTimeout(job.timeout);
93
+ job.timeout = undefined;
94
+ }
95
+
96
+ const { config } = job;
97
+
98
+ if (config.oneTime && config.runAt) {
99
+ if (!config.active) {
100
+ return;
101
+ }
102
+
103
+ const delay = config.runAt.getTime() - Date.now();
104
+ if (delay <= 0) {
105
+ return;
106
+ }
107
+
108
+ job.timeout = setTimeout(async () => {
109
+ await this.executeJob(config.id);
110
+ this.removeSchedule(config.id);
111
+ }, delay);
112
+ return;
113
+ }
114
+
115
+ if (config.cronExpression) {
116
+ job.task = cron.schedule(
117
+ config.cronExpression,
118
+ async () => {
119
+ await this.executeJob(config.id);
120
+ },
121
+ {
122
+ timezone: this.timezone,
123
+ }
124
+ );
125
+
126
+ if (!config.active) {
127
+ job.task.stop();
128
+ }
129
+ }
130
+ }
131
+
132
+ private toPersistedSchedule(config: ScheduleConfig): PersistedScheduleConfig {
133
+ return {
134
+ id: config.id,
135
+ cronExpression: config.cronExpression,
136
+ oneTime: config.oneTime,
137
+ runAt: config.runAt ? config.runAt.toISOString() : undefined,
138
+ message: config.message,
139
+ description: config.description,
140
+ createdAt: config.createdAt.toISOString(),
141
+ lastRun: config.lastRun ? config.lastRun.toISOString() : undefined,
142
+ active: config.active,
143
+ };
144
+ }
145
+
146
+ private parsePersistedSchedule(config: PersistedScheduleConfig): ScheduleConfig {
147
+ return {
148
+ id: config.id,
149
+ cronExpression: config.cronExpression,
150
+ oneTime: config.oneTime,
151
+ runAt: config.runAt ? new Date(config.runAt) : undefined,
152
+ message: config.message,
153
+ description: config.description,
154
+ createdAt: new Date(config.createdAt),
155
+ lastRun: config.lastRun ? new Date(config.lastRun) : undefined,
156
+ active: config.active,
157
+ };
158
+ }
159
+
160
+ private persistSchedules(): void {
161
+ if (!this.persistenceFilePath) {
162
+ return;
163
+ }
164
+
165
+ try {
166
+ fs.mkdirSync(path.dirname(this.persistenceFilePath), { recursive: true });
167
+ const schedules = Array.from(this.jobs.values()).map((job) => this.toPersistedSchedule(job.config));
168
+ fs.writeFileSync(this.persistenceFilePath, `${JSON.stringify({ schedules }, null, 2)}\n`, 'utf8');
169
+ } catch (error) {
170
+ this.logInfo('Failed to persist schedules', {
171
+ persistenceFilePath: this.persistenceFilePath,
172
+ error: getErrorMessage(error),
173
+ });
174
+ }
175
+ }
176
+
177
+ private loadPersistedSchedules(): void {
178
+ if (!this.persistenceFilePath) {
179
+ return;
180
+ }
181
+
182
+ try {
183
+ if (!fs.existsSync(this.persistenceFilePath)) {
184
+ return;
185
+ }
186
+
187
+ const raw = fs.readFileSync(this.persistenceFilePath, 'utf8');
188
+ const parsed = JSON.parse(raw) as { schedules?: PersistedScheduleConfig[] };
189
+ const schedules = Array.isArray(parsed?.schedules) ? parsed.schedules : [];
190
+ const now = new Date();
191
+
192
+ for (const persisted of schedules) {
193
+ const config = this.parsePersistedSchedule(persisted);
194
+
195
+ // Drop one-time schedules that are already past due on restart
196
+ if (config.oneTime && config.runAt && config.runAt <= now) {
197
+ continue;
198
+ }
199
+
200
+ if (!config.oneTime && (!config.cronExpression || !cron.validate(config.cronExpression))) {
201
+ this.logInfo('Skipping invalid persisted recurring schedule', { scheduleId: config.id });
202
+ continue;
203
+ }
204
+
205
+ const job: ScheduleJob = { config };
206
+ this.configureRuntimeForJob(job);
207
+ this.jobs.set(config.id, job);
208
+ }
209
+
210
+ this.logInfo('Loaded persisted schedules', {
211
+ count: this.jobs.size,
212
+ persistenceFilePath: this.persistenceFilePath,
213
+ });
214
+
215
+ // Clean up persisted file if we dropped stale entries
216
+ this.persistSchedules();
217
+ } catch (error) {
218
+ this.logInfo('Failed to load persisted schedules', {
219
+ persistenceFilePath: this.persistenceFilePath,
220
+ error: getErrorMessage(error),
221
+ });
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Create a new schedule
227
+ */
228
+ createSchedule(config: Omit<ScheduleConfig, 'id' | 'createdAt' | 'active'>): ScheduleConfig {
229
+ const scheduleConfig: ScheduleConfig = {
230
+ ...config,
231
+ id: this.generateId(),
232
+ createdAt: new Date(),
233
+ active: true,
234
+ };
235
+
236
+ this.validateScheduleConfig(scheduleConfig);
237
+
238
+ const job: ScheduleJob = {
239
+ config: scheduleConfig,
240
+ };
241
+
242
+ this.configureRuntimeForJob(job);
243
+
244
+ this.jobs.set(scheduleConfig.id, job);
245
+ this.persistSchedules();
246
+ return scheduleConfig;
247
+ }
248
+
249
+ /**
250
+ * Execute a scheduled job
251
+ */
252
+ private async executeJob(scheduleId: string): Promise<void> {
253
+ const job = this.jobs.get(scheduleId);
254
+ if (!job || !job.config.active) {
255
+ return;
256
+ }
257
+
258
+ // Skip if job is already in flight (prevents overlapping executions)
259
+ if (job.inFlight) {
260
+ console.warn(`[CronScheduler] Job ${scheduleId} skipped - previous execution still in progress`);
261
+ return;
262
+ }
263
+
264
+ job.inFlight = true;
265
+ job.config.lastRun = new Date();
266
+ this.persistSchedules();
267
+
268
+ try {
269
+ await this.jobCallback(job.config);
270
+ } catch (error: any) {
271
+ console.error(`[CronScheduler] Job ${scheduleId} execution failed: ${getErrorMessage(error)}`);
272
+ } finally {
273
+ job.inFlight = false;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Get a schedule by ID
279
+ */
280
+ getSchedule(scheduleId: string): ScheduleConfig | null {
281
+ const job = this.jobs.get(scheduleId);
282
+ return job ? job.config : null;
283
+ }
284
+
285
+ /**
286
+ * List all schedules
287
+ */
288
+ listSchedules(): ScheduleConfig[] {
289
+ return Array.from(this.jobs.values()).map(job => job.config);
290
+ }
291
+
292
+ /**
293
+ * Remove a schedule
294
+ */
295
+ removeSchedule(scheduleId: string): boolean {
296
+ const job = this.jobs.get(scheduleId);
297
+ if (!job) {
298
+ return false;
299
+ }
300
+
301
+ // Stop the cron task if it exists
302
+ if (job.task) {
303
+ job.task.stop();
304
+ }
305
+
306
+ // Clear timeout if it exists (for one-time jobs)
307
+ if (job.timeout) {
308
+ clearTimeout(job.timeout);
309
+ }
310
+
311
+ this.jobs.delete(scheduleId);
312
+ this.persistSchedules();
313
+ return true;
314
+ }
315
+
316
+ /**
317
+ * Pause/resume a schedule
318
+ */
319
+ toggleSchedule(scheduleId: string, active: boolean): boolean {
320
+ const job = this.jobs.get(scheduleId);
321
+ if (!job) {
322
+ return false;
323
+ }
324
+
325
+ job.config.active = active;
326
+
327
+ if (job.task) {
328
+ if (active) {
329
+ job.task.start();
330
+ } else {
331
+ job.task.stop();
332
+ }
333
+ }
334
+
335
+ this.persistSchedules();
336
+
337
+ return true;
338
+ }
339
+
340
+ /**
341
+ * Generate a unique ID for a schedule
342
+ */
343
+ private generateId(): string {
344
+ return `schedule_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
345
+ }
346
+
347
+ /**
348
+ * Shutdown all scheduled jobs
349
+ */
350
+ shutdown(): void {
351
+ for (const [scheduleId] of this.jobs) {
352
+ this.removeSchedule(scheduleId);
353
+ }
354
+ }
355
+ }
@@ -0,0 +1,55 @@
1
+ import { ScheduleConfig } from './cronScheduler.js';
2
+ import { getErrorMessage } from '../utils/error.js';
3
+
4
+ export interface ScheduledJobHandlerDeps {
5
+ logInfo: (message: string, details?: unknown) => void;
6
+ buildPromptWithMemory: (userPrompt: string) => string;
7
+ runScheduledPromptWithTempAcp: (promptForGemini: string, scheduleId: string) => Promise<string>;
8
+ resolveTargetChatId: () => string | null;
9
+ sendTextToChat: (chatId: string | number, text: string) => Promise<void>;
10
+ normalizeOutgoingText: (text: unknown) => string;
11
+ }
12
+
13
+ export function createScheduledJobHandler(deps: ScheduledJobHandlerDeps) {
14
+ const {
15
+ logInfo,
16
+ buildPromptWithMemory,
17
+ runScheduledPromptWithTempAcp,
18
+ resolveTargetChatId,
19
+ sendTextToChat,
20
+ normalizeOutgoingText,
21
+ } = deps;
22
+
23
+ return async function handleScheduledJob(schedule: ScheduleConfig): Promise<void> {
24
+ logInfo('Executing scheduled job', { scheduleId: schedule.id, message: schedule.message });
25
+
26
+ try {
27
+ const promptForGemini = buildPromptWithMemory(schedule.message);
28
+ logInfo('Scheduler prompt payload sent to Gemini', {
29
+ scheduleId: schedule.id,
30
+ prompt: promptForGemini,
31
+ });
32
+
33
+ const response = await runScheduledPromptWithTempAcp(promptForGemini, schedule.id);
34
+
35
+ const targetChatId = resolveTargetChatId();
36
+ if (targetChatId) {
37
+ await sendTextToChat(targetChatId, normalizeOutgoingText(response));
38
+ logInfo('Scheduled job result sent to Telegram', { scheduleId: schedule.id, chatId: targetChatId });
39
+ } else {
40
+ logInfo('No target chat available for scheduled job result', { scheduleId: schedule.id });
41
+ }
42
+ } catch (error: any) {
43
+ logInfo('Scheduled job execution failed', {
44
+ scheduleId: schedule.id,
45
+ error: getErrorMessage(error),
46
+ });
47
+
48
+ const targetChatId = resolveTargetChatId();
49
+ if (targetChatId) {
50
+ const errorMessage = `❌ Scheduled task failed: ${schedule.description || schedule.message}\n\nError: ${getErrorMessage(error)}`;
51
+ await sendTextToChat(targetChatId, normalizeOutgoingText(errorMessage));
52
+ }
53
+ }
54
+ };
55
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ BASE_URL="${CALLBACK_BASE_URL:-http://127.0.0.1:8788}"
5
+ curl -sS "$BASE_URL/healthz"
6
+ echo
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [[ $# -lt 1 ]]; then
5
+ echo "Usage: $0 <chatId> [message]" >&2
6
+ exit 1
7
+ fi
8
+
9
+ CHAT_ID="$1"
10
+ MESSAGE="${2:-Callback test to explicit chat from clawless}"
11
+ BASE_URL="${CALLBACK_BASE_URL:-http://127.0.0.1:8788}"
12
+ ENDPOINT="$BASE_URL/callback/telegram"
13
+
14
+ if [[ -n "${CALLBACK_AUTH_TOKEN:-}" ]]; then
15
+ curl -sS -X POST "$ENDPOINT" \
16
+ -H "Content-Type: application/json" \
17
+ -H "x-callback-token: ${CALLBACK_AUTH_TOKEN}" \
18
+ -d "{\"chatId\":\"${CHAT_ID//\"/\\\"}\",\"text\":\"${MESSAGE//\"/\\\"}\"}"
19
+ else
20
+ curl -sS -X POST "$ENDPOINT" \
21
+ -H "Content-Type: application/json" \
22
+ -d "{\"chatId\":\"${CHAT_ID//\"/\\\"}\",\"text\":\"${MESSAGE//\"/\\\"}\"}"
23
+ fi
24
+
25
+ echo
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ BASE_URL="${CALLBACK_BASE_URL:-http://127.0.0.1:8788}"
5
+ ENDPOINT="$BASE_URL/callback/telegram"
6
+ MESSAGE="${1:-Callback test from clawless}"
7
+
8
+ if [[ -n "${CALLBACK_AUTH_TOKEN:-}" ]]; then
9
+ curl -sS -X POST "$ENDPOINT" \
10
+ -H "Content-Type: application/json" \
11
+ -H "x-callback-token: ${CALLBACK_AUTH_TOKEN}" \
12
+ -d "{\"text\":\"${MESSAGE//\"/\\\"}\"}"
13
+ else
14
+ curl -sS -X POST "$ENDPOINT" \
15
+ -H "Content-Type: application/json" \
16
+ -d "{\"text\":\"${MESSAGE//\"/\\\"}\"}"
17
+ fi
18
+
19
+ echo