feat: gateway to chat to goose - telegram etc (#7199)

This commit is contained in:
Michael Neale
2026-02-25 17:49:55 +11:00
committed by GitHub
parent d1f4dc947d
commit 19dc192efe
25 changed files with 2794 additions and 18 deletions
+2 -1
View File
@@ -7434,7 +7434,8 @@
"scheduled",
"sub_agent",
"hidden",
"terminal"
"terminal",
"gateway"
]
},
"SessionsQuery": {
+32 -10
View File
@@ -241,6 +241,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -623,6 +624,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -663,6 +665,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -1120,6 +1123,7 @@
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^4.1.1",
"fs-extra": "^9.0.1",
@@ -2340,6 +2344,7 @@
"integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@inquirer/checkbox": "^3.0.1",
"@inquirer/confirm": "^4.0.1",
@@ -2815,6 +2820,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.0.tgz",
"integrity": "sha512-qOdO524oPMkUsOJTrsH9vz/HN3B5pKyW+9zIW51A9kDMVe7ON70drz1ouoyoyOcfzc+oxhkQ6jWmbyKnlWmYqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -5860,8 +5866,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -6149,6 +6154,7 @@
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -6190,6 +6196,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -6200,6 +6207,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6340,6 +6348,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -6738,6 +6747,7 @@
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "4.0.18",
"fflate": "^0.8.2",
@@ -6986,6 +6996,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7058,6 +7069,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -7598,6 +7610,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8900,8 +8913,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@@ -8959,6 +8971,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^24.9.0",
@@ -9966,6 +9979,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10449,6 +10463,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -11709,6 +11724,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz",
"integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -12765,6 +12781,7 @@
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1",
@@ -14008,7 +14025,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -14029,6 +14045,7 @@
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
@@ -16419,6 +16436,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -16520,7 +16538,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -16536,7 +16553,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -16892,6 +16908,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16901,6 +16918,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -16922,8 +16940,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
@@ -18910,7 +18927,8 @@
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@@ -19441,6 +19459,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -19865,6 +19884,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -19955,6 +19975,7 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
@@ -20603,6 +20624,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1 -1
View File
@@ -1211,7 +1211,7 @@ export type SessionListResponse = {
sessions: Array<Session>;
};
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal';
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal' | 'gateway';
export type SessionsQuery = {
limit: number;
@@ -11,6 +11,9 @@ import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Bot, Share2, Monitor, MessageSquare, FileText, Keyboard, HardDrive } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import TunnelSection from './tunnel/TunnelSection';
import GatewaySettingsSection from './gateways/GatewaySettingsSection';
import { getTunnelStatus } from '../../api/sdk.gen';
import ChatSettingsSection from './chat/ChatSettingsSection';
import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection';
import LocalInferenceSection from './localInference/LocalInferenceSection';
@@ -33,6 +36,7 @@ export default function SettingsView({
viewOptions: SettingsViewOptions;
}) {
const [activeTab, setActiveTab] = useState('models');
const [tunnelDisabled, setTunnelDisabled] = useState(false);
const hasTrackedInitialTab = useRef(false);
const handleTabChange = (tab: string) => {
@@ -55,6 +59,7 @@ export default function SettingsView({
chat: 'chat',
prompts: 'prompts',
keyboard: 'keyboard',
gateway: 'sharing',
'local-inference': 'local-inference',
};
@@ -72,6 +77,16 @@ export default function SettingsView({
}
}, [activeTab]);
useEffect(() => {
getTunnelStatus()
.then(({ data }) => {
setTunnelDisabled(data?.state === 'disabled');
})
.catch(() => {
setTunnelDisabled(false);
});
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !event.defaultPrevented) {
@@ -183,9 +198,15 @@ export default function SettingsView({
value="sharing"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<div className="space-y-8">
<div className="space-y-8 pb-8">
<SessionSharingSection />
<ExternalBackendSection />
{!tunnelDisabled && (
<div className="space-y-4">
<TunnelSection />
<GatewaySettingsSection />
</div>
)}
</div>
</TabsContent>
@@ -4,7 +4,6 @@ import { Button } from '../../ui/button';
import { Settings, ChevronDown, ChevronUp } from 'lucide-react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
import UpdateSection from './UpdateSection';
import TunnelSection from '../tunnel/TunnelSection';
import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
@@ -336,7 +335,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
{/* Navigation Settings */}
<NavigationSettingsCard />
<TunnelSection />
<TelemetrySettings isWelcome={false} />
@@ -0,0 +1,419 @@
import { useState, useEffect, useCallback } from 'react';
import { Button } from '../../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
import { Input } from '../../ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../../ui/dialog';
import { Loader2, Copy, Check, Square, Trash2, ExternalLink, User } from 'lucide-react';
import { getApiUrl } from '../../../config';
interface PairedUserInfo {
platform: string;
user_id: string;
display_name: string | null;
session_id: string;
paired_at: number;
}
interface GatewayStatus {
gateway_type: string;
running: boolean;
configured: boolean;
paired_users: PairedUserInfo[];
info?: Record<string, string>;
}
interface PairingCodeResponse {
code: string;
expires_at: number;
}
async function gatewayFetch(endpoint: string, options: globalThis.RequestInit = {}) {
const secretKey = await window.electron.getSecretKey();
const url = getApiUrl(endpoint);
return fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': secretKey,
...options.headers,
},
});
}
export default function GatewaySettingsSection() {
const [gateways, setGateways] = useState<GatewayStatus[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pairingCode, setPairingCode] = useState<PairingCodeResponse | null>(null);
const [pairingGatewayType, setPairingGatewayType] = useState<string | null>(null);
const [copiedCode, setCopiedCode] = useState(false);
const fetchStatus = useCallback(async () => {
try {
const response = await gatewayFetch('/gateway/status');
if (response.ok) {
setGateways(await response.json());
}
} catch (err) {
console.error('Failed to fetch gateway status:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 5000);
return () => clearInterval(interval);
}, [fetchStatus]);
const doPost = async (endpoint: string, body: object, errorMsg: string) => {
setError(null);
try {
const response = await gatewayFetch(endpoint, {
method: 'POST',
body: JSON.stringify(body),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || errorMsg);
}
await fetchStatus();
} catch (err) {
setError(err instanceof Error ? err.message : errorMsg);
}
};
const handleUnpairUser = async (platform: string, userId: string) => {
setError(null);
try {
const response = await gatewayFetch(`/gateway/pair/${platform}/${userId}`, {
method: 'DELETE',
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to unpair user');
}
await fetchStatus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to unpair user');
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedCode(true);
setTimeout(() => setCopiedCode(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-text-muted py-4">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
);
}
const telegram = gateways.find((g) => g.gateway_type === 'telegram');
return (
<>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded text-sm text-red-800 dark:text-red-200 mb-4">
{error}
</div>
)}
<TelegramGatewayCard
status={telegram}
onStart={(config) =>
doPost('/gateway/start', { gateway_type: 'telegram', platform_config: config, max_sessions: 0 }, 'Failed to start')
}
onRestart={() => doPost('/gateway/restart', { gateway_type: 'telegram' }, 'Failed to start')}
onStop={() => doPost('/gateway/stop', { gateway_type: 'telegram' }, 'Failed to stop')}
onRemove={() => doPost('/gateway/remove', { gateway_type: 'telegram' }, 'Failed to remove')}
onGenerateCode={async () => {
setError(null);
try {
const response = await gatewayFetch('/gateway/pair', {
method: 'POST',
body: JSON.stringify({ gateway_type: 'telegram' }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to generate pairing code');
}
const data: PairingCodeResponse = await response.json();
setPairingCode(data);
setPairingGatewayType('telegram');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate pairing code');
}
}}
onUnpairUser={handleUnpairUser}
/>
<PairingCodeModal
open={pairingCode !== null}
onClose={() => {
setPairingCode(null);
setPairingGatewayType(null);
}}
code={pairingCode}
gatewayType={pairingGatewayType}
onCopy={copyToClipboard}
copied={copiedCode}
/>
</>
);
}
function PairedUsersList({
users,
onUnpairUser,
}: {
users: PairedUserInfo[];
onUnpairUser: (platform: string, userId: string) => void;
}) {
if (users.length === 0) return null;
return (
<div className="space-y-1 mt-2">
<h4 className="text-xs text-text-muted font-medium">Paired Users</h4>
{users.map((user) => (
<div
key={`${user.platform}-${user.user_id}`}
className="flex items-center justify-between py-1.5 px-2 bg-background-muted rounded text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<User className="h-3 w-3 text-text-muted flex-shrink-0" />
<span className="truncate">{user.display_name || user.user_id}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onUnpairUser(user.platform, user.user_id)}
className="h-6 w-6 p-0 text-text-muted hover:text-red-600 flex-shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
);
}
function TelegramGatewayCard({
status,
onStart,
onRestart,
onStop,
onRemove,
onGenerateCode,
onUnpairUser,
}: {
status: GatewayStatus | undefined;
onStart: (config: Record<string, unknown>) => Promise<void>;
onRestart: () => Promise<void>;
onStop: () => Promise<void>;
onRemove: () => Promise<void>;
onGenerateCode: () => void;
onUnpairUser: (platform: string, userId: string) => void;
}) {
const [botToken, setBotToken] = useState('');
const [busy, setBusy] = useState(false);
const running = status?.running ?? false;
const configured = status?.configured ?? false;
const wrap = (fn: () => Promise<void>) => async () => {
setBusy(true);
try { await fn(); } finally { setBusy(false); }
};
const handleFirstStart = wrap(async () => {
if (!botToken.trim()) return;
await onStart({ bot_token: botToken.trim() });
setBotToken('');
});
return (
<Card className="rounded-lg">
<CardHeader className="pb-0">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
Telegram
{running && (
<span className="inline-flex items-center text-xs text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-0.5 rounded-full">
Running
</span>
)}
{!running && configured && (
<span className="inline-flex items-center text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30 px-2 py-0.5 rounded-full">
Stopped
</span>
)}
</CardTitle>
<div className="flex items-center gap-2">
{running && (
<>
<Button variant="outline" size="sm" onClick={onGenerateCode}>
Pair Device
</Button>
<Button variant="destructive" size="sm" disabled={busy} onClick={wrap(onStop)}>
<Square className="h-3 w-3 mr-1" />
Stop
</Button>
</>
)}
{!running && configured && (
<>
<Button size="sm" disabled={busy} onClick={wrap(onRestart)}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Start'}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={wrap(onRemove)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/20"
>
<Trash2 className="h-3 w-3 mr-1" />
Remove
</Button>
</>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-3 space-y-2">
{!running && !configured && (
<>
<div className="text-xs text-text-muted space-y-1.5 mb-2">
<p>
Open{' '}
<a
href="https://t.me/BotFather"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-blue-600 dark:text-blue-400 hover:underline"
>
@BotFather
<ExternalLink className="h-3 w-3" />
</a>
{' '}on your phone, send <code className="bg-background-muted px-1 py-0.5 rounded">/newbot</code>, and follow
the prompts to name your bot. BotFather will reply with an API token paste it below.
</p>
</div>
<div className="flex items-center gap-2">
<Input
type="password"
placeholder="Paste bot token here"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleFirstStart()}
className="text-sm"
/>
<Button size="sm" onClick={handleFirstStart} disabled={busy || !botToken.trim()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Start'}
</Button>
</div>
</>
)}
{status && <PairedUsersList users={status.paired_users} onUnpairUser={onUnpairUser} />}
</CardContent>
</Card>
);
}
function PairingCodeModal({
open,
onClose,
code,
gatewayType,
onCopy,
copied,
}: {
open: boolean;
onClose: () => void;
code: PairingCodeResponse | null;
gatewayType: string | null;
onCopy: (text: string) => void;
copied: boolean;
}) {
const [timeRemaining, setTimeRemaining] = useState(0);
useEffect(() => {
if (!code) return;
const updateTimer = () => {
const remaining = Math.max(0, code.expires_at - Math.floor(Date.now() / 1000));
setTimeRemaining(remaining);
if (remaining === 0) {
onClose();
}
};
updateTimer();
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [code, onClose]);
if (!code) return null;
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Pairing Code</DialogTitle>
</DialogHeader>
<div className="py-6 space-y-4">
<div className="flex justify-center">
<div className="flex items-center gap-2">
<code className="text-4xl font-mono font-bold tracking-[0.3em] select-all">
{code.code}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => onCopy(code.code)}
className="flex-shrink-0"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<p className="text-center text-sm text-text-muted">
Send this code to your{' '}
<span className="capitalize font-medium">{gatewayType}</span> bot to pair.
</p>
<div className="text-center text-xs text-text-muted">
Expires in {minutes}:{seconds.toString().padStart(2, '0')}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -127,7 +127,7 @@ export default function TunnelSection() {
<>
<Card className="rounded-lg">
<CardHeader className="pb-0">
<CardTitle className="mb-1">Remote Access</CardTitle>
<CardTitle className="mb-1">Mobile App</CardTitle>
<CardDescription className="flex flex-col gap-2">
<div className="flex items-start gap-2 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
@@ -205,7 +205,7 @@ export default function TunnelSection() {
<Dialog open={showQRModal} onOpenChange={setShowQRModal}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Remote Access Connection</DialogTitle>
<DialogTitle>Mobile App Connection</DialogTitle>
</DialogHeader>
{tunnelInfo.state === 'running' && (