|
|
@@ -32,6 +32,8 @@ export class TaskExecutor {
|
|
|
private accountCursor = 0
|
|
|
// 账号缓存
|
|
|
private accountCache: TgUser[] = []
|
|
|
+ // 本批次临时排除的账号(连接/功能异常等),避免在同一轮里反复选中导致任务整体失败
|
|
|
+ private accountExcludedInBatch: Set<string> = new Set()
|
|
|
|
|
|
constructor(private app: FastifyInstance) {
|
|
|
const ds = app.dataSource
|
|
|
@@ -58,6 +60,7 @@ export class TaskExecutor {
|
|
|
const concurrency = Math.min(10, Math.max(1, Number(task.threads ?? 1)))
|
|
|
this.currentAccountCacheTake = this.computeAccountCacheTake(task, concurrency)
|
|
|
await this.refreshAccountCache()
|
|
|
+ this.accountExcludedInBatch.clear()
|
|
|
|
|
|
await this.process(task)
|
|
|
await this.finalize(task.id)
|
|
|
@@ -130,6 +133,7 @@ export class TaskExecutor {
|
|
|
let tgUser: TgUser | null = null
|
|
|
const workerTgClient = new TgClientManager()
|
|
|
let accountUsageInRound = 0
|
|
|
+ // 仅用于 INVITE_TO_GROUP:缓存当前账号已加入的群实体,避免每条都重复解析/入群
|
|
|
let inviteGroupEntity: any | null = null
|
|
|
|
|
|
try {
|
|
|
@@ -155,6 +159,8 @@ export class TaskExecutor {
|
|
|
|
|
|
// tgUser 轮换逻辑
|
|
|
if (!tgUser || accountUsageInRound >= this.currentAccountLimit) {
|
|
|
+ // 换号前:若当前 tgUser 已加入群聊,则先退出群聊再断开
|
|
|
+ await this.safeLeaveInviteGroup(workerTgClient, inviteGroupEntity).catch(() => {})
|
|
|
await workerTgClient.disconnect()
|
|
|
tgUser = await this.pickAccount()
|
|
|
const sessionString = await this.ensureSessionString(tgUser)
|
|
|
@@ -163,12 +169,23 @@ export class TaskExecutor {
|
|
|
await workerTgClient.connect(sessionString)
|
|
|
} catch (error) {
|
|
|
const msg = error instanceof Error ? error.message : String(error)
|
|
|
+ // 会话失效:直接移除该账号
|
|
|
if (this.isSessionRevokedMessage(msg)) {
|
|
|
await this.handleSessionRevoked(tgUser)
|
|
|
tgUser = null
|
|
|
continue
|
|
|
}
|
|
|
- throw error
|
|
|
+ // 连接失败:不让错误冒泡导致任务被调度器判死;先排除此账号,换号继续
|
|
|
+ this.app.log.warn(
|
|
|
+ { taskId, sender: tgUser.id, err: msg },
|
|
|
+ 'TelegramClient connect failed, rotate account'
|
|
|
+ )
|
|
|
+ this.accountExcludedInBatch.add(tgUser.id)
|
|
|
+ tgUser = null
|
|
|
+ accountUsageInRound = 0
|
|
|
+ inviteGroupEntity = null
|
|
|
+ await workerTgClient.disconnect().catch(() => {})
|
|
|
+ continue
|
|
|
}
|
|
|
|
|
|
accountUsageInRound = 0
|
|
|
@@ -181,15 +198,38 @@ export class TaskExecutor {
|
|
|
await this.sleep(delaySeconds * 1000)
|
|
|
|
|
|
try {
|
|
|
- await this.processTaskItem(task, taskItem, tgUser, workerTgClient)
|
|
|
+ const result = await this.processTaskItem(task, taskItem, tgUser, workerTgClient, inviteGroupEntity)
|
|
|
+ // 更新缓存(仅 INVITE_TO_GROUP 会返回)
|
|
|
+ if (result?.inviteGroupEntity !== undefined) {
|
|
|
+ inviteGroupEntity = result.inviteGroupEntity
|
|
|
+ }
|
|
|
+ // 邀请失败:按你的预期,立即换一个 tgUser 继续流程
|
|
|
+ if (result?.rotateAccount) {
|
|
|
+ this.app.log.info(
|
|
|
+ { taskId, itemId: taskItem.id, sender: tgUser.id, reason: result.reason ?? 'rotate' },
|
|
|
+ 'rotate account due to task item failure'
|
|
|
+ )
|
|
|
+ this.accountExcludedInBatch.add(tgUser.id)
|
|
|
+ // 换号前:退出群聊(如果已加入)
|
|
|
+ await this.safeLeaveInviteGroup(workerTgClient, inviteGroupEntity).catch(() => {})
|
|
|
+ await workerTgClient.disconnect().catch(() => {})
|
|
|
+ tgUser = null
|
|
|
+ accountUsageInRound = 0
|
|
|
+ inviteGroupEntity = null
|
|
|
+ }
|
|
|
} catch (error) {
|
|
|
+ // 兜底:理论上 processTaskItem 不应抛错;如果抛错,也不要影响整任务
|
|
|
const msg = error instanceof Error ? error.message : '未知错误'
|
|
|
+ this.app.log.error({ taskId, itemId: taskItem.id, sender: tgUser?.id, err: msg }, 'processTaskItem crashed')
|
|
|
if (tgUser && this.isSessionRevokedMessage(msg)) {
|
|
|
await this.handleSessionRevoked(tgUser)
|
|
|
- await workerTgClient.disconnect()
|
|
|
- tgUser = null
|
|
|
- accountUsageInRound = 0
|
|
|
+ } else if (tgUser) {
|
|
|
+ this.accountExcludedInBatch.add(tgUser.id)
|
|
|
}
|
|
|
+ await workerTgClient.disconnect().catch(() => {})
|
|
|
+ tgUser = null
|
|
|
+ accountUsageInRound = 0
|
|
|
+ inviteGroupEntity = null
|
|
|
} finally {
|
|
|
accountUsageInRound++
|
|
|
if (tgUser) {
|
|
|
@@ -205,6 +245,8 @@ export class TaskExecutor {
|
|
|
}
|
|
|
}
|
|
|
} finally {
|
|
|
+ // worker 结束前:尽量退出群聊,避免账号一直挂在群里
|
|
|
+ await this.safeLeaveInviteGroup(workerTgClient, inviteGroupEntity).catch(() => {})
|
|
|
await workerTgClient.disconnect()
|
|
|
}
|
|
|
}
|
|
|
@@ -254,10 +296,11 @@ export class TaskExecutor {
|
|
|
task: Task,
|
|
|
item: TaskItem,
|
|
|
sender: TgUser,
|
|
|
- workerTgClient: TgClientManager
|
|
|
- ): Promise<void> {
|
|
|
+ workerTgClient: TgClientManager,
|
|
|
+ inviteGroupEntity: any | null
|
|
|
+ ): Promise<{ rotateAccount?: boolean; reason?: string; inviteGroupEntity?: any | null } | void> {
|
|
|
if (task.type === TaskType.INVITE_TO_GROUP) {
|
|
|
- return await this.processInviteToGroup(task, item, sender, workerTgClient)
|
|
|
+ return await this.processInviteToGroup(task, item, sender, workerTgClient, inviteGroupEntity)
|
|
|
}
|
|
|
|
|
|
return await this.processSendMessage(task, item, sender, workerTgClient)
|
|
|
@@ -268,7 +311,7 @@ export class TaskExecutor {
|
|
|
item: TaskItem,
|
|
|
sender: TgUser,
|
|
|
workerTgClient: TgClientManager
|
|
|
- ): Promise<void> {
|
|
|
+ ): Promise<{ rotateAccount?: boolean; reason?: string } | void> {
|
|
|
const message = String(task.payload?.message ?? '').trim()
|
|
|
if (!message) {
|
|
|
await this.taskItemRepo.update(item.id, {
|
|
|
@@ -326,7 +369,8 @@ export class TaskExecutor {
|
|
|
await this.taskRepo.increment({ id: task.id }, 'processed', 1)
|
|
|
|
|
|
this.app.log.warn(`❌ 发送失败 taskId=${task.id}, itemId=${item.id}, sender=${sender.id}, error: ${msg}`)
|
|
|
- throw error
|
|
|
+ // SEND_MESSAGE 默认不强制换号(避免快速耗尽账号池);如需也换号,可在此处返回 rotateAccount: true
|
|
|
+ return { rotateAccount: false, reason: msg }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -334,8 +378,9 @@ export class TaskExecutor {
|
|
|
task: Task,
|
|
|
item: TaskItem,
|
|
|
sender: TgUser,
|
|
|
- workerTgClient: TgClientManager
|
|
|
- ): Promise<void> {
|
|
|
+ workerTgClient: TgClientManager,
|
|
|
+ inviteGroupEntity: any | null
|
|
|
+ ): Promise<{ rotateAccount?: boolean; reason?: string; inviteGroupEntity?: any | null } | void> {
|
|
|
try {
|
|
|
const inviteLink = String(task.payload?.inviteLink ?? '').trim()
|
|
|
if (!inviteLink) {
|
|
|
@@ -357,13 +402,21 @@ export class TaskExecutor {
|
|
|
throw new Error('TelegramClient 未连接')
|
|
|
}
|
|
|
|
|
|
- // tgUser 加入群组,获取群组实体
|
|
|
- const inviteGroupEntity = await workerTgClient.resolveGroupEntityByInviteLink(inviteLink)
|
|
|
- if (!inviteGroupEntity) {
|
|
|
+ // tgUser 加入群组,获取群组实体(每个账号缓存一次)
|
|
|
+ let groupEntity = inviteGroupEntity
|
|
|
+ if (!groupEntity) {
|
|
|
+ groupEntity = await workerTgClient.resolveGroupEntityByInviteLink(inviteLink)
|
|
|
+ }
|
|
|
+ if (!groupEntity) {
|
|
|
throw new Error('群拉人任务:未获取到群组实体(inviteGroupEntity 为空)')
|
|
|
}
|
|
|
+ const chatId = groupEntity.chatId ?? groupEntity.id
|
|
|
+ const accessHash = groupEntity.accessHash
|
|
|
+ if (chatId === undefined || chatId === null || accessHash === undefined || accessHash === null) {
|
|
|
+ throw new Error('群拉人任务:群组实体缺少 id/chatId/accessHash(请检查 resolveGroupEntityByInviteLink 返回值)')
|
|
|
+ }
|
|
|
|
|
|
- const inputChannel = await workerTgClient.getInputChannel(inviteGroupEntity.chatId, inviteGroupEntity.accessHash)
|
|
|
+ const inputChannel = await workerTgClient.getInputChannel(chatId, accessHash)
|
|
|
await workerTgClient.inviteMembersToChannelGroup(inputChannel, [targetUser])
|
|
|
|
|
|
await this.taskItemRepo.update(item.id, {
|
|
|
@@ -378,6 +431,7 @@ export class TaskExecutor {
|
|
|
await this.taskRepo.increment({ id: task.id }, 'success', 1)
|
|
|
|
|
|
this.app.log.info(`✅ 邀请成功 taskId=${task.id}, itemId=${item.id}, sender=${sender.id}`)
|
|
|
+ return { rotateAccount: false, inviteGroupEntity: groupEntity }
|
|
|
} catch (error) {
|
|
|
const msg = error instanceof Error ? error.message : '未知错误'
|
|
|
|
|
|
@@ -392,7 +446,7 @@ export class TaskExecutor {
|
|
|
await this.taskRepo.increment({ id: task.id }, 'processed', 1)
|
|
|
await this.taskRepo.increment({ id: task.id }, 'success', 1)
|
|
|
this.app.log.info(`ℹ️ 成员已在群组中 taskId=${task.id}, itemId=${item.id}, target=${item.target}`)
|
|
|
- return
|
|
|
+ return { rotateAccount: false }
|
|
|
}
|
|
|
|
|
|
await this.taskItemRepo.update(item.id, {
|
|
|
@@ -406,7 +460,26 @@ export class TaskExecutor {
|
|
|
await this.taskRepo.increment({ id: task.id }, 'processed', 1)
|
|
|
|
|
|
this.app.log.warn(`❌ 邀请失败 taskId=${task.id}, itemId=${item.id}, sender=${sender.id}, error: ${msg}`)
|
|
|
- throw error
|
|
|
+ // INVITE_TO_GROUP:按需求,失败就换号继续(避免单号被冻结/受限导致整体停滞)
|
|
|
+ return { rotateAccount: true, reason: msg }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 换号/断开前:让当前账号退出已加入的群聊
|
|
|
+ * - 仅对 INVITE_TO_GROUP 有意义;其它任务 inviteGroupEntity 为空会直接跳过
|
|
|
+ * - 不抛错:避免退群失败影响整体任务流程
|
|
|
+ */
|
|
|
+ private async safeLeaveInviteGroup(workerTgClient: TgClientManager, inviteGroupEntity: any | null): Promise<void> {
|
|
|
+ if (!inviteGroupEntity) return
|
|
|
+ const chatId = inviteGroupEntity.chatId ?? inviteGroupEntity.id
|
|
|
+ const accessHash = inviteGroupEntity.accessHash
|
|
|
+ if (chatId === undefined || chatId === null || accessHash === undefined || accessHash === null) return
|
|
|
+ try {
|
|
|
+ const inputChannel = await workerTgClient.getInputChannel(chatId, accessHash)
|
|
|
+ await workerTgClient.leaveGroup(inputChannel)
|
|
|
+ } catch {
|
|
|
+ // 忽略退群异常(可能已不在群、权限问题等)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -494,19 +567,39 @@ export class TaskExecutor {
|
|
|
}
|
|
|
|
|
|
const total = this.accountCache.length
|
|
|
- for (let i = 0; i < total; i++) {
|
|
|
- const index = (this.accountCursor + i) % total
|
|
|
- const account = this.accountCache[index]
|
|
|
- const used = this.accountUsageInBatch.get(account.id) ?? 0
|
|
|
- if (used < this.currentAccountLimit) {
|
|
|
- this.accountCursor = (index + 1) % total
|
|
|
- return account
|
|
|
+ const tryPick = (): TgUser | null => {
|
|
|
+ for (let i = 0; i < total; i++) {
|
|
|
+ const index = (this.accountCursor + i) % total
|
|
|
+ const account = this.accountCache[index]
|
|
|
+ if (this.accountExcludedInBatch.has(account.id)) continue
|
|
|
+ const used = this.accountUsageInBatch.get(account.id) ?? 0
|
|
|
+ if (used < this.currentAccountLimit) {
|
|
|
+ this.accountCursor = (index + 1) % total
|
|
|
+ return account
|
|
|
+ }
|
|
|
}
|
|
|
+ return null
|
|
|
}
|
|
|
|
|
|
+ const picked1 = tryPick()
|
|
|
+ if (picked1) return picked1
|
|
|
+
|
|
|
this.app.log.info('所有 tgUser 均已达到当前批次上限,重置计数后重新轮询')
|
|
|
this.accountUsageInBatch.clear()
|
|
|
this.accountCursor = 0
|
|
|
+
|
|
|
+ const picked2 = tryPick()
|
|
|
+ if (picked2) return picked2
|
|
|
+
|
|
|
+ // 如果全部被排除,说明这一批账号都异常/受限:清空排除集再尝试一次
|
|
|
+ if (this.accountExcludedInBatch.size > 0) {
|
|
|
+ this.app.log.warn('本批次所有 tgUser 均被排除,清空排除列表后重试')
|
|
|
+ this.accountExcludedInBatch.clear()
|
|
|
+ const picked3 = tryPick()
|
|
|
+ if (picked3) return picked3
|
|
|
+ }
|
|
|
+
|
|
|
+ // 兜底:返回第一个,避免直接抛错导致任务中断(但大概率会在连接失败后再次被排除并轮换)
|
|
|
return this.accountCache[0]
|
|
|
}
|
|
|
|