464 lines
25 KiB
Go
464 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const telegramPage = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Hermes on Telegram</title>
|
|
<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260813-telegram-readiness-v1">
|
|
</head>
|
|
<body class="hermes-link-page">
|
|
<main class="hermes-link-card" data-telegram-page>
|
|
<a class="hermes-back" href="/">← Back to Hermes</a>
|
|
<h1>Hermes on Telegram</h1>
|
|
<p>The operator configures one shared Hermes bot. Link your own Telegram account once so direct messages use this Keycloak account's isolated Hermes tenant.</p>
|
|
<p><strong>Account-link commands go only to the private chat with the Hermes bot—not to Hermes WebUI and not to BotFather.</strong></p>
|
|
<p id="telegram-status">Checking Telegram…</p>
|
|
<div class="hermes-link-actions">
|
|
<button id="telegram-link" type="button">Create one-time link</button>
|
|
<button id="telegram-unlink" class="secondary" type="button">Unlink Telegram</button>
|
|
</div>
|
|
<section id="telegram-result" hidden></section>
|
|
<section id="telegram-operator-setup" class="hermes-operator-setup" hidden>
|
|
<h2>Activate the shared bot (operator, once)</h2>
|
|
<ol>
|
|
<li><a class="hermes-button primary" href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer">Open @BotFather</a> and send <code>/newbot</code>.</li>
|
|
<li>Choose the bot's display name and a username ending in <code>bot</code>, then copy the token BotFather returns.</li>
|
|
<li>On a trusted administrator computer with the Vault CLI, sign in to <a href="https://secret.bstein.dev" target="_blank" rel="noopener noreferrer">Atlas Vault</a> and store the token at <code>kv/atlas/hermes/chat-telegram</code> under <code>bot_token</code>.</li>
|
|
</ol>
|
|
<pre>export VAULT_ADDR=https://secret.bstein.dev
|
|
vault login -method=oidc
|
|
vault kv patch -mount=kv atlas/hermes/chat-telegram bot_token='<BOTFATHER_TOKEN>'</pre>
|
|
<p><code>vault kv patch</code> changes only <code>bot_token</code>, so it preserves the existing <code>relay_key</code>. <strong>Do not paste the token into Hermes, a browser form, Git, or a message.</strong></p>
|
|
<p>After the token is saved, ask the Hermes operator to apply the tracked router rollout. Then return here: the account-link button will appear and each Keycloak user can privately link their own Telegram account.</p>
|
|
</section>
|
|
<p class="hermes-fine-print">Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.</p>
|
|
</main>
|
|
<script src="/hermes-chat-bridge.js?v=20260813-telegram-readiness-v1" defer></script>
|
|
</body>
|
|
</html>`
|
|
|
|
const privateFilesPage = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Hermes Private Files</title>
|
|
<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260813-telegram-operator-v2">
|
|
</head>
|
|
<body class="hermes-files-page">
|
|
<main class="hermes-files-shell" data-files-page>
|
|
<header class="hermes-files-header">
|
|
<div>
|
|
<a id="files-back" class="hermes-back" href="/">← Back to Hermes</a>
|
|
<h1>Private files</h1>
|
|
<p>Files created by Hermes in your isolated 10 GiB Home workspace.</p>
|
|
</div>
|
|
<label>Conversation <select id="files-session"></select></label>
|
|
</header>
|
|
<nav id="files-breadcrumbs" class="hermes-breadcrumbs" aria-label="Folder path"></nav>
|
|
<p id="files-status">Loading your workspace…</p>
|
|
<div class="hermes-files-grid">
|
|
<section>
|
|
<div class="hermes-files-toolbar">
|
|
<strong>Files and folders</strong>
|
|
<a id="files-download-folder" class="hermes-button" href="#">Download folder</a>
|
|
</div>
|
|
<ul id="files-list" class="hermes-file-list"></ul>
|
|
</section>
|
|
<section class="hermes-file-viewer">
|
|
<div class="hermes-files-toolbar">
|
|
<strong id="file-title">Select a file</strong>
|
|
<a id="file-download" class="hermes-button" href="#" hidden>Download</a>
|
|
</div>
|
|
<pre id="file-content">Choose a file to preview it here.</pre>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
<script src="/hermes-chat-bridge.js?v=20260813-telegram-operator-v2" defer></script>
|
|
</body>
|
|
</html>`
|
|
|
|
const bridgeCSS = `
|
|
#hermes-files-sidebar,#hermes-telegram-sidebar{display:flex;align-items:center}
|
|
.hermes-link-page{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font:16px/1.5 system-ui,sans-serif}
|
|
.hermes-link-card{width:min(620px,calc(100% - 40px));box-sizing:border-box;padding:32px;border:1px solid #334155;border-radius:18px;background:#111827;box-shadow:0 20px 60px #0006}
|
|
.hermes-link-card h1{margin:.6rem 0}.hermes-back{color:#7dd3fc}.hermes-link-actions{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0}
|
|
.hermes-link-card button{border:0;border-radius:10px;padding:11px 16px;background:#229ed9;color:#fff;font-weight:700;cursor:pointer}.hermes-link-card button.secondary{background:#334155}.hermes-link-card button:disabled{cursor:not-allowed;opacity:.45}
|
|
#telegram-result{padding:16px;border-radius:10px;background:#1e293b;overflow-wrap:anywhere}#telegram-result a{color:#7dd3fc}.hermes-fine-print{color:#94a3b8;font-size:13px}.hermes-operator-setup{margin:24px 0;padding:18px;border:1px solid #334155;border-radius:12px;background:#0b1220}.hermes-operator-setup h2{margin:0 0 12px;font-size:19px}.hermes-operator-setup li{margin:10px 0}.hermes-operator-setup code,.hermes-operator-setup pre{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.hermes-operator-setup pre{padding:12px;border-radius:8px;background:#020617;color:#cbd5e1;white-space:pre-wrap;overflow-wrap:anywhere}.hermes-button{display:inline-block;padding:8px 12px;border-radius:8px;background:#334155;color:#e5e7eb;text-decoration:none}.hermes-button.primary{background:#229ed9;color:#fff;font-weight:700}
|
|
.hermes-files-page{margin:0;min-height:100vh;background:#0b1020;color:#e5e7eb;font:15px/1.5 system-ui,sans-serif}.hermes-files-shell{width:min(1500px,calc(100% - 36px));margin:auto;padding:28px 0}.hermes-files-header{display:flex;justify-content:space-between;gap:28px;align-items:end;border-bottom:1px solid #293249;padding-bottom:18px}.hermes-files-header h1{margin:.4rem 0 0}.hermes-files-header p{margin:.25rem 0;color:#9ca3af}.hermes-files-header label{display:grid;gap:6px;color:#9ca3af}.hermes-files-header select{min-width:260px;background:#151b2e;color:#e5e7eb;border:1px solid #39445f;border-radius:8px;padding:9px}.hermes-breadcrumbs{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.hermes-breadcrumbs button{border:0;background:transparent;color:#7dd3fc;cursor:pointer;padding:4px}.hermes-files-grid{display:grid;grid-template-columns:minmax(280px,38%) 1fr;gap:18px}.hermes-files-grid>section{border:1px solid #293249;border-radius:12px;background:#11172a;min-height:65vh;overflow:hidden}.hermes-files-toolbar{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid #293249}.hermes-button{padding:6px 10px;border-radius:7px;background:#334155;color:#e5e7eb;text-decoration:none}.hermes-file-list{list-style:none;margin:0;padding:8px}.hermes-file-list button{width:100%;display:grid;grid-template-columns:1fr auto;gap:14px;text-align:left;border:0;border-radius:7px;padding:9px 10px;background:transparent;color:#e5e7eb;cursor:pointer}.hermes-file-list button:hover,.hermes-file-list button:focus{background:#202941}.hermes-file-meta{color:#8d98ad;font-size:12px}.hermes-file-viewer pre{box-sizing:border-box;margin:0;padding:18px;max-height:calc(65vh - 64px);overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:#d9e2f1;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}#files-status{color:#9ca3af}@media(max-width:800px){.hermes-files-header{display:block}.hermes-files-header label{margin-top:14px}.hermes-files-header select{width:100%;min-width:0}.hermes-files-grid{grid-template-columns:1fr}.hermes-files-grid>section{min-height:38vh}}
|
|
`
|
|
|
|
const bridgeJS = `(() => {
|
|
const page = document.querySelector('[data-telegram-page]');
|
|
const filesPage = document.querySelector('[data-files-page]');
|
|
const hideChatAdministration = () => {
|
|
document.querySelectorAll('[data-panel="kanban"]').forEach((node) => { node.hidden = true; });
|
|
};
|
|
hideChatAdministration();
|
|
if (!page && !filesPage) {
|
|
const labelLink = (link, id, href, label, ariaLabel) => {
|
|
if (link.id !== id) link.id = id;
|
|
if (link.getAttribute('href') !== href) link.setAttribute('href', href);
|
|
if (link.getAttribute('aria-label') !== ariaLabel) link.setAttribute('aria-label', ariaLabel);
|
|
const walker = document.createTreeWalker(link, NodeFilter.SHOW_TEXT);
|
|
let textNode = walker.nextNode();
|
|
let replaced = false;
|
|
while (textNode) {
|
|
if (textNode.textContent.trim()) {
|
|
const current = textNode.textContent.trim();
|
|
if (current.toLowerCase() !== label.toLowerCase()) {
|
|
textNode.textContent = textNode.textContent.replace(current, label);
|
|
}
|
|
replaced = true;
|
|
break;
|
|
}
|
|
textNode = walker.nextNode();
|
|
}
|
|
if (!replaced) link.append(document.createTextNode(label));
|
|
};
|
|
const installSidebarLinks = () => {
|
|
const match = location.pathname.match(/^\/session\/([^/]+)/);
|
|
let files = document.getElementById('hermes-files-sidebar');
|
|
if (!files) {
|
|
files = Array.from(document.querySelectorAll('a[href]')).find((link) => {
|
|
try { return new URL(link.href, location.href).pathname === '/files'; } catch (_) { return false; }
|
|
});
|
|
}
|
|
if (files) labelLink(files, 'hermes-files-sidebar', '/private-files' + (match ? '?session_id=' + encodeURIComponent(match[1]) : ''), 'Files', 'Browse private Hermes files');
|
|
let telegram = document.getElementById('hermes-telegram-sidebar');
|
|
if (!telegram) {
|
|
const rail = document.querySelector('.rail');
|
|
if (!rail) return;
|
|
telegram = document.createElement('button');
|
|
telegram.type = 'button';
|
|
telegram.id = 'hermes-telegram-sidebar';
|
|
telegram.className = 'rail-btn nav-tab has-tooltip';
|
|
telegram.setAttribute('data-tooltip', 'Telegram');
|
|
telegram.setAttribute('aria-label', 'Connect this account to the shared Hermes Telegram bot');
|
|
telegram.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 2 9.5 14.5"/><path d="m22 2-7 20-4-9-9-4Z"/></svg>';
|
|
telegram.addEventListener('click', () => { location.assign('/telegram'); });
|
|
const anchor = rail.querySelector('[data-panel="logs"]') || rail.querySelector('.rail-spacer');
|
|
rail.insertBefore(telegram, anchor || null);
|
|
}
|
|
};
|
|
let scheduled = false;
|
|
const observer = new MutationObserver(() => {
|
|
if (scheduled) return;
|
|
scheduled = true;
|
|
requestAnimationFrame(() => { scheduled = false; installSidebarLinks(); });
|
|
});
|
|
installSidebarLinks();
|
|
observer.observe(document.body, {childList:true, subtree:true});
|
|
return;
|
|
}
|
|
if (filesPage) {
|
|
const params = new URLSearchParams(location.search);
|
|
const sessionPicker = document.getElementById('files-session');
|
|
const list = document.getElementById('files-list');
|
|
const status = document.getElementById('files-status');
|
|
const breadcrumbs = document.getElementById('files-breadcrumbs');
|
|
const title = document.getElementById('file-title');
|
|
const content = document.getElementById('file-content');
|
|
const fileDownload = document.getElementById('file-download');
|
|
const folderDownload = document.getElementById('files-download-folder');
|
|
const back = document.getElementById('files-back');
|
|
let sessionId = params.get('session_id') || '';
|
|
let currentPath = '.';
|
|
const api = async (path) => {
|
|
const response = await fetch(path, {cache:'no-store'});
|
|
let payload;
|
|
try { payload = await response.json(); } catch (_) { payload = {}; }
|
|
if (!response.ok) throw new Error(payload.error || payload.detail || ('Request failed (' + response.status + ')'));
|
|
return payload;
|
|
};
|
|
const endpoint = (kind, path) => '/api/' + kind + '?session_id=' + encodeURIComponent(sessionId) + '&path=' + encodeURIComponent(path || '.');
|
|
const renderBreadcrumbs = () => {
|
|
breadcrumbs.replaceChildren();
|
|
const parts = currentPath === '.' ? [] : currentPath.split('/').filter(Boolean);
|
|
const roots = [{label:'Home', path:'.'}];
|
|
let built = '';
|
|
parts.forEach((part) => { built = built ? built + '/' + part : part; roots.push({label:part, path:built}); });
|
|
roots.forEach((item, index) => {
|
|
if (index) breadcrumbs.append(document.createTextNode(' / '));
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.textContent = item.label;
|
|
button.addEventListener('click', () => loadDirectory(item.path));
|
|
breadcrumbs.appendChild(button);
|
|
});
|
|
};
|
|
const openFile = async (entry) => {
|
|
status.textContent = 'Opening ' + entry.name + '…';
|
|
try {
|
|
const payload = await api(endpoint('file', entry.path));
|
|
title.textContent = entry.path;
|
|
content.textContent = payload.content || '[Empty file]';
|
|
fileDownload.href = endpoint('file/raw', entry.path);
|
|
fileDownload.setAttribute('download', entry.name);
|
|
fileDownload.hidden = false;
|
|
status.textContent = entry.size == null ? 'File loaded.' : 'File loaded (' + entry.size.toLocaleString() + ' bytes).';
|
|
} catch (error) { status.textContent = error.message; }
|
|
};
|
|
const loadDirectory = async (path) => {
|
|
currentPath = path || '.';
|
|
status.textContent = 'Loading ' + (currentPath === '.' ? 'Home' : currentPath) + '…';
|
|
title.textContent = 'Select a file';
|
|
content.textContent = 'Choose a file to preview it here.';
|
|
fileDownload.hidden = true;
|
|
renderBreadcrumbs();
|
|
folderDownload.href = endpoint('folder/download', currentPath);
|
|
try {
|
|
const payload = await api(endpoint('list', currentPath));
|
|
const entries = Array.isArray(payload.entries) ? payload.entries.slice() : [];
|
|
entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
|
|
list.replaceChildren();
|
|
if (!entries.length) {
|
|
const empty = document.createElement('li'); empty.textContent = 'This folder is empty.'; list.appendChild(empty);
|
|
}
|
|
entries.forEach((entry) => {
|
|
const item = document.createElement('li');
|
|
const button = document.createElement('button'); button.type = 'button';
|
|
const name = document.createElement('span'); name.textContent = (entry.type === 'dir' ? '📁 ' : '📄 ') + entry.name;
|
|
const meta = document.createElement('span'); meta.className = 'hermes-file-meta'; meta.textContent = entry.type === 'dir' ? 'folder' : ((entry.size || 0).toLocaleString() + ' B');
|
|
button.append(name, meta);
|
|
button.addEventListener('click', () => entry.type === 'dir' ? loadDirectory(entry.path) : openFile(entry));
|
|
item.appendChild(button); list.appendChild(item);
|
|
});
|
|
status.textContent = entries.length + (entries.length === 1 ? ' item' : ' items') + ' in ' + (currentPath === '.' ? 'Home' : currentPath) + '.';
|
|
} catch (error) { list.replaceChildren(); status.textContent = error.message; }
|
|
};
|
|
const bootFiles = async () => {
|
|
try {
|
|
const payload = await api('/api/sessions?sidebar_source=webui&exclude_hidden=1');
|
|
const sessions = (payload.sessions || []).filter((item) => item && item.session_id && !item.read_only);
|
|
if (!sessions.length) throw new Error('Start a chat first so Hermes can attach the private Home workspace.');
|
|
if (!sessions.some((item) => item.session_id === sessionId)) sessionId = sessions[0].session_id;
|
|
sessionPicker.replaceChildren();
|
|
sessions.forEach((item) => {
|
|
const option = document.createElement('option'); option.value = item.session_id; option.textContent = item.title || item.session_id; option.selected = item.session_id === sessionId; sessionPicker.appendChild(option);
|
|
});
|
|
sessionPicker.addEventListener('change', () => { sessionId = sessionPicker.value; back.href = '/session/' + encodeURIComponent(sessionId); loadDirectory('.'); });
|
|
back.href = '/session/' + encodeURIComponent(sessionId);
|
|
await loadDirectory('.');
|
|
} catch (error) { status.textContent = error.message; }
|
|
};
|
|
bootFiles();
|
|
return;
|
|
}
|
|
const status = document.getElementById('telegram-status');
|
|
const result = document.getElementById('telegram-result');
|
|
const linkButton = document.getElementById('telegram-link');
|
|
const unlinkButton = document.getElementById('telegram-unlink');
|
|
const operatorSetup = document.getElementById('telegram-operator-setup');
|
|
const action = async (path) => {
|
|
const response = await fetch(path, {method:'POST',headers:{'Content-Type':'application/json','X-Hermes-Action':'telegram-link'},body:'{}'});
|
|
const payload = await response.json();
|
|
if (!response.ok) throw new Error(payload.error || 'Request failed');
|
|
return payload;
|
|
};
|
|
const refresh = async () => {
|
|
try {
|
|
const response = await fetch('/api/telegram/status', {cache:'no-store'});
|
|
const payload = await response.json();
|
|
if (!payload.configured) {
|
|
status.textContent = 'Telegram is not active yet: the operator must add the BotFather bot token before account links can be created.';
|
|
linkButton.hidden = true;
|
|
unlinkButton.hidden = true;
|
|
operatorSetup.hidden = false;
|
|
return;
|
|
}
|
|
if (!payload.ready) {
|
|
status.textContent = payload.error || 'The shared Telegram bot token is not accepted by Telegram. The operator must update the token and roll out the router.';
|
|
linkButton.hidden = true;
|
|
unlinkButton.hidden = true;
|
|
operatorSetup.hidden = false;
|
|
return;
|
|
}
|
|
operatorSetup.hidden = true;
|
|
linkButton.hidden = false;
|
|
linkButton.disabled = false;
|
|
const botName = payload.bot_username ? '@' + payload.bot_username : 'the Hermes bot';
|
|
status.textContent = payload.linked ? 'Telegram is linked to this private account through ' + botName + '.' : 'Telegram is ready. Link this account with ' + botName + '.';
|
|
unlinkButton.hidden = !payload.linked;
|
|
} catch (_) { status.textContent = 'Telegram status is temporarily unavailable.'; }
|
|
};
|
|
linkButton.addEventListener('click', async () => {
|
|
try {
|
|
const payload = await action('/api/telegram/link');
|
|
result.hidden = false;
|
|
result.replaceChildren();
|
|
const text = document.createElement('p');
|
|
const botName = payload.bot_username ? '@' + payload.bot_username : 'the Hermes bot';
|
|
text.textContent = 'In a private Telegram chat with ' + botName + ', send /link ' + payload.code + '. Do not send it to BotFather or paste it into Hermes WebUI. This code expires at ' + new Date(payload.expires_at).toLocaleTimeString() + '.';
|
|
result.appendChild(text);
|
|
if (payload.deep_link) {
|
|
const anchor = document.createElement('a');
|
|
anchor.href = payload.deep_link;
|
|
anchor.rel = 'noopener noreferrer';
|
|
anchor.target = '_blank';
|
|
anchor.textContent = 'Open ' + botName + ' in Telegram and link this account';
|
|
result.appendChild(anchor);
|
|
}
|
|
} catch (error) { status.textContent = error.message; }
|
|
});
|
|
unlinkButton.addEventListener('click', async () => {
|
|
try { await action('/api/telegram/unlink'); result.hidden = true; await refresh(); }
|
|
catch (error) { status.textContent = error.message; }
|
|
});
|
|
refresh();
|
|
})();`
|
|
|
|
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
writer.WriteHeader(status)
|
|
_ = json.NewEncoder(writer).Encode(value)
|
|
}
|
|
|
|
func validTelegramAction(request *http.Request) bool {
|
|
return request.Header.Get("X-Hermes-Action") == "telegram-link" &&
|
|
strings.HasPrefix(request.Header.Get("Content-Type"), "application/json")
|
|
}
|
|
|
|
func (router *tenantRouter) serveTelegramWeb(writer http.ResponseWriter, request *http.Request, subject string) bool {
|
|
switch request.URL.Path {
|
|
case "/hermes-chat-bridge.css":
|
|
if request.Method != http.MethodGet {
|
|
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
|
|
return true
|
|
}
|
|
writer.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
writer.Header().Set("Cache-Control", "public, max-age=3600")
|
|
_, _ = io.WriteString(writer, bridgeCSS)
|
|
return true
|
|
case "/hermes-chat-bridge.js":
|
|
if request.Method != http.MethodGet {
|
|
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
|
|
return true
|
|
}
|
|
writer.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
writer.Header().Set("Cache-Control", "public, max-age=3600")
|
|
_, _ = io.WriteString(writer, bridgeJS)
|
|
return true
|
|
case "/telegram":
|
|
if request.Method != http.MethodGet {
|
|
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
|
|
return true
|
|
}
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
writer.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'")
|
|
_, _ = io.WriteString(writer, telegramPage)
|
|
return true
|
|
case "/private-files":
|
|
if request.Method != http.MethodGet {
|
|
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
|
|
return true
|
|
}
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
writer.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'")
|
|
_, _ = io.WriteString(writer, privateFilesPage)
|
|
return true
|
|
case "/api/telegram/status":
|
|
if request.Method != http.MethodGet {
|
|
writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
|
return true
|
|
}
|
|
linked, err := router.telegramLinked(subject)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
|
return true
|
|
}
|
|
username := ""
|
|
ready := false
|
|
lastError := ""
|
|
if router.telegram != nil {
|
|
username, ready, lastError = router.telegram.status()
|
|
}
|
|
writeJSON(writer, http.StatusOK, map[string]any{
|
|
"configured": router.telegram != nil,
|
|
"ready": ready,
|
|
"linked": linked,
|
|
"bot_username": username,
|
|
"error": lastError,
|
|
})
|
|
return true
|
|
case "/api/telegram/link":
|
|
if request.Method != http.MethodPost || !validTelegramAction(request) {
|
|
writeJSON(writer, http.StatusForbidden, map[string]string{"error": "same-origin action required"})
|
|
return true
|
|
}
|
|
if router.telegram == nil {
|
|
writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "Telegram bot token is not configured"})
|
|
return true
|
|
}
|
|
username, ready, _ := router.telegram.status()
|
|
if !ready || username == "" {
|
|
writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "The configured Telegram bot is not active. Ask the operator to update its BotFather token and roll out the router."})
|
|
return true
|
|
}
|
|
code, expires, err := router.createLink(subject)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
|
return true
|
|
}
|
|
deepLink := fmt.Sprintf("https://t.me/%s?start=%s", url.PathEscape(username), url.QueryEscape(code))
|
|
writeJSON(writer, http.StatusOK, map[string]any{
|
|
"code": code,
|
|
"expires_at": expires.Format(time.RFC3339),
|
|
"deep_link": deepLink,
|
|
"bot_username": username,
|
|
})
|
|
return true
|
|
case "/api/telegram/unlink":
|
|
if request.Method != http.MethodPost || !validTelegramAction(request) {
|
|
writeJSON(writer, http.StatusForbidden, map[string]string{"error": "same-origin action required"})
|
|
return true
|
|
}
|
|
if err := router.unlinkTelegram(subject); err != nil {
|
|
writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
|
return true
|
|
}
|
|
writeJSON(writer, http.StatusOK, map[string]bool{"unlinked": true})
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func injectChatBridge(response *http.Response) error {
|
|
if !strings.Contains(response.Header.Get("Content-Type"), "text/html") {
|
|
return nil
|
|
}
|
|
body, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = response.Body.Close()
|
|
content := string(body)
|
|
if !strings.Contains(content, "hermes-chat-bridge.js") {
|
|
content = strings.Replace(content, "</head>", `<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260813-telegram-readiness-v1"></head>`, 1)
|
|
content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js?v=20260813-telegram-readiness-v1" defer></script></body>`, 1)
|
|
}
|
|
response.Body = io.NopCloser(strings.NewReader(content))
|
|
response.ContentLength = int64(len(content))
|
|
response.Header.Set("Content-Length", strconv.Itoa(len(content)))
|
|
response.Header.Set("Cache-Control", "no-store")
|
|
response.Header.Del("ETag")
|
|
return nil
|
|
}
|