history.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "log"
  6. "os"
  7. "strconv"
  8. "time"
  9. "github.com/bwmarrin/discordgo"
  10. "github.com/dustin/go-humanize"
  11. "github.com/fatih/color"
  12. "github.com/hako/durafmt"
  13. orderedmap "github.com/wk8/go-ordered-map/v2"
  14. )
  15. type historyStatus int
  16. const (
  17. historyStatusWaiting historyStatus = iota
  18. historyStatusRunning
  19. historyStatusAbortRequested
  20. historyStatusAbortCompleted
  21. historyStatusErrorReadMessageHistoryPerms
  22. historyStatusErrorRequesting
  23. historyStatusCompletedNoMoreMessages
  24. historyStatusCompletedToBeforeFilter
  25. historyStatusCompletedToSinceFilter
  26. )
  27. func historyStatusLabel(status historyStatus) string {
  28. switch status {
  29. case historyStatusWaiting:
  30. return "Waiting..."
  31. case historyStatusRunning:
  32. return "Currently Downloading..."
  33. case historyStatusAbortRequested:
  34. return "Abort Requested..."
  35. case historyStatusAbortCompleted:
  36. return "Aborted..."
  37. case historyStatusErrorReadMessageHistoryPerms:
  38. return "ERROR: Cannot Read Message History"
  39. case historyStatusErrorRequesting:
  40. return "ERROR: Message Requests Failed"
  41. case historyStatusCompletedNoMoreMessages:
  42. return "COMPLETE: No More Messages"
  43. case historyStatusCompletedToBeforeFilter:
  44. return "COMPLETE: Exceeded Before Date Filter"
  45. case historyStatusCompletedToSinceFilter:
  46. return "COMPLETE: Exceeded Since Date Filter"
  47. default:
  48. return "Unknown"
  49. }
  50. }
  51. type historyJob struct {
  52. Status historyStatus
  53. OriginUser string
  54. OriginChannel string
  55. TargetCommandingMessage *discordgo.Message
  56. TargetChannelID string
  57. TargetBefore string
  58. TargetSince string
  59. DownloadCount int64
  60. DownloadSize int64
  61. Updated time.Time
  62. Added time.Time
  63. }
  64. var (
  65. historyJobs *orderedmap.OrderedMap[string, historyJob]
  66. historyJobCnt int
  67. historyJobCntWaiting int
  68. historyJobCntRunning int
  69. historyJobCntAborted int
  70. historyJobCntErrored int
  71. historyJobCntCompleted int
  72. )
  73. // TODO: cleanup
  74. type historyCache struct {
  75. Updated time.Time
  76. Running bool
  77. RunningBefore string // messageID for last before range attempted if interrupted
  78. CompletedSince string // messageID for last message the bot has 100% assumed completion on (since start of channel)
  79. }
  80. func handleHistory(commandingMessage *discordgo.Message, subjectChannelID string, before string, since string) int {
  81. var err error
  82. historyStartTime := time.Now()
  83. // Log Prefix
  84. var commander string = "AUTORUN"
  85. var autorun bool = true
  86. if commandingMessage != nil { // Only time commandingMessage is nil is Autorun
  87. commander = getUserIdentifier(*commandingMessage.Author)
  88. autorun = false
  89. }
  90. logPrefix := fmt.Sprintf("%s/%s: ", subjectChannelID, commander)
  91. // Skip Requested
  92. if job, exists := historyJobs.Get(subjectChannelID); exists && job.Status != historyStatusWaiting {
  93. log.Println(lg("History", "", color.RedString, logPrefix+"History job skipped, Status: %s", historyStatusLabel(job.Status)))
  94. return -1
  95. }
  96. // Vars
  97. var totalMessages int64 = 0
  98. var totalDownloads int64 = 0
  99. var totalFilesize int64 = 0
  100. var messageRequestCount int = 0
  101. var responseMsg *discordgo.Message = &discordgo.Message{} // dummy message
  102. responseMsg.ID = ""
  103. responseMsg.ChannelID = subjectChannelID
  104. responseMsg.GuildID = ""
  105. var baseChannelInfo *discordgo.Channel
  106. if baseChannelInfo, err = bot.State.Channel(subjectChannelID); err != nil {
  107. log.Println(lg("History", "", color.HiRedString, logPrefix+"ERROR FETCHING BOT STATE FROM DISCORDGO!!!\t%s", err))
  108. }
  109. guildName := getGuildName(baseChannelInfo.GuildID)
  110. categoryName := getChannelCategoryName(baseChannelInfo.ID)
  111. subjectChannels := []discordgo.Channel{}
  112. // Check channel type
  113. baseChannelIsForum := true
  114. if baseChannelInfo.Type != discordgo.ChannelTypeGuildCategory &&
  115. baseChannelInfo.Type != discordgo.ChannelTypeGuildForum &&
  116. baseChannelInfo.Type != discordgo.ChannelTypeGuildNews &&
  117. baseChannelInfo.Type != discordgo.ChannelTypeGuildStageVoice &&
  118. baseChannelInfo.Type != discordgo.ChannelTypeGuildVoice &&
  119. baseChannelInfo.Type != discordgo.ChannelTypeGuildStore {
  120. subjectChannels = append(subjectChannels, *baseChannelInfo)
  121. baseChannelIsForum = false
  122. }
  123. // Index Threads
  124. now := time.Now()
  125. if threads, err := bot.ThreadsArchived(subjectChannelID, &now, 0); err == nil {
  126. for _, thread := range threads.Threads {
  127. subjectChannels = append(subjectChannels, *thread)
  128. }
  129. }
  130. if threads, err := bot.ThreadsActive(subjectChannelID); err == nil {
  131. for _, thread := range threads.Threads {
  132. subjectChannels = append(subjectChannels, *thread)
  133. }
  134. }
  135. // Send Status?
  136. var sendStatus bool = true
  137. if (autorun && !config.SendAutoHistoryStatus) || (!autorun && !config.SendHistoryStatus) {
  138. sendStatus = false
  139. }
  140. // Check Read History perms
  141. if !baseChannelIsForum && !hasPerms(subjectChannelID, discordgo.PermissionReadMessageHistory) {
  142. if job, exists := historyJobs.Get(subjectChannelID); exists {
  143. job.Status = historyStatusRunning
  144. job.Updated = time.Now()
  145. historyJobs.Set(subjectChannelID, job)
  146. }
  147. log.Println(lg("History", "", color.HiRedString, logPrefix+"BOT DOES NOT HAVE PERMISSION TO READ MESSAGE HISTORY!!!"))
  148. }
  149. // Update Job Status to Downloading
  150. if job, exists := historyJobs.Get(subjectChannelID); exists {
  151. job.Status = historyStatusRunning
  152. job.Updated = time.Now()
  153. historyJobs.Set(subjectChannelID, job)
  154. }
  155. //#region Cache Files
  156. openHistoryCache := func(channel string) historyCache {
  157. if f, err := os.ReadFile(pathCacheHistory + string(os.PathSeparator) + channel + ".json"); err == nil {
  158. var ret historyCache
  159. if err = json.Unmarshal(f, &ret); err != nil {
  160. log.Println(lg("Debug", "History", color.RedString,
  161. logPrefix+"Failed to unmarshal json for cache:\t%s", err))
  162. } else {
  163. return ret
  164. }
  165. }
  166. return historyCache{}
  167. }
  168. writeHistoryCache := func(channel string, cache historyCache) {
  169. cacheJson, err := json.Marshal(cache)
  170. if err != nil {
  171. log.Println(lg("Debug", "History", color.RedString,
  172. logPrefix+"Failed to format cache into json:\t%s", err))
  173. } else {
  174. if err := os.MkdirAll(pathCacheHistory, 0755); err != nil {
  175. log.Println(lg("Debug", "History", color.HiRedString,
  176. logPrefix+"Error while creating history cache folder \"%s\": %s", pathCacheHistory, err))
  177. }
  178. f, err := os.OpenFile(
  179. pathCacheHistory+string(os.PathSeparator)+channel+".json",
  180. os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
  181. if err != nil {
  182. log.Println(lg("Debug", "History", color.RedString,
  183. logPrefix+"Failed to open cache file:\t%s", err))
  184. }
  185. if _, err = f.WriteString(string(cacheJson)); err != nil {
  186. log.Println(lg("Debug", "History", color.RedString,
  187. logPrefix+"Failed to write cache file:\t%s", err))
  188. } else if !autorun && config.Debug {
  189. log.Println(lg("Debug", "History", color.YellowString,
  190. logPrefix+"Wrote to cache file."))
  191. }
  192. f.Close()
  193. }
  194. }
  195. deleteHistoryCache := func(channel string) {
  196. fp := pathCacheHistory + string(os.PathSeparator) + channel + ".json"
  197. if _, err := os.Stat(fp); err == nil {
  198. err = os.Remove(fp)
  199. if err != nil {
  200. log.Println(lg("Debug", "History", color.HiRedString,
  201. logPrefix+"Encountered error deleting cache file:\t%s", err))
  202. } else if commandingMessage != nil && config.Debug {
  203. log.Println(lg("Debug", "History", color.HiRedString,
  204. logPrefix+"Deleted cache file."))
  205. }
  206. }
  207. }
  208. //#endregion
  209. for _, channel := range subjectChannels {
  210. logPrefix = fmt.Sprintf("%s/%s: ", channel.ID, commander)
  211. if channelConfig := getSource(responseMsg, &channel); channelConfig != emptyConfig {
  212. // Overwrite Send Status
  213. if channelConfig.SendAutoHistoryStatus != nil {
  214. if autorun && !*channelConfig.SendAutoHistoryStatus {
  215. sendStatus = false
  216. }
  217. }
  218. if channelConfig.SendHistoryStatus != nil {
  219. if !autorun && !*channelConfig.SendHistoryStatus {
  220. sendStatus = false
  221. }
  222. }
  223. hasPermsToRespond := hasPerms(channel.ID, discordgo.PermissionSendMessages)
  224. if !autorun {
  225. hasPermsToRespond = hasPerms(commandingMessage.ChannelID, discordgo.PermissionSendMessages)
  226. }
  227. // Date Range Vars
  228. rangeContent := ""
  229. var beforeTime time.Time
  230. var beforeID = before
  231. var sinceID = ""
  232. // Handle Cache File
  233. if cache := openHistoryCache(channel.ID); cache != (historyCache{}) {
  234. if cache.CompletedSince != "" {
  235. if config.Debug {
  236. log.Println(lg("Debug", "History", color.GreenString,
  237. logPrefix+"Assuming history is completed prior to "+cache.CompletedSince))
  238. }
  239. since = cache.CompletedSince
  240. }
  241. if cache.Running {
  242. if config.Debug {
  243. log.Println(lg("Debug", "History", color.YellowString,
  244. logPrefix+"Job was interrupted last run, picking up from "+beforeID))
  245. }
  246. beforeID = cache.RunningBefore
  247. }
  248. }
  249. //#region Date Range Output
  250. var beforeRange = before
  251. if beforeRange != "" {
  252. if isDate(beforeRange) {
  253. beforeRange = discordTimestampToSnowflake(beforeID, "2006-01-02")
  254. }
  255. if isNumeric(beforeRange) {
  256. rangeContent += fmt.Sprintf("**Before:** `%s`\n", beforeRange)
  257. }
  258. before = beforeRange
  259. }
  260. var sinceRange = since
  261. if sinceRange != "" {
  262. if isDate(sinceRange) {
  263. sinceRange = discordTimestampToSnowflake(sinceRange, "2006-01-02")
  264. }
  265. if isNumeric(sinceRange) {
  266. rangeContent += fmt.Sprintf("**Since:** `%s`\n", sinceRange)
  267. }
  268. since = sinceRange
  269. }
  270. if rangeContent != "" {
  271. rangeContent += "\n"
  272. }
  273. //#endregion
  274. channelName := getChannelName(channel.ID, &channel)
  275. if channel.ParentID != "" {
  276. channelName = getChannelName(channel.ParentID, nil) + " \"" + getChannelName(channel.ID, &channel) + "\""
  277. }
  278. sourceName := fmt.Sprintf("%s / %s", guildName, channelName)
  279. msgSourceDisplay := fmt.Sprintf("`Server:` **%s**\n`Channel:` #%s", guildName, channelName)
  280. if categoryName != "unknown" {
  281. sourceName = fmt.Sprintf("%s / %s / %s", guildName, categoryName, channelName)
  282. msgSourceDisplay = fmt.Sprintf("`Server:` **%s**\n`Category:` _%s_\n`Channel:` #%s",
  283. guildName, categoryName, channelName)
  284. }
  285. // Initial Status Message
  286. if sendStatus {
  287. if hasPermsToRespond {
  288. responseMsg, err = replyEmbed(commandingMessage, "Command — History", msgSourceDisplay)
  289. if err != nil {
  290. log.Println(lg("History", "", color.HiRedString,
  291. logPrefix+"Failed to send command embed message:\t%s", err))
  292. }
  293. } else {
  294. log.Println(lg("History", "", color.HiRedString,
  295. logPrefix+fmtBotSendPerm, commandingMessage.ChannelID))
  296. }
  297. }
  298. log.Println(lg("History", "", color.HiCyanString, logPrefix+"Began checking history for \"%s\"...", sourceName))
  299. lastMessageID := ""
  300. MessageRequestingLoop:
  301. for {
  302. // Next 100
  303. if beforeTime != (time.Time{}) {
  304. messageRequestCount++
  305. writeHistoryCache(channel.ID, historyCache{
  306. Updated: time.Now(),
  307. Running: true,
  308. RunningBefore: beforeID,
  309. })
  310. // Update Status
  311. log.Println(lg("History", "", color.CyanString,
  312. logPrefix+"Requesting more, \t%d downloaded (%s), \t%d processed, \tsearching before %s ago (%s)",
  313. totalDownloads, humanize.Bytes(uint64(totalFilesize)), totalMessages, durafmt.ParseShort(time.Since(beforeTime)).String(), beforeTime.String()[:10]))
  314. if sendStatus {
  315. status := fmt.Sprintf(
  316. "``%s:`` **%s files downloaded...** `(%s so far, avg %1.1f MB/s)`\n``"+
  317. "%s messages processed...``\n\n"+
  318. "%s\n\n"+
  319. "%s`(%d)` _Processing more messages, please wait..._",
  320. shortenTime(durafmt.ParseShort(time.Since(historyStartTime)).String()), formatNumber(totalDownloads),
  321. humanize.Bytes(uint64(totalFilesize)), float64(totalFilesize/humanize.MByte)/time.Since(historyStartTime).Seconds(),
  322. formatNumber(totalMessages),
  323. msgSourceDisplay, rangeContent, messageRequestCount)
  324. if responseMsg == nil {
  325. log.Println(lg("History", "", color.RedString,
  326. logPrefix+"Tried to edit status message but it doesn't exist, sending new one."))
  327. if responseMsg, err = replyEmbed(responseMsg, "Command — History", status); err != nil { // Failed to Edit Status, Send New Message
  328. log.Println(lg("History", "", color.HiRedString,
  329. logPrefix+"Failed to send replacement status message:\t%s", err))
  330. }
  331. } else {
  332. if !hasPermsToRespond {
  333. log.Println(lg("History", "", color.HiRedString,
  334. logPrefix+fmtBotSendPerm+" - %s", responseMsg.ChannelID, status))
  335. } else {
  336. // Edit Status
  337. if selfbot {
  338. responseMsg, err = bot.ChannelMessageEdit(responseMsg.ChannelID, responseMsg.ID,
  339. fmt.Sprintf("**Command — History**\n\n%s", status))
  340. } else {
  341. responseMsg, err = bot.ChannelMessageEditComplex(&discordgo.MessageEdit{
  342. ID: responseMsg.ID,
  343. Channel: responseMsg.ChannelID,
  344. Embed: buildEmbed(responseMsg.ChannelID, "Command — History", status),
  345. })
  346. }
  347. // Failed to Edit Status
  348. if err != nil {
  349. log.Println(lg("History", "", color.HiRedString,
  350. logPrefix+"Failed to edit status message, sending new one:\t%s", err))
  351. if responseMsg, err = replyEmbed(responseMsg, "Command — History", status); err != nil { // Failed to Edit Status, Send New Message
  352. log.Println(lg("History", "", color.HiRedString,
  353. logPrefix+"Failed to send replacement status message:\t%s", err))
  354. }
  355. }
  356. }
  357. }
  358. }
  359. // Update presence
  360. timeLastUpdated = time.Now()
  361. if *channelConfig.PresenceEnabled {
  362. go updateDiscordPresence()
  363. }
  364. }
  365. // Request More Messages
  366. msg_rq_cnt := 0
  367. request_messages:
  368. msg_rq_cnt++
  369. if messages, err := bot.ChannelMessages(channel.ID, 100, beforeID, sinceID, ""); err != nil {
  370. // Error requesting messages
  371. if sendStatus {
  372. if !hasPermsToRespond {
  373. log.Println(lg("History", "", color.HiRedString,
  374. logPrefix+fmtBotSendPerm, responseMsg.ChannelID))
  375. } else {
  376. _, err = replyEmbed(responseMsg, "Command — History",
  377. fmt.Sprintf("Encountered an error requesting messages for %s: %s", channel, err.Error()))
  378. if err != nil {
  379. log.Println(lg("History", "", color.HiRedString,
  380. logPrefix+"Failed to send error message:\t%s", err))
  381. }
  382. }
  383. }
  384. log.Println(lg("History", "", color.HiRedString, logPrefix+"Error requesting messages:\t%s", err))
  385. if job, exists := historyJobs.Get(subjectChannelID); exists {
  386. job.Status = historyStatusErrorRequesting
  387. job.Updated = time.Now()
  388. historyJobs.Set(subjectChannelID, job)
  389. }
  390. //TODO: delete cahce or handle it differently?
  391. break MessageRequestingLoop
  392. } else {
  393. // No More Messages
  394. if len(messages) <= 0 {
  395. if msg_rq_cnt > 3 {
  396. if job, exists := historyJobs.Get(subjectChannelID); exists {
  397. job.Status = historyStatusCompletedNoMoreMessages
  398. job.Updated = time.Now()
  399. historyJobs.Set(subjectChannelID, job)
  400. }
  401. writeHistoryCache(channel.ID, historyCache{
  402. Updated: time.Now(),
  403. Running: false,
  404. RunningBefore: "",
  405. CompletedSince: lastMessageID,
  406. })
  407. break MessageRequestingLoop
  408. } else { // retry to make sure no more
  409. time.Sleep(10 * time.Millisecond)
  410. goto request_messages
  411. }
  412. }
  413. // Set New Range, this shouldn't be changed regardless of before/since filters. The bot will always go latest to oldest.
  414. beforeID = messages[len(messages)-1].ID
  415. beforeTime = messages[len(messages)-1].Timestamp
  416. sinceID = ""
  417. // Process Messages
  418. if channelConfig.HistoryTyping != nil && !autorun {
  419. if *channelConfig.HistoryTyping && hasPermsToRespond {
  420. bot.ChannelTyping(commandingMessage.ChannelID)
  421. }
  422. }
  423. for _, message := range messages {
  424. // Ordered to Cancel
  425. if job, exists := historyJobs.Get(subjectChannelID); exists {
  426. if job.Status == historyStatusAbortRequested {
  427. job.Status = historyStatusAbortCompleted
  428. job.Updated = time.Now()
  429. historyJobs.Set(subjectChannelID, job)
  430. deleteHistoryCache(channel.ID) //TODO: Replace with different variation of writing cache?
  431. break MessageRequestingLoop
  432. }
  433. }
  434. lastMessageID = message.ID
  435. // Check Message Range
  436. message64, _ := strconv.ParseInt(message.ID, 10, 64)
  437. if before != "" {
  438. before64, _ := strconv.ParseInt(before, 10, 64)
  439. if message64 > before64 { // keep scrolling back in messages
  440. continue
  441. }
  442. }
  443. if since != "" {
  444. since64, _ := strconv.ParseInt(since, 10, 64)
  445. if message64 < since64 { // message too old, kill loop
  446. if job, exists := historyJobs.Get(subjectChannelID); exists {
  447. job.Status = historyStatusCompletedToSinceFilter
  448. job.Updated = time.Now()
  449. historyJobs.Set(subjectChannelID, job)
  450. }
  451. deleteHistoryCache(channel.ID) // unsure of consequences of caching when using filters, so deleting to be safe for now.
  452. break MessageRequestingLoop
  453. }
  454. }
  455. // Process Message
  456. downloadCount, filesize := handleMessage(message, &channel, false, true)
  457. if downloadCount > 0 {
  458. totalDownloads += downloadCount
  459. totalFilesize += filesize
  460. }
  461. totalMessages++
  462. }
  463. }
  464. }
  465. // Final log
  466. log.Println(lg("History", "", color.HiGreenString, logPrefix+"Finished history for \"%s\", %s files, %s total",
  467. sourceName, formatNumber(totalDownloads), humanize.Bytes(uint64(totalFilesize))))
  468. // Final status update
  469. if sendStatus {
  470. jobStatus := "Unknown"
  471. if job, exists := historyJobs.Get(subjectChannelID); exists {
  472. jobStatus = historyStatusLabel(job.Status)
  473. }
  474. status := fmt.Sprintf(
  475. "``%s:`` **%s total files downloaded!** `%s total, avg %1.1f MB/s`\n"+
  476. "``%s total messages processed``\n\n"+
  477. "%s\n\n"+ // msgSourceDisplay^
  478. "**DONE!** - %s\n"+
  479. "Ran ``%d`` message history requests\n\n"+
  480. "%s_Duration was %s_",
  481. durafmt.ParseShort(time.Since(historyStartTime)).String(), formatNumber(int64(totalDownloads)),
  482. humanize.Bytes(uint64(totalFilesize)), float64(totalFilesize/humanize.MByte)/time.Since(historyStartTime).Seconds(),
  483. formatNumber(int64(totalMessages)),
  484. msgSourceDisplay,
  485. jobStatus,
  486. messageRequestCount,
  487. rangeContent, durafmt.Parse(time.Since(historyStartTime)).String(),
  488. )
  489. if !hasPermsToRespond {
  490. log.Println(lg("History", "", color.HiRedString, logPrefix+fmtBotSendPerm, responseMsg.ChannelID))
  491. } else {
  492. if responseMsg == nil {
  493. log.Println(lg("History", "", color.RedString,
  494. logPrefix+"Tried to edit status message but it doesn't exist, sending new one."))
  495. if _, err = replyEmbed(responseMsg, "Command — History", status); err != nil { // Failed to Edit Status, Send New Message
  496. log.Println(lg("History", "", color.HiRedString,
  497. logPrefix+"Failed to send replacement status message:\t%s", err))
  498. }
  499. } else {
  500. if selfbot {
  501. responseMsg, err = bot.ChannelMessageEdit(responseMsg.ChannelID, responseMsg.ID,
  502. fmt.Sprintf("**Command — History**\n\n%s", status))
  503. } else {
  504. responseMsg, err = bot.ChannelMessageEditComplex(&discordgo.MessageEdit{
  505. ID: responseMsg.ID,
  506. Channel: responseMsg.ChannelID,
  507. Embed: buildEmbed(responseMsg.ChannelID, "Command — History", status),
  508. })
  509. }
  510. // Edit failure
  511. if err != nil {
  512. log.Println(lg("History", "", color.RedString,
  513. logPrefix+"Failed to edit status message, sending new one:\t%s", err))
  514. if _, err = replyEmbed(responseMsg, "Command — History", status); err != nil {
  515. log.Println(lg("History", "", color.HiRedString,
  516. logPrefix+"Failed to send replacement status message:\t%s", err))
  517. }
  518. }
  519. }
  520. }
  521. }
  522. }
  523. }
  524. return int(totalDownloads)
  525. }