From 83b91b3bf106d36e3260fe0eee35a159302d3fc8 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 8 Mar 2026 22:43:37 +0200 Subject: [PATCH 01/23] skill/telegram: Telegram channel integration Co-Authored-By: Claude Opus 4.6 --- .env.example | 2 +- package-lock.json | 113 ++++- package.json | 1 + src/channels/index.ts | 1 + src/channels/telegram.test.ts | 932 ++++++++++++++++++++++++++++++++++ src/channels/telegram.ts | 257 ++++++++++ 6 files changed, 1300 insertions(+), 6 deletions(-) create mode 100644 src/channels/telegram.test.ts create mode 100644 src/channels/telegram.ts diff --git a/.env.example b/.env.example index 8b13789..b90e6c9 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ - +TELEGRAM_BOT_TOKEN= diff --git a/package-lock.json b/package-lock.json index 4e6b681..6bc4160 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", + "grammy": "^1.39.3", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", @@ -531,6 +532,12 @@ "node": ">=18" } }, + "node_modules/@grammyjs/types": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz", + "integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==", + "license": "MIT" + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1109,6 +1116,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1258,6 +1277,23 @@ "node": "*" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1359,6 +1395,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1454,6 +1499,21 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/grammy": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz", + "integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.25.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1654,6 +1714,12 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1691,6 +1757,26 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1740,7 +1826,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2289,13 +2374,18 @@ "node": ">=14.0.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2355,7 +2445,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2431,7 +2520,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -2504,6 +2592,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2532,7 +2636,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index f4863e4..687b875 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "better-sqlite3": "^11.8.1", + "grammy": "^1.39.3", "cron-parser": "^5.5.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", diff --git a/src/channels/index.ts b/src/channels/index.ts index 44f4f55..48356db 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -8,5 +8,6 @@ // slack // telegram +import './telegram.js'; // whatsapp diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts new file mode 100644 index 0000000..9a97223 --- /dev/null +++ b/src/channels/telegram.test.ts @@ -0,0 +1,932 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// --- Mocks --- + +// Mock registry (registerChannel runs at import time) +vi.mock('./registry.js', () => ({ registerChannel: vi.fn() })); + +// Mock env reader (used by the factory, not needed in unit tests) +vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); + +// Mock config +vi.mock('../config.js', () => ({ + ASSISTANT_NAME: 'Andy', + TRIGGER_PATTERN: /^@Andy\b/i, +})); + +// Mock logger +vi.mock('../logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// --- Grammy mock --- + +type Handler = (...args: any[]) => any; + +const botRef = vi.hoisted(() => ({ current: null as any })); + +vi.mock('grammy', () => ({ + Bot: class MockBot { + token: string; + commandHandlers = new Map(); + filterHandlers = new Map(); + errorHandler: Handler | null = null; + + api = { + sendMessage: vi.fn().mockResolvedValue(undefined), + sendChatAction: vi.fn().mockResolvedValue(undefined), + }; + + constructor(token: string) { + this.token = token; + botRef.current = this; + } + + command(name: string, handler: Handler) { + this.commandHandlers.set(name, handler); + } + + on(filter: string, handler: Handler) { + const existing = this.filterHandlers.get(filter) || []; + existing.push(handler); + this.filterHandlers.set(filter, existing); + } + + catch(handler: Handler) { + this.errorHandler = handler; + } + + start(opts: { onStart: (botInfo: any) => void }) { + opts.onStart({ username: 'andy_ai_bot', id: 12345 }); + } + + stop() {} + }, +})); + +import { TelegramChannel, TelegramChannelOpts } from './telegram.js'; + +// --- Test helpers --- + +function createTestOpts( + overrides?: Partial, +): TelegramChannelOpts { + return { + onMessage: vi.fn(), + onChatMetadata: vi.fn(), + registeredGroups: vi.fn(() => ({ + 'tg:100200300': { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + ...overrides, + }; +} + +function createTextCtx(overrides: { + chatId?: number; + chatType?: string; + chatTitle?: string; + text: string; + fromId?: number; + firstName?: string; + username?: string; + messageId?: number; + date?: number; + entities?: any[]; +}) { + const chatId = overrides.chatId ?? 100200300; + const chatType = overrides.chatType ?? 'group'; + return { + chat: { + id: chatId, + type: chatType, + title: overrides.chatTitle ?? 'Test Group', + }, + from: { + id: overrides.fromId ?? 99001, + first_name: overrides.firstName ?? 'Alice', + username: overrides.username ?? 'alice_user', + }, + message: { + text: overrides.text, + date: overrides.date ?? Math.floor(Date.now() / 1000), + message_id: overrides.messageId ?? 1, + entities: overrides.entities ?? [], + }, + me: { username: 'andy_ai_bot' }, + reply: vi.fn(), + }; +} + +function createMediaCtx(overrides: { + chatId?: number; + chatType?: string; + fromId?: number; + firstName?: string; + date?: number; + messageId?: number; + caption?: string; + extra?: Record; +}) { + const chatId = overrides.chatId ?? 100200300; + return { + chat: { + id: chatId, + type: overrides.chatType ?? 'group', + title: 'Test Group', + }, + from: { + id: overrides.fromId ?? 99001, + first_name: overrides.firstName ?? 'Alice', + username: 'alice_user', + }, + message: { + date: overrides.date ?? Math.floor(Date.now() / 1000), + message_id: overrides.messageId ?? 1, + caption: overrides.caption, + ...(overrides.extra || {}), + }, + me: { username: 'andy_ai_bot' }, + }; +} + +function currentBot() { + return botRef.current; +} + +async function triggerTextMessage(ctx: ReturnType) { + const handlers = currentBot().filterHandlers.get('message:text') || []; + for (const h of handlers) await h(ctx); +} + +async function triggerMediaMessage( + filter: string, + ctx: ReturnType, +) { + const handlers = currentBot().filterHandlers.get(filter) || []; + for (const h of handlers) await h(ctx); +} + +// --- Tests --- + +describe('TelegramChannel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // --- Connection lifecycle --- + + describe('connection lifecycle', () => { + it('resolves connect() when bot starts', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + + expect(channel.isConnected()).toBe(true); + }); + + it('registers command and message handlers on connect', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + + expect(currentBot().commandHandlers.has('chatid')).toBe(true); + expect(currentBot().commandHandlers.has('ping')).toBe(true); + expect(currentBot().filterHandlers.has('message:text')).toBe(true); + expect(currentBot().filterHandlers.has('message:photo')).toBe(true); + expect(currentBot().filterHandlers.has('message:video')).toBe(true); + expect(currentBot().filterHandlers.has('message:voice')).toBe(true); + expect(currentBot().filterHandlers.has('message:audio')).toBe(true); + expect(currentBot().filterHandlers.has('message:document')).toBe(true); + expect(currentBot().filterHandlers.has('message:sticker')).toBe(true); + expect(currentBot().filterHandlers.has('message:location')).toBe(true); + expect(currentBot().filterHandlers.has('message:contact')).toBe(true); + }); + + it('registers error handler on connect', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + + expect(currentBot().errorHandler).not.toBeNull(); + }); + + it('disconnects cleanly', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + await channel.connect(); + expect(channel.isConnected()).toBe(true); + + await channel.disconnect(); + expect(channel.isConnected()).toBe(false); + }); + + it('isConnected() returns false before connect', () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + expect(channel.isConnected()).toBe(false); + }); + }); + + // --- Text message handling --- + + describe('text message handling', () => { + it('delivers message for registered group', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hello everyone' }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:100200300', + expect.any(String), + 'Test Group', + 'telegram', + true, + ); + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + id: '1', + chat_jid: 'tg:100200300', + sender: '99001', + sender_name: 'Alice', + content: 'Hello everyone', + is_from_me: false, + }), + ); + }); + + it('only emits metadata for unregistered chats', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:999999', + expect.any(String), + 'Test Group', + 'telegram', + true, + ); + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('skips command messages (starting with /)', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: '/start' }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).not.toHaveBeenCalled(); + expect(opts.onChatMetadata).not.toHaveBeenCalled(); + }); + + it('extracts sender name from first_name', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ sender_name: 'Bob' }), + ); + }); + + it('falls back to username when first_name missing', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hi' }); + ctx.from.first_name = undefined as any; + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ sender_name: 'alice_user' }), + ); + }); + + it('falls back to user ID when name and username missing', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'Hi', fromId: 42 }); + ctx.from.first_name = undefined as any; + ctx.from.username = undefined as any; + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ sender_name: '42' }), + ); + }); + + it('uses sender name as chat name for private chats', async () => { + const opts = createTestOpts({ + registeredGroups: vi.fn(() => ({ + 'tg:100200300': { + name: 'Private', + folder: 'private', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + }); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'Hello', + chatType: 'private', + firstName: 'Alice', + }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:100200300', + expect.any(String), + 'Alice', // Private chats use sender name + 'telegram', + false, + ); + }); + + it('uses chat title as name for group chats', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'Hello', + chatType: 'supergroup', + chatTitle: 'Project Team', + }); + await triggerTextMessage(ctx); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'tg:100200300', + expect.any(String), + 'Project Team', + 'telegram', + true, + ); + }); + + it('converts message.date to ISO timestamp', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z + const ctx = createTextCtx({ text: 'Hello', date: unixTime }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + timestamp: '2024-01-01T00:00:00.000Z', + }), + ); + }); + }); + + // --- @mention translation --- + + describe('@mention translation', () => { + it('translates @bot_username mention to trigger format', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: '@andy_ai_bot what time is it?', + entities: [{ type: 'mention', offset: 0, length: 12 }], + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@Andy @andy_ai_bot what time is it?', + }), + ); + }); + + it('does not translate if message already matches trigger', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: '@Andy @andy_ai_bot hello', + entities: [{ type: 'mention', offset: 6, length: 12 }], + }); + await triggerTextMessage(ctx); + + // Should NOT double-prepend — already starts with @Andy + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@Andy @andy_ai_bot hello', + }), + ); + }); + + it('does not translate mentions of other bots', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: '@some_other_bot hi', + entities: [{ type: 'mention', offset: 0, length: 15 }], + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@some_other_bot hi', // No translation + }), + ); + }); + + it('handles mention in middle of message', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'hey @andy_ai_bot check this', + entities: [{ type: 'mention', offset: 4, length: 12 }], + }); + await triggerTextMessage(ctx); + + // Bot is mentioned, message doesn't match trigger → prepend trigger + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: '@Andy hey @andy_ai_bot check this', + }), + ); + }); + + it('handles message with no entities', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ text: 'plain message' }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: 'plain message', + }), + ); + }); + + it('ignores non-mention entities', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createTextCtx({ + text: 'check https://example.com', + entities: [{ type: 'url', offset: 6, length: 19 }], + }); + await triggerTextMessage(ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ + content: 'check https://example.com', + }), + ); + }); + }); + + // --- Non-text messages --- + + describe('non-text messages', () => { + it('stores photo with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:photo', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Photo]' }), + ); + }); + + it('stores photo with caption', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ caption: 'Look at this' }); + await triggerMediaMessage('message:photo', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Photo] Look at this' }), + ); + }); + + it('stores video with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:video', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Video]' }), + ); + }); + + it('stores voice message with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:voice', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Voice message]' }), + ); + }); + + it('stores audio with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:audio', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Audio]' }), + ); + }); + + it('stores document with filename', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ + extra: { document: { file_name: 'report.pdf' } }, + }); + await triggerMediaMessage('message:document', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Document: report.pdf]' }), + ); + }); + + it('stores document with fallback name when filename missing', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ extra: { document: {} } }); + await triggerMediaMessage('message:document', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Document: file]' }), + ); + }); + + it('stores sticker with emoji', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ + extra: { sticker: { emoji: '😂' } }, + }); + await triggerMediaMessage('message:sticker', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Sticker 😂]' }), + ); + }); + + it('stores location with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:location', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Location]' }), + ); + }); + + it('stores contact with placeholder', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({}); + await triggerMediaMessage('message:contact', ctx); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '[Contact]' }), + ); + }); + + it('ignores non-text messages from unregistered chats', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const ctx = createMediaCtx({ chatId: 999999 }); + await triggerMediaMessage('message:photo', ctx); + + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + }); + + // --- sendMessage --- + + describe('sendMessage', () => { + it('sends message via bot API', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.sendMessage('tg:100200300', 'Hello'); + + expect(currentBot().api.sendMessage).toHaveBeenCalledWith( + '100200300', + 'Hello', + ); + }); + + it('strips tg: prefix from JID', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.sendMessage('tg:-1001234567890', 'Group message'); + + expect(currentBot().api.sendMessage).toHaveBeenCalledWith( + '-1001234567890', + 'Group message', + ); + }); + + it('splits messages exceeding 4096 characters', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const longText = 'x'.repeat(5000); + await channel.sendMessage('tg:100200300', longText); + + expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2); + expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( + 1, + '100200300', + 'x'.repeat(4096), + ); + expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( + 2, + '100200300', + 'x'.repeat(904), + ); + }); + + it('sends exactly one message at 4096 characters', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const exactText = 'y'.repeat(4096); + await channel.sendMessage('tg:100200300', exactText); + + expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1); + }); + + it('handles send failure gracefully', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + currentBot().api.sendMessage.mockRejectedValueOnce( + new Error('Network error'), + ); + + // Should not throw + await expect( + channel.sendMessage('tg:100200300', 'Will fail'), + ).resolves.toBeUndefined(); + }); + + it('does nothing when bot is not initialized', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + // Don't connect — bot is null + await channel.sendMessage('tg:100200300', 'No bot'); + + // No error, no API call + }); + }); + + // --- ownsJid --- + + describe('ownsJid', () => { + it('owns tg: JIDs', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('tg:123456')).toBe(true); + }); + + it('owns tg: JIDs with negative IDs (groups)', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('tg:-1001234567890')).toBe(true); + }); + + it('does not own WhatsApp group JIDs', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('12345@g.us')).toBe(false); + }); + + it('does not own WhatsApp DM JIDs', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false); + }); + + it('does not own unknown JID formats', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.ownsJid('random-string')).toBe(false); + }); + }); + + // --- setTyping --- + + describe('setTyping', () => { + it('sends typing action when isTyping is true', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.setTyping('tg:100200300', true); + + expect(currentBot().api.sendChatAction).toHaveBeenCalledWith( + '100200300', + 'typing', + ); + }); + + it('does nothing when isTyping is false', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + await channel.setTyping('tg:100200300', false); + + expect(currentBot().api.sendChatAction).not.toHaveBeenCalled(); + }); + + it('does nothing when bot is not initialized', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + + // Don't connect + await channel.setTyping('tg:100200300', true); + + // No error, no API call + }); + + it('handles typing indicator failure gracefully', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + currentBot().api.sendChatAction.mockRejectedValueOnce( + new Error('Rate limited'), + ); + + await expect( + channel.setTyping('tg:100200300', true), + ).resolves.toBeUndefined(); + }); + }); + + // --- Bot commands --- + + describe('bot commands', () => { + it('/chatid replies with chat ID and metadata', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const handler = currentBot().commandHandlers.get('chatid')!; + const ctx = { + chat: { id: 100200300, type: 'group' as const }, + from: { first_name: 'Alice' }, + reply: vi.fn(), + }; + + await handler(ctx); + + expect(ctx.reply).toHaveBeenCalledWith( + expect.stringContaining('tg:100200300'), + expect.objectContaining({ parse_mode: 'Markdown' }), + ); + }); + + it('/chatid shows chat type', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const handler = currentBot().commandHandlers.get('chatid')!; + const ctx = { + chat: { id: 555, type: 'private' as const }, + from: { first_name: 'Bob' }, + reply: vi.fn(), + }; + + await handler(ctx); + + expect(ctx.reply).toHaveBeenCalledWith( + expect.stringContaining('private'), + expect.any(Object), + ); + }); + + it('/ping replies with bot status', async () => { + const opts = createTestOpts(); + const channel = new TelegramChannel('test-token', opts); + await channel.connect(); + + const handler = currentBot().commandHandlers.get('ping')!; + const ctx = { reply: vi.fn() }; + + await handler(ctx); + + expect(ctx.reply).toHaveBeenCalledWith('Andy is online.'); + }); + }); + + // --- Channel properties --- + + describe('channel properties', () => { + it('has name "telegram"', () => { + const channel = new TelegramChannel('test-token', createTestOpts()); + expect(channel.name).toBe('telegram'); + }); + }); +}); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts new file mode 100644 index 0000000..4176f03 --- /dev/null +++ b/src/channels/telegram.ts @@ -0,0 +1,257 @@ +import { Bot } from 'grammy'; + +import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; +import { readEnvFile } from '../env.js'; +import { logger } from '../logger.js'; +import { registerChannel, ChannelOpts } from './registry.js'; +import { + Channel, + OnChatMetadata, + OnInboundMessage, + RegisteredGroup, +} from '../types.js'; + +export interface TelegramChannelOpts { + onMessage: OnInboundMessage; + onChatMetadata: OnChatMetadata; + registeredGroups: () => Record; +} + +export class TelegramChannel implements Channel { + name = 'telegram'; + + private bot: Bot | null = null; + private opts: TelegramChannelOpts; + private botToken: string; + + constructor(botToken: string, opts: TelegramChannelOpts) { + this.botToken = botToken; + this.opts = opts; + } + + async connect(): Promise { + this.bot = new Bot(this.botToken); + + // Command to get chat ID (useful for registration) + this.bot.command('chatid', (ctx) => { + const chatId = ctx.chat.id; + const chatType = ctx.chat.type; + const chatName = + chatType === 'private' + ? ctx.from?.first_name || 'Private' + : (ctx.chat as any).title || 'Unknown'; + + ctx.reply( + `Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`, + { parse_mode: 'Markdown' }, + ); + }); + + // Command to check bot status + this.bot.command('ping', (ctx) => { + ctx.reply(`${ASSISTANT_NAME} is online.`); + }); + + this.bot.on('message:text', async (ctx) => { + // Skip commands + if (ctx.message.text.startsWith('/')) return; + + const chatJid = `tg:${ctx.chat.id}`; + let content = ctx.message.text; + const timestamp = new Date(ctx.message.date * 1000).toISOString(); + const senderName = + ctx.from?.first_name || + ctx.from?.username || + ctx.from?.id.toString() || + 'Unknown'; + const sender = ctx.from?.id.toString() || ''; + const msgId = ctx.message.message_id.toString(); + + // Determine chat name + const chatName = + ctx.chat.type === 'private' + ? senderName + : (ctx.chat as any).title || chatJid; + + // Translate Telegram @bot_username mentions into TRIGGER_PATTERN format. + // Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN + // (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned. + const botUsername = ctx.me?.username?.toLowerCase(); + if (botUsername) { + const entities = ctx.message.entities || []; + const isBotMentioned = entities.some((entity) => { + if (entity.type === 'mention') { + const mentionText = content + .substring(entity.offset, entity.offset + entity.length) + .toLowerCase(); + return mentionText === `@${botUsername}`; + } + return false; + }); + if (isBotMentioned && !TRIGGER_PATTERN.test(content)) { + content = `@${ASSISTANT_NAME} ${content}`; + } + } + + // Store chat metadata for discovery + const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + this.opts.onChatMetadata(chatJid, timestamp, chatName, 'telegram', isGroup); + + // Only deliver full message for registered groups + const group = this.opts.registeredGroups()[chatJid]; + if (!group) { + logger.debug( + { chatJid, chatName }, + 'Message from unregistered Telegram chat', + ); + return; + } + + // Deliver message — startMessageLoop() will pick it up + this.opts.onMessage(chatJid, { + id: msgId, + chat_jid: chatJid, + sender, + sender_name: senderName, + content, + timestamp, + is_from_me: false, + }); + + logger.info( + { chatJid, chatName, sender: senderName }, + 'Telegram message stored', + ); + }); + + // Handle non-text messages with placeholders so the agent knows something was sent + const storeNonText = (ctx: any, placeholder: string) => { + const chatJid = `tg:${ctx.chat.id}`; + const group = this.opts.registeredGroups()[chatJid]; + if (!group) return; + + const timestamp = new Date(ctx.message.date * 1000).toISOString(); + const senderName = + ctx.from?.first_name || + ctx.from?.username || + ctx.from?.id?.toString() || + 'Unknown'; + const caption = ctx.message.caption ? ` ${ctx.message.caption}` : ''; + + const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + this.opts.onChatMetadata(chatJid, timestamp, undefined, 'telegram', isGroup); + this.opts.onMessage(chatJid, { + id: ctx.message.message_id.toString(), + chat_jid: chatJid, + sender: ctx.from?.id?.toString() || '', + sender_name: senderName, + content: `${placeholder}${caption}`, + timestamp, + is_from_me: false, + }); + }; + + this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]')); + this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]')); + this.bot.on('message:voice', (ctx) => + storeNonText(ctx, '[Voice message]'), + ); + this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]')); + this.bot.on('message:document', (ctx) => { + const name = ctx.message.document?.file_name || 'file'; + storeNonText(ctx, `[Document: ${name}]`); + }); + this.bot.on('message:sticker', (ctx) => { + const emoji = ctx.message.sticker?.emoji || ''; + storeNonText(ctx, `[Sticker ${emoji}]`); + }); + this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]')); + this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]')); + + // Handle errors gracefully + this.bot.catch((err) => { + logger.error({ err: err.message }, 'Telegram bot error'); + }); + + // Start polling — returns a Promise that resolves when started + return new Promise((resolve) => { + this.bot!.start({ + onStart: (botInfo) => { + logger.info( + { username: botInfo.username, id: botInfo.id }, + 'Telegram bot connected', + ); + console.log(`\n Telegram bot: @${botInfo.username}`); + console.log( + ` Send /chatid to the bot to get a chat's registration ID\n`, + ); + resolve(); + }, + }); + }); + } + + async sendMessage(jid: string, text: string): Promise { + if (!this.bot) { + logger.warn('Telegram bot not initialized'); + return; + } + + try { + const numericId = jid.replace(/^tg:/, ''); + + // Telegram has a 4096 character limit per message — split if needed + const MAX_LENGTH = 4096; + if (text.length <= MAX_LENGTH) { + await this.bot.api.sendMessage(numericId, text); + } else { + for (let i = 0; i < text.length; i += MAX_LENGTH) { + await this.bot.api.sendMessage( + numericId, + text.slice(i, i + MAX_LENGTH), + ); + } + } + logger.info({ jid, length: text.length }, 'Telegram message sent'); + } catch (err) { + logger.error({ jid, err }, 'Failed to send Telegram message'); + } + } + + isConnected(): boolean { + return this.bot !== null; + } + + ownsJid(jid: string): boolean { + return jid.startsWith('tg:'); + } + + async disconnect(): Promise { + if (this.bot) { + this.bot.stop(); + this.bot = null; + logger.info('Telegram bot stopped'); + } + } + + async setTyping(jid: string, isTyping: boolean): Promise { + if (!this.bot || !isTyping) return; + try { + const numericId = jid.replace(/^tg:/, ''); + await this.bot.api.sendChatAction(numericId, 'typing'); + } catch (err) { + logger.debug({ jid, err }, 'Failed to send Telegram typing indicator'); + } + } +} + +registerChannel('telegram', (opts: ChannelOpts) => { + const envVars = readEnvFile(['TELEGRAM_BOT_TOKEN']); + const token = + process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN || ''; + if (!token) { + logger.warn('Telegram: TELEGRAM_BOT_TOKEN not set'); + return null; + } + return new TelegramChannel(token, opts); +}); From 5acab2c09d9d567cc748bea2bced75e7c5a3b40a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 9 Mar 2026 23:43:19 +0200 Subject: [PATCH 02/23] ci: add upstream sync and merge-forward workflow Co-Authored-By: Claude Opus 4.6 --- .github/workflows/merge-forward-skills.yml | 200 +++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 .github/workflows/merge-forward-skills.yml diff --git a/.github/workflows/merge-forward-skills.yml b/.github/workflows/merge-forward-skills.yml new file mode 100644 index 0000000..5b6d7df --- /dev/null +++ b/.github/workflows/merge-forward-skills.yml @@ -0,0 +1,200 @@ +name: Sync upstream & merge-forward skill branches + +on: + # Triggered by upstream repo via repository_dispatch + repository_dispatch: + types: [upstream-main-updated] + # Fallback: run on schedule in case dispatch isn't configured + schedule: + - cron: '0 */6 * * *' # every 6 hours + # Also run when fork's main is pushed directly + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + issues: write + +jobs: + sync-and-merge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Sync with upstream main + id: sync + run: | + # Add upstream remote + git remote add upstream https://github.com/qwibitai/nanoclaw.git + git fetch upstream main + + # Check if upstream has new commits + if git merge-base --is-ancestor upstream/main HEAD; then + echo "Already up to date with upstream main." + echo "synced=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Merge upstream main into fork's main + if ! git merge upstream/main --no-edit; then + echo "::error::Failed to merge upstream/main into fork main — conflicts detected" + git merge --abort + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Validate build + npm ci + if ! npm run build; then + echo "::error::Build failed after merging upstream/main" + git reset --hard "origin/main" + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ! npm test 2>/dev/null; then + echo "::error::Tests failed after merging upstream/main" + git reset --hard "origin/main" + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git push origin main + echo "synced=true" >> "$GITHUB_OUTPUT" + + - name: Merge main into skill branches + id: merge + run: | + FAILED="" + SUCCEEDED="" + + # List all remote skill branches + SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs) + + if [ -z "$SKILL_BRANCHES" ]; then + echo "No skill branches found." + exit 0 + fi + + for BRANCH in $SKILL_BRANCHES; do + SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||') + echo "" + echo "=== Processing $BRANCH ===" + + git checkout -B "$BRANCH" "origin/$BRANCH" + + if ! git merge main --no-edit; then + echo "::warning::Merge conflict in $BRANCH" + git merge --abort + FAILED="$FAILED $SKILL_NAME" + continue + fi + + # Check if there's anything new to push + if git diff --quiet "origin/$BRANCH"; then + echo "$BRANCH is already up to date with main." + SUCCEEDED="$SUCCEEDED $SKILL_NAME" + continue + fi + + npm ci + + if ! npm run build; then + echo "::warning::Build failed for $BRANCH" + git reset --hard "origin/$BRANCH" + FAILED="$FAILED $SKILL_NAME" + continue + fi + + if ! npm test 2>/dev/null; then + echo "::warning::Tests failed for $BRANCH" + git reset --hard "origin/$BRANCH" + FAILED="$FAILED $SKILL_NAME" + continue + fi + + git push origin "$BRANCH" + SUCCEEDED="$SUCCEEDED $SKILL_NAME" + echo "$BRANCH merged and pushed successfully." + done + + echo "" + echo "=== Results ===" + echo "Succeeded: $SUCCEEDED" + echo "Failed: $FAILED" + + echo "failed=$FAILED" >> "$GITHUB_OUTPUT" + echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT" + + - name: Open issue for upstream sync failure + if: steps.sync.outputs.sync_failed == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Upstream sync failed — merge conflict or build failure`, + body: [ + 'The automated sync with `qwibitai/nanoclaw` main failed.', + '', + 'This usually means upstream made changes that conflict with this fork\'s channel code.', + '', + 'To resolve manually:', + '```bash', + 'git fetch upstream main', + 'git merge upstream/main', + '# resolve conflicts', + 'npm run build && npm test', + 'git push', + '```', + ].join('\n'), + labels: ['upstream-sync'] + }); + + - name: Open issue for failed skill merges + if: steps.merge.outputs.failed != '' + uses: actions/github-script@v7 + with: + script: | + const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/); + const body = [ + `The merge-forward workflow failed to merge \`main\` into the following skill branches:`, + '', + ...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`), + '', + 'Please resolve manually:', + '```bash', + ...failed.map(s => [ + `git checkout skill/${s}`, + `git merge main`, + `# resolve conflicts, then: git push`, + '' + ]).flat(), + '```', + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Merge-forward failed for ${failed.length} skill branch(es)`, + body, + labels: ['skill-maintenance'] + }); From d487faf55aecd872ef8082179b2603d7e9571e44 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 00:53:40 +0200 Subject: [PATCH 03/23] ci: rename sync workflow to fork-sync-skills.yml to avoid merge conflicts with core --- .github/workflows/fork-sync-skills.yml | 201 +++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 .github/workflows/fork-sync-skills.yml diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml new file mode 100644 index 0000000..14e10a0 --- /dev/null +++ b/.github/workflows/fork-sync-skills.yml @@ -0,0 +1,201 @@ +name: Sync upstream & merge-forward skill branches + +on: + # Triggered by upstream repo via repository_dispatch + repository_dispatch: + types: [upstream-main-updated] + # Fallback: run on schedule in case dispatch isn't configured + schedule: + - cron: '0 */6 * * *' # every 6 hours + # Also run when fork's main is pushed directly + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + issues: write + +jobs: + sync-and-merge: + if: github.repository_owner != 'qwibitai' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Sync with upstream main + id: sync + run: | + # Add upstream remote + git remote add upstream https://github.com/qwibitai/nanoclaw.git + git fetch upstream main + + # Check if upstream has new commits + if git merge-base --is-ancestor upstream/main HEAD; then + echo "Already up to date with upstream main." + echo "synced=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Merge upstream main into fork's main + if ! git merge upstream/main --no-edit; then + echo "::error::Failed to merge upstream/main into fork main — conflicts detected" + git merge --abort + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Validate build + npm ci + if ! npm run build; then + echo "::error::Build failed after merging upstream/main" + git reset --hard "origin/main" + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ! npm test 2>/dev/null; then + echo "::error::Tests failed after merging upstream/main" + git reset --hard "origin/main" + echo "synced=false" >> "$GITHUB_OUTPUT" + echo "sync_failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git push origin main + echo "synced=true" >> "$GITHUB_OUTPUT" + + - name: Merge main into skill branches + id: merge + run: | + FAILED="" + SUCCEEDED="" + + # List all remote skill branches + SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs) + + if [ -z "$SKILL_BRANCHES" ]; then + echo "No skill branches found." + exit 0 + fi + + for BRANCH in $SKILL_BRANCHES; do + SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||') + echo "" + echo "=== Processing $BRANCH ===" + + git checkout -B "$BRANCH" "origin/$BRANCH" + + if ! git merge main --no-edit; then + echo "::warning::Merge conflict in $BRANCH" + git merge --abort + FAILED="$FAILED $SKILL_NAME" + continue + fi + + # Check if there's anything new to push + if git diff --quiet "origin/$BRANCH"; then + echo "$BRANCH is already up to date with main." + SUCCEEDED="$SUCCEEDED $SKILL_NAME" + continue + fi + + npm ci + + if ! npm run build; then + echo "::warning::Build failed for $BRANCH" + git reset --hard "origin/$BRANCH" + FAILED="$FAILED $SKILL_NAME" + continue + fi + + if ! npm test 2>/dev/null; then + echo "::warning::Tests failed for $BRANCH" + git reset --hard "origin/$BRANCH" + FAILED="$FAILED $SKILL_NAME" + continue + fi + + git push origin "$BRANCH" + SUCCEEDED="$SUCCEEDED $SKILL_NAME" + echo "$BRANCH merged and pushed successfully." + done + + echo "" + echo "=== Results ===" + echo "Succeeded: $SUCCEEDED" + echo "Failed: $FAILED" + + echo "failed=$FAILED" >> "$GITHUB_OUTPUT" + echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT" + + - name: Open issue for upstream sync failure + if: steps.sync.outputs.sync_failed == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Upstream sync failed — merge conflict or build failure`, + body: [ + 'The automated sync with `qwibitai/nanoclaw` main failed.', + '', + 'This usually means upstream made changes that conflict with this fork\'s channel code.', + '', + 'To resolve manually:', + '```bash', + 'git fetch upstream main', + 'git merge upstream/main', + '# resolve conflicts', + 'npm run build && npm test', + 'git push', + '```', + ].join('\n'), + labels: ['upstream-sync'] + }); + + - name: Open issue for failed skill merges + if: steps.merge.outputs.failed != '' + uses: actions/github-script@v7 + with: + script: | + const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/); + const body = [ + `The merge-forward workflow failed to merge \`main\` into the following skill branches:`, + '', + ...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`), + '', + 'Please resolve manually:', + '```bash', + ...failed.map(s => [ + `git checkout skill/${s}`, + `git merge main`, + `# resolve conflicts, then: git push`, + '' + ]).flat(), + '```', + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Merge-forward failed for ${failed.length} skill branch(es)`, + body, + labels: ['skill-maintenance'] + }); From b913a37c2142a3abafef64b74c46d9f77b4b5a1d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 00:53:51 +0200 Subject: [PATCH 04/23] ci: remove old merge-forward-skills.yml (replaced by fork-sync-skills.yml) --- .github/workflows/merge-forward-skills.yml | 200 --------------------- 1 file changed, 200 deletions(-) delete mode 100644 .github/workflows/merge-forward-skills.yml diff --git a/.github/workflows/merge-forward-skills.yml b/.github/workflows/merge-forward-skills.yml deleted file mode 100644 index 5b6d7df..0000000 --- a/.github/workflows/merge-forward-skills.yml +++ /dev/null @@ -1,200 +0,0 @@ -name: Sync upstream & merge-forward skill branches - -on: - # Triggered by upstream repo via repository_dispatch - repository_dispatch: - types: [upstream-main-updated] - # Fallback: run on schedule in case dispatch isn't configured - schedule: - - cron: '0 */6 * * *' # every 6 hours - # Also run when fork's main is pushed directly - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: write - issues: write - -jobs: - sync-and-merge: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Sync with upstream main - id: sync - run: | - # Add upstream remote - git remote add upstream https://github.com/qwibitai/nanoclaw.git - git fetch upstream main - - # Check if upstream has new commits - if git merge-base --is-ancestor upstream/main HEAD; then - echo "Already up to date with upstream main." - echo "synced=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Merge upstream main into fork's main - if ! git merge upstream/main --no-edit; then - echo "::error::Failed to merge upstream/main into fork main — conflicts detected" - git merge --abort - echo "synced=false" >> "$GITHUB_OUTPUT" - echo "sync_failed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Validate build - npm ci - if ! npm run build; then - echo "::error::Build failed after merging upstream/main" - git reset --hard "origin/main" - echo "synced=false" >> "$GITHUB_OUTPUT" - echo "sync_failed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if ! npm test 2>/dev/null; then - echo "::error::Tests failed after merging upstream/main" - git reset --hard "origin/main" - echo "synced=false" >> "$GITHUB_OUTPUT" - echo "sync_failed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - git push origin main - echo "synced=true" >> "$GITHUB_OUTPUT" - - - name: Merge main into skill branches - id: merge - run: | - FAILED="" - SUCCEEDED="" - - # List all remote skill branches - SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs) - - if [ -z "$SKILL_BRANCHES" ]; then - echo "No skill branches found." - exit 0 - fi - - for BRANCH in $SKILL_BRANCHES; do - SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||') - echo "" - echo "=== Processing $BRANCH ===" - - git checkout -B "$BRANCH" "origin/$BRANCH" - - if ! git merge main --no-edit; then - echo "::warning::Merge conflict in $BRANCH" - git merge --abort - FAILED="$FAILED $SKILL_NAME" - continue - fi - - # Check if there's anything new to push - if git diff --quiet "origin/$BRANCH"; then - echo "$BRANCH is already up to date with main." - SUCCEEDED="$SUCCEEDED $SKILL_NAME" - continue - fi - - npm ci - - if ! npm run build; then - echo "::warning::Build failed for $BRANCH" - git reset --hard "origin/$BRANCH" - FAILED="$FAILED $SKILL_NAME" - continue - fi - - if ! npm test 2>/dev/null; then - echo "::warning::Tests failed for $BRANCH" - git reset --hard "origin/$BRANCH" - FAILED="$FAILED $SKILL_NAME" - continue - fi - - git push origin "$BRANCH" - SUCCEEDED="$SUCCEEDED $SKILL_NAME" - echo "$BRANCH merged and pushed successfully." - done - - echo "" - echo "=== Results ===" - echo "Succeeded: $SUCCEEDED" - echo "Failed: $FAILED" - - echo "failed=$FAILED" >> "$GITHUB_OUTPUT" - echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT" - - - name: Open issue for upstream sync failure - if: steps.sync.outputs.sync_failed == 'true' - uses: actions/github-script@v7 - with: - script: | - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `Upstream sync failed — merge conflict or build failure`, - body: [ - 'The automated sync with `qwibitai/nanoclaw` main failed.', - '', - 'This usually means upstream made changes that conflict with this fork\'s channel code.', - '', - 'To resolve manually:', - '```bash', - 'git fetch upstream main', - 'git merge upstream/main', - '# resolve conflicts', - 'npm run build && npm test', - 'git push', - '```', - ].join('\n'), - labels: ['upstream-sync'] - }); - - - name: Open issue for failed skill merges - if: steps.merge.outputs.failed != '' - uses: actions/github-script@v7 - with: - script: | - const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/); - const body = [ - `The merge-forward workflow failed to merge \`main\` into the following skill branches:`, - '', - ...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`), - '', - 'Please resolve manually:', - '```bash', - ...failed.map(s => [ - `git checkout skill/${s}`, - `git merge main`, - `# resolve conflicts, then: git push`, - '' - ]).flat(), - '```', - ].join('\n'); - - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `Merge-forward failed for ${failed.length} skill branch(es)`, - body, - labels: ['skill-maintenance'] - }); From 9a4fb61f6e037d3f82f0ec700ec6a888964321d9 Mon Sep 17 00:00:00 2001 From: James Schindler Date: Tue, 10 Mar 2026 11:58:00 -0400 Subject: [PATCH 05/23] feat: add Markdown formatting for outbound messages Wrap outbound sendMessage calls with parse_mode: 'Markdown' so that Claude's natural formatting (*bold*, _italic_, `code`, etc.) renders correctly in Telegram instead of showing raw asterisks and underscores. Falls back to plain text if Telegram rejects the Markdown formatting. --- src/channels/telegram.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 4176f03..c7d19e5 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -1,4 +1,4 @@ -import { Bot } from 'grammy'; +import { Api, Bot } from 'grammy'; import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; import { readEnvFile } from '../env.js'; @@ -17,6 +17,29 @@ export interface TelegramChannelOpts { registeredGroups: () => Record; } +/** + * Send a message with Telegram Markdown parse mode, falling back to plain text. + * Claude's output naturally matches Telegram's Markdown v1 format: + * *bold*, _italic_, `code`, ```code blocks```, [links](url) + */ +async function sendTelegramMessage( + api: { sendMessage: Api['sendMessage'] }, + chatId: string | number, + text: string, + options: { message_thread_id?: number } = {}, +): Promise { + try { + await api.sendMessage(chatId, text, { + ...options, + parse_mode: 'Markdown', + }); + } catch (err) { + // Fallback: send as plain text if Markdown parsing fails + logger.debug({ err }, 'Markdown send failed, falling back to plain text'); + await api.sendMessage(chatId, text, options); + } +} + export class TelegramChannel implements Channel { name = 'telegram'; @@ -203,10 +226,11 @@ export class TelegramChannel implements Channel { // Telegram has a 4096 character limit per message — split if needed const MAX_LENGTH = 4096; if (text.length <= MAX_LENGTH) { - await this.bot.api.sendMessage(numericId, text); + await sendTelegramMessage(this.bot.api, numericId, text); } else { for (let i = 0; i < text.length; i += MAX_LENGTH) { - await this.bot.api.sendMessage( + await sendTelegramMessage( + this.bot.api, numericId, text.slice(i, i + MAX_LENGTH), ); From 107f9742a9ba0e13cfb334f751b7cb26aafb703e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 22:00:36 +0200 Subject: [PATCH 06/23] fix: update sync condition to check repo name, not owner --- .github/workflows/fork-sync-skills.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index 14e10a0..e1c81c7 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -18,7 +18,7 @@ permissions: jobs: sync-and-merge: - if: github.repository_owner != 'qwibitai' + if: github.repository != 'qwibitai/nanoclaw' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -166,7 +166,8 @@ jobs: 'npm run build && npm test', 'git push', '```', - ].join('\n'), + ].join(' +'), labels: ['upstream-sync'] }); @@ -190,7 +191,8 @@ jobs: '' ]).flat(), '```', - ].join('\n'); + ].join(' +'); await github.rest.issues.create({ owner: context.repo.owner, From 15ed3cf2a65f1a14b5e712a95a5b7400f8b7fea0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 22:10:37 +0200 Subject: [PATCH 07/23] fix: repair escaped newlines in fork-sync workflow --- .github/workflows/fork-sync-skills.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index e1c81c7..259dec8 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -4,7 +4,7 @@ on: # Triggered by upstream repo via repository_dispatch repository_dispatch: types: [upstream-main-updated] - # Fallback: run on schedule in case dispatch isn't configured + # Fallback: run on a schedule in case dispatch isn't configured schedule: - cron: '0 */6 * * *' # every 6 hours # Also run when fork's main is pushed directly @@ -166,8 +166,7 @@ jobs: 'npm run build && npm test', 'git push', '```', - ].join(' -'), + ].join('\n'), labels: ['upstream-sync'] }); @@ -191,8 +190,7 @@ jobs: '' ]).flat(), '```', - ].join(' -'); + ].join('\n'); await github.rest.issues.create({ owner: context.repo.owner, @@ -200,4 +198,4 @@ jobs: title: `Merge-forward failed for ${failed.length} skill branch(es)`, body, labels: ['skill-maintenance'] - }); + }); \ No newline at end of file From 018deca3ef027ecd6d1e4bb571b64d2aa8f636a4 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 22:16:02 +0200 Subject: [PATCH 08/23] fix: use GitHub App token for fork-sync (workflows permission needed) --- .github/workflows/fork-sync-skills.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index 259dec8..dced479 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -21,10 +21,16 @@ jobs: if: github.repository != 'qwibitai/nanoclaw' runs-on: ubuntu-latest steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - uses: actions/setup-node@v4 with: From 51ad9499797836c234d6100b6d350893501adfd0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 22:28:12 +0200 Subject: [PATCH 09/23] fix: re-fetch before skill branch merges to avoid stale refs --- .github/workflows/fork-sync-skills.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index dced479..273bfc7 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -89,6 +89,9 @@ jobs: - name: Merge main into skill branches id: merge run: | + # Re-fetch to pick up any changes pushed since job start + git fetch origin + FAILED="" SUCCEEDED="" From 7061480ac008a46390017bcc1d6dfcc8250e9ca9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 22:43:00 +0200 Subject: [PATCH 10/23] fix: add concurrency group to prevent parallel fork-sync races --- .github/workflows/fork-sync-skills.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/fork-sync-skills.yml b/.github/workflows/fork-sync-skills.yml index 273bfc7..8d25ee2 100644 --- a/.github/workflows/fork-sync-skills.yml +++ b/.github/workflows/fork-sync-skills.yml @@ -16,6 +16,10 @@ permissions: contents: write issues: write +concurrency: + group: fork-sync + cancel-in-progress: true + jobs: sync-and-merge: if: github.repository != 'qwibitai/nanoclaw' From 272cbcf18f204df78ccf82dc45b0d27ba4341693 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 11 Mar 2026 12:06:28 +0200 Subject: [PATCH 11/23] fix: update sendMessage test expectations for Markdown parse_mode The sendTelegramMessage helper now passes { parse_mode: 'Markdown' } to bot.api.sendMessage, but three tests still expected only two args. Co-Authored-By: Claude Opus 4.6 --- src/channels/telegram.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts index 9a97223..564a20e 100644 --- a/src/channels/telegram.test.ts +++ b/src/channels/telegram.test.ts @@ -710,6 +710,7 @@ describe('TelegramChannel', () => { expect(currentBot().api.sendMessage).toHaveBeenCalledWith( '100200300', 'Hello', + { parse_mode: 'Markdown' }, ); }); @@ -723,6 +724,7 @@ describe('TelegramChannel', () => { expect(currentBot().api.sendMessage).toHaveBeenCalledWith( '-1001234567890', 'Group message', + { parse_mode: 'Markdown' }, ); }); @@ -739,11 +741,13 @@ describe('TelegramChannel', () => { 1, '100200300', 'x'.repeat(4096), + { parse_mode: 'Markdown' }, ); expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( 2, '100200300', 'x'.repeat(904), + { parse_mode: 'Markdown' }, ); }); From 845da49fa39fa27b4378b607e6c8f58e11f7af5c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 11 Mar 2026 12:08:52 +0200 Subject: [PATCH 12/23] fix: prettier formatting for telegram.ts Pre-existing formatting issue that causes CI format check to fail. Co-Authored-By: Claude Opus 4.6 --- src/channels/telegram.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index c7d19e5..7b95924 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -117,8 +117,15 @@ export class TelegramChannel implements Channel { } // Store chat metadata for discovery - const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; - this.opts.onChatMetadata(chatJid, timestamp, chatName, 'telegram', isGroup); + const isGroup = + ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + this.opts.onChatMetadata( + chatJid, + timestamp, + chatName, + 'telegram', + isGroup, + ); // Only deliver full message for registered groups const group = this.opts.registeredGroups()[chatJid]; @@ -161,8 +168,15 @@ export class TelegramChannel implements Channel { 'Unknown'; const caption = ctx.message.caption ? ` ${ctx.message.caption}` : ''; - const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; - this.opts.onChatMetadata(chatJid, timestamp, undefined, 'telegram', isGroup); + const isGroup = + ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; + this.opts.onChatMetadata( + chatJid, + timestamp, + undefined, + 'telegram', + isGroup, + ); this.opts.onMessage(chatJid, { id: ctx.message.message_id.toString(), chat_jid: chatJid, @@ -176,9 +190,7 @@ export class TelegramChannel implements Channel { this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]')); this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]')); - this.bot.on('message:voice', (ctx) => - storeNonText(ctx, '[Voice message]'), - ); + this.bot.on('message:voice', (ctx) => storeNonText(ctx, '[Voice message]')); this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]')); this.bot.on('message:document', (ctx) => { const name = ctx.message.document?.file_name || 'file'; From cb9fba8472b629bee7f8ae1a64140eda17d27b36 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 10:09:48 +0000 Subject: [PATCH 13/23] chore: bump version to 1.2.13 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e7969b..34b2aa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.2.13", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index 560200d..0e915d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.12", + "version": "1.2.13", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 2dedd15ec71a2b52553f334eb4a0c2bd8398f0ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 10:09:50 +0000 Subject: [PATCH 14/23] =?UTF-8?q?docs:=20update=20token=20count=20to=2040.?= =?UTF-8?q?9k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 182aaa2..993856e 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 37.5k tokens, 19% of context window + + 40.9k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 37.5k + + 40.9k From d000acc6873bc611563b312d6c95ea4a4ecb5622 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 11 Mar 2026 22:46:57 +0200 Subject: [PATCH 15/23] fix: use https.globalAgent in grammY Bot to support sandbox proxy grammY creates its own https.Agent internally, bypassing any global proxy. In Docker Sandbox, NanoClaw sets https.globalAgent to a proxy agent at startup. This tells grammY to use it instead. On non-sandbox setups it's a no-op. Co-Authored-By: Claude Opus 4.6 --- src/channels/telegram.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 7b95924..0b990d2 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -1,3 +1,4 @@ +import https from 'https'; import { Api, Bot } from 'grammy'; import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; @@ -53,7 +54,11 @@ export class TelegramChannel implements Channel { } async connect(): Promise { - this.bot = new Bot(this.botToken); + this.bot = new Bot(this.botToken, { + client: { + baseFetchConfig: { agent: https.globalAgent, compress: true }, + }, + }); // Command to get chat ID (useful for registration) this.bot.command('chatid', (ctx) => { From f210fd5049a704f0404a0ebcc68e89f96d23fe9a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 20:52:39 +0000 Subject: [PATCH 16/23] chore: bump version to 1.2.14 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 34b2aa8..b720403 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.13", + "version": "1.2.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.13", + "version": "1.2.14", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index 0e915d3..a0d1e63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.13", + "version": "1.2.14", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From d81f8e122113f00e65cd9391801267bbcc56dfbd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 20:52:43 +0000 Subject: [PATCH 17/23] =?UTF-8?q?docs:=20update=20token=20count=20to=2041.?= =?UTF-8?q?0k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 993856e..be808ed 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 40.9k tokens, 20% of context window + + 41.0k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 40.9k + + 41.0k From d1975462c49b79e8023fc24a1206b65078abdb71 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 14 Mar 2026 15:16:33 +0200 Subject: [PATCH 18/23] chore: bump claude-agent-sdk to ^0.2.76 Co-Authored-By: Claude Opus 4.6 --- container/agent-runner/package-lock.json | 8 ++++---- container/agent-runner/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json index 89cee2c..9ae119b 100644 --- a/container/agent-runner/package-lock.json +++ b/container/agent-runner/package-lock.json @@ -8,7 +8,7 @@ "name": "nanoclaw-agent-runner", "version": "1.0.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" @@ -19,9 +19,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.68", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.68.tgz", - "integrity": "sha512-y4n6hTTgAqmiV/pqy1G4OgIdg6gDiAKPJaEgO1NOh7/rdsrXyc/HQoUmUy0ty4HkBq1hasm7hB92wtX3W1UMEw==", + "version": "0.2.76", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz", + "integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index bf13328..42a994e 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -9,7 +9,7 @@ "start": "node dist/index.js" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" From 54a55affa403fceac6e87c1247f0b23580515cf5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 13:16:49 +0000 Subject: [PATCH 19/23] chore: bump version to 1.2.15 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b720403..0db62f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.14", + "version": "1.2.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.14", + "version": "1.2.15", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index a0d1e63..c77580e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.14", + "version": "1.2.15", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 662e81fc9e9858be5135078585ce643e97ef14fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 13:17:37 +0000 Subject: [PATCH 20/23] chore: bump version to 1.2.16 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0db62f4..deffe16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.15", + "version": "1.2.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.15", + "version": "1.2.16", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index c77580e..4db8178 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.15", + "version": "1.2.16", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From cb20038956fdd2cc89c778614e72b62cff8b3ff7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 14 Mar 2026 17:01:23 +0200 Subject: [PATCH 21/23] fix: only skip /chatid and /ping, let other / messages through Previously all messages starting with / were silently dropped. This prevented NanoClaw-level commands like /remote-control from reaching the onMessage callback. Now only Telegram bot commands (/chatid, /ping) are skipped; everything else flows through as a regular message. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/telegram.test.ts | 21 +++++++++++++++++---- src/channels/telegram.ts | 10 ++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/channels/telegram.test.ts b/src/channels/telegram.test.ts index 564a20e..538c87b 100644 --- a/src/channels/telegram.test.ts +++ b/src/channels/telegram.test.ts @@ -295,16 +295,29 @@ describe('TelegramChannel', () => { expect(opts.onMessage).not.toHaveBeenCalled(); }); - it('skips command messages (starting with /)', async () => { + it('skips bot commands (/chatid, /ping) but passes other / messages through', async () => { const opts = createTestOpts(); const channel = new TelegramChannel('test-token', opts); await channel.connect(); - const ctx = createTextCtx({ text: '/start' }); - await triggerTextMessage(ctx); - + // Bot commands should be skipped + const ctx1 = createTextCtx({ text: '/chatid' }); + await triggerTextMessage(ctx1); expect(opts.onMessage).not.toHaveBeenCalled(); expect(opts.onChatMetadata).not.toHaveBeenCalled(); + + const ctx2 = createTextCtx({ text: '/ping' }); + await triggerTextMessage(ctx2); + expect(opts.onMessage).not.toHaveBeenCalled(); + + // Non-bot /commands should flow through + const ctx3 = createTextCtx({ text: '/remote-control' }); + await triggerTextMessage(ctx3); + expect(opts.onMessage).toHaveBeenCalledTimes(1); + expect(opts.onMessage).toHaveBeenCalledWith( + 'tg:100200300', + expect.objectContaining({ content: '/remote-control' }), + ); }); it('extracts sender name from first_name', async () => { diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 0b990d2..effca6e 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -80,9 +80,15 @@ export class TelegramChannel implements Channel { ctx.reply(`${ASSISTANT_NAME} is online.`); }); + // Telegram bot commands handled above — skip them in the general handler + // so they don't also get stored as messages. All other /commands flow through. + const TELEGRAM_BOT_COMMANDS = new Set(['chatid', 'ping']); + this.bot.on('message:text', async (ctx) => { - // Skip commands - if (ctx.message.text.startsWith('/')) return; + if (ctx.message.text.startsWith('/')) { + const cmd = ctx.message.text.slice(1).split(/[\s@]/)[0].toLowerCase(); + if (TELEGRAM_BOT_COMMANDS.has(cmd)) return; + } const chatJid = `tg:${ctx.chat.id}`; let content = ctx.message.text; From 3d649c386ebef00f7024e196d6f39ea69c638aa5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 15:08:11 +0000 Subject: [PATCH 22/23] chore: bump version to 1.2.17 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index deffe16..5f7f779 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.16", + "version": "1.2.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.16", + "version": "1.2.17", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index 4db8178..5c6a114 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.16", + "version": "1.2.17", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From c984e6f13da4267a64f9fd300b05bc236cf86216 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 15:08:11 +0000 Subject: [PATCH 23/23] =?UTF-8?q?docs:=20update=20token=20count=20to=2041.?= =?UTF-8?q?1k=20tokens=20=C2=B7=2021%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index be808ed..1b06f80 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 41.0k tokens, 20% of context window + + 41.1k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 41.0k + + 41.1k