pummmp.fun/server/socket-server.ts
2026-07-04 12:49:09 -07:00

1105 lines
47 KiB
TypeScript

import { createServer } from "http";
import { Server, Socket } from "socket.io";
import { MongoClient, ObjectId } from "mongodb";
import dotenv from "dotenv";
import { decode } from "next-auth/jwt";
import {
NET_EVENTS,
signMessage,
verifyMessage
} from "../lib/vendor-metrics";
// Load environment variables
dotenv.config({ path: '.env.local' });
if (!process.env.MONGODB_URI) {
dotenv.config({ path: '.env' });
}
const PORT = 6767;
const MONGODB_URI = process.env.MONGODB_URI;
const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
const RAIN_DISCORD_WEBHOOK_URL = '';
const RAIN_ROLE_ID = '1465848784528474202'; // Set your rain role ID here
if (!MONGODB_URI) {
console.error("MONGODB_URI is not defined in .env");
process.exit(1);
}
const httpServer = createServer();
const io = new Server(httpServer, {
cors: {
origin: ["http://localhost:6969", "http://127.0.0.1:6969", "https://pummmp.fun", "https://www.pummmp.fun"],
methods: ["GET", "POST"],
credentials: true
}
});
interface AuthenticatedSocket extends Socket {
userId?: string;
sessionConfig?: {
market: string;
nonce: string;
nonceTimestamp: number;
};
}
async function sendDiscordRainAlert(rain: any, usersCollection: any) {
try {
const host = await usersCollection.findOne({ _id: new ObjectId(rain.hostId) });
const hostName = host ? host.name : 'Unknown';
const embed = {
title: '🌧️ Rain Started!',
description: `A new rain has been created! Join now to participate.`,
color: 0x00ff00,
fields: [
{
name: 'Amount',
value: `${rain.amount} SOL`,
inline: true
},
{
name: 'Host',
value: hostName,
inline: true
},
{
name: 'Ends',
value: `<t:${Math.floor(new Date(rain.endsAt).getTime() / 1000)}:R>`,
inline: true
}
],
timestamp: new Date().toISOString(),
footer: {
text: 'pummmp.fun',
icon_url: 'https://pummmp.fun/logo.png'
}
};
const components = [
{
type: 1, // Action row
components: [
{
type: 2, // Button
style: 5, // Link style
label: 'Participate in Rain',
url: 'https://pummmp.fun'
}
]
}
];
await fetch(RAIN_DISCORD_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: `<@&${RAIN_ROLE_ID}> 🌧️ A new rain has started!`,
embeds: [embed],
components: components // <-- this must be a top-level property, not inside embeds
})
});
} catch (error) {
console.error('Failed to send Discord rain webhook:', error);
}
}
async function startServer() {
try {
const client = new MongoClient(MONGODB_URI!);
await client.connect();
console.log("✅ Custom server connected to MongoDB");
const db = client.db("pummmpfun");
const usersCollection = db.collection("users");
const coinsCollection = db.collection("coins");
const commentsCollection = db.collection("comments");
const tradesCollection = db.collection("trades");
// Rate limiting configuration - very generous for active chat
const RATE_LIMIT_WINDOW = 60000; // 1 minute
const CHAT_RATE_LIMIT = 50; // 50 messages per minute
const ACTION_RATE_LIMIT = 30; // 30 actions per minute
const rateLimits = new Map<string, { chatCount: number, actionCount: number, lastReset: number }>();
// User mute system for rate limit violations
const userMutes = new Map<string, { mutedUntil: number, reason: string }>();
function checkRateLimit(userId: string, type: 'chat' | 'action'): boolean {
const now = Date.now();
// Check if user is currently muted
const muteInfo = userMutes.get(userId);
if (muteInfo && now < muteInfo.mutedUntil) {
console.log(`[RateLimit] User ${userId} is muted until ${new Date(muteInfo.mutedUntil).toISOString()}: ${muteInfo.reason}`);
return true;
}
let limit = rateLimits.get(userId);
if (!limit || now - limit.lastReset > RATE_LIMIT_WINDOW) {
limit = { chatCount: 0, actionCount: 0, lastReset: now };
}
if (type === 'chat') {
if (limit.chatCount >= CHAT_RATE_LIMIT) {
// Mute user for 10 seconds
const muteUntil = now + 10000;
userMutes.set(userId, {
mutedUntil: muteUntil,
reason: 'Rate limit exceeded - sending messages too fast'
});
console.log(`[RateLimit] User ${userId} muted for 10 seconds due to chat rate limit`);
return true;
}
limit.chatCount++;
} else {
if (limit.actionCount >= ACTION_RATE_LIMIT) {
// Mute user for 10 seconds
const muteUntil = now + 10000;
userMutes.set(userId, {
mutedUntil: muteUntil,
reason: 'Rate limit exceeded - too many actions'
});
console.log(`[RateLimit] User ${userId} muted for 10 seconds due to action rate limit`);
return true;
}
limit.actionCount++;
}
rateLimits.set(userId, limit);
return false;
}
// Middleware to authenticate socket connections
io.use(async (socket: AuthenticatedSocket, next) => {
try {
const cookieHeader = socket.handshake.headers.cookie;
// console.log(`[Auth] Cookie header length: ${cookieHeader ? cookieHeader.length : 0}`);
if (!cookieHeader) {
// Allow guests? Or strict auth?
// For now, let's allow connection but they won't have userId
return next();
}
// Simple cookie parsing
const getCookie = (name: string) => {
const value = `; ${cookieHeader}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
return null;
};
const sessionToken = getCookie("next-auth.session-token") || getCookie("__Secure-next-auth.session-token");
// console.log(`[Auth] Session token found: ${!!sessionToken}`);
if (sessionToken && NEXTAUTH_SECRET) {
const decoded = await decode({
token: sessionToken,
secret: NEXTAUTH_SECRET
});
if (decoded?.sub) {
socket.userId = decoded.sub;
console.log(`Authenticated user: ${socket.userId}`);
} else {
console.log(`[Auth] Failed to decode token`);
}
}
} catch (err) {
console.error("Auth middleware error:", err);
}
next();
});
console.log("✅ Watching for changes in 'coins', 'comments', and 'trades'...");
// 1. Watch for new coins and updates
const coinStream = coinsCollection.watch([], { fullDocument: 'updateLookup' });
coinStream.on("change", (change) => {
if (change.operationType === 'insert') {
io.emit('new_coin', change.fullDocument);
} else if (change.operationType === 'update') {
io.emit('update_coin', {
_id: change.documentKey._id,
...change.updateDescription?.updatedFields
});
}
});
// 2. Watch for new comments (Global Chat & Coin Chat)
const commentStream = commentsCollection.watch([], { fullDocument: 'updateLookup' });
commentStream.on("change", async (change) => {
if (change.operationType === 'insert') {
const comment = change.fullDocument;
// Emit to specific coin room/channel
io.emit(`comment:${comment.coinId}`, comment);
// Also emit to global if we decide to aggregate or if coinId is specific
if (comment.coinId === 'global') {
// V6 Broadcast
const sockets = io.sockets.sockets;
for (const [id, s] of sockets) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(comment), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
}
}
});
// 3. Watch for new trades
const tradeStream = tradesCollection.watch([], { fullDocument: 'updateLookup' });
tradeStream.on("change", (change) => {
if (change.operationType === 'insert') {
const trade = change.fullDocument;
io.emit(`trade:${trade.coinId}`, trade);
// Also update basic price info for listings globally
io.emit('trade_update', trade);
}
});
// 4. Watch for Rain events
const rainsCollection = db.collection("rains");
const rainStream = rainsCollection.watch([], { fullDocument: 'updateLookup' });
rainStream.on("change", async (change) => {
if (change.operationType === 'insert' || change.operationType === 'update') {
const rain = change.fullDocument;
if (rain && rain.participants && rain.participants.length > 0) {
const participantIds = rain.participants.map((id: string) => new ObjectId(id));
const users = await usersCollection.find({ _id: { $in: participantIds } }, { projection: { name: 1, image: 1 } }).toArray();
rain.participants = users.map(user => ({
id: user._id.toString(),
name: user.name,
image: user.image
}));
}
io.emit('rain_update', rain);
}
if (change.operationType === 'insert') {
await sendDiscordRainAlert(change.fullDocument, usersCollection);
}
});
// Periodic cleanup of expired mutes
setInterval(() => {
const now = Date.now();
for (const [userId, muteInfo] of userMutes.entries()) {
if (now > muteInfo.mutedUntil) {
userMutes.delete(userId);
}
}
}, 30000); // Clean up every 30 seconds
setInterval(async () => {
try {
const now = new Date();
// console.log("Checking for expired rains at", now.toISOString());
const activeRains = await rainsCollection.find({ active: true, endsAt: { $lte: now } }).toArray();
if (activeRains.length > 0) {
console.log(`Found ${activeRains.length} expired rains to process.`);
}
for (const rain of activeRains) {
console.log(`🌧️ Ending rain ${rain._id}`);
const participantCount = rain.participants ? rain.participants.length : 0;
if (participantCount === 0) {
// Refund host
await usersCollection.updateOne(
{ _id: new ObjectId(rain.hostId) },
{ $inc: { balance: rain.amount } }
);
io.emit('rain_ended', {
_id: rain._id,
amount: rain.amount,
payoutPerUser: 0,
totalParticipants: 0,
refunded: true
});
} else {
const payout = rain.amount / participantCount;
// Bulk update participants
// We need to convert string IDs to ObjectIds
const pIds = rain.participants.map((uid: string) => new ObjectId(uid));
await usersCollection.updateMany(
{ _id: { $in: pIds } },
{ $inc: { balance: payout } }
);
io.emit('rain_ended', {
_id: rain._id,
amount: rain.amount,
payoutPerUser: payout,
totalParticipants: participantCount
});
// Announce in chat
const botMsg = {
_id: Math.random().toString(36).substring(7),
coinId: 'global',
userId: 'pummmp.rain',
userName: 'pummmp.rain',
userImage: 'https://pummmp.fun/rain.png',
text: `🌧️ RAIN ENDED! ${rain.amount} SOL distributed to ${participantCount} users (${payout.toFixed(4)} SOL each)!`,
createdAt: new Date().toISOString(),
userVerified: true
};
// Broadcast via VM
const sockets = io.sockets.sockets;
for (const [id, s] of sockets) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(botMsg), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
}
// Deactivate rain
await rainsCollection.updateOne(
{ _id: rain._id },
{ $set: { active: false } }
);
}
} catch (e) {
console.error("Rain loop error:", e);
}
}, 5000);
io.on("connection", (socket: AuthenticatedSocket) => {
const clientIP = socket.handshake.address;
console.log(`Client ${socket.id} connected from ${socket.handshake.headers.origin} (${clientIP})`);
// Hyperion Handshake
const marketId = Math.random().toString(36).substring(7);
const nonce = Math.random().toString(36).substring(7) + Date.now().toString(36);
socket.sessionConfig = {
market: marketId,
nonce: nonce,
nonceTimestamp: Date.now()
};
socket.emit(NET_EVENTS.CONNECT, {
_h_mid: marketId, // Handshake Market ID
_h_nonce: nonce, // Session Nonce
_h_shard: "us-east-1",
});
// socket.onAny((eventName, ...args) => {
// console.log(`[Socket] Received event: ${eventName}`, args);
// });
socket.on("join_room", (room) => {
console.log(`[Socket] Joining room: ${room}`);
if (typeof room !== 'string' || room.length > 50) return;
socket.join(room);
});
socket.on("leave_room", (room) => {
if (typeof room !== 'string') return;
socket.leave(room);
});
// Hyperion Uplink Listener - Validates nonce before processing
socket.on(NET_EVENTS.OUTGOING, async (messageData: any) => {
// console.log(`[Hyperion] Received message of size ${JSON.stringify(messageData)?.length}`);
if (!socket.sessionConfig || !messageData) {
console.log("[Hyperion] Invalid message structure or no session config");
return;
}
// Additional security checks
if (typeof messageData === 'string' && messageData.length > 10000) {
console.log("[Security] Message too long, rejecting");
socket.disconnect();
return;
}
if (typeof messageData === 'object' && JSON.stringify(messageData).length > 10000) {
console.log("[Security] Payload too large, rejecting");
socket.disconnect();
return;
}
// Validate nonce
let payload: any = { text: messageData };
try {
if (typeof messageData === 'string' && messageData.startsWith('{')) {
payload = JSON.parse(messageData);
} else if (typeof messageData === 'object') {
payload = messageData;
}
} catch(e) {
console.log("JSON Parse error on payload", e);
return;
}
// Check nonce validity
if (!payload._nonce || payload._nonce !== socket.sessionConfig.nonce) {
console.log("[Hyperion] Invalid or missing nonce - possible replay attack");
socket.disconnect();
return;
}
// Check nonce age (prevent replay of old nonces)
const nonceAge = Date.now() - socket.sessionConfig.nonceTimestamp;
if (nonceAge > 600000) { // 600 second expiry (10 minutes)
console.log("[Hyperion] Nonce expired");
// socket.disconnect();
return;
}
// Rotate nonce after successful validation (only for actions, not chat)
if (payload.type === 'tip' || payload.type === 'gamble') {
const newNonce = Math.random().toString(36).substring(7) + Date.now().toString(36);
socket.sessionConfig.nonce = newNonce;
socket.sessionConfig.nonceTimestamp = Date.now();
// Send new nonce to client
socket.emit('nonce_update', { nonce: newNonce });
}
// Remove nonce from payload before processing
delete payload._nonce;
console.log(`[Hyperion] Processing: "${typeof payload.text === 'string' ? payload.text.substring(0, 50) : JSON.stringify(payload).substring(0, 50)}..." from user ${socket.userId || 'guest'}`);
// Handle Tip / Gamble / Chat based on payload internal structure or text commands
if (payload.type === 'gamble') {
await handleGambleNotification(socket, usersCollection, payload);
} else if (payload.type === 'tip') {
await handleTipNotification(socket, usersCollection, payload);
} else {
await handleGlobalMessage(socket, usersCollection, payload);
}
});
// Standard events for trades/price updates remain for public data
socket.on("new_trade", (trade) => {
io.emit(`trade:${trade.coinId}`, trade);
io.emit('trade_update', trade); // For listings
});
socket.on("disconnect", () => {});
});
// Defined Handlers
async function handleGlobalMessage(socket: Socket, usersCollection: any, payload: any) {
try {
if (!payload || typeof payload !== 'object' || typeof payload.text !== 'string') {
console.warn('[Chat Debug] Dropped: Invalid payload', payload);
return;
}
const userId = (socket as AuthenticatedSocket).userId;
if (!userId) {
console.warn('[Chat Debug] Dropped: Guests cannot send messages (only authenticated users)');
return;
}
const ipAddress = socket.handshake.address;
if (checkRateLimit(userId, 'chat')) {
const muteInfo = userMutes.get(userId);
const message = muteInfo ?
`You're temporarily muted for sending messages too fast. Try again in ${Math.ceil((muteInfo.mutedUntil - Date.now()) / 1000)} seconds.` :
'You are sending messages too fast.';
console.warn('[Chat Debug] Dropped: Rate limit exceeded for user', userId);
socket.emit('error_message', message);
return;
}
if (!payload.text.trim()) {
console.warn('[Chat Debug] Dropped: Empty message from user', userId);
return;
}
if (payload.text.length > 500) {
payload.text = payload.text.substring(0, 500);
}
let user = await usersCollection.findOne({ discordId: userId });
if (!user && ObjectId.isValid(userId)) {
user = await usersCollection.findOne({ _id: new ObjectId(userId) });
}
if (!user) {
console.warn('[Chat Debug] Dropped: No user found for userId', userId);
return;
}
// Check if user is chat banned
if (user.chatBannedUntil) {
const banExpiry = new Date(user.chatBannedUntil);
if (banExpiry > new Date()) {
const isPermanent = banExpiry.getFullYear() === 9999;
const remainingTime = isPermanent ?
'permanently' :
`${Math.ceil((banExpiry.getTime() - Date.now()) / (1000 * 60))} minutes`;
socket.emit('error_message', `You are banned from chat ${isPermanent ? 'permanently' : `for ${remainingTime} more minutes`}.`);
return;
} else {
// Ban expired, remove it
await usersCollection.updateOne(
{ _id: user._id },
{ $unset: { chatBannedUntil: 1 } }
);
}
}
// Handle admin commands
if (user.isAdmin && payload.text.startsWith('.')) {
// .ban <user> <duration>
if (payload.text.startsWith('.ban ')) {
const parts = payload.text.split(' ');
if (parts.length >= 3) {
const targetUsername = parts[1];
const durationStr = parts[2];
const targetUser = await usersCollection.findOne({ name: { $regex: new RegExp(`^${targetUsername}$`, 'i') } });
if (!targetUser) {
socket.emit('error_message', `User "${targetUsername}" not found.`);
return;
}
if (targetUser.isAdmin) {
socket.emit('error_message', 'Cannot ban admin users.');
return;
}
let banUntil: Date | null = null;
if (durationStr === 'perm' || durationStr === 'permanent') {
banUntil = new Date('9999-12-31');
} else {
const durationMatch = durationStr.match(/^(\d+)([mhd])$/);
if (durationMatch) {
const amount = parseInt(durationMatch[1]);
const unit = durationMatch[2];
let milliseconds = 0;
switch (unit) {
case 'm': milliseconds = amount * 60 * 1000; break;
case 'h': milliseconds = amount * 60 * 60 * 1000; break;
case 'd': milliseconds = amount * 24 * 60 * 60 * 1000; break;
}
banUntil = new Date(Date.now() + milliseconds);
} else {
socket.emit('error_message', 'Invalid duration format. Use: 30m, 2h, 1d, or "perm"');
return;
}
}
await usersCollection.updateOne(
{ _id: targetUser._id },
{ $set: { chatBannedUntil: banUntil } }
);
const durationText = banUntil.getFullYear() === 9999 ? 'permanently' : `for ${Math.ceil((banUntil.getTime() - Date.now()) / (1000 * 60))} minutes`;
const banMsg = {
_id: Math.random().toString(36).substring(7),
coinId: 'global',
userId: 'system',
userName: 'System',
userImage: '/logo.ico',
text: `🚫 ${targetUser.name} got pwn'd ${durationText} by @${user.name} 😆 np`,
createdAt: new Date().toISOString(),
userVerified: true
};
const sockets = io.sockets.sockets;
for (const [id, s] of sockets) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(banMsg), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
socket.emit('error_message', `Banned ${targetUser.name} ${durationText}`);
return;
}
}
// .unban <user>
else if (payload.text.startsWith('.unban ')) {
const parts = payload.text.split(' ');
if (parts.length >= 2) {
const targetUsername = parts[1];
const targetUser = await usersCollection.findOne({ name: { $regex: new RegExp(`^${targetUsername}$`, 'i') } });
if (!targetUser) {
socket.emit('error_message', `User "${targetUsername}" not found.`);
return;
}
await usersCollection.updateOne(
{ _id: targetUser._id },
{ $unset: { chatBannedUntil: 1 } }
);
const unbanMsg = {
_id: Math.random().toString(36).substring(7),
coinId: 'global',
userId: 'system',
userName: 'System',
userImage: '/logo.ico',
text: `${targetUser.name} has been unbanned from chat by ${user.name}`,
createdAt: new Date().toISOString(),
userVerified: true
};
const sockets = io.sockets.sockets;
for (const [id, s] of sockets) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(unbanMsg), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
socket.emit('error_message', `Unbanned ${targetUser.name}`);
return;
}
}
// .tipban <user> <duration>
else if (payload.text.startsWith('.tipban ')) {
const parts = payload.text.split(' ');
if (parts.length >= 3) {
const targetUsername = parts[1];
const durationStr = parts[2];
const targetUser = await usersCollection.findOne({ name: { $regex: new RegExp(`^${targetUsername}$`, 'i') } });
if (!targetUser) {
socket.emit('error_message', `User "${targetUsername}" not found.`);
return;
}
if (targetUser.isAdmin) {
socket.emit('error_message', 'Cannot tip ban admin users.');
return;
}
let banUntil: Date | null = null;
if (durationStr === 'perm' || durationStr === 'permanent') {
banUntil = new Date('9999-12-31');
} else {
const durationMatch = durationStr.match(/^(\d+)([mhd])$/);
if (durationMatch) {
const amount = parseInt(durationMatch[1]);
const unit = durationMatch[2];
let milliseconds = 0;
switch (unit) {
case 'm': milliseconds = amount * 60 * 1000; break;
case 'h': milliseconds = amount * 60 * 60 * 1000; break;
case 'd': milliseconds = amount * 24 * 60 * 60 * 1000; break;
}
banUntil = new Date(Date.now() + milliseconds);
} else {
socket.emit('error_message', 'Invalid duration format. Use: 30m, 2h, 1d, or "perm"');
return;
}
}
await usersCollection.updateOne(
{ _id: targetUser._id },
{ $set: { tipBannedUntil: banUntil } }
);
const durationText = banUntil.getFullYear() === 9999 ? 'permanently' : `for ${Math.ceil((banUntil.getTime() - Date.now()) / (1000 * 60))} minutes`;
const banMsg = {
_id: Math.random().toString(36).substring(7),
coinId: 'global',
userId: 'system',
userName: 'System',
userImage: '/logo.ico',
text: `💸🚫 ${targetUser.name} got tip banned ${durationText} by @${user.name}`,
createdAt: new Date().toISOString(),
userVerified: true
};
const sockets = io.sockets.sockets;
for (const [id, s] of sockets) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(banMsg), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
socket.emit('error_message', `Tip banned ${targetUser.name} ${durationText}`);
return;
}
}
// .tipunban <user>
else if (payload.text.startsWith('.tipunban ')) {
const parts = payload.text.split(' ');
if (parts.length >= 2) {
const targetUsername = parts[1];
const targetUser = await usersCollection.findOne({ name: { $regex: new RegExp(`^${targetUsername}$`, 'i') } });
if (!targetUser) {
socket.emit('error_message', `User "${targetUsername}" not found.`);
return;
}
await usersCollection.updateOne(
{ _id: targetUser._id },
{ $unset: { tipBannedUntil: 1 } }
);
const unbanMsg = {
_id: Math.random().toString(36).substring(7),
coinId: 'global',
userId: 'system',
userName: 'System',
userImage: '/logo.ico',
text: `💸✅ ${targetUser.name} has been unbanned from tipping by ${user.name}`,
createdAt: new Date().toISOString(),
userVerified: true
};
const sockets = io.sockets.sockets;
for (const [id, s] of sockets) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(unbanMsg), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
socket.emit('error_message', `Tip unbanned ${targetUser.name}`);
return;
}
}
// .tradeban <user> <duration>
else if (payload.text.startsWith('.tradeban ')) {
const parts = payload.text.split(' ');
if (parts.length >= 3) {
const targetUsername = parts[1];
const durationStr = parts[2];
const targetUser = await usersCollection.findOne({ name: { $regex: new RegExp(`^${targetUsername}$`, 'i') } });
if (!targetUser) {
socket.emit('error_message', `User "${targetUsername}" not found`);
return;
}
if (targetUser.isAdmin) {
socket.emit('error_message', 'Cannot trade ban admin users.');
return;
}
let banUntil: Date | null = null;
if (durationStr === 'perm' || durationStr === 'permanent') {
banUntil = new Date('9999-12-31');
} else {
const durationMatch = durationStr.match(/^(\d+)([mhd])$/);
if (durationMatch) {
const amount = parseInt(durationMatch[1]);
const unit = durationMatch[2];
let milliseconds = 0;
switch (unit) {
case 'm': milliseconds = amount * 60 * 1000; break;
case 'h': milliseconds = amount * 60 * 60 * 1000; break;
case 'd': milliseconds = amount * 24 * 60 * 60 * 1000; break;
}
banUntil = new Date(Date.now() + milliseconds);
} else {
socket.emit('error_message', 'Invalid duration format. Use: 30m, 2h, 1d, or "perm"');
return;
}
}
await usersCollection.updateOne(
{ _id: targetUser._id },
{ $set: { tradeBannedUntil: banUntil } }
);
const durationText = banUntil!.getFullYear() === 9999 ? 'permanently' : `for ${Math.ceil((banUntil!.getTime() - Date.now()) / (1000 * 60))} minutes`;
const banMsg = {
_id: Math.random().toString(36).substring(7),
coinId: 'global',
userId: 'system',
userName: 'System',
userImage: '/logo.ico',
text: `⛔️ ${targetUser.name} has been trade-banned ${durationText} by ${user.name}`,
createdAt: new Date().toISOString(),
userVerified: true
};
const sockets2 = io.sockets.sockets;
for (const [id, s] of sockets2) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(banMsg), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
socket.emit('error_message', `Trade banned ${targetUser.name} ${durationText}`);
return;
}
}
// .tradeunban <user>
else if (payload.text.startsWith('.tradeunban ')) {
const parts = payload.text.split(' ');
if (parts.length >= 2) {
const targetUsername = parts[1];
const targetUser = await usersCollection.findOne({ name: { $regex: new RegExp(`^${targetUsername}$`, 'i') } });
if (!targetUser) {
socket.emit('error_message', `User "${targetUsername}" not found`);
return;
}
await usersCollection.updateOne(
{ _id: targetUser._id },
{ $unset: { tradeBannedUntil: 1 } }
);
const unbanMsg2 = {
_id: Math.random().toString(36).substring(7),
coinId: 'global',
userId: 'system',
userName: 'System',
userImage: '/logo.ico',
text: `${targetUser.name} has been unbanned from trading by ${user.name}`,
createdAt: new Date().toISOString(),
userVerified: true
};
const sockets3 = io.sockets.sockets;
for (const [id, s] of sockets3) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(unbanMsg2), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
socket.emit('error_message', `Trade unbanned ${targetUser.name}`);
return;
}
}
}
const chatMsg = {
_id: Math.random().toString(36).substring(7),
coinId: 'global',
userId: user._id.toString(),
userName: user.name,
userImage: user.image,
text: payload.text,
createdAt: new Date().toISOString(),
userVerified: !!user.verified,
userIsAdmin: !!user.isAdmin,
userIsBetaTester: !!user.isBetaTester,
userIsBugHunter: !!user.isBugHunter,
};
const sockets = io.sockets.sockets;
for (const [id, s] of sockets) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(chatMsg), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
} catch (e) {
console.error("Error processing chat message:", e);
}
}
async function handleTipNotification(socket: Socket, usersCollection: any, payload: any) {
try {
console.log(`[Tip] Processing tip from user ${(socket as AuthenticatedSocket).userId}:`, payload);
if (!payload || typeof payload !== 'object') return;
// Validations...
const userId = (socket as AuthenticatedSocket).userId;
if (!userId) return;
if (checkRateLimit(userId, 'action')) {
const muteInfo = userMutes.get(userId);
const message = muteInfo ?
`You're temporarily muted for too many actions. Try again in ${Math.ceil((muteInfo.mutedUntil - Date.now()) / 1000)} seconds.` :
'You are performing actions too fast.';
socket.emit('error_message', message);
return;
}
let sender = await usersCollection.findOne({ discordId: userId });
if (!sender && ObjectId.isValid(userId)) {
sender = await usersCollection.findOne({ _id: new ObjectId(userId) });
}
if (!sender) {
socket.emit('error_message', 'Sender not found.');
return;
}
// Check if sender is tip banned
if (sender.tipBannedUntil) {
const banExpiry = new Date(sender.tipBannedUntil);
if (banExpiry > new Date()) {
const isPermanent = banExpiry.getFullYear() === 9999;
const remainingTime = isPermanent ?
'permanently' :
`${Math.ceil((banExpiry.getTime() - Date.now()) / (1000 * 60))} minutes`;
socket.emit('error_message', `You are tip banned ${isPermanent ? 'permanently' : `for ${remainingTime} more minutes`}.`);
return;
} else {
// Ban expired, remove it
await usersCollection.updateOne(
{ _id: sender._id },
{ $unset: { tipBannedUntil: 1 } }
);
}
}
// Find recipient
let recipient = null;
if (payload.recipientId) {
recipient = await usersCollection.findOne({ _id: new ObjectId(payload.recipientId) });
} else if (payload.recipientName) {
recipient = await usersCollection.findOne({ name: { $regex: new RegExp(`^${payload.recipientName}$`, 'i') } });
}
if (!recipient) {
socket.emit('error_message', 'Recipient not found.');
return;
}
// Check if recipient is tip banned
if (recipient.tipBannedUntil) {
const banExpiry = new Date(recipient.tipBannedUntil);
if (banExpiry > new Date()) {
socket.emit('error_message', 'Cannot tip this user - they are tip banned.');
return;
} else {
// Ban expired, remove it
await usersCollection.updateOne(
{ _id: recipient._id },
{ $unset: { tipBannedUntil: 1 } }
);
}
}
if (sender) {
const botMsg = {
_id: Math.random().toString(36).substring(7),
coinId: 'global',
userId: 'pummmp.bot',
userName: 'pummmp.bot',
userImage: 'https://pummmp.fun/logo.ico',
text: `💸 @${sender.name} tipped @${payload.recipientName || recipient.name} ${payload.amount} SOL!`,
createdAt: new Date().toISOString(),
userVerified: true
};
console.log(`[Tip] Broadcasting tip notification: ${botMsg.text}`);
const sockets = io.sockets.sockets;
for (const [id, s] of sockets) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(botMsg), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
}
} catch (e) {
console.error("Error handling tip notification:", e);
}
}
async function handleGambleNotification(socket: Socket, usersCollection: any, payload: any) {
try {
if (!payload || typeof payload !== 'object') return;
const userId = (socket as AuthenticatedSocket).userId;
if (!userId) return;
if (checkRateLimit(userId, 'action')) {
const muteInfo = userMutes.get(userId);
const message = muteInfo ?
`You're temporarily muted for too many actions. Try again in ${Math.ceil((muteInfo.mutedUntil - Date.now()) / 1000)} seconds.` :
'You are performing actions too fast.';
socket.emit('error_message', message);
return;
}
let sender = await usersCollection.findOne({ discordId: userId });
if (!sender && ObjectId.isValid(userId)) {
sender = await usersCollection.findOne({ _id: new ObjectId(userId) });
}
if (sender) {
const isBigWin = payload.won && payload.payout > payload.amount * 5;
const emoji = payload.won ? (isBigWin ? '🎰 JACKPOT! 🎰' : '🎲 WINNER!') : '💀 RIPPED';
let text = '';
if (payload.won) {
text = `${emoji} @${sender.name} bet ${payload.amount} SOL with ${payload.chance}% chance and WON ${payload.payout.toFixed(4)} SOL!`;
} else {
text = `${emoji} @${sender.name} lost ${payload.amount} SOL rolling the dice (${payload.chance}% chance)...`;
}
const botMsg = {
_id: Math.random().toString(36).substring(7),
coinId: 'global',
userId: 'pummmp.casino',
userName: 'pummmp.casino',
userImage: 'https://pummmp.fun/logo.ico',
text: text,
createdAt: new Date().toISOString(),
userVerified: true
};
const sockets = io.sockets.sockets;
for (const [id, s] of sockets) {
const authSocket = s as AuthenticatedSocket;
if (authSocket.sessionConfig) {
const bytecode = await signMessage(JSON.stringify(botMsg), authSocket.sessionConfig.market);
authSocket.emit(NET_EVENTS.INCOMING, bytecode);
}
}
}
} catch (e) {
console.error("Error handling gamble notification:", e);
}
}
httpServer.listen(PORT, () => {
console.log(`🚀 WebSocket server running on port ${PORT}`);
});
} catch (error) {
console.error("Failed to start socket server:", error);
process.exit(1);
}
}
startServer();