This commit is contained in:
2026-06-07 20:57:49 -05:00
commit d44d854f74
5 changed files with 493 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
# Discord Chat Relay
A bidirectional chat relay mod for TERA Proxy.
- **Game → Discord:** Forwards in-game chat to Discord via webhooks.
- **Discord → Game:** Relays Discord messages into the game using a bot.
## Requirements
- [TERA Proxy](https://github.com/justkeepquiet/tera-proxy-server) or compatible fork
- A Discord **Webhook URL** for each chat channel (game → Discord)
- A Discord **Bot Token** with `Send Messages` and `Read Message History` permissions (Discord → game)
## Installation
Place the `discord-chat-relay` folder inside your Proxy's `mods/` directory:
```
mods/
└── discord-chat-relay/
├── index.js
├── manifest.json
├── module.config.json
├── module.json
└── README.md
```
Restart your Proxy or reload the mod.
## Configuration
Edit `module.config.json`:
```json
{
"enabled": true,
"webhooks": {
"general": "https://discord.com/api/webhooks/...",
"trade": "https://discord.com/api/webhooks/..."
},
"botToken": "your-discord-bot-token",
"discordChannels": {
"discord-channel-id": 27
},
"pollInterval": 3000,
"logLevel": "info"
}
```
| Field | Description |
|---|---|
| `enabled` | Set to `false` to disable the mod. |
| `webhooks` | Webhook URLs for forwarding in-game chat to Discord. |
| `botToken` | Discord bot token (required for Discord → game). |
| `discordChannels` | Maps Discord channel IDs to in-game channels (`27` = General, `4` = Trade). |
| `pollInterval` | Polling interval in ms (default: `3000`). |
| `logLevel` | Log verbosity: `"info"` (minimal), `"warn"` (default, shows routing), `"debug"` (everything). |
## Logs
Check the Proxy console for lines prefixed with `[Discord Chat Relay]` to monitor activity and troubleshoot.
## License
MIT
+395
View File
@@ -0,0 +1,395 @@
const https = require('https');
const urlModule = require('url');
module.exports = function DiscordChatRelay(mod) {
const config = loadConfig();
const webhooks = config.webhooks || {};
const enabled = config.enabled !== false;
const botToken = config.botToken || '';
const discordChannels = config.discordChannels || {};
const pollInterval = config.pollInterval || 3000;
const logLevel = (config.logLevel || 'info').toLowerCase();
const log = {
info: (...args) => { console.log('[Discord Chat Relay]', ...args); },
warn: (...args) => { if (logLevel !== 'info') console.log('[Discord Chat Relay]', ...args); },
debug: (...args) => { if (logLevel === 'debug') console.log('[Discord Chat Relay]', ...args); }
};
// Channel mapping
const CHANNELS = {
4: 'Trade',
27: 'General'
};
// Which channels go to which webhook
const CHANNEL_TO_WEBHOOK = {
27: 'general',
4: 'trade'
};
// Track last seen message IDs per Discord channel
const lastMessageIds = {};
let pollTimer = null;
function loadConfig() {
try {
delete require.cache[require.resolve('./module.config.json')];
return require('./module.config.json');
} catch (e) {
return { enabled: true, webhooks: {} };
}
}
function getChannelName(channelId) {
return CHANNELS[channelId] || `Channel ${channelId}`;
}
function stripHtmlTags(text) {
if (!text) return '';
let result = text.replace(/<[^>]*>/g, '');
result = result.replace(/&lt;/g, '').replace(/&gt;/g, '');
return result;
}
// ====== DISCORD -> GAME (WEBHOOK SENDING - EXISTING) ======
function sendToDiscord(playerName, message, webhookType) {
const webhookUrl = webhooks[webhookType];
if (!webhookUrl) {
log.warn(`Webhook for "${webhookType}" not configured`);
return;
}
const payload = JSON.stringify({
content: message,
username: playerName,
avatar_url: null
});
log.debug('Sending payload:', payload.substring(0, 100));
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
try {
const req = https.request(webhookUrl, options, (res) => {
log.debug('Response status:', res.statusCode);
if (res.statusCode !== 204 && res.statusCode !== 200) {
log.warn(`Warning: ${res.statusCode}`);
}
});
req.on('error', (err) => {
log.warn(`Request error: ${err.message}`);
});
req.write(payload);
req.end();
} catch (e) {
log.warn(`Exception: ${e.message}`);
}
}
// ====== DISCORD -> GAME (BOT POLLING) ======
/**
* Makes a request to the Discord REST API using the bot token.
* @param {string} endpoint - API endpoint (e.g., '/channels/123/messages')
* @param {object} [options] - Additional options
* @returns {Promise<any>}
*/
function discordApiRequest(endpoint, options = {}) {
return new Promise((resolve, reject) => {
const parsedUrl = urlModule.parse(`https://discord.com/api/v10${endpoint}`);
const reqOptions = {
hostname: parsedUrl.hostname,
path: parsedUrl.path,
method: options.method || 'GET',
headers: {
'Authorization': `Bot ${botToken}`,
'User-Agent': 'DiscordBot (discord-chat-relay, 1.0.0)'
}
};
if (options.method === 'POST' && options.body) {
const body = JSON.stringify(options.body);
reqOptions.headers['Content-Type'] = 'application/json';
reqOptions.headers['Content-Length'] = Buffer.byteLength(body);
}
const req = https.request(reqOptions, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
log.warn(`API error ${res.statusCode}: ${data.substring(0, 200)}`);
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
try {
resolve(JSON.parse(data));
} catch (e) {
resolve(data);
}
});
});
req.on('error', reject);
if (options.method === 'POST' && options.body) {
req.write(JSON.stringify(options.body));
}
req.end();
});
}
/**
* Dynamically discovers all available versions of a protocol message.
* @param {string} messageName - Message name (e.g. 'S_CHAT')
* @returns {number[]} Available versions sorted descending (highest first)
*/
function getMessageVersions(messageName) {
try {
const messages = mod.dispatch?.protocol?.messages;
if (messages && messages instanceof Map) {
const defs = messages.get(messageName);
if (defs instanceof Map && defs.size > 0) {
return [...defs.keys()].map(Number).sort((a, b) => b - a);
}
}
} catch (e) {
// ignore
}
return [];
}
/**
* Builds the correct fields object for a given S_CHAT version.
* @param {number} version - S_CHAT version
* @param {number} channel - Game chat channel ID
* @param {string} name - Author name
* @param {string} message - Message content
* @returns {object} Fields object for mod.toClient()
*/
function buildSChatFields(version, channel, name, message) {
if (version >= 3) {
return {
channel,
gameId: 0n,
isWorldEventTarget: false,
gm: false,
founder: false,
name,
message
};
}
// v1 and v2 share the same field structure
return {
channel,
authorName: name,
message,
blue: 0
};
}
/**
* Sends a message from Discord to the game client.
* @param {number} gameChannel - Game chat channel ID
* @param {string} username - Discord username
* @param {string} content - Message content
*/
const MAX_CHARS = 300;
function sendToGame(gameChannel, username, content) {
const prefix = `[Discord][${username}]: `;
// Truncate content so the full message fits within the game's 300-char limit
const maxContentLen = MAX_CHARS - prefix.length;
const truncatedContent = content.length > maxContentLen
? content.substring(0, maxContentLen - 3) + '...'
: content;
const formattedMessage = `${prefix}${truncatedContent}`;
log.warn(`Sending to game [${getChannelName(gameChannel)}]: ${formattedMessage}`);
// Dynamically discover available S_CHAT versions from the protocol definitions
const versions = getMessageVersions('S_CHAT');
for (const version of versions) {
try {
const fields = buildSChatFields(version, gameChannel, '[Discord]', `[${username}]: ${content}`);
mod.toClient('S_CHAT', version, fields);
log.warn(`Sent via S_CHAT v${version}`);
return;
} catch (e) {
log.debug(`S_CHAT v${version} failed: ${e.message}`);
}
}
// Last resort: use mod.command.message() (shows as system message, not in channel)
try {
if (mod.command && typeof mod.command.message === 'function') {
mod.command.message(formattedMessage);
log.warn('Sent via mod.command.message() (fallback)');
}
} catch (e2) {
log.warn(`All send methods failed: ${e2.message}`);
}
}
/**
* Compare two Discord snowflake IDs safely (they can exceed Number.MAX_SAFE_INTEGER).
* Discord snowflakes with the same length can be compared as strings.
*/
function isNewerId(newId, oldId) {
if (!oldId) return true;
// String comparison works if both have the same length
if (newId.length !== oldId.length) return newId.length > oldId.length;
return newId > oldId;
}
/**
* Polls all configured Discord channels for new messages.
*/
async function pollDiscordChannels() {
for (const [discordChannelId, gameChannelId] of Object.entries(discordChannels)) {
try {
const messages = await discordApiRequest(
`/channels/${discordChannelId}/messages?limit=5`
);
if (!Array.isArray(messages) || messages.length === 0) continue;
// Process messages from oldest to newest (reverse order)
const reversed = [...messages].reverse();
for (const msg of reversed) {
// Skip bot's own messages
if (msg.author && msg.author.bot) continue;
// Skip messages we've already seen
if (lastMessageIds[discordChannelId] && !isNewerId(msg.id, lastMessageIds[discordChannelId])) continue;
// Use display name with priority: server nickname > global display name > username
let username = 'Unknown';
if (msg.author) {
username = (msg.member && msg.member.nick) || msg.author.global_name || msg.author.username;
}
const content = stripHtmlTags(msg.content || '');
if (content.trim()) {
log.warn(`New message from ${username}: "${content.substring(0, 100)}"`);
sendToGame(parseInt(gameChannelId), username, content);
}
}
// Update the latest message ID silently
const latestId = messages[0] ? messages[0].id : null;
if (latestId && (!lastMessageIds[discordChannelId] ||
isNewerId(latestId, lastMessageIds[discordChannelId]))) {
lastMessageIds[discordChannelId] = latestId;
}
} catch (e) {
log.warn(`Poll error: ${e.message}`);
}
}
}
/**
* Initializes the Discord bot: validates the token and fetches latest message IDs
* to avoid replaying old messages on startup.
*/
async function initBot() {
log.info('Initializing Discord bot...');
try {
// Validate bot token by fetching bot user info
const botInfo = await discordApiRequest('/users/@me');
log.info(`Bot connected as: ${botInfo.username}#${botInfo.discriminator || ''}`);
// Initialize last message IDs for each channel (so we don't replay old messages)
for (const discordChannelId of Object.keys(discordChannels)) {
try {
const messages = await discordApiRequest(
`/channels/${discordChannelId}/messages?limit=1`
);
if (Array.isArray(messages) && messages.length > 0) {
lastMessageIds[discordChannelId] = messages[0].id;
}
} catch (e) {
log.warn(`Could not fetch messages for channel ${discordChannelId}`);
}
}
log.info(`Bot initialized. Watching ${Object.keys(discordChannels).length} channel(s)`);
// Start polling
pollTimer = setInterval(pollDiscordChannels, pollInterval);
} catch (e) {
log.warn(`Bot init failed: ${e.message}`);
log.warn('Check botToken in module.config.json');
}
}
// ====== INIT ======
if (!enabled) {
log.info('Mod disabled');
return;
}
log.info('Setting up C_CHAT hook');
// Hook game chat (game -> Discord)
mod.hook('C_CHAT', 1, (event) => {
try {
if (!event) {
return true;
}
const webhookType = CHANNEL_TO_WEBHOOK[event.channel];
if (!webhookType) {
return true;
}
let playerName = 'Unknown';
try {
playerName = (mod.game && mod.game.me && mod.game.me.name) || 'Unknown';
} catch (e) {
// ignore
}
const cleanMessage = stripHtmlTags(event.message);
const channelName = getChannelName(event.channel);
if (cleanMessage.trim()) {
log.warn(`Sending to Discord [${channelName}]: ${playerName}: ${cleanMessage.substring(0, 50)}`);
sendToDiscord(playerName, cleanMessage, webhookType);
}
} catch (e) {
log.warn(`Error: ${e.message}`);
}
return true;
});
// Start Discord bot (Discord -> game)
if (botToken && Object.keys(discordChannels).length > 0) {
log.info('Starting Discord bot polling...');
initBot();
} else {
log.info('Bot not configured - one-way mode only (game -> Discord)');
if (!botToken) log.warn(' Set "botToken" in module.config.json');
if (Object.keys(discordChannels).length === 0) log.warn(' Set "discordChannels" in module.config.json');
}
log.info('Mod loaded successfully');
log.info('General webhook:', webhooks.general ? 'Configured' : 'Not configured');
log.info('Trade webhook:', webhooks.trade ? 'Configured' : 'Not configured');
};
+13
View File
@@ -0,0 +1,13 @@
{
"files": {
"index.js": {
"hash": "741D6808180ACBB489672E8BD7E07E251651E7BADDF6F3751CB3DC0E9C466AD0"
},
"module.json": {
"hash": "711F2562EA3B7B427523511BA313A8FC84848B46C16AF90AD28F0E48B81BDF04"
},
"module.config.json": {
"hash": "auto"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"enabled": true,
"webhooks": {
"general": "https://discord.com/api/webhooks/...",
"trade": "https://discord.com/api/webhooks/..."
},
"botToken": "",
"discordChannels": {"00000000000000000000":27},
"pollInterval": 3000,
"logLevel": "info"
}
+9
View File
@@ -0,0 +1,9 @@
{
"disableAutoUpdate": true,
"name": "discord-chat-relay",
"author": "Archgeus",
"description": "Bidirectional chat relay between TERA and Discord. Forwards in-game chat to Discord via webhooks and relays Discord messages to the game via a bot.",
"options": {
"guiName": "Discord Chat Relay"
}
}