|
|
@@ -1,7 +1,8 @@
|
|
|
import { FastifyInstance } from 'fastify'
|
|
|
+import { DataSource } from 'typeorm'
|
|
|
import { Repository } from 'typeorm'
|
|
|
import { TelegramClient, Api } from 'telegram'
|
|
|
-import { Task, TaskStatus } from '../entities/task.entity'
|
|
|
+import { Task, TaskStatus, TaskType } from '../entities/task.entity'
|
|
|
import { TaskItem, TaskItemStatus } from '../entities/task-item.entity'
|
|
|
import { TgUser } from '../entities/tg-user.entity'
|
|
|
import { TgUserService } from '../services/tg-user.service'
|
|
|
@@ -9,6 +10,7 @@ import { TgClientService } from '../services/tgClient.service'
|
|
|
import { buildStringSessionByDcIdAndAuthKey } from '../utils/tg.util'
|
|
|
|
|
|
export class TaskExecutor {
|
|
|
+ private ds: DataSource
|
|
|
private taskRepo: Repository<Task>
|
|
|
private taskItemRepo: Repository<TaskItem>
|
|
|
private senderRepo: Repository<TgUser>
|
|
|
@@ -22,6 +24,7 @@ export class TaskExecutor {
|
|
|
|
|
|
constructor(private app: FastifyInstance) {
|
|
|
const ds = app.dataSource
|
|
|
+ this.ds = ds
|
|
|
this.senderService = new TgUserService(app)
|
|
|
this.taskRepo = ds.getRepository(Task)
|
|
|
this.taskItemRepo = ds.getRepository(TaskItem)
|
|
|
@@ -37,7 +40,7 @@ export class TaskExecutor {
|
|
|
|
|
|
// 初始化 sender 配置
|
|
|
this.currentSenderSendLimit =
|
|
|
- task.sendLimit && Number(task.sendLimit) > 0 ? Number(task.sendLimit) : this.defaultSenderSendLimit
|
|
|
+ task.accountLimit && Number(task.accountLimit) > 0 ? Number(task.accountLimit) : this.defaultSenderSendLimit
|
|
|
this.senderUsageInBatch.clear()
|
|
|
this.senderCursor = 0
|
|
|
await this.refreshSenderCache()
|
|
|
@@ -70,7 +73,7 @@ export class TaskExecutor {
|
|
|
* 核心发送逻辑(并发 worker)
|
|
|
*/
|
|
|
private async process(task: Task): Promise<void> {
|
|
|
- const concurrency = Math.max(1, task.concurrentCount)
|
|
|
+ const concurrency = Math.min(10, Math.max(1, Number(task.threads ?? 1)))
|
|
|
|
|
|
const workers: Promise<void>[] = []
|
|
|
|
|
|
@@ -88,7 +91,7 @@ export class TaskExecutor {
|
|
|
let sender: TgUser | null = null
|
|
|
const workerTgClient = new TgClientService()
|
|
|
let senderSentInRound = 0
|
|
|
- const sendIntervalMs = await this.getSendInterval(taskId)
|
|
|
+ let inviteGroupEntity: any | null = null
|
|
|
|
|
|
try {
|
|
|
while (true) {
|
|
|
@@ -130,6 +133,7 @@ export class TaskExecutor {
|
|
|
}
|
|
|
|
|
|
senderSentInRound = 0
|
|
|
+ inviteGroupEntity = null
|
|
|
|
|
|
// 获取当前账号信息并延迟
|
|
|
const me = await workerTgClient
|
|
|
@@ -144,10 +148,19 @@ export class TaskExecutor {
|
|
|
},延迟 ${delaySeconds}s 后开始发送`
|
|
|
)
|
|
|
await this.sleep(delaySeconds * 1000)
|
|
|
+
|
|
|
+ // 邀请任务:每次换号后,确保已加入目标群并拿到群实体
|
|
|
+ if (task.type === TaskType.INVITE_TO_GROUP) {
|
|
|
+ const inviteLink = String(task.payload?.inviteLink ?? '').trim()
|
|
|
+ if (!inviteLink) {
|
|
|
+ throw new Error('邀请链接为空,请检查 task.payload.inviteLink')
|
|
|
+ }
|
|
|
+ inviteGroupEntity = await workerTgClient.resolveGroupEntityByInviteLink(inviteLink)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
- await this.processTaskItem(task, taskItem, sender, workerTgClient)
|
|
|
+ await this.processTaskItem(task, taskItem, sender, workerTgClient, inviteGroupEntity)
|
|
|
} catch (error) {
|
|
|
const msg = error instanceof Error ? error.message : '未知错误'
|
|
|
if (sender && this.isSessionRevokedMessage(msg)) {
|
|
|
@@ -164,9 +177,10 @@ export class TaskExecutor {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 发送间隔
|
|
|
- if (sendIntervalMs > 0) {
|
|
|
- await this.sleep(sendIntervalMs)
|
|
|
+ // 处理间隔(每条随机)
|
|
|
+ const intervalMs = this.pickIntervalMs(task.intervalTime)
|
|
|
+ if (intervalMs > 0) {
|
|
|
+ await this.sleep(intervalMs)
|
|
|
}
|
|
|
}
|
|
|
} finally {
|
|
|
@@ -178,32 +192,75 @@ export class TaskExecutor {
|
|
|
* 拉取一个待发送的 TaskItem(DB 层保证并发安全)
|
|
|
*/
|
|
|
private async pickNextTaskItem(taskId: number): Promise<TaskItem | null> {
|
|
|
- const item = await this.taskItemRepo
|
|
|
- .createQueryBuilder()
|
|
|
- .where('taskId = :taskId', { taskId })
|
|
|
- .andWhere('status = :status', { status: TaskItemStatus.PENDING })
|
|
|
- .orderBy('id', 'ASC')
|
|
|
- .setLock('pessimistic_write')
|
|
|
- .getOne()
|
|
|
-
|
|
|
- if (!item) return null
|
|
|
-
|
|
|
- await this.taskItemRepo.update(item.id, {
|
|
|
- status: TaskItemStatus.PENDING
|
|
|
- })
|
|
|
+ const queryRunner = this.ds.createQueryRunner()
|
|
|
+ await queryRunner.connect()
|
|
|
+ await queryRunner.startTransaction()
|
|
|
+ try {
|
|
|
+ const repo = queryRunner.manager.getRepository(TaskItem)
|
|
|
+ const item = await repo
|
|
|
+ .createQueryBuilder('item')
|
|
|
+ .setLock('pessimistic_write')
|
|
|
+ .where('item.taskId = :taskId', { taskId })
|
|
|
+ .andWhere('item.status = :status', { status: TaskItemStatus.PENDING })
|
|
|
+ .orderBy('item.id', 'ASC')
|
|
|
+ .getOne()
|
|
|
+
|
|
|
+ if (!item) {
|
|
|
+ await queryRunner.commitTransaction()
|
|
|
+ return null
|
|
|
+ }
|
|
|
|
|
|
- return item
|
|
|
+ await repo.update(item.id, {
|
|
|
+ status: TaskItemStatus.PROCESSING,
|
|
|
+ operatingAt: new Date(),
|
|
|
+ errorMsg: null
|
|
|
+ })
|
|
|
+
|
|
|
+ await queryRunner.commitTransaction()
|
|
|
+ return { ...item, status: TaskItemStatus.PROCESSING }
|
|
|
+ } catch (err) {
|
|
|
+ await queryRunner.rollbackTransaction()
|
|
|
+ throw err
|
|
|
+ } finally {
|
|
|
+ await queryRunner.release()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 真正发送一条消息
|
|
|
+ * 处理单个 TaskItem(按 Task.type 分发)
|
|
|
*/
|
|
|
private async processTaskItem(
|
|
|
+ task: Task,
|
|
|
+ item: TaskItem,
|
|
|
+ sender: TgUser,
|
|
|
+ workerTgClient: TgClientService,
|
|
|
+ inviteGroupEntity: any | null
|
|
|
+ ): Promise<void> {
|
|
|
+ if (task.type === TaskType.INVITE_TO_GROUP) {
|
|
|
+ return await this.processInviteToGroup(task, item, sender, workerTgClient, inviteGroupEntity)
|
|
|
+ }
|
|
|
+
|
|
|
+ return await this.processSendMessage(task, item, sender, workerTgClient)
|
|
|
+ }
|
|
|
+
|
|
|
+ private async processSendMessage(
|
|
|
task: Task,
|
|
|
item: TaskItem,
|
|
|
sender: TgUser,
|
|
|
workerTgClient: TgClientService
|
|
|
): Promise<void> {
|
|
|
+ const message = String(task.payload?.message ?? '').trim()
|
|
|
+ if (!message) {
|
|
|
+ await this.taskItemRepo.update(item.id, {
|
|
|
+ status: TaskItemStatus.FAILED,
|
|
|
+ operatingAt: new Date(),
|
|
|
+ operationId: sender.id,
|
|
|
+ errorMsg: '消息内容为空'
|
|
|
+ })
|
|
|
+ await this.taskRepo.increment({ id: task.id }, 'processed', 1)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
try {
|
|
|
const parsedTarget = this.parseTarget(item.target)
|
|
|
if (!parsedTarget) {
|
|
|
@@ -220,49 +277,154 @@ export class TaskExecutor {
|
|
|
throw new Error('目标用户不允许接收消息或已被限制')
|
|
|
}
|
|
|
|
|
|
- await workerTgClient.sendMessageToPeer(targetPeer, task.message)
|
|
|
+ await workerTgClient.sendMessageToPeer(targetPeer, message)
|
|
|
await workerTgClient.clearConversation(targetPeer).catch(() => {})
|
|
|
await workerTgClient.deleteTempContact((targetPeer as any).id).catch(() => {})
|
|
|
|
|
|
await this.taskItemRepo.update(item.id, {
|
|
|
status: TaskItemStatus.SUCCESS,
|
|
|
- sentAt: new Date(),
|
|
|
- senderId: sender.id,
|
|
|
+ operatingAt: new Date(),
|
|
|
+ operationId: sender.id,
|
|
|
errorMsg: null
|
|
|
})
|
|
|
|
|
|
await this.senderService.incrementUsageCount(sender.id)
|
|
|
- await this.taskRepo.increment({ id: task.id }, 'sent', 1)
|
|
|
- await this.taskRepo.increment({ id: task.id }, 'successCount', 1)
|
|
|
+ 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}, sender=${sender.id}`)
|
|
|
} catch (error) {
|
|
|
const msg = error instanceof Error ? error.message : '未知错误'
|
|
|
await this.taskItemRepo.update(item.id, {
|
|
|
status: TaskItemStatus.FAILED,
|
|
|
- sentAt: new Date(),
|
|
|
- senderId: sender.id,
|
|
|
+ operatingAt: new Date(),
|
|
|
+ operationId: sender.id,
|
|
|
errorMsg: msg
|
|
|
})
|
|
|
|
|
|
await this.senderService.incrementUsageCount(sender.id)
|
|
|
- await this.taskRepo.increment({ id: task.id }, 'sent', 1)
|
|
|
+ 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
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private async processInviteToGroup(
|
|
|
+ task: Task,
|
|
|
+ item: TaskItem,
|
|
|
+ sender: TgUser,
|
|
|
+ workerTgClient: TgClientService,
|
|
|
+ inviteGroupEntity: any | null
|
|
|
+ ): Promise<void> {
|
|
|
+ try {
|
|
|
+ if (!inviteGroupEntity) {
|
|
|
+ throw new Error('未获取到群组实体(inviteGroupEntity 为空)')
|
|
|
+ }
|
|
|
+
|
|
|
+ const parsedTarget = this.parseTarget(item.target)
|
|
|
+ if (!parsedTarget) {
|
|
|
+ throw new Error('target 格式错误,请检查是否正确')
|
|
|
+ }
|
|
|
+
|
|
|
+ const targetUser = await workerTgClient.getTargetPeer(parsedTarget)
|
|
|
+ if (!targetUser) {
|
|
|
+ throw new Error('target 无效,无法获取目标信息')
|
|
|
+ }
|
|
|
+
|
|
|
+ const client = workerTgClient.getClient()
|
|
|
+ if (!client) {
|
|
|
+ throw new Error('TelegramClient 未连接')
|
|
|
+ }
|
|
|
+
|
|
|
+ const isChannel = inviteGroupEntity?.className === 'Channel'
|
|
|
+ const isChat = inviteGroupEntity?.className === 'Chat'
|
|
|
+ if (!isChannel && !isChat) {
|
|
|
+ throw new Error('目标并非群组或频道,无法邀请成员')
|
|
|
+ }
|
|
|
+
|
|
|
+ const inputUser = new Api.InputUser({
|
|
|
+ userId: targetUser.id,
|
|
|
+ accessHash: targetUser.accessHash || BigInt(0)
|
|
|
+ })
|
|
|
+
|
|
|
+ if (isChannel) {
|
|
|
+ if (!inviteGroupEntity?.accessHash) {
|
|
|
+ throw new Error('缺少 accessHash,无法邀请到频道/超级群组')
|
|
|
+ }
|
|
|
+ const inputChannel = new Api.InputChannel({
|
|
|
+ channelId: inviteGroupEntity.id,
|
|
|
+ accessHash: inviteGroupEntity.accessHash
|
|
|
+ })
|
|
|
+ await client.invoke(
|
|
|
+ new Api.channels.InviteToChannel({
|
|
|
+ channel: inputChannel,
|
|
|
+ users: [inputUser]
|
|
|
+ })
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ await client.invoke(
|
|
|
+ new Api.messages.AddChatUser({
|
|
|
+ chatId: inviteGroupEntity.id,
|
|
|
+ userId: inputUser,
|
|
|
+ fwdLimit: 0
|
|
|
+ })
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ await this.taskItemRepo.update(item.id, {
|
|
|
+ status: TaskItemStatus.SUCCESS,
|
|
|
+ operatingAt: new Date(),
|
|
|
+ operationId: sender.id,
|
|
|
+ errorMsg: null
|
|
|
+ })
|
|
|
+
|
|
|
+ await this.senderService.incrementUsageCount(sender.id)
|
|
|
+ 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}, sender=${sender.id}`)
|
|
|
+ } catch (error) {
|
|
|
+ const msg = error instanceof Error ? error.message : '未知错误'
|
|
|
+
|
|
|
+ // 已在群内:计为成功
|
|
|
+ if (msg.includes('USER_ALREADY_PARTICIPANT')) {
|
|
|
+ await this.taskItemRepo.update(item.id, {
|
|
|
+ status: TaskItemStatus.SUCCESS,
|
|
|
+ operatingAt: new Date(),
|
|
|
+ operationId: sender.id,
|
|
|
+ errorMsg: null
|
|
|
+ })
|
|
|
+ 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
|
|
|
+ }
|
|
|
+
|
|
|
+ await this.taskItemRepo.update(item.id, {
|
|
|
+ status: TaskItemStatus.FAILED,
|
|
|
+ operatingAt: new Date(),
|
|
|
+ operationId: sender.id,
|
|
|
+ errorMsg: msg
|
|
|
+ })
|
|
|
+
|
|
|
+ await this.senderService.incrementUsageCount(sender.id)
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 收尾逻辑
|
|
|
*/
|
|
|
private async finalize(taskId: number): Promise<void> {
|
|
|
- const remain = await this.taskItemRepo.count({
|
|
|
- where: {
|
|
|
- taskId,
|
|
|
- status: TaskItemStatus.PENDING
|
|
|
- }
|
|
|
- })
|
|
|
+ const remain = await this.taskItemRepo
|
|
|
+ .createQueryBuilder('item')
|
|
|
+ .where('item.taskId = :taskId', { taskId })
|
|
|
+ .andWhere('item.status IN (:...statuses)', { statuses: [TaskItemStatus.PENDING, TaskItemStatus.PROCESSING] })
|
|
|
+ .getCount()
|
|
|
|
|
|
if (remain > 0) {
|
|
|
return
|
|
|
@@ -283,12 +445,29 @@ export class TaskExecutor {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 获取任务发送间隔(毫秒)
|
|
|
- */
|
|
|
- private async getSendInterval(taskId: number): Promise<number> {
|
|
|
- const task = await this.taskRepo.findOneBy({ id: taskId })
|
|
|
- return Math.max(0, Number(task?.sendInterval ?? 0) * 1000)
|
|
|
+ private pickIntervalMs(intervalTime?: string | null): number {
|
|
|
+ const raw = String(intervalTime ?? '').trim()
|
|
|
+ if (!raw) return 0
|
|
|
+
|
|
|
+ const normalized = raw.replace('~', '-').replace(',', '-').replace(/\s+/g, '-')
|
|
|
+ const parts = normalized.split('-').filter(Boolean)
|
|
|
+
|
|
|
+ let minSec: number
|
|
|
+ let maxSec: number
|
|
|
+ if (parts.length === 1) {
|
|
|
+ minSec = Number(parts[0])
|
|
|
+ maxSec = Number(parts[0])
|
|
|
+ } else {
|
|
|
+ minSec = Number(parts[0])
|
|
|
+ maxSec = Number(parts[1])
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Number.isNaN(minSec) || Number.isNaN(maxSec)) return 0
|
|
|
+ if (minSec < 0 || maxSec < 0) return 0
|
|
|
+ if (maxSec < minSec) [minSec, maxSec] = [maxSec, minSec]
|
|
|
+
|
|
|
+ const sec = minSec === maxSec ? minSec : Math.floor(Math.random() * (maxSec - minSec + 1)) + minSec
|
|
|
+ return sec * 1000
|
|
|
}
|
|
|
|
|
|
/**
|