Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/app/pr/telegram-connector.md
Original file line number Diff line number Diff line change
@@ -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/
8 changes: 8 additions & 0 deletions apps/app/src/app/connections/telegram/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { TelegramSettings } from "./telegram-settings";
export {
telegramState,
connectTelegram,
disconnectTelegram,
initTelegramStore,
} from "./telegram-store";
export type { TelegramIdentity } from "./telegram-api";
71 changes: 71 additions & 0 deletions apps/app/src/app/connections/telegram/telegram-api.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> {
const token = (window as any).__OPENWORK_TOKEN__ ?? "";
return token ? { Authorization: `Bearer ${token}` } : {};
}

export async function fetchTelegramIdentities(
workspacePath: string
): Promise<TelegramIdentity[]> {
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<boolean> {
try {
const res = await fetch(
`${getBase()}/opencode-router/identities/telegram/${id}`,
{ method: "DELETE", headers: await authHeaders() }
);
return res.ok;
} catch {
return false;
}
}
96 changes: 96 additions & 0 deletions apps/app/src/app/connections/telegram/telegram-settings.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>)[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 (
<div class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 space-y-4">
<div class="flex items-center justify-between">
<span class="font-medium text-zinc-100">Telegram Bot</span>
<span class={`text-sm ${statusColor()}`}>{statusLabel()}</span>
</div>

<Show when={telegramState.status === "error"}>
<div class="rounded bg-red-950 border border-red-800 px-3 py-2 text-sm text-red-300">
{telegramState.errorMessage}
</div>
</Show>

<Show when={telegramState.status === "connected" && telegramState.identity}>
<Show when={telegramState.identity?.botUsername}>
<div class="flex items-center justify-between text-sm">
<span class="text-zinc-400">Bot</span>
<div class="flex items-center gap-2">
<span class="text-zinc-100 font-mono">@{telegramState.identity!.botUsername}</span>
<button onClick={copyUsername}
class="text-xs text-zinc-400 hover:text-zinc-100 border border-zinc-700 rounded px-2 py-0.5">
Copy
</button>
</div>
</div>
</Show>
<button onClick={() => disconnectTelegram(props.workspacePath)}
class="w-full rounded-md border border-red-800 bg-red-950 hover:bg-red-900 text-red-300 text-sm py-1.5 transition-colors">
Disconnect
</button>
</Show>

<Show when={telegramState.status !== "connected"}>
<p class="text-xs text-zinc-400">
Get a token from <a href="https://t.me/BotFather" target="_blank" class="text-blue-400 hover:underline">@BotFather</a>
</p>
<div class="relative">
<input
type={show() ? "text" : "password"}
placeholder="123456:ABC-DEF..."
value={token()}
onInput={e => 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"
/>
<button onClick={() => setShow(!show())}
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-zinc-500 hover:text-zinc-300">
{show() ? "Hide" : "Show"}
</button>
</div>
<button onClick={handleConnect}
disabled={!token().trim() || telegramState.status === "connecting"}
class="w-full rounded-md bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm py-1.5 font-medium transition-colors">
{telegramState.status === "connecting" ? "Connecting…" : "Connect"}
</button>
</Show>
</div>
);
}
78 changes: 78 additions & 0 deletions apps/app/src/app/connections/telegram/telegram-store.ts
Original file line number Diff line number Diff line change
@@ -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<TelegramState>({
status: "idle",
identity: null,
errorMessage: null,
});

export { state as telegramState };

let pollTimer: ReturnType<typeof setInterval> | 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);
}