Skip to content

Commit 5b76216

Browse files
committed
Move bot operations into Telegram mini app
1 parent b2a51e2 commit 5b76216

7 files changed

Lines changed: 562 additions & 23 deletions

File tree

internal/adminapi/adminapi_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"testing"
1919
"time"
2020

21+
"telegram-agent/internal/agent"
2122
"telegram-agent/internal/config"
2223
"telegram-agent/internal/llm"
2324
"telegram-agent/internal/store"
@@ -285,6 +286,172 @@ func TestTGAdminModelSetUpdatesCurrentRoleSlot(t *testing.T) {
285286
}
286287
}
287288

289+
type fakeTGOperationsAgent struct {
290+
clearedChatID int64
291+
compactChatID int64
292+
compactErr error
293+
stats store.ChatStats
294+
statsOK bool
295+
tools []agent.ToolInfo
296+
}
297+
298+
func (f *fakeTGOperationsAgent) ClearHistory(chatID int64) {
299+
f.clearedChatID = chatID
300+
}
301+
302+
func (f *fakeTGOperationsAgent) Compact(_ context.Context, chatID int64) error {
303+
f.compactChatID = chatID
304+
return f.compactErr
305+
}
306+
307+
func (f *fakeTGOperationsAgent) GetStats(int64) (store.ChatStats, bool) {
308+
return f.stats, f.statsOK
309+
}
310+
311+
func (f *fakeTGOperationsAgent) ListTools() []agent.ToolInfo {
312+
return f.tools
313+
}
314+
315+
type fakeMCPReloader struct {
316+
configs map[string]config.MCPServerConfig
317+
toolCount int
318+
}
319+
320+
func (f *fakeMCPReloader) ReloadMCP(_ context.Context, configs map[string]config.MCPServerConfig) (int, error) {
321+
f.configs = configs
322+
return f.toolCount, nil
323+
}
324+
325+
type fakeSettingsStore struct {
326+
values map[string]string
327+
}
328+
329+
func (f fakeSettingsStore) GetSetting(_ context.Context, key string) (string, bool, error) {
330+
v, ok := f.values[key]
331+
return v, ok, nil
332+
}
333+
334+
func (f fakeSettingsStore) PutSetting(context.Context, string, string) error {
335+
return nil
336+
}
337+
338+
func TestTGAdminStatsReturnsOwnerChatStats(t *testing.T) {
339+
s := newTestServer(t)
340+
s.cfgRef.Telegram.BotToken = "123:abc"
341+
s.cfgRef.Telegram.OwnerChatID = 42
342+
s.opsAgent = &fakeTGOperationsAgent{
343+
statsOK: true,
344+
stats: store.ChatStats{
345+
ActiveMessages: 7,
346+
ActiveChars: 1234,
347+
LastMessageAt: time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC),
348+
},
349+
}
350+
351+
req := httptest.NewRequest(http.MethodGet, "/tg-admin/api/stats", nil)
352+
req.Header.Set("X-Telegram-Init-Data", signedTGInitData(t, "123:abc", 42, time.Now()))
353+
rec := httptest.NewRecorder()
354+
s.handleTGAdminRouter(rec, req)
355+
356+
if rec.Code != http.StatusOK {
357+
t.Fatalf("status = %d, want %d, body: %s", rec.Code, http.StatusOK, rec.Body.String())
358+
}
359+
var payload tgAdminStats
360+
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
361+
t.Fatal(err)
362+
}
363+
if !payload.Available || payload.ActiveMessages != 7 || payload.ActiveChars != 1234 {
364+
t.Fatalf("unexpected stats: %+v", payload)
365+
}
366+
}
367+
368+
func TestTGAdminToolsReturnsGroupedTools(t *testing.T) {
369+
s := newTestServer(t)
370+
s.cfgRef.Telegram.BotToken = "123:abc"
371+
s.cfgRef.Telegram.OwnerChatID = 42
372+
s.opsAgent = &fakeTGOperationsAgent{tools: []agent.ToolInfo{
373+
{Name: "search", ServerName: "memory"},
374+
{Name: "recall", ServerName: "memory"},
375+
{Name: "query", ServerName: "finance"},
376+
}}
377+
378+
req := httptest.NewRequest(http.MethodGet, "/tg-admin/api/tools", nil)
379+
req.Header.Set("X-Telegram-Init-Data", signedTGInitData(t, "123:abc", 42, time.Now()))
380+
rec := httptest.NewRecorder()
381+
s.handleTGAdminRouter(rec, req)
382+
383+
if rec.Code != http.StatusOK {
384+
t.Fatalf("status = %d, want %d, body: %s", rec.Code, http.StatusOK, rec.Body.String())
385+
}
386+
var payload []tgAdminToolGroup
387+
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
388+
t.Fatal(err)
389+
}
390+
if len(payload) != 2 || payload[0].Server != "finance" || payload[1].Server != "memory" {
391+
t.Fatalf("unexpected groups: %+v", payload)
392+
}
393+
if strings.Join(payload[1].Tools, ",") != "recall,search" {
394+
t.Fatalf("memory tools not sorted: %+v", payload[1].Tools)
395+
}
396+
}
397+
398+
func TestTGAdminActionRejectsUnknownAction(t *testing.T) {
399+
s := newTestServer(t)
400+
s.cfgRef.Telegram.BotToken = "123:abc"
401+
s.cfgRef.Telegram.OwnerChatID = 42
402+
403+
req := httptest.NewRequest(http.MethodPost, "/tg-admin/api/action", strings.NewReader(`{"action":"bad"}`))
404+
req.Header.Set("X-Telegram-Init-Data", signedTGInitData(t, "123:abc", 42, time.Now()))
405+
rec := httptest.NewRecorder()
406+
s.handleTGAdminRouter(rec, req)
407+
408+
if rec.Code != http.StatusBadRequest {
409+
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
410+
}
411+
}
412+
413+
func TestTGAdminClearActionClearsOwnerChat(t *testing.T) {
414+
s := newTestServer(t)
415+
s.cfgRef.Telegram.BotToken = "123:abc"
416+
s.cfgRef.Telegram.OwnerChatID = 42
417+
ops := &fakeTGOperationsAgent{statsOK: true}
418+
s.opsAgent = ops
419+
420+
req := httptest.NewRequest(http.MethodPost, "/tg-admin/api/action", strings.NewReader(`{"action":"clear"}`))
421+
req.Header.Set("X-Telegram-Init-Data", signedTGInitData(t, "123:abc", 42, time.Now()))
422+
rec := httptest.NewRecorder()
423+
s.handleTGAdminRouter(rec, req)
424+
425+
if rec.Code != http.StatusOK {
426+
t.Fatalf("status = %d, want %d, body: %s", rec.Code, http.StatusOK, rec.Body.String())
427+
}
428+
if ops.clearedChatID != 42 {
429+
t.Fatalf("cleared chat id = %d, want 42", ops.clearedChatID)
430+
}
431+
}
432+
433+
func TestTGAdminMCPReloadUsesSettingsConfig(t *testing.T) {
434+
s := newTestServer(t)
435+
s.cfgRef.Telegram.BotToken = "123:abc"
436+
s.cfgRef.Telegram.OwnerChatID = 42
437+
serversJSON := `{"memory":{"type":"http","url":"http://memory:8080"}}`
438+
s.settings = fakeSettingsStore{values: map[string]string{SettingKeyMCPServers: serversJSON}}
439+
reloader := &fakeMCPReloader{toolCount: 3}
440+
s.reloader = reloader
441+
442+
req := httptest.NewRequest(http.MethodPost, "/tg-admin/api/action", strings.NewReader(`{"action":"mcp_reload"}`))
443+
req.Header.Set("X-Telegram-Init-Data", signedTGInitData(t, "123:abc", 42, time.Now()))
444+
rec := httptest.NewRecorder()
445+
s.handleTGAdminRouter(rec, req)
446+
447+
if rec.Code != http.StatusOK {
448+
t.Fatalf("status = %d, want %d, body: %s", rec.Code, http.StatusOK, rec.Body.String())
449+
}
450+
if len(reloader.configs) != 1 || reloader.configs["memory"].URL != "http://memory:8080" {
451+
t.Fatalf("unexpected reload configs: %+v", reloader.configs)
452+
}
453+
}
454+
288455
// TestAuthRequired covers the four auth paths + the 401 default.
289456
func TestAuthRequired(t *testing.T) {
290457
s := newTestServer(t)

internal/adminapi/chat.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,14 @@ type ChatAgent interface {
3030
PopLastUserTurn(chatID int64) (string, bool)
3131
}
3232

33-
// SetAgent wires the agent so the Chat tab can process messages.
34-
func (s *Server) SetAgent(a ChatAgent) { s.agent = a }
33+
// SetAgent wires the agent so the Chat tab and Telegram Mini App operations
34+
// can call the shared runtime without importing concrete agent state elsewhere.
35+
func (s *Server) SetAgent(a ChatAgent) {
36+
s.agent = a
37+
if ops, ok := a.(TGOperationalAgent); ok {
38+
s.opsAgent = ops
39+
}
40+
}
3541

3642
// chatIDFor returns the chat_id the web admin should read/write. Priority:
3743
// 1. Telegram OwnerChatID when configured — unifies the web chat with the

internal/adminapi/server.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import (
1313
"strings"
1414
"time"
1515

16+
"telegram-agent/internal/agent"
1617
"telegram-agent/internal/config"
1718
"telegram-agent/internal/llm"
19+
"telegram-agent/internal/store"
1820
)
1921

2022
// MCPReloader is the minimal surface the admin UI needs from the bot's MCP
@@ -23,6 +25,13 @@ type MCPReloader interface {
2325
ReloadMCP(ctx context.Context, configs map[string]config.MCPServerConfig) (int, error)
2426
}
2527

28+
type TGOperationalAgent interface {
29+
ClearHistory(chatID int64)
30+
Compact(ctx context.Context, chatID int64) error
31+
GetStats(chatID int64) (store.ChatStats, bool)
32+
ListTools() []agent.ToolInfo
33+
}
34+
2635
// Server wraps an http.Server + the upstream dependencies it needs to render
2736
// and mutate routing state.
2837
type Server struct {
@@ -34,6 +43,7 @@ type Server struct {
3443
cfgRef *config.Config // needed for enumerating OpenRouter slots
3544
reloader MCPReloader // may be nil (local dev / tests)
3645
agent ChatAgent // may be nil (admin UI only, no chat tab)
46+
opsAgent TGOperationalAgent
3747
logger *slog.Logger
3848

3949
httpSrv *http.Server
@@ -126,7 +136,7 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
126136
mux.Handle("/models", authed(http.HandlerFunc(s.handleModels)))
127137
mux.Handle("/routing", authed(http.HandlerFunc(s.handleRouting)))
128138
mux.Handle("/slots/", authed(http.HandlerFunc(s.handleSlotAssign))) // POST /slots/{slot}/assign
129-
mux.Handle("/routing/", authed(http.HandlerFunc(s.handleRoleSet))) // POST /routing/{role}/set
139+
mux.Handle("/routing/", authed(http.HandlerFunc(s.handleRoleSet))) // POST /routing/{role}/set
130140
mux.Handle("/refresh", authed(http.HandlerFunc(s.handleRefresh)))
131141
mux.Handle("/usage", authed(http.HandlerFunc(s.handleUsage)))
132142
mux.Handle("/analytics", authed(http.HandlerFunc(s.handleAnalytics)))

0 commit comments

Comments
 (0)