+
+
+
+
+
+
+
+
+
+
+
{{ propSchema.description }}
+
+
+
Use as Authorization: Bearer <token>
@@ -223,6 +270,7 @@ import FormInput from '../../components/FormInput.vue'
import FormSelect from '../../components/FormSelect.vue'
import FormLabel from '../../components/FormLabel.vue'
import Icon from '../../components/Icon.vue'
+import { buildAllowedChatsPayload } from './allowedChatsRules.js'
const emit = defineEmits(['saved'])
const toast = inject('toast')
@@ -231,6 +279,7 @@ const dialogRef = ref(null)
const editId = ref(null)
const isEdit = ref(false)
const tokenVisible = ref(false)
+const isTestingTelegram = ref(false)
const showAllEntities = ref(false)
const maxVisibleEntities = 6
@@ -389,6 +438,46 @@ function csvToArray(propSchema, val) {
return parts
}
+function normalizeAllowedChatRows(val) {
+ if (!Array.isArray(val)) return []
+ return val.map(rule => {
+ if (rule && typeof rule === 'object') {
+ return {
+ chatId: rule.chatId !== undefined && rule.chatId !== null ? String(rule.chatId) : '',
+ threadId: rule.threadId !== undefined && rule.threadId !== null ? String(rule.threadId) : '',
+ }
+ }
+ return null
+ }).filter(Boolean)
+}
+
+function getAllowedChatRows() {
+ return normalizeAllowedChatRows(form.config.allowedChats)
+}
+
+function setAllowedChatRows(rows) {
+ form.config.allowedChats = rows
+}
+
+function addAllowedChatRule() {
+ const rows = getAllowedChatRows()
+ rows.push({ chatId: '', threadId: '' })
+ setAllowedChatRows(rows)
+}
+
+function removeAllowedChatRule(index) {
+ const rows = getAllowedChatRows()
+ rows.splice(index, 1)
+ setAllowedChatRows(rows)
+}
+
+function updateAllowedChatRule(index, field, value) {
+ const rows = getAllowedChatRows()
+ if (!rows[index]) return
+ rows[index][field] = value?.toString().trim() ?? ''
+ setAllowedChatRows(rows)
+}
+
function onTypeChange() {
form.config = {}
const props = allProperties.value
@@ -407,6 +496,9 @@ function open(client = null) {
form.enabled = client?.enabled ?? true
form.allowedAgents = [...(client?.allowedAgents || [])]
form.config = { ...(client?.config?.[client?.type] || {}) }
+ if (form.type === 'telegram') {
+ form.config.allowedChats = normalizeAllowedChatRows(form.config.allowedChats)
+ }
form.token = client?.token || ''
tokenVisible.value = false
showAllEntities.value = false
@@ -449,7 +541,29 @@ async function regenerateToken() {
}
}
-async function save() {
+async function sendTelegramTest() {
+ if (!editId.value || form.type !== 'telegram' || isTestingTelegram.value) return
+ isTestingTelegram.value = true
+ try {
+ const telegramConfig = buildTypeConfig().telegram || {}
+ const result = await clientsApi.telegramTest(editId.value, { config: telegramConfig })
+ if (result.failed > 0) {
+ const details = Array.isArray(result.errors) ? result.errors.filter(Boolean) : []
+ const preview = details.slice(0, 2).join(' | ')
+ const more = details.length > 2 ? ` (+${details.length - 2} more)` : ''
+ const detailText = preview ? ` Details: ${preview}${more}` : ''
+ toast.error(`Test completed with errors. Sent ${result.sent}/${result.attempted}.${detailText}`)
+ return
+ }
+ toast.success(`Test message sent to ${result.sent} destination(s).`)
+ } catch (e) {
+ toast.error(e.message)
+ } finally {
+ isTestingTelegram.value = false
+ }
+}
+
+function buildTypeConfig() {
const config = {}
const schema = currentSchema.value
const props = schema.properties || {}
@@ -461,7 +575,12 @@ async function save() {
if (propSchema.type === 'boolean') {
typeCfg[key] = !!val
} else if (propSchema.type === 'array') {
- if (Array.isArray(val) && val.length) {
+ if (key === 'allowedChats') {
+ const rules = buildAllowedChatsPayload(val)
+ if (rules.length) {
+ typeCfg[key] = rules
+ }
+ } else if (Array.isArray(val) && val.length) {
typeCfg[key] = val
}
} else if (propSchema.type === 'integer' || propSchema.type === 'number') {
@@ -476,14 +595,20 @@ async function save() {
config[form.type] = typeCfg
}
- const data = {
- name: form.name.trim(),
- type: form.type,
- allowedAgents: form.allowedAgents,
- enabled: form.enabled,
- config,
- }
+ return config
+}
+
+async function save() {
try {
+ const config = buildTypeConfig()
+ const data = {
+ name: form.name.trim(),
+ type: form.type,
+ allowedAgents: form.allowedAgents,
+ enabled: form.enabled,
+ config,
+ }
+
if (isEdit.value) {
await clientsApi.update(editId.value, data)
} else {
diff --git a/frontend/admin-ui/src/views/clients/allowedChatsRules.js b/frontend/admin-ui/src/views/clients/allowedChatsRules.js
new file mode 100644
index 0000000..116c28a
--- /dev/null
+++ b/frontend/admin-ui/src/views/clients/allowedChatsRules.js
@@ -0,0 +1,72 @@
+export const ALLOWED_CHATS_RULES_ERROR = 'Invalid allowed chat rules.'
+
+export function buildAllowedChatsPayload(val) {
+ const rows = normalizeAllowedChatRows(val)
+ const payload = []
+ const seen = new Set()
+ const errors = []
+
+ for (const [index, row] of rows.entries()) {
+ const rowNum = index + 1
+ const chatRaw = row.chatId?.toString().trim() ?? ''
+ const threadRaw = row.threadId?.toString().trim() ?? ''
+
+ if (chatRaw === '' && threadRaw === '') {
+ continue
+ }
+
+ if (chatRaw === '') {
+ errors.push(`Row ${rowNum}: chatId is required.`)
+ continue
+ }
+
+ const chatID = Number(chatRaw)
+ if (Number.isNaN(chatID) || !Number.isInteger(chatID)) {
+ errors.push(`Row ${rowNum}: chatId must be an integer.`)
+ continue
+ }
+ if (Math.trunc(chatID) === 0) {
+ errors.push(`Row ${rowNum}: chatId must be non-zero.`)
+ continue
+ }
+
+ const item = { chatId: Math.trunc(chatID) }
+ if (threadRaw !== '') {
+ const threadID = Number(threadRaw)
+ if (Number.isNaN(threadID) || !Number.isInteger(threadID)) {
+ errors.push(`Row ${rowNum}: threadId must be an integer when provided.`)
+ continue
+ }
+ if (Math.trunc(threadID) <= 0) {
+ errors.push(`Row ${rowNum}: threadId must be greater than zero when provided.`)
+ continue
+ }
+ item.threadId = Math.trunc(threadID)
+ }
+
+ const key = `${item.chatId}:${item.threadId ?? 0}`
+ if (!seen.has(key)) {
+ seen.add(key)
+ payload.push(item)
+ }
+ }
+
+ if (errors.length > 0) {
+ throw new Error(`${ALLOWED_CHATS_RULES_ERROR} ${errors.join(' ')}`)
+ }
+
+ return payload
+}
+
+function normalizeAllowedChatRows(val) {
+ if (!Array.isArray(val)) return []
+ return val.map(rule => {
+ if (rule && typeof rule === 'object') {
+ return {
+ chatId: rule.chatId !== undefined && rule.chatId !== null ? String(rule.chatId) : '',
+ threadId: rule.threadId !== undefined && rule.threadId !== null ? String(rule.threadId) : '',
+ }
+ }
+ return null
+ }).filter(Boolean)
+}
diff --git a/frontend/admin-ui/src/views/clients/allowedChatsRules.test.js b/frontend/admin-ui/src/views/clients/allowedChatsRules.test.js
new file mode 100644
index 0000000..9f9cb57
--- /dev/null
+++ b/frontend/admin-ui/src/views/clients/allowedChatsRules.test.js
@@ -0,0 +1,60 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { buildAllowedChatsPayload, ALLOWED_CHATS_RULES_ERROR } from './allowedChatsRules.js'
+
+test('buildAllowedChatsPayload trims values and keeps valid rules', () => {
+ const payload = buildAllowedChatsPayload([
+ { chatId: ' -1001234567890 ', threadId: ' 12 ' },
+ { chatId: ' -1001234567891 ', threadId: '' },
+ ])
+
+ assert.deepEqual(payload, [
+ { chatId: -1001234567890, threadId: 12 },
+ { chatId: -1001234567891 },
+ ])
+})
+
+test('buildAllowedChatsPayload ignores fully empty rows', () => {
+ const payload = buildAllowedChatsPayload([
+ { chatId: '', threadId: '' },
+ { chatId: ' ', threadId: ' ' },
+ ])
+
+ assert.deepEqual(payload, [])
+})
+
+test('buildAllowedChatsPayload rejects chatId 0', () => {
+ assert.throws(
+ () => buildAllowedChatsPayload([{ chatId: '0', threadId: '' }]),
+ (error) => error.message.includes(ALLOWED_CHATS_RULES_ERROR) && error.message.includes('Row 1: chatId must be non-zero.'),
+ )
+})
+
+test('buildAllowedChatsPayload rejects non-positive threadId when provided', () => {
+ assert.throws(
+ () => buildAllowedChatsPayload([{ chatId: '-1001234567890', threadId: '0' }]),
+ (error) => error.message.includes(ALLOWED_CHATS_RULES_ERROR) && error.message.includes('Row 1: threadId must be greater than zero when provided.'),
+ )
+})
+
+test('buildAllowedChatsPayload reports explicit error for missing chatId', () => {
+ assert.throws(
+ () => buildAllowedChatsPayload([{ chatId: ' ', threadId: '12' }]),
+ (error) => error.message.includes('Row 1: chatId is required.'),
+ )
+})
+
+test('buildAllowedChatsPayload deduplicates repeated chat rules', () => {
+ const payload = buildAllowedChatsPayload([
+ { chatId: '-1001234567890', threadId: '12' },
+ { chatId: '-1001234567890', threadId: '12' },
+ { chatId: '-1001234567890', threadId: '' },
+ { chatId: '-1001234567890', threadId: '' },
+ ])
+
+ assert.deepEqual(payload, [
+ { chatId: -1001234567890, threadId: 12 },
+ { chatId: -1001234567890 },
+ ])
+})
diff --git a/server/api/admin/clients.go b/server/api/admin/clients.go
index 3953293..7ab50ea 100644
--- a/server/api/admin/clients.go
+++ b/server/api/admin/clients.go
@@ -1,15 +1,147 @@
package admin
import (
+ "context"
"encoding/json"
+ "fmt"
+ "io"
"net/http"
+ "os"
+ "strings"
"github.com/gorilla/mux"
+ "github.com/mymmrac/telego"
+ tu "github.com/mymmrac/telego/telegoutil"
"github.com/achetronic/magec/server/clients"
"github.com/achetronic/magec/server/store"
)
+const telegramConfigTestMessage = "This is a test message from Magec Telegram client configuration."
+
+type telegramMessageSender interface {
+ SendMessage(ctx context.Context, params *telego.SendMessageParams) (*telego.Message, error)
+}
+
+type telegramTestTarget struct {
+ ChatID int64
+ ThreadID int
+}
+
+type TelegramConfigTestResult struct {
+ Attempted int `json:"attempted"`
+ Sent int `json:"sent"`
+ Failed int `json:"failed"`
+ Errors []string `json:"errors,omitempty"`
+}
+
+type telegramTestRequest struct {
+ Config *store.TelegramClientConfig `json:"config,omitempty"`
+}
+
+func targetKey(chatID int64, threadID int) string {
+ return fmt.Sprintf("%d:%d", chatID, threadID)
+}
+
+func buildTelegramTestTargets(cfg *store.TelegramClientConfig) []telegramTestTarget {
+ if cfg == nil {
+ return nil
+ }
+
+ targets := make([]telegramTestTarget, 0, len(cfg.AllowedUsers)+len(cfg.AllowedChats))
+ seen := map[string]struct{}{}
+
+ for _, userID := range cfg.AllowedUsers {
+ key := targetKey(userID, 0)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ targets = append(targets, telegramTestTarget{ChatID: userID})
+ }
+
+ for _, rule := range cfg.AllowedChats {
+ threadID := 0
+ if rule.ThreadID != nil {
+ threadID = *rule.ThreadID
+ }
+ key := targetKey(rule.ChatID, threadID)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ targets = append(targets, telegramTestTarget{ChatID: rule.ChatID, ThreadID: threadID})
+ }
+
+ return targets
+}
+
+func sendTelegramConfigTest(ctx context.Context, sender telegramMessageSender, cfg *store.TelegramClientConfig) TelegramConfigTestResult {
+ targets := buildTelegramTestTargets(cfg)
+ result := TelegramConfigTestResult{Attempted: len(targets)}
+
+ for _, target := range targets {
+ params := &telego.SendMessageParams{
+ ChatID: tu.ID(target.ChatID),
+ Text: telegramConfigTestMessage,
+ }
+ if target.ThreadID > 0 {
+ params.MessageThreadID = target.ThreadID
+ }
+
+ if _, err := sender.SendMessage(ctx, params); err != nil {
+ result.Failed++
+ result.Errors = append(result.Errors, fmt.Sprintf("target %s: %v", targetKey(target.ChatID, target.ThreadID), err))
+ continue
+ }
+ result.Sent++
+ }
+
+ return result
+}
+
+func resolveTelegramTestConfig(stored, override *store.TelegramClientConfig) *store.TelegramClientConfig {
+ if override == nil {
+ return stored
+ }
+ data, err := json.Marshal(override)
+ if err != nil {
+ return override
+ }
+
+ expanded := os.ExpandEnv(string(data))
+ var resolved store.TelegramClientConfig
+ if err := json.Unmarshal([]byte(expanded), &resolved); err != nil {
+ return override
+ }
+
+ return &resolved
+}
+
+func dedupeTelegramAllowedChats(cfg *store.TelegramClientConfig) {
+ if cfg == nil || len(cfg.AllowedChats) < 2 {
+ return
+ }
+
+ seen := make(map[string]struct{}, len(cfg.AllowedChats))
+ deduped := make(store.TelegramAllowedChatRules, 0, len(cfg.AllowedChats))
+
+ for _, rule := range cfg.AllowedChats {
+ threadID := 0
+ if rule.ThreadID != nil {
+ threadID = *rule.ThreadID
+ }
+ key := targetKey(rule.ChatID, threadID)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ deduped = append(deduped, rule)
+ }
+
+ cfg.AllowedChats = deduped
+}
+
// listClients returns all clients.
// @Summary List clients
// @Description Returns all configured clients (devices, Telegram bots, etc.)
@@ -73,6 +205,7 @@ func (h *Handler) createClient(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "unsupported client type: "+c.Type)
return
}
+ dedupeTelegramAllowedChats(c.Config.Telegram)
if err := validateClientConfig(c); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
@@ -105,6 +238,7 @@ func (h *Handler) updateClient(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
+ dedupeTelegramAllowedChats(c.Config.Telegram)
if c.Type != "" {
if err := validateClientConfig(c); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
@@ -157,10 +291,76 @@ func (h *Handler) regenerateClientToken(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, cl)
}
+// testTelegramClient sends a fixed test message to all configured Telegram test targets
+// (allowed users and allowed chat/thread rules) of the given client.
+// @Summary Test Telegram client delivery
+// @Description Sends a fixed English test message to all configured Telegram user/chat targets
+// @Tags clients
+// @Produce json
+// @Param id path string true "Client ID"
+// @Success 200 {object} TelegramConfigTestResult
+// @Failure 400 {object} ErrorResponse
+// @Failure 404 {object} ErrorResponse
+// @Security AdminAuth
+// @Router /clients/{id}/telegram-test [post]
+func (h *Handler) testTelegramClient(w http.ResponseWriter, r *http.Request) {
+ id := mux.Vars(r)["id"]
+ client, ok := h.store.GetClient(id)
+ if !ok {
+ writeError(w, http.StatusNotFound, "client not found")
+ return
+ }
+ if client.Type != "telegram" {
+ writeError(w, http.StatusBadRequest, "client is not telegram")
+ return
+ }
+ if client.Config.Telegram == nil {
+ writeError(w, http.StatusBadRequest, "telegram config is missing")
+ return
+ }
+
+ testCfg := client.Config.Telegram
+ if r.Body != nil {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "failed to read request body: "+err.Error())
+ return
+ }
+ if strings.TrimSpace(string(body)) != "" {
+ var req telegramTestRequest
+ if err := json.Unmarshal(body, &req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
+ return
+ }
+ testCfg = resolveTelegramTestConfig(client.Config.Telegram, req.Config)
+ }
+ }
+
+ if strings.TrimSpace(testCfg.BotToken) == "" {
+ writeError(w, http.StatusBadRequest, "telegram bot token is required")
+ return
+ }
+
+ targets := buildTelegramTestTargets(testCfg)
+ if len(targets) == 0 {
+ writeError(w, http.StatusBadRequest, "no Telegram targets configured (allowedUsers or allowedChats)")
+ return
+ }
+
+ bot, err := telego.NewBot(testCfg.BotToken)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "failed to initialize telegram bot: "+err.Error())
+ return
+ }
+
+ result := sendTelegramConfigTest(r.Context(), bot, testCfg)
+ writeJSON(w, http.StatusOK, result)
+}
+
// ClientTypeInfo represents a registered client type with its JSON Schema.
type ClientTypeInfo struct {
- Type string `json:"type" example:"telegram"`
- DisplayName string `json:"displayName" example:"Telegram"`
+ Type string `json:"type" example:"telegram"`
+ DisplayName string `json:"displayName" example:"Telegram"`
ConfigSchema clients.Schema `json:"configSchema"`
}
@@ -193,5 +393,20 @@ func validateClientConfig(c store.ClientDefinition) error {
if err := json.Unmarshal(raw, &full); err != nil {
return nil
}
- return clients.ValidateConfig(c.Type, full[c.Type])
+ if err := clients.ValidateConfig(c.Type, full[c.Type]); err != nil {
+ return err
+ }
+
+ if c.Type == "telegram" && c.Config.Telegram != nil {
+ for i, rule := range c.Config.Telegram.AllowedChats {
+ if rule.ChatID == 0 {
+ return fmt.Errorf("allowedChats[%d].chatId must be a non-zero integer", i)
+ }
+ if rule.ThreadID != nil && *rule.ThreadID <= 0 {
+ return fmt.Errorf("allowedChats[%d].threadId must be a positive integer", i)
+ }
+ }
+ }
+
+ return nil
}
diff --git a/server/api/admin/clients_telegram_test.go b/server/api/admin/clients_telegram_test.go
new file mode 100644
index 0000000..36f38ea
--- /dev/null
+++ b/server/api/admin/clients_telegram_test.go
@@ -0,0 +1,291 @@
+package admin
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/achetronic/magec/server/store"
+ "github.com/mymmrac/telego"
+)
+
+type fakeTelegramSender struct {
+ sent []*telego.SendMessageParams
+ failures map[string]error
+}
+
+func (f *fakeTelegramSender) SendMessage(_ context.Context, params *telego.SendMessageParams) (*telego.Message, error) {
+ f.sent = append(f.sent, params)
+ key := targetKey(params.ChatID.ID, params.MessageThreadID)
+ if err, ok := f.failures[key]; ok {
+ return nil, err
+ }
+ return &telego.Message{}, nil
+}
+
+func TestBuildTelegramTestTargets_MixedAndDeduplicated(t *testing.T) {
+ thread := 12
+ cfg := &store.TelegramClientConfig{
+ AllowedUsers: []int64{1001, 1001, 1002},
+ AllowedChats: store.TelegramAllowedChatRules{
+ {ChatID: -1001234567890},
+ {ChatID: -1001234567890, ThreadID: &thread},
+ {ChatID: -1001234567890, ThreadID: &thread},
+ },
+ }
+
+ targets := buildTelegramTestTargets(cfg)
+ if len(targets) != 4 {
+ t.Fatalf("expected 4 unique targets, got %d", len(targets))
+ }
+
+ got := map[string]bool{}
+ for _, target := range targets {
+ got[targetKey(target.ChatID, target.ThreadID)] = true
+ }
+ for _, key := range []string{
+ targetKey(1001, 0),
+ targetKey(1002, 0),
+ targetKey(-1001234567890, 0),
+ targetKey(-1001234567890, 12),
+ } {
+ if !got[key] {
+ t.Fatalf("expected target %s not found", key)
+ }
+ }
+}
+
+func TestBuildTelegramTestTargets_NilConfig(t *testing.T) {
+ targets := buildTelegramTestTargets(nil)
+ if len(targets) != 0 {
+ t.Fatalf("expected no targets for nil config, got %d", len(targets))
+ }
+}
+
+func TestSendTelegramConfigTest_AllSuccess(t *testing.T) {
+ thread := 44
+ cfg := &store.TelegramClientConfig{
+ AllowedUsers: []int64{7},
+ AllowedChats: store.TelegramAllowedChatRules{
+ {ChatID: -1001001001, ThreadID: &thread},
+ },
+ }
+
+ sender := &fakeTelegramSender{}
+ result := sendTelegramConfigTest(context.Background(), sender, cfg)
+
+ if result.Attempted != 2 || result.Sent != 2 || result.Failed != 0 {
+ t.Fatalf("unexpected result: %+v", result)
+ }
+ if len(sender.sent) != 2 {
+ t.Fatalf("expected 2 send calls, got %d", len(sender.sent))
+ }
+ for _, msg := range sender.sent {
+ if msg.Text != telegramConfigTestMessage {
+ t.Fatalf("unexpected test message text: %q", msg.Text)
+ }
+ }
+}
+
+func TestSendTelegramConfigTest_PartialFailures(t *testing.T) {
+ cfg := &store.TelegramClientConfig{
+ AllowedUsers: []int64{10, 11},
+ }
+
+ sender := &fakeTelegramSender{
+ failures: map[string]error{
+ targetKey(11, 0): errors.New("forbidden"),
+ },
+ }
+ result := sendTelegramConfigTest(context.Background(), sender, cfg)
+
+ if result.Attempted != 2 || result.Sent != 1 || result.Failed != 1 {
+ t.Fatalf("unexpected result: %+v", result)
+ }
+ if len(result.Errors) != 1 {
+ t.Fatalf("expected one error detail, got %d", len(result.Errors))
+ }
+}
+
+func TestSendTelegramConfigTest_NoTargets(t *testing.T) {
+ result := sendTelegramConfigTest(context.Background(), &fakeTelegramSender{}, &store.TelegramClientConfig{})
+ if result.Attempted != 0 || result.Sent != 0 || result.Failed != 0 {
+ t.Fatalf("expected empty result for no targets, got %+v", result)
+ }
+}
+
+func TestResolveTelegramTestConfig_UsesOverrideTargetsWithoutSave(t *testing.T) {
+ stored := &store.TelegramClientConfig{
+ BotToken: "stored-token",
+ AllowedUsers: []int64{1},
+ }
+ override := &store.TelegramClientConfig{
+ BotToken: "override-token",
+ AllowedUsers: []int64{99},
+ }
+
+ resolved := resolveTelegramTestConfig(stored, override)
+ if resolved.BotToken != "override-token" {
+ t.Fatalf("expected override token, got %q", resolved.BotToken)
+ }
+ if len(resolved.AllowedUsers) != 1 || resolved.AllowedUsers[0] != 99 {
+ t.Fatalf("expected override allowedUsers, got %+v", resolved.AllowedUsers)
+ }
+}
+
+func TestResolveTelegramTestConfig_NilOverrideReturnsStored(t *testing.T) {
+ stored := &store.TelegramClientConfig{BotToken: "stored-token"}
+ resolved := resolveTelegramTestConfig(stored, nil)
+ if resolved != stored {
+ t.Fatalf("expected stored config pointer when override is nil")
+ }
+}
+
+func TestResolveTelegramTestConfig_UsesCurrentOverrideWithoutFallback(t *testing.T) {
+ stored := &store.TelegramClientConfig{
+ BotToken: "stored-token",
+ AllowedUsers: []int64{1},
+ }
+ override := &store.TelegramClientConfig{
+ AllowedUsers: []int64{42},
+ }
+
+ resolved := resolveTelegramTestConfig(stored, override)
+ if resolved.BotToken != "" {
+ t.Fatalf("expected no token fallback, got %q", resolved.BotToken)
+ }
+ if len(resolved.AllowedUsers) != 1 || resolved.AllowedUsers[0] != 42 {
+ t.Fatalf("expected override targets, got %+v", resolved.AllowedUsers)
+ }
+}
+
+func TestResolveTelegramTestConfig_ExpandsEnvPlaceholderInOverrideToken(t *testing.T) {
+ const key = "MAGEC_TEST_TELEGRAM_TOKEN"
+ const value = "token-from-secret"
+ t.Setenv(key, value)
+
+ override := &store.TelegramClientConfig{
+ BotToken: "${" + key + "}",
+ }
+
+ resolved := resolveTelegramTestConfig(nil, override)
+ if resolved.BotToken != value {
+ t.Fatalf("expected expanded token %q, got %q", value, resolved.BotToken)
+ }
+}
+
+func TestResolveTelegramTestConfig_UsesEmptyTokenWhenEnvMissing(t *testing.T) {
+ const key = "MAGEC_TEST_TELEGRAM_MISSING_TOKEN"
+ _ = os.Unsetenv(key)
+
+ override := &store.TelegramClientConfig{
+ BotToken: "${" + key + "}",
+ }
+
+ resolved := resolveTelegramTestConfig(nil, override)
+ if resolved.BotToken != "" {
+ t.Fatalf("expected empty token for missing env var, got %q", resolved.BotToken)
+ }
+}
+
+func TestValidateClientConfig_TelegramRejectsZeroChatID(t *testing.T) {
+ c := store.ClientDefinition{
+ Type: "telegram",
+ Config: store.ClientConfig{
+ Telegram: &store.TelegramClientConfig{
+ BotToken: "token",
+ AllowedChats: store.TelegramAllowedChatRules{
+ {ChatID: 0},
+ },
+ },
+ },
+ }
+
+ err := validateClientConfig(c)
+ if err == nil {
+ t.Fatalf("expected validation error for chatId=0")
+ }
+}
+
+func TestValidateClientConfig_TelegramRejectsNonPositiveThreadID(t *testing.T) {
+ threadID := 0
+ c := store.ClientDefinition{
+ Type: "telegram",
+ Config: store.ClientConfig{
+ Telegram: &store.TelegramClientConfig{
+ BotToken: "token",
+ AllowedChats: store.TelegramAllowedChatRules{
+ {ChatID: -1001234567890, ThreadID: &threadID},
+ },
+ },
+ },
+ }
+
+ err := validateClientConfig(c)
+ if err == nil {
+ t.Fatalf("expected validation error for threadId<=0")
+ }
+}
+
+func TestValidateClientConfig_TelegramAcceptsValidAllowedChats(t *testing.T) {
+ threadID := 12
+ c := store.ClientDefinition{
+ Type: "telegram",
+ Config: store.ClientConfig{
+ Telegram: &store.TelegramClientConfig{
+ BotToken: "token",
+ AllowedChats: store.TelegramAllowedChatRules{
+ {ChatID: -1001234567890, ThreadID: &threadID},
+ {ChatID: -1001234567891},
+ },
+ },
+ },
+ }
+
+ if err := validateClientConfig(c); err != nil {
+ t.Fatalf("unexpected validation error: %v", err)
+ }
+}
+
+func TestDedupeTelegramAllowedChats_RemovesExactDuplicates(t *testing.T) {
+ thread := 12
+ cfg := &store.TelegramClientConfig{
+ AllowedChats: store.TelegramAllowedChatRules{
+ {ChatID: -1001234567890, ThreadID: &thread},
+ {ChatID: -1001234567890, ThreadID: &thread},
+ {ChatID: -1001234567890},
+ {ChatID: -1001234567890},
+ },
+ }
+
+ dedupeTelegramAllowedChats(cfg)
+
+ if len(cfg.AllowedChats) != 2 {
+ t.Fatalf("expected 2 deduplicated rules, got %d", len(cfg.AllowedChats))
+ }
+ if cfg.AllowedChats[0].ThreadID == nil || *cfg.AllowedChats[0].ThreadID != 12 {
+ t.Fatalf("expected thread rule preserved at index 0")
+ }
+ if cfg.AllowedChats[1].ThreadID != nil {
+ t.Fatalf("expected chat-only rule preserved at index 1")
+ }
+}
+
+func TestDedupeTelegramAllowedChats_KeepsDistinctThreadVariants(t *testing.T) {
+ threadA := 12
+ threadB := 13
+ cfg := &store.TelegramClientConfig{
+ AllowedChats: store.TelegramAllowedChatRules{
+ {ChatID: -1001234567890, ThreadID: &threadA},
+ {ChatID: -1001234567890, ThreadID: &threadB},
+ {ChatID: -1001234567890},
+ },
+ }
+
+ dedupeTelegramAllowedChats(cfg)
+
+ if len(cfg.AllowedChats) != 3 {
+ t.Fatalf("expected 3 distinct rules, got %d", len(cfg.AllowedChats))
+ }
+}
diff --git a/server/api/admin/handler.go b/server/api/admin/handler.go
index bdb9d95..e5ddaa1 100644
--- a/server/api/admin/handler.go
+++ b/server/api/admin/handler.go
@@ -97,6 +97,7 @@ func (h *Handler) buildRouter() *mux.Router {
r.HandleFunc("/clients/{id}", h.updateClient).Methods("PUT")
r.HandleFunc("/clients/{id}", h.deleteClient).Methods("DELETE")
r.HandleFunc("/clients/{id}/regenerate-token", h.regenerateClientToken).Methods("POST")
+ r.HandleFunc("/clients/{id}/telegram-test", h.testTelegramClient).Methods("POST")
// Commands
r.HandleFunc("/commands", h.listCommands).Methods("GET")
diff --git a/server/clients/telegram/bot.go b/server/clients/telegram/bot.go
index e313a09..701448d 100644
--- a/server/clients/telegram/bot.go
+++ b/server/clients/telegram/bot.go
@@ -243,7 +243,7 @@ func agentLabel(a *AgentInfo, fallback string) string {
// handleStartCommand responds to /start with a welcome message.
func (c *Client) handleStartCommand(ctx *th.Context, msg telego.Message) error {
- if !c.isAllowed(msg.From.ID, msg.Chat.ID) {
+ if !c.isAllowed(msg.From.ID, msg.Chat.ID, msg.MessageThreadID) {
return nil
}
@@ -251,16 +251,17 @@ func (c *Client) handleStartCommand(ctx *th.Context, msg telego.Message) error {
name := agentLabel(c.getAgentInfo(agentID), agentID)
text := fmt.Sprintf("👋 *Welcome!* You are now talking to *%s*.\n\nType /help to see available commands.", name)
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: text,
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: text,
+ ParseMode: "Markdown",
})
return nil
}
// handleHelpCommand responds to /help with a list of all supported bot commands.
func (c *Client) handleHelpCommand(ctx *th.Context, msg telego.Message) error {
- if !c.isAllowed(msg.From.ID, msg.Chat.ID) {
+ if !c.isAllowed(msg.From.ID, msg.Chat.ID, msg.MessageThreadID) {
return nil
}
@@ -273,9 +274,10 @@ func (c *Client) handleHelpCommand(ctx *th.Context, msg telego.Message) error {
"/start — Show the welcome message"
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: text,
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: text,
+ ParseMode: "Markdown",
})
return nil
}
@@ -284,7 +286,7 @@ func (c *Client) handleHelpCommand(ctx *th.Context, msg telego.Message) error {
// agent and lists all available ones. With an agent ID it switches the chat to
// that agent.
func (c *Client) handleAgentCommand(ctx *th.Context, msg telego.Message) error {
- if !c.isAllowed(msg.From.ID, msg.Chat.ID) {
+ if !c.isAllowed(msg.From.ID, msg.Chat.ID, msg.MessageThreadID) {
return nil
}
@@ -312,9 +314,10 @@ func (c *Client) handleAgentCommand(ctx *th.Context, msg telego.Message) error {
}
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: fmt.Sprintf("*Active agent:* %s\n\n*Available agents:*\n%s\nUsage: `/agent
`", currentLabel, agentList),
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: fmt.Sprintf("*Active agent:* %s\n\n*Available agents:*\n%s\nUsage: `/agent `", currentLabel, agentList),
+ ParseMode: "Markdown",
})
return nil
}
@@ -325,9 +328,10 @@ func (c *Client) handleAgentCommand(ctx *th.Context, msg telego.Message) error {
ids = append(ids, "`"+a.ID+"`")
}
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: fmt.Sprintf("Unknown agent `%s`. Available: %s", args, strings.Join(ids, ", ")),
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: fmt.Sprintf("Unknown agent `%s`. Available: %s", args, strings.Join(ids, ", ")),
+ ParseMode: "Markdown",
})
return nil
}
@@ -336,16 +340,17 @@ func (c *Client) handleAgentCommand(ctx *th.Context, msg telego.Message) error {
c.logger.Info("Agent switched", "chat_id", msg.Chat.ID, "user_id", msg.From.ID, "agent", args)
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: fmt.Sprintf("Switched to agent *%s* (`%s`)", agentLabel(c.getAgentInfo(args), args), args),
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: fmt.Sprintf("Switched to agent *%s* (`%s`)", agentLabel(c.getAgentInfo(args), args), args),
+ ParseMode: "Markdown",
})
return nil
}
// handleResetCommand deletes the ADK session for the current chat and thread.
func (c *Client) handleResetCommand(ctx *th.Context, msg telego.Message) error {
- if !c.isAllowed(msg.From.ID, msg.Chat.ID) {
+ if !c.isAllowed(msg.From.ID, msg.Chat.ID, msg.MessageThreadID) {
return nil
}
@@ -354,8 +359,9 @@ func (c *Client) handleResetCommand(ctx *th.Context, msg telego.Message) error {
if err := c.deleteSession(agentID, sessionID); err != nil {
c.logger.Error("Failed to delete session", "error", err)
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: "Failed to reset session.",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: "Failed to reset session.",
})
return nil
}
@@ -368,9 +374,10 @@ func (c *Client) handleResetCommand(ctx *th.Context, msg telego.Message) error {
)
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: fmt.Sprintf("Session reset for *%s*. Next message starts a fresh conversation.", agentLabel(c.getAgentInfo(agentID), agentID)),
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: fmt.Sprintf("Session reset for *%s*. Next message starts a fresh conversation.", agentLabel(c.getAgentInfo(agentID), agentID)),
+ ParseMode: "Markdown",
})
return nil
}
@@ -382,7 +389,7 @@ func (c *Client) handleMessage(ctx *th.Context, msg telego.Message) error {
if msg.Text == "" {
return nil
}
- if !c.isAllowed(msg.From.ID, msg.Chat.ID) {
+ if !c.isAllowed(msg.From.ID, msg.Chat.ID, msg.MessageThreadID) {
c.logger.Debug("Unauthorized access attempt", "user_id", msg.From.ID, "chat_id", msg.Chat.ID)
return nil
}
@@ -390,12 +397,13 @@ func (c *Client) handleMessage(ctx *th.Context, msg telego.Message) error {
c.logger.Info("Received message", "user_id", msg.From.ID, "chat_id", msg.Chat.ID, "text", msg.Text)
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "👀")
_ = ctx.Bot().SendChatAction(ctx, &telego.SendChatActionParams{
- ChatID: tu.ID(msg.Chat.ID),
- Action: telego.ChatActionTyping,
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Action: telego.ChatActionTyping,
})
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "🧠")
- typingDone := c.startTypingLoop(ctx, msg.Chat.ID)
+ typingDone := c.startTypingLoop(ctx, msg.Chat.ID, msg.MessageThreadID)
inputText, truncated := msgutil.ValidateInputLength(msg.Text, msgutil.DefaultMaxInputLength)
if truncated {
@@ -431,17 +439,18 @@ func (c *Client) handleMessage(ctx *th.Context, msg telego.Message) error {
hasText = true
toolCount = 0
toolCounterMsgID = 0
- c.sendTextResponse(ctx, msg.Chat.ID, evt.Text, false)
+ c.sendTextResponse(ctx, msg.Chat.ID, msg.MessageThreadID, evt.Text, false)
case msgutil.SSEEventToolCall:
hasToolActivity = true
- toolCounterMsgID = c.sendToolCounter(ctx, msg.Chat.ID, toolCounterMsgID, &toolCount, evt)
+ toolCounterMsgID = c.sendToolCounter(ctx, msg.Chat.ID, msg.MessageThreadID, toolCounterMsgID, &toolCount, evt)
case msgutil.SSEEventToolResult:
hasToolActivity = true
if c.getShowTools() {
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: msgutil.FormatToolResultTelegram(evt),
- ParseMode: "HTML",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: msgutil.FormatToolResultTelegram(evt),
+ ParseMode: "HTML",
})
}
case msgutil.SSEEventError:
@@ -458,8 +467,9 @@ func (c *Client) handleMessage(ctx *th.Context, msg telego.Message) error {
c.logger.Error("Failed to call agent", "error", err)
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "👎")
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: fmt.Sprintf("Failed to process your request: %s", sanitizeError(err)),
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: fmt.Sprintf("Failed to process your request: %s", sanitizeError(err)),
})
return nil
}
@@ -477,13 +487,14 @@ func (c *Client) handleMessage(ctx *th.Context, msg telego.Message) error {
}
c.logger.Warn("No text in agent response", logFields...)
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: msgutil.ExplainNoResponse(lastFinishReason, lastErrorMessage),
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: msgutil.ExplainNoResponse(lastFinishReason, lastErrorMessage),
})
}
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "👍")
- c.sendNewArtifacts(ctx, msg.Chat.ID, agentID, userIDStr, sessionID, artifactsBefore)
+ c.sendNewArtifacts(ctx, msg.Chat.ID, msg.MessageThreadID, agentID, userIDStr, sessionID, artifactsBefore)
return nil
}
@@ -495,15 +506,16 @@ func (c *Client) handleVoice(ctx *th.Context, msg telego.Message) error {
if msg.Voice == nil {
return nil
}
- if !c.isAllowed(msg.From.ID, msg.Chat.ID) {
+ if !c.isAllowed(msg.From.ID, msg.Chat.ID, msg.MessageThreadID) {
return nil
}
c.logger.Info("Received voice message", "user_id", msg.From.ID, "chat_id", msg.Chat.ID, "duration", msg.Voice.Duration)
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "👀")
_ = ctx.Bot().SendChatAction(ctx, &telego.SendChatActionParams{
- ChatID: tu.ID(msg.Chat.ID),
- Action: telego.ChatActionTyping,
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Action: telego.ChatActionTyping,
})
file, err := ctx.Bot().GetFile(ctx, &telego.GetFileParams{FileID: msg.Voice.FileID})
@@ -511,8 +523,9 @@ func (c *Client) handleVoice(ctx *th.Context, msg telego.Message) error {
c.logger.Error("Failed to get voice file", "error", err)
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "👎")
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: "Failed to download your voice message. Please try again.",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: "Failed to download your voice message. Please try again.",
})
return nil
}
@@ -523,8 +536,9 @@ func (c *Client) handleVoice(ctx *th.Context, msg telego.Message) error {
c.logger.Error("Failed to download voice file", "error", err)
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "👎")
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: "Failed to download your voice message. Please try again.",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: "Failed to download your voice message. Please try again.",
})
return nil
}
@@ -535,8 +549,9 @@ func (c *Client) handleVoice(ctx *th.Context, msg telego.Message) error {
c.logger.Error("Failed to transcribe audio", "error", err)
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "👎")
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: "Sorry, I couldn't transcribe your voice message.",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: "Sorry, I couldn't transcribe your voice message.",
})
return nil
}
@@ -544,7 +559,7 @@ func (c *Client) handleVoice(ctx *th.Context, msg telego.Message) error {
c.logger.Info("Transcribed voice", "text", text)
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "🧠")
- typingDone := c.startTypingLoop(ctx, msg.Chat.ID)
+ typingDone := c.startTypingLoop(ctx, msg.Chat.ID, msg.MessageThreadID)
voiceInput, truncated := msgutil.ValidateInputLength(text, msgutil.DefaultMaxInputLength)
if truncated {
@@ -575,17 +590,18 @@ func (c *Client) handleVoice(ctx *th.Context, msg telego.Message) error {
lastTextResponse = evt.Text
toolCount = 0
toolCounterMsgID = 0
- c.sendTextResponse(ctx, msg.Chat.ID, evt.Text, true)
+ c.sendTextResponse(ctx, msg.Chat.ID, msg.MessageThreadID, evt.Text, true)
case msgutil.SSEEventToolCall:
hasToolActivity = true
- toolCounterMsgID = c.sendToolCounter(ctx, msg.Chat.ID, toolCounterMsgID, &toolCount, evt)
+ toolCounterMsgID = c.sendToolCounter(ctx, msg.Chat.ID, msg.MessageThreadID, toolCounterMsgID, &toolCount, evt)
case msgutil.SSEEventToolResult:
hasToolActivity = true
if c.getShowTools() {
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: msgutil.FormatToolResultTelegram(evt),
- ParseMode: "HTML",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: msgutil.FormatToolResultTelegram(evt),
+ ParseMode: "HTML",
})
}
case msgutil.SSEEventError:
@@ -602,33 +618,35 @@ func (c *Client) handleVoice(ctx *th.Context, msg telego.Message) error {
c.logger.Error("Failed to call agent", "error", err)
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "👎")
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: fmt.Sprintf("Failed to process your request: %s", sanitizeError(err)),
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: fmt.Sprintf("Failed to process your request: %s", sanitizeError(err)),
})
return nil
}
if !hasText && !hasToolActivity {
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: msgutil.ExplainNoResponse(lastFinishReason, lastErrorMessage),
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: msgutil.ExplainNoResponse(lastFinishReason, lastErrorMessage),
})
}
mode := c.getResponseMode()
if (mode == ResponseModeVoice || mode == ResponseModeBoth || mode == ResponseModeMirror) && lastTextResponse != "" {
- c.sendVoiceResponse(ctx, msg.Chat.ID, lastTextResponse, agentID)
+ c.sendVoiceResponse(ctx, msg.Chat.ID, msg.MessageThreadID, lastTextResponse, agentID)
}
c.setReaction(ctx, msg.Chat.ID, msg.MessageID, "👍")
- c.sendNewArtifacts(ctx, msg.Chat.ID, agentID, userIDStr, sessionID, artifactsBefore)
+ c.sendNewArtifacts(ctx, msg.Chat.ID, msg.MessageThreadID, agentID, userIDStr, sessionID, artifactsBefore)
return nil
}
// startTypingLoop sends a typing indicator every 4 seconds until the returned
// channel is closed. Call close(done) when the agent response is complete.
-func (c *Client) startTypingLoop(ctx *th.Context, chatID int64) chan struct{} {
+func (c *Client) startTypingLoop(ctx *th.Context, chatID int64, threadID int) chan struct{} {
done := make(chan struct{})
go func() {
ticker := time.NewTicker(4 * time.Second)
@@ -639,8 +657,9 @@ func (c *Client) startTypingLoop(ctx *th.Context, chatID int64) chan struct{} {
return
case <-ticker.C:
_ = ctx.Bot().SendChatAction(ctx, &telego.SendChatActionParams{
- ChatID: tu.ID(chatID),
- Action: telego.ChatActionTyping,
+ ChatID: tu.ID(chatID),
+ MessageThreadID: threadID,
+ Action: telego.ChatActionTyping,
})
}
}
@@ -651,12 +670,13 @@ func (c *Client) startTypingLoop(ctx *th.Context, chatID int64) chan struct{} {
// sendToolCounter posts or edits a compact tool activity counter in the chat.
// When showTools is enabled it posts the full tool call instead.
// Returns the updated message ID for subsequent edits.
-func (c *Client) sendToolCounter(ctx *th.Context, chatID int64, counterMsgID int, toolCount *int, evt msgutil.SSEEvent) int {
+func (c *Client) sendToolCounter(ctx *th.Context, chatID int64, threadID int, counterMsgID int, toolCount *int, evt msgutil.SSEEvent) int {
if c.getShowTools() {
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(chatID),
- Text: msgutil.FormatToolCallTelegram(evt),
- ParseMode: "HTML",
+ ChatID: tu.ID(chatID),
+ MessageThreadID: threadID,
+ Text: msgutil.FormatToolCallTelegram(evt),
+ ParseMode: "HTML",
})
return counterMsgID
}
@@ -666,8 +686,9 @@ func (c *Client) sendToolCounter(ctx *th.Context, chatID int64, counterMsgID int
if counterMsgID == 0 {
sent, err := ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(chatID),
- Text: counterText,
+ ChatID: tu.ID(chatID),
+ MessageThreadID: threadID,
+ Text: counterText,
})
if err == nil {
return sent.MessageID
@@ -685,7 +706,7 @@ func (c *Client) sendToolCounter(ctx *th.Context, chatID int64, counterMsgID int
// sendTextResponse delivers a text message to the chat, splitting if needed.
// If inputWasVoice is true and the mode is voice-only or mirror, text is suppressed.
-func (c *Client) sendTextResponse(ctx *th.Context, chatID int64, text string, inputWasVoice bool) {
+func (c *Client) sendTextResponse(ctx *th.Context, chatID int64, threadID int, text string, inputWasVoice bool) {
mode := c.getResponseMode()
switch mode {
case ResponseModeVoice:
@@ -699,8 +720,9 @@ func (c *Client) sendTextResponse(ctx *th.Context, chatID int64, text string, in
chunks := msgutil.SplitMessage(text, msgutil.TelegramMaxMessageLength)
for _, chunk := range chunks {
_, err := ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(chatID),
- Text: chunk,
+ ChatID: tu.ID(chatID),
+ MessageThreadID: threadID,
+ Text: chunk,
})
if err != nil {
c.logger.Error("Failed to send message", "error", err)
@@ -724,7 +746,7 @@ func (c *Client) getResponseMode() string {
// the current mode. With a mode name it overrides the config default. "reset"
// clears the override back to the config value.
func (c *Client) handleResponseModeCommand(ctx *th.Context, msg telego.Message) error {
- if !c.isAllowed(msg.From.ID, msg.Chat.ID) {
+ if !c.isAllowed(msg.From.ID, msg.Chat.ID, msg.MessageThreadID) {
return nil
}
@@ -744,9 +766,10 @@ func (c *Client) handleResponseModeCommand(ctx *th.Context, msg telego.Message)
status += fmt.Sprintf("\n*Options:* `%s`, `reset`", strings.Join(validModes, "`, `"))
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: status,
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: status,
+ ParseMode: "Markdown",
})
return nil
}
@@ -757,18 +780,20 @@ func (c *Client) handleResponseModeCommand(ctx *th.Context, msg telego.Message)
c.responseMu.Unlock()
c.logger.Info("Response mode override cleared", "user_id", msg.From.ID, "config_mode", c.clientDef.Config.Telegram.ResponseMode)
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: fmt.Sprintf("Response mode reset to config default: `%s`", c.clientDef.Config.Telegram.ResponseMode),
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: fmt.Sprintf("Response mode reset to config default: `%s`", c.clientDef.Config.Telegram.ResponseMode),
+ ParseMode: "Markdown",
})
return nil
}
if !slices.Contains(validModes, args) {
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: fmt.Sprintf("Invalid mode `%s`. Valid options: `%s`, `reset`", args, strings.Join(validModes, "`, `")),
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: fmt.Sprintf("Invalid mode `%s`. Valid options: `%s`, `reset`", args, strings.Join(validModes, "`, `")),
+ ParseMode: "Markdown",
})
return nil
}
@@ -778,9 +803,10 @@ func (c *Client) handleResponseModeCommand(ctx *th.Context, msg telego.Message)
c.responseMu.Unlock()
c.logger.Info("Response mode overridden", "user_id", msg.From.ID, "new_mode", args)
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: fmt.Sprintf("Response mode set to `%s` (until restart)", args),
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: fmt.Sprintf("Response mode set to `%s` (until restart)", args),
+ ParseMode: "Markdown",
})
return nil
}
@@ -792,7 +818,7 @@ func (c *Client) getShowTools() bool {
}
func (c *Client) handleShowToolsCommand(ctx *th.Context, msg telego.Message) error {
- if !c.isAllowed(msg.From.ID, msg.Chat.ID) {
+ if !c.isAllowed(msg.From.ID, msg.Chat.ID, msg.MessageThreadID) {
return nil
}
@@ -806,24 +832,30 @@ func (c *Client) handleShowToolsCommand(ctx *th.Context, msg telego.Message) err
label = "ON"
}
_, _ = ctx.Bot().SendMessage(ctx, &telego.SendMessageParams{
- ChatID: tu.ID(msg.Chat.ID),
- Text: fmt.Sprintf("🔧 Tool call visibility: *%s*", label),
- ParseMode: "Markdown",
+ ChatID: tu.ID(msg.Chat.ID),
+ MessageThreadID: msg.MessageThreadID,
+ Text: fmt.Sprintf("🔧 Tool call visibility: *%s*", label),
+ ParseMode: "Markdown",
})
return nil
}
// isAllowed checks whether a Telegram user or chat is permitted to interact
// with this bot. If no allowlists are configured, all users are allowed.
-func (c *Client) isAllowed(userID, chatID int64) bool {
+func (c *Client) isAllowed(userID, chatID int64, threadID int) bool {
if len(c.clientDef.Config.Telegram.AllowedUsers) == 0 && len(c.clientDef.Config.Telegram.AllowedChats) == 0 {
return true
}
if len(c.clientDef.Config.Telegram.AllowedUsers) > 0 && slices.Contains(c.clientDef.Config.Telegram.AllowedUsers, userID) {
return true
}
- if len(c.clientDef.Config.Telegram.AllowedChats) > 0 && slices.Contains(c.clientDef.Config.Telegram.AllowedChats, chatID) {
- return true
+ for _, pair := range c.clientDef.Config.Telegram.AllowedChats {
+ if pair.ChatID != chatID {
+ continue
+ }
+ if pair.ThreadID == nil || *pair.ThreadID == threadID {
+ return true
+ }
}
return false
}
@@ -1073,10 +1105,11 @@ func (c *Client) transcribeAudio(audioData []byte, filePath string, agentID stri
// sendVoiceResponse generates speech audio for the given text via the magec
// TTS proxy and sends it back to the chat as a Telegram voice message.
-func (c *Client) sendVoiceResponse(ctx *th.Context, chatID int64, text string, agentID string) {
+func (c *Client) sendVoiceResponse(ctx *th.Context, chatID int64, threadID int, text string, agentID string) {
_ = ctx.Bot().SendChatAction(ctx, &telego.SendChatActionParams{
- ChatID: tu.ID(chatID),
- Action: telego.ChatActionRecordVoice,
+ ChatID: tu.ID(chatID),
+ MessageThreadID: threadID,
+ Action: telego.ChatActionRecordVoice,
})
audioData, err := c.generateTTS(text, agentID)
@@ -1086,8 +1119,9 @@ func (c *Client) sendVoiceResponse(ctx *th.Context, chatID int64, text string, a
}
_, err = ctx.Bot().SendVoice(ctx, &telego.SendVoiceParams{
- ChatID: tu.ID(chatID),
- Voice: tu.FileFromReader(bytes.NewReader(audioData), "voice.ogg"),
+ ChatID: tu.ID(chatID),
+ MessageThreadID: threadID,
+ Voice: tu.FileFromReader(bytes.NewReader(audioData), "voice.ogg"),
})
if err != nil {
c.logger.Error("Failed to send voice message", "error", err)
@@ -1235,7 +1269,7 @@ func (c *Client) downloadArtifact(agentID, userID, sessionID, name string) ([]by
return []byte(artifact.Text), "text/plain", nil
}
-func (c *Client) sendNewArtifacts(ctx *th.Context, chatID int64, agentID, userID, sessionID string, before []string) {
+func (c *Client) sendNewArtifacts(ctx *th.Context, chatID int64, threadID int, agentID, userID, sessionID string, before []string) {
after := c.listArtifacts(agentID, userID, sessionID)
if len(after) == 0 {
return
@@ -1258,8 +1292,9 @@ func (c *Client) sendNewArtifacts(ctx *th.Context, chatID int64, agentID, userID
}
_, err = ctx.Bot().SendDocument(ctx, &telego.SendDocumentParams{
- ChatID: tu.ID(chatID),
- Document: tu.FileFromReader(bytes.NewReader(data), name),
+ ChatID: tu.ID(chatID),
+ MessageThreadID: threadID,
+ Document: tu.FileFromReader(bytes.NewReader(data), name),
})
if err != nil {
c.logger.Error("Failed to send artifact as document", "name", name, "error", err)
diff --git a/server/clients/telegram/bot_test.go b/server/clients/telegram/bot_test.go
new file mode 100644
index 0000000..664c297
--- /dev/null
+++ b/server/clients/telegram/bot_test.go
@@ -0,0 +1,59 @@
+package telegram
+
+import (
+ "testing"
+
+ "github.com/achetronic/magec/server/store"
+)
+
+func TestIsAllowed_NoRulesConfigured_Allows(t *testing.T) {
+ c := &Client{clientDef: store.ClientDefinition{Config: store.ClientConfig{Telegram: &store.TelegramClientConfig{}}}}
+ if !c.isAllowed(1001, -1001234567890, 0) {
+ t.Fatalf("expected access allowed when no rules are configured")
+ }
+}
+
+func TestIsAllowed_AllowedUsers_Allows(t *testing.T) {
+ c := &Client{clientDef: store.ClientDefinition{Config: store.ClientConfig{Telegram: &store.TelegramClientConfig{AllowedUsers: []int64{42}}}}}
+ if !c.isAllowed(42, -1001234567890, 99) {
+ t.Fatalf("expected access allowed for allowed user")
+ }
+}
+
+func TestIsAllowed_UsersConfigured_DeniesUnknownUserWithoutChatRules(t *testing.T) {
+ c := &Client{clientDef: store.ClientDefinition{Config: store.ClientConfig{Telegram: &store.TelegramClientConfig{AllowedUsers: []int64{42}}}}}
+ if c.isAllowed(99, -1001234567890, 0) {
+ t.Fatalf("expected access denied for user not in allowedUsers")
+ }
+}
+
+func TestIsAllowed_ChatOnlyRule_AllowsAnyThread(t *testing.T) {
+ c := &Client{clientDef: store.ClientDefinition{Config: store.ClientConfig{Telegram: &store.TelegramClientConfig{AllowedChats: store.TelegramAllowedChatRules{{ChatID: -1001234567890}}}}}}
+ if !c.isAllowed(5, -1001234567890, 0) {
+ t.Fatalf("expected access allowed for matching chat without thread")
+ }
+ if !c.isAllowed(5, -1001234567890, 77) {
+ t.Fatalf("expected access allowed for matching chat with any thread")
+ }
+}
+
+func TestIsAllowed_ChatAndThreadRule_RestrictsByThread(t *testing.T) {
+ thread := 12
+ c := &Client{clientDef: store.ClientDefinition{Config: store.ClientConfig{Telegram: &store.TelegramClientConfig{AllowedChats: store.TelegramAllowedChatRules{{ChatID: -1001234567890, ThreadID: &thread}}}}}}
+ if !c.isAllowed(5, -1001234567890, 12) {
+ t.Fatalf("expected access allowed for matching chat and thread")
+ }
+ if c.isAllowed(5, -1001234567890, 13) {
+ t.Fatalf("expected access denied for non-matching thread")
+ }
+ if c.isAllowed(5, -1001234567890, 0) {
+ t.Fatalf("expected access denied when required thread is missing")
+ }
+}
+
+func TestIsAllowed_NonMatchingRules_Denies(t *testing.T) {
+ c := &Client{clientDef: store.ClientDefinition{Config: store.ClientConfig{Telegram: &store.TelegramClientConfig{AllowedChats: store.TelegramAllowedChatRules{{ChatID: -1001234567890}}}}}}
+ if c.isAllowed(5, -1000000000000, 0) {
+ t.Fatalf("expected access denied when chat does not match")
+ }
+}
diff --git a/server/clients/telegram/spec.go b/server/clients/telegram/spec.go
index 95a41cf..71a64c8 100644
--- a/server/clients/telegram/spec.go
+++ b/server/clients/telegram/spec.go
@@ -31,10 +31,23 @@ func (p *Provider) ConfigSchema() clients.Schema {
"x-placeholder": "Comma-separated Telegram user IDs",
},
"allowedChats": clients.Schema{
- "type": "array",
- "items": clients.Schema{"type": "integer"},
- "title": "Allowed Chats",
- "x-placeholder": "Comma-separated Telegram chat IDs",
+ "type": "array",
+ "items": clients.Schema{
+ "type": "object",
+ "properties": clients.Schema{
+ "chatId": clients.Schema{
+ "type": "integer",
+ "title": "Chat ID",
+ },
+ "threadId": clients.Schema{
+ "type": "integer",
+ "title": "Thread ID",
+ },
+ },
+ "required": []string{"chatId"},
+ },
+ "title": "Allowed Chats",
+ "description": "Allowed chat IDs with optional thread IDs (topics).",
},
"responseMode": clients.Schema{
"type": "string",
diff --git a/server/store/types.go b/server/store/types.go
index 8f307b2..bd54ebb 100644
--- a/server/store/types.go
+++ b/server/store/types.go
@@ -1,6 +1,10 @@
package store
-import "github.com/google/uuid"
+import (
+ "encoding/json"
+
+ "github.com/google/uuid"
+)
// generateID returns a new random UUID v4 string (e.g. "550e8400-e29b-41d4-a716-446655440000").
func generateID() string {
@@ -113,13 +117,41 @@ type ClientConfig struct {
Webhook *WebhookClientConfig `json:"webhook,omitempty" yaml:"webhook,omitempty"`
}
+type TelegramAllowedChatRule struct {
+ ChatID int64 `json:"chatId" yaml:"chatId"`
+ ThreadID *int `json:"threadId,omitempty" yaml:"threadId,omitempty"`
+}
+
+type TelegramAllowedChatRules []TelegramAllowedChatRule
+
+func (r *TelegramAllowedChatRules) UnmarshalJSON(data []byte) error {
+ var typed []TelegramAllowedChatRule
+ if err := json.Unmarshal(data, &typed); err == nil {
+ *r = typed
+ return nil
+ }
+
+ var legacy []int64
+ if err := json.Unmarshal(data, &legacy); err != nil {
+ return err
+ }
+
+ parsed := make([]TelegramAllowedChatRule, 0, len(legacy))
+ for _, chatID := range legacy {
+ parsed = append(parsed, TelegramAllowedChatRule{ChatID: chatID})
+ }
+
+ *r = parsed
+ return nil
+}
+
// TelegramClientConfig holds Telegram bot settings for a client.
type TelegramClientConfig struct {
- BotToken string `json:"botToken,omitempty" yaml:"botToken,omitempty"`
- AllowedUsers []int64 `json:"allowedUsers,omitempty" yaml:"allowedUsers,omitempty"`
- AllowedChats []int64 `json:"allowedChats,omitempty" yaml:"allowedChats,omitempty"`
- ResponseMode string `json:"responseMode,omitempty" yaml:"responseMode,omitempty"`
- DefaultAgent string `json:"defaultAgent,omitempty" yaml:"defaultAgent,omitempty"`
+ BotToken string `json:"botToken,omitempty" yaml:"botToken,omitempty"`
+ AllowedUsers []int64 `json:"allowedUsers,omitempty" yaml:"allowedUsers,omitempty"`
+ AllowedChats TelegramAllowedChatRules `json:"allowedChats,omitempty" yaml:"allowedChats,omitempty"`
+ ResponseMode string `json:"responseMode,omitempty" yaml:"responseMode,omitempty"`
+ DefaultAgent string `json:"defaultAgent,omitempty" yaml:"defaultAgent,omitempty"`
}
// DiscordClientConfig holds Discord bot settings for a client.
diff --git a/server/store/types_test.go b/server/store/types_test.go
new file mode 100644
index 0000000..6cb0d86
--- /dev/null
+++ b/server/store/types_test.go
@@ -0,0 +1,56 @@
+package store
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestTelegramAllowedChatRules_UnmarshalStructured(t *testing.T) {
+ var cfg TelegramClientConfig
+ err := json.Unmarshal([]byte(`{"allowedChats":[{"chatId":-1001234567890,"threadId":12},{"chatId":-1001234567891}]}`), &cfg)
+ if err != nil {
+ t.Fatalf("unexpected unmarshal error: %v", err)
+ }
+
+ if len(cfg.AllowedChats) != 2 {
+ t.Fatalf("expected 2 rules, got %d", len(cfg.AllowedChats))
+ }
+ if cfg.AllowedChats[0].ChatID != -1001234567890 {
+ t.Fatalf("unexpected chatId at index 0: %d", cfg.AllowedChats[0].ChatID)
+ }
+ if cfg.AllowedChats[0].ThreadID == nil || *cfg.AllowedChats[0].ThreadID != 12 {
+ t.Fatalf("expected threadId 12 at index 0")
+ }
+ if cfg.AllowedChats[1].ChatID != -1001234567891 {
+ t.Fatalf("unexpected chatId at index 1: %d", cfg.AllowedChats[1].ChatID)
+ }
+ if cfg.AllowedChats[1].ThreadID != nil {
+ t.Fatalf("expected nil threadId at index 1")
+ }
+}
+
+func TestTelegramAllowedChatRules_UnmarshalLegacyIntArray(t *testing.T) {
+ var cfg TelegramClientConfig
+ err := json.Unmarshal([]byte(`{"allowedChats":[-1001234567890,-1001234567891]}`), &cfg)
+ if err != nil {
+ t.Fatalf("unexpected unmarshal error: %v", err)
+ }
+
+ if len(cfg.AllowedChats) != 2 {
+ t.Fatalf("expected 2 migrated rules, got %d", len(cfg.AllowedChats))
+ }
+ if cfg.AllowedChats[0].ChatID != -1001234567890 || cfg.AllowedChats[0].ThreadID != nil {
+ t.Fatalf("unexpected first migrated rule: %+v", cfg.AllowedChats[0])
+ }
+ if cfg.AllowedChats[1].ChatID != -1001234567891 || cfg.AllowedChats[1].ThreadID != nil {
+ t.Fatalf("unexpected second migrated rule: %+v", cfg.AllowedChats[1])
+ }
+}
+
+func TestTelegramAllowedChatRules_UnmarshalInvalidArrayType(t *testing.T) {
+ var cfg TelegramClientConfig
+ err := json.Unmarshal([]byte(`{"allowedChats":["invalid"]}`), &cfg)
+ if err == nil {
+ t.Fatalf("expected unmarshal error for invalid allowedChats payload")
+ }
+}