Sfoglia il codice sorgente

添加创建聊天群和邀请成员功能,更新 TaskController、TaskService 和相关 DTO,增强错误处理和日志记录。

wuyi 1 mese fa
parent
commit
c395c711f8

+ 62 - 1
src/controllers/task.controller.ts

@@ -1,6 +1,13 @@
 import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
 import { TaskService } from '../services/task.service'
-import { UpdateTaskBody, ListTaskQuery, ListTaskItemQuery, SendMessageBody } from '../dto/task.dto'
+import {
+  UpdateTaskBody,
+  ListTaskQuery,
+  ListTaskItemQuery,
+  SendMessageBody,
+  CreateChatGroupBody,
+  InviteMembersBody
+} from '../dto/task.dto'
 import { Task } from '../entities/task.entity'
 
 export class TaskController {
@@ -195,4 +202,58 @@ export class TaskController {
       })
     }
   }
+
+  async testCreateChatGroup(request: FastifyRequest<{ Body: CreateChatGroupBody }>, reply: FastifyReply) {
+    const { senderId, session, dcId, authKey, groupName, groupDescription, groupType } = request.body
+
+    if (!senderId || !groupName || !groupType) {
+      return reply.code(400).send({
+        success: false,
+        message: 'senderId, groupName 和 groupType 为必填参数'
+      })
+    }
+
+    const validGroupTypes = ['megagroup', 'channel']
+    if (!validGroupTypes.includes(groupType)) {
+      return reply.code(400).send({
+        success: false,
+        message: `groupType 必须是以下之一: ${validGroupTypes.join(', ')}`
+      })
+    }
+
+    const result = await this.taskService.testCreateChatGroup(
+      senderId,
+      groupName,
+      groupDescription,
+      groupType,
+      session,
+      dcId,
+      authKey
+    )
+
+    if (result.success) {
+      return reply.code(200).send(result)
+    } else {
+      return reply.code(500).send(result)
+    }
+  }
+
+  async testInviteMembersToChat(request: FastifyRequest<{ Body: InviteMembersBody }>, reply: FastifyReply) {
+    const { senderId, chatId, accessHash, members } = request.body
+
+    if (!senderId || !chatId || !members || members.length === 0) {
+      return reply.code(400).send({
+        success: false,
+        message: 'senderId, chatId 和 members 为必填参数'
+      })
+    }
+
+    const result = await this.taskService.testInviteMembersToChat(senderId, chatId, members, accessHash)
+
+    if (result.success) {
+      return reply.code(200).send(result)
+    } else {
+      return reply.code(500).send(result)
+    }
+  }
 }

+ 17 - 0
src/dto/task.dto.ts

@@ -44,3 +44,20 @@ export interface SendMessageBody {
   verifyAccounts?: string[]
 }
 
+export interface CreateChatGroupBody {
+  senderId: string
+  session?: string
+  dcId?: number
+  authKey?: string
+  groupName: string
+  groupDescription: string
+  groupType: string
+  groupLink: string
+}
+
+export interface InviteMembersBody {
+  senderId: string
+  chatId: string
+  accessHash?: string
+  members: string[]
+}

+ 18 - 4
src/routes/task.routes.ts

@@ -1,7 +1,14 @@
 import { FastifyInstance } from 'fastify'
 import { TaskController } from '../controllers/task.controller'
 import { hasRole } from '../middlewares/auth.middleware'
-import { UpdateTaskBody, ListTaskQuery, ListTaskItemQuery, SendMessageBody } from '../dto/task.dto'
+import {
+  UpdateTaskBody,
+  ListTaskQuery,
+  ListTaskItemQuery,
+  SendMessageBody,
+  CreateChatGroupBody,
+  InviteMembersBody
+} from '../dto/task.dto'
 import { UserRole } from '../entities/user.entity'
 
 export default async function taskRoutes(fastify: FastifyInstance) {
@@ -39,8 +46,15 @@ export default async function taskRoutes(fastify: FastifyInstance) {
     taskController.listTaskItems.bind(taskController)
   )
 
-  fastify.post<{ Body: SendMessageBody }>(
-    '/test/send',
-    taskController.testSendMessage.bind(taskController)
+  fastify.post<{ Body: SendMessageBody }>('/test/send', taskController.testSendMessage.bind(taskController))
+
+  fastify.post<{ Body: CreateChatGroupBody }>(
+    '/test/create-chat-group',
+    taskController.testCreateChatGroup.bind(taskController)
+  )
+
+  fastify.post<{ Body: InviteMembersBody }>(
+    '/test/invite-members',
+    taskController.testInviteMembersToChat.bind(taskController)
   )
 }

+ 536 - 4
src/services/task.service.ts

@@ -423,7 +423,9 @@ export class TaskService {
         try {
           await this.senderService.incrementUsageCount(senderId)
         } catch (error) {
-          // 更新 usageCount 失败不影响主流程,静默处理
+          this.app.log.warn(
+            `更新 sender usageCount 失败 [${senderId}]: ${error instanceof Error ? error.message : '未知错误'}`
+          )
         }
 
         totalSentCount++
@@ -478,6 +480,539 @@ export class TaskService {
     }
   }
 
+  async testCreateChatGroup(
+    senderId: string,
+    groupName: string,
+    groupDescription: string,
+    groupType: string,
+    session?: string,
+    dcId?: number,
+    authKey?: string
+  ): Promise<any> {
+    let client: TelegramClient | null = null
+
+    try {
+      const sessionResult = await this.resolveSenderSession(senderId, session, dcId, authKey)
+      if ('error' in sessionResult) {
+        return sessionResult.error
+      }
+      const { sessionString } = sessionResult
+
+      this.app.log.info('正在连接 TelegramClient...')
+      client = await this.tgClientService.connect(sessionString)
+      this.app.log.info('TelegramClient 连接完成')
+
+      // 新 Session 登录后等待 20-40 秒,避免新账号或首次使用触发限制
+      const waitTime = Math.floor(Math.random() * 21) + 20
+      this.app.log.info(`连接成功后等待 ${waitTime} 秒,避免新 Session 被限制`)
+      await new Promise(resolve => setTimeout(resolve, waitTime * 1000))
+
+      this.app.log.info(`等待完成,开始创建群组`)
+
+      // 群类型校验
+      if (groupType !== 'channel' && groupType !== 'megagroup') {
+        return {
+          success: false,
+          message: `不支持的群组类型: ${groupType},仅支持: megagroup(超级群组), channel(频道)`
+        }
+      }
+
+      this.app.log.info(`正在创建${groupType === 'channel' ? '频道' : '超级群组'}: ${groupName}`)
+
+      // 创建群
+      const result = await client.invoke(
+        new Api.channels.CreateChannel({
+          title: groupName,
+          about: groupDescription || '',
+          megagroup: groupType === 'megagroup'
+        })
+      )
+
+      const updates = result as any
+      const createdChat = updates.chats?.[0]
+
+      if (!createdChat) {
+        return {
+          success: false,
+          message: '创建群组失败,未返回群组信息'
+        }
+      }
+
+      const chatId = createdChat.id
+      const accessHash = createdChat.accessHash
+
+      this.app.log.info(`群组创建成功,ID: ${chatId}, accessHash: ${accessHash}`)
+
+      // 必须构造 InputChannel(否则无法发送消息 / 获取 full info / 邀请)
+      const inputChannel = new Api.InputChannel({
+        channelId: chatId,
+        accessHash: accessHash
+      })
+
+      // ⭐ 必须发送一条消息,让群出现在会话列表中
+      try {
+        await client.sendMessage(inputChannel, {
+          message: groupDescription || `Welcome to ${groupName}!`
+        })
+        this.app.log.info('已向群组发送欢迎消息')
+      } catch (e) {
+        this.app.log.warn('发送欢迎消息失败: ' + (e instanceof Error ? e.message : '未知错误'))
+      }
+
+      // 获取链接
+      let groupLink: string | null = null
+      try {
+        const fullChannel = await client.invoke(
+          new Api.channels.GetFullChannel({
+            channel: inputChannel
+          })
+        )
+
+        const channel = fullChannel.chats?.[0] as any
+
+        // 若存在 username(公开群)
+        if (channel?.username) {
+          groupLink = `https://t.me/${channel.username}`
+          this.app.log.info(`群组公开链接(永久): ${groupLink}`)
+        } else {
+          // 创建私有邀请链接(有效期 1 天)
+          const expireDate = Math.floor(Date.now() / 1000) + 24 * 60 * 60
+
+          const inviteLink = await client.invoke(
+            new Api.messages.ExportChatInvite({
+              peer: inputChannel,
+              expireDate: expireDate
+            })
+          )
+
+          const invite = inviteLink as any
+          if (invite?.link) {
+            groupLink = invite.link
+            this.app.log.info(`群组邀请链接(1 天有效): ${groupLink}`)
+          } else {
+            this.app.log.warn('未能获取群组邀请链接')
+          }
+        }
+      } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : '未知错误'
+        this.app.log.warn(`获取群组链接失败: ${errorMessage}`)
+      }
+
+      return {
+        success: true,
+        message: '群组创建成功',
+        data: {
+          chatId: chatId.toString(),
+          chatTitle: groupName,
+          chatType: groupType,
+          groupLink: groupLink || null
+        }
+      }
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '未知错误'
+      this.app.log.error(`创建聊天群失败: ${errorMessage}`)
+      return {
+        success: false,
+        message: `创建聊天群失败: ${errorMessage}`
+      }
+    } finally {
+      if (client) {
+        try {
+          await this.tgClientService.disconnect()
+          this.app.log.info('TelegramClient 已断开连接')
+        } catch (error) {
+          const errorMessage = error instanceof Error ? error.message : '未知错误'
+          this.app.log.error(`断开连接失败: ${errorMessage}`)
+        }
+      }
+    }
+  }
+
+  async testInviteMembersToChat(
+    senderId: string,
+    chatId: string,
+    members: string[],
+    accessHash?: string
+  ): Promise<{
+    success: boolean
+    message: string
+    data?: { chatId: string; inviteResults: { total: number; success: number; failed: number; errors: string[] } }
+  }> {
+    let client: TelegramClient | null = null
+
+    try {
+      if (!members || members.length === 0) {
+        return { success: false, message: '成员列表不能为空' }
+      }
+
+      if (!chatId || !chatId.trim()) {
+        return { success: false, message: 'chatId 不能为空' }
+      }
+
+      const sessionResult = await this.resolveSenderSession(senderId)
+      if ('error' in sessionResult) {
+        return sessionResult.error
+      }
+      const { sessionString } = sessionResult
+
+      this.app.log.info('正在连接 TelegramClient...')
+      client = await this.tgClientService.connect(sessionString)
+      this.app.log.info('TelegramClient 连接完成')
+
+      const waitTime = Math.floor(Math.random() * 21) + 20
+      this.app.log.info(`连接成功后等待 ${waitTime} 秒,避免新 Session 被限制`)
+      await new Promise(resolve => setTimeout(resolve, waitTime * 1000))
+      this.app.log.info('等待完成,开始邀请成员')
+
+      let successCount = 0
+      let failedCount = 0
+      const errors: string[] = []
+
+      // 获取群组/频道实体(兼容 channel、megagroup、普通群)
+      const chatTarget = chatId.trim()
+      const accessHashTrimmed = accessHash?.trim()
+      const channelIdWithHashMatch = chatTarget.match(/^(-?\d+)[,:#](\d+)$/)
+      let chatEntity: any
+      try {
+        if (channelIdWithHashMatch) {
+          // 直接使用已知 id + accessHash,避免 getEntity 失败
+          const [, idStr, accessHashStr] = channelIdWithHashMatch
+          chatEntity = {
+            id: BigInt(idStr),
+            accessHash: BigInt(accessHashStr),
+            className: 'Channel',
+            title: chatTarget
+          }
+          this.app.log.info('已使用显式 channelId + accessHash 初始化频道实体')
+        } else if (accessHashTrimmed && /^-?\d+$/.test(chatTarget)) {
+          // 单独提供 chatId + accessHash
+          chatEntity = {
+            id: BigInt(chatTarget),
+            accessHash: BigInt(accessHashTrimmed),
+            className: 'Channel',
+            title: chatTarget
+          }
+          this.app.log.info('已使用 chatId + accessHash 初始化频道实体')
+        } else {
+          const parsedChatId = /^-?\d+$/.test(chatTarget) ? Number(chatTarget) : chatTarget
+          chatEntity = await client.getEntity(parsedChatId)
+        }
+      } catch (error) {
+        // 如果是纯数字且未带 -100 前缀,尝试补全后再次获取(频道/超级群常见)
+        if (/^\d+$/.test(chatTarget)) {
+          try {
+            const prefixedChannelId = BigInt(`-100${chatTarget}`)
+            chatEntity = await client.getEntity(prefixedChannelId.toString())
+            this.app.log.info(`通过 -100 前缀重试获取群组成功: ${prefixedChannelId.toString()}`)
+          } catch (error2) {
+            const errorMessage2 = error2 instanceof Error ? error2.message : '未知错误'
+            this.app.log.error(`获取群组信息失败(二次尝试): ${errorMessage2}`)
+            return { success: false, message: `获取群组信息失败: ${errorMessage2}` }
+          }
+        } else {
+          const errorMessage = error instanceof Error ? error.message : '未知错误'
+          this.app.log.error(`获取群组信息失败: ${errorMessage}`)
+          return { success: false, message: `获取群组信息失败: ${errorMessage}` }
+        }
+      }
+
+      this.app.log.info(
+        `已获取群组信息: ${chatEntity.title || chatEntity.username || chatTarget} (class: ${chatEntity.className})`
+      )
+
+      const isChannel = chatEntity?.className === 'Channel'
+      const isChat = chatEntity?.className === 'Chat'
+      if (!isChannel && !isChat) {
+        return { success: false, message: '目标并非群组或频道,无法邀请成员' }
+      }
+
+      const inviteToGroup = async (inputUser: Api.InputUser) => {
+        if (isChannel) {
+          if (!chatEntity?.accessHash) {
+            throw new Error('缺少 accessHash,无法邀请到频道/超级群组')
+          }
+          const inputChannel = new Api.InputChannel({
+            channelId: chatEntity.id,
+            accessHash: chatEntity.accessHash
+          })
+          await client!.invoke(
+            new Api.channels.InviteToChannel({
+              channel: inputChannel,
+              users: [inputUser]
+            })
+          )
+        } else {
+          await client!.invoke(
+            new Api.messages.AddChatUser({
+              chatId: chatEntity.id,
+              userId: inputUser,
+              fwdLimit: 0
+            })
+          )
+        }
+      }
+
+      // 遍历成员列表,逐个邀请
+      for (let i = 0; i < members.length; i++) {
+        const member = members[i]
+        let lastError: string | null = null
+
+        this.app.log.info(`正在邀请第 ${i + 1}/${members.length} 个成员: ${member}`)
+
+        try {
+          // 解析成员 target
+          const parsedTarget = this.parseTarget(member)
+          if (!parsedTarget) {
+            throw new Error(`成员格式错误: ${member}`)
+          }
+
+          // 获取目标用户实体
+          let targetUser: any
+          try {
+            targetUser = await this.tgClientService.getTargetPeer(client, parsedTarget)
+            if (!targetUser) {
+              throw new Error('无法获取用户信息')
+            }
+          } catch (error) {
+            throw new Error(`获取用户信息失败: ${error instanceof Error ? error.message : '未知错误'}`)
+          }
+
+          // 构建 InputUser
+          const inputUser = new Api.InputUser({
+            userId: targetUser.id,
+            accessHash: targetUser.accessHash || BigInt(0)
+          })
+
+          // 邀请用户到群组/频道
+          try {
+            await inviteToGroup(inputUser)
+            this.app.log.info(`成功邀请成员: ${member}`)
+            successCount++
+          } catch (inviteError) {
+            const inviteErrorMessage = inviteError instanceof Error ? inviteError.message : '未知错误'
+
+            // 处理特定的错误情况
+            if (inviteErrorMessage.includes('USER_ALREADY_PARTICIPANT')) {
+              this.app.log.info(`成员已在群组中: ${member}`)
+              successCount++
+            } else if (inviteErrorMessage.includes('PEER_FLOOD')) {
+              throw new Error('邀请频率过快,触发 PEER_FLOOD 限制,请降低邀请频率')
+            } else if (inviteErrorMessage.includes('USER_PRIVACY_RESTRICTED')) {
+              throw new Error('用户隐私设置不允许被邀请')
+            } else if (inviteErrorMessage.includes('USER_NOT_MUTUAL_CONTACT')) {
+              throw new Error('用户不是双向联系人,无法邀请')
+            } else if (inviteErrorMessage.includes('USER_CHANNELS_TOO_MUCH')) {
+              throw new Error('用户加入的群组/频道数量已达上限')
+            } else if (inviteErrorMessage.includes('INVITE_REQUEST_SENT')) {
+              this.app.log.info(`已向用户发送邀请请求: ${member}`)
+              successCount++
+            } else {
+              throw inviteError
+            }
+          }
+        } catch (error) {
+          const errorMessage = error instanceof Error ? error.message : '未知错误'
+          const errorDetail = `${member}: ${errorMessage}`
+          this.app.log.error(`邀请成员失败: ${errorDetail}`)
+          errors.push(errorDetail)
+          failedCount++
+          lastError = errorMessage
+        } finally {
+          // 统一处理延迟逻辑
+          if (i < members.length - 1) {
+            let delay: number
+            let delayReason: string
+
+            // 如果有错误,根据错误类型决定延迟时间
+            if (lastError) {
+              if (
+                lastError.includes('FLOOD_WAIT') ||
+                lastError.includes('SLOWMODE_WAIT') ||
+                lastError.includes('PEER_FLOOD') ||
+                lastError.includes('Too Many Requests')
+              ) {
+                delay = 120
+                delayReason = `检测到限制(${lastError.includes('PEER_FLOOD') ? 'PEER_FLOOD' : 'FLOOD_WAIT'})`
+              } else {
+                // 普通错误也需要遵守 30 秒以上的间隔
+                delay = Math.floor(Math.random() * 11) + 30
+                delayReason = '普通错误后等待'
+              }
+            } else {
+              // 成功邀请后的正常延迟
+              delay = Math.floor(Math.random() * 11) + 30
+              delayReason = '避免 Peer Flood'
+            }
+
+            this.app.log.info(`${delayReason},等待 ${delay} 秒后继续邀请下一个成员`)
+            await new Promise(resolve => setTimeout(resolve, delay * 1000))
+          }
+        }
+      }
+
+      this.app.log.info(`成员邀请完成: 总计=${members.length}, 成功=${successCount}, 失败=${failedCount}`)
+
+      return {
+        success: true,
+        message: `成员邀请完成,共邀请 ${members.length} 个成员,成功 ${successCount} 个,失败 ${failedCount} 个`,
+        data: {
+          chatId: chatId.toString(),
+          inviteResults: {
+            total: members.length,
+            success: successCount,
+            failed: failedCount,
+            errors: errors
+          }
+        }
+      }
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '未知错误'
+      this.app.log.error(`邀请成员失败: ${errorMessage}`)
+      return {
+        success: false,
+        message: `邀请成员失败: ${errorMessage}`
+      }
+    } finally {
+      if (client) {
+        try {
+          await this.tgClientService.disconnect()
+          this.app.log.info('TelegramClient 已断开连接')
+        } catch (error) {
+          const errorMessage = error instanceof Error ? error.message : '未知错误'
+          this.app.log.error(`断开连接失败: ${errorMessage}`)
+        }
+      }
+    }
+  }
+
+  private async resolveSenderSession(
+    senderId: string,
+    session?: string,
+    dcId?: number,
+    authKey?: string
+  ): Promise<{ sender: Sender; sessionString: string } | { error: { success: false; message: string } }> {
+    if ((dcId !== undefined && authKey === undefined) || (dcId === undefined && authKey !== undefined)) {
+      return {
+        error: {
+          success: false,
+          message: 'dcId 和 authKey 必须同时传参'
+        }
+      }
+    }
+
+    let sender: Sender | null = null
+    let sessionString: string | null = null
+
+    if (session) {
+      try {
+        sessionString = buildStringSession(session)
+      } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : '未知错误'
+        return {
+          error: {
+            success: false,
+            message: `解析 session 失败: ${errorMessage}`
+          }
+        }
+      }
+
+      const existingSender = await this.senderRepository.findOne({ where: { id: senderId } })
+      if (existingSender) {
+        await this.senderRepository.update(senderId, { sessionStr: sessionString })
+        sender = await this.senderRepository.findOne({ where: { id: senderId } })
+      } else {
+        sender = await this.senderService.create(senderId, undefined, undefined, sessionString)
+      }
+
+      if (!sender) {
+        return {
+          error: {
+            success: false,
+            message: '创建或更新 sender 失败'
+          }
+        }
+      }
+    } else if (dcId !== undefined && authKey !== undefined) {
+      try {
+        sessionString = buildStringSessionByDcIdAndAuthKey(dcId, authKey)
+      } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : '未知错误'
+        return {
+          error: {
+            success: false,
+            message: `解析 session 失败: ${errorMessage}`
+          }
+        }
+      }
+
+      const existingSender = await this.senderRepository.findOne({ where: { id: senderId } })
+      if (existingSender) {
+        await this.senderRepository.update(senderId, { dcId, authKey, sessionStr: sessionString })
+        sender = await this.senderRepository.findOne({ where: { id: senderId } })
+      } else {
+        sender = await this.senderService.create(senderId, dcId, authKey, sessionString)
+      }
+
+      if (!sender) {
+        return {
+          error: {
+            success: false,
+            message: '创建或更新 sender 失败'
+          }
+        }
+      }
+    } else {
+      sender = await this.senderRepository.findOne({ where: { id: senderId, delFlag: false } })
+      if (!sender) {
+        return {
+          error: {
+            success: false,
+            message: '发送账号不存在或已被删除'
+          }
+        }
+      }
+    }
+
+    if (!sender) {
+      return {
+        error: {
+          success: false,
+          message: '发送账号不存在或已被删除'
+        }
+      }
+    }
+
+    if (!sessionString) {
+      sessionString = sender.sessionStr
+      if (!sessionString) {
+        if (!sender.dcId || !sender.authKey) {
+          return {
+            error: {
+              success: false,
+              message: 'Sender 缺少 dcId 或 authKey 信息'
+            }
+          }
+        }
+        sessionString = buildStringSessionByDcIdAndAuthKey(sender.dcId, sender.authKey)
+        await this.senderRepository.update(sender.id, { sessionStr: sessionString })
+      }
+    }
+
+    if (!sessionString) {
+      return {
+        error: {
+          success: false,
+          message: '无法生成 session 信息'
+        }
+      }
+    }
+
+    return {
+      sender,
+      sessionString
+    }
+  }
+
   private parseTarget(targetId: string): string | number | null {
     const trimmed = targetId.trim()
 
@@ -501,9 +1036,6 @@ export class TaskService {
     return null
   }
 
-  /**
-   * 检查是否可以向目标发送消息
-   */
   private async checkCanSendMessage(client: TelegramClient, targetPeer: any): Promise<boolean> {
     try {
       // 检查目标用户的完整信息