feat: initial commit
This commit is contained in:
commit
c4514cd4c4
19 changed files with 4403 additions and 0 deletions
39
src/config.ts
Normal file
39
src/config.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { Config } from './types.js'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const CONFIG_PATH = './config.json'
|
||||
|
||||
function loadConfig(): Config {
|
||||
if (!existsSync(CONFIG_PATH)) {
|
||||
throw new Error(`Config file not found: ${CONFIG_PATH}`)
|
||||
}
|
||||
|
||||
const raw = readFileSync(CONFIG_PATH, 'utf-8')
|
||||
const config = JSON.parse(raw) as Config
|
||||
|
||||
if (!config.telegram?.chatId) {
|
||||
throw new Error('Config missing telegram.chatId')
|
||||
}
|
||||
|
||||
if (!Array.isArray(config.servers) || config.servers.length === 0) {
|
||||
throw new Error('Config missing servers array')
|
||||
}
|
||||
|
||||
for (const server of config.servers) {
|
||||
if (!server.guildId) {
|
||||
throw new Error(`Server "${server.name}" missing guildId`)
|
||||
}
|
||||
if (!Array.isArray(server.roles) || server.roles.length === 0) {
|
||||
throw new Error(`Server "${server.name}" missing roles array`)
|
||||
}
|
||||
for (const role of server.roles) {
|
||||
if (!role.id || !role.name || typeof role.topicId !== 'number') {
|
||||
throw new Error(`Invalid role config in server "${server.name}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export const config = loadConfig()
|
||||
9
src/discord/client.ts
Normal file
9
src/discord/client.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Client } from 'discord.js-selfbot-v13'
|
||||
import env from '../env.js'
|
||||
|
||||
export const discord = new Client()
|
||||
|
||||
export async function startDiscord(): Promise<void> {
|
||||
await discord.login(env.discordToken)
|
||||
console.log(`Discord logged in as ${discord.user?.tag}`)
|
||||
}
|
||||
58
src/discord/handlers.ts
Normal file
58
src/discord/handlers.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { Message } from 'discord.js-selfbot-v13'
|
||||
import type { AttachmentInfo, RoleConfig } from '../types.js'
|
||||
import { config } from '../config.js'
|
||||
import { forwardMessage } from '../telegram/sender.js'
|
||||
import { discord } from './client.js'
|
||||
|
||||
export function setupHandlers(): void {
|
||||
discord.on('messageCreate', handleMessage)
|
||||
}
|
||||
|
||||
async function handleMessage(message: Message): Promise<void> {
|
||||
if (!message.guild || !message.member)
|
||||
return
|
||||
|
||||
const serverConfig = config.servers.find((s: { guildId: string }) => s.guildId === message.guild!.id)
|
||||
if (!serverConfig)
|
||||
return
|
||||
|
||||
const matchedRole = findHighestPriorityRole(message.member.roles.cache, serverConfig.roles)
|
||||
if (!matchedRole)
|
||||
return
|
||||
|
||||
const attachments: AttachmentInfo[] = message.attachments.map(att => ({
|
||||
url: att.url,
|
||||
name: att.name ?? 'file',
|
||||
contentType: att.contentType,
|
||||
}))
|
||||
|
||||
const messageLink = `https://discord.com/channels/${message.guild.id}/${message.channel.id}/${message.id}`
|
||||
|
||||
try {
|
||||
await forwardMessage({
|
||||
topicId: matchedRole.topicId,
|
||||
author: message.author.displayName ?? message.author.username,
|
||||
role: matchedRole.name,
|
||||
channel: 'name' in message.channel ? (message.channel.name ?? 'unknown') : 'DM',
|
||||
content: message.content,
|
||||
attachments,
|
||||
messageLink,
|
||||
})
|
||||
console.log(`Forwarded message from ${message.author.tag} (${matchedRole.name}) in ${serverConfig.name}`)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to forward message:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function findHighestPriorityRole(
|
||||
memberRoles: Map<string, unknown>,
|
||||
configRoles: RoleConfig[],
|
||||
): RoleConfig | null {
|
||||
for (const role of configRoles) {
|
||||
if (memberRoles.has(role.id)) {
|
||||
return role
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
12
src/env.ts
Normal file
12
src/env.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { existsSync } from 'node:fs'
|
||||
import { loadEnvFile } from 'node:process'
|
||||
import env from 'env-var'
|
||||
|
||||
if (existsSync('.env'))
|
||||
loadEnvFile('.env')
|
||||
|
||||
export default {
|
||||
mode: env.get('NODE_ENV').default('production').asString(),
|
||||
discordToken: env.get('DISCORD_TOKEN').required().asString(),
|
||||
telegramBotToken: env.get('TELEGRAM_BOT_TOKEN').required().asString(),
|
||||
}
|
||||
25
src/index.ts
Normal file
25
src/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import process from 'node:process'
|
||||
import { config } from './config.js'
|
||||
import { discord, startDiscord } from './discord/client.js'
|
||||
import { setupHandlers } from './discord/handlers.js'
|
||||
|
||||
console.log(`Loaded ${config.servers.length} server(s) to track`)
|
||||
|
||||
setupHandlers()
|
||||
|
||||
startDiscord().catch((err: unknown) => {
|
||||
console.error('Failed to start Discord client:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
process.once('SIGINT', () => {
|
||||
console.log('Shutting down...')
|
||||
discord.destroy()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.once('SIGTERM', () => {
|
||||
console.log('Shutting down...')
|
||||
discord.destroy()
|
||||
process.exit(0)
|
||||
})
|
||||
4
src/telegram/client.ts
Normal file
4
src/telegram/client.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { Telegram } from 'wrappergram'
|
||||
import env from '../env.js'
|
||||
|
||||
export const telegram = new Telegram(env.telegramBotToken)
|
||||
128
src/telegram/sender.ts
Normal file
128
src/telegram/sender.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import type { ForwardMessageOptions } from '../types.js'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { MediaUpload } from 'wrappergram'
|
||||
import { config } from '../config.js'
|
||||
import { telegram } from './client.js'
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB Telegram limit
|
||||
|
||||
interface DownloadedAttachment {
|
||||
buffer: Buffer
|
||||
name: string
|
||||
contentType: string | null
|
||||
}
|
||||
|
||||
export async function forwardMessage(opts: ForwardMessageOptions): Promise<void> {
|
||||
const { topicId, author, role, channel, content, attachments, messageLink } = opts
|
||||
|
||||
const text = `<b>${escapeHtml(author)}</b> (${escapeHtml(role)}) in <code>#${escapeHtml(channel)}</code>\n${escapeHtml(content)}\n\n<a href="${messageLink}">Jump to message</a>`
|
||||
|
||||
await telegram.api.sendMessage({
|
||||
chat_id: config.telegram.chatId,
|
||||
text,
|
||||
message_thread_id: topicId,
|
||||
parse_mode: 'HTML',
|
||||
link_preview_options: { is_disabled: true },
|
||||
})
|
||||
|
||||
if (attachments.length === 0)
|
||||
return
|
||||
|
||||
// Download all attachments first
|
||||
const downloaded: DownloadedAttachment[] = []
|
||||
for (const att of attachments) {
|
||||
try {
|
||||
const response = await fetch(att.url)
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
|
||||
if (buffer.length > MAX_FILE_SIZE) {
|
||||
console.warn(`Skipping attachment ${att.name}: exceeds 50MB limit`)
|
||||
continue
|
||||
}
|
||||
|
||||
downloaded.push({ buffer, name: att.name, contentType: att.contentType })
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`Failed to download attachment ${att.name}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (downloaded.length === 0)
|
||||
return
|
||||
|
||||
// Separate media (photos/videos) from documents
|
||||
const media: DownloadedAttachment[] = []
|
||||
const documents: DownloadedAttachment[] = []
|
||||
|
||||
for (const att of downloaded) {
|
||||
const isImage = att.contentType?.startsWith('image/')
|
||||
const isVideo = att.contentType?.startsWith('video/')
|
||||
|
||||
if (isImage || isVideo) {
|
||||
media.push(att)
|
||||
}
|
||||
else {
|
||||
documents.push(att)
|
||||
}
|
||||
}
|
||||
|
||||
// Send media as group or single
|
||||
if (media.length > 1) {
|
||||
try {
|
||||
await telegram.api.sendMediaGroup({
|
||||
chat_id: config.telegram.chatId,
|
||||
message_thread_id: topicId,
|
||||
media: media.map(att => ({
|
||||
type: att.contentType?.startsWith('video/') ? 'video' : 'photo',
|
||||
media: MediaUpload.buffer(att.buffer, att.name),
|
||||
})),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send media group:', err)
|
||||
}
|
||||
}
|
||||
else if (media.length === 1) {
|
||||
const att = media[0]
|
||||
try {
|
||||
if (att.contentType?.startsWith('video/')) {
|
||||
await telegram.api.sendVideo({
|
||||
chat_id: config.telegram.chatId,
|
||||
video: MediaUpload.buffer(att.buffer, att.name),
|
||||
message_thread_id: topicId,
|
||||
})
|
||||
}
|
||||
else {
|
||||
await telegram.api.sendPhoto({
|
||||
chat_id: config.telegram.chatId,
|
||||
photo: MediaUpload.buffer(att.buffer, att.name),
|
||||
message_thread_id: topicId,
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`Failed to send ${att.contentType?.startsWith('video/') ? 'video' : 'photo'}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send documents separately
|
||||
for (const att of documents) {
|
||||
try {
|
||||
await telegram.api.sendDocument({
|
||||
chat_id: config.telegram.chatId,
|
||||
document: MediaUpload.buffer(att.buffer, att.name),
|
||||
message_thread_id: topicId,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`Failed to send document ${att.name}:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
34
src/types.ts
Normal file
34
src/types.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export interface RoleConfig {
|
||||
id: string
|
||||
name: string
|
||||
topicId: number
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
name: string
|
||||
guildId: string
|
||||
roles: RoleConfig[]
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
telegram: {
|
||||
chatId: string
|
||||
}
|
||||
servers: ServerConfig[]
|
||||
}
|
||||
|
||||
export interface ForwardMessageOptions {
|
||||
topicId: number
|
||||
author: string
|
||||
role: string
|
||||
channel: string
|
||||
content: string
|
||||
attachments: AttachmentInfo[]
|
||||
messageLink: string
|
||||
}
|
||||
|
||||
export interface AttachmentInfo {
|
||||
url: string
|
||||
name: string
|
||||
contentType: string | null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue