Extend the chat router HTML-injection bridge so tenants see a clean chat + conversation-history experience instead of the full agent cockpit. hideChatAdministration now hides the rail buttons and panels for logs, insights, memory, skills, workspaces, todos, tasks and profiles (alongside the existing Kanban), and bridgeCSS hides the same [data-panel] targets pre-JS to avoid a flash. Also hide the redundant legacy settings model dropdown (#settingsModel) via bridgeCSS while keeping the composer routing chip. Chat, history/sessions, new-chat, composer and the model chip are untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
482 lines
26 KiB
Go
482 lines
26 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>After linking, you can send text or photos for analysis. Images Hermes creates or revises are returned directly in the same private chat.</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 = `
|
|
[data-panel="kanban"],[data-panel="logs"],[data-panel="insights"],[data-panel="memory"],[data-panel="skills"],[data-panel="workspaces"],[data-panel="todos"],[data-panel="tasks"],[data-panel="profiles"]{display:none!important}
|
|
#settingsModel,label:has(#settingsModel),.settings-row:has(#settingsModel){display:none!important}
|
|
#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 chatAdministrationPanels = ['kanban','logs','insights','memory','skills','workspaces','todos','tasks','profiles'];
|
|
const hideChatAdministration = () => {
|
|
chatAdministrationPanels.forEach((panel) => {
|
|
document.querySelectorAll('[data-panel="' + panel + '"]').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; hideChatAdministration(); 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 "/hermes-session-continuity.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, sessionContinuityJS)
|
|
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)
|
|
}
|
|
if !strings.Contains(content, "hermes-session-continuity.js") {
|
|
content = strings.Replace(content, "</body>", `<script src="/hermes-session-continuity.js?v=20260817-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
|
|
}
|