From 20cbb3714ad201b2223ad71bff39cada53706e30 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:54:51 +0330 Subject: [PATCH 01/26] feat: add Telegram channel integration --- src/server/telegram-channel.ts | 142 +++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/server/telegram-channel.ts diff --git a/src/server/telegram-channel.ts b/src/server/telegram-channel.ts new file mode 100644 index 00000000..d193acee --- /dev/null +++ b/src/server/telegram-channel.ts @@ -0,0 +1,142 @@ +import { + createChannel, + type ChannelIdentityContext, + type IncomingMessage, + type Thread, +} from '@copilotkit/channels'; +import { telegram } from '@copilotkit/channels/telegram'; +import type { PlatformConfig } from './platform-config.js'; + +type TelegramConfig = Pick; +type Report = (operation: string, errors: string[]) => void; + +export function telegramIdentity( + context: ChannelIdentityContext, + config: TelegramConfig, + ownerId: string, +) { + if ( + context.provider !== 'telegram' || + context.actor.kind !== 'human' || + !config.telegramUsers.includes(context.actor.id) + ) + return null; + return { id: ownerId, name: 'OpenDots owner' }; +} + +type Turn = { + thread: Pick; + message: IncomingMessage; +}; + +const safeErrorName = (error: unknown): string => + error instanceof Error && + ['Error', 'TypeError', 'AbortError', 'TimeoutError'].includes(error.name) + ? error.name + : 'Error'; + +export function telegramHandlers(options: { + config: TelegramConfig; + ownerId: string; + paused: () => boolean; + report?: Report; +}) { + const report = + options.report ?? + ((operation, errors) => { + console.error(operation + ': ' + errors.join('; ')); + }); + + const eligible = ({ message }: Turn) => + message.platform === 'telegram' && + message.user?.id === options.ownerId && + message.actor.kind === 'human' && + options.config.telegramUsers.includes(message.actor.id) && + (message.operation?.kind ?? 'created') === 'created'; + + async function notice(thread: Turn['thread'], text: string) { + try { + await thread.post(text); + } catch (error) { + const safe = safeErrorName(error); + report('Telegram notice failed', [safe]); + throw new Error('Telegram notice failed: ' + safe); + } + } + + async function run(thread: Turn['thread']) { + if (options.paused()) { + await notice( + thread, + 'OpenDots is paused. Resume it in the app before asking me to continue.', + ); + return; + } + try { + await thread.runAgent(); + } catch (error) { + const runError = safeErrorName(error); + try { + await thread.post( + 'I couldn’t complete that request. Please check OpenDots and send a new message when you’re ready to try again.', + ); + } catch (postError) { + const replyError = safeErrorName(postError); + report('Telegram agent run and error reply failed', [ + runError, + replyError, + ]); + throw new AggregateError( + [new Error('Agent run: ' + runError), new Error('Error reply: ' + replyError)], + 'Telegram agent run and error reply failed', + ); + } + report('Telegram agent run failed; error reply posted', [runError]); + } + } + + return { + async mention(turn: Turn) { + if (eligible(turn)) await run(turn.thread); + }, + async message(turn: Turn) { + if (eligible(turn)) await run(turn.thread); + }, + }; +} + +export function createTelegramChannel(options: { + name: string; + agent: NonNullable[0]['agent']>; + config: TelegramConfig; + ownerId: string; + paused: () => boolean; + token: string; + mode?: 'polling' | 'webhook' | 'auto'; + webhook?: { + domain: string; + path?: string; + port?: number; + secretToken?: string; + }; +}) { + const channel = createChannel({ + name: options.name, + agent: options.agent, + identifyUser: (context) => + telegramIdentity(context, options.config, options.ownerId), + adapters: [ + telegram({ + token: options.token, + ...(options.mode ? { mode: options.mode } : {}), + ...(options.webhook ? { webhook: options.webhook } : {}), + }), + ], + store: { concurrency: 'serial' }, + }); + + const handlers = telegramHandlers(options); + channel.onMention(handlers.mention); + channel.onMessage(handlers.message); + return channel; +} From 59c63b7b81c4c28a8b020fbb3fddd000303287c5 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:55:25 +0330 Subject: [PATCH 02/26] feat: add Telegram configuration --- src/server/platform-config.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/server/platform-config.ts b/src/server/platform-config.ts index b34ca7fe..79143930 100644 --- a/src/server/platform-config.ts +++ b/src/server/platform-config.ts @@ -19,6 +19,15 @@ export interface PlatformConfig { slackTeam?: string; slackUsers: string[]; slackDotId?: string; + telegramBotToken?: string; + telegramChannel?: string; + telegramUsers: string[]; + telegramDotId?: string; + telegramMode?: 'polling' | 'webhook' | 'auto'; + telegramWebhookDomain?: string; + telegramWebhookPath?: string; + telegramWebhookPort?: number; + telegramWebhookSecret?: string; runtimeUrl: string; ownerToken?: string; } @@ -26,6 +35,7 @@ export function setupStatus( config: PlatformConfig, slack = 'not_configured', activationFailed = false, + telegram = 'not_configured', ): SetupStatus { const missing = [ !config.intelligenceKey && 'INTELLIGENCE_API_KEY', @@ -37,6 +47,11 @@ export function setupStatus( config.slackTeam && config.slackUsers.length ); + const declaredTelegram = !!( + config.telegramBotToken && + config.telegramChannel && + config.telegramUsers.length + ); slack = declaredSlack ? activationFailed && slack !== 'online' ? 'activation_failed' @@ -44,12 +59,22 @@ export function setupStatus( : config.slackChannel || config.slackTeam || config.slackUsers.length ? 'setup_required' : 'not_configured'; + telegram = declaredTelegram + ? activationFailed && telegram !== 'online' + ? 'activation_failed' + : telegram + : config.telegramBotToken || + config.telegramChannel || + config.telegramUsers.length + ? 'setup_required' + : 'not_configured'; return { intelligence: !!config.intelligenceKey, model: !!(config.apiKey && config.model), browser: !!(config.browserUrl && config.browserSecret), voice: !!(config.voiceKey && config.voiceModel && !missing.length), slack, + telegram, missing, }; } From 20ab9fa70ac4a2233dd5561848cc2c0efd1184d9 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:55:27 +0330 Subject: [PATCH 03/26] feat: expose Telegram setup state --- src/shared/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/types.ts b/src/shared/types.ts index c87b548c..77717390 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -107,6 +107,7 @@ export interface SetupStatus { browser: boolean; voice: boolean; slack: string; + telegram: string; missing: string[]; } export interface WorkspaceState { From 4c06a48d12cc1232c291757ca3cc631115417567 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:55:29 +0330 Subject: [PATCH 04/26] feat: wire Telegram environment configuration --- src/server/index.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/server/index.ts b/src/server/index.ts index f5039978..9310d845 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -47,6 +47,21 @@ const config: PlatformConfig = { .map((value) => value.trim()) .filter(Boolean), slackDotId: process.env.SLACK_DOT_ID || undefined, + telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || undefined, + telegramChannel: process.env.TELEGRAM_CHANNEL_NAME || 'opendots-telegram', + telegramUsers: (process.env.TELEGRAM_USER_IDS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), + telegramDotId: process.env.TELEGRAM_DOT_ID || undefined, + telegramMode: + (process.env.TELEGRAM_MODE as PlatformConfig['telegramMode']) || 'polling', + telegramWebhookDomain: process.env.TELEGRAM_WEBHOOK_DOMAIN || undefined, + telegramWebhookPath: process.env.TELEGRAM_WEBHOOK_PATH || undefined, + telegramWebhookPort: process.env.TELEGRAM_WEBHOOK_PORT + ? Number(process.env.TELEGRAM_WEBHOOK_PORT) + : undefined, + telegramWebhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET || undefined, runtimeUrl: `http://${host === '::1' ? '[::1]' : '127.0.0.1'}:${port}/api/copilotkit`, ownerToken, }; @@ -107,7 +122,7 @@ const server = serve({ fetch: app.fetch, hostname: host, port }, (info) => { .start() .catch((error) => reportChannelFailure( - 'Slack Channels activation failed; check setup status', + 'Channels activation failed; check setup status', [safeFailure(error)], ), ); From 6eb05e852511fbceff400c82e18ae8253f43374b Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:56:18 +0330 Subject: [PATCH 05/26] fix: disable Telegram by default --- src/server/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/index.ts b/src/server/index.ts index 9310d845..c9a73126 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -48,7 +48,9 @@ const config: PlatformConfig = { .filter(Boolean), slackDotId: process.env.SLACK_DOT_ID || undefined, telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || undefined, - telegramChannel: process.env.TELEGRAM_CHANNEL_NAME || 'opendots-telegram', + telegramChannel: + process.env.TELEGRAM_CHANNEL_NAME || + (process.env.TELEGRAM_BOT_TOKEN ? 'opendots-telegram' : undefined), telegramUsers: (process.env.TELEGRAM_USER_IDS ?? '') .split(',') .map((value) => value.trim()) From 3532884674c5b9257b96fcff04de0c7dfa81c2c1 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:56:21 +0330 Subject: [PATCH 06/26] feat: wire Telegram channel into runtime --- src/server/platform.ts | 50 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/server/platform.ts b/src/server/platform.ts index 7d19ad47..e23acbfb 100644 --- a/src/server/platform.ts +++ b/src/server/platform.ts @@ -8,7 +8,9 @@ import { type CopilotHonoApp, } from '@copilotkit/runtime/v2'; import { createSlackChannel } from './slack-channel.js'; +import { createTelegramChannel } from './telegram-channel.js'; export { slackIdentity } from './slack-channel.js'; +export { telegramIdentity } from './telegram-channel.js'; import { Store } from './store.js'; import { WorkspaceStore } from './workspace.js'; import { DotAgent } from './dot-agent.js'; @@ -60,6 +62,41 @@ export class Platform { }); channels.push(slack); } + if ( + config.telegramBotToken && + config.telegramChannel && + config.telegramUsers.length + ) { + const dotId = config.telegramDotId ?? workspace.dots()[0].id; + if (!workspace.dot(dotId)) + throw new Error('TELEGRAM_DOT_ID does not identify an existing Dot.'); + const channel = createTelegramChannel({ + name: config.telegramChannel, + config, + ownerId: workspace.ownerId, + paused: () => store.settings().paused, + agent: () => new DotAgent(store, workspace, config, dotId, true), + token: config.telegramBotToken, + ...(config.telegramMode ? { mode: config.telegramMode } : {}), + ...(config.telegramWebhookDomain + ? { + webhook: { + domain: config.telegramWebhookDomain, + ...(config.telegramWebhookPath + ? { path: config.telegramWebhookPath } + : {}), + ...(config.telegramWebhookPort + ? { port: config.telegramWebhookPort } + : {}), + ...(config.telegramWebhookSecret + ? { secretToken: config.telegramWebhookSecret } + : {}), + }, + } + : {}), + }); + channels.push(channel); + } const runtime = new CopilotRuntime({ intelligence: this.intelligence, identifyUser: async () => ({ @@ -85,11 +122,20 @@ export class Platform { }); } setup() { + const status = this.handler?.channels?.status(); + const slackStatus = this.config.slackChannel + ? status?.channels[this.config.slackChannel] ?? + (this.channelStartupFailed ? 'activation_failed' : 'setup_required') + : 'not_configured'; + const telegramStatus = this.config.telegramChannel + ? status?.channels[this.config.telegramChannel] ?? + (this.channelStartupFailed ? 'activation_failed' : 'setup_required') + : 'not_configured'; return setupStatus( this.config, - this.handler?.channels?.status().overall ?? - (this.config.slackChannel ? 'setup_required' : 'not_configured'), + slackStatus, this.channelStartupFailed, + telegramStatus, ); } requireReady() { From 98403954ca7836fe1ee8bd9c6dc9bda61828f2d6 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:56:23 +0330 Subject: [PATCH 07/26] feat: show Telegram setup status --- src/client/WorkspaceDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client/WorkspaceDialog.tsx b/src/client/WorkspaceDialog.tsx index 234fa4d2..dd5a4307 100644 --- a/src/client/WorkspaceDialog.tsx +++ b/src/client/WorkspaceDialog.tsx @@ -367,7 +367,8 @@ export function WorkspaceDialog({ : 'Text configuration is present. A successful conversation confirms connectivity.'}

- Slack: {workspace.setup.slack.replaceAll('_', ' ')}. Voice:{' '} + Slack: {workspace.setup.slack.replaceAll('_', ' ')}. Telegram:{' '} + {workspace.setup.telegram.replaceAll('_', ' ')}. Voice:{' '} {workspace.setup.voice ? 'configuration present' : 'needs VOICE_API_KEY and VOICE_MODEL'} From 7d5b559c01b14a8bc0395425e7ae718921cc4d97 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:56:25 +0330 Subject: [PATCH 08/26] docs: document Telegram environment --- .env.example | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.env.example b/.env.example index 75cd5cab..cc05a975 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,18 @@ SLACK_TEAM_ID= SLACK_USER_IDS= # SLACK_DOT_ID= (defaults to the initial Dot) +# Optional direct Telegram channel. Long-polling is the default and does not +# require a public URL. Restrict access with explicit Telegram numeric user IDs. +TELEGRAM_BOT_TOKEN= +# TELEGRAM_CHANNEL_NAME=opendots-telegram +TELEGRAM_USER_IDS= +# TELEGRAM_DOT_ID= (defaults to the initial Dot) +# TELEGRAM_MODE=polling +# TELEGRAM_WEBHOOK_DOMAIN=https://bot.example.com +# TELEGRAM_WEBHOOK_PATH=/telegram +# TELEGRAM_WEBHOOK_PORT=8443 +# TELEGRAM_WEBHOOK_SECRET= + # Optional persistent computers, one per Dot, using OpenBot services. # See docs/COMPUTERS.md. Keep these two different random secrets on the server. COMPUTER_SUPERVISOR_URL= From a022bbbf632ecde1d5cb19cb070679f63e8043db Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:56:49 +0330 Subject: [PATCH 09/26] test: cover Telegram channel identity and handlers --- tests/telegram-channel.test.ts | 128 +++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/telegram-channel.test.ts diff --git a/tests/telegram-channel.test.ts b/tests/telegram-channel.test.ts new file mode 100644 index 00000000..f0636c8e --- /dev/null +++ b/tests/telegram-channel.test.ts @@ -0,0 +1,128 @@ +import { expect, it, vi } from 'vitest'; +import type { + ChannelIdentityContext, + IncomingMessage, + Thread, +} from '@copilotkit/channels'; +import { createTelegramChannel, telegramHandlers, telegramIdentity } from '../src/server/telegram-channel.js'; + +const config = { telegramUsers: ['12345'] }; +const identity: ChannelIdentityContext = { + provider: 'telegram', + tenant: { id: '-100123' }, + installation: { id: 'telegram-bot' }, + actor: { id: '12345', kind: 'human' }, + conversation: { id: 'tg:-100123:user:12345', kind: 'supergroup' }, + trigger: 'message', + event: { id: '99' }, + raw: null, +}; +const message: IncomingMessage = { + text: 'Help', + user: { id: 'owner', name: 'Owner' }, + actor: { id: '12345', kind: 'human' }, + ref: { id: 'message' }, + platform: 'telegram', + operation: { + kind: 'created', + mentioned: true, + logicalMessageId: 'message', + revisionId: '1', + }, +}; + +function fixture(paused = false) { + const thread = { + runAgent: vi.fn(async () => undefined), + post: vi.fn(async () => ({ id: 'reply' })), + }; + const report = vi.fn(); + return { + thread, + report, + handlers: telegramHandlers({ + config, + ownerId: 'owner', + paused: () => paused, + report, + }), + }; +} + +it('requires an explicit Telegram user allowlist for the owner identity', () => { + expect(telegramIdentity(identity, config, 'owner')?.id).toBe('owner'); + expect( + telegramIdentity( + { ...identity, actor: { id: '99999', kind: 'human' } }, + config, + 'owner', + ), + ).toBeNull(); + expect( + telegramIdentity( + { ...identity, actor: { id: '12345', kind: 'bot' } }, + config, + 'owner', + ), + ).toBeNull(); + expect( + telegramIdentity({ ...identity, provider: 'slack' }, config, 'owner'), + ).toBeNull(); +}); + +it('runs allowed Telegram turns and ignores other platforms/users', async () => { + const f = fixture(); + await f.handlers.message({ thread: f.thread, message }); + expect(f.thread.runAgent).toHaveBeenCalledTimes(1); + + await f.handlers.message({ + thread: f.thread, + message: { ...message, platform: 'slack' }, + }); + await f.handlers.message({ + thread: f.thread, + message: { + ...message, + actor: { id: '99999', kind: 'human' }, + }, + }); + await f.handlers.message({ + thread: f.thread, + message: { + ...message, + operation: { ...message.operation!, kind: 'deleted' }, + }, + }); + expect(f.thread.runAgent).toHaveBeenCalledTimes(1); +}); + +it('posts a safe pause notice without running the agent', async () => { + const f = fixture(true); + await f.handlers.mention({ thread: f.thread, message }); + expect(f.thread.post).toHaveBeenCalledWith( + expect.stringMatching(/paused/i), + ); + expect(f.thread.runAgent).not.toHaveBeenCalled(); +}); + +it('does not leak provider errors into Telegram replies or reports', async () => { + const f = fixture(); + f.thread.runAgent.mockRejectedValue(new Error('SECRET provider details')); + await f.handlers.message({ thread: f.thread, message }); + expect(f.thread.post.mock.calls[0]?.[0]).not.toContain('SECRET'); + expect(JSON.stringify(f.report.mock.calls)).not.toContain('SECRET'); +}); + +it('registers a direct Telegram adapter without starting network I/O', () => { + const channel = createTelegramChannel({ + name: 'test-telegram', + agent: () => ({}) as never, + config, + ownerId: 'owner', + paused: () => false, + token: 'test-token', + }); + expect(channel.name).toBe('test-telegram'); + expect(channel.adapters).toHaveLength(1); + expect(channel.adapters[0]?.platform).toBe('telegram'); +}); From cd99353423aa7fc94f3480c0d28f5f52ee96ca5e Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:56:54 +0330 Subject: [PATCH 10/26] test: cover Telegram setup status --- tests/setup.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/setup.test.ts b/tests/setup.test.ts index db74aa78..6d5e40b2 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -14,6 +14,7 @@ const config: PlatformConfig = { }; it('never claims Slack online without a complete managed channel declaration', () => { expect(setupStatus(config, 'online').slack).toBe('not_configured'); + expect(setupStatus(config, 'online').telegram).toBe('not_configured'); expect( setupStatus({ ...config, slackChannel: 'support' }, 'online').slack, ).toBe('setup_required'); @@ -50,4 +51,5 @@ it('reports activation failure until the SDK recovers online', () => { 'activation_failed', ); expect(setupStatus(declared, 'online', true).slack).toBe('online'); + expect(setupStatus({ ...config, telegramBotToken: 'token', telegramChannel: 'bot', telegramUsers: ['12345'] }, 'online', false, 'online').telegram).toBe('online'); }); From 71ed05ebc10f6c5dde0cf49269e56f3a9f5a5bc2 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:57:21 +0330 Subject: [PATCH 11/26] docs: document Telegram support --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index b1ea8f22..d3aab25f 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,14 @@ https://github.com/user-attachments/assets/3c06cf71-39ed-4e2b-b846-5463b2722389 _Connect, talk, mute, minimize, and return to chat. This is a silent screen capture of a real call, with waiting time trimmed and playback accelerated._ +### Telegram + +Connect a Telegram bot directly to OpenDots with the CopilotKit Channels Telegram adapter. Long-polling is the default, so a public webhook endpoint is not required. Telegram users are mapped to the single OpenDots owner through an explicit numeric user-ID allowlist. + +In a private chat, every message is eligible. In groups, the bot responds when it is mentioned or when a user replies to one of its messages. The same Dot, tools, permissions, memory, and Intelligence conversation flow are used as web and Slack. + +See [Telegram setup](docs/SETUP.md#telegram) to configure the bot token, allowlist, selected Dot, and optional webhook mode. + ### Slack Mention a Dot through a managed Slack connection using Channels SDK, then continue in its thread. The integration follows [OpenTag](https://github.com/CopilotKit/OpenTag), with an explicit workspace/user allowlist and a selected specialist. See [Slack setup](docs/SETUP.md#slack) to connect your deployment. @@ -172,6 +180,7 @@ See [Setup](docs/SETUP.md) for configuration, Slack, calls, the browser service, | Pages | Searchable library, visual editor, slash commands, autosave, and revision checks | | Conversations | React SDK chat and Threads integration, page-specific conversations, and source links | | Slack | Managed Channels SDK declaration with workspace and user allowlists | +| Telegram | Direct Channels SDK adapter with explicit user allowlist and polling/webhook ingress | | Calls | WebRTC speech, delegated compute, bounded sessions, hangup, and timeline receipts | | Background work | Scheduled server-side turns in their original conversation, with pause and retry controls | | Browser | Separate read-only public-page service with page capture and navigation limits | From 455879eff739a346d81aeb9a8c901226dea91f09 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:57:24 +0330 Subject: [PATCH 12/26] docs: add Telegram setup guide --- docs/SETUP.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/SETUP.md b/docs/SETUP.md index 5d883ce6..8f0ab75f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -107,6 +107,58 @@ From an allowed user, mention the bot and verify a response in the same Slack th Local tests exercise channel behavior with fixtures. A live Slack mention/reply remains unverified until you provision the managed connection and model credentials. [Channels SDK documentation](https://github.com/CopilotKit/channels-sdk) describes extending the adapter and channel behavior. +## Telegram + +OpenDots can run a direct Telegram bot through the CopilotKit Channels Telegram adapter. This does not require creating a managed Intelligence Channel; the adapter is attached to the same runtime as the application's other conversations. + +### Configure the bot + +Create a bot with Telegram's `@BotFather`, then set these server-side variables: + +```dotenv +TELEGRAM_BOT_TOKEN=123456:replace-me +TELEGRAM_CHANNEL_NAME=opendots-telegram +TELEGRAM_USER_IDS=123456789 +TELEGRAM_DOT_ID= +TELEGRAM_MODE=polling +``` + +Use a comma-separated list for more than one permitted Telegram user. OpenDots requires an explicit allowlist because the template has a single-owner identity model. The Telegram bot token and IDs stay on the server. + +`TELEGRAM_DOT_ID` selects the Dot used for Telegram turns; when omitted, the first Dot is used. `TELEGRAM_CHANNEL_NAME` names the runtime Channel and defaults to `opendots-telegram` when a bot token is configured. + +### Polling + +Long-polling is the default: + +```dotenv +TELEGRAM_MODE=polling +``` + +No public URL is required. Start OpenDots normally and verify Telegram status under Settings & setup. + +In private chats, every user message is addressed to the bot. In groups and supergroups, the adapter only emits turns when the bot is mentioned or the user replies to one of the bot's messages. Forum topics keep their own Telegram conversation context. + +### Webhook + +For deployments where long-polling is not suitable, use webhook mode: + +```dotenv +TELEGRAM_MODE=webhook +TELEGRAM_WEBHOOK_DOMAIN=https://bot.example.com +TELEGRAM_WEBHOOK_PATH=/telegram +TELEGRAM_WEBHOOK_PORT=8443 +TELEGRAM_WEBHOOK_SECRET=replace-with-a-random-secret +``` + +The domain must be publicly reachable over HTTPS and point to the OpenDots process. Telegram's supported webhook ports include 443, 80, 88, and 8443; the adapter defaults to 8443. Put the webhook endpoint behind your reverse proxy when that is how the application is exposed. + +### Verify + +Send `/start` to the bot, then send a normal message and verify the selected Dot answers. In a group, mention the bot and then reply to the bot's response to verify conversation continuity. Test an unlisted Telegram user and confirm no agent run is started. Pause the assistant in OpenDots and verify that an allowed request receives the paused notice. + +The adapter supports Telegram inline interactions and streamed replies through the Channels SDK. OpenDots uses the same runtime, agent permissions, and Intelligence conversation machinery as its other channels. + ## Calls The included speech adapter uses the Realtime API at `api.openai.com`. Set `VOICE_API_KEY` to a key with access to that API and `VOICE_MODEL` to a supported Realtime model (the local UI test used `gpt-realtime-2.1`); `VOICE_NAME` selects the voice. `OPENAI_BASE_URL` changes the compute model endpoint only, not speech. Calls use browser microphone access and WebRTC. Hosted deployments need HTTPS. The server mediates provider setup and delegates compute to the selected Dot's conversation. From 4e2b45ebcd954cd2d0c722527fcf9ac6e3e9c36b Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:57:51 +0330 Subject: [PATCH 13/26] test: update setup fixture for Telegram --- tests/voice.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/voice.test.ts b/tests/voice.test.ts index 75e53452..44b203ee 100644 --- a/tests/voice.test.ts +++ b/tests/voice.test.ts @@ -50,6 +50,7 @@ function fixture() { model: true, browser: false, slack: 'not_configured', + telegram: 'not_configured', missing: [], }), }, From b5502e4568f2dbf9492835ee3168246e93637450 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:57:54 +0330 Subject: [PATCH 14/26] test: use a real Telegram agent fixture --- tests/telegram-channel.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/telegram-channel.test.ts b/tests/telegram-channel.test.ts index f0636c8e..57832e6e 100644 --- a/tests/telegram-channel.test.ts +++ b/tests/telegram-channel.test.ts @@ -4,6 +4,7 @@ import type { IncomingMessage, Thread, } from '@copilotkit/channels'; +import { HttpAgent } from '@ag-ui/client'; import { createTelegramChannel, telegramHandlers, telegramIdentity } from '../src/server/telegram-channel.js'; const config = { telegramUsers: ['12345'] }; @@ -116,7 +117,7 @@ it('does not leak provider errors into Telegram replies or reports', async () => it('registers a direct Telegram adapter without starting network I/O', () => { const channel = createTelegramChannel({ name: 'test-telegram', - agent: () => ({}) as never, + agent: () => new HttpAgent({ url: 'http://unused.invalid' }), config, ownerId: 'owner', paused: () => false, From 9c7e84afe716db492e7956b8a847c8802d57fea8 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 12:58:19 +0330 Subject: [PATCH 15/26] docs: add Telegram to architecture --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d3aab25f..215f136d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # OpenDots -### Always-on AI coworkers that move between text, calls, and Slack. +### Always-on AI coworkers that move between text, calls, Slack, and Telegram. **An open-source template for persistent AI agents, each with its own computer. Available on Web and Mobile.** @@ -136,7 +136,8 @@ The template uses TanStack AI for model streaming and server-tool execution, Cop flowchart TB Web["Web app: pages, Spaces, Dots, chat"] -->|AG-UI| Runtime[CopilotKit runtime] Slack[Slack] <--> Managed[Managed channel connection] - Managed <--> Channels[Channels SDK] + Telegram[Telegram] --> Channels[Channels SDK] + Managed <--> Channels Channels --> Agents[Specialist compute agents] Runtime --> Agents Agents --> AI[TanStack AI] From e6aa5af773b9fa28a030e06a5fa1f02a6398b30f Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 13:10:18 +0330 Subject: [PATCH 16/26] feat: add inbox and watcher types --- src/shared/types.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/shared/types.ts b/src/shared/types.ts index 77717390..a7b3a77e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -55,10 +55,36 @@ export interface Detail { runs: Run[]; events: TaskEvent[]; } +export interface InboxItem { + id: string; + kind: 'task_completed' | 'task_failed' | 'watch_triggered'; + title: string; + body: string; + taskId: string | null; + createdAt: number; + readAt: number | null; + dismissedAt: number | null; +} +export interface Watcher { + id: string; + url: string; + prompt: string; + threadId: string; + intervalSeconds: number; + lastCheckedAt: number | null; + lastHash: string | null; + nextCheckAt: number; + enabled: boolean; + error: string | null; + createdAt: number; + updatedAt: number; +} export interface State { settings: Settings; tasks: Task[]; memories: Memory[]; + inbox?: InboxItem[]; + watchers?: Watcher[]; mode: 'sample' | 'live'; configured: boolean; } From 7ede0c9b9ce8e32573a683ec7eaa6a6596148b84 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 13:10:21 +0330 Subject: [PATCH 17/26] feat: persist inbox items and URL watchers --- src/server/store.ts | 177 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/src/server/store.ts b/src/server/store.ts index b361de9a..cb2b3096 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -11,6 +11,8 @@ import type { Settings, Task, TaskEvent, + InboxItem, + Watcher, } from '../shared/types.js'; export type Claim = Task & { lease: string }; @@ -33,6 +35,10 @@ export class Store { CREATE TABLE IF NOT EXISTS memories (id TEXT PRIMARY KEY, text TEXT NOT NULL, createdAt INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS tasks_due ON tasks(status, nextRunAt); CREATE INDEX IF NOT EXISTS runs_task ON runs(taskId, startedAt); + CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL, taskId TEXT, createdAt INTEGER NOT NULL, readAt INTEGER, dismissedAt INTEGER); + CREATE INDEX IF NOT EXISTS inbox_created ON inbox(createdAt DESC); + CREATE TABLE IF NOT EXISTS watchers (id TEXT PRIMARY KEY, url TEXT NOT NULL, prompt TEXT NOT NULL, threadId TEXT NOT NULL, intervalSeconds INTEGER NOT NULL, lastCheckedAt INTEGER, lastHash TEXT, nextCheckAt INTEGER NOT NULL, enabled INTEGER NOT NULL, error TEXT, createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL); + CREATE INDEX IF NOT EXISTS watchers_due ON watchers(enabled, nextCheckAt); CREATE INDEX IF NOT EXISTS events_task ON events(taskId, id);`); this.db .prepare('INSERT OR IGNORE INTO settings VALUES (1, ?)') @@ -252,6 +258,12 @@ export class Store { ? 'Fictional sample brief ready.' : 'Research brief ready.', ); + this.createInboxItem( + 'task_completed', + 'Research is ready', + task.prompt + '\\n\\n' + result.text.slice(0, 800), + claim.id, + ); return true; }); } @@ -277,6 +289,171 @@ export class Store { this.event(claim.id, claim.lease, error); }); } + inbox(limit = 100): InboxItem[] { + return this.db + .prepare( + 'SELECT * FROM inbox WHERE dismissedAt IS NULL ORDER BY createdAt DESC LIMIT ?', + ) + .all(limit) as unknown as InboxItem[]; + } + unreadInboxCount(): number { + const row = this.db + .prepare( + 'SELECT COUNT(*) AS count FROM inbox WHERE dismissedAt IS NULL AND readAt IS NULL', + ) + .get() as { count: number }; + return Number(row.count); + } + createInboxItem( + kind: InboxItem['kind'], + title: string, + body: string, + taskId: string | null = null, + ): InboxItem { + const item: InboxItem = { + id: randomUUID(), + kind, + title, + body, + taskId, + createdAt: Date.now(), + readAt: null, + dismissedAt: null, + }; + this.db + .prepare('INSERT INTO inbox VALUES (?, ?, ?, ?, ?, ?, NULL, NULL)') + .run( + item.id, + item.kind, + item.title, + item.body, + item.taskId, + item.createdAt, + ); + return item; + } + inboxAction( + id: string, + action: 'read' | 'unread' | 'dismiss', + ): InboxItem | undefined { + const existing = this.db + .prepare('SELECT * FROM inbox WHERE id=?') + .get(id) as unknown as InboxItem | undefined; + if (!existing) return undefined; + const readAt = + action === 'read' + ? Date.now() + : action === 'unread' + ? null + : existing.readAt; + const dismissedAt = + action === 'dismiss' ? Date.now() : existing.dismissedAt; + this.db + .prepare('UPDATE inbox SET readAt=?, dismissedAt=? WHERE id=?') + .run(readAt, dismissedAt, id); + return this.db + .prepare('SELECT * FROM inbox WHERE id=?') + .get(id) as unknown as InboxItem; + } + watchers(): Watcher[] { + return this.db + .prepare('SELECT * FROM watchers ORDER BY createdAt DESC') + .all() + .map((row) => ({ + ...row, + intervalSeconds: Number(row.intervalSeconds), + enabled: !!row.enabled, + })) as unknown as Watcher[]; + } + dueWatchers(now = Date.now()): Watcher[] { + return this.db + .prepare( + 'SELECT * FROM watchers WHERE enabled=1 AND nextCheckAt<=? ORDER BY nextCheckAt LIMIT 20', + ) + .all(now) + .map((row) => ({ + ...row, + intervalSeconds: Number(row.intervalSeconds), + enabled: !!row.enabled, + })) as unknown as Watcher[]; + } + createWatcher( + url: string, + prompt: string, + threadId: string, + intervalSeconds: number, + ): Watcher { + const now = Date.now(); + const watcher: Watcher = { + id: randomUUID(), + url, + prompt, + threadId, + intervalSeconds, + lastCheckedAt: null, + lastHash: null, + nextCheckAt: now, + enabled: true, + error: null, + createdAt: now, + updatedAt: now, + }; + this.db + .prepare( + 'INSERT INTO watchers VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, 1, NULL, ?, ?)', + ) + .run( + watcher.id, + watcher.url, + watcher.prompt, + watcher.threadId, + watcher.intervalSeconds, + watcher.nextCheckAt, + watcher.createdAt, + watcher.updatedAt, + ); + return watcher; + } + updateWatcherCheck( + id: string, + patch: { + lastCheckedAt: number; + lastHash?: string | null; + nextCheckAt: number; + error?: string | null; + }, + ): Watcher | undefined { + this.db + .prepare( + 'UPDATE watchers SET lastCheckedAt=?, lastHash=COALESCE(?, lastHash), nextCheckAt=?, error=?, updatedAt=? WHERE id=?', + ) + .run( + patch.lastCheckedAt, + patch.lastHash ?? null, + patch.nextCheckAt, + patch.error ?? null, + Date.now(), + id, + ); + return this.watchers().find((watcher) => watcher.id === id); + } + watcherAction( + id: string, + action: 'pause' | 'resume' | 'delete', + ): Watcher | undefined { + const existing = this.watchers().find((watcher) => watcher.id === id); + if (!existing) return undefined; + if (action === 'delete') { + this.db.prepare('DELETE FROM watchers WHERE id=?').run(id); + return undefined; + } + this.db + .prepare( + 'UPDATE watchers SET enabled=?, error=NULL, updatedAt=? WHERE id=?', + ) + .run(action === 'resume' ? 1 : 0, Date.now(), id); + return this.watchers().find((watcher) => watcher.id === id); + } memories(): Memory[] { return this.db .prepare('SELECT * FROM memories ORDER BY createdAt DESC') From ef0eeffae4bac0f1fa9f470f6926cfd399771fef Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 13:10:24 +0330 Subject: [PATCH 18/26] feat: expose inbox and watcher APIs --- src/server/app.ts | 72 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/server/app.ts b/src/server/app.ts index fc9bae54..166baaa1 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -81,6 +81,8 @@ export function createApp({ settings: store.settings(), tasks: store.tasks(), memories: store.memories(), + inbox: store.inbox(), + watchers: store.watchers(), mode: config.mode, configured: configured(config), }), @@ -132,6 +134,76 @@ export function createApp({ platform.workspace.bindTask(task.id, parsed.data.threadId); return c.json(task, 201); }); + app.get('/api/inbox', (c) => + c.json({ items: store.inbox(), unread: store.unreadInboxCount() }), + ); + app.post('/api/inbox/:id/actions', async (c) => { + const parsed = z + .object({ action: z.enum(['read', 'unread', 'dismiss']) }) + .strict() + .safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Unknown inbox action.' }, 400); + const item = store.inboxAction(c.req.param('id'), parsed.data.action); + return item + ? c.json(item) + : c.json({ error: 'Inbox item not found.' }, 404); + }); + app.get('/api/watchers', (c) => c.json(store.watchers())); + app.post('/api/watchers', async (c) => { + const parsed = z + .object({ + url: z.string().trim().url().max(2048), + prompt: z.string().trim().min(3).max(4000), + threadId: z.string().min(1), + intervalSeconds: z.number().int().min(60).max(86400), + }) + .strict() + .safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) + return c.json( + { error: 'Enter a public URL, prompt, conversation, and an interval from 60 seconds to 24 hours.' }, + 400, + ); + if (!store.settings().researchAllowed) + return c.json({ error: 'Research is disabled in Settings.' }, 403); + if (platform) { + if (platform.setup().missing.length) + return c.json( + { error: `Setup required: ${platform.setup().missing.join(', ')}.` }, + 503, + ); + try { + platform.workspace.requireThread(parsed.data.threadId); + } catch { + return c.json( + { error: 'Conversation is not owned by this workspace.' }, + 403, + ); + } + } + return c.json( + store.createWatcher( + parsed.data.url, + parsed.data.prompt, + parsed.data.threadId, + parsed.data.intervalSeconds, + ), + 201, + ); + }); + app.post('/api/watchers/:id/actions', async (c) => { + const parsed = z + .object({ action: z.enum(['pause', 'resume', 'delete']) }) + .strict() + .safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Unknown watcher action.' }, 400); + const watcher = store.watcherAction(c.req.param('id'), parsed.data.action); + return parsed.data.action === 'delete' + ? c.json({ ok: true }) + : watcher + ? c.json(watcher) + : c.json({ error: 'Watcher not found.' }, 404); + }); app.get('/api/tasks/:id', (c) => { const detail = store.detail(c.req.param('id')); return detail ? c.json(detail) : c.json({ error: 'Task not found.' }, 404); From 112113bdbb43db1887e4b642bfa295d4d8bdecf9 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 13:10:26 +0330 Subject: [PATCH 19/26] feat: run URL watchers with server lifecycle --- src/server/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/server/index.ts b/src/server/index.ts index c9a73126..b3ff567a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,7 @@ import { createApp } from './app.js'; import { WorkspaceStore } from './workspace.js'; import { Platform } from './platform.js'; import type { PlatformConfig } from './platform-config.js'; +import { WatcherRunner } from './watcher-runner.js'; const host = process.env.HOST ?? '127.0.0.1'; const port = Number(process.env.PORT ?? 4310); const ownerToken = process.env.OWNER_TOKEN; @@ -93,6 +94,7 @@ const runner = new Runner( const wsOrigin = new URL( config.intelligenceWsUrl ?? 'wss://realtime.intelligence.copilotkit.ai', ).origin; +const watcherRunner = new WatcherRunner(store, workspace); const app = createApp({ store, runner, @@ -120,6 +122,7 @@ app.get('*', serveStatic({ path: './dist/client/index.html' })); const server = serve({ fetch: app.fetch, hostname: host, port }, (info) => { console.log(`OpenDots template listening on http://${host}:${info.port}`); runner.start(); + watcherRunner.start(); void platform .start() .catch((error) => @@ -130,7 +133,10 @@ const server = serve({ fetch: app.fetch, hostname: host, port }, (info) => { ); }); const shutdown = createShutdown({ - stopRunner: () => runner.stop(), + stopRunner: () => { + runner.stop(); + watcherRunner.stop(); + }, stopPlatform: () => platform.stop(), closeServer: () => new Promise((resolve, reject) => From a448dfce0ef343d08572757b94a86bb40c6811db Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 13:11:03 +0330 Subject: [PATCH 20/26] feat: add public URL watcher runner --- src/server/watcher-runner.ts | 188 +++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/server/watcher-runner.ts diff --git a/src/server/watcher-runner.ts b/src/server/watcher-runner.ts new file mode 100644 index 00000000..7eddbeca --- /dev/null +++ b/src/server/watcher-runner.ts @@ -0,0 +1,188 @@ +import { createHash } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; +import { Store } from './store.js'; +import { WorkspaceStore } from './workspace.js'; + +const MAX_BODY = 1_000_000; +const MAX_REDIRECTS = 3; + +function privateAddress(address: string): boolean { + if (isIP(address) === 4) { + const [a, b] = address.split('.').map(Number); + return ( + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + a === 0 + ); + } + const value = address.toLowerCase(); + return ( + value === '::1' || + value === '::' || + value.startsWith('fc') || + value.startsWith('fd') || + value.startsWith('fe80:') + ); +} + +async function validatePublicUrl(value: string): Promise { + const url = new URL(value); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password + ) + throw new Error('Watcher URLs must be public HTTP(S) URLs without credentials.'); + + const hostname = url.hostname.toLowerCase(); + if ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname.endsWith('.local') + ) + throw new Error('Watcher URL resolves to a local hostname.'); + + if (isIP(hostname) && privateAddress(hostname)) + throw new Error('Watcher URL resolves to a private address.'); + + const addresses = await lookup(hostname, { all: true }); + if (!addresses.length || addresses.some((entry) => privateAddress(entry.address))) + throw new Error('Watcher URL resolves to a private or local address.'); + + return url; +} + +async function readBody(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) return ''; + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_BODY) + throw new Error('Watcher response exceeded the 1 MB limit.'); + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + + const data = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + data.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(data); +} + +async function fetchPublicBody(start: string): Promise { + let current = await validatePublicUrl(start); + + for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect++) { + const response = await fetch(current, { + redirect: 'manual', + headers: { 'user-agent': 'OpenDots/0.1 watcher' }, + signal: AbortSignal.timeout(15_000), + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) throw new Error('Watcher redirect did not provide a location.'); + if (redirect === MAX_REDIRECTS) + throw new Error('Watcher followed too many redirects.'); + current = await validatePublicUrl(new URL(location, current).toString()); + continue; + } + + if (!response.ok) throw new Error(`Watcher HTTP ${response.status}.`); + return readBody(response); + } + + throw new Error('Watcher request failed.'); +} + +export class WatcherRunner { + private timer?: ReturnType; + private running = false; + + constructor( + private store: Store, + private workspace: WorkspaceStore, + ) {} + + start() { + if (this.timer) return; + this.timer = setInterval(() => void this.tick(), 15_000); + void this.tick(); + } + + stop() { + clearInterval(this.timer); + this.timer = undefined; + } + + async tick() { + const settings = this.store.settings(); + if ( + this.running || + settings.paused || + !settings.researchAllowed || + !this.store.dueWatchers().length + ) + return; + + this.running = true; + try { + for (const watcher of this.store.dueWatchers()) { + const checkedAt = Date.now(); + const nextCheckAt = + checkedAt + watcher.intervalSeconds * 1000; + + try { + this.workspace.requireThread(watcher.threadId); + const body = await fetchPublicBody(watcher.url); + const hash = createHash('sha256').update(body).digest('hex'); + + if (watcher.lastHash && watcher.lastHash !== hash) { + const task = this.store.createTask( + `A watched page changed: ${watcher.url}\\n\\n${watcher.prompt}`, + ); + this.workspace.bindTask(task.id, watcher.threadId); + this.store.createInboxItem( + 'watch_triggered', + 'A watcher detected a change', + `${watcher.url}\\n\\nA new task was queued in the selected conversation.`, + task.id, + ); + } + + this.store.updateWatcherCheck(watcher.id, { + lastCheckedAt: checkedAt, + lastHash: hash, + nextCheckAt, + error: null, + }); + } catch (error) { + this.store.updateWatcherCheck(watcher.id, { + lastCheckedAt: checkedAt, + nextCheckAt, + error: + error instanceof Error + ? error.message.slice(0, 300) + : 'Watcher check failed.', + }); + } + } + } finally { + this.running = false; + } + } +} From e82289209134f521f03973f28901b402d430ec65 Mon Sep 17 00:00:00 2001 From: vaheeD khoshnouD Date: Fri, 2 Oct 2026 13:11:07 +0330 Subject: [PATCH 21/26] feat: add inbox and watcher controls --- src/client/App.tsx | 232 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 221 insertions(+), 11 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index 353b631b..9fe67660 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -6,6 +6,7 @@ import { CopilotKitProvider } from '@copilotkit/react-core/v2'; import { ArrowUp, ArrowUpRight, + Bell, BookOpen, Clock3, Code2, @@ -45,9 +46,9 @@ export function App() { const [workspace, setWorkspace] = useState(); const [selectedDot, setSelectedDot] = useState(''); const [selectedThread, setSelectedThread] = useState(); - const [view, rawSetView] = useState<'chat' | 'tasks' | 'memories' | 'space'>( - 'chat', - ); + const [view, rawSetView] = useState< + 'chat' | 'tasks' | 'memories' | 'space' | 'inbox' + >('chat'); const dirtyPage = useRef(false); const [spaceId, setSpaceId] = useState(''); const [pageId, setPageId] = useState(); @@ -289,8 +290,8 @@ export function App() { > - )} - {view === 'memories' ? ( + {view === 'inbox' ? ( + <> +

+ {(state.inbox ?? []).map((item) => ( +
+
+ +
+
+ {item.title} + {new Date(item.createdAt).toLocaleString()} +

{item.body}

+
+ {item.taskId && ( + + )} + + +
+
+
+ ))} +
+ {!(state.inbox ?? []).length && ( +
+ +

Nothing waiting for you.

+

Completed work, failures, and watcher triggers appear here.

+
+ )} + + ) : view === 'memories' ? ( <>
{state.memories.map((memory) => ( @@ -773,6 +859,130 @@ export function App() { ) : ( <> +
+
+

Watchers

+ Queue a task when a public page changes. +
+ {workspace.conversations.length ? ( +
{ + event.preventDefault(); + const form = event.currentTarget; + const data = new FormData(form); + const minutes = Number(data.get('minutes') ?? 15); + const url = String(data.get('url') ?? '').trim(); + const prompt = String(data.get('prompt') ?? '').trim(); + const threadId = String(data.get('threadId') ?? ''); + if ( + !url || + !prompt || + !threadId || + !Number.isFinite(minutes) || + minutes < 1 || + minutes > 1440 + ) { + setError('Complete the watcher fields with a 1–1440 minute interval.'); + return; + } + const ok = await mutate('/watchers', 'POST', { + url, + prompt, + threadId, + intervalSeconds: Math.round(minutes * 60), + }); + if (ok) form.reset(); + }} + > + +