feat: harden support, localize knowledge and add operator availability [skip ci]

Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
This commit is contained in:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -1,6 +1,11 @@
// Modified in the independent hardening fork; see docs/hardening.
package dto
import "agent-desk/internal/pkg/enums"
import (
"agent-desk/internal/pkg/enums"
"time"
)
type AuthPrincipal struct {
UserID int64
@@ -11,16 +16,22 @@ type AuthPrincipal struct {
Status enums.Status
Roles []string
Permissions []string
// Session metadata stays internal and binds long-lived connections to the
// original login session and its absolute deadline.
LoginSessionID int64 `json:"-"`
ExpiresAt time.Time `json:"-"`
}
type WebChannelConfig struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ThemeColor string `json:"themeColor"`
Position string `json:"position"`
Width string `json:"width"`
UserTokenSecret string `json:"userTokenSecret,omitempty"`
PromptStarters []string `json:"promptStarters,omitempty"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ThemeColor string `json:"themeColor"`
Position string `json:"position"`
Width string `json:"width"`
UserTokenSecret string `json:"userTokenSecret,omitempty"`
RequireSignedIdentity bool `json:"requireSignedIdentity,omitempty"`
PromptStarters []string `json:"promptStarters,omitempty"`
}
type TelegramChannelConfig struct {

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package request
type SetupSimulateRouteRequest struct {
@@ -6,6 +7,8 @@ type SetupSimulateRouteRequest struct {
}
type SetupQuickFixRequest struct {
Action string `json:"action" form:"action"`
EntityID int64 `json:"entity_id" form:"entity_id"`
Action string `json:"action" form:"action"`
EntityID int64 `json:"entity_id" form:"entity_id"`
Apply bool `json:"apply" form:"apply"`
PreviewToken string `json:"preview_token" form:"preview_token"`
}

View File

@@ -0,0 +1,34 @@
package request
import "agent-desk/internal/pkg/enums"
type SupportCalendarPeriod struct {
Start string `json:"start"`
End string `json:"end"`
}
type SupportCalendarWeeklyPeriod struct {
Weekday int `json:"weekday"`
SupportCalendarPeriod
}
type SupportCalendarException struct {
Date string `json:"date"`
Closed bool `json:"closed"`
Periods []SupportCalendarPeriod `json:"periods"`
}
type SupportCalendarRequest struct {
TeamID int64 `json:"teamId"`
Mode string `json:"mode"`
Timezone string `json:"timezone"`
Weekly []SupportCalendarWeeklyPeriod `json:"weekly"`
Exceptions []SupportCalendarException `json:"exceptions"`
}
type SupportCalendarPreviewRequest struct {
Calendar SupportCalendarRequest `json:"calendar"`
At string `json:"at"`
}
type SupportAvailabilityRequest struct {
UserID int64 `json:"userId"`
ServiceStatus enums.ServiceStatus `json:"serviceStatus"`
AbsenceStartAt string `json:"absenceStartAt"`
AbsenceEndAt string `json:"absenceEndAt"`
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package response
type SetupIssueItem struct {
@@ -92,7 +93,20 @@ type SetupSimulateRouteResponse struct {
CandidateOperators []SimulatedOperatorCandidate `json:"candidate_operators"`
}
type SetupQuickFixResponse struct {
AppliedActions []string `json:"applied_actions"`
Readiness SetupReadinessResponse `json:"readiness"`
type SetupQuickFixChange struct {
EntityType string `json:"entity_type"`
EntityID int64 `json:"entity_id"`
EntityName string `json:"entity_name"`
Operation string `json:"operation"`
Before map[string]any `json:"before"`
After map[string]any `json:"after"`
}
type SetupQuickFixResponse struct {
Applied bool `json:"applied"`
PreviewToken string `json:"preview_token"`
Changes []SetupQuickFixChange `json:"changes"`
WarningCodes []string `json:"warning_codes"`
AppliedActions []string `json:"applied_actions"`
Readiness *SetupReadinessResponse `json:"readiness,omitempty"`
}

View File

@@ -0,0 +1,41 @@
package response
import (
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
)
type SupportCalendarResponse struct {
request.SupportCalendarRequest
EffectiveTimezone string `json:"effectiveTimezone"`
DeploymentTimezone string `json:"deploymentTimezone"`
}
type SupportCalendarPreviewResponse struct {
Open bool `json:"open"`
Timezone string `json:"timezone"`
Mode string `json:"mode"`
NextOpeningAt string `json:"nextOpeningAt,omitempty"`
}
type SupportAvailabilityResponse struct {
UserID int64 `json:"userId"`
ProfileID int64 `json:"profileId"`
TeamID int64 `json:"teamId"`
DisplayName string `json:"displayName"`
ServiceStatus enums.ServiceStatus `json:"serviceStatus"`
ReturnServiceStatus enums.ServiceStatus `json:"returnServiceStatus"`
EffectiveServiceStatus enums.ServiceStatus `json:"effectiveServiceStatus"`
AbsenceStartAt string `json:"absenceStartAt,omitempty"`
AbsenceEndAt string `json:"absenceEndAt,omitempty"`
Online bool `json:"online"`
Eligible bool `json:"eligible"`
Reason string `json:"reason"`
Calendar SupportCalendarResponse `json:"calendar"`
CalendarOpen bool `json:"calendarOpen"`
NextOpeningAt string `json:"nextOpeningAt,omitempty"`
}
type PublicSupportStatusResponse struct {
Available bool `json:"available"`
Reason string `json:"reason"`
Timezone string `json:"timezone"`
NextOpeningAt string `json:"nextOpeningAt,omitempty"`
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
package enums
type AgentRunResolutionStatus string
@@ -31,18 +32,27 @@ var AgentRunEvidenceStatusValues = []AgentRunEvidenceStatus{
type ServiceStatus int
const (
ServiceStatusIdle ServiceStatus = 0
ServiceStatusBusy ServiceStatus = 1
ServiceStatusIdle ServiceStatus = 0
ServiceStatusBusy ServiceStatus = 1
ServiceStatusBreak ServiceStatus = 2
ServiceStatusUnavailable ServiceStatus = 3
ServiceStatusVacation ServiceStatus = 4
)
var ServiceStatusValues = []ServiceStatus{
ServiceStatusIdle,
ServiceStatusBusy,
ServiceStatusBreak,
ServiceStatusUnavailable,
ServiceStatusVacation,
}
var serviceStatusLabelMap = map[ServiceStatus]string{
ServiceStatusIdle: "空闲",
ServiceStatusBusy: "忙碌",
ServiceStatusIdle: "空闲",
ServiceStatusBusy: "忙碌",
ServiceStatusBreak: "休息",
ServiceStatusUnavailable: "不可用",
ServiceStatusVacation: "休假",
}
func IsValidServiceStatus(status ServiceStatus) bool {

View File

@@ -1,3 +1,4 @@
// Modified by the independent AgentDesk fork for support reliability and availability.
package eventbus
import (
@@ -28,6 +29,7 @@ type Bus[T any] struct {
// 用于限制 PublishAsync 的 goroutine 并发数;nil 表示不限制
asyncSem chan struct{}
asyncWG sync.WaitGroup
}
func New[T any](opts ...Option[T]) *Bus[T] {
@@ -130,10 +132,21 @@ func (b *Bus[T]) Publish(ctx context.Context, event T) error {
func (b *Bus[T]) PublishAsync(ctx context.Context, event T) {
handlers := b.snapshotHandlers()
for _, h := range handlers {
go b.callHandlerAsync(ctx, h, event)
b.asyncWG.Add(1)
go func() {
defer b.asyncWG.Done()
b.callHandlerAsync(ctx, h, event)
}()
}
}
// Wait blocks until already-published asynchronous handlers and their error
// callbacks finish. Stop external publishers before calling Wait; a handler
// may still publish nested work while its own invocation is pending.
func (b *Bus[T]) Wait() {
b.asyncWG.Wait()
}
func (b *Bus[T]) HandlerCount() int {
b.mu.RLock()
defer b.mu.RUnlock()

View File

@@ -1,3 +1,4 @@
// Modified by the independent AgentDesk fork for support reliability and availability.
package eventbus
import (
@@ -197,3 +198,21 @@ func TestPublishAsyncAllowsReentrantPublishWithConcurrencyLimit(t *testing.T) {
t.Fatalf("expected reentrant async publish to complete")
}
}
func TestWaitJoinsNestedAsyncHandlersAndErrorCallbacks(t *testing.T) {
var handled atomic.Int64
bus := New[testEvent](WithAsyncConcurrency[testEvent](1), WithErrorHandler[testEvent](func(context.Context, error) {
handled.Add(1)
}))
bus.Subscribe(func(ctx context.Context, event testEvent) error {
if event.ID == 1 {
bus.PublishAsync(ctx, testEvent{ID: 2})
}
return errors.New("expected fixture error")
})
bus.PublishAsync(context.Background(), testEvent{ID: 1})
bus.Wait()
if handled.Load() != 2 {
t.Fatalf("Wait returned before nested work: %d error callbacks", handled.Load())
}
}

View File

@@ -1,6 +1,11 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package i18nx
import "github.com/gin-gonic/gin"
import (
"context"
"github.com/gin-gonic/gin"
)
const contextLocaleKey = "i18nx.locale"
@@ -25,3 +30,27 @@ func SetLocale(ctx *gin.Context, locale string) {
}
ctx.Set(contextLocaleKey, NormalizeLocale(locale))
}
// localeContextKey is deliberately distinct from Gin's string key.
type localeContextKey struct{}
// WithLocale carries an explicitly selected application locale without losing
// the parent's cancellation, deadline, or values. It does not detect a locale
// from HTTP headers or change the configured default locale.
func WithLocale(ctx context.Context, locale string) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, localeContextKey{}, NormalizeLocale(locale))
}
// LocaleFromContext preserves the configured-default behavior of callers that
// do not provide an explicit locale (including background jobs).
func LocaleFromContext(ctx context.Context) string {
if ctx != nil {
if locale, ok := ctx.Value(localeContextKey{}).(string); ok {
return NormalizeLocale(locale)
}
}
return DefaultLocale
}

View File

@@ -0,0 +1,45 @@
package i18nx
import (
"context"
"errors"
"testing"
"time"
)
func TestWithLocalePreservesContext(t *testing.T) {
type testKey struct{}
deadline := time.Now().Add(time.Minute)
parent, cancel := context.WithDeadline(context.WithValue(context.Background(), testKey{}, "request-value"), deadline)
defer cancel()
localized := WithLocale(parent, "ru")
if got := LocaleFromContext(localized); got != LocaleRuRU {
t.Fatalf("locale = %q", got)
}
if got := localized.Value(testKey{}); got != "request-value" {
t.Fatalf("parent value lost: %v", got)
}
if got, ok := localized.Deadline(); !ok || !got.Equal(deadline) {
t.Fatalf("deadline lost: %v, %v", got, ok)
}
cancel()
if !errors.Is(localized.Err(), context.Canceled) {
t.Fatalf("cancellation lost: %v", localized.Err())
}
}
func TestLocaleFromContextDefaultsAndIsolation(t *testing.T) {
for _, ctx := range []context.Context{nil, context.Background(), context.WithValue(context.Background(), "i18nx.locale", LocaleZhCN)} {
if got := LocaleFromContext(ctx); got != DefaultLocale {
t.Fatalf("missing explicit locale = %q, want default %q", got, DefaultLocale)
}
}
english := WithLocale(context.Background(), LocaleEnUS)
russian := WithLocale(english, LocaleRuRU)
if LocaleFromContext(english) != LocaleEnUS || LocaleFromContext(russian) != LocaleRuRU {
t.Fatal("child locale changed parent locale")
}
if got := LocaleFromContext(WithLocale(nil, "unsupported")); got != DefaultLocale {
t.Fatalf("invalid locale = %q", got)
}
}

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "AI Agent not found"
error.e0002: "AI Agent not found."
error.e0003: "AI Agent not found or disabled."
@@ -567,3 +568,77 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "The user's expected outcom
tool.graph.prepareTicketDraft.param.currentAttempt: "Current attempted troubleshooting steps. Optional."
error.setup.invalidQuickFixAction: "Unsupported setup quick-fix action: %v"
error.setup.authRequired: "Sign in to run setup diagnostics or quick fixes"
error.setup.previewRequired: "Review a setup preview and explicitly confirm its token before applying changes."
error.setup.previewChanged: "Configuration changed since the preview. Review a new preview before applying."
error.setup.targetRequired: "Select a specific existing record, or use its settings to choose the intended configuration."
error.setup.targetUnavailable: "The selected record is unavailable, deleted or ineligible. It was not restored."
error.setup.persistenceFailed: "Setup configuration could not be read or saved. No incomplete repair was committed."
error.setup.bypassUnsupported: "Bypassing AI is not supported by this diagnostic. Run a human-only route check or test AI separately."
error.channel.configChanged: "The channel configuration changed. Reload it and review your changes before saving again."
error.channel.configUpdateFailed: "The channel configuration could not be saved. Please try again."
# Knowledge-base display labels and safe application-owned messages.
knowledge.labels.unknown: "Unknown"
knowledge.labels.type.document: "Document knowledge base"
knowledge.labels.type.faq: "FAQ knowledge base"
knowledge.labels.status.0: "Enabled"
knowledge.labels.status.1: "Disabled"
knowledge.labels.status.2: "Deleted"
knowledge.labels.index.pending: "Pending"
knowledge.labels.index.indexed: "Indexed"
knowledge.labels.index.failed: "Index failed"
knowledge.labels.mode.1: "Strict mode"
knowledge.labels.mode.2: "Assist mode"
knowledge.labels.channel.im: "Conversations"
knowledge.labels.channel.agent_assist: "Agent assist"
knowledge.labels.channel.api: "API"
knowledge.labels.channel.debug: "Debug"
knowledge.labels.scene.first_response: "First response"
knowledge.labels.scene.assist: "Assisted reply"
knowledge.labels.scene.qa: "Q&A"
knowledge.labels.answer.1: "Answered"
knowledge.labels.answer.2: "No answer"
knowledge.labels.answer.3: "Fallback"
knowledge.labels.answer.4: "Blocked"
knowledge.labels.chunk.text: "Text"
knowledge.labels.chunk.faq: "Q&A"
knowledge.labels.chunk.table: "Table"
knowledge.labels.chunk.code: "Code"
error.knowledge.indexFailed: "Indexing failed. Check the indexing configuration and try again."
error.knowledge.retrieveFailed: "Knowledge retrieval failed. Check the retrieval configuration and try again."
error.knowledge.workflowReference: "This knowledge base is used by workflow “%s”. Remove it from the knowledge retrieval node first."
error.knowledge.workflowReferences: "This knowledge base is used by %d workflows. Remove it from their knowledge retrieval nodes first."
knowledge.rag.fallback.noAnswer: "The knowledge base currently has no clear information."
knowledge.rag.fallback.suggestRetry: "The knowledge base did not contain enough clear information. Try asking a more specific question."
knowledge.rag.system.assist: "You are a customer support knowledge base assistant. Prioritize the supplied knowledge excerpts. You may summarize them, but do not invent facts that were not provided. Respond in English."
knowledge.rag.system.strict: "You are a strict customer support knowledge base assistant. Answer only from the supplied knowledge excerpts. If they are insufficient, explicitly state that the knowledge base has no clear information. Respond in English."
knowledge.rag.userPrompt: "User question: %s\n\nReference material:\n%s"
knowledge.excel.question: "Primary question"
knowledge.excel.answer: "Answer"
knowledge.excel.similarQuestions: "Similar questions"
knowledge.excel.remark: "Notes"
knowledge.excel.exampleQuestion: "How do I reset my password?"
knowledge.excel.exampleAnswer: "Open your profile settings and select Reset password."
knowledge.excel.exampleSimilar: "I forgot my password\nWhere can I reset my password?"
knowledge.excel.exampleRemark: "Account"
knowledge.rag.context.faq: "[FAQ: %s]\n%s\n\n"
knowledge.rag.context.faqTitle: "FAQ#%d"
knowledge.rag.context.documentTitle: "Document#%d"
knowledge.rag.context.documentSection: "[Document: %s | Section: %s]\n%s\n\n"
knowledge.rag.context.documentHeading: "[Document: %s | Title: %s]\n%s\n\n"
knowledge.rag.context.document: "[Document: %s]\n%s\n\n"
knowledge.rag.faq.question: "Question: %s"
knowledge.rag.faq.similarQuestions: "Similar questions: %s"
knowledge.rag.faq.answer: "Answer: %s"
knowledge.rag.faq.separator: "; "
# Support availability validation.
error.supportAvailability.invalidCalendar: "Enter a valid timezone, weekly hours, and date exceptions."
error.supportAvailability.invalidAbsence: "Vacation requires a start and end with an explicit timezone; the end must be after the start."
error.supportAvailability.unavailable: "This operator is not currently eligible for new conversations. Refresh availability and try again."
error.supportAvailability.persistence: "Support availability could not be loaded or saved. Please try again."
error.supportAvailability.noProfile: "An enabled support operator profile is required."
error.supportAvailability.invalidStatus: "Choose a valid working availability status."
error.supportAvailability.invalidTime: "Enter a timestamp including its timezone offset."

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "ИИ-агент не найден"
error.e0002: "ИИ-агент не найден."
error.e0003: "ИИ-агент не найден или отключён."
@@ -566,4 +567,77 @@ tool.graph.prepareTicketDraft.param.impact: "Область воздействи
tool.graph.prepareTicketDraft.param.expectedOutcome: "Ожидаемый результат или запрос пользователя."
tool.graph.prepareTicketDraft.param.currentAttempt: "Предпринятые шаги по устранению. Необязательно."
error.setup.invalidQuickFixAction: "Неподдерживаемое действие быстрой настройки: %v"
error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки"
error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки"
error.setup.previewRequired: "Перед применением изменений просмотрите план и явно подтвердите его токен."
error.setup.previewChanged: "После предварительного просмотра настройки изменились. Просмотрите новый план."
error.setup.targetRequired: "Выберите конкретную запись или задайте нужную конфигурацию в её настройках."
error.setup.targetUnavailable: "Выбранная запись недоступна, удалена или не подходит. Она не была восстановлена."
error.setup.persistenceFailed: "Не удалось прочитать или сохранить настройки. Неполное исправление не было сохранено."
error.setup.bypassUnsupported: "Эта диагностика не поддерживает обход ИИ. Проверьте маршрут только с оператором или протестируйте ИИ отдельно."
error.channel.configChanged: "Настройки канала изменились. Перезагрузите их и проверьте изменения перед повторным сохранением."
error.channel.configUpdateFailed: "Не удалось сохранить настройки канала. Попробуйте ещё раз."
# Knowledge-base display labels and safe application-owned messages.
knowledge.labels.unknown: "Неизвестно"
knowledge.labels.type.document: "База знаний документов"
knowledge.labels.type.faq: "База знаний FAQ"
knowledge.labels.status.0: "Включено"
knowledge.labels.status.1: "Отключено"
knowledge.labels.status.2: "Удалено"
knowledge.labels.index.pending: "Ожидает индексации"
knowledge.labels.index.indexed: "Проиндексировано"
knowledge.labels.index.failed: "Ошибка индексации"
knowledge.labels.mode.1: "Строгий режим"
knowledge.labels.mode.2: "Вспомогательный режим"
knowledge.labels.channel.im: "Диалоги"
knowledge.labels.channel.agent_assist: "Помощник оператора"
knowledge.labels.channel.api: "API"
knowledge.labels.channel.debug: "Отладка"
knowledge.labels.scene.first_response: "Первый ответ"
knowledge.labels.scene.assist: "Подсказка ответа"
knowledge.labels.scene.qa: "Вопросы и ответы"
knowledge.labels.answer.1: "Ответ получен"
knowledge.labels.answer.2: "Нет ответа"
knowledge.labels.answer.3: "Резервный ответ"
knowledge.labels.answer.4: "Заблокирован"
knowledge.labels.chunk.text: "Текст"
knowledge.labels.chunk.faq: "Вопросы и ответы"
knowledge.labels.chunk.table: "Таблица"
knowledge.labels.chunk.code: "Код"
error.knowledge.indexFailed: "Не удалось выполнить индексацию. Проверьте настройки индексации и повторите попытку."
error.knowledge.retrieveFailed: "Не удалось выполнить поиск по базе знаний. Проверьте настройки поиска и повторите попытку."
error.knowledge.workflowReference: "Эта база знаний используется в процессе «%s». Сначала удалите её из узла поиска по базе знаний."
error.knowledge.workflowReferences: "Эта база знаний используется в %d процессах. Сначала удалите её из узлов поиска по базе знаний."
knowledge.rag.fallback.noAnswer: "В базе знаний пока нет однозначной информации."
knowledge.rag.fallback.suggestRetry: "В базе знаний недостаточно однозначной информации. Попробуйте задать более конкретный вопрос."
knowledge.rag.system.assist: "Вы помощник службы поддержки по базе знаний. Отвечайте прежде всего на основе предоставленных фрагментов. Можно обобщать их, но нельзя придумывать отсутствующие факты. Отвечайте на русском языке."
knowledge.rag.system.strict: "Вы помощник службы поддержки, строго использующий базу знаний. Отвечайте только на основе предоставленных фрагментов. Если их недостаточно, прямо сообщите, что в базе знаний нет однозначной информации. Отвечайте на русском языке."
knowledge.rag.userPrompt: "Вопрос пользователя: %s\n\nСправочные материалы:\n%s"
knowledge.excel.question: "Основной вопрос"
knowledge.excel.answer: "Ответ"
knowledge.excel.similarQuestions: "Похожие вопросы"
knowledge.excel.remark: "Примечания"
knowledge.excel.exampleQuestion: "Как сбросить пароль?"
knowledge.excel.exampleAnswer: "Откройте настройки профиля и выберите «Сбросить пароль»."
knowledge.excel.exampleSimilar: "Я забыл пароль\nГде можно сбросить пароль?"
knowledge.excel.exampleRemark: "Учётная запись"
knowledge.rag.context.faq: "[FAQ: %s]\n%s\n\n"
knowledge.rag.context.faqTitle: "FAQ#%d"
knowledge.rag.context.documentTitle: "Документ №%d"
knowledge.rag.context.documentSection: "[Документ: %s | Раздел: %s]\n%s\n\n"
knowledge.rag.context.documentHeading: "[Документ: %s | Заголовок: %s]\n%s\n\n"
knowledge.rag.context.document: "[Документ: %s]\n%s\n\n"
knowledge.rag.faq.question: "Вопрос: %s"
knowledge.rag.faq.similarQuestions: "Похожие вопросы: %s"
knowledge.rag.faq.answer: "Ответ: %s"
knowledge.rag.faq.separator: "; "
# Support availability validation.
error.supportAvailability.invalidCalendar: "Укажите корректный часовой пояс, часы работы по дням недели и исключения по датам."
error.supportAvailability.invalidAbsence: "Для отпуска укажите начало и окончание с часовым поясом. Окончание должно быть позже начала."
error.supportAvailability.unavailable: "Этот оператор сейчас не может принимать новые обращения. Обновите данные о доступности и повторите попытку."
error.supportAvailability.persistence: "Не удалось загрузить или сохранить настройки доступности поддержки. Повторите попытку."
error.supportAvailability.noProfile: "Требуется активный профиль оператора поддержки."
error.supportAvailability.invalidStatus: "Выберите корректный рабочий статус доступности."
error.supportAvailability.invalidTime: "Укажите дату и время со смещением часового пояса."

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "AI Agent not found"
error.e0002: "AI Agent 不存在"
error.e0003: "AI Agent 不存在或已停用"
@@ -567,3 +568,77 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "用户期望的处理结
tool.graph.prepareTicketDraft.param.currentAttempt: "当前已尝试过的处理步骤,可选。"
error.setup.invalidQuickFixAction: "不支持的一键修复操作:%v"
error.setup.authRequired: "请先登录以执行配置诊断或一键修复"
error.setup.previewRequired: "应用更改前,请先预览设置并明确确认预览令牌。"
error.setup.previewChanged: "预览后配置已更改。请重新预览后再应用。"
error.setup.targetRequired: "请选择具体记录,或在对应设置中选择所需配置。"
error.setup.targetUnavailable: "所选记录不可用、已删除或不符合条件,未进行恢复。"
error.setup.persistenceFailed: "无法读取或保存设置配置,未提交不完整的修复。"
error.setup.bypassUnsupported: "此诊断不支持绕过 AI。请检查仅人工路由,或单独测试 AI。"
error.channel.configChanged: "渠道配置已更改。请重新加载并检查更改后再保存。"
error.channel.configUpdateFailed: "无法保存渠道配置,请重试。"
# Knowledge-base display labels and safe application-owned messages.
knowledge.labels.unknown: "未知"
knowledge.labels.type.document: "文档知识库"
knowledge.labels.type.faq: "FAQ知识库"
knowledge.labels.status.0: "启用"
knowledge.labels.status.1: "禁用"
knowledge.labels.status.2: "已删除"
knowledge.labels.index.pending: "待索引"
knowledge.labels.index.indexed: "已索引"
knowledge.labels.index.failed: "索引失败"
knowledge.labels.mode.1: "严格模式"
knowledge.labels.mode.2: "辅助模式"
knowledge.labels.channel.im: "客服会话"
knowledge.labels.channel.agent_assist: "客服助手"
knowledge.labels.channel.api: "API接口"
knowledge.labels.channel.debug: "调试"
knowledge.labels.scene.first_response: "首次回复"
knowledge.labels.scene.assist: "辅助回复"
knowledge.labels.scene.qa: "问答"
knowledge.labels.answer.1: "正常"
knowledge.labels.answer.2: "无答案"
knowledge.labels.answer.3: "兜底回复"
knowledge.labels.answer.4: "已屏蔽"
knowledge.labels.chunk.text: "文本"
knowledge.labels.chunk.faq: "问答"
knowledge.labels.chunk.table: "表格"
knowledge.labels.chunk.code: "代码"
error.knowledge.indexFailed: "索引失败。请检查索引配置后重试。"
error.knowledge.retrieveFailed: "知识检索失败。请检查检索配置后重试。"
error.knowledge.workflowReference: "知识库正在被流程「%s」使用,请先从知识检索节点中移除"
error.knowledge.workflowReferences: "知识库正在被 %d 个流程使用,请先从知识检索节点中移除"
knowledge.rag.fallback.noAnswer: "当前知识库暂无明确信息。"
knowledge.rag.fallback.suggestRetry: "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
knowledge.rag.system.assist: "你是客服知识库助手。请优先依据提供的知识片段回答,可以做轻度归纳,但不要编造未提供的事实。请使用中文回答。"
knowledge.rag.system.strict: "你是严格的客服知识库助手。只能依据提供的知识片段回答;如果资料不足,请明确说明知识库暂无明确信息。请使用中文回答。"
knowledge.rag.userPrompt: "用户问题:%s\n\n参考资料:\n%s"
knowledge.excel.question: "标准问题"
knowledge.excel.answer: "答案"
knowledge.excel.similarQuestions: "相似问"
knowledge.excel.remark: "备注"
knowledge.excel.exampleQuestion: "如何重置密码?"
knowledge.excel.exampleAnswer: "进入个人设置后点击重置密码。"
knowledge.excel.exampleSimilar: "忘记密码怎么办\n重置密码在哪里"
knowledge.excel.exampleRemark: "账号"
knowledge.rag.context.faq: "【FAQ:%s】\n%s\n\n"
knowledge.rag.context.faqTitle: "FAQ#%d"
knowledge.rag.context.documentTitle: "文档#%d"
knowledge.rag.context.documentSection: "【文档:%s|章节:%s】\n%s\n\n"
knowledge.rag.context.documentHeading: "【文档:%s|标题:%s】\n%s\n\n"
knowledge.rag.context.document: "【文档:%s】\n%s\n\n"
knowledge.rag.faq.question: "问题:%s"
knowledge.rag.faq.similarQuestions: "相似问:%s"
knowledge.rag.faq.answer: "回答:%s"
knowledge.rag.faq.separator: ";"
# Support availability validation.
error.supportAvailability.invalidCalendar: "请输入有效的时区、每周工作时间和特殊日期规则。"
error.supportAvailability.invalidAbsence: "休假需要包含明确时区的开始和结束时间,结束时间必须晚于开始时间。"
error.supportAvailability.unavailable: "该客服当前无法接收新会话。请刷新可用状态后重试。"
error.supportAvailability.persistence: "无法加载或保存客服可用状态。请重试。"
error.supportAvailability.noProfile: "需要已启用的客服资料。"
error.supportAvailability.invalidStatus: "请选择有效的工作状态。"
error.supportAvailability.invalidTime: "请输入包含时区偏移的日期和时间。"

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package openidentity
import (
@@ -269,3 +271,33 @@ func flipLastChar(signature string) string {
func itoa(value int64) string {
return strconv.FormatInt(value, 10)
}
func TestRequireSignedIdentityRejectsUnsignedAndInvalidSignatures(t *testing.T) {
for _, signature := range []string{"", "invalid"} {
ctx, _ := newIdentityContext(map[string]string{"X-External-Id": testUserID, "X-External-Id-Signature": signature})
if _, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true}); err == nil {
t.Fatal("signed-only policy accepted an unsigned or invalid identity")
}
}
ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret, testChannelID, testUserID, time.Now()))
user, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true})
if err != nil || user.IdentityScope != IdentityScope(testChannelID, "hmac") {
t.Fatal("valid signed identity did not retain its HMAC scope")
}
}
func TestIdentityScopeSeparatesIssuersAndChannels(t *testing.T) {
seen := map[string]bool{}
for _, channel := range []string{"channel-one", "channel-two"} {
for _, issuer := range []string{"guest", "hmac", "jwt"} {
scope := IdentityScope(channel, issuer)
if scope == "" || seen[scope] {
t.Fatal("identity scopes collided")
}
seen[scope] = true
}
}
if IdentityScope("", "hmac") != "" || IdentityScope("channel", "unknown") != "" {
t.Fatal("invalid scope inputs accepted")
}
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package openidentity
import (
@@ -24,6 +26,9 @@ type ExternalUser struct {
ExternalSource enums.ExternalSource `json:"externalSource"`
ExternalID string `json:"externalId"`
ExternalName string `json:"externalName"`
// IdentityScope and ChannelID are server-established authorization context, never request fields.
IdentityScope string `json:"-"`
ChannelID int64 `json:"-"`
}
type UserTokenClaims struct {
@@ -46,7 +51,8 @@ type Secrets struct {
IdentityMaxAge time.Duration
// ChannelID is bound into the signed payload so a signature obtained for one
// channel cannot be replayed on another.
ChannelID string
ChannelID string
RequireSignedIdentity bool
}
// DefaultIdentityMaxAge is how long a signed visitor identity stays acceptable
@@ -65,9 +71,8 @@ const identityFutureSkewTolerance = 5 * time.Minute
//
// issuedAt is unix seconds. Lower-case hex output. Three details are deliberate:
//
// - The channel id is included because CustomerIdentity is keyed by
// (source, externalId) globally, so without it a signature minted for one
// channel would assert the same identity on every other channel.
// - The channel id is included so signatures cannot be replayed on a
// different channel, even when a deployment shares an HMAC signing key.
// - The timestamp is inside the signed bytes even though it also travels as its
// own field, so it cannot be re-dated without breaking the signature. This
// bounds how long a leaked pair stays usable; it is not a defence against key
@@ -126,6 +131,7 @@ func GetExternalUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error) {
ExternalSource: enums.ExternalSourceUser,
ExternalID: claims.UserID,
ExternalName: claims.Name,
IdentityScope: IdentityScope(secrets.ChannelID, "jwt"),
}, nil
}
return getExternalIDUser(ctx, secrets)
@@ -201,13 +207,18 @@ func getExternalIDUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error)
ExternalSource: enums.ExternalSourceUser,
ExternalID: externalID,
ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "hmac"),
}, nil
}
if secrets.RequireSignedIdentity {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
return &ExternalUser{
ExternalSource: enums.ExternalSourceGuest,
ExternalID: externalID,
ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "guest"),
}, nil
}
@@ -267,3 +278,30 @@ func decodeExternalDisplayName(s string) string {
}
return strings.TrimSpace(dec)
}
// IdentityScope separates independent identity issuers, trust levels and channels.
// It deliberately does not depend on a secret so ordinary key rotation preserves
// the issuer's history. Reassigning a channel to a different issuer requires a new
// channel, not silently reusing that issuer's namespace.
func IdentityScope(channelCode, issuer string) string {
if strings.TrimSpace(channelCode) == "" {
return ""
}
switch issuer {
case "jwt", "hmac", "guest":
default:
return ""
}
digest := sha256.Sum256([]byte(strings.TrimSpace(channelCode)))
return "v1:" + issuer + ":" + hex.EncodeToString(digest[:])
}
func ValidIdentityScope(channelCode string, external ExternalUser) bool {
if external.ExternalSource == enums.ExternalSourceGuest {
return external.IdentityScope != "" && external.IdentityScope == IdentityScope(channelCode, "guest")
}
if external.ExternalSource == enums.ExternalSourceUser {
return external.IdentityScope != "" && (external.IdentityScope == IdentityScope(channelCode, "jwt") || external.IdentityScope == IdentityScope(channelCode, "hmac"))
}
return false
}