288 lines
12 KiB
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 != ""
|
|
}
|