feat: initial commit
This commit is contained in:
commit
c4514cd4c4
19 changed files with 4403 additions and 0 deletions
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
DISCORD_TOKEN=your_discord_user_token
|
||||||
|
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||||
238
.gitignore
vendored
Normal file
238
.gitignore
vendored
Normal 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
50
.vscode/settings.json
vendored
Normal 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
44
CLAUDE.md
Normal 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
11
Dockerfile
Normal 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
15
config.json.example
Normal 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
13
eslint.config.mjs
Normal 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
26
package.json
Normal 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
3674
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
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
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue