mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
feat: add telegram bulk unbind commands
This commit is contained in:
@@ -555,3 +555,104 @@ func TestBotAdminCodeAndUserCommands(t *testing.T) {
|
||||
t.Fatalf("delete without confirm should be rejected, got %q", reply.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAdminUnbindMultipleUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, bot := newBotTestService(t)
|
||||
admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
|
||||
viewer := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
guest := &model.User{Username: "guest", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
for _, user := range []*model.User{admin, viewer, guest} {
|
||||
if err := repos.User.Create(ctx, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
bindings := []model.TelegramBinding{
|
||||
{TelegramUserID: 9401, TelegramName: "@root", ChatID: 9401, UserID: admin.ID},
|
||||
{TelegramUserID: 9402, TelegramName: "@viewer", ChatID: 9402, UserID: viewer.ID},
|
||||
{TelegramUserID: 9403, TelegramName: "@guest", ChatID: 9403, UserID: guest.ID},
|
||||
}
|
||||
for i := range bindings {
|
||||
if err := repos.DB.Create(&bindings[i]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9401"}`}
|
||||
msg := &TelegramMessage{From: TelegramUser{ID: 9401, Username: "root"}, Chat: TelegramChat{ID: 9401, Type: "private"}}
|
||||
|
||||
reply, err := bot.executeCommand(ctx, channel, msg, "/unbind viewer,guest missing root")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "已解绑:<b>2</b>") || !strings.Contains(reply.Text, "root(管理员)") || !strings.Contains(reply.Text, "missing") {
|
||||
t.Fatalf("unexpected unbind reply: %q", reply.Text)
|
||||
}
|
||||
for _, user := range []*model.User{viewer, guest} {
|
||||
var count int64
|
||||
if err := repos.DB.Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("%s binding count = %d, want 0", user.Username, count)
|
||||
}
|
||||
}
|
||||
if binding := bot.telegramBinding(ctx, 9401); binding == nil {
|
||||
t.Fatal("admin binding should be protected from /unbind by username")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAdminUnbindInactiveAndInvalidBindings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, bot := newBotTestService(t)
|
||||
oldTime := time.Now().Add(-45 * 24 * time.Hour)
|
||||
recentTime := time.Now().Add(-2 * 24 * time.Hour)
|
||||
admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true, LastLoginAt: &oldTime}
|
||||
oldUser := &model.User{Username: "old", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &oldTime}
|
||||
recentUser := &model.User{Username: "recent", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &recentTime}
|
||||
for _, user := range []*model.User{admin, oldUser, recentUser} {
|
||||
if err := repos.User.Create(ctx, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, binding := range []model.TelegramBinding{
|
||||
{TelegramUserID: 9501, TelegramName: "@root", ChatID: 9501, UserID: admin.ID},
|
||||
{TelegramUserID: 9502, TelegramName: "@old", ChatID: 9502, UserID: oldUser.ID},
|
||||
{TelegramUserID: 9503, TelegramName: "@recent", ChatID: 9503, UserID: recentUser.ID},
|
||||
{TelegramUserID: 9504, TelegramName: "@ghost", ChatID: 9504, UserID: "missing-user"},
|
||||
} {
|
||||
row := binding
|
||||
if err := repos.DB.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9501"}`}
|
||||
msg := &TelegramMessage{From: TelegramUser{ID: 9501, Username: "root"}, Chat: TelegramChat{ID: 9501, Type: "private"}}
|
||||
|
||||
reply, err := bot.executeCommand(ctx, channel, msg, "/unbind_inactive 30")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "已解绑:<b>1</b>") || !strings.Contains(reply.Text, "old") {
|
||||
t.Fatalf("unexpected inactive unbind reply: %q", reply.Text)
|
||||
}
|
||||
if binding := bot.telegramBinding(ctx, 9502); binding != nil {
|
||||
t.Fatal("old user binding should be removed")
|
||||
}
|
||||
if binding := bot.telegramBinding(ctx, 9501); binding == nil {
|
||||
t.Fatal("admin binding should be skipped by inactive cleanup")
|
||||
}
|
||||
if binding := bot.telegramBinding(ctx, 9503); binding == nil {
|
||||
t.Fatal("recent user binding should remain")
|
||||
}
|
||||
|
||||
reply, err = bot.executeCommand(ctx, channel, msg, "/unbind_duplicates")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "已解绑:<b>1</b>") || !strings.Contains(reply.Text, "tg:9504") {
|
||||
t.Fatalf("unexpected duplicate cleanup reply: %q", reply.Text)
|
||||
}
|
||||
if binding := bot.telegramBinding(ctx, 9504); binding != nil {
|
||||
t.Fatal("invalid binding should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +449,8 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
|
||||
"<b>/capacity</b> / <b>/users</b> — 容量与用户管理(管理员)\n" +
|
||||
"<b>/gencode register|renew 天数 [有效天数]</b> — 生成兑换码(管理员)\n" +
|
||||
"<b>/renew_user 用户名 天数</b> / <b>/delete_user 用户名 confirm</b> — 续期/删除用户(管理员)\n" +
|
||||
"<b>/unbind 用户1 用户2</b> — 批量解绑 Telegram 绑定(管理员)\n" +
|
||||
"<b>/unbind_duplicates</b> / <b>/unbind_inactive 天数</b> — 清理重复/无效绑定或久未登录绑定(管理员)\n" +
|
||||
"<b>/antishare on play=3 login=3 warn=2</b> — 防共享策略(管理员)\n" +
|
||||
"<b>/cleanup on|off|run</b> — 删号规则开关/巡检(管理员)\n" +
|
||||
"<b>/cleanup_mode any|all|count 2</b> — 保号模式(管理员)\n" +
|
||||
|
||||
@@ -55,6 +55,9 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
|
||||
{Aliases: []string{"/gencode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdGenCode(ctx, msg, args), nil }},
|
||||
{Aliases: []string{"/renew_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }},
|
||||
{Aliases: []string{"/delete_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserDelete(ctx, args), nil }},
|
||||
{Aliases: []string{"/unbind"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbind(ctx, args), nil }},
|
||||
{Aliases: []string{"/unbind_duplicates"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbindDuplicates(ctx), nil }},
|
||||
{Aliases: []string{"/unbind_inactive"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbindInactive(ctx, args), nil }},
|
||||
{Aliases: []string{"/devicepolicy", "/policy"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDevicePolicy(ctx, args), nil }},
|
||||
{Aliases: []string{"/antishare"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdAntiShare(ctx, args), nil }},
|
||||
{Aliases: []string{"/cleanup"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanup(ctx, args), nil }},
|
||||
@@ -118,7 +121,7 @@ var telegramSupportedCommandSet = map[string]struct{}{
|
||||
"/account": {}, "/me": {}, "/signin": {}, "/checkin": {}, "/devices": {}, "/kick": {}, "/setname": {}, "/rename": {}, "/setpass": {}, "/passwd": {}, "/password": {},
|
||||
"/redeem": {}, "/redeem_register": {}, "/redeem_renew": {},
|
||||
"/register": {}, "/reg": {}, "/signup": {}, "/registration": {}, "/reg_switch": {}, "/openreg": {},
|
||||
"/capacity": {}, "/users": {}, "/gencode": {}, "/renew_user": {}, "/delete_user": {},
|
||||
"/capacity": {}, "/users": {}, "/gencode": {}, "/renew_user": {}, "/delete_user": {}, "/unbind": {}, "/unbind_duplicates": {}, "/unbind_inactive": {},
|
||||
"/devicepolicy": {}, "/policy": {}, "/antishare": {}, "/cleanup": {}, "/cleanup_mode": {}, "/cleanup_rule": {},
|
||||
"/ban": {}, "/unban": {}, "/status": {}, "/search": {}, "/downloads": {}, "/stats": {},
|
||||
}
|
||||
|
||||
@@ -847,6 +847,215 @@ func (s *TelegramBotService) cmdUserDelete(ctx context.Context, args []string) t
|
||||
return s.replyUserDelete(ctx, user.ID)
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) cmdUnbind(ctx context.Context, args []string) telegramCommandReply {
|
||||
targets := parseTelegramUnbindTargets(args)
|
||||
if len(targets) == 0 {
|
||||
return telegramCommandReply{Text: "用法:<code>/unbind 用户名1 用户名2</code>\n也支持逗号分隔,或使用 <code>tg:TelegramID</code> 按 Telegram ID 解绑。此命令只解绑 Bot,不删除媒体账号。"}
|
||||
}
|
||||
var removed int64
|
||||
var done []string
|
||||
var skipped []string
|
||||
var missing []string
|
||||
for _, target := range targets {
|
||||
if tgIDRaw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok {
|
||||
tgID, err := strconv.ParseInt(tgIDRaw, 10, 64)
|
||||
if err != nil || tgID == 0 {
|
||||
missing = append(missing, target)
|
||||
continue
|
||||
}
|
||||
n, err := s.deleteTelegramBindings(ctx, "telegram_user_id = ?", tgID)
|
||||
if err != nil {
|
||||
return telegramCommandReply{Text: "解绑失败:" + err.Error()}
|
||||
}
|
||||
if n == 0 {
|
||||
missing = append(missing, target)
|
||||
continue
|
||||
}
|
||||
removed += n
|
||||
done = append(done, target)
|
||||
continue
|
||||
}
|
||||
|
||||
user, _ := s.repo.User.FindByUsername(ctx, target)
|
||||
if user == nil {
|
||||
user, _ = s.repo.User.FindByID(ctx, target)
|
||||
}
|
||||
if user == nil {
|
||||
missing = append(missing, target)
|
||||
continue
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
skipped = append(skipped, user.Username+"(管理员)")
|
||||
continue
|
||||
}
|
||||
n, err := s.deleteTelegramBindings(ctx, "user_id = ?", user.ID)
|
||||
if err != nil {
|
||||
return telegramCommandReply{Text: "解绑失败:" + err.Error()}
|
||||
}
|
||||
if n == 0 {
|
||||
missing = append(missing, user.Username+"(未绑定)")
|
||||
continue
|
||||
}
|
||||
removed += n
|
||||
done = append(done, user.Username)
|
||||
}
|
||||
return formatUnbindResult("批量解绑完成", removed, done, skipped, missing)
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) cmdUnbindDuplicates(ctx context.Context) telegramCommandReply {
|
||||
if s == nil || s.repo == nil || s.repo.DB == nil {
|
||||
return telegramCommandReply{Text: "仓库不可用。"}
|
||||
}
|
||||
var bindings []model.TelegramBinding
|
||||
if err := s.repo.DB.WithContext(ctx).Order("updated_at desc, created_at desc").Find(&bindings).Error; err != nil {
|
||||
return telegramCommandReply{Text: "读取绑定失败:" + err.Error()}
|
||||
}
|
||||
seenTelegram := make(map[int64]string)
|
||||
seenUser := make(map[string]string)
|
||||
var removeIDs []string
|
||||
var removedLabels []string
|
||||
for _, binding := range bindings {
|
||||
remove := false
|
||||
if binding.UserID == "" || binding.TelegramUserID == 0 {
|
||||
remove = true
|
||||
} else if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user == nil {
|
||||
remove = true
|
||||
} else if _, ok := seenTelegram[binding.TelegramUserID]; ok {
|
||||
remove = true
|
||||
} else if _, ok := seenUser[binding.UserID]; ok {
|
||||
remove = true
|
||||
}
|
||||
if remove {
|
||||
removeIDs = append(removeIDs, binding.ID)
|
||||
removedLabels = append(removedLabels, fmt.Sprintf("tg:%d", binding.TelegramUserID))
|
||||
continue
|
||||
}
|
||||
seenTelegram[binding.TelegramUserID] = binding.ID
|
||||
seenUser[binding.UserID] = binding.ID
|
||||
}
|
||||
if len(removeIDs) == 0 {
|
||||
return telegramCommandReply{Text: "未发现重复或无效绑定。"}
|
||||
}
|
||||
n, err := s.deleteTelegramBindings(ctx, "id IN ?", removeIDs)
|
||||
if err != nil {
|
||||
return telegramCommandReply{Text: "清理失败:" + err.Error()}
|
||||
}
|
||||
return formatUnbindResult("重复/无效绑定清理完成", n, removedLabels, nil, nil)
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) cmdUnbindInactive(ctx context.Context, args []string) telegramCommandReply {
|
||||
if len(args) == 0 {
|
||||
return telegramCommandReply{Text: "用法:<code>/unbind_inactive 天数</code>\n例如 <code>/unbind_inactive 30</code> 会解绑 30 天未登录的普通用户 Bot 绑定,不删除账号。"}
|
||||
}
|
||||
days, err := strconv.Atoi(strings.TrimSpace(args[0]))
|
||||
if err != nil || days < 1 {
|
||||
return telegramCommandReply{Text: "天数必须是大于 0 的整数。"}
|
||||
}
|
||||
users, err := s.repo.User.List(ctx)
|
||||
if err != nil {
|
||||
return telegramCommandReply{Text: "读取用户失败:" + err.Error()}
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
var userIDs []string
|
||||
var done []string
|
||||
for _, user := range users {
|
||||
if user.Role == "admin" {
|
||||
continue
|
||||
}
|
||||
lastActive := user.CreatedAt
|
||||
if user.LastLoginAt != nil {
|
||||
lastActive = *user.LastLoginAt
|
||||
}
|
||||
if lastActive.IsZero() || lastActive.After(cutoff) {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
_ = s.repo.DB.WithContext(ctx).Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&count).Error
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, user.ID)
|
||||
done = append(done, user.Username)
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return telegramCommandReply{Text: fmt.Sprintf("未发现 %d 天未登录且已绑定 Bot 的普通用户。", days)}
|
||||
}
|
||||
n, err := s.deleteTelegramBindings(ctx, "user_id IN ?", userIDs)
|
||||
if err != nil {
|
||||
return telegramCommandReply{Text: "解绑失败:" + err.Error()}
|
||||
}
|
||||
return formatUnbindResult(fmt.Sprintf("已解绑 %d 天未登录用户", days), n, done, nil, nil)
|
||||
}
|
||||
|
||||
func parseTelegramUnbindTargets(args []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var targets []string
|
||||
for _, arg := range args {
|
||||
for _, part := range strings.FieldsFunc(arg, func(r rune) bool {
|
||||
return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\t'
|
||||
}) {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(part)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
targets = append(targets, part)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) deleteTelegramBindings(ctx context.Context, query string, args ...interface{}) (int64, error) {
|
||||
if s == nil || s.repo == nil || s.repo.DB == nil {
|
||||
return 0, nil
|
||||
}
|
||||
tx := s.repo.DB.WithContext(ctx).Unscoped().Where(query, args...).Delete(&model.TelegramBinding{})
|
||||
return tx.RowsAffected, tx.Error
|
||||
}
|
||||
|
||||
func formatUnbindResult(title string, removed int64, done, skipped, missing []string) telegramCommandReply {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<b>")
|
||||
sb.WriteString(title)
|
||||
sb.WriteString("</b>\n\n")
|
||||
sb.WriteString(fmt.Sprintf("已解绑:<b>%d</b> 条绑定", removed))
|
||||
if len(done) > 0 {
|
||||
sb.WriteString("\n目标:")
|
||||
sb.WriteString(formatShortList(done, 12))
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
sb.WriteString("\n跳过:")
|
||||
sb.WriteString(formatShortList(skipped, 8))
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sb.WriteString("\n未找到/未绑定:")
|
||||
sb.WriteString(formatShortList(missing, 8))
|
||||
}
|
||||
return telegramCommandReply{Text: sb.String()}
|
||||
}
|
||||
|
||||
func formatShortList(items []string, limit int) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
out := items
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
text := "<code>" + strings.Join(out, "</code>、<code>") + "</code>"
|
||||
if len(items) > limit {
|
||||
text += fmt.Sprintf(" 等 %d 项", len(items))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// protectReason returns a non-empty message when a user must not be
|
||||
// disabled/deleted (admins and the default admin are protected).
|
||||
func (s *TelegramBotService) protectReason(ctx context.Context, userID string) string {
|
||||
|
||||
Reference in New Issue
Block a user