Skip to content

Commit c7c1593

Browse files
committed
Add Telegram admin mini app
1 parent 5477ea2 commit c7c1593

13 files changed

Lines changed: 1106 additions & 10 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,8 +433,19 @@ Deployment recipe (behind Traefik + Authentik):
433433
# docker-compose.yml
434434
labels:
435435
- "traefik.enable=true"
436+
# Telegram Mini App: public at the proxy layer, protected by Telegram initData
437+
# HMAC + TELEGRAM_OWNER_CHAT_ID inside the Go service.
438+
- "traefik.http.routers.assistant-tg-admin.entrypoints=https"
439+
- "traefik.http.routers.assistant-tg-admin.rule=Host(`assistant.example.com`) && PathPrefix(`/tg-admin`)"
440+
- "traefik.http.routers.assistant-tg-admin.priority=100"
441+
- "traefik.http.routers.assistant-tg-admin.tls=true"
442+
- "traefik.http.routers.assistant-tg-admin.tls.certresolver=letsEncrypt"
443+
- "traefik.http.routers.assistant-tg-admin.service=assistant-admin"
444+
445+
# Full web admin: keep Authentik/forward-auth protection.
436446
- "traefik.http.routers.assistant-admin.entrypoints=https"
437447
- "traefik.http.routers.assistant-admin.rule=Host(`assistant.example.com`)"
448+
- "traefik.http.routers.assistant-admin.priority=10"
438449
- "traefik.http.routers.assistant-admin.tls=true"
439450
- "traefik.http.routers.assistant-admin.tls.certresolver=letsEncrypt"
440451
- "traefik.http.routers.assistant-admin.middlewares=authentik-auth"

internal/adminapi/adminapi_test.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,25 @@ package adminapi
22

33
import (
44
"bytes"
5+
"context"
6+
"crypto/hmac"
7+
"crypto/sha256"
8+
"encoding/hex"
9+
"encoding/json"
10+
"io"
511
"log/slog"
612
"net/http"
713
"net/http/httptest"
14+
"net/url"
15+
"sort"
16+
"strconv"
817
"strings"
918
"testing"
19+
"time"
1020

1121
"telegram-agent/internal/config"
1222
"telegram-agent/internal/llm"
23+
"telegram-agent/internal/store"
1324
)
1425

1526
func newTestServer(t *testing.T) *Server {
@@ -48,6 +59,7 @@ func TestTemplatesParse(t *testing.T) {
4859
viewIndex: data,
4960
viewRouting: data.Routing, // routing view takes uiRouting directly
5061
viewModelsBrowser: data,
62+
viewTGAdmin: tgAdminData{FullAdminURL: "https://assistant.example/admin"},
5163
}
5264
for v, d := range cases {
5365
t.Run(v, func(t *testing.T) {
@@ -62,6 +74,105 @@ func TestTemplatesParse(t *testing.T) {
6274
}
6375
}
6476

77+
func signedTGInitData(t *testing.T, token string, userID int64, authTime time.Time) string {
78+
t.Helper()
79+
userJSON, err := json.Marshal(tgAdminUser{ID: userID, FirstName: "Alex"})
80+
if err != nil {
81+
t.Fatal(err)
82+
}
83+
values := url.Values{}
84+
values.Set("auth_date", strconv.FormatInt(authTime.Unix(), 10))
85+
values.Set("query_id", "AAEAAAE")
86+
values.Set("user", string(userJSON))
87+
88+
keys := make([]string, 0, len(values))
89+
for k := range values {
90+
keys = append(keys, k)
91+
}
92+
sort.Strings(keys)
93+
parts := make([]string, 0, len(keys))
94+
for _, k := range keys {
95+
parts = append(parts, k+"="+values.Get(k))
96+
}
97+
98+
secretMAC := hmac.New(sha256.New, []byte("WebAppData"))
99+
_, _ = secretMAC.Write([]byte(token))
100+
secret := secretMAC.Sum(nil)
101+
102+
hashMAC := hmac.New(sha256.New, secret)
103+
_, _ = hashMAC.Write([]byte(strings.Join(parts, "\n")))
104+
values.Set("hash", hex.EncodeToString(hashMAC.Sum(nil)))
105+
return values.Encode()
106+
}
107+
108+
func TestValidateTGInitDataAcceptsOwnerSignedData(t *testing.T) {
109+
s := newTestServer(t)
110+
s.cfgRef.Telegram.BotToken = "123:abc"
111+
now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
112+
113+
user, err := s.validateTGInitData(signedTGInitData(t, "123:abc", 42, now), now)
114+
if err != nil {
115+
t.Fatalf("validateTGInitData: %v", err)
116+
}
117+
if user.ID != 42 {
118+
t.Fatalf("user id = %d, want 42", user.ID)
119+
}
120+
}
121+
122+
func TestTGAdminAPIRejectsInvalidAuth(t *testing.T) {
123+
s := newTestServer(t)
124+
s.cfgRef.Telegram.BotToken = "123:abc"
125+
s.cfgRef.Telegram.OwnerChatID = 42
126+
127+
req := httptest.NewRequest(http.MethodGet, "/tg-admin/api/summary", nil)
128+
req.Header.Set("X-Telegram-Init-Data", "auth_date=1&user={}&hash=bad")
129+
rec := httptest.NewRecorder()
130+
s.handleTGAdminRouter(rec, req)
131+
132+
if rec.Code != http.StatusUnauthorized {
133+
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
134+
}
135+
}
136+
137+
func TestTGAdminAPIAcceptsOwner(t *testing.T) {
138+
s := newTestServer(t)
139+
s.cfgRef.Telegram.BotToken = "123:abc"
140+
s.cfgRef.Telegram.OwnerChatID = 42
141+
now := time.Now()
142+
143+
req := httptest.NewRequest(http.MethodGet, "/tg-admin/api/summary", nil)
144+
req.Header.Set("X-Telegram-Init-Data", signedTGInitData(t, "123:abc", 42, now))
145+
rec := httptest.NewRecorder()
146+
s.handleTGAdminRouter(rec, req)
147+
148+
if rec.Code != http.StatusOK {
149+
t.Fatalf("status = %d, want %d, body: %s", rec.Code, http.StatusOK, rec.Body.String())
150+
}
151+
var payload tgAdminSummary
152+
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
153+
t.Fatal(err)
154+
}
155+
if payload.Status.Bot != "running" {
156+
t.Fatalf("bot status = %q", payload.Status.Bot)
157+
}
158+
}
159+
160+
func TestTGAdminAPIRejectsNonOwner(t *testing.T) {
161+
s := newTestServer(t)
162+
s.cfgRef.Telegram.BotToken = "123:abc"
163+
s.cfgRef.Telegram.OwnerChatID = 42
164+
now := time.Now()
165+
166+
req := httptest.NewRequest(http.MethodGet, "/tg-admin/api/summary", nil)
167+
req.Header.Set("X-Telegram-Init-Data", signedTGInitData(t, "123:abc", 99, now))
168+
rec := httptest.NewRecorder()
169+
s.handleTGAdminRouter(rec, req)
170+
171+
if rec.Code != http.StatusForbidden {
172+
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
173+
}
174+
}
175+
65176
// TestAuthRequired covers the four auth paths + the 401 default.
66177
func TestAuthRequired(t *testing.T) {
67178
s := newTestServer(t)
@@ -213,3 +324,91 @@ func TestHealthz(t *testing.T) {
213324
t.Errorf("healthz body: %s", string(body[:n]))
214325
}
215326
}
327+
328+
type fakeChatAgent struct {
329+
history []store.HistoryItem
330+
}
331+
332+
func (f *fakeChatAgent) Process(context.Context, int64, llm.Message, func(string)) (string, error) {
333+
return "", nil
334+
}
335+
336+
func (f *fakeChatAgent) ProcessStream(context.Context, int64, llm.Message, func(string), func(string)) (string, error) {
337+
return "", nil
338+
}
339+
340+
func (f *fakeChatAgent) GetChatHistory(int64) []llm.Message { return nil }
341+
342+
func (f *fakeChatAgent) GetDisplayHistory(_ int64, limit, offset int) []store.HistoryItem {
343+
if offset >= len(f.history) {
344+
return nil
345+
}
346+
end := offset + limit
347+
if end > len(f.history) {
348+
end = len(f.history)
349+
}
350+
return f.history[offset:end]
351+
}
352+
353+
func (f *fakeChatAgent) ClearChatHistory(int64) {}
354+
355+
func (f *fakeChatAgent) PopLastUserTurn(int64) (string, bool) { return "", false }
356+
357+
func TestChatInitialAssistantUsesBotClassAndNoRequiredTextarea(t *testing.T) {
358+
s := newTestServer(t)
359+
s.SetAgent(&fakeChatAgent{history: []store.HistoryItem{{
360+
Role: "assistant",
361+
Content: "hello",
362+
CreatedAt: time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC),
363+
}}})
364+
365+
req := httptest.NewRequest(http.MethodGet, "/chat", nil)
366+
rec := httptest.NewRecorder()
367+
s.handleChat(rec, req)
368+
369+
body := rec.Body.String()
370+
if !strings.Contains(body, `chat-msg chat-msg--bot`) {
371+
t.Fatalf("assistant bubble should use bot class, body:\n%s", body)
372+
}
373+
if strings.Contains(body, `chat-msg--assistant`) {
374+
t.Fatalf("assistant class leaked into initial chat render:\n%s", body)
375+
}
376+
if strings.Contains(body, "required></textarea>") {
377+
t.Fatal("chat textarea should not be marked required; image-only submits must be allowed")
378+
}
379+
}
380+
381+
func TestChatHistoryEscapesLazyLoadedMessages(t *testing.T) {
382+
s := newTestServer(t)
383+
s.SetAgent(&fakeChatAgent{history: []store.HistoryItem{{
384+
Role: "user",
385+
Content: `<img src=x onerror=alert(1)>`,
386+
ImageURLs: []string{`x" onerror="alert(2)`},
387+
CreatedAt: time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC),
388+
}}})
389+
390+
req := httptest.NewRequest(http.MethodGet, "/chat/history?offset=0", nil)
391+
rec := httptest.NewRecorder()
392+
s.handleChatHistory(rec, req)
393+
394+
resp := rec.Result()
395+
defer resp.Body.Close()
396+
if resp.StatusCode != http.StatusOK {
397+
t.Fatalf("status = %d", resp.StatusCode)
398+
}
399+
data, err := io.ReadAll(resp.Body)
400+
if err != nil {
401+
t.Fatal(err)
402+
}
403+
var payload map[string]any
404+
if err := json.Unmarshal(data, &payload); err != nil {
405+
t.Fatal(err)
406+
}
407+
html, _ := payload["html"].(string)
408+
if strings.Contains(html, `<img src=x`) || strings.Contains(html, `src="x&quot; onerror`) {
409+
t.Fatalf("lazy history response contains unsafe HTML: %s", html)
410+
}
411+
if !strings.Contains(html, `&lt;img src=x onerror=alert(1)&gt;`) {
412+
t.Fatalf("lazy history did not preserve escaped user text: %s", html)
413+
}
414+
}

internal/adminapi/chat.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"encoding/json"
66
"fmt"
77
"hash/fnv"
8+
"html"
89
"net/http"
10+
"net/url"
911
"strings"
1012
"sync"
1113
"time"
@@ -144,10 +146,10 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
144146
}
145147
if v.Role == "break" {
146148
sb.WriteString(`<div class="divider--labeled"><span class="divider__label">`)
147-
sb.WriteString(v.BreakDate)
149+
sb.WriteString(html.EscapeString(v.BreakDate))
148150
if v.Body != "" {
149151
sb.WriteString(" &middot; ")
150-
sb.WriteString(v.Body)
152+
sb.WriteString(html.EscapeString(v.Body))
151153
}
152154
sb.WriteString(`</span></div>`)
153155
} else {
@@ -165,19 +167,22 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
165167
}
166168
if v.Time != "" {
167169
sb.WriteString(" &middot; ")
168-
sb.WriteString(v.Time)
170+
sb.WriteString(html.EscapeString(v.Time))
169171
}
170172
sb.WriteString(`</div>`)
171173
if len(v.ImageURLs) > 0 {
172174
sb.WriteString(`<div class="chat-msg__body">`)
173175
for _, u := range v.ImageURLs {
176+
if !safeImageURL(u) {
177+
continue
178+
}
174179
sb.WriteString(`<img src="`)
175-
sb.WriteString(u)
180+
sb.WriteString(htmlEscapeAttr(u))
176181
sb.WriteString(`" alt="attachment">`)
177182
}
178183
if v.Body != "" {
179184
sb.WriteString(`<div>`)
180-
sb.WriteString(v.Body)
185+
sb.WriteString(html.EscapeString(v.Body))
181186
sb.WriteString(`</div>`)
182187
}
183188
sb.WriteString(`</div>`)
@@ -188,7 +193,7 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
188193
sb.WriteString(`"></div>`)
189194
} else {
190195
sb.WriteString(`<div class="chat-msg__body">`)
191-
sb.WriteString(v.Body)
196+
sb.WriteString(html.EscapeString(v.Body))
192197
sb.WriteString(`</div>`)
193198
}
194199
sb.WriteString(`</div>`)
@@ -223,6 +228,21 @@ func itemToView(it store.HistoryItem) *chatMsgView {
223228
return v
224229
}
225230

231+
func safeImageURL(raw string) bool {
232+
u, err := url.Parse(raw)
233+
if err != nil {
234+
return false
235+
}
236+
switch u.Scheme {
237+
case "http", "https":
238+
return u.Host != ""
239+
case "data":
240+
return strings.HasPrefix(strings.ToLower(raw), "data:image/")
241+
default:
242+
return false
243+
}
244+
}
245+
226246
func htmlEscapeAttr(s string) string {
227247
s = strings.ReplaceAll(s, "&", "&amp;")
228248
s = strings.ReplaceAll(s, `"`, "&quot;")

internal/adminapi/server.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
116116
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
117117
})
118118

119+
// Telegram Mini App shell + Telegram init-data protected API.
120+
mux.Handle("/tg-admin", http.HandlerFunc(s.handleTGAdmin))
121+
mux.Handle("/tg-admin/", http.HandlerFunc(s.handleTGAdminRouter))
122+
119123
// Authenticated routes.
120124
authed := s.requireAuth
121125
mux.Handle("/", authed(http.HandlerFunc(s.handleIndex)))

internal/adminapi/static/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ design system `@main`.
1717

1818
Do **not** commit real builds of `dzarlax.css`, `dzarlax.js`, or
1919
`htmx.min.js` — they bloat diffs and go stale.
20+
21+
`marked.min.js` is intentionally committed as a small local runtime asset so
22+
the Chat tab does not depend on a browser-time CDN request.

0 commit comments

Comments
 (0)