1105 lines
47 KiB
TypeScript
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();
|