233 lines
9.4 KiB
Go
233 lines
9.4 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">
|
|
</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>Link this Keycloak account to a private Telegram chat. Messages will use the same isolated Hermes tenant as the WebUI.</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>
|
|
<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" defer></script>
|
|
</body>
|
|
</html>`
|
|
|
|
const bridgeCSS = `
|
|
#hermes-telegram-shortcut{position:fixed;right:18px;bottom:18px;z-index:9999;padding:10px 14px;border-radius:999px;background:#229ed9;color:#fff;text-decoration:none;font:600 14px system-ui,sans-serif;box-shadow:0 5px 20px #0005}
|
|
.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}
|
|
#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}
|
|
`
|
|
|
|
const bridgeJS = `(() => {
|
|
const page = document.querySelector('[data-telegram-page]');
|
|
if (!page) {
|
|
if (!document.getElementById('hermes-telegram-shortcut')) {
|
|
const link = document.createElement('a');
|
|
link.id = 'hermes-telegram-shortcut';
|
|
link.href = '/telegram';
|
|
link.textContent = 'Telegram';
|
|
link.setAttribute('aria-label', 'Connect Hermes to Telegram');
|
|
document.body.appendChild(link);
|
|
}
|
|
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 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 prepared, but the bot token has not been added by the operator yet.';
|
|
linkButton.disabled = true;
|
|
unlinkButton.hidden = true;
|
|
return;
|
|
}
|
|
status.textContent = payload.linked ? 'Telegram is linked to this private account.' : 'Telegram is ready to link.';
|
|
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');
|
|
text.textContent = 'Send /link ' + payload.code + ' to the Hermes bot. 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.textContent = 'Open Telegram and link now';
|
|
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 "/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 := ""
|
|
if router.telegram != nil {
|
|
username = router.telegram.username()
|
|
}
|
|
writeJSON(writer, http.StatusOK, map[string]any{
|
|
"configured": router.telegram != nil,
|
|
"linked": linked,
|
|
"bot_username": username,
|
|
})
|
|
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
|
|
}
|
|
code, expires, err := router.createLink(subject)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
|
return true
|
|
}
|
|
username := router.telegram.username()
|
|
deepLink := ""
|
|
if username != "" {
|
|
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,
|
|
})
|
|
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"></head>`, 1)
|
|
content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js" 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
|
|
}
|