feat: initial commit

This commit is contained in:
devilreef 2026-01-08 21:52:19 +06:00
commit c4514cd4c4
19 changed files with 4403 additions and 0 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
DISCORD_TOKEN=your_discord_user_token
TELEGRAM_BOT_TOKEN=your_telegram_bot_token

238
.gitignore vendored Normal file
View file

@ -0,0 +1,238 @@
# Created by https://www.toptal.com/developers/gitignore/api/node,visualstudiocode,windows,macos,linux
# Edit at https://www.toptal.com/developers/gitignore?templates=node,visualstudiocode,windows,macos,linux
config.json
.claude
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### macOS Patch ###
# iCloud generated files
*.icloud
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode,windows,macos,linux

50
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,50 @@
{
// Disable the default formatter, use eslint instead
"prettier.enable": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"json5",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
]
}

44
CLAUDE.md Normal file
View file

@ -0,0 +1,44 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
pnpm dev # Run with hot-reload (development)
pnpm start # Run production
pnpm lint # ESLint check (npx eslint src/)
```
## Architecture
Discord-to-Telegram message forwarder. Tracks messages from users with specific roles on Discord servers and forwards them to Telegram topics.
```
src/
├── index.ts # Entry point, initializes clients
├── env.ts # Environment variables (DISCORD_TOKEN, TELEGRAM_BOT_TOKEN)
├── config.ts # Loads config.json, validates server/role structure
├── types.ts # Shared TypeScript interfaces
├── discord/
│ ├── client.ts # discord.js-selfbot-v13 client
│ └── handlers.ts # messageCreate handler, role priority filtering
└── telegram/
├── client.ts # wrappergram client
└── sender.ts # Message forwarding, media group handling
```
**Flow**: Discord messageCreate → filter by guild + role → find highest priority role → forward to Telegram topic with attachments
**Role Priority**: Users with multiple tracked roles → message goes to first matching role's topic (config array order = priority)
## Config
- `config.json` - Server/role/topic mappings (see `config.json.example`)
- `.env` - Tokens (see `.env.example`)
## Code Style
- ESM with `.js` extensions in imports
- Node globals via explicit imports (`import process from 'node:process'`)
- @antfu/eslint-config with 2-space indent, single quotes

11
Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY . /app
WORKDIR /app
RUN pnpm install --prod --frozen-lockfile
CMD ["pnpm", "start"]

15
config.json.example Normal file
View file

@ -0,0 +1,15 @@
{
"telegram": {
"chatId": "-100123456789"
},
"servers": [
{
"name": "Hytale",
"guildId": "123456789012345678",
"roles": [
{ "id": "111111111111111111", "name": "Developer", "topicId": 5 },
{ "id": "222222222222222222", "name": "Moderator", "topicId": 6 }
]
}
]
}

13
eslint.config.mjs Normal file
View file

@ -0,0 +1,13 @@
import antfu from '@antfu/eslint-config'
export default antfu({
stylistic: {
indent: 2,
quotes: 'single',
},
typescript: true,
rules: {
'antfu/no-top-level-await': 'off',
'no-console': 'off',
},
})

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "hytale-says",
"type": "module",
"version": "0.1.0",
"packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad",
"author": {
"name": "devilreef",
"url": "https://github.com/devilr33f"
},
"scripts": {
"dev": "cross-env NODE_ENV=development tsx --watch src/index.ts",
"start": "tsx src/index.ts"
},
"dependencies": {
"discord.js-selfbot-v13": "^3.7.1",
"env-var": "^7.5.0",
"tsx": "^4.20.3",
"wrappergram": "^1.3.0"
},
"devDependencies": {
"@antfu/eslint-config": "^4.16.2",
"@types/node": "^22.16.3",
"cross-env": "^7.0.3",
"eslint": "^9.31.0"
}
}

3674
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

39
src/config.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}

34
src/types.ts Normal file
View 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
}

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es2024",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"rootDir": "./src",
"module": "nodenext",
"moduleResolution": "nodenext",
"paths": {
"@/*": ["./src/*"]
},
"strict": true,
"outDir": "./dist",
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}