Created
June 9, 2024 14:45
-
-
Save Zekiah-A/f4b76e17aea924dc89d4aaf564c8da38 to your computer and use it in GitHub Desktop.
A hacky script that moves messages from a telegram chat export to a discord server
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { join } from "path"; | |
| import { parseArgs } from "util"; | |
| const { values } = parseArgs({ | |
| args: Bun.argv, | |
| options: { | |
| folder: { | |
| type: "string", | |
| }, | |
| }, | |
| strict: true, | |
| allowPositionals: true, | |
| }); | |
| const moverConfigPath = "./mover-config.json" | |
| let moverConfigFile = null | |
| try { moverConfigFile = Bun.file(moverConfigPath) } catch { } | |
| if (!await moverConfigFile?.exists()) { | |
| Bun.write(moverConfigPath, JSON.stringify({ | |
| sendIntervalMs: 1500, | |
| webhookUrls: [ "ENTER_DISCORD_WEBHOOK_URL_HERE" ], | |
| sendAttempts: 4, | |
| avatarUrls: { "user546432622": "https://cdn.discordapp.com/media/avatar.png" }, | |
| nameOverrides: { "user436343422": "Zubigri" }, | |
| proxyUrls: [ null ] | |
| }, null, 4)) | |
| console.log("Config created! Please update ./mover-config.json before running again.") | |
| process.exit(0) | |
| } | |
| const { sendIntervalMs, webhookUrls, sendAttempts, avatarUrls, nameOverrides, proxyUrls } = await moverConfigFile.json() | |
| let proxyUrlsI = 0 | |
| function nextProxyUrl() { | |
| const proxyUrl = proxyUrls[proxyUrlsI++ % proxyUrls.length] | |
| return proxyUrl | |
| } | |
| let webhookUrlsI = 0 | |
| function nextWebhookUrl() { | |
| const webhookUrl = webhookUrls[webhookUrlsI++ % webhookUrls.length] | |
| return webhookUrl | |
| } | |
| async function handleRateLimit(response) { | |
| if (response.status === 429) { | |
| const retryAfter = response.headers.get('Retry-After') || (await response.json()).retry_after || 1 | |
| console.warn(`Rate limited. Retrying after ${retryAfter} seconds...`) | |
| await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)) | |
| return true | |
| } | |
| return false | |
| } | |
| // Attachments can only be images | |
| async function sendEmbed(username, message, title = username, attachments = null, avatarUrl = null) { | |
| const formData = new FormData(); | |
| const embed = { | |
| description: message, | |
| author: { name: title } | |
| } | |
| if (attachments) { | |
| const { data, mimeType, fileName } = attachments; | |
| let blob = new Blob([new Uint8Array(data)], { type: mimeType }); | |
| formData.append("file", blob, fileName) | |
| embed.image = { | |
| url: `attachment://${fileName}` | |
| }; | |
| } | |
| const payload = { | |
| embeds: [embed], | |
| username: username | |
| }; | |
| if (avatarUrl) { | |
| payload.avatar_url = avatarUrl | |
| } | |
| formData.append("payload_json", JSON.stringify(payload)); | |
| while (true) { | |
| const response = await fetch(nextWebhookUrl(), { | |
| method: "POST", | |
| body: formData, | |
| proxy: nextProxyUrl() | |
| }); | |
| try { | |
| if (!response.ok) { | |
| if (await handleRateLimit(response)) continue; | |
| console.error("Failed to send embed message:", response.status, await response.json()); | |
| return false | |
| } | |
| else { | |
| console.log("Embed message sent successfully:", message); | |
| return true | |
| } | |
| } | |
| catch(e) { | |
| console.error("Failed to send embed message", e) | |
| return false | |
| } | |
| } | |
| } | |
| async function sendMessage(username, message, attachment = null, avatarUrl = null) { | |
| const formData = new FormData(); | |
| formData.append("content", message); | |
| formData.append("username", username); | |
| if (avatarUrl) { | |
| formData.append("avatar_url", avatarUrl) | |
| } | |
| if (attachment) { | |
| const { data, mimeType, fileName } = attachment; | |
| const blob = new Blob([data], { type: mimeType }); | |
| formData.append("file", blob, fileName) | |
| } | |
| const response = await fetch(nextWebhookUrl(), { | |
| method: "POST", | |
| body: formData, | |
| proxy: nextProxyUrl() | |
| }); | |
| while (true) { | |
| try { | |
| if (!response.ok) { | |
| if (await handleRateLimit(response)) continue; | |
| console.error("Failed to send message:", response.status, await response.json()); | |
| return false | |
| } | |
| else { | |
| console.log("Message sent successfully:", message); | |
| return true | |
| } | |
| } | |
| catch(e) { | |
| console.error("Failed to send mesage", e) | |
| return false | |
| } | |
| } | |
| } | |
| let resultObject = null | |
| let i = parseInt(await (Bun.file("./progress.txt")).text()) || 0 | |
| function proceed() { | |
| i++ | |
| Bun.write("./progress.txt", i.toString()) | |
| } | |
| function mimeToExtension(mimeType) { | |
| const mimeToExt = { | |
| "image/jpeg": "jpg", | |
| "image/png": "png", | |
| "image/gif": "gif", | |
| "image/bmp": "bmp", | |
| "image/webp": "webp", | |
| "video/mp4": "mp4", | |
| "video/mpeg": "mpeg", | |
| "video/ogg": "ogv", | |
| "video/webm": "webm", | |
| "video/quicktime": "mov", | |
| "audio/mpeg": "mp3", | |
| "audio/ogg": "ogg", | |
| "audio/wav": "wav", | |
| "audio/webm": "weba", | |
| "audio/aac": "aac", | |
| "audio/flac": "flac", | |
| "audio/mp4": "m4a", | |
| "application/pdf": "pdf", | |
| "application/zip": "zip", | |
| "application/json": "json", | |
| "text/plain": "txt", | |
| "text/html": "html", | |
| "text/css": "css", | |
| "text/javascript": "js", | |
| "application/x-msdownload": "exe", | |
| "application/vnd.microsoft.portable-executable": "exe", | |
| "application/x-msdos-program": "exe" | |
| } | |
| return mimeToExt[mimeType] || "" | |
| } | |
| async function attemptWithCooldown(sendFnPromise) { | |
| let success = false | |
| for (let i = 0; i < sendAttempts; i++) { | |
| success = await sendFnPromise | |
| await new Promise(resolve => setTimeout(resolve, sendIntervalMs)); | |
| if (success) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| function parseMessageText(message) { | |
| let text = "" | |
| if (typeof message.text === "string") { | |
| text = message.text | |
| } | |
| else if (typeof message.text === "object") { | |
| function visitTextNode(data) { | |
| if (Array.isArray(data)) { | |
| let childrenText = "" | |
| for (const node of data) { | |
| childrenText += visitTextNode(node) | |
| } | |
| return childrenText | |
| } | |
| else if (typeof data === "object") { | |
| return data.text | |
| } | |
| else if (typeof data == "string") { | |
| return data | |
| } | |
| else { | |
| throw new Error("Couldn't visit text node - unknown node type") | |
| } | |
| } | |
| text = visitTextNode(message.text) | |
| } | |
| return text | |
| } | |
| function resolveAuthorName(message) { | |
| return nameOverrides[message.from_id] || message.from || "Deleted account" | |
| } | |
| async function startSendLoop() { | |
| while (i < resultObject.messages.length) { | |
| const message = resultObject.messages[i] | |
| let text = parseMessageText(message).replaceAll("@everyone", "@ everyone") | |
| let sender = `[${resultObject.name.slice(0, 12).trim()}] ${resolveAuthorName(message)}` | |
| if (message.type != "message") { | |
| console.log("Ignoring message", message.id, "of type", message.type) | |
| proceed() | |
| continue | |
| } | |
| if (message.reply_to_message_id) { | |
| let targetMessage = null | |
| for (const replyMessage of resultObject.messages) { | |
| if (replyMessage.id == message.reply_to_message_id) { | |
| targetMessage = replyMessage | |
| break | |
| } | |
| } | |
| if (!targetMessage) { | |
| text = `> *Replying to* Deleted Message\n${text}` | |
| } | |
| else { | |
| let targetmessageText = parseMessageText(targetMessage).replaceAll("@everyone", "@ everyone").replaceAll("\\n", "\n> ") | |
| if (targetmessageText.length > 512) { | |
| targetmessageText = targetmessageText.slice(0, 509) + `*...(${targetmessageText.length-512} more)*` | |
| } | |
| text = `> *Replying to* __${resolveAuthorName(targetMessage)}__ ${targetmessageText}\n${text}` | |
| } | |
| } | |
| let file = null | |
| if (message.photo) { | |
| let photoFile = null | |
| try { photoFile = Bun.file(join(values.folder, message.photo)) } catch(e){} | |
| if (!await photoFile?.exists()) { | |
| console.log("Ignoring message", message.id, "photo", message.photo, "(not found)") | |
| } | |
| else { | |
| const buffer = await photoFile.arrayBuffer() | |
| let fileName = /*message.photo.split("@")[1] ||*/ "photo.jpg" | |
| file = { data: buffer, mimeType: "image/jpeg", fileName: fileName } | |
| } | |
| } | |
| else if (message.file) { | |
| let messageFile = null | |
| try { messageFile = Bun.file(join(values.folder, message.file)) } catch(e){} | |
| if (!await messageFile?.exists()) { | |
| console.log("Ignoring message", message.id, "file", message.file, "(not found)") | |
| } | |
| else { | |
| const buffer = await messageFile.arrayBuffer() | |
| let fileName = /*message.file_name ||*/ "attachment" | |
| fileName += "." + mimeToExtension(message.mime_type) | |
| file = { data: buffer, mimeType: message.mime_type, fileName: fileName } | |
| } | |
| } | |
| if (file != null) { | |
| if (!file.fileName.includes(".")) { | |
| file.fileName += "." + file.mimeType.slice("/")[1] | |
| } | |
| if (!text) { | |
| text = "⠀" | |
| } | |
| } | |
| else if (!text) { | |
| console.log("Ignoring message", message.id, "empty text and no attachment") | |
| proceed() | |
| continue | |
| } | |
| else if (text.length > 2000) { | |
| console.log("Truncating message", message.id, "as text is too long") | |
| text = text.slice(0, 1500) | |
| } | |
| const avatarUrl = avatarUrls[message.from_id] | |
| if (message.forwarded_from) { | |
| const forwardTxt = `Forwarded from ${message.forwarded_from}` | |
| if (file) { | |
| if (file.mimeType.startsWith("image/")) { | |
| await attemptWithCooldown(sendEmbed(sender, text, forwardTxt, file, avatarUrl)) | |
| } | |
| else { | |
| await attemptWithCooldown(sendEmbed(sender, text, forwardTxt, null, avatarUrl)) | |
| await attemptWithCooldown(sendMessage(sender, "> ↴", file, avatarUrl)) | |
| } | |
| } | |
| } | |
| else { | |
| await attemptWithCooldown(sendMessage(sender, text, file, avatarUrl)) | |
| } | |
| proceed() | |
| } | |
| } | |
| (async function() { | |
| let messagesFile = join(values.folder, "result.json") | |
| console.log("Alright, loading from", messagesFile) | |
| const resultFile = Bun.file(messagesFile) | |
| resultObject = await resultFile.json() | |
| startSendLoop() | |
| })() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
More info:
mover-config.json. A simple way to do this, is to upload the profile images to discord separately, then reference them in the avatarUrls section of the config, with format user id : profile image url (optionally adding an entry above for their actual name for clarity). For example: