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:
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
34
internal/pkg/dto/request/support_availability_req.go
Normal file
34
internal/pkg/dto/request/support_availability_req.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
41
internal/pkg/dto/response/support_availability_resp.go
Normal file
41
internal/pkg/dto/response/support_availability_resp.go
Normal 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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
45
internal/pkg/i18nx/context_test.go
Normal file
45
internal/pkg/i18nx/context_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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: "Укажите дату и время со смещением часового пояса."
|
||||
|
||||
@@ -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: "请输入包含时区偏移的日期和时间。"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user