diff --git a/apps/app/pr/telegram-connector.md b/apps/app/pr/telegram-connector.md new file mode 100644 index 000000000..982b13116 --- /dev/null +++ b/apps/app/pr/telegram-connector.md @@ -0,0 +1,21 @@ +# PRD: Telegram Connector + +## Summary +Add a Telegram connector panel to the OpenWork desktop settings UI so users +can link a Telegram bot to their workspace without touching config files. + +## Problem +The opencode-router binary already supports Telegram via grammy. +The OpenWork server already proxies /opencode-router/* routes. +But there is zero UI to configure this. Users are blocked. + +## Solution +A settings panel under Connections where users paste a bot token, +see live status, and can disconnect. + +## Architecture Rules Followed +- UI never calls opencode-router directly — proxied through openwork-server +- Bot token is write-only (cleared from state after submit) +- opencode-router stays optional — disabling Telegram cannot crash OpenWork +- Identity upsert passes workspacePath as defaultDirectory (per ARCHITECTURE.md) +- CUPID domain structure: apps/app/src/app/connections/telegram/ diff --git a/apps/app/src/app/connections/telegram/index.ts b/apps/app/src/app/connections/telegram/index.ts new file mode 100644 index 000000000..ac40a5740 --- /dev/null +++ b/apps/app/src/app/connections/telegram/index.ts @@ -0,0 +1,8 @@ +export { TelegramSettings } from "./telegram-settings"; +export { + telegramState, + connectTelegram, + disconnectTelegram, + initTelegramStore, +} from "./telegram-store"; +export type { TelegramIdentity } from "./telegram-api"; diff --git a/apps/app/src/app/connections/telegram/telegram-api.ts b/apps/app/src/app/connections/telegram/telegram-api.ts new file mode 100644 index 000000000..6e9544156 --- /dev/null +++ b/apps/app/src/app/connections/telegram/telegram-api.ts @@ -0,0 +1,71 @@ +/** + * Telegram API client. + * NEVER calls opencode-router directly. + * All requests proxy through openwork-server per ARCHITECTURE.md. + */ + +export interface TelegramIdentity { + id: string; + botUsername?: string; + workspacePath: string; + status: "connected" | "disconnected" | "error"; + errorMessage?: string; +} + +function getBase(): string { + return (window as any).__OPENWORK_SERVER_URL__ ?? "http://localhost:7878"; +} + +async function authHeaders(): Promise> { + const token = (window as any).__OPENWORK_TOKEN__ ?? ""; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export async function fetchTelegramIdentities( + workspacePath: string +): Promise { + try { + const params = new URLSearchParams({ workspacePath }); + const res = await fetch(`${getBase()}/opencode-router/identities?${params}`, { + headers: await authHeaders(), + }); + if (!res.ok) return []; + const data = await res.json(); + return data.identities ?? []; + } catch { + return []; + } +} + +export async function upsertTelegramIdentity( + botToken: string, + workspacePath: string +): Promise<{ ok: boolean; identity?: TelegramIdentity; error?: string }> { + try { + const res = await fetch(`${getBase()}/opencode-router/identities/telegram`, { + method: "POST", + headers: { + ...(await authHeaders()), + "Content-Type": "application/json", + }, + body: JSON.stringify({ botToken, workspacePath }), + }); + const data = await res.json(); + if (!res.ok) return { ok: false, error: data.error ?? "Failed" }; + return { ok: true, identity: data }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +export async function deleteTelegramIdentity(id: string): Promise { + try { + const res = await fetch( + `${getBase()}/opencode-router/identities/telegram/${id}`, + { method: "DELETE", headers: await authHeaders() } + ); + return res.ok; + } catch { + return false; + } +} diff --git a/apps/app/src/app/connections/telegram/telegram-settings.tsx b/apps/app/src/app/connections/telegram/telegram-settings.tsx new file mode 100644 index 000000000..68c9c1abc --- /dev/null +++ b/apps/app/src/app/connections/telegram/telegram-settings.tsx @@ -0,0 +1,96 @@ +import { createSignal, onMount, Show } from "solid-js"; +import { + telegramState, + connectTelegram, + disconnectTelegram, + initTelegramStore, +} from "./telegram-store"; + +interface Props { workspacePath: string } + +export function TelegramSettings(props: Props) { + const [token, setToken] = createSignal(""); + const [show, setShow] = createSignal(false); + + onMount(() => initTelegramStore(props.workspacePath)); + + const statusColor = () => + ({ connected: "text-green-500", connecting: "text-yellow-500", + error: "text-red-500" } as Record)[telegramState.status] + ?? "text-zinc-400"; + + const statusLabel = () => + ({ connected: "● Connected", connecting: "◌ Connecting…", + error: "✕ Error", disabled: "○ Disabled", idle: "○ Not configured" } + )[telegramState.status] ?? "○ Not configured"; + + async function handleConnect() { + const t = token().trim(); + if (!t) return; + await connectTelegram(t, props.workspacePath); + setToken(""); + } + + function copyUsername() { + const u = telegramState.identity?.botUsername; + if (u) navigator.clipboard.writeText(`@${u}`); + } + + return ( +
+
+ Telegram Bot + {statusLabel()} +
+ + +
+ {telegramState.errorMessage} +
+
+ + + +
+ Bot +
+ @{telegramState.identity!.botUsername} + +
+
+
+ +
+ + +

+ Get a token from @BotFather +

+
+ setToken(e.currentTarget.value)} + class="w-full rounded-md bg-zinc-800 border border-zinc-700 focus:border-blue-500 focus:outline-none px-3 py-2 text-sm text-zinc-100 font-mono placeholder:text-zinc-600 pr-16" + /> + +
+ +
+
+ ); +} diff --git a/apps/app/src/app/connections/telegram/telegram-store.ts b/apps/app/src/app/connections/telegram/telegram-store.ts new file mode 100644 index 000000000..a3553054d --- /dev/null +++ b/apps/app/src/app/connections/telegram/telegram-store.ts @@ -0,0 +1,78 @@ +import { createStore } from "solid-js/store"; +import { + fetchTelegramIdentities, + upsertTelegramIdentity, + deleteTelegramIdentity, + type TelegramIdentity, +} from "./telegram-api"; + +export type ConnectorStatus = + | "idle" + | "connecting" + | "connected" + | "error" + | "disabled"; + +interface TelegramState { + status: ConnectorStatus; + identity: TelegramIdentity | null; + errorMessage: string | null; +} + +const [state, setState] = createStore({ + status: "idle", + identity: null, + errorMessage: null, +}); + +export { state as telegramState }; + +let pollTimer: ReturnType | null = null; + +function startPolling(workspacePath: string) { + if (pollTimer) return; + pollTimer = setInterval(() => refreshStatus(workspacePath), 5000); +} + +function stopPolling() { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } +} + +export async function refreshStatus(workspacePath: string) { + const ids = await fetchTelegramIdentities(workspacePath); + if (!ids.length) { + setState({ status: "idle", identity: null, errorMessage: null }); + stopPolling(); + return; + } + const id = ids[0]; + setState({ + status: id.status === "connected" ? "connected" : "error", + identity: id, + errorMessage: id.errorMessage ?? null, + }); +} + +export async function connectTelegram(botToken: string, workspacePath: string) { + setState({ status: "connecting", errorMessage: null }); + const result = await upsertTelegramIdentity(botToken, workspacePath); + if (!result.ok) { + setState({ status: "error", errorMessage: result.error ?? "Failed to connect" }); + return; + } + setState({ status: "connected", identity: result.identity ?? null }); + startPolling(workspacePath); +} + +export async function disconnectTelegram(workspacePath: string) { + const id = state.identity?.id; + stopPolling(); + setState({ status: "disabled", identity: null, errorMessage: null }); + if (id) await deleteTelegramIdentity(id); + setState({ status: "idle" }); +} + +export async function initTelegramStore(workspacePath: string) { + await refreshStatus(workspacePath); + if (state.status === "connected") startPolling(workspacePath); +}