diff --git a/core/src/main/groovy/com/muwire/core/Core.groovy b/core/src/main/groovy/com/muwire/core/Core.groovy index 286f9fae..f1a09fcd 100644 --- a/core/src/main/groovy/com/muwire/core/Core.groovy +++ b/core/src/main/groovy/com/muwire/core/Core.groovy @@ -311,7 +311,7 @@ public class Core { connectionEstablisher = new ConnectionEstablisher(eventBus, i2pConnector, props, connectionManager, hostCache) log.info("initializing chat server") - chatServer = new ChatServer(eventBus, props, trustService, me) + chatServer = new ChatServer(eventBus, props, trustService, me, spk) eventBus.with { register(ChatMessageEvent.class, chatServer) register(ChatDisconnectionEvent.class, chatServer) diff --git a/core/src/main/groovy/com/muwire/core/chat/ChatAction.java b/core/src/main/groovy/com/muwire/core/chat/ChatAction.java index 1fa934b3..a5ef8296 100644 --- a/core/src/main/groovy/com/muwire/core/chat/ChatAction.java +++ b/core/src/main/groovy/com/muwire/core/chat/ChatAction.java @@ -1,5 +1,17 @@ package com.muwire.core.chat; enum ChatAction { - JOIN, LEAVE, SAY + JOIN(true, false), + LEAVE(false, false), + SAY(false, false), + LIST(true, true), + HELP(true, true), + INFO(true, true); + + final boolean console; + final boolean stateless; + ChatAction(boolean console, boolean stateless) { + this.console = console; + this.stateless = stateless; + } } diff --git a/core/src/main/groovy/com/muwire/core/chat/ChatCommand.groovy b/core/src/main/groovy/com/muwire/core/chat/ChatCommand.groovy index 4c7681a4..d7ea8c82 100644 --- a/core/src/main/groovy/com/muwire/core/chat/ChatCommand.groovy +++ b/core/src/main/groovy/com/muwire/core/chat/ChatCommand.groovy @@ -3,16 +3,26 @@ package com.muwire.core.chat class ChatCommand { private final ChatAction action private final String payload - + final String source ChatCommand(String source) { - int space = source.indexOf(' ') - if (space < 0) - throw new Exception("Invalid command $source") - String command = source.substring(0, space) - if (command.charAt(0) != '/') + if (source.charAt(0) != '/') throw new Exception("command doesn't start with / $source") - command = command.substring(1) + + int position = 1 + StringBuilder sb = new StringBuilder() + while(position < source.length()) { + char c = source.charAt(position) + if (c == ' ') + break + sb.append(c) + position++ + } + String command = sb.toString().toUpperCase() action = ChatAction.valueOf(command) - payload = source.substring(space + 1) + if (position < source.length()) + payload = source.substring(position + 1) + else + payload = "" + this.source = source } } diff --git a/core/src/main/groovy/com/muwire/core/chat/ChatConnection.groovy b/core/src/main/groovy/com/muwire/core/chat/ChatConnection.groovy index f6f58fc7..2445caa3 100644 --- a/core/src/main/groovy/com/muwire/core/chat/ChatConnection.groovy +++ b/core/src/main/groovy/com/muwire/core/chat/ChatConnection.groovy @@ -23,7 +23,7 @@ import net.i2p.data.Signature import net.i2p.data.SigningPrivateKey @Log -class ChatConnection implements Closeable { +class ChatConnection implements ChatLink { private static final long PING_INTERVAL = 20000 private static final long MAX_CHAT_AGE = 5 * 60 * 1000 @@ -39,6 +39,7 @@ class ChatConnection implements Closeable { private final BlockingQueue messages = new LinkedBlockingQueue() private final Thread reader, writer private final LinkedList timestamps = new LinkedList<>() + private final BlockingQueue incomingEvents = new LinkedBlockingQueue() private final DataInputStream dis private final DataOutputStream dos @@ -77,6 +78,11 @@ class ChatConnection implements Closeable { writer.start() } + @Override + public boolean isUp() { + running.get() + } + @Override public void close() { if (!running.compareAndSet(true, false)) { @@ -193,11 +199,13 @@ class ChatConnection implements Closeable { def event = new ChatMessageEvent( uuid : uuid, payload : payload, sender : sender, host : host, room : room, chatTime : chatTime, sig : sig) eventBus.publish(event) + incomingEvents.put(event) } private void handleLeave(def json) { Persona leaver = fromString(json.persona) eventBus.publish(new UserDisconnectedEvent(user : leaver, host : persona)) + incomingEvents.put(leaver) } private static Persona fromString(String base64) { @@ -257,4 +265,8 @@ class ChatConnection implements Closeable { leave.persona = p.toBase64() messages.put(leave) } + + public Object nextEvent() { + incomingEvents.take() + } } diff --git a/core/src/main/groovy/com/muwire/core/chat/ChatConnectionEvent.groovy b/core/src/main/groovy/com/muwire/core/chat/ChatConnectionEvent.groovy index 4809d782..93172d04 100644 --- a/core/src/main/groovy/com/muwire/core/chat/ChatConnectionEvent.groovy +++ b/core/src/main/groovy/com/muwire/core/chat/ChatConnectionEvent.groovy @@ -6,5 +6,5 @@ import com.muwire.core.Persona class ChatConnectionEvent extends Event { ChatConnectionAttemptStatus status Persona persona - ChatConnection connection + ChatLink connection } diff --git a/core/src/main/groovy/com/muwire/core/chat/ChatLink.java b/core/src/main/groovy/com/muwire/core/chat/ChatLink.java new file mode 100644 index 00000000..e15714ae --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/chat/ChatLink.java @@ -0,0 +1,13 @@ +package com.muwire.core.chat; + +import java.io.Closeable; + +import com.muwire.core.Persona; + +public interface ChatLink extends Closeable { + public boolean isUp(); + public void sendChat(ChatMessageEvent e); + public void sendLeave(Persona p); + public void sendPing(); + public Object nextEvent() throws InterruptedException; +} diff --git a/core/src/main/groovy/com/muwire/core/chat/ChatManager.groovy b/core/src/main/groovy/com/muwire/core/chat/ChatManager.groovy index e625b87e..7919144f 100644 --- a/core/src/main/groovy/com/muwire/core/chat/ChatManager.groovy +++ b/core/src/main/groovy/com/muwire/core/chat/ChatManager.groovy @@ -30,10 +30,13 @@ class ChatManager { } void onUIConnectChatEvent(UIConnectChatEvent e) { - if (e.host == me) - return - ChatClient client = new ChatClient(connector, eventBus, e.host, me, trustService, settings) - clients.put(e.host, client) + if (e.host == me) { + eventBus.publish(new ChatConnectionEvent(status : ChatConnectionAttemptStatus.SUCCESSFUL, + persona : me, connection : LocalChatLink.INSTANCE)) + } else { + ChatClient client = new ChatClient(connector, eventBus, e.host, me, trustService, settings) + clients.put(e.host, client) + } } void onUIDisconnectChatEvent(UIDisconnectChatEvent e) { @@ -44,7 +47,7 @@ class ChatManager { } void onChatMessageEvent(ChatMessageEvent e) { - if (e.host == me) + if (e.host == me) return if (e.sender != me) return diff --git a/core/src/main/groovy/com/muwire/core/chat/ChatServer.groovy b/core/src/main/groovy/com/muwire/core/chat/ChatServer.groovy index 03aaff7b..53e385cc 100644 --- a/core/src/main/groovy/com/muwire/core/chat/ChatServer.groovy +++ b/core/src/main/groovy/com/muwire/core/chat/ChatServer.groovy @@ -18,6 +18,7 @@ import com.muwire.core.util.DataUtil import groovy.util.logging.Log import net.i2p.data.Base64 import net.i2p.data.Destination +import net.i2p.data.SigningPrivateKey import net.i2p.util.ConcurrentHashSet @Log @@ -27,18 +28,20 @@ class ChatServer { private final MuWireSettings settings private final TrustService trustService private final Persona me + private final SigningPrivateKey spk - private final Map connections = new ConcurrentHashMap() + private final Map connections = new ConcurrentHashMap() private final Map> rooms = new ConcurrentHashMap<>() private final Map> memberships = new ConcurrentHashMap<>() private final AtomicBoolean running = new AtomicBoolean() - ChatServer(EventBus eventBus, MuWireSettings settings, TrustService trustService, Persona me) { + ChatServer(EventBus eventBus, MuWireSettings settings, TrustService trustService, Persona me, SigningPrivateKey spk) { this.eventBus = eventBus this.settings = settings this.trustService = trustService this.me = me + this.spk = spk Timer timer = new Timer("chat-server-pinger", true) timer.schedule({sendPings()} as TimerTask, 1000, 1000) @@ -46,6 +49,7 @@ class ChatServer { public void start() { running.set(true) + connections.put(me.destination, LocalChatLink.INSTANCE) } private void sendPings() { @@ -175,11 +179,18 @@ class ChatServer { log.log(Level.WARNING, "bad chat command",badCommand) return } - + + if ((command.action.console && e.room != CONSOLE) || + (!command.action.console && e.room == CONSOLE)) + return + switch(command.action) { case ChatAction.JOIN : processJoin(command.payload, e); break case ChatAction.LEAVE : processLeave(e); break case ChatAction.SAY : processSay(e); break + case ChatAction.LIST : processList(e); break + case ChatAction.INFO : processInfo(e); break + case ChatAction.HELP : processHelp(e); break } } @@ -215,6 +226,38 @@ class ChatServer { } } + private void processList(ChatMessageEvent e) { + String roomList = "/SAY " + String.join("\n", rooms.keySet()) + echo(roomList, e) + } + + private void processInfo(ChatMessageEvent e) { + String info = "/SAY The address of this server is \n${me.toBase64()}\nCopy/paste this and share it" + echo(info, e) + } + + private void processHelp(ChatMessageEvent e) { + String help = "/SAY Available commands: /JOIN /LEAVE /SAY /LIST /INFO /HELP" + echo(help, e) + } + + private void echo(String payload, ChatMessageEvent e) { + log.info "echoing $payload" + UUID uuid = UUID.randomUUID() + long now = System.currentTimeMillis() + byte [] sig = ChatConnection.sign(uuid, now, CONSOLE, payload, me, me, spk) + ChatMessageEvent echo = new ChatMessageEvent( + uuid : uuid, + payload : payload, + sender : me, + host : me, + room : CONSOLE, + chatTime : now, + sig : sig + ) + connections[e.sender.destination]?.sendChat(echo) + } + void stop() { if (running.compareAndSet(true, false)) { connections.each { k, v -> diff --git a/core/src/main/groovy/com/muwire/core/chat/LocalChatLink.groovy b/core/src/main/groovy/com/muwire/core/chat/LocalChatLink.groovy new file mode 100644 index 00000000..d66c2e8e --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/chat/LocalChatLink.groovy @@ -0,0 +1,45 @@ +package com.muwire.core.chat + +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue + +import com.muwire.core.Persona + +import groovy.util.logging.Log + +@Log +class LocalChatLink implements ChatLink { + + public static final LocalChatLink INSTANCE = new LocalChatLink() + + private final BlockingQueue messages = new LinkedBlockingQueue() + + private LocalChatLink() {} + + @Override + public void close() throws IOException { + } + + @Override + public void sendChat(ChatMessageEvent e) { + messages.put(e) + } + + @Override + public void sendLeave(Persona p) { + messages.put(p) + } + + @Override + public void sendPing() {} + + @Override + public Object nextEvent() { + messages.take() + } + + @Override + public boolean isUp() { + true + } +} diff --git a/gui/griffon-app/controllers/com/muwire/gui/ChatRoomController.groovy b/gui/griffon-app/controllers/com/muwire/gui/ChatRoomController.groovy index 1343078e..936ee0f2 100644 --- a/gui/griffon-app/controllers/com/muwire/gui/ChatRoomController.groovy +++ b/gui/griffon-app/controllers/com/muwire/gui/ChatRoomController.groovy @@ -4,18 +4,24 @@ import griffon.core.artifact.GriffonController import griffon.core.controller.ControllerAction import griffon.inject.MVCMember import griffon.metadata.ArtifactProviderFor +import groovy.util.logging.Log import net.i2p.crypto.DSAEngine import net.i2p.data.DataHelper import net.i2p.data.Signature import java.nio.charset.StandardCharsets +import java.util.logging.Level import javax.annotation.Nonnull +import com.muwire.core.Persona +import com.muwire.core.chat.ChatCommand +import com.muwire.core.chat.ChatAction import com.muwire.core.chat.ChatConnection import com.muwire.core.chat.ChatMessageEvent import com.muwire.core.chat.ChatServer +@Log @ArtifactProviderFor(GriffonController) class ChatRoomController { @MVCMember @Nonnull @@ -28,25 +34,78 @@ class ChatRoomController { String words = view.sayField.text view.sayField.setText(null) + ChatCommand command + try { + command = new ChatCommand(words) + } catch (Exception nope) { + command = new ChatCommand("/SAY $words") + } + long now = System.currentTimeMillis() UUID uuid = UUID.randomUUID() String room = model.console ? ChatServer.CONSOLE : model.room - byte [] sig = ChatConnection.sign(uuid, now, room, words, model.core.me, mvcGroup.parentGroup.model.host, model.core.spk) - + byte [] sig = ChatConnection.sign(uuid, now, room, command.source, model.core.me, mvcGroup.parentGroup.model.host, model.core.spk) + def event = new ChatMessageEvent(uuid : uuid, - payload : words, - sender : model.core.me, - host : mvcGroup.parentGroup.model.host, - room : room, - chatTime : now, - sig : sig) + payload : command.source, + sender : model.core.me, + host : mvcGroup.parentGroup.model.host, + room : room, + chatTime : now, + sig : sig) + + model.core.eventBus.publish(event) + if (command.payload.length() > 0) { + String toShow = DataHelper.formatTime(now) + " <" + model.core.me.getHumanReadableName() + "> "+command.payload + + view.roomTextArea.append(toShow) + view.roomTextArea.append('\n') + } + } + + void handleChatMessage(ChatMessageEvent e) { + ChatCommand command + try { + command = new ChatCommand(e.payload) + } catch (Exception bad) { + log.log(Level.WARNING,"bad chat command",bad) + return + } - model.core.eventBus.publish(event) - - String toShow = DataHelper.formatTime(now) + " <" + model.core.me.getHumanReadableName() + "> "+words - - view.roomTextArea.append(toShow) - view.roomTextArea.append('\n') + switch(command.action) { + case ChatAction.SAY : processSay(e, command.payload);break + case ChatAction.JOIN : processJoin(e.timestamp, e.sender); break + case ChatAction.LEAVE : processLeave(e.timestamp, e.sender); break + } + } + + private void processSay(ChatMessageEvent e, String text) { + log.info "processing say $text" + String toDisplay = DataHelper.formatTime(e.timestamp) + " <"+e.sender.getHumanReadableName()+"> " + text + "\n" + runInsideUIAsync { + view.roomTextArea.append(toDisplay) + } + } + + private void processJoin(long timestamp, Persona p) { + String toDisplay = DataHelper.formatTime(timestamp) + " " + p.getHumanReadableName() + " joined the room\n" + runInsideUIAsync { + view.roomTextArea.append(toDisplay) + } + } + + private void processLeave(long timestamp, Persona p) { + String toDisplay = DataHelper.formatTime(timestamp) + " " + p.getHumanReadableName() + " left the room\n" + runInsideUIAsync { + view.roomTextArea.append(toDisplay) + } + } + + void handleLeave(Persona p) { + String toDisplay = DataHelper.formatTime(System.currentTimeMillis()) + " " + p.getHumanReadableName() + " disconnected\n" + runInsideUIAsync { + view.roomTextArea.append(toDisplay) + } } } \ No newline at end of file diff --git a/gui/griffon-app/models/com/muwire/gui/ChatServerModel.groovy b/gui/griffon-app/models/com/muwire/gui/ChatServerModel.groovy index 4e65dadc..e962adbf 100644 --- a/gui/griffon-app/models/com/muwire/gui/ChatServerModel.groovy +++ b/gui/griffon-app/models/com/muwire/gui/ChatServerModel.groovy @@ -2,20 +2,83 @@ package com.muwire.gui import com.muwire.core.Core import com.muwire.core.Persona +import com.muwire.core.chat.ChatConnectionAttemptStatus +import com.muwire.core.chat.ChatConnectionEvent +import com.muwire.core.chat.ChatLink +import com.muwire.core.chat.ChatMessageEvent +import com.muwire.core.chat.UIConnectChatEvent import griffon.core.artifact.GriffonModel import griffon.transform.Observable +import groovy.util.logging.Log import griffon.metadata.ArtifactProviderFor +@Log @ArtifactProviderFor(GriffonModel) class ChatServerModel { Persona host Core core @Observable boolean disconnectActionEnabled - + @Observable ChatConnectionAttemptStatus status + + volatile ChatLink link + volatile Thread poller + volatile boolean running void mvcGroupInit(Map params) { disconnectActionEnabled = host != core.me // can't disconnect from myself + + core.eventBus.with { + register(ChatConnectionEvent.class, this) + publish(new UIConnectChatEvent(host : host)) + } + + running = true + poller = new Thread({eventLoop()} as Runnable) + poller.setDaemon(true) + poller.start() + } + + void mvcGroupDestroy() { + running = false + poller?.interrupt() + } + + void onChatConnectionEvent(ChatConnectionEvent e) { + if (e.connection != null) + link = e.connection + runInsideUIAsync { + status = e.status + } + } + + private void eventLoop() { + while(running) { + ChatLink link = this.link + if (link == null || !link.isUp()) { + Thread.sleep(100) + continue + } + + Object event = link.nextEvent() + if (event instanceof ChatMessageEvent) + handleChatMessage(event) + else if (event instanceof Persona) + handleLeave(event) + else + throw new IllegalArgumentException("event type $event") + } + } + + private void handleChatMessage(ChatMessageEvent e) { + log.info("dispatching to room ${e.room}") + mvcGroup.childrenGroups[e.room]?.controller?.handleChatMessage(e) + } + + private void handleLeave(Persona p) { + mvcGroup.childrenGroups.each { + it.controller.handleLeave(p) + } } } \ No newline at end of file diff --git a/gui/griffon-app/views/com/muwire/gui/ChatServerView.groovy b/gui/griffon-app/views/com/muwire/gui/ChatServerView.groovy index e9f74b2f..0dd1c7c2 100644 --- a/gui/griffon-app/views/com/muwire/gui/ChatServerView.groovy +++ b/gui/griffon-app/views/com/muwire/gui/ChatServerView.groovy @@ -5,6 +5,8 @@ import griffon.inject.MVCMember import griffon.metadata.ArtifactProviderFor import javax.swing.SwingConstants +import com.muwire.core.chat.ChatServer + import java.awt.BorderLayout import javax.annotation.Nonnull @@ -24,7 +26,10 @@ class ChatServerView { borderLayout() tabbedPane(id : model.host.getHumanReadableName()+"-chat-rooms", constraints : BorderLayout.CENTER) panel(constraints : BorderLayout.SOUTH) { + gridLayout(rows : 1, cols : 3) + panel {} button(text : "Disconnect", enabled : bind {model.disconnectActionEnabled}, disconnectAction) + label(text : bind {model.status.toString()}) } } } @@ -55,7 +60,7 @@ class ChatServerView { params['tabName'] = model.host.getHumanReadableName() + "-chat-rooms" params['room'] = 'Console' params['console'] = true - mvcGroup.createMVCGroup("chat-room","Console", params) + mvcGroup.createMVCGroup("chat-room",ChatServer.CONSOLE, params) } def closeTab = {