From f97ce089f9052ce063cd3dd803e38e308a3990f9 Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:09:55 +0200 Subject: [PATCH 01/12] Wire messaging channels (Telegram) into the Assistant Persist messaging-channel configs (id/type/token/allowFrom/enabled) in electron-store, mirroring the existing Provider lifecycle. Introduce a ChannelAdapter interface with a dispatch-callback contract so adapters never import the assistant module. Wire the existing TelegramAdapter to route inbound text to AssistantRegistry.get(DEFAULT_ASSISTANT_ID).send and reply via grammy. Register a ChannelManager service that owns adapter lifecycles, started after IPC bootstrap so config edits hot-apply via new app:get-channels / app:add-channel / app:delete-channel IPC. https://claude.ai/code/session_01R6i53ytUebsbZFZhF4ZuoB --- src/main/channels/adapter.ts | 10 +++ src/main/channels/manager.ts | 107 ++++++++++++++++++++++++++++++ src/main/channels/telegram.ts | 121 +++++++++++++++++++++------------- src/main/store/channels.ts | 39 +++++++++++ src/main/store/types.ts | 4 +- 5 files changed, 234 insertions(+), 47 deletions(-) create mode 100644 src/main/channels/adapter.ts create mode 100644 src/main/channels/manager.ts create mode 100644 src/main/store/channels.ts diff --git a/src/main/channels/adapter.ts b/src/main/channels/adapter.ts new file mode 100644 index 000000000..fe0b80b04 --- /dev/null +++ b/src/main/channels/adapter.ts @@ -0,0 +1,10 @@ +import type { ChannelType } from '../../shared/types'; + +export type ChannelDispatch = (senderId: string, text: string) => Promise; + +export interface ChannelAdapter { + readonly id: string; + readonly type: ChannelType; + start(): Promise; + stop(): Promise; +} diff --git a/src/main/channels/manager.ts b/src/main/channels/manager.ts new file mode 100644 index 000000000..ae01c3849 --- /dev/null +++ b/src/main/channels/manager.ts @@ -0,0 +1,107 @@ +import type { Disposable } from '../core/service-container'; +import type { StoreService } from '../store'; +import type { LoggerService } from '../services/logger'; +import type { AssistantRegistry } from '../assistant'; +import { DEFAULT_ASSISTANT_ID } from '../assistant'; +import type { Channel } from '../../shared/types'; +import type { ChannelAdapter, ChannelDispatch } from './adapter'; +import { TelegramAdapter } from './telegram'; + +/** + * Owns the lifecycle of all messaging-channel adapters. + * + * On `start()` it spawns adapters for every enabled channel persisted in + * the store. On `upsert(channel)` it stops any running adapter for the + * same id and (re)spawns it if `enabled === true`. On `remove(id)` it + * stops and forgets the adapter. `destroy()` is invoked via the + * Disposable contract during container shutdown. + * + * Inbound messages are dispatched to the default Assistant; the Assistant's + * reply text is sent back through the originating adapter. The dispatch + * callback decouples adapters from the assistant module. + */ +export class ChannelManager implements Disposable { + private readonly adapters = new Map(); + + constructor( + private readonly store: StoreService, + private readonly registry: AssistantRegistry, + private readonly logger: LoggerService + ) {} + + async start(): Promise { + for (const ch of this.store.getChannels()) { + if (ch.enabled) await this.spawn(ch); + } + this.logger.info( + 'ChannelManager', + `Started with ${this.adapters.size} adapter(s)` + ); + } + + async upsert(ch: Channel): Promise { + await this.remove(ch.id); + if (ch.enabled) await this.spawn(ch); + } + + async remove(id: string): Promise { + const adapter = this.adapters.get(id); + if (!adapter) return; + this.adapters.delete(id); + try { + await adapter.stop(); + this.logger.info('ChannelManager', `Stopped adapter "${id}"`); + } catch (err) { + this.logger.error('ChannelManager', `Failed to stop "${id}"`, err); + } + } + + destroy(): void { + for (const [id, adapter] of this.adapters) { + adapter.stop().catch((err) => { + this.logger.error('ChannelManager', `destroy ${id}`, err); + }); + } + this.adapters.clear(); + this.logger.info('ChannelManager', 'Disposed'); + } + + private async spawn(ch: Channel): Promise { + const dispatch: ChannelDispatch = (_senderId, text) => + this.registry.get(DEFAULT_ASSISTANT_ID).send(text); + + let adapter: ChannelAdapter; + try { + adapter = this.build(ch, dispatch); + } catch (err) { + this.logger.error('ChannelManager', `Failed to build "${ch.id}"`, err); + return; + } + + try { + await adapter.start(); + this.adapters.set(ch.id, adapter); + this.logger.info( + 'ChannelManager', + `Started ${ch.type} adapter "${ch.id}"` + ); + } catch (err) { + this.logger.error('ChannelManager', `Failed to start "${ch.id}"`, err); + } + } + + private build(ch: Channel, dispatch: ChannelDispatch): ChannelAdapter { + switch (ch.type) { + case 'TELEGRAM': + return new TelegramAdapter({ + id: ch.id, + bot_token: ch.token, + allow_from: ch.allowFrom, + dispatch, + logger: this.logger, + }); + default: + throw new Error(`Unsupported channel type: ${ch.type}`); + } + } +} diff --git a/src/main/channels/telegram.ts b/src/main/channels/telegram.ts index f5d15eeed..f09b59f19 100644 --- a/src/main/channels/telegram.ts +++ b/src/main/channels/telegram.ts @@ -1,50 +1,79 @@ -import { Bot } from "grammy"; +import { Bot } from 'grammy'; +import type { LoggerService } from '../services/logger'; +import type { ChannelAdapter, ChannelDispatch } from './adapter'; const TELEGRAM_MAX_LENGTH = 4096; -export class TelegramAdapter { - private bot: Bot; - private allowFrom: Set; - - constructor(opts: { - bot_token: string; - allow_from: string[]; - }) { - this.bot = new Bot(opts.bot_token); - this.allowFrom = new Set(opts.allow_from.map(String)); - - // plain text only — no commands, photos, stickers, etc. - this.bot.on("message:text", async (ctx) => { - const text = ctx.message.text; - if (!text || text.startsWith("/")) return; - - const senderId = String(ctx.from?.id ?? ""); - if (this.allowFrom.size > 0 && !this.allowFrom.has(senderId)) { - console.warn(`Ignored message from unauthorized user ${senderId}`); - return; - } - }); - } - - async send(chatId: string, text: string): Promise { - let remaining = text; - while (remaining.length > 0) { - const chunk = remaining.slice(0, TELEGRAM_MAX_LENGTH); - remaining = remaining.slice(TELEGRAM_MAX_LENGTH); - await this.bot.api.sendMessage(chatId, chunk); - } - } - - async start(): Promise { - // start polling without awaiting (start() resolves only on stop) - this.bot.start({ drop_pending_updates: true }).catch((e) => { - console.error("Telegram polling error:", e); - }); - // give grammy a tick to init - await new Promise((r) => setTimeout(r, 100)); - } - - async stop(): Promise { - await this.bot.stop(); - } +export interface TelegramAdapterOptions { + id: string; + bot_token: string; + allow_from: string[]; + dispatch: ChannelDispatch; + logger: LoggerService; +} + +export class TelegramAdapter implements ChannelAdapter { + readonly id: string; + readonly type = 'TELEGRAM' as const; + + private readonly bot: Bot; + private readonly allowFrom: Set; + private readonly logger: LoggerService; + + constructor(opts: TelegramAdapterOptions) { + this.id = opts.id; + this.bot = new Bot(opts.bot_token); + this.allowFrom = new Set(opts.allow_from.map(String)); + this.logger = opts.logger; + + // plain text only — no commands, photos, stickers, etc. + this.bot.on('message:text', async (ctx) => { + const text = ctx.message.text; + if (!text || text.startsWith('/')) return; + + const senderId = String(ctx.from?.id ?? ''); + if (this.allowFrom.size > 0 && !this.allowFrom.has(senderId)) { + this.logger.warn( + 'TelegramAdapter', + `Ignored message from unauthorized user ${senderId}` + ); + return; + } + + try { + const reply = await opts.dispatch(senderId, text); + if (reply.trim().length > 0) { + await this.send(String(ctx.chat.id), reply); + } + } catch (err) { + this.logger.error( + 'TelegramAdapter', + `dispatch failed (chat ${ctx.chat.id})`, + err + ); + } + }); + } + + async send(chatId: string, text: string): Promise { + let remaining = text; + while (remaining.length > 0) { + const chunk = remaining.slice(0, TELEGRAM_MAX_LENGTH); + remaining = remaining.slice(TELEGRAM_MAX_LENGTH); + await this.bot.api.sendMessage(chatId, chunk); + } + } + + async start(): Promise { + // start polling without awaiting (start() resolves only on stop) + this.bot.start({ drop_pending_updates: true }).catch((e) => { + this.logger.error('TelegramAdapter', 'Telegram polling error', e); + }); + // give grammy a tick to init + await new Promise((r) => setTimeout(r, 100)); + } + + async stop(): Promise { + await this.bot.stop(); + } } diff --git a/src/main/store/channels.ts b/src/main/store/channels.ts new file mode 100644 index 000000000..46e10a99b --- /dev/null +++ b/src/main/store/channels.ts @@ -0,0 +1,39 @@ +import { isKnownChannelType } from '../../shared/types'; +import type { Channel } from '../../shared/types'; +import { isRecord } from './utils'; + +export function normalizeChannelInput(value: unknown): Channel | null { + if (!isRecord(value)) return null; + const id = typeof value.id === 'string' ? value.id.trim() : ''; + if (!id) return null; + if (!isKnownChannelType(value.type)) return null; + const token = typeof value.token === 'string' ? value.token : ''; + const enabled = value.enabled === true; + const allowFrom = Array.isArray(value.allowFrom) + ? value.allowFrom.filter((s): s is string => typeof s === 'string').map((s) => s.trim()) + : []; + return { id, type: value.type, enabled, token, allowFrom }; +} + +export function normalizeChannels(value: unknown): Channel[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: Channel[] = []; + for (const entry of value) { + const channel = normalizeChannelInput(entry); + if (!channel || seen.has(channel.id)) continue; + seen.add(channel.id); + out.push(channel); + } + return out; +} + +export function cloneChannel(channel: Channel): Channel { + return { + id: channel.id, + type: channel.type, + enabled: channel.enabled, + token: channel.token, + allowFrom: [...channel.allowFrom], + }; +} diff --git a/src/main/store/types.ts b/src/main/store/types.ts index 5f1bf6e48..b3a501fbd 100644 --- a/src/main/store/types.ts +++ b/src/main/store/types.ts @@ -1,4 +1,4 @@ -import type { AgentSettings, Provider, UserProfile } from '../../shared/types'; +import type { AgentSettings, Channel, Provider, UserProfile } from '../../shared/types'; export interface WorkspaceInfo { path: string; @@ -8,6 +8,7 @@ export interface WorkspaceInfo { export interface StoreSchema { providers: Provider[]; agents: AgentSettings[]; + channels: Channel[]; currentWorkspace: string | null; recentWorkspaces: WorkspaceInfo[]; startupCount: number; @@ -18,6 +19,7 @@ export interface StoreSchema { export const DEFAULTS: StoreSchema = { providers: [], agents: [], + channels: [], currentWorkspace: null, recentWorkspaces: [], startupCount: 0, From b33a4addccc90e704ec9c328d2f7cb9fa7e00d9e Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:11:18 +0200 Subject: [PATCH 02/12] Update bootstrap, IPC, store, validators, preload, shared types for channels https://claude.ai/code/session_01R6i53ytUebsbZFZhF4ZuoB --- src/main/shared/validators.ts | 68 ++++++++++++++++++++++++++++++++- src/main/store/store-service.ts | 60 ++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/main/shared/validators.ts b/src/main/shared/validators.ts index 346d6b441..cc07beef6 100644 --- a/src/main/shared/validators.ts +++ b/src/main/shared/validators.ts @@ -3,7 +3,8 @@ * Used by AppIpc (store handlers) to validate user inputs. */ -import type { AgentSettings, Provider } from '../../shared/types'; +import type { AgentSettings, Channel, Provider } from '../../shared/types'; +import { isKnownChannelType } from '../../shared/types'; import { isKnownProvider } from '../../shared/providers'; export class StoreValidators { @@ -117,6 +118,71 @@ export class StoreValidators { } } +/** + * Input validators for Channel store operations. + */ +export class ChannelValidators { + private static readonly MAX_TOKEN_LENGTH = 500; + private static readonly MAX_FIELD_LENGTH = 200; + private static readonly DANGEROUS_CHARS = /[<>;"'`]/; + private static readonly ID_PATTERN = /^[a-zA-Z0-9\-_]+$/; + + static validateChannelId(id: string): void { + if (typeof id !== 'string' || id.trim().length === 0) { + throw new Error('Channel id must be a non-empty string'); + } + if (id.length > this.MAX_FIELD_LENGTH) { + throw new Error('Channel id exceeds maximum length'); + } + if (!this.ID_PATTERN.test(id)) { + throw new Error('Channel id contains invalid characters'); + } + } + + static validateChannel(channel: Channel): void { + if (typeof channel !== 'object' || channel === null) { + throw new Error('Channel must be an object'); + } + this.validateChannelId(channel.id); + + if (!isKnownChannelType(channel.type)) { + throw new Error(`Unknown channel type: ${String(channel.type)}`); + } + + if (typeof channel.enabled !== 'boolean') { + throw new Error('Channel enabled must be a boolean'); + } + + if (typeof channel.token !== 'string') { + throw new Error('Channel token must be a string'); + } + if (channel.token.length > this.MAX_TOKEN_LENGTH) { + throw new Error(`Channel token exceeds maximum length of ${this.MAX_TOKEN_LENGTH}`); + } + if (channel.token.length > 0 && this.DANGEROUS_CHARS.test(channel.token)) { + throw new Error('Channel token contains invalid characters'); + } + if (channel.enabled && channel.token.trim().length === 0) { + throw new Error('Enabled channel must have a non-empty token'); + } + + if (!Array.isArray(channel.allowFrom)) { + throw new Error('Channel allowFrom must be an array'); + } + for (const sender of channel.allowFrom) { + if (typeof sender !== 'string' || sender.trim().length === 0) { + throw new Error('Channel allowFrom entries must be non-empty strings'); + } + if (sender.length > this.MAX_FIELD_LENGTH) { + throw new Error('Channel allowFrom entry exceeds maximum length'); + } + if (!this.ID_PATTERN.test(sender)) { + throw new Error('Channel allowFrom entry contains invalid characters'); + } + } + } +} + /** * Validators for agent service inputs */ diff --git a/src/main/store/store-service.ts b/src/main/store/store-service.ts index aa547a772..42286c4a3 100644 --- a/src/main/store/store-service.ts +++ b/src/main/store/store-service.ts @@ -1,6 +1,11 @@ import Store from 'electron-store'; import { MAX_RECENT_WORKSPACES } from '../constants'; -import type { AgentSettings, Provider, UserProfile } from '../../shared/types'; +import type { + AgentSettings, + Channel, + Provider, + UserProfile, +} from '../../shared/types'; import type { AppStartupInfo } from '../../shared/types'; import { DEFAULTS, type SettingsStore, type StoreSchema, type WorkspaceInfo } from './types'; import { @@ -13,6 +18,11 @@ import { normalizeAgentInput, normalizeAgents, } from './agents'; +import { + cloneChannel, + normalizeChannelInput, + normalizeChannels, +} from './channels'; export class StoreService { private store: SettingsStore; @@ -27,6 +37,7 @@ export class StoreService { this.migrateLegacyProviderSettings(); this.normalizeStoredProviders(); this.normalizeStoredAgents(); + this.normalizeStoredChannels(); this.reconcileStartupState(); this.incrementStartupCount(); } @@ -67,6 +78,42 @@ export class StoreService { this.store.set('providers', providers); } + // --- Channel methods --- + + getChannels(): Channel[] { + return this.store.get('channels').map(cloneChannel); + } + + getChannelById(channelId: string): Channel | undefined { + const normalized = channelId.trim(); + return this.getChannels().find((c) => c.id === normalized); + } + + /** + * Upsert a channel entry by its `id` (one entry per channel id). + */ + addChannel(channel: Channel): Channel { + const incoming = normalizeChannelInput(channel); + if (!incoming) { + throw new Error('Invalid channel'); + } + const channels = this.store.get('channels').map(cloneChannel); + const index = channels.findIndex((c) => c.id === incoming.id); + if (index >= 0) { + channels[index] = incoming; + } else { + channels.push(incoming); + } + this.store.set('channels', channels); + return cloneChannel(incoming); + } + + deleteChannel(channelId: string): void { + const normalized = channelId.trim(); + const channels = this.store.get('channels').filter((c) => c.id !== normalized); + this.store.set('channels', channels); + } + getProfile(): UserProfile | null { const profile = this.store.get('profile'); if (!profile) return null; @@ -219,6 +266,17 @@ export class StoreService { } } + private normalizeStoredChannels(): void { + const normalized = normalizeChannels(this.rawStore.get('channels')); + const current = this.store.get('channels'); + const needsRewrite = + current.length !== normalized.length || + current.some((c, i) => JSON.stringify(c) !== JSON.stringify(normalized[i])); + if (needsRewrite) { + this.store.set('channels', normalized); + } + } + private reconcileStartupState(): void { if (this.store.get('startupCount') > 0 || this.store.get('isInitialized')) { return; From a1a0334d9cde68d3b49b12b7406f673ad66c8802 Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:13:24 +0200 Subject: [PATCH 03/12] Add channel IPC names, types, and preload bindings https://claude.ai/code/session_01R6i53ytUebsbZFZhF4ZuoB --- src/shared/channels.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/shared/channels.ts b/src/shared/channels.ts index 55450bf29..9b22941cb 100644 --- a/src/shared/channels.ts +++ b/src/shared/channels.ts @@ -55,6 +55,7 @@ import type { Provider, ProviderModelInfo, UserProfile, + Channel, } from './types'; import type { ShortcutId } from './shortcuts'; @@ -175,6 +176,10 @@ export const AppChannels = { getProviders: 'app:get-providers', addProvider: 'app:add-provider', deleteProvider: 'app:delete-provider', + // Channels (messaging integrations) + getChannels: 'app:get-channels', + addChannel: 'app:add-channel', + deleteChannel: 'app:delete-channel', getAgents: 'app:get-agents', updateAgent: 'app:update-agent', getStartupInfo: 'app:get-startup-info', @@ -232,6 +237,9 @@ export interface InvokeChannelMap { [AppChannels.getProviders]: { args: []; result: Provider[] }; [AppChannels.addProvider]: { args: [provider: Provider]; result: Provider }; [AppChannels.deleteProvider]: { args: [id: string]; result: void }; + [AppChannels.getChannels]: { args: []; result: Channel[] }; + [AppChannels.addChannel]: { args: [channel: Channel]; result: Channel }; + [AppChannels.deleteChannel]: { args: [id: string]; result: void }; [AppChannels.getAgents]: { args: []; result: AgentSettings[] }; [AppChannels.updateAgent]: { args: [agent: AgentSettings]; result: AgentSettings }; [AppChannels.getStartupInfo]: { args: []; result: AppStartupInfo }; From 6f1c428679d8026fab6064477863d4d9d0570617 Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:15:46 +0200 Subject: [PATCH 04/12] Add Channel types and bootstrap channel manager registration https://claude.ai/code/session_01R6i53ytUebsbZFZhF4ZuoB --- src/shared/types.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/shared/types.ts b/src/shared/types.ts index c51fc52f3..2940daa2d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -138,6 +138,29 @@ export const PROVIDERS = [ { id: 'anthropic', name: 'Anthropic', apiKey: '' }, ] as const satisfies readonly Provider[]; +// ---- Channels -------------------------------------------------------------- + +export type ChannelType = 'TELEGRAM' | 'WHATSAPP'; + +export interface Channel { + id: string; + type: ChannelType; + enabled: boolean; + token: string; + allowFrom: string[]; +} + +export const CHANNEL_TYPES = [ + { id: 'TELEGRAM', name: 'Telegram' }, + { id: 'WHATSAPP', name: 'WhatsApp' }, +] as const; + +export function isKnownChannelType(value: unknown): value is ChannelType { + return ( + typeof value === 'string' && CHANNEL_TYPES.some((t) => t.id === value) + ); +} + /** * Single model entry as returned by a provider's `/models` endpoint. * Shape is normalised across providers. From f5ab624f5058e7af6f6553fecfe6fb16090cf853 Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:17:44 +0200 Subject: [PATCH 05/12] Revert: delete src/main/channels/adapter.ts https://claude.ai/code/session_01R6i53ytUebsbZFZhF4ZuoB --- src/main/channels/adapter.ts | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 src/main/channels/adapter.ts diff --git a/src/main/channels/adapter.ts b/src/main/channels/adapter.ts deleted file mode 100644 index fe0b80b04..000000000 --- a/src/main/channels/adapter.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ChannelType } from '../../shared/types'; - -export type ChannelDispatch = (senderId: string, text: string) => Promise; - -export interface ChannelAdapter { - readonly id: string; - readonly type: ChannelType; - start(): Promise; - stop(): Promise; -} From 3ffe5d2c2f0bcbd0cdb4db3483a16909c053fd07 Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:17:50 +0200 Subject: [PATCH 06/12] Revert: delete src/main/channels/manager.ts --- src/main/channels/manager.ts | 107 ----------------------------------- 1 file changed, 107 deletions(-) delete mode 100644 src/main/channels/manager.ts diff --git a/src/main/channels/manager.ts b/src/main/channels/manager.ts deleted file mode 100644 index ae01c3849..000000000 --- a/src/main/channels/manager.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { Disposable } from '../core/service-container'; -import type { StoreService } from '../store'; -import type { LoggerService } from '../services/logger'; -import type { AssistantRegistry } from '../assistant'; -import { DEFAULT_ASSISTANT_ID } from '../assistant'; -import type { Channel } from '../../shared/types'; -import type { ChannelAdapter, ChannelDispatch } from './adapter'; -import { TelegramAdapter } from './telegram'; - -/** - * Owns the lifecycle of all messaging-channel adapters. - * - * On `start()` it spawns adapters for every enabled channel persisted in - * the store. On `upsert(channel)` it stops any running adapter for the - * same id and (re)spawns it if `enabled === true`. On `remove(id)` it - * stops and forgets the adapter. `destroy()` is invoked via the - * Disposable contract during container shutdown. - * - * Inbound messages are dispatched to the default Assistant; the Assistant's - * reply text is sent back through the originating adapter. The dispatch - * callback decouples adapters from the assistant module. - */ -export class ChannelManager implements Disposable { - private readonly adapters = new Map(); - - constructor( - private readonly store: StoreService, - private readonly registry: AssistantRegistry, - private readonly logger: LoggerService - ) {} - - async start(): Promise { - for (const ch of this.store.getChannels()) { - if (ch.enabled) await this.spawn(ch); - } - this.logger.info( - 'ChannelManager', - `Started with ${this.adapters.size} adapter(s)` - ); - } - - async upsert(ch: Channel): Promise { - await this.remove(ch.id); - if (ch.enabled) await this.spawn(ch); - } - - async remove(id: string): Promise { - const adapter = this.adapters.get(id); - if (!adapter) return; - this.adapters.delete(id); - try { - await adapter.stop(); - this.logger.info('ChannelManager', `Stopped adapter "${id}"`); - } catch (err) { - this.logger.error('ChannelManager', `Failed to stop "${id}"`, err); - } - } - - destroy(): void { - for (const [id, adapter] of this.adapters) { - adapter.stop().catch((err) => { - this.logger.error('ChannelManager', `destroy ${id}`, err); - }); - } - this.adapters.clear(); - this.logger.info('ChannelManager', 'Disposed'); - } - - private async spawn(ch: Channel): Promise { - const dispatch: ChannelDispatch = (_senderId, text) => - this.registry.get(DEFAULT_ASSISTANT_ID).send(text); - - let adapter: ChannelAdapter; - try { - adapter = this.build(ch, dispatch); - } catch (err) { - this.logger.error('ChannelManager', `Failed to build "${ch.id}"`, err); - return; - } - - try { - await adapter.start(); - this.adapters.set(ch.id, adapter); - this.logger.info( - 'ChannelManager', - `Started ${ch.type} adapter "${ch.id}"` - ); - } catch (err) { - this.logger.error('ChannelManager', `Failed to start "${ch.id}"`, err); - } - } - - private build(ch: Channel, dispatch: ChannelDispatch): ChannelAdapter { - switch (ch.type) { - case 'TELEGRAM': - return new TelegramAdapter({ - id: ch.id, - bot_token: ch.token, - allow_from: ch.allowFrom, - dispatch, - logger: this.logger, - }); - default: - throw new Error(`Unsupported channel type: ${ch.type}`); - } - } -} From 9419beaf179ac096f08e492876f51ea13dc3cf31 Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:17:56 +0200 Subject: [PATCH 07/12] Revert: delete src/main/store/channels.ts --- src/main/store/channels.ts | 39 -------------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 src/main/store/channels.ts diff --git a/src/main/store/channels.ts b/src/main/store/channels.ts deleted file mode 100644 index 46e10a99b..000000000 --- a/src/main/store/channels.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { isKnownChannelType } from '../../shared/types'; -import type { Channel } from '../../shared/types'; -import { isRecord } from './utils'; - -export function normalizeChannelInput(value: unknown): Channel | null { - if (!isRecord(value)) return null; - const id = typeof value.id === 'string' ? value.id.trim() : ''; - if (!id) return null; - if (!isKnownChannelType(value.type)) return null; - const token = typeof value.token === 'string' ? value.token : ''; - const enabled = value.enabled === true; - const allowFrom = Array.isArray(value.allowFrom) - ? value.allowFrom.filter((s): s is string => typeof s === 'string').map((s) => s.trim()) - : []; - return { id, type: value.type, enabled, token, allowFrom }; -} - -export function normalizeChannels(value: unknown): Channel[] { - if (!Array.isArray(value)) return []; - const seen = new Set(); - const out: Channel[] = []; - for (const entry of value) { - const channel = normalizeChannelInput(entry); - if (!channel || seen.has(channel.id)) continue; - seen.add(channel.id); - out.push(channel); - } - return out; -} - -export function cloneChannel(channel: Channel): Channel { - return { - id: channel.id, - type: channel.type, - enabled: channel.enabled, - token: channel.token, - allowFrom: [...channel.allowFrom], - }; -} From 6ae429f0d80434ac2d776bc44bd12284597d712f Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:18:15 +0200 Subject: [PATCH 08/12] Revert: restore modified files to pre-channel state --- src/main/channels/telegram.ts | 123 +++++++++++++--------------------- src/main/store/types.ts | 4 +- 2 files changed, 48 insertions(+), 79 deletions(-) diff --git a/src/main/channels/telegram.ts b/src/main/channels/telegram.ts index f09b59f19..4ffd30fd4 100644 --- a/src/main/channels/telegram.ts +++ b/src/main/channels/telegram.ts @@ -1,79 +1,50 @@ -import { Bot } from 'grammy'; -import type { LoggerService } from '../services/logger'; -import type { ChannelAdapter, ChannelDispatch } from './adapter'; +import { Bot } from "grammy"; const TELEGRAM_MAX_LENGTH = 4096; -export interface TelegramAdapterOptions { - id: string; - bot_token: string; - allow_from: string[]; - dispatch: ChannelDispatch; - logger: LoggerService; -} - -export class TelegramAdapter implements ChannelAdapter { - readonly id: string; - readonly type = 'TELEGRAM' as const; - - private readonly bot: Bot; - private readonly allowFrom: Set; - private readonly logger: LoggerService; - - constructor(opts: TelegramAdapterOptions) { - this.id = opts.id; - this.bot = new Bot(opts.bot_token); - this.allowFrom = new Set(opts.allow_from.map(String)); - this.logger = opts.logger; - - // plain text only — no commands, photos, stickers, etc. - this.bot.on('message:text', async (ctx) => { - const text = ctx.message.text; - if (!text || text.startsWith('/')) return; - - const senderId = String(ctx.from?.id ?? ''); - if (this.allowFrom.size > 0 && !this.allowFrom.has(senderId)) { - this.logger.warn( - 'TelegramAdapter', - `Ignored message from unauthorized user ${senderId}` - ); - return; - } - - try { - const reply = await opts.dispatch(senderId, text); - if (reply.trim().length > 0) { - await this.send(String(ctx.chat.id), reply); - } - } catch (err) { - this.logger.error( - 'TelegramAdapter', - `dispatch failed (chat ${ctx.chat.id})`, - err - ); - } - }); - } - - async send(chatId: string, text: string): Promise { - let remaining = text; - while (remaining.length > 0) { - const chunk = remaining.slice(0, TELEGRAM_MAX_LENGTH); - remaining = remaining.slice(TELEGRAM_MAX_LENGTH); - await this.bot.api.sendMessage(chatId, chunk); - } - } - - async start(): Promise { - // start polling without awaiting (start() resolves only on stop) - this.bot.start({ drop_pending_updates: true }).catch((e) => { - this.logger.error('TelegramAdapter', 'Telegram polling error', e); - }); - // give grammy a tick to init - await new Promise((r) => setTimeout(r, 100)); - } - - async stop(): Promise { - await this.bot.stop(); - } -} +export class TelegramAdapter { + private bot: Bot; + private allowFrom: Set; + + constructor(opts: { + bot_token: string; + allow_from: string[]; + }) { + this.bot = new Bot(opts.bot_token); + this.allowFrom = new Set(opts.allow_from.map(String)); + + // plain text only — no commands, photos, stickers, etc. + this.bot.on("message:text", async (ctx) => { + const text = ctx.message.text; + if (!text || text.startsWith("/")) return; + + const senderId = String(ctx.from?.id ?? ""); + if (this.allowFrom.size > 0 && !this.allowFrom.has(senderId)) { + console.warn(`Ignored message from unauthorized user ${senderId}`); + return; + } + }); + } + + async send(chatId: string, text: string): Promise { + let remaining = text; + while (remaining.length > 0) { + const chunk = remaining.slice(0, TELEGRAM_MAX_LENGTH); + remaining = remaining.slice(TELEGRAM_MAX_LENGTH); + await this.bot.api.sendMessage(chatId, chunk); + } + } + + async start(): Promise { + // start polling without awaiting (start() resolves only on stop) + this.bot.start({ drop_pending_updates: true }).catch((e) => { + console.error("Telegram polling error:", e); + }); + // give grammy a tick to init + await new Promise((r) => setTimeout(r, 100)); + } + + async stop(): Promise { + await this.bot.stop(); + } +} \ No newline at end of file diff --git a/src/main/store/types.ts b/src/main/store/types.ts index b3a501fbd..5f1bf6e48 100644 --- a/src/main/store/types.ts +++ b/src/main/store/types.ts @@ -1,4 +1,4 @@ -import type { AgentSettings, Channel, Provider, UserProfile } from '../../shared/types'; +import type { AgentSettings, Provider, UserProfile } from '../../shared/types'; export interface WorkspaceInfo { path: string; @@ -8,7 +8,6 @@ export interface WorkspaceInfo { export interface StoreSchema { providers: Provider[]; agents: AgentSettings[]; - channels: Channel[]; currentWorkspace: string | null; recentWorkspaces: WorkspaceInfo[]; startupCount: number; @@ -19,7 +18,6 @@ export interface StoreSchema { export const DEFAULTS: StoreSchema = { providers: [], agents: [], - channels: [], currentWorkspace: null, recentWorkspaces: [], startupCount: 0, From 4d15b03f0cf7db64a5493c7840cc83c1acfc61e6 Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:19:28 +0200 Subject: [PATCH 09/12] Revert: restore store-service.ts and validators.ts --- src/main/shared/validators.ts | 68 +-------------------------------- src/main/store/store-service.ts | 60 +---------------------------- 2 files changed, 2 insertions(+), 126 deletions(-) diff --git a/src/main/shared/validators.ts b/src/main/shared/validators.ts index cc07beef6..346d6b441 100644 --- a/src/main/shared/validators.ts +++ b/src/main/shared/validators.ts @@ -3,8 +3,7 @@ * Used by AppIpc (store handlers) to validate user inputs. */ -import type { AgentSettings, Channel, Provider } from '../../shared/types'; -import { isKnownChannelType } from '../../shared/types'; +import type { AgentSettings, Provider } from '../../shared/types'; import { isKnownProvider } from '../../shared/providers'; export class StoreValidators { @@ -118,71 +117,6 @@ export class StoreValidators { } } -/** - * Input validators for Channel store operations. - */ -export class ChannelValidators { - private static readonly MAX_TOKEN_LENGTH = 500; - private static readonly MAX_FIELD_LENGTH = 200; - private static readonly DANGEROUS_CHARS = /[<>;"'`]/; - private static readonly ID_PATTERN = /^[a-zA-Z0-9\-_]+$/; - - static validateChannelId(id: string): void { - if (typeof id !== 'string' || id.trim().length === 0) { - throw new Error('Channel id must be a non-empty string'); - } - if (id.length > this.MAX_FIELD_LENGTH) { - throw new Error('Channel id exceeds maximum length'); - } - if (!this.ID_PATTERN.test(id)) { - throw new Error('Channel id contains invalid characters'); - } - } - - static validateChannel(channel: Channel): void { - if (typeof channel !== 'object' || channel === null) { - throw new Error('Channel must be an object'); - } - this.validateChannelId(channel.id); - - if (!isKnownChannelType(channel.type)) { - throw new Error(`Unknown channel type: ${String(channel.type)}`); - } - - if (typeof channel.enabled !== 'boolean') { - throw new Error('Channel enabled must be a boolean'); - } - - if (typeof channel.token !== 'string') { - throw new Error('Channel token must be a string'); - } - if (channel.token.length > this.MAX_TOKEN_LENGTH) { - throw new Error(`Channel token exceeds maximum length of ${this.MAX_TOKEN_LENGTH}`); - } - if (channel.token.length > 0 && this.DANGEROUS_CHARS.test(channel.token)) { - throw new Error('Channel token contains invalid characters'); - } - if (channel.enabled && channel.token.trim().length === 0) { - throw new Error('Enabled channel must have a non-empty token'); - } - - if (!Array.isArray(channel.allowFrom)) { - throw new Error('Channel allowFrom must be an array'); - } - for (const sender of channel.allowFrom) { - if (typeof sender !== 'string' || sender.trim().length === 0) { - throw new Error('Channel allowFrom entries must be non-empty strings'); - } - if (sender.length > this.MAX_FIELD_LENGTH) { - throw new Error('Channel allowFrom entry exceeds maximum length'); - } - if (!this.ID_PATTERN.test(sender)) { - throw new Error('Channel allowFrom entry contains invalid characters'); - } - } - } -} - /** * Validators for agent service inputs */ diff --git a/src/main/store/store-service.ts b/src/main/store/store-service.ts index 42286c4a3..aa547a772 100644 --- a/src/main/store/store-service.ts +++ b/src/main/store/store-service.ts @@ -1,11 +1,6 @@ import Store from 'electron-store'; import { MAX_RECENT_WORKSPACES } from '../constants'; -import type { - AgentSettings, - Channel, - Provider, - UserProfile, -} from '../../shared/types'; +import type { AgentSettings, Provider, UserProfile } from '../../shared/types'; import type { AppStartupInfo } from '../../shared/types'; import { DEFAULTS, type SettingsStore, type StoreSchema, type WorkspaceInfo } from './types'; import { @@ -18,11 +13,6 @@ import { normalizeAgentInput, normalizeAgents, } from './agents'; -import { - cloneChannel, - normalizeChannelInput, - normalizeChannels, -} from './channels'; export class StoreService { private store: SettingsStore; @@ -37,7 +27,6 @@ export class StoreService { this.migrateLegacyProviderSettings(); this.normalizeStoredProviders(); this.normalizeStoredAgents(); - this.normalizeStoredChannels(); this.reconcileStartupState(); this.incrementStartupCount(); } @@ -78,42 +67,6 @@ export class StoreService { this.store.set('providers', providers); } - // --- Channel methods --- - - getChannels(): Channel[] { - return this.store.get('channels').map(cloneChannel); - } - - getChannelById(channelId: string): Channel | undefined { - const normalized = channelId.trim(); - return this.getChannels().find((c) => c.id === normalized); - } - - /** - * Upsert a channel entry by its `id` (one entry per channel id). - */ - addChannel(channel: Channel): Channel { - const incoming = normalizeChannelInput(channel); - if (!incoming) { - throw new Error('Invalid channel'); - } - const channels = this.store.get('channels').map(cloneChannel); - const index = channels.findIndex((c) => c.id === incoming.id); - if (index >= 0) { - channels[index] = incoming; - } else { - channels.push(incoming); - } - this.store.set('channels', channels); - return cloneChannel(incoming); - } - - deleteChannel(channelId: string): void { - const normalized = channelId.trim(); - const channels = this.store.get('channels').filter((c) => c.id !== normalized); - this.store.set('channels', channels); - } - getProfile(): UserProfile | null { const profile = this.store.get('profile'); if (!profile) return null; @@ -266,17 +219,6 @@ export class StoreService { } } - private normalizeStoredChannels(): void { - const normalized = normalizeChannels(this.rawStore.get('channels')); - const current = this.store.get('channels'); - const needsRewrite = - current.length !== normalized.length || - current.some((c, i) => JSON.stringify(c) !== JSON.stringify(normalized[i])); - if (needsRewrite) { - this.store.set('channels', normalized); - } - } - private reconcileStartupState(): void { if (this.store.get('startupCount') > 0 || this.store.get('isInitialized')) { return; From 8cfa83442503f5bc38db05d194d07f1354b9b74f Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:21:02 +0200 Subject: [PATCH 10/12] Revert: restore shared/channels.ts to pre-channel state --- src/shared/channels.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/shared/channels.ts b/src/shared/channels.ts index 9b22941cb..55450bf29 100644 --- a/src/shared/channels.ts +++ b/src/shared/channels.ts @@ -55,7 +55,6 @@ import type { Provider, ProviderModelInfo, UserProfile, - Channel, } from './types'; import type { ShortcutId } from './shortcuts'; @@ -176,10 +175,6 @@ export const AppChannels = { getProviders: 'app:get-providers', addProvider: 'app:add-provider', deleteProvider: 'app:delete-provider', - // Channels (messaging integrations) - getChannels: 'app:get-channels', - addChannel: 'app:add-channel', - deleteChannel: 'app:delete-channel', getAgents: 'app:get-agents', updateAgent: 'app:update-agent', getStartupInfo: 'app:get-startup-info', @@ -237,9 +232,6 @@ export interface InvokeChannelMap { [AppChannels.getProviders]: { args: []; result: Provider[] }; [AppChannels.addProvider]: { args: [provider: Provider]; result: Provider }; [AppChannels.deleteProvider]: { args: [id: string]; result: void }; - [AppChannels.getChannels]: { args: []; result: Channel[] }; - [AppChannels.addChannel]: { args: [channel: Channel]; result: Channel }; - [AppChannels.deleteChannel]: { args: [id: string]; result: void }; [AppChannels.getAgents]: { args: []; result: AgentSettings[] }; [AppChannels.updateAgent]: { args: [agent: AgentSettings]; result: AgentSettings }; [AppChannels.getStartupInfo]: { args: []; result: AppStartupInfo }; From af360d3e0cfff993dcff7d6f5c966d74c75fca0c Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:23:13 +0200 Subject: [PATCH 11/12] Revert: restore shared/types.ts to pre-channel state --- src/shared/types.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/shared/types.ts b/src/shared/types.ts index 2940daa2d..c51fc52f3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -138,29 +138,6 @@ export const PROVIDERS = [ { id: 'anthropic', name: 'Anthropic', apiKey: '' }, ] as const satisfies readonly Provider[]; -// ---- Channels -------------------------------------------------------------- - -export type ChannelType = 'TELEGRAM' | 'WHATSAPP'; - -export interface Channel { - id: string; - type: ChannelType; - enabled: boolean; - token: string; - allowFrom: string[]; -} - -export const CHANNEL_TYPES = [ - { id: 'TELEGRAM', name: 'Telegram' }, - { id: 'WHATSAPP', name: 'WhatsApp' }, -] as const; - -export function isKnownChannelType(value: unknown): value is ChannelType { - return ( - typeof value === 'string' && CHANNEL_TYPES.some((t) => t.id === value) - ); -} - /** * Single model entry as returned by a provider's `/models` endpoint. * Shape is normalised across providers. From 3856fdf47608c7a28c0ff10833ac481afbfe53a9 Mon Sep 17 00:00:00 2001 From: Harald Bregu Date: Sun, 3 May 2026 14:23:34 +0200 Subject: [PATCH 12/12] Revert: fix trailing newline on telegram.ts --- src/main/channels/telegram.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/channels/telegram.ts b/src/main/channels/telegram.ts index 4ffd30fd4..f5d15eeed 100644 --- a/src/main/channels/telegram.ts +++ b/src/main/channels/telegram.ts @@ -47,4 +47,4 @@ export class TelegramAdapter { async stop(): Promise { await this.bot.stop(); } -} \ No newline at end of file +}