Files
dobotai-platform/internal/settings/controller.go

288 lines
12 KiB
Go

package settings
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
type ResourceBinding struct {
Kind, TenantKey, ConversationID, ConversationTitle string
BaseID, SheetID, ViewID, OperatorID string
CreatedByStaffID string
FieldMapping map[string]string
ValidationCode string
Activate bool
}
type BootstrapCandidate struct {
TenantKey, Code, StaffID, UnionID, DisplayName, Department string
DetectedAt time.Time
}
type SettingSession struct {
TenantKey, StaffID, ConversationID, ConversationType, PendingAction string
ExpiresAt time.Time
}
type SettingInput struct{ TenantKey, Channel, StaffID, UnionID, DisplayName, Department, Text, ConversationType, ConversationID, ConversationTitle string }
type ControllerRepository interface {
IsAdministrator(context.Context, string, string) (bool, error)
SetPreference(context.Context, string, string, string, string) error
SetReasoningMaxRounds(context.Context, string, string, string, int, time.Time) error
SetReasoningHintEnabled(context.Context, string, string, string, bool, time.Time) error
SaveBinding(context.Context, ResourceBinding) error
TestAndActivateSupportGroup(context.Context, ResourceBinding) error
DetectBootstrapCandidate(context.Context, BootstrapCandidate) error
GetSettingSession(context.Context, string, string) (SettingSession, bool, error)
SaveSettingSession(context.Context, SettingSession) error
DeleteSettingSession(context.Context, string, string) error
}
type Controller struct {
repo ControllerRepository
now func() time.Time
}
func NewController(repo ControllerRepository) *Controller {
return &Controller{repo: repo, now: time.Now}
}
func (c *Controller) ShouldHandle(ctx context.Context, tenant, staff, text string) bool {
if _, ok := ParseCommand(text); ok {
return true
}
if c == nil || c.repo == nil {
return false
}
s, ok, err := c.repo.GetSettingSession(ctx, tenant, staff)
return err == nil && ok && s.ExpiresAt.After(c.now())
}
func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error) {
if c == nil || c.repo == nil {
return "", errors.New("settings repository is required")
}
cmd, explicit := ParseCommand(in.Text)
var action string
var args []string
if explicit {
args = cmd.Args
} else {
s, ok, err := c.repo.GetSettingSession(ctx, in.TenantKey, in.StaffID)
if err != nil {
return "", err
}
if !ok || !s.ExpiresAt.After(c.now()) {
return "", nil
}
if strings.TrimSpace(in.Text) == "取消" {
_ = c.repo.DeleteSettingSession(ctx, in.TenantKey, in.StaffID)
return "已取消设置。", nil
}
action = s.PendingAction
args = []string{action, strings.TrimSpace(in.Text)}
}
if explicit && len(args) == 1 && len(args[0]) == 6 && allDigits(args[0]) {
if strings.TrimSpace(in.ConversationType) != "direct" {
return "管理员绑定码只能私聊机器人使用。", nil
}
err := c.repo.DetectBootstrapCandidate(ctx, BootstrapCandidate{TenantKey: in.TenantKey, Code: args[0], StaffID: in.StaffID, UnionID: in.UnionID, DisplayName: in.DisplayName, Department: in.Department, DetectedAt: c.now()})
if err != nil {
return "", err
}
return "身份已验证,服务器终端将自动完成管理员绑定。", nil
}
if explicit && len(args) == 0 {
admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID)
if err != nil {
return "", err
}
if admin {
return settingMenu(true, strings.TrimSpace(in.ConversationType) == "direct"), nil
}
return settingMenu(false, false), nil
}
if action == "" {
action = strings.ToLower(args[0])
}
if action == "language" {
if len(args) != 2 || !validLocale(args[1]) {
return "可选语言:auto、zh-CN、en、ja、ko、es。", nil
}
if err := c.repo.SetPreference(ctx, in.TenantKey, defaultChannel(in.Channel), in.StaffID, args[1]); err != nil {
return "", err
}
return "✅ 回答语言已设置为 " + args[1] + "。", nil
}
if action == "rounds" {
if len(args) != 2 {
return "智能推理自动关闭轮数必须是1-50之间的整数。", nil
}
rounds, parseErr := strconv.Atoi(args[1])
if parseErr != nil || rounds < 1 || rounds > 50 {
return "智能推理自动关闭轮数必须是1-50之间的整数。", nil
}
if err := c.repo.SetReasoningMaxRounds(ctx, in.TenantKey, defaultChannel(in.Channel), in.StaffID, rounds, c.now()); err != nil {
return "", err
}
return fmt.Sprintf("✅ 智能推理将在%d轮对话后自动关闭。", rounds), nil
}
if action == "hint" {
if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
return "用法:/setting hint <on|off>。", nil
}
enabled := args[1] == "on"
if err := c.repo.SetReasoningHintEnabled(ctx, in.TenantKey, defaultChannel(in.Channel), in.StaffID, enabled, c.now()); err != nil {
return "", err
}
if enabled {
return "✅ 智能推理剩余轮次提醒已开启。", nil
}
return "✅ 智能推理剩余轮次提醒已关闭。", nil
}
admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID)
if err != nil {
return "", err
}
if !admin {
return "你没有系统管理权限。", nil
}
switch action {
case "support":
if in.ConversationType != "group" || strings.TrimSpace(in.ConversationID) == "" {
return "请在目标技术支持群中@机器人发送 /setting support。", nil
}
binding := ResourceBinding{Kind: "support_group", TenantKey: in.TenantKey, ConversationID: in.ConversationID, ConversationTitle: in.ConversationTitle, CreatedByStaffID: in.StaffID}
if len(args) == 2 && strings.EqualFold(args[1], "test") {
err = c.repo.TestAndActivateSupportGroup(ctx, binding)
if err == nil {
return "✅ 测试成功,当前群已激活为技术支持群。", nil
}
break
}
err = c.repo.SaveBinding(ctx, binding)
if err == nil {
return "✅ 当前群已保存为技术支持群(待验证)。确认发送测试消息后,请在本群@机器人发送 /setting support test。", nil
}
case "question", "faq":
if len(args) != 2 {
err = c.repo.SaveSettingSession(ctx, SettingSession{TenantKey: in.TenantKey, StaffID: in.StaffID, ConversationID: in.ConversationID, ConversationType: in.ConversationType, PendingAction: action, ExpiresAt: c.now().Add(10 * time.Minute)})
if err != nil {
return "", err
}
return tableBindingInstructions(action), nil
}
link, e := ParseNotableLink(args[1])
if e != nil {
return "AI表格链接无法识别。", nil
}
kind := action + "_table"
err = c.repo.SaveBinding(ctx, ResourceBinding{Kind: kind, TenantKey: in.TenantKey, BaseID: link.BaseID, SheetID: link.SheetID, ViewID: link.ViewID, OperatorID: in.UnionID, CreatedByStaffID: in.StaffID})
if err != nil {
return tableBindingFailureReply(action, err), nil
}
_ = c.repo.DeleteSettingSession(ctx, in.TenantKey, in.StaffID)
return fmt.Sprintf("✅ %sSchema验证通过并已激活,DobotAI将短暂重启加载Dispatcher。", map[string]string{"question": "问题AI表格", "faq": "FAQ表格"}[action]), nil
default:
return "未知设置项。发送 /setting 查看菜单。", nil
}
return "", err
}
func settingMenu(admin, _ bool) string {
menu := "⚙️ 个人设置\n入口:/setting 或 /设置\n" +
"回答语言:/setting language <auto|zh-CN|en|ja|ko|es>\n" +
"- auto:跟随当前提问语言(推荐)\n" +
"- zh-CN:简体中文\n" +
"- en / ja / ko / es:英语 / 日语 / 韩语 / 西班牙语\n" +
"例如:/setting language en\n\n" +
"智能推理自动关闭轮数:/setting rounds <1-50>\n" +
"例如:/setting rounds 15\n" +
"剩余轮次提醒:/setting hint <on|off>"
if !admin {
return menu
}
menu += "\n\n🛠 系统管理(仅管理员)\n" +
"- support:在目标技术支持群@机器人发送 /setting support 保存;再发送 /setting support test,测试成功后激活。\n" +
"- question:私聊发送 /setting question,再在10分钟内发送问题AI表链接;Schema验证通过后激活并短暂重启。\n" +
"- faq:私聊发送 /setting faq,再在10分钟内发送FAQ表链接;Schema验证通过后激活并短暂重启。"
return menu
}
func tableBindingFailureReply(action string, err error) string {
label := map[string]string{"question": "问题AI表格", "faq": "FAQ表格"}[action]
if label == "" {
label = "AI表格"
}
code := "table_validation_failed"
guidance := "请检查链接是否来自目标表格中的任意记录,并确认字段名称和类型符合提示要求。"
raw := ""
if err != nil {
raw = strings.ToLower(err.Error())
}
switch {
case strings.Contains(raw, "schema_http_401"), strings.Contains(raw, "schema_http_403"), strings.Contains(raw, "schema_token_failed"):
code = "table_permission_denied"
guidance = "请确认机器人应用已开通钉钉AI表格接口权限,并确认当前管理员对目标表格有访问和编辑权限。"
case strings.Contains(raw, "missing_field"):
code = "table_schema_missing_field"
guidance = "目标表缺少必需字段,请按上一条提示补齐字段后重试。"
case strings.Contains(raw, "duplicate_field"):
code = "table_schema_duplicate_field"
guidance = "目标表存在重名必需字段,请保留唯一字段后重试。"
case strings.Contains(raw, "field_type_mismatch"):
code = "table_schema_field_type_mismatch"
guidance = "目标表的必需字段类型不正确,请按上一条提示调整字段类型后重试。"
case strings.Contains(raw, "schema_network_failed"), strings.Contains(raw, "schema_request_failed"), strings.Contains(raw, "schema_read_failed"):
code = "table_schema_network_failed"
guidance = "钉钉AI表格接口暂时不可用,请稍后重试。"
case strings.Contains(raw, "schema_decode_failed"), strings.Contains(raw, "schema_empty"):
code = "table_schema_response_invalid"
guidance = "未读取到可用字段,请确认链接指向AI表格中的具体数据表。"
}
return fmt.Sprintf("❌ %s验证失败,新配置未激活。\n错误码:%s\n%s\n当前设置仍在10分钟等待期内,可直接重新发送链接卡片;发送“取消”退出。", label, code, guidance)
}
func tableBindingInstructions(action string) string {
if action == "faq" {
return "请在10分钟内直接发送FAQ表中任意一条记录的链接(钉钉显示为链接卡片也可以直接发送)。\n\n" +
"操作前请确认:\n" +
"1. 当前机器人应用具备钉钉AI表格接口权限,当前管理员具备目标表格的访问和编辑权限。\n" +
"2. FAQ表包含以下字段:标准问题(必填)、原始问题、语言、提问人。\n" +
"3. Schema验证成功后会立即激活FAQ写入,并短暂重启DobotAI加载Dispatcher。\n\n" +
"如果完整URL被钉钉自动转换为链接卡片,请只发送 alidocs.dingtalk.com/ 后面的部分。示例:\n" +
"i/nodes/<BaseID>?iframeQuery=entrance%3Ddata%26sheetId%3D<SheetID>%26viewId%3D<ViewID>\n\n" +
"iframeQuery必须完整保留,系统会自动解码其中的sheetId/viewId。\n\n" +
"发送“取消”退出。"
}
return "请在10分钟内直接发送问题AI表格中任意一条记录的链接(钉钉显示为链接卡片也可以直接发送)。\n\n" +
"操作前请确认:\n" +
"1. 当前机器人应用具备钉钉AI表格接口权限,当前管理员具备目标表格的访问和编辑权限。\n" +
"2. 问题表包含:中文问题、提问时间、提问人、原始问题、AI答案、流程触发人员信息、语言、部门、区域。\n" +
"3. Schema验证成功后会立即激活问题表写入,并短暂重启DobotAI加载Dispatcher。\n\n" +
"如果完整URL被钉钉自动转换为链接卡片,请只发送 alidocs.dingtalk.com/ 后面的部分。示例:\n" +
"i/nodes/<BaseID>?iframeQuery=entrance%3Ddata%26sheetId%3D<SheetID>%26viewId%3D<ViewID>\n\n" +
"iframeQuery必须完整保留,系统会自动解码其中的sheetId/viewId。\n\n" +
"发送“取消”退出。"
}
func validLocale(s string) bool {
switch s {
case "auto", "zh-CN", "en", "ja", "ko", "es":
return true
}
return false
}
func defaultChannel(s string) string {
if strings.TrimSpace(s) == "" {
return "dingtalk"
}
return strings.TrimSpace(s)
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}