import Hyperswarm from 'hyperswarm'; import crypto from 'hypercore-crypto'; import b4a from 'b4a'; import ServeDrive from 'serve-drive'; import Hyperdrive from 'hyperdrive'; import Corestore from 'corestore'; import { EventEmitter } from 'events'; import fs from 'fs'; import handleCommand from './commands.js'; import MarkdownIt from 'markdown-it'; import hljs from 'highlight.js'; import DOMPurify from 'dompurify'; const md = new MarkdownIt({ highlight: function (str, lang) { if (lang && hljs.getLanguage(lang)) { try { return '
' +
hljs.highlight(str, { language: lang }).value +
'
';
} catch (__) {}
}
return '' + md.utils.escapeHtml(str) + '
';
}
});
const agentAvatarPath = './assets/agent.png';
let agentAvatar = '';
const storagePath = `./storage/`;
const store = new Corestore(storagePath);
const drive = new Hyperdrive(store);
await drive.ready();
// Load the agent avatar once when the app starts
if (fs.existsSync(agentAvatarPath)) {
const avatarBuffer = fs.readFileSync(agentAvatarPath);
agentAvatar = `data:image/png;base64,${b4a.toString(avatarBuffer, 'base64')}`;
}
let activeRooms = [];
const eventEmitter = new EventEmitter();
// Define servePort at the top level
let servePort;
// Object to store all the information we want to save
let config = {
userName: '',
userAvatar: '',
guilds: {},
registeredUsers: {}
};
// Store messages for each room
let messagesStore = {};
// Function to get a random port between 49152 and 65535
function getRandomPort() {
return Math.floor(Math.random() * (65535 - 49152 + 1)) + 49152;
}
function currentGuildTopic() {
return document.querySelector('#chat-guild-topic').innerText;
}
function getCurrentPeerCount() {
const topic = currentTopic();
const room = activeRooms.find(room => room.topic === topic);
return room ? room.swarm.connections.size : 0;
}
function updatePeerCount() {
const peerCountElement = document.querySelector('#peers-count');
if (peerCountElement) {
peerCountElement.textContent = getCurrentPeerCount(); // Display the actual peer count
}
}
async function processGuild(guildData) {
const parsedData = JSON.parse(guildData);
config.guilds[parsedData.guildTopic] = {
alias: parsedData.guildAlias,
rooms: parsedData.rooms,
owner: parsedData.owner
};
writeConfigToFile("./config.json");
renderGuildList();
await joinGuild(parsedData.guildTopic);
}
async function joinGuild(guildTopic) {
const guild = config.guilds[guildTopic];
if (guild) {
for (const room of guild.rooms) {
await joinRoom(guildTopic, room.topic, room.alias);
}
}
}
async function joinRoom(guildTopic, roomTopic, alias) {
const topicBuffer = b4a.from(roomTopic, 'hex');
addRoomToList(guildTopic, roomTopic, alias);
await joinSwarm(topicBuffer);
}
async function createRoom(guildTopic, alias) {
const roomTopicBuffer = crypto.randomBytes(32);
const roomTopic = b4a.toString(roomTopicBuffer, 'hex');
config.guilds[guildTopic].rooms.push({ topic: roomTopic, alias: alias || truncateHash(roomTopic) });
writeConfigToFile("./config.json");
addRoomToList(guildTopic, roomTopic, alias);
await joinSwarm(roomTopicBuffer);
// Synchronize the new room with other peers
const roomMessage = JSON.stringify({
type: 'room',
guildTopic,
room: { topic: roomTopic, alias: alias || truncateHash(roomTopic) }
});
const guildSwarm = activeRooms.find(room => room.topic === guildTopic);
if (guildSwarm) {
const peers = [...guildSwarm.swarm.connections];
for (const peer of peers) {
peer.write(roomMessage);
}
}
}
async function listFiles() {
const files = [];
for await (const entry of drive.readdir('/files')) {
files.push(entry);
}
return files;
}
async function deleteFile(filename) {
await drive.del(`/files/${filename}`);
}
async function initialize() {
try {
servePort = getRandomPort();
const serve = new ServeDrive({ port: servePort, get: ({ key, filename, version }) => drive });
await serve.ready();
console.log('Listening on http://localhost:' + serve.address().port);
// Event listeners setup
setupEventListeners();
const configExists = fs.existsSync("./config.json");
if (configExists) {
loadConfigFromFile();
renderGuildList();
await connectToAllRooms();
await joinAllGuilds(); // Ensure the app joins all guilds on startup
}
if (!configExists) {
document.querySelector('#register').classList.remove('hidden');
}
eventEmitter.on('onMessage', async (messageObj) => {
handleIncomingMessage(messageObj);
});
document.addEventListener("DOMContentLoaded", (event) => {
hljs.highlightAll();
});
document.addEventListener('createGuild', (event) => {
const { guildName } = event.detail;
createGuild(guildName);
});
document.addEventListener('addRoomToGuild', (event) => {
const { guildTopic, roomName } = event.detail;
createRoom(guildTopic, roomName);
});
document.addEventListener('manageGuild', (event) => {
const { guildTopic } = event.detail;
openManageGuildModal(guildTopic);
});
document.addEventListener('switchRoom', (event) => {
const { guildTopic, roomTopic } = event.detail;
if (!roomTopic) {
console.error('Invalid room topic:', roomTopic);
return;
}
console.log('Event switchRoom with roomTopic:', roomTopic);
switchRoom(guildTopic, roomTopic);
});
document.addEventListener('joinGuildRequest', (event) => {
const { guildTopic } = event.detail;
joinGuildRequest(guildTopic);
});
} catch (error) {
console.error('Error during initialization:', error);
}
}
async function joinAllGuilds() {
for (const guildTopic in config.guilds) {
await joinGuildRequest(guildTopic);
}
}
function setupEventListeners() {
const registerForm = document.querySelector('#register-form');
const selectAvatarButton = document.querySelector('#select-avatar');
const createChatRoomButton = document.querySelector('#create-chat-room');
const joinChatRoomButton = document.querySelector('#join-chat-room');
const messageForm = document.querySelector('#message-form');
const toggleSetupBtn = document.querySelector('#toggle-setup-btn');
const removeRoomBtn = document.querySelector('#remove-room-btn');
const attachFileButton = document.getElementById('attach-file');
const fileInput = document.getElementById('file-input');
const talkButton = document.getElementById('talk-btn');
const joinGuildBtn = document.getElementById('join-guild');
if (registerForm) {
registerForm.addEventListener('submit', registerUser);
}
if (selectAvatarButton) {
selectAvatarButton.addEventListener('click', () => {
document.querySelector('#avatar-file').click();
});
}
if (createChatRoomButton) {
createChatRoomButton.addEventListener('click', () => {
const guildTopic = currentGuildTopic();
createRoom(guildTopic);
});
}
if (joinChatRoomButton) {
joinChatRoomButton.addEventListener('click', joinChatRoom);
}
if (messageForm) {
messageForm.addEventListener('submit', sendMessage);
}
if (toggleSetupBtn) {
toggleSetupBtn.addEventListener('click', toggleSetupView);
}
if (removeRoomBtn) {
removeRoomBtn.addEventListener('click', () => {
const topic = currentTopic();
leaveRoom(topic);
});
}
if (attachFileButton) {
attachFileButton.addEventListener('click', () => fileInput.click());
}
if (fileInput) {
fileInput.addEventListener('change', handleFileInput);
}
if (talkButton) {
setupTalkButton();
}
if (joinGuildBtn) {
joinGuildBtn.addEventListener('click', () => {
const guildTopic = document.getElementById('join-guild-topic').value.trim();
if (guildTopic) {
joinGuildRequest(guildTopic);
}
});
}
// Add event listeners only for room items
document.querySelectorAll('.room-item').forEach(item => {
item.addEventListener('click', () => {
const guildTopic = item.dataset.guildTopic;
const roomTopic = item.dataset.topic;
if (!roomTopic) {
console.error('Invalid room topic for item:', item);
return;
}
switchRoom(guildTopic, roomTopic);
});
});
}
function handleIncomingMessage(messageObj, connection) {
console.log('Received message:', messageObj); // Debugging log
if (messageObj.type === 'icon') {
const username = messageObj.name;
if (messageObj.avatar) {
try {
const avatarBuffer = b4a.from(messageObj.avatar, 'base64');
drive.put(`/icons/${username}.png`, avatarBuffer).then(() => {
console.log(`Icon stored for user: ${username}`); // Debugging log
updateIcon(username, avatarBuffer);
}).catch(error => {
console.error(`Failed to store icon for user ${username}:`, error);
});
} catch (error) {
console.error('Error processing avatar data:', error);
}
} else {
console.error('Received icon message with missing avatar data:', messageObj);
}
} else if (messageObj.type === 'file') {
if (messageObj.file && messageObj.fileName) {
const fileBuffer = b4a.from(messageObj.file, 'base64');
drive.put(`/files/${messageObj.fileName}`, fileBuffer).then(() => {
const fileUrl = `http://localhost:${servePort}/files/${messageObj.fileName}`;
addFileMessage(messageObj.name, messageObj.fileName, updatePortInUrl(fileUrl), messageObj.fileType, updatePortInUrl(messageObj.avatar), messageObj.topic);
}).catch(error => {
console.error(`Failed to store file ${messageObj.fileName}:`, error);
});
} else {
console.error('Received file message with missing file data or fileName:', messageObj);
}
} else if (messageObj.type === 'message') {
onMessageAdded(messageObj.name, messageObj.message, messageObj.avatar, messageObj.topic, messageObj.timestamp);
} else if (messageObj.type === 'audio') {
const audioBuffer = b4a.from(messageObj.audio, 'base64');
const filePath = `/audio/${Date.now()}.webm`;
drive.put(filePath, audioBuffer).then(() => {
const audioUrl = `http://localhost:${servePort}${filePath}`;
addAudioMessage(messageObj.name, audioUrl, messageObj.avatar, messageObj.topic);
}).catch(error => {
console.error(`Failed to store audio message:`, error);
});
} else if (messageObj.type === 'guild') {
const guildData = messageObj.guildData;
processGuild(guildData);
} else if (messageObj.type === 'room') {
const { guildTopic, room } = messageObj;
if (config.guilds[guildTopic]) {
config.guilds[guildTopic].rooms.push(room);
writeConfigToFile("./config.json");
renderGuildList();
joinRoom(guildTopic, room.topic, room.alias);
}
} else if (messageObj.type === 'remove-room') {
const { guildTopic, roomTopic } = messageObj;
if (config.guilds[guildTopic]) {
const roomIndex = config.guilds[guildTopic].rooms.findIndex(room => room.topic === roomTopic);
if (roomIndex !== -1) {
config.guilds[guildTopic].rooms.splice(roomIndex, 1);
writeConfigToFile("./config.json");
renderGuildList();
leaveRoom(roomTopic);
}
}
} else if (messageObj.type === 'rename-room') {
const { guildTopic, roomTopic, newAlias } = messageObj;
if (config.guilds[guildTopic]) {
const room = config.guilds[guildTopic].rooms.find(room => room.topic === roomTopic);
if (room) {
room.alias = newAlias;
writeConfigToFile("./config.json");
// Synchronize the room rename with other peers
const renameMessage = JSON.stringify({
type: 'rename-room',
guildTopic,
roomTopic: topic,
newAlias
});
const guildSwarm = activeRooms.find(room => room.topic === guildTopic);
if (guildSwarm) {
const peers = [...guildSwarm.swarm.connections];
for (const peer of peers) {
peer.write(renameMessage);
}
}
}
}
} else if (messageObj.type === 'guildJoin') {
const { guildTopic, guildData } = messageObj;
if (!config.guilds[guildTopic]) {
config.guilds[guildTopic] = guildData;
writeConfigToFile("./config.json");
renderGuildList();
joinGuild(guildTopic);
}
} else if (messageObj.type === 'guildRequest') {
const guildTopic = messageObj.guildTopic;
const guild = config.guilds[guildTopic];
if (guild) {
const guildResponseMessage = JSON.stringify({
type: 'guildResponse',
guildData: JSON.stringify({
guildTopic,
guildAlias: guild.alias,
rooms: guild.rooms,
owner: guild.owner
})
});
connection.write(guildResponseMessage);
}
} else if (messageObj.type === 'guildResponse') {
const guildData = messageObj.guildData;
processGuild(guildData);
} else {
console.error('Received unknown message type:', messageObj);
}
}
async function handleConnection(connection, info) {
console.log('New connection', info);
// Sending the icon immediately upon connection
const iconBuffer = await drive.get(`/icons/${config.userName}.png`);
if (iconBuffer) {
const iconMessage = JSON.stringify({
type: 'icon',
name: config.userName,
avatar: b4a.toString(iconBuffer, 'base64'),
timestamp: Date.now()
});
console.log('Sending icon to new peer:', iconMessage);
connection.write(iconMessage);
} else {
console.error('Icon not found for user:', config.userName);
}
// Sending the guilds and rooms information
for (const guildTopic in config.guilds) {
const guild = config.guilds[guildTopic];
const guildMessage = JSON.stringify({
type: 'guild',
guildData: JSON.stringify({
guildTopic,
guildAlias: guild.alias,
rooms: guild.rooms,
owner: guild.owner
})
});
console.log('Sending guild information to new peer:', guildMessage);
connection.write(guildMessage);
}
connection.on('data', (data) => {
const messageObj = JSON.parse(data.toString());
if (messageObj.type === 'guildJoin') {
const guildData = config.guilds[messageObj.guildTopic];
if (guildData) {
const guildJoinMessage = JSON.stringify({
type: 'guildJoin',
guildTopic: messageObj.guildTopic,
guildData: guildData
});
connection.write(guildJoinMessage);
}
} else {
eventEmitter.emit('onMessage', messageObj, connection);
}
});
connection.on('close', () => {
console.log('Connection closed', info);
updatePeerCount();
});
connection.on('error', (error) => {
console.error('Connection error', error);
if (error.code === 'ETIMEDOUT') {
retryConnection(info.topicBuffer);
}
});
updatePeerCount();
}
function retryConnection(topicBuffer) {
if (!topicBuffer || !topicBuffer.buffer) {
console.error('Invalid topicBuffer:', topicBuffer);
return;
}
const topic = b4a.toString(topicBuffer, 'hex');
const room = activeRooms.find(room => room.topic === topic);
if (room) {
console.log('Retrying connection to room:', topic);
room.swarm.leave(topicBuffer);
joinSwarm(topicBuffer).catch((error) => {
console.error('Failed to rejoin room after timeout:', error);
});
}
}
function setupTalkButton() {
const talkButton = document.getElementById('talk-btn');
if (!talkButton) return;
let mediaRecorder;
let audioChunks = [];
talkButton.addEventListener('mousedown', async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.start();
mediaRecorder.addEventListener('dataavailable', event => {
audioChunks.push(event.data);
});
mediaRecorder.addEventListener('stop', async () => {
const audioBlob = new Blob(audioChunks);
audioChunks = [];
const arrayBuffer = await audioBlob.arrayBuffer();
const buffer = new Uint8Array(arrayBuffer);
const topic = currentTopic();
const filePath = `/audio/${Date.now()}.webm`;
await drive.put(filePath, buffer);
const audioUrl = `http://localhost:${servePort}${filePath}`;
const audioMessage = {
type: 'audio',
name: config.userName,
avatar: updatePortInUrl(config.userAvatar),
topic: topic,
timestamp: Date.now(),
audio: b4a.toString(buffer, 'base64'),
audioType: audioBlob.type
};
console.log('Sending audio message:', audioMessage); // Debugging log
const peers = [...activeRooms.find(room => room.topic === topic).swarm.connections];
for (const peer of peers) {
peer.write(JSON.stringify(audioMessage));
}
addAudioMessage(config.userName, audioUrl, config.userAvatar, topic);
});
});
talkButton.addEventListener('mouseup', () => {
if (mediaRecorder) {
mediaRecorder.stop();
}
});
talkButton.addEventListener('mouseleave', () => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
});
}
function registerUser(e) {
e.preventDefault();
const regUsername = document.querySelector('#reg-username').value;
if (config.registeredUsers[regUsername]) {
alert('Username already taken. Please choose another.');
return;
}
const avatarFile = document.querySelector('#avatar-file').files[0];
if (avatarFile) {
const reader = new FileReader();
reader.onload = async (event) => {
const buffer = new Uint8Array(event.target.result);
await drive.put(`/icons/${regUsername}.png`, buffer);
config.userAvatar = `http://localhost:${servePort}/icons/${regUsername}.png`; // Set the correct URL
config.registeredUsers[regUsername] = `http://localhost:${servePort}/icons/${regUsername}.png`; // Use placeholder URL
writeConfigToFile("./config.json");
continueRegistration(regUsername);
};
reader.readAsArrayBuffer(avatarFile);
} else {
continueRegistration(regUsername);
}
}
async function continueRegistration(regUsername) {
const loadingDiv = document.querySelector('#loading');
const setupDiv = document.querySelector('#setup');
if (!regUsername) {
alert('Please enter a username.');
return;
}
config.userName = regUsername;
setupDiv.classList.remove('hidden');
document.querySelector('#register').classList.add('hidden');
loadingDiv.classList.add('hidden');
const randomTopic = crypto.randomBytes(32);
document.querySelector('#chat-room-topic').innerText = truncateHash(b4a.toString(randomTopic, 'hex'));
writeConfigToFile("./config.json");
}
async function createGuild(guildName) {
const topicBuffer = crypto.randomBytes(32);
const topic = b4a.toString(topicBuffer, 'hex');
config.guilds[topic] = {
alias: guildName,
rooms: [],
owner: config.userName
};
addGuildToList(topic, guildName);
writeConfigToFile("./config.json");
}
async function joinChatRoom(e) {
e.preventDefault();
const guildTopic = document.querySelector('#join-guild-topic').value.trim();
const roomTopic = document.querySelector('#join-chat-room-topic').value.trim();
// Validate the topic string
const isValidGuildTopic = /^[0-9a-fA-F]{64}$/.test(guildTopic);
const isValidRoomTopic = /^[0-9a-fA-F]{64}$/.test(roomTopic);
if (!isValidGuildTopic || !isValidRoomTopic) {
alert('Invalid topic format. Please enter 64-character hexadecimal strings.');
return;
}
const guildTopicBuffer = b4a.from(guildTopic, 'hex');
const roomTopicBuffer = b4a.from(roomTopic, 'hex');
addRoomToList(guildTopic, roomTopic);
await joinSwarm(roomTopicBuffer);
// Fetch guild data from a peer
const guildJoinMessage = JSON.stringify({
type: 'guildJoin',
guildTopic: guildTopic
});
const guildSwarm = activeRooms.find(room => room.topic === guildTopic);
if (guildSwarm) {
const peers = [...guildSwarm.swarm.connections];
for (const peer of peers) {
peer.write(guildJoinMessage);
}
}
}
async function joinSwarm(topicBuffer) {
const topic = b4a.toString(topicBuffer, 'hex');
if (!activeRooms.some(room => room.topic === topic)) {
try {
const swarm = new Hyperswarm();
const discovery = swarm.join(topicBuffer, { client: true, server: true });
await discovery.flushed();
swarm.on('connection', (connection, info) => {
handleConnection(connection, info);
});
activeRooms.push({ topic, swarm, discovery });
console.log('Joined room:', topic); // Debugging log
renderMessagesForRoom(topic);
updatePeerCount();
} catch (error) {
console.error('Error joining swarm for topic:', topic, error);
}
}
}
function addGuildToList(guildTopic, alias) {
const guildList = document.querySelector('#guild-list');
const guildItem = document.createElement('li');
guildItem.textContent = alias || truncateHash(guildTopic);
guildItem.dataset.guildTopic = guildTopic;
guildItem.classList.add('guild-item');
if (config.guilds[guildTopic].owner === config.userName) {
const manageButton = document.createElement('button');
manageButton.textContent = 'Manage';
manageButton.classList.add('mini-button', 'manage-guild-btn');
manageButton.addEventListener('click', (e) => {
e.stopPropagation();
openManageGuildModal(guildTopic);
});
guildItem.appendChild(manageButton);
}
const roomList = document.createElement('ul');
roomList.classList.add('room-list');
guildItem.appendChild(roomList);
guildList.appendChild(guildItem);
// Add the rooms for this guild
config.guilds[guildTopic].rooms.forEach(room => {
addRoomToList(guildTopic, room.topic, room.alias);
});
}
function addRoomToList(guildTopic, roomTopic, alias) {
const guildItem = document.querySelector(`li[data-guild-topic="${guildTopic}"]`);
if (guildItem) {
let roomList = guildItem.querySelector('.room-list');
if (!roomList) {
roomList = document.createElement('ul');
roomList.classList.add('room-list');
guildItem.appendChild(roomList);
}
if (!roomList.querySelector(`li[data-topic="${roomTopic}"]`)) {
const roomItem = document.createElement('li');
roomItem.textContent = alias || truncateHash(roomTopic);
roomItem.dataset.topic = roomTopic;
roomItem.dataset.guildTopic = guildTopic;
roomItem.classList.add('room-item');
roomItem.addEventListener('dblclick', () => enterEditMode(roomItem));
roomItem.addEventListener('click', () => switchRoom(guildTopic, roomTopic));
roomList.appendChild(roomItem);
}
}
}
function openManageGuildModal(guildTopic) {
const guild = config.guilds[guildTopic];
if (!guild) return;
const manageGuildModal = document.getElementById('manage-guild-modal');
manageGuildModal.dataset.guildTopic = guildTopic;
const guildInfo = manageGuildModal.querySelector('#guild-info');
guildInfo.innerHTML = `