first commit

This commit is contained in:
2026-01-13 22:55:46 +01:00
parent 3a3b0b046d
commit 2faf2dd8dc
31 changed files with 8490 additions and 0 deletions

15
server/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Create data directory
RUN mkdir -p data
EXPOSE 3000
CMD ["node", "index.js"]

56
server/db.js Normal file
View File

@@ -0,0 +1,56 @@
const duckdb = require('duckdb');
const path = require('path');
const dbPath = path.join(__dirname, 'data', 'chat.duckdb');
const db = new duckdb.Database(dbPath);
const con = db.connect();
// Initialize Schema
con.exec(`
CREATE TABLE IF NOT EXISTS users (
wallet_address VARCHAR PRIMARY KEY,
username VARCHAR UNIQUE,
last_seen TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
channel_id VARCHAR,
wallet_address VARCHAR,
content VARCHAR,
timestamp TIMESTAMP,
tx_id VARCHAR,
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE TABLE IF NOT EXISTS reactions (
message_id INTEGER,
wallet_address VARCHAR,
emoji VARCHAR,
PRIMARY KEY (message_id, wallet_address, emoji),
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE SEQUENCE IF NOT EXISTS seq_msg_id START 1;
-- Migration: Add tx_id to messages if it doesn't exist (for existing DBs)
PRAGMA table_info('messages');
`, (err) => {
if (err) return console.error('Schema error:', err);
// Check if tx_id exists, if not add it
con.all("PRAGMA table_info('messages')", (err, rows) => {
if (err) return;
const hasTxId = rows.some(r => r.name === 'tx_id');
if (!hasTxId) {
con.run("ALTER TABLE messages ADD COLUMN tx_id VARCHAR", (err) => {
if (err) console.error("Error adding tx_id column:", err);
else console.log("Added tx_id column to messages table");
});
}
});
console.log('Database initialized and cleared');
});
module.exports = { db, con };

286
server/index.js Normal file
View File

@@ -0,0 +1,286 @@
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const cors = require('cors');
const { db, con } = require('./db');
const app = express();
app.use(cors());
app.use(express.json());
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*", // Allow all for dev, restrict in prod
methods: ["GET", "POST"]
}
});
const CHANNELS = [
{ id: 'nebula', name: 'Nebula' },
{ id: 'solstice', name: 'Solstice' },
{ id: 'zenith', name: 'Zenith' },
{ id: 'aether', name: 'Aether' },
{ id: 'vortex', name: 'Vortex' },
{ id: 'borealis', name: 'Borealis' },
{ id: 'chronos', name: 'Chronos' },
{ id: 'elysium', name: 'Elysium' },
{ id: 'ignis', name: 'Ignis' },
{ id: 'nova', name: 'Nova' }
];
// Store connected users in memory for "Online" status
// Map<socketId, walletAddress>
const connectedSockets = new Map();
io.on('connection', (socket) => {
console.log('A user connected:', socket.id);
socket.on('join', async ({ walletAddress, username }) => {
console.log(`User joining: ${username} (${walletAddress})`);
connectedSockets.set(socket.id, walletAddress);
const now = new Date().toISOString();
// First, check if this wallet already has a username
con.prepare(`SELECT username FROM users WHERE wallet_address = ?`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(walletAddress, (err, rows) => {
stmt.finalize();
if (err) return console.error("Execute error:", err);
if (rows.length > 0) {
// User exists, update last seen
con.prepare(`UPDATE users SET last_seen = ? WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return console.error("Prepare error:", err);
uStmt.run(now, walletAddress, (err) => {
uStmt.finalize();
if (err) console.error("Update error:", err);
broadcastUserList();
});
});
} else {
// New user, check if username is taken
con.prepare(`SELECT wallet_address FROM users WHERE username = ?`, (err, nStmt) => {
if (err) return console.error("Prepare error:", err);
nStmt.all(username, (err, uRows) => {
nStmt.finalize();
if (err) return console.error("Check error:", err);
let finalUsername = username;
if (uRows.length > 0) {
finalUsername = `${username}_${walletAddress.slice(0, 4)}`;
}
con.prepare(`INSERT INTO users (wallet_address, username, last_seen) VALUES (?, ?, ?)`, (err, iStmt) => {
if (err) return console.error("Prepare error:", err);
iStmt.run(walletAddress, finalUsername, now, (err) => {
iStmt.finalize();
if (err) console.error("Insert error:", err);
broadcastUserList();
});
});
});
});
}
});
});
});
socket.on('updateUsername', ({ walletAddress, newUsername, txId }) => {
console.log(`User ${walletAddress} requesting username change to ${newUsername} (TX: ${txId})`);
// Check if username is taken
con.prepare(`SELECT wallet_address FROM users WHERE username = ?`, (err, stmt) => {
if (err) return socket.emit('error', { message: 'Database error' });
stmt.all(newUsername, (err, rows) => {
stmt.finalize();
if (err) return socket.emit('error', { message: 'Database error' });
if (rows.length > 0) {
return socket.emit('error', { message: 'Username already taken' });
}
// Update username
con.prepare(`UPDATE users SET username = ? WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return socket.emit('error', { message: 'Failed to update username' });
uStmt.run(newUsername, walletAddress, (err) => {
uStmt.finalize();
if (err) return socket.emit('error', { message: 'Failed to update username' });
console.log(`Username updated for ${walletAddress} to ${newUsername}`);
socket.emit('usernameUpdated', { username: newUsername });
broadcastUserList();
// Also broadcast a system message about the change
const systemMsg = {
id: Date.now(),
channelId: 'nebula',
walletAddress: 'system',
username: 'System',
content: `${walletAddress.slice(0, 4)}... changed their name to ${newUsername}`,
timestamp: new Date().toISOString()
};
io.emit('newMessage', systemMsg);
});
});
});
});
});
socket.on('sendMessage', ({ channelId, walletAddress, content, txId }) => {
if (!content || content.trim() === '') return;
console.log(`Message from ${walletAddress} in ${channelId} (TX: ${txId}): ${content}`);
const timestamp = new Date().toISOString();
con.prepare(`INSERT INTO messages (id, channel_id, wallet_address, content, timestamp, tx_id)
VALUES (nextval('seq_msg_id'), ?, ?, ?, ?, ?) RETURNING id`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(channelId, walletAddress, content, timestamp, txId, (err, rows) => {
stmt.finalize();
if (err) {
console.error("Error saving message:", err);
return;
}
const msgId = rows[0].id;
con.prepare(`SELECT username FROM users WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return console.error("Prepare error:", err);
uStmt.all(walletAddress, (err, uRows) => {
uStmt.finalize();
const username = uRows.length > 0 ? uRows[0].username : walletAddress.slice(0, 4) + '...';
const message = {
id: msgId,
channelId,
walletAddress,
username,
content,
timestamp,
txId
};
io.emit('newMessage', message);
});
});
});
});
});
socket.on('toggleReaction', ({ messageId, walletAddress, emoji }) => {
console.log(`Toggling reaction: ${emoji} on message ${messageId} by ${walletAddress}`);
con.prepare(`SELECT * FROM reactions WHERE message_id = ? AND wallet_address = ? AND emoji = ?`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(messageId, walletAddress, emoji, (err, rows) => {
stmt.finalize();
if (err) return console.error("Error checking reaction:", err);
if (rows.length > 0) {
con.prepare(`DELETE FROM reactions WHERE message_id = ? AND wallet_address = ? AND emoji = ?`, (err, dStmt) => {
if (err) return console.error("Prepare error:", err);
dStmt.run(messageId, walletAddress, emoji, (err) => {
dStmt.finalize();
if (err) return console.error("Error removing reaction:", err);
broadcastReactions(messageId);
});
});
} else {
con.prepare(`INSERT INTO reactions (message_id, wallet_address, emoji) VALUES (?, ?, ?)`, (err, iStmt) => {
if (err) return console.error("Prepare error:", err);
iStmt.run(messageId, walletAddress, emoji, (err) => {
iStmt.finalize();
if (err) return console.error("Error adding reaction:", err);
broadcastReactions(messageId);
});
});
}
});
});
});
function broadcastReactions(messageId) {
console.log("Broadcasting reactions for message:", messageId);
con.prepare(`SELECT emoji, wallet_address FROM reactions WHERE message_id = ?`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(messageId, (err, rows) => {
stmt.finalize();
if (err) {
console.error("Error fetching reactions for broadcast:", err);
return;
}
console.log(`Found ${rows.length} reactions, emitting updateReactions`);
io.emit('updateReactions', { messageId, reactions: rows });
});
});
}
socket.on('disconnect', () => {
const walletAddress = connectedSockets.get(socket.id);
connectedSockets.delete(socket.id);
if (walletAddress) {
// Update last seen?
broadcastUserList();
}
console.log('User disconnected:', socket.id);
});
function broadcastUserList() {
// Get all users from DB to show offline ones too
con.prepare(`SELECT wallet_address, username, last_seen FROM users`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all((err, rows) => {
stmt.finalize();
if (err) return;
const onlineWallets = new Set(connectedSockets.values());
const users = rows.map(u => ({
...u,
online: onlineWallets.has(u.wallet_address)
}));
io.emit('userList', users);
});
});
}
});
app.get('/api/channels', (req, res) => {
res.json(CHANNELS);
});
app.get('/api/messages/:channelId', (req, res) => {
const { channelId } = req.params;
con.prepare(`
SELECT m.*, u.username
FROM messages m
LEFT JOIN users u ON m.wallet_address = u.wallet_address
WHERE m.channel_id = ?
ORDER BY m.timestamp ASC
LIMIT 100
`, (err, stmt) => {
if (err) return res.status(500).json({ error: err.message });
stmt.all(channelId, (err, messages) => {
stmt.finalize();
if (err) return res.status(500).json({ error: err.message });
// Fetch reactions for all these messages
con.prepare(`SELECT * FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)`, (err, rStmt) => {
if (err) return res.json(messages.map(m => ({ ...m, reactions: [] })));
rStmt.all(channelId, (err, reactions) => {
rStmt.finalize();
if (err) return res.json(messages.map(m => ({ ...m, reactions: [] })));
const messagesWithReactions = messages.map(m => ({
...m,
reactions: reactions.filter(r => r.message_id === m.id)
}));
res.json(messagesWithReactions);
});
});
});
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

5
server/inspect-db.js Normal file
View File

@@ -0,0 +1,5 @@
const duckdb = require('duckdb');
const db = new duckdb.Database(':memory:');
const con = db.connect();
console.log('Connection methods:', Object.keys(Object.getPrototypeOf(con)));
process.exit(0);

3334
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
server/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "server",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "mocha tests/**/*.test.js --exit"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"bs58": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"duckdb": "^1.4.3",
"express": "^5.2.1",
"socket.io": "^4.8.3",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"chai": "^6.2.2",
"mocha": "^11.7.5",
"socket.io-client": "^4.8.3"
}
}

47
server/test-db.js Normal file
View File

@@ -0,0 +1,47 @@
const duckdb = require('duckdb');
const path = require('path');
const dbPath = path.join(__dirname, 'data', 'chat.duckdb');
const db = new duckdb.Database(dbPath);
const con = db.connect();
console.log('Testing DuckDB operations...');
const walletAddress = 'test_wallet_' + Date.now();
const username = 'TestUser';
const now = new Date().toISOString();
// Test User Upsert
con.run(`INSERT INTO users (wallet_address, username, last_seen) VALUES (?, ?, ?)
ON CONFLICT (wallet_address) DO UPDATE SET last_seen = EXCLUDED.last_seen, username = EXCLUDED.username`,
[walletAddress, username, now], (err) => {
if (err) {
console.error('FAIL: User upsert failed:', err);
process.exit(1);
}
console.log('PASS: User upsert successful');
// Test Message Insert
const channelId = 'nebula';
const content = 'Test message content';
con.run(`INSERT INTO messages (id, channel_id, wallet_address, content, timestamp)
VALUES (nextval('seq_msg_id'), ?, ?, ?, ?)`,
[channelId, walletAddress, content, now], function (err) {
if (err) {
console.error('FAIL: Message insert failed:', err);
process.exit(1);
}
console.log('PASS: Message insert successful');
// Verify Data
con.all(`SELECT * FROM messages WHERE wallet_address = ?`, [walletAddress], (err, rows) => {
if (err || rows.length === 0) {
console.error('FAIL: Verification failed:', err);
process.exit(1);
}
console.log('PASS: Verification successful, found message:', rows[0].content);
console.log('All tests passed!');
process.exit(0);
});
});
});

View File

@@ -0,0 +1,70 @@
const io = require('socket.io-client');
const assert = require('chai').assert;
describe('Plexus Socket Integration Tests', function () {
this.timeout(5000);
let client;
before((done) => {
client = io('http://localhost:3000');
client.on('connect', done);
});
after(() => {
client.disconnect();
});
it('should join and broadcast user list', (done) => {
client.emit('join', { walletAddress: 'test_wallet', username: 'TestUser' });
client.on('userList', (users) => {
const user = users.find(u => u.wallet_address === 'test_wallet');
assert.exists(user);
assert.equal(user.username, 'TestUser');
done();
});
});
it('should send and receive messages', (done) => {
const msgData = {
channelId: 'nebula',
walletAddress: 'test_wallet',
content: 'Hello World',
txId: 'TX123'
};
client.emit('sendMessage', msgData);
client.on('newMessage', (msg) => {
if (msg.content === 'Hello World') {
assert.equal(msg.walletAddress, 'test_wallet');
assert.equal(msg.txId, 'TX123');
done();
}
});
});
it('should toggle reactions', (done) => {
const msgData = {
channelId: 'nebula',
walletAddress: 'test_wallet',
content: 'Reaction Test',
txId: 'TX_REACT'
};
// Set up listener first
client.once('newMessage', (msg) => {
const messageId = msg.id;
console.log('Received newMessage with ID:', messageId, 'type:', typeof messageId);
client.emit('toggleReaction', { messageId, walletAddress: 'test_wallet', emoji: '👍' });
client.on('updateReactions', (data) => {
console.log('Received updateReactions for message:', data.messageId, 'type:', typeof data.messageId);
if (String(data.messageId) === String(messageId)) {
assert.exists(data.reactions);
done();
}
});
});
client.emit('sendMessage', msgData);
});
});