Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/admin-ui/src/lib/api/clients.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@ export const clientsApi = {
update: (id, c) => request(`/clients/${id}`, { method: 'PUT', body: JSON.stringify(c) }),
delete: (id) => request(`/clients/${id}`, { method: 'DELETE' }),
regenerateToken: (id) => request(`/clients/${id}/regenerate-token`, { method: 'POST' }),
telegramTest: (id, payload = null) => request(`/clients/${id}/telegram-test`, {
method: 'POST',
...(payload ? { body: JSON.stringify(payload) } : {}),
}),
listTypes: () => request('/clients/types'),
}
145 changes: 135 additions & 10 deletions frontend/admin-ui/src/views/clients/ClientDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,45 @@
</summary>
<div class="px-4 pb-4 space-y-4">
<template v-for="(propSchema, key) in permissionProperties" :key="key">
<div>
<div v-if="key === 'allowedChats'">
<FormLabel :label="propSchema.title || key" :required="isFieldRequired(key)" />
<div class="space-y-2">
<div
v-for="(rule, idx) in getAllowedChatRows()"
:key="idx"
class="grid grid-cols-[1fr_1fr_auto] gap-2"
>
<FormInput
:modelValue="rule.chatId"
@update:modelValue="updateAllowedChatRule(idx, 'chatId', $event)"
placeholder="Chat ID (e.g. -1001234567890)"
/>
<FormInput
:modelValue="rule.threadId"
@update:modelValue="updateAllowedChatRule(idx, 'threadId', $event)"
placeholder="Thread ID (optional)"
/>
<button
type="button"
@click="removeAllowedChatRule(idx)"
title="Remove rule"
aria-label="Remove rule"
class="px-2.5 py-2 bg-piedra-800 hover:bg-lava-500/20 border border-lava-500/40 rounded-lg text-lava-300 hover:text-lava-200 transition-colors"
>
<Icon name="trash" size="md" />
</button>
</div>
<button
type="button"
@click="addAllowedChatRule()"
class="px-3 py-2 bg-piedra-800 hover:bg-piedra-700 border border-piedra-700 rounded-lg text-xs text-arena-300 transition-colors"
>
+ Add chat rule
</button>
</div>
<p v-if="propSchema.description" class="text-[10px] text-arena-500 mt-1">{{ propSchema.description }}</p>
</div>
<div v-else>
<FormLabel :label="propSchema.title || key" :required="isFieldRequired(key)" />
<FormInput
:modelValue="arrayToCSV(form.config[key])"
Expand All @@ -202,6 +240,15 @@
<button type="button" @click="regenerateToken" class="px-3 py-2 bg-piedra-800 hover:bg-lava-500/20 border border-piedra-700 rounded-lg text-xs text-arena-300 transition-colors flex-shrink-0">
<Icon name="refresh" size="md" />
</button>
<button
v-if="form.type === 'telegram'"
type="button"
@click="sendTelegramTest"
:disabled="isTestingTelegram"
class="px-3 py-2 bg-piedra-800 hover:bg-teal-500/20 disabled:opacity-50 disabled:cursor-not-allowed border border-piedra-700 rounded-lg text-xs text-arena-300 transition-colors flex-shrink-0"
>
{{ isTestingTelegram ? 'Testing...' : 'Test' }}
</button>
</div>
<p class="text-[10px] text-arena-500 mt-1">Use as <code class="text-arena-400">Authorization: Bearer &lt;token&gt;</code></p>

Expand All @@ -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')
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 || {}
Expand All @@ -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') {
Expand All @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions frontend/admin-ui/src/views/clients/allowedChatsRules.js
Original file line number Diff line number Diff line change
@@ -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)
}
60 changes: 60 additions & 0 deletions frontend/admin-ui/src/views/clients/allowedChatsRules.test.js
Original file line number Diff line number Diff line change
@@ -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 },
])
})
Loading