mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
Fix bot binding and terminal device limits
This commit is contained in:
@@ -798,7 +798,7 @@ WHERE deleted_at IS NULL
|
||||
SELECT id
|
||||
FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
|
||||
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC, created_at DESC, id DESC) AS rn
|
||||
FROM telegram_bindings
|
||||
WHERE deleted_at IS NULL
|
||||
) AS ranked_bindings
|
||||
|
||||
@@ -43,6 +43,13 @@ func TestEnforceTelegramBindingOneToOneCleansDuplicatesAndAddsIndex(t *testing.T
|
||||
if count != 1 {
|
||||
t.Fatalf("active bindings for user-1 = %d, want 1", count)
|
||||
}
|
||||
var kept model.TelegramBinding
|
||||
if err := db.First(&kept, "user_id = ?", "user-1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kept.TelegramUserID != 10002 {
|
||||
t.Fatalf("kept telegram binding = %d, want newest 10002", kept.TelegramUserID)
|
||||
}
|
||||
if err := db.Create(&model.TelegramBinding{TelegramUserID: 10003, ChatID: 10003, UserID: "user-1"}).Error; err == nil {
|
||||
t.Fatal("expected unique index to reject another active binding for the same user")
|
||||
}
|
||||
|
||||
@@ -66,10 +66,10 @@ func (s *SignIn) BeforeCreate(_ *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDevice 记录一个用户在某台设备/客户端上的会话指纹,用于设备管控:
|
||||
// - 登录设备数:某用户名下 UserDevice 行数(近期活跃)。
|
||||
// - 并发播放数:LastPlayAt 在并发窗口内的 UserDevice 行数。
|
||||
// - 设备指纹:首次见到的 Fingerprint;后续同一 DeviceID 上报不同指纹则告警。
|
||||
// UserDevice 记录一个用户在某台终端设备上的登录渠道,用于设备管控:
|
||||
// - 登录终端数:按 Fingerprint 去重后的近期活跃终端数。
|
||||
// - 并发播放数:按 Fingerprint 去重后的播放终端数。
|
||||
// - 设备指纹:同一终端不同 App 共用 Fingerprint,Client 只作为渠道标签。
|
||||
// - 观看时长:结合 PlaybackHistory 统计随机窗口内的观看时长。
|
||||
type UserDevice struct {
|
||||
Base
|
||||
|
||||
@@ -128,23 +128,24 @@ func (r *UserDeviceRepository) ListByUser(ctx context.Context, userID string) ([
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// CountActiveClients counts distinct logged-in devices for a user that were
|
||||
// seen on or after `since` (used for the "max logged-in clients" rule).
|
||||
// CountActiveClients counts distinct terminal devices for a user that were
|
||||
// seen on or after `since`. Multiple apps on the same terminal share the same
|
||||
// fingerprint and count as one terminal; rows remain as login channels.
|
||||
func (r *UserDeviceRepository) CountActiveClients(ctx context.Context, userID string, since time.Time) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&model.UserDevice{}).
|
||||
Where("user_id = ? AND last_seen_at >= ? AND kicked = ?", userID, since, false).
|
||||
Count(&n).Error
|
||||
Select("COUNT(DISTINCT COALESCE(NULLIF(fingerprint, ''), device_id))").Scan(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CountConcurrentPlaying counts devices for a user whose last playback ping was
|
||||
// on or after `since` (used for the "max concurrent playback" rule).
|
||||
// CountConcurrentPlaying counts terminal devices for a user whose last playback
|
||||
// ping was on or after `since` (used for the max concurrent playback rule).
|
||||
func (r *UserDeviceRepository) CountConcurrentPlaying(ctx context.Context, userID string, since time.Time) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&model.UserDevice{}).
|
||||
Where("user_id = ? AND last_play_at IS NOT NULL AND last_play_at >= ?", userID, since).
|
||||
Count(&n).Error
|
||||
Select("COUNT(DISTINCT COALESCE(NULLIF(fingerprint, ''), device_id))").Scan(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ func TestFingerprintStability(t *testing.T) {
|
||||
if a != b {
|
||||
t.Fatalf("fingerprint should be case/space-insensitive: %s != %s", a, b)
|
||||
}
|
||||
if a != fingerprint("Emby", "iPhone") {
|
||||
t.Fatal("different apps on the same terminal must share one fingerprint")
|
||||
}
|
||||
if a == fingerprint("Infuse", "iPad") {
|
||||
t.Fatal("different device names must yield different fingerprints")
|
||||
}
|
||||
@@ -239,6 +242,92 @@ func TestDeviceKickAndConcurrency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, _ := newBotTestService(t)
|
||||
dev := NewDeviceService(zap.NewNop(), repos)
|
||||
u := &model.User{Username: "device-user", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
if err := repos.User.Create(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Setting.Set(ctx, SettingAntiShareEnabled, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Setting.Set(ctx, SettingMaxLoggedClients, "3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, login := range []struct {
|
||||
id string
|
||||
name string
|
||||
client string
|
||||
}{
|
||||
{id: "phone-infuse", name: "iPhone", client: "Infuse"},
|
||||
{id: "phone-emby", name: " iPhone ", client: "Emby"},
|
||||
{id: "phone-jellyfin", name: "IPHONE", client: "Jellyfin"},
|
||||
} {
|
||||
dev.RecordLogin(ctx, u.ID, login.id, login.name, login.client, "1.2.3.4")
|
||||
}
|
||||
count, err := repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("same terminal through multiple apps should count as 1, got %d", count)
|
||||
}
|
||||
got, _ := repos.User.FindByID(ctx, u.ID)
|
||||
if !got.IsActive {
|
||||
t.Fatal("same terminal through multiple apps must not disable the account")
|
||||
}
|
||||
|
||||
dev.RecordLogin(ctx, u.ID, "tablet", "iPad", "Infuse", "1.2.3.4")
|
||||
dev.RecordLogin(ctx, u.ID, "pc", "Windows PC", "Browser", "1.2.3.4")
|
||||
count, err = repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("three distinct terminal devices should count as 3, got %d", count)
|
||||
}
|
||||
got, _ = repos.User.FindByID(ctx, u.ID)
|
||||
if !got.IsActive {
|
||||
t.Fatal("device limit is inclusive; 3 of 3 terminals should stay active")
|
||||
}
|
||||
|
||||
dev.RecordLogin(ctx, u.ID, "tv", "Apple TV", "Emby", "1.2.3.4")
|
||||
got, _ = repos.User.FindByID(ctx, u.ID)
|
||||
if got.IsActive {
|
||||
t.Fatal("fourth distinct terminal should disable the account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentPlaybackDeduplicatesAppsOnSameDevice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, _ := newBotTestService(t)
|
||||
u := &model.User{Username: "play-user", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
if err := repos.User.Create(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
fp := fingerprint("Infuse", "Living Room TV")
|
||||
for _, row := range []model.UserDevice{
|
||||
{UserID: u.ID, DeviceID: "tv-emby", DeviceName: "Living Room TV", Client: "Emby", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
|
||||
{UserID: u.ID, DeviceID: "tv-jellyfin", DeviceName: "living room tv", Client: "Jellyfin", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
|
||||
{UserID: u.ID, DeviceID: "phone", DeviceName: "iPhone", Client: "Infuse", Fingerprint: fingerprint("Infuse", "iPhone"), FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
|
||||
} {
|
||||
if err := repos.UserDevice.Create(ctx, &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
count, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("same terminal playback through multiple apps should count as 1 terminal, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedAdminNeverViolated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, _ := newBotTestService(t)
|
||||
|
||||
@@ -27,10 +27,10 @@ const (
|
||||
SettingOpenRegLimit = "telegram.openreg_limit" // 本轮开注名额上限(0=不限)
|
||||
SettingOpenRegUsed = "telegram.openreg_used" // 本轮已用名额
|
||||
|
||||
// 防共享(警告制:并发播放 / 登录客户端 / 设备指纹)。
|
||||
// 防共享(警告制:并发播放 / 登录终端 / 设备指纹)。
|
||||
SettingAntiShareEnabled = "device.antishare_enabled" // 总开关(默认关)
|
||||
SettingMaxConcurrentPlay = "device.max_concurrent_play" // 最大并发播放设备
|
||||
SettingMaxLoggedClients = "device.max_logged_clients" // 最大同时登录客户端
|
||||
SettingMaxLoggedClients = "device.max_logged_clients" // 最大同时登录终端
|
||||
SettingWarnThreshold = "device.warn_threshold" // 警告几次后禁用
|
||||
SettingPlayWindowSeconds = "device.play_window_seconds" // 并发播放判定窗口(秒)
|
||||
SettingClientActiveDays = "device.client_active_days" // 登录设备活跃天数窗口
|
||||
|
||||
@@ -45,11 +45,15 @@ func (s *DeviceService) SetNotifier(fn func(ctx context.Context, userID, text st
|
||||
s.notifyUser = fn
|
||||
}
|
||||
|
||||
// fingerprint derives a stable short hash from the client + device name. A
|
||||
// changed fingerprint for the same device id signals the session was cloned
|
||||
// onto different hardware/software.
|
||||
// fingerprint derives a stable terminal hash from the device name. Client/app
|
||||
// names are deliberately ignored so one phone/TV/PC using multiple apps is
|
||||
// still counted as one terminal device; Client remains a login channel label.
|
||||
func fingerprint(client, deviceName string) string {
|
||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(client)) + "|" + strings.ToLower(strings.TrimSpace(deviceName))))
|
||||
terminal := strings.ToLower(strings.TrimSpace(deviceName))
|
||||
if terminal == "" {
|
||||
terminal = strings.ToLower(strings.TrimSpace(client))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(terminal))
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
@@ -59,9 +63,9 @@ func (s *DeviceService) isProtected(ctx context.Context, u *model.User) bool {
|
||||
return UserIsProtectedAccount(ctx, s.repo, u)
|
||||
}
|
||||
|
||||
// RecordLogin records (or refreshes) a device session at authentication time
|
||||
// and runs the logged-in-client + fingerprint anti-share checks. It is safe to
|
||||
// call on every Emby/Jellyfin AuthenticateByName request.
|
||||
// RecordLogin records (or refreshes) a login channel at authentication time and
|
||||
// runs the terminal-device + fingerprint anti-share checks. It is safe to call
|
||||
// on every Emby/Jellyfin AuthenticateByName request.
|
||||
func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, deviceName, client, ip string) {
|
||||
if userID == "" {
|
||||
return
|
||||
@@ -110,7 +114,7 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic
|
||||
}
|
||||
since := now.Add(-time.Duration(cfg.ClientActiveDays) * 24 * time.Hour)
|
||||
if n, err := s.repo.UserDevice.CountActiveClients(ctx, userID, since); err == nil && int(n) > cfg.MaxLoggedClients {
|
||||
s.disableForPolicy(ctx, userID, fmt.Sprintf("同时登录客户端 %d 台,超过上限 %d 台", n, cfg.MaxLoggedClients))
|
||||
s.disableForPolicy(ctx, userID, fmt.Sprintf("同时登录终端设备 %d 台,超过上限 %d 台", n, cfg.MaxLoggedClients))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +142,9 @@ func (s *DeviceService) RecordPlayback(ctx context.Context, userID, deviceID, de
|
||||
existing.LastPlayAt = &now
|
||||
_ = s.repo.UserDevice.Create(ctx, existing)
|
||||
} else {
|
||||
existing.DeviceName = deviceName
|
||||
existing.Client = client
|
||||
existing.Fingerprint = fingerprint(client, deviceName)
|
||||
existing.LastSeenAt = now
|
||||
existing.LastPlayAt = &now
|
||||
_ = s.repo.UserDevice.Save(ctx, existing)
|
||||
@@ -149,7 +156,7 @@ func (s *DeviceService) RecordPlayback(ctx context.Context, userID, deviceID, de
|
||||
}
|
||||
since := now.Add(-time.Duration(cfg.PlayWindowSeconds) * time.Second)
|
||||
if n, err := s.repo.UserDevice.CountConcurrentPlaying(ctx, userID, since); err == nil && int(n) > cfg.MaxConcurrentPlay {
|
||||
s.disableForPolicy(ctx, userID, fmt.Sprintf("同时播放设备 %d 台,超过上限 %d 台", n, cfg.MaxConcurrentPlay))
|
||||
s.disableForPolicy(ctx, userID, fmt.Sprintf("同时播放终端设备 %d 台,超过上限 %d 台", n, cfg.MaxConcurrentPlay))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1369,10 +1369,8 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel
|
||||
var existing model.TelegramBinding
|
||||
err := tx.Where("telegram_user_id = ?", telegramUserID).First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.UserID != userID {
|
||||
if err := s.ensureTelegramAccountBindingAvailableTx(ctx, tx, userID, telegramUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&existing).Updates(map[string]any{
|
||||
"telegram_name": name,
|
||||
@@ -1391,7 +1389,7 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel
|
||||
if err := tx.Unscoped().Where("telegram_user_id = ?", telegramUserID).Delete(&model.TelegramBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureTelegramAccountBindingAvailableTx(ctx, tx, userID, telegramUserID); err != nil {
|
||||
if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.Create(&model.TelegramBinding{
|
||||
@@ -1430,29 +1428,10 @@ func telegramPrivateChatIDFromBinding(binding model.TelegramBinding) int64 {
|
||||
return binding.TelegramUserID
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) ensureTelegramAccountBindingAvailable(ctx context.Context, userID string, telegramUserID int64) error {
|
||||
return s.ensureTelegramAccountBindingAvailableTx(ctx, s.repo.DB.WithContext(ctx), userID, telegramUserID)
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) ensureTelegramAccountBindingAvailableTx(ctx context.Context, tx *gorm.DB, userID string, telegramUserID int64) error {
|
||||
var bound model.TelegramBinding
|
||||
err := tx.WithContext(ctx).
|
||||
func (s *TelegramBotService) replaceTelegramAccountBindingTx(ctx context.Context, tx *gorm.DB, userID string, telegramUserID int64) error {
|
||||
return tx.WithContext(ctx).Unscoped().
|
||||
Where("user_id = ? AND telegram_user_id <> ?", userID, telegramUserID).
|
||||
First(&bound).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.WithContext(ctx).Where("id = ?", bound.UserID).First(&user).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
_ = tx.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", bound.ID).Error
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return errTelegramAccountAlreadyBound
|
||||
Delete(&model.TelegramBinding{}).Error
|
||||
}
|
||||
|
||||
func telegramBindingUniqueErr(err error) bool {
|
||||
|
||||
@@ -302,7 +302,7 @@ func TestTelegramStartClearsStaleUserBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramStartRejectsAccountAlreadyBoundToAnotherTelegram(t *testing.T) {
|
||||
func TestTelegramStartReplacesAccountBindingFromAnotherTelegram(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
repos, auth, _, _ := newAuthTestServices(t)
|
||||
user, _, err := auth.Register(ctx, "viewer", "secret-pass")
|
||||
@@ -332,8 +332,8 @@ func TestTelegramStartRejectsAccountAlreadyBoundToAnotherTelegram(t *testing.T)
|
||||
}
|
||||
reply := bot.cmdStart(ctx, msg, []string{"viewer", "secret-pass"})
|
||||
|
||||
if !strings.Contains(reply.Text, "已绑定其他 Telegram") {
|
||||
t.Fatalf("expected already-bound rejection, got %q", reply.Text)
|
||||
if !strings.Contains(reply.Text, "绑定成功") {
|
||||
t.Fatalf("expected new telegram account to replace old binding, got %q", reply.Text)
|
||||
}
|
||||
var accountBindings int64
|
||||
if err := repos.DB.Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&accountBindings).Error; err != nil {
|
||||
@@ -342,8 +342,11 @@ func TestTelegramStartRejectsAccountAlreadyBoundToAnotherTelegram(t *testing.T)
|
||||
if accountBindings != 1 {
|
||||
t.Fatalf("account should keep exactly one telegram binding, got %d", accountBindings)
|
||||
}
|
||||
if binding := bot.telegramBinding(ctx, 20002); binding != nil {
|
||||
t.Fatal("second telegram account must not be bound")
|
||||
if binding := bot.telegramBinding(ctx, 20002); binding == nil || binding.UserID != user.ID {
|
||||
t.Fatalf("new telegram account should be bound to user, got %#v", binding)
|
||||
}
|
||||
if binding := bot.telegramBinding(ctx, 20001); binding != nil {
|
||||
t.Fatalf("old telegram binding should be removed, got %#v", binding)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1125,7 +1125,7 @@ func (s *TelegramBotService) protectReason(ctx context.Context, userID string) s
|
||||
func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply {
|
||||
cfg := loadBotConfig(ctx, s.repo)
|
||||
text := fmt.Sprintf(
|
||||
"<b>设备策略</b>\n\n① 防共享:<b>%s</b>\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:<b>%s</b>\n 保号模式:%s;启用规则 %d 条。\n\n<b>命令:</b>\n<code>/antishare on play=3 login=3 warn=2</code>\n<code>/cleanup run</code> 预览候选\n<code>/cleanup run confirm</code> 确认清理\n<code>/cleanup on|off</code>\n<code>/cleanup_rule list|add|edit|修改|del|enable|disable</code>\n\n策略默认关闭;清理前会先预览候选;满足任意一条保号规则即保留;管理员/受保护账号永不自动处理。",
|
||||
"<b>设备策略</b>\n\n① 防共享:<b>%s</b>\n 并发播放终端上限 %d / 登录终端上限 %d;同一终端多个 App 只算 1 台,App 作为登录渠道记录。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:<b>%s</b>\n 保号模式:%s;启用规则 %d 条。\n\n<b>命令:</b>\n<code>/antishare on play=3 login=3 warn=2</code>\n<code>/cleanup run</code> 预览候选\n<code>/cleanup run confirm</code> 确认清理\n<code>/cleanup on|off</code>\n<code>/cleanup_rule list|add|edit|修改|del|enable|disable</code>\n\n策略默认关闭;清理前会先预览候选;满足任意一条保号规则即保留;管理员/受保护账号永不自动处理。",
|
||||
onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold,
|
||||
onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), countEnabledCleanupRules(cfg.AccountCleanupRules))
|
||||
return telegramCommandReply{
|
||||
@@ -1156,7 +1156,7 @@ func (s *TelegramBotService) cmdAntiShare(ctx context.Context, args []string) te
|
||||
}
|
||||
enabled, ok := parseCommandBool(args[0])
|
||||
if !ok {
|
||||
return telegramCommandReply{Text: "用法:<code>/antishare on|off [play=3] [login=3] [warn=2]</code>"}
|
||||
return telegramCommandReply{Text: "用法:<code>/antishare on|off [play=3] [login=3] [warn=2]</code>,login 表示登录终端设备上限,同一终端多个 App 不重复计数。"}
|
||||
}
|
||||
if err := s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(enabled)); err != nil {
|
||||
return telegramCommandReply{Text: "更新失败:" + err.Error()}
|
||||
|
||||
Reference in New Issue
Block a user