From 3bfba7cf250eb3a79e10c966f6cf15f8ed3f5c0e Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:16:16 -0800 Subject: [PATCH] Libchat library Integration (#55) * chore: add smoke test and redesign CI workflow (#62) Add a smoke test that validates the binary links all dependencies at runtime by instantiating a client without networking. Redesign CI into separate build and test jobs, with test gated on build. * Add libchat module * Add Context * Add libchat * Update to latest libchat * Remove stale files * Bump to latest Libchat * Update imports * Update client * Update library to work with Libchat * Fix examples * Remove Tui Examples - Replace with logos-core * Add Indentity Todo * fix: add `build-libchat` as dependency for examples, tests, and library (#59) The Rust liblogos_chat.so was not being built automatically, causing runtime failures when loading the shared library. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add Todo for Sender data * Updated error log --------- Co-authored-by: osmaczko <33099791+osmaczko@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitmodules | 3 + Makefile | 15 +- config.nims | 3 + examples/bot_echo.nim | 2 +- examples/pingpong.nim | 37 +- examples/tui.nim | 7 - examples/tui/layout.nim | 78 ---- examples/tui/persistence.nim | 135 ------ examples/tui/tui.nim | 584 -------------------------- examples/tui/utils.nim | 53 --- nim_chat_poc.nimble | 1 + src/chat.nim | 8 +- src/chat/client.nim | 196 ++++----- src/chat/conversation_store.nim | 16 - src/chat/conversations.nim | 5 - src/chat/conversations/convo_impl.nim | 28 -- src/chat/conversations/convo_type.nim | 29 -- src/chat/conversations/message.nim | 11 - src/chat/conversations/private_v1.nim | 282 ------------- src/chat/crypto.nim | 35 -- src/chat/delivery/waku_client.nim | 7 +- src/chat/errors.nim | 1 + src/chat/identity.nim | 15 +- src/chat/inbox.nim | 171 -------- src/chat/links.nim | 58 --- src/chat/proto_types.nim | 104 ----- src/chat/utils.nim | 15 - src/content_types/all.nim | 2 - vendor/libchat | 1 + 29 files changed, 143 insertions(+), 1759 deletions(-) delete mode 100644 examples/tui.nim delete mode 100644 examples/tui/layout.nim delete mode 100644 examples/tui/persistence.nim delete mode 100644 examples/tui/tui.nim delete mode 100644 examples/tui/utils.nim delete mode 100644 src/chat/conversation_store.nim delete mode 100644 src/chat/conversations.nim delete mode 100644 src/chat/conversations/convo_impl.nim delete mode 100644 src/chat/conversations/convo_type.nim delete mode 100644 src/chat/conversations/message.nim delete mode 100644 src/chat/conversations/private_v1.nim delete mode 100644 src/chat/crypto.nim delete mode 100644 src/chat/inbox.nim delete mode 100644 src/chat/links.nim delete mode 100644 src/chat/proto_types.nim create mode 160000 vendor/libchat diff --git a/.gitmodules b/.gitmodules index b35d845..04c430b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -46,3 +46,6 @@ url = https://github.com/logos-messaging/nim-ffi.git ignore = untracked branch = master +[submodule "vendor/libchat"] + path = vendor/libchat + url = https://github.com/logos-messaging/libchat.git diff --git a/Makefile b/Makefile index d243276..2c3f724 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,7 @@ update: | update-common clean: rm -rf build + cd vendor/libchat && cargo clean # must be included after the default target -include $(BUILD_SYSTEM_DIR)/makefiles/targets.mk @@ -86,9 +87,15 @@ build-waku-nat: @echo "Start building waku nat-libs" $(MAKE) -C vendor/nwaku nat-libs @echo "Completed building nat-libs" - + +.PHONY: build-libchat +build-libchat: + @echo "Start building libchat" + cd vendor/libchat && cargo build --release + @echo "Completed building libchat" + .PHONY: tests -tests: | build-waku-librln build-waku-nat nim_chat_poc.nims +tests: | build-waku-librln build-waku-nat build-libchat nim_chat_poc.nims echo -e $(BUILD_MSG) "build/$@" && \ $(ENV_SCRIPT) nim tests $(NIM_PARAMS) nim_chat_poc.nims @@ -98,7 +105,7 @@ tests: | build-waku-librln build-waku-nat nim_chat_poc.nims ########## # Ensure there is a nimble task with a name that matches the target -tui bot_echo pingpong: | build-waku-librln build-waku-nat nim_chat_poc.nims +tui bot_echo pingpong: | build-waku-librln build-waku-nat build-libchat nim_chat_poc.nims echo -e $(BUILD_MSG) "build/$@" && \ $(ENV_SCRIPT) nim $@ $(NIM_PARAMS) --path:src nim_chat_poc.nims @@ -118,7 +125,7 @@ endif LIBLOGOSCHAT := build/liblogoschat.$(LIBLOGOSCHAT_EXT) .PHONY: liblogoschat -liblogoschat: | build-waku-librln build-waku-nat nim_chat_poc.nims +liblogoschat: | build-waku-librln build-waku-nat build-libchat nim_chat_poc.nims echo -e $(BUILD_MSG) "$(LIBLOGOSCHAT)" && \ $(ENV_SCRIPT) nim liblogoschat $(NIM_PARAMS) --path:src nim_chat_poc.nims && \ echo -e "\n\x1B[92mLibrary built successfully:\x1B[39m" && \ diff --git a/config.nims b/config.nims index 27a5d0b..c0b0096 100644 --- a/config.nims +++ b/config.nims @@ -5,3 +5,6 @@ for dir in walkDir(thisDir() / "vendor"): if dir.kind == pcDir: switch("path", dir.path) switch("path", dir.path / "src") + +switch("path", thisDir() / "vendor/libchat/nim-bindings") +switch("path", thisDir() / "vendor/libchat/nim-bindings/src") \ No newline at end of file diff --git a/examples/bot_echo.nim b/examples/bot_echo.nim index 0e78481..fd2a5d2 100644 --- a/examples/bot_echo.nim +++ b/examples/bot_echo.nim @@ -24,7 +24,7 @@ proc main() {.async.} = await chatClient.start() info "EchoBot started" - info "Invite", link=chatClient.createIntroBundle().toLink() + info "Invite", link=chatClient.createIntroBundle() when isMainModule: waitFor main() diff --git a/examples/pingpong.nim b/examples/pingpong.nim index 8f07f19..5dbe00d 100644 --- a/examples/pingpong.nim +++ b/examples/pingpong.nim @@ -5,8 +5,6 @@ import strformat import chat import content_types -# TEsting -import ../src/chat/crypto proc getContent(content: ContentFrame): string = @@ -35,39 +33,38 @@ proc main() {.async.} = var saro = newClient(waku_saro, Identity(name: "saro", privateKey: sKey)) var raya = newClient(waku_raya, Identity(name: "raya", privateKey: rKey)) - var ri = 0 - # Wire Callbacks - saro.onNewMessage(proc(convo: Conversation, msg: ReceivedMessage) {.async.} = + # Wire Saro Callbacks + saro.onNewMessage(proc(convo: Conversation, msg: ReceivedMessage) {.async, closure.} = let contentFrame = msg.content.fromBytes() - echo " Saro <------ :: " & getContent(contentFrame) - await sleepAsync(5000.milliseconds) + notice " Saro <------ ", content = getContent(contentFrame) + await sleepAsync(1000.milliseconds) discard await convo.sendMessage(initTextFrame("Ping").toContentFrame().toBytes()) - - ) + ) saro.onDeliveryAck(proc(convo: Conversation, msgId: string) {.async.} = - echo " Saro -- Read Receipt for " & msgId + notice " Saro -- Read Receipt for ", msgId= msgId ) + # Wire Raya Callbacks + var i = 0 + raya.onNewConversation(proc(convo: Conversation) {.async.} = + notice " ------> Raya :: New Conversation: ", id = convo.id() + discard await convo.sendMessage(initTextFrame("Hello").toContentFrame().toBytes()) + ) raya.onNewMessage(proc(convo: Conversation,msg: ReceivedMessage) {.async.} = let contentFrame = msg.content.fromBytes() - echo fmt" ------> Raya :: from:{msg.sender} " & getContent(contentFrame) + notice " ------> Raya :: from: ", content= getContent(contentFrame) await sleepAsync(500.milliseconds) - discard await convo.sendMessage(initTextFrame("Pong" & $ri).toContentFrame().toBytes()) + discard await convo.sendMessage(initTextFrame("Pong" & $i).toContentFrame().toBytes()) await sleepAsync(800.milliseconds) - discard await convo.sendMessage(initTextFrame("Pong" & $ri).toContentFrame().toBytes()) - await sleepAsync(500.milliseconds) - discard await convo.sendMessage(initTextFrame("Pong" & $ri).toContentFrame().toBytes()) - inc ri + discard await convo.sendMessage(initTextFrame("Pang" & $i).toContentFrame().toBytes()) + inc i ) - raya.onNewConversation(proc(convo: Conversation) {.async.} = - echo " ------> Raya :: New Conversation: " & convo.id() - discard await convo.sendMessage(initTextFrame("Hello").toContentFrame().toBytes()) - ) + raya.onDeliveryAck(proc(convo: Conversation, msgId: string) {.async.} = echo " raya -- Read Receipt for " & msgId ) diff --git a/examples/tui.nim b/examples/tui.nim deleted file mode 100644 index 6120b20..0000000 --- a/examples/tui.nim +++ /dev/null @@ -1,7 +0,0 @@ -import chronos - -import tui/tui - - -when isMainModule: - waitFor main() diff --git a/examples/tui/layout.nim b/examples/tui/layout.nim deleted file mode 100644 index b72381a..0000000 --- a/examples/tui/layout.nim +++ /dev/null @@ -1,78 +0,0 @@ -import illwill - - -type - Pane* = object - xStart*: int - yStart*: int - width*: int - height*: int - -proc getPanes*(): seq[Pane] = - - let statusBarHeight = 6 - let convoWidth = 40 - let inboxHeight = 5 - let footerHeight = 14 - let modalXOffset = 10 - let modalHeight = 10 - - result = @[] - let statusBar = Pane( - xStart: 0, - yStart: 0, - width: terminalWidth(), - height: statusBarHeight - ) - result.add(statusBar) - - let convoPane = Pane( - xStart: 0, - yStart: statusBar.yStart + statusBar.height, - width: convoWidth, - height: terminalHeight() - statusBar.height - footerHeight - 1 - ) - result.add(convoPane) - - - let msgPane = Pane( - xStart: convoPane.width, - yStart: statusBar.yStart + statusBar.height, - width: terminalWidth() - convoPane.width, - height: convoPane.height - inboxHeight - ) - result.add(msgPane) - - let msgInputPane = Pane( - xStart: convoPane.width, - yStart: msgPane.yStart + msgPane.height, - width: msgPane.width, - height: inboxHeight - ) - result.add(msgInputPane) - - let footerPane = Pane( - xStart: 0, - yStart: convoPane.yStart + convoPane.height, - width: int(terminalWidth()), - height: footerHeight - ) - result.add(footerPane) - - - let modalPane = Pane( - xStart: modalXOffset, - yStart: int(terminalHeight()/2 - modalHeight/2), - width: int(terminalWidth() - 2*modalXOffset), - height: int(modalHeight) - ) - result.add(modalPane) - - -proc offsetPane(pane: Pane): Pane = - result = Pane( - xStart: pane.xStart , - yStart: pane.yStart + 1, - width: pane.width , - height: pane.height - 2 - ) diff --git a/examples/tui/persistence.nim b/examples/tui/persistence.nim deleted file mode 100644 index cfcfe46..0000000 --- a/examples/tui/persistence.nim +++ /dev/null @@ -1,135 +0,0 @@ -import chronicles -import chronos -import libp2p/crypto/crypto -import sequtils -import std/json -import std/jsonutils except distinctBase -import std/marshal -import std/options -import std/os -import std/streams -import strformat -import strutils -import tables - -import chat/crypto/ecdh -import chat/delivery/waku_client -import chat/identity - - -const REGISTRATION_DIR = ".registry" -const KEY_DIR = ".savedkeys" - - -type Config* = object - ident*: Identity - waku*: WakuConfig - -type SavedConfig* = object - name*: string - idkey*: seq[byte] - nodekey*: seq[byte] - port*: uint16 - clusterId*: uint16 - shardId*: seq[uint16] - pubsubTopic*: string - staticPeers*: seq[string] - - -proc toWakuConfig(s: SavedConfig): WakuConfig = - result = WakuConfig( - nodekey: crypto.PrivateKey.init(s.nodekey).get(), - port: s.port, - clusterId: s.clusterId, - shardId: s.shardId, - pubsubTopic: s.pubsubTopic, - staticPeers: s.staticPeers - ) - - -proc toIdent(s: SavedConfig): Identity = - result = Identity( - name: s.name, - privateKey: loadPrivateKeyFromBytes(s.idkey).get() - ) - - -proc register(name: string, multiAddr: string) {.async.} = - - notice "Registering Account", name=name, maddr=multiAddr - - if not dirExists(REGISTRATION_DIR): - createDir(REGISTRATION_DIR) - - try: - writeFile(joinPath(REGISTRATION_DIR, fmt"{name.toLower()}.maddr"), multiAddr) - except IOError as e: - echo "Failed to write registration file: ", e.msg - raise e - - -proc fetchRegistrations*(): Table[string, string] = - - let allFiles = toSeq(walkFiles(fmt"{REGISTRATION_DIR}/*")) - result = allFiles.mapIt((splitFile(it)[1], readFile(it).strip())).toTable() - - -proc loadCfg(file: string): Option[Config] = - let data = parseFile(file) - - let cfg = Config( - ident: toIdent(data.to(SavedConfig)), - waku: toWakuConfig(data.to(SavedConfig)) - ) - - result = some(cfg) - - -proc fetchCfg(name: string): Option[Config ] = - let allFiles = toSeq(walkFiles(fmt"{KEY_DIR}/*")) - - for file in allFiles: - if name == splitFile(file)[1]: - return loadCfg(file) - return none(Config) - - -proc saveCfg(name:string, cfg: Config) = - - let s = SavedConfig( - name: name, - idkey: cfg.ident.privatekey.bytes().toSeq(), - nodekey: cfg.waku.nodekey.getBytes().get(), - port: cfg.waku.port, - clusterId: cfg.waku.clusterId, - shardId: cfg.waku.shardId, - pubsubTopic: cfg.waku.pubsubTopic, - staticPeers: cfg.waku.staticPeers - ) - - let json = jsonutils.toJson(s) - - - if not dirExists(KEY_DIR): - createDir(KEY_DIR) - - try: - writeFile(joinPath(KEY_DIR, fmt"{name.toLower()}.cfg"), $json) - except IOError as e: - echo "Failed to write cfg file: ", e.msg - raise e - - -proc getCfg*(name: string): Future[Config] {.async.} = - let cfgOpt = fetchCfg(name) - if cfgOpt.isSome: - result = cfgOpt.get() - else: - let newCfg = Config( - ident: createIdentity(name), - waku: DefaultConfig() - ) - saveCfg(name, newCfg) - await register(name, newCfg.waku.getMultiAddr()) - - result = newCfg diff --git a/examples/tui/tui.nim b/examples/tui/tui.nim deleted file mode 100644 index 010f17d..0000000 --- a/examples/tui/tui.nim +++ /dev/null @@ -1,584 +0,0 @@ - -import algorithm -import chronicles -import chronos -import illwill -import libp2p/crypto/crypto -import times -import strformat -import strutils -import sugar -import tables - -import chat -import content_types/all - -import layout -import persistence -import utils - -const charVert = "│" -const charHoriz = "─" -const charTopLeft = "┌" -const charTopRight = "┐" -const charBottomLeft = "└" -const charBottomRight = "┘" - -type - - LogEntry = object - level: string - ts: DateTime - msg: string - - Message = object - sender: string - content: string - timestamp: DateTime - id: string - isAcknowledged: bool - - ConvoInfo = object - name: string - convo: Conversation - messages: seq[Message] - lastMsgTime*: DateTime - isTooLong*: bool - - ChatApp = ref object - client: Client - tb: TerminalBuffer - conversations: Table[string, ConvoInfo] - selectedConv: string - inputBuffer: string - inputInviteBuffer: string - scrollOffset: int - messageScrollOffset: int - inviteModal: bool - - currentInviteLink: string - isInviteReady: bool - peerCount: int - - logMsgs: seq[LogEntry] - -proc `==`(a,b: ConvoInfo):bool = - if a.name==b.name: - true - else: - false - - -################################################# -# Data Management -################################################# - -proc addMessage(conv: var ConvoInfo, messageId: MessageId, sender: string, content: string) = - - let now = now() - conv.messages.add(Message( - sender: sender, - id: messageId, - content: content, - timestamp: now, - isAcknowledged: false - )) - conv.lastMsgTime = now - - -proc mostRecentConvos(app: ChatApp): seq[ConvoInfo] = - var convos = collect(for v in app.conversations.values: v) - convos.sort(proc(a, b: ConvoInfo): int = -cmp(a.lastMsgTime, b.lastMsgTime)) - - return convos - -proc getSelectedConvo(app: ChatApp): ptr ConvoInfo = - if app.conversations.hasKey(app.selectedConv): - return addr app.conversations[app.selectedConv] - - return addr app.conversations[app.mostRecentConvos()[0].name] - - - -################################################# -# ChatSDK Setup -################################################# - -proc createChatClient(name: string): Future[Client] {.async.} = - var cfg = await getCfg(name) - result = newClient(cfg.waku, cfg.ident) - - -proc createInviteLink(app: var ChatApp): string = - app.client.createIntroBundle().toLink() - - -proc createConvo(app: ChatApp) {.async.} = - discard await app.client.newPrivateConversation(toBundle(app.inputInviteBuffer.strip()).get()) - -proc sendMessage(app: ChatApp, convoInfo: ptr ConvoInfo, msg: string) {.async.} = - - var msgId = "" - if convoInfo.convo != nil: - msgId = await convoInfo.convo.sendMessage(initTextFrame(msg).toContentFrame()) - - convoInfo[].addMessage(msgId, "You", app.inputBuffer) - - -proc setupChatSdk(app: ChatApp) = - - let client = app.client - - app.client.onNewMessage(proc(convo: Conversation, msg: ReceivedMessage) {.async.} = - info "New Message: ", convoId = convo.id(), msg= msg - app.logMsgs.add(LogEntry(level: "info",ts: now(), msg: "NewMsg")) - - var contentStr = case msg.content.contentType - of text: - decode(msg.content.bytes, TextFrame).get().text - of unknown: - "" - - app.conversations[convo.id()].messages.add(Message(sender: msg.sender.toHex(), content: contentStr, timestamp: now())) - ) - - app.client.onNewConversation(proc(convo: Conversation) {.async.} = - app.logMsgs.add(LogEntry(level: "info",ts: now(), msg: fmt"Adding Convo: {convo.id()}")) - info "New Conversation: ", convoId = convo.id() - - app.conversations[convo.id()] = ConvoInfo(name: convo.id(), convo: convo, messages: @[], lastMsgTime: now(), isTooLong: false) - ) - - app.client.onDeliveryAck(proc(convo: Conversation, msgId: string) {.async.} = - info "DeliveryAck", msgId=msgId - app.logMsgs.add(LogEntry(level: "info",ts: now(), msg: fmt"Ack:{msgId}")) - - var s = "" - var msgs = addr app.conversations[convo.id()].messages - for i in countdown(msgs[].high, 0): - s = fmt"{s},{msgs[i].id}" - var m = addr msgs[i] - - if m.id == msgId: - m.isAcknowledged = true - break # Stop after - ) - - -################################################# -# Draw Funcs -################################################# - -proc resetTuiCursor(tb: var TerminalBuffer) = - tb.setForegroundColor(fgWhite) - tb.setBackgroundColor(bgBlack) - - -proc drawOutline(tb: var TerminalBuffer, layout: Pane, color: ForegroundColor, - bg: BackgroundColor = bgBlack) = - - for x in layout.xStart+1.. yEnd: - convo.isTooLong = true - app.logMsgs.add(LogEntry(level: "info",ts: now(), msg: fmt" TOO LONG: {convo.name}")) - - return - tb.write(x, y, fmt"[{timeStr}] {deliveryIcon} {m.sender}") - y = y + 1 - - while remainingText.len > 0: - if y > yEnd: - convo.isTooLong = true - app.logMsgs.add(LogEntry(level: "info",ts: now(), msg: fmt" TOO LON2: {convo.name}")) - - return - - let (line, remain) = remainingText.splitAt(maxContentWidth) - remainingText = remain - - tb.write(x+3, y, line) - y = y + 1 - y = y + 1 - else: - var y = yEnd - for i in countdown(convo.messages.len-1, 0): - - let m = convo.messages[i] - let timeStr = m.timestamp.format("HH:mm:ss") - var deliveryIcon = " " - var remainingText = m.content - - if m.sender == "You": - tb.setForegroundColor(fgYellow) - deliveryIcon = if m.isAcknowledged: "✔" else: "◯" - else: - tb.setForegroundColor(fgGreen) - - # Print lines in reverse order - while remainingText.len > 0: - if (y <= yStart + 1): - return - var lineLen = remainingText.len mod maxContentWidth - if lineLen == 0: - lineLen = maxContentWidth - - let line = remainingText[^lineLen..^1] - remainingText = remainingText[0..^lineLen+1] - - tb.write(x+3, y, line) - y = y - 1 - - tb.write(x, y, fmt"[{timeStr}] {deliveryIcon} {m.sender}") - y = y - 2 - -proc drawMsgInput( app: ChatApp, layout: Pane) = - var tb = app.tb - drawOutline(tb, layout, fgCyan) - - - let inputY = layout.yStart + 2 - let paneStart = layout.xStart - - # Draw input prompt - tb.write(paneStart + 1, inputY, " > " & app.inputBuffer, fgWhite) - - # Draw cursor - let cursorX = paneStart + 3 + app.inputBuffer.len + 1 - if cursorX < paneStart + layout.width - 1: - tb.write(cursorX, inputY, "_", fgYellow) - - discard - -proc drawFooter( app: ChatApp, layout: Pane) = - var tb = app.tb - drawOutline(tb, layout, fgBlue) - - let xStart = layout.xStart + 3 - let yStart = layout.yStart + 2 - - for i in countdown(app.logMsgs.len - 1, 0): - let o = app.logMsgs[i] - let timeStr = o.ts.format("HH:mm:ss") - let s = fmt"[{timeStr}] {o.level} - {o.msg}" - app.tb.write( xStart, yStart+i*2, s ) - discard - - -proc drawModal(app: ChatApp, layout: Pane, - color: ForegroundColor, bg: BackgroundColor = bgBlack) = - - var tb = app.tb - tb.setForegroundColor(color) - tb.setBackgroundColor(bg) - for x in layout.xStart.. " & app.inputInviteBuffer) - - # Draw cursor - let cursorX = layout.xStart+5+1 + app.inputInviteBuffer.len - if cursorX < terminalWidth() - 1: - tb.write(cursorX, layout.yStart+inputLine, "_", fgYellow) - - tb.setForegroundColor(fgBlack) - tb.setBackgroundColor(bgGreen) - tb.write(layout.xStart+5, layout.yStart+inputLine+3, "InviteLink: " & app.currentInviteLink) - - resetTuiCursor(tb) - -################################################# -# Input Handling -################################################# - -proc gePreviousConv(app: ChatApp): string = - let convos = app.mostRecentConvos() - let i = convos.find(app.getSelectedConvo()[]) - - return convos[max(i-1, 0)].name - - -proc getNextConv(app: ChatApp): string = - let convos = app.mostRecentConvos() - var s = "" - for c in convos: - s = s & c.name - - let i = convos.find(app.getSelectedConvo()[]) - app.logMsgs.add(LogEntry(level: "info",ts: now(), msg: fmt"i:{convos[min(i+1, convos.len-1)].isTooLong}")) - - return convos[min(i+1, convos.len-1)].name - - -proc handleInput(app: ChatApp, key: Key) {.async.} = - case key - of Key.Up: - app.selectedConv = app.gePreviousConv() - of Key.Down: - app.selectedConv = app.getNextConv() - - of Key.PageUp: - app.messageScrollOffset = min(app.messageScrollOffset + 5, 0) - of Key.PageDown: - app.messageScrollOffset = max(app.messageScrollOffset - 5, - -(max(0, app.getSelectedConvo().messages.len - 10))) - of Key.Enter: - - if app.inviteModal: - notice "Enter Invite", link= app.inputInviteBuffer - app.inviteModal = false - app.isInviteReady = true - else: - if app.inputBuffer.len > 0 and app.conversations.len > 0: - - let sc = app.getSelectedConvo() - await app.sendMessage(sc, app.inputBuffer) - - app.inputBuffer = "" - app.messageScrollOffset = 0 # Auto-scroll to bottom - of Key.Backspace: - if app.inputBuffer.len > 0: - app.inputBuffer.setLen(app.inputBuffer.len - 1) - of Key.Tab: - app.inviteModal = not app.inviteModal - - if app.inviteModal: - app.currentInviteLink = app.client.createIntroBundle().toLink() - of Key.Escape, Key.CtrlC: - quit(0) - else: - # Handle regular character input - let ch = char(key) - if ch.isAlphaNumeric() or ch in " !@#$%^&*()_+-=[]{}|;':\",./<>?": - if app.inviteModal: - app.inputInviteBuffer.add(ch) - else: - app.inputBuffer.add(ch) - - -################################################# -# Tasks -################################################# - -proc appLoop(app: ChatApp, panes: seq[Pane]) : Future[void] {.async.} = - illwillInit(fullscreen = false) - # Clear buffer - while true: - await sleepAsync(chronos.milliseconds(5)) - app.tb.clear() - - drawStatusBar(app, panes[0], fgBlack, getIdColor(app.client.getId())) - drawConversationPane(app, panes[1]) - drawMsgPane(app, panes[2]) - - if app.inviteModal: - drawModal(app, panes[5], fgYellow, bgGreen) - else: - drawMsgInput(app, panes[3]) - - drawFooter(app, panes[4]) - - # Draw help text - app.tb.write(1, terminalHeight()-1, "Tab: Invite Modal | ↑/↓: Select conversation | PgUp/PgDn: Scroll messages | Enter: Send | Esc: Quit", fgGreen) - - # Display buffer - app.tb.display() - - # Handle input - let key = getKey() - await handleInput(app, key) - - if app.isInviteReady: - - try: - let sanitized = app.inputInviteBuffer.replace(" ", "").replace("\r","") - discard await app.client.newPrivateConversation(toBundle(app.inputInviteBuffer.strip()).get()) - - except Exception as e: - info "bad invite", invite = app.inputInviteBuffer - app.inputInviteBuffer = "" - app.isInviteReady = false - -proc peerWatch(app: ChatApp): Future[void] {.async.} = - while true: - await sleepAsync(chronos.seconds(1)) - app.peerCount = app.client.ds.getConnectedPeerCount() - - -################################################# -# Main -################################################# - -proc initChatApp(client: Client): Future[ChatApp] {.async.} = - - var app = ChatApp( - client: client, - tb: newTerminalBuffer(terminalWidth(), terminalHeight()), - conversations: initTable[string, ConvoInfo](), - selectedConv: "Bob", - inputBuffer: "", - scrollOffset: 0, - messageScrollOffset: 0, - isInviteReady: false, - peerCount: -1, - logMsgs: @[] - ) - - app.setupChatSdk() - await app.client.start() - - - # Add some sample conversations with messages - var sender = "Nobody" - var conv1 = ConvoInfo(name: "ReadMe", messages: @[]) - conv1.addMessage("",sender, "First start multiple clients and ensure, that he PeerCount is correct (it's listed in the top left corner)") - conv1.addMessage("",sender, "Once connected, The sender needs to get the recipients introduction link. The links contains the key material and information required to initialize a conversation. Press `Tab` to generate a link") - conv1.addMessage("",sender, "Paste the link from one client into another. This will start the initialization protocol, which will send an invite to the recipient and negotiate a conversation") - conv1.addMessage("",sender, "Once established, Applications are notified by a callback that a new conversation has been established, and participants can send messages") - - - - app.conversations[conv1.name] = conv1 - - return app - - -proc main*() {.async.} = - - let args = getCmdArgs() - let client = await createChatClient(args.username) - var app = await initChatApp(client) - - let tasks: seq[Future[void]] = @[ - appLoop(app, getPanes()), - peerWatch(app) - ] - - discard await allFinished(tasks) - -when isMainModule: - waitFor main() - -# this is not nim code \ No newline at end of file diff --git a/examples/tui/utils.nim b/examples/tui/utils.nim deleted file mode 100644 index 8b56ca4..0000000 --- a/examples/tui/utils.nim +++ /dev/null @@ -1,53 +0,0 @@ -import std/parseopt -import illwill -import times - -################################################# -# Command Line Args -################################################# - -type CmdArgs* = object - username*: string - invite*: string - -proc getCmdArgs*(): CmdArgs = - var username = "" - var invite = "" - for kind, key, val in getopt(): - case kind - of cmdArgument: - discard - of cmdLongOption, cmdShortOption: - case key - of "name", "n": - username = val - of "invite", "i": - invite = val - of cmdEnd: - break - if username == "": - username = "" - - result = CmdArgs(username: username, invite:invite) - - -################################################# -# Utils -################################################# - -proc getIdColor*(id: string): BackgroundColor = - var i = ord(id[0]) - - - let colors = @[bgCyan, - bgGreen, - bgMagenta, - bgRed, - bgYellow, - bgBlue - ] - - return colors[i mod colors.len] - -proc toStr*(ts: DateTime): string = - ts.format("HH:mm:ss") \ No newline at end of file diff --git a/nim_chat_poc.nimble b/nim_chat_poc.nimble index 04eaf01..c10c360 100644 --- a/nim_chat_poc.nimble +++ b/nim_chat_poc.nimble @@ -21,6 +21,7 @@ requires "nim >= 2.2.4", "regex", "web3", "https://github.com/jazzz/nim-sds#exports", + "libchat", "waku", "ffi" diff --git a/src/chat.nim b/src/chat.nim index 754d8da..8cabde4 100644 --- a/src/chat.nim +++ b/src/chat.nim @@ -1,17 +1,13 @@ import chat/[ client, - crypto, - conversations, delivery/waku_client, identity, - links, types ] -export client, conversations, identity, links, waku_client +export client, identity, waku_client +export identity.`$` #export specific frames need by applications export MessageId -export toHex -export crypto.`$` diff --git a/src/chat/client.nim b/src/chat/client.nim index ef3d6c4..c98e554 100644 --- a/src/chat/client.nim +++ b/src/chat/client.nim @@ -6,24 +6,15 @@ import # Foreign chronicles, chronos, - sds, - sequtils, - std/tables, - std/sequtils, + libchat, + std/options, strformat, - strutils, - tables, types import #local - conversations, - conversations/convo_impl, - crypto, delivery/waku_client, errors, identity, - inbox, - proto_types, types, utils @@ -35,6 +26,27 @@ logScope: # Definitions ################################################# +# Type used to return message data via callback +type ReceivedMessage* = ref object of RootObj + sender*: PublicKey + timestamp*: int64 + content*: seq[byte] + + +type ConvoType = enum + PrivateV1 + +type Conversation* = object + ctx: LibChat + convoId: string + ds: WakuClient + convo_type: ConvoType + + +proc id*(self: Conversation): string = + return self.convoId + + type MessageCallback* = proc(conversation: Conversation, msg: ReceivedMessage): Future[void] {.async.} NewConvoCallback* = proc(conversation: Conversation): Future[void] {.async.} @@ -48,13 +60,11 @@ type KeyEntry* = object timestamp: int64 type ChatClient* = ref object - ident: Identity + libchatCtx: LibChat ds*: WakuClient - keyStore: Table[string, KeyEntry] # Keyed by HexEncoded Public Key - conversations: Table[string, Conversation] # Keyed by conversation ID + id: string inboundQueue: QueueRef isRunning: bool - inbox: Inbox newMessageCallbacks: seq[MessageCallback] newConvoCallbacks: seq[NewConvoCallback] @@ -64,30 +74,24 @@ type ChatClient* = ref object # Constructors ################################################# -proc newClient*(ds: WakuClient, ident: Identity): ChatClient {.raises: [IOError, - ValueError, SerializationError].} = +proc newClient*(ds: WakuClient, ident: Identity): ChatClient {.raises: [IOError, ValueError].} = ## Creates new instance of a `ChatClient` with a given `WakuConfig` + ## TODO: (P1) Currently the passed in Identity is not used. Libchat Generates one for every invocation. try: - let rm = newReliabilityManager().valueOr: - raise newException(ValueError, fmt"SDS InitializationError") - - let defaultInbox = initInbox(ident) var q = QueueRef(queue: newAsyncQueue[ChatPayload](10)) - var c = ChatClient(ident: ident, + var c = ChatClient( + libchatCtx: newConversationsContext(), ds: ds, - keyStore: initTable[string, KeyEntry](), - conversations: initTable[string, Conversation](), + id: ident.getName(), inboundQueue: q, isRunning: false, - inbox: defaultInbox, newMessageCallbacks: @[], newConvoCallbacks: @[]) - c.conversations[defaultInbox.id()] = defaultInbox - notice "Client started", client = c.ident.getName(), - defaultInbox = defaultInbox, inTopic= topic_inbox(c.ident.get_addr()) + notice "Client started", client = c.id + result = c except Exception as e: error "newCLient", err = e.msg @@ -97,27 +101,12 @@ proc newClient*(ds: WakuClient, ident: Identity): ChatClient {.raises: [IOError, ################################################# proc getId*(client: ChatClient): string = - result = client.ident.getName() - -proc identity*(client: ChatClient): Identity = - result = client.ident - -proc defaultInboxConversationId*(self: ChatClient): string = - ## Returns the default inbox address for the client. - result = conversationIdFor(self.ident.getPubkey()) - -proc getConversationFromHint(self: ChatClient, - conversationHint: string): Result[Option[Conversation], string] = - - # TODO: Implementing Hinting - if not self.conversations.hasKey(conversationHint): - ok(none(Conversation)) - else: - ok(some(self.conversations[conversationHint])) + result = client.id proc listConversations*(client: ChatClient): seq[Conversation] = - result = toSeq(client.conversations.values()) + # TODO: (P1) Implement list conversations + result = @[] ################################################# # Callback Handling @@ -135,6 +124,7 @@ proc onNewConversation*(client: ChatClient, callback: NewConvoCallback) = proc notifyNewConversation(client: ChatClient, convo: Conversation) = for cb in client.newConvoCallbacks: + debug "calling OnConvo CB", client=client.getId(), len = client.newConvoCallbacks.len() discard cb(convo) proc onDeliveryAck*(client: ChatClient, callback: DeliveryAckCallback) = @@ -149,51 +139,47 @@ proc notifyDeliveryAck(client: ChatClient, convo: Conversation, # Functional ################################################# -proc createIntroBundle*(self: var ChatClient): IntroBundle = +proc createIntroBundle*(self: var ChatClient): seq[byte] = ## Generates an IntroBundle for the client, which includes ## the required information to send a message. - - # Create Ephemeral keypair, save it in the key store - let ephemeralKey = generateKey() - - self.keyStore[ephemeralKey.getPublicKey().bytes().bytesToHex()] = KeyEntry( - keyType: "ephemeral", - privateKey: ephemeralKey, - timestamp: getCurrentTimestamp() - ) - - result = IntroBundle( - ident: @(self.ident.getPubkey().bytes()), - ephemeral: @(ephemeralKey.getPublicKey().bytes()), - ) - + result = self.libchatCtx.createIntroductionBundle().valueOr: + error "could not create bundle",error=error, client = self.getId() + return + notice "IntroBundleCreated", client = self.getId(), - pubBytes = result.ident + bundle = result + +proc sendPayloads(ds: WakuClient, payloads: seq[PayloadResult]) = + for payload in payloads: + # TODO: (P2) surface errors + discard ds.sendBytes(payload.address, payload.data) ################################################# # Conversation Initiation ################################################# -proc addConversation*(client: ChatClient, convo: Conversation) = - notice "Creating conversation", client = client.getId(), convoId = convo.id() - client.conversations[convo.id()] = convo - client.notifyNewConversation(convo) proc getConversation*(client: ChatClient, convoId: string): Conversation = - notice "Get conversation", client = client.getId(), convoId = convoId - result = client.conversations[convoId] + result = Conversation(ctx:client.libchatCtx, convoId:convoId, ds: client.ds, convo_type: PrivateV1) proc newPrivateConversation*(client: ChatClient, - introBundle: IntroBundle, content: Content): Future[Option[ChatError]] {.async.} = - ## Creates a private conversation with the given `IntroBundle`. - ## `IntroBundles` are provided out-of-band. - let remote_pubkey = loadPublicKeyFromBytes(introBundle.ident).get() - let remote_ephemeralkey = loadPublicKeyFromBytes(introBundle.ephemeral).get() + introBundle: seq[byte], content: Content): Future[Option[ChatError]] {.async.} = - let convo = await client.inbox.inviteToPrivateConversation(client.ds,remote_pubkey, remote_ephemeralkey, content ) - client.addConversation(convo) # TODO: Fix re-entrantancy bug. Convo needs to be saved before payload is sent. + let res = client.libchatCtx.createNewPrivateConvo(introBundle, content) + let (convoId, payloads) = res.valueOr: + error "could not create bundle",error=error, client = client.getId() + return some(ChatError(code: errLibChat, context:fmt"got: {error}" )) + + client.ds.sendPayloads(payloads); + + + client.notifyNewConversation(Conversation(ctx: client.libchatCtx, + convoId : convoId, ds: client.ds, convo_type: ConvoType.PrivateV1 + )) + + notice "CREATED", client=client.getId(), convoId=convoId return none(ChatError) @@ -202,31 +188,33 @@ proc newPrivateConversation*(client: ChatClient, # Receives a incoming payload, decodes it, and processes it. ################################################# -proc parseMessage(client: ChatClient, msg: ChatPayload) {.raises: [ValueError, - SerializationError].} = - let envelopeRes = decode(msg.bytes, WapEnvelopeV1) - if envelopeRes.isErr: - debug "Failed to decode WapEnvelopeV1", client = client.getId(), err = envelopeRes.error - return - let envelope = envelopeRes.get() - - let convo = block: - let opt = client.getConversationFromHint(envelope.conversationHint).valueOr: - raise newException(ValueError, "Failed to get conversation: " & error) - - if opt.isSome(): - opt.get() - else: - let k = toSeq(client.conversations.keys()).join(", ") - warn "No conversation found", client = client.getId(), - hint = envelope.conversationHint, knownIds = k - return +proc parseMessage(client: ChatClient, msg: ChatPayload) {.raises: [ValueError].} = try: - convo.handleFrame(client, envelope.payload) + let opt_content = client.libchatCtx.handlePayload(msg.bytes).valueOr: + error "handlePayload" , error=error, client=client.getId() + return + + if opt_content.isSome(): + let content = opt_content.get() + let convo = client.getConversation(content.conversationId) + # TODO: (P1) Add sender information from LibChat. + let msg = ReceivedMessage(sender: nil,timestamp:getCurrentTimestamp(),content: content.data ) + client.notifyNewMessage(convo, msg) + else: + debug "Parsed message generated no content", client=client.getId() + except Exception as e: error "HandleFrame Failed", error = e.msg +proc sendMessage*(convo: Conversation, content: Content) : Future[MessageId] {.async, gcsafe.} = + let payloads = convo.ctx.sendContent(convo.convoId, content).valueOr: + error "SendMessage", e=error + return "error" + + convo.ds.sendPayloads(payloads); + + ################################################# # Async Tasks ################################################# @@ -237,20 +225,9 @@ proc messageQueueConsumer(client: ChatClient) {.async.} = while client.isRunning: let message = await client.inboundQueue.queue.get() + debug "Got WakuMessage", client = client.getId() , topic= message.content_topic, len=message.bytes.len() - let topicRes = inbox.parseTopic(message.contentTopic).or(private_v1.parseTopic(message.contentTopic)) - if topicRes.isErr: - debug "Invalid content topic", client = client.getId(), err = topicRes.error, contentTopic = message.contentTopic - continue - - notice "Inbound Message Received", client = client.getId(), - contentTopic = message.contentTopic, len = message.bytes.len() - try: - client.parseMessage(message) - - except CatchableError as e: - error "Error in message listener", err = e.msg, - pubsub = message.pubsubTopic, contentTopic = message.contentTopic + client.parseMessage(message) ################################################# @@ -271,5 +248,6 @@ proc start*(client: ChatClient) {.async.} = proc stop*(client: ChatClient) {.async.} = ## Stop the client. await client.ds.stop() + client.libchatCtx.destroy() client.isRunning = false notice "Client stopped", client = client.getId() diff --git a/src/chat/conversation_store.nim b/src/chat/conversation_store.nim deleted file mode 100644 index e629ea7..0000000 --- a/src/chat/conversation_store.nim +++ /dev/null @@ -1,16 +0,0 @@ -import ./conversations/[convo_type, message] -import identity -import types - -type ConvoId = string - -type - ConversationStore* = concept - proc addConversation(self: Self, convo: Conversation) - proc getConversation(self: Self, convoId: string): Conversation - proc identity(self: Self): Identity - proc getId(self: Self): string - - proc notifyNewMessage(self: Self, convo: Conversation, msg: ReceivedMessage) - proc notifyDeliveryAck(self: Self, convo: Conversation, - msgId: MessageId) diff --git a/src/chat/conversations.nim b/src/chat/conversations.nim deleted file mode 100644 index eb426a7..0000000 --- a/src/chat/conversations.nim +++ /dev/null @@ -1,5 +0,0 @@ -import - ./conversations/[convo_type, private_v1, message] - - -export private_v1, convo_type, message diff --git a/src/chat/conversations/convo_impl.nim b/src/chat/conversations/convo_impl.nim deleted file mode 100644 index 60fb9e5..0000000 --- a/src/chat/conversations/convo_impl.nim +++ /dev/null @@ -1,28 +0,0 @@ -import ../conversation_store -import ../conversations -import ../inbox - - -proc getType(convo: Conversation): ConvoTypes = - if convo of Inbox: - return InboxV1Type - - elif convo of PrivateV1: - return PrivateV1Type - - else: - raise newException(Defect, "Conversation Type not processed") - -proc handleFrame*[T: ConversationStore](convo: Conversation, client: T, - bytes: seq[byte]) = - - case convo.getType(): - of InboxV1Type: - let inbox = Inbox(convo) - inbox.handleFrame(client, bytes) - - of PrivateV1Type: - let priv = PrivateV1(convo) - priv.handleFrame(client, bytes) - - diff --git a/src/chat/conversations/convo_type.nim b/src/chat/conversations/convo_type.nim deleted file mode 100644 index b810eed..0000000 --- a/src/chat/conversations/convo_type.nim +++ /dev/null @@ -1,29 +0,0 @@ -import chronos -import strformat -import strutils - -import ../proto_types -import ../utils -import ../types - -type - ConvoTypes* = enum - InboxV1Type, PrivateV1Type - -type - Conversation* = ref object of RootObj - name: string - -proc `$`(conv: Conversation): string = - fmt"Convo: {conv.name}" - -# TODO: Removing the raises clause and the exception raise causes this -# error --> ...src/chat_sdk/client.nim(166, 9) Error: addConversation(client, convo) can raise an unlisted exception: Exception -# Need better understanding of NIMs Exception model -method id*(self: Conversation): string {.raises: [Defect, ValueError].} = - # TODO: make this a compile time check - panic("ProgramError: Missing concrete implementation") - -method sendMessage*(convo: Conversation, content_frame: Content) : Future[MessageId] {.async, base, gcsafe.} = - # TODO: make this a compile time check - panic("ProgramError: Missing concrete implementation") diff --git a/src/chat/conversations/message.nim b/src/chat/conversations/message.nim deleted file mode 100644 index 6f82edf..0000000 --- a/src/chat/conversations/message.nim +++ /dev/null @@ -1,11 +0,0 @@ -import ../crypto - -# How to surface different verifability of properties across conversation types - - -type ReceivedMessage* = ref object of RootObj - sender*: PublicKey - timestamp*: int64 - content*: seq[byte] - - diff --git a/src/chat/conversations/private_v1.nim b/src/chat/conversations/private_v1.nim deleted file mode 100644 index 9e8b264..0000000 --- a/src/chat/conversations/private_v1.nim +++ /dev/null @@ -1,282 +0,0 @@ - -import blake2 -import chronicles -import chronos -import sds -import std/[sequtils, strutils, strformat] -import std/algorithm -import sugar -import tables - -import ../conversation_store -import ../crypto -import ../delivery/waku_client - -import ../[ - identity, - errors, - proto_types, - types, - utils -] -import convo_type -import message - -import ../../naxolotl as nax - -const TopicPrefixPrivateV1 = "/convo/private/" - -type - ReceivedPrivateV1Message* = ref object of ReceivedMessage - -proc initReceivedMessage(sender: PublicKey, timestamp: int64, content: Content) : ReceivedPrivateV1Message = - ReceivedPrivateV1Message(sender:sender, timestamp:timestamp, content:content) - - -type - PrivateV1* = ref object of Conversation - ds: WakuClient - sdsClient: ReliabilityManager - owner: Identity - participant: PublicKey - discriminator: string - doubleratchet: naxolotl.Doubleratchet - -proc derive_topic(participant: PublicKey): string = - ## Derives a topic from the participants' public keys. - return TopicPrefixPrivateV1 & participant.get_addr() - -proc getTopicInbound*(self: PrivateV1): string = - ## Returns the topic where the local client is listening for messages - return derive_topic(self.owner.getPubkey()) - -proc getTopicOutbound*(self: PrivateV1): string = - ## Returns the topic where the remote recipient is listening for messages - return derive_topic(self.participant) - -## Parses the topic to extract the conversation ID. -proc parseTopic*(topic: string): Result[string, ChatError] = - if not topic.startsWith(TopicPrefixPrivateV1): - return err(ChatError(code: errTopic, context: "Invalid topic prefix")) - - let id = topic.split('/')[^1] - if id == "": - return err(ChatError(code: errTopic, context: "Empty conversation ID")) - - return ok(id) - -proc allParticipants(self: PrivateV1): seq[PublicKey] = - return @[self.owner.getPubkey(), self.participant] - -proc getConvoIdRaw(participants: seq[PublicKey], - discriminator: string): string = - # This is a placeholder implementation. - var addrs = participants.map(x => x.get_addr()); - addrs.sort() - addrs.add(discriminator) - let raw = addrs.join("|") - return utils.hash_func(raw) - -proc getConvoId*(self: PrivateV1): string = - return getConvoIdRaw(@[self.owner.getPubkey(), self.participant], self.discriminator) - - -proc calcMsgId(self: PrivateV1, msgBytes: seq[byte]): string = - let s = fmt"{self.getConvoId()}|{msgBytes}" - result = getBlake2b(s, 16, "") - - -proc encrypt*(convo: PrivateV1, plaintext: var seq[byte]): EncryptedPayload = - - let (header, ciphertext) = convo.doubleratchet.encrypt(plaintext) #TODO: Associated Data - - result = EncryptedPayload(doubleratchet: proto_types.DoubleRatchet( - dh: toSeq(header.dhPublic), - msgNum: header.msgNumber, - prevChainLen: header.prevChainLen, - ciphertext: ciphertext) - ) - -proc decrypt*(convo: PrivateV1, enc: EncryptedPayload): Result[seq[byte], ChatError] = - # Ensure correct type as received - if enc.doubleratchet.ciphertext == @[]: - return err(ChatError(code: errTypeError, context: "Expected doubleratchet encrypted payload got ???")) - - let dr = enc.doubleratchet - - var header = DrHeader( - msgNumber: dr.msgNum, - prevChainLen: dr.prevChainLen - ) - copyMem(addr header.dhPublic[0], unsafeAddr dr.dh[0], dr.dh.len) # TODO: Avoid this copy - - convo.doubleratchet.decrypt(header, dr.ciphertext, @[]).mapErr(proc(e: NaxolotlError): ChatError = ChatError(code: errWrapped, context: repr(e) )) - - - -proc wireCallbacks(convo: PrivateV1, deliveryAckCb: proc( - conversation: Conversation, - msgId: string): Future[void] {.async.} = nil) = - ## Accepts lambdas/functions to be called from Reliability Manager callbacks. - let funcMsg = proc(messageId: SdsMessageID, - channelId: SdsChannelID) {.gcsafe.} = - debug "sds message ready", messageId = messageId, - channelId = channelId - - let funcDeliveryAck = proc(messageId: SdsMessageID, - channelId: SdsChannelID) {.gcsafe.} = - debug "sds message ack", messageId = messageId, - channelId = channelId - - if deliveryAckCb != nil: - asyncSpawn deliveryAckCb(convo, messageId) - - let funcDroppedMsg = proc(messageId: SdsMessageID, missingDeps: seq[ - SdsMessageID], channelId: SdsChannelID) {.gcsafe.} = - debug "sds message missing", messageId = messageId, - missingDeps = missingDeps, channelId = channelId - - convo.sdsClient.setCallbacks( - funcMsg, funcDeliveryAck, funcDroppedMsg - ) - - - -proc initPrivateV1*(owner: Identity, ds:WakuClient, participant: PublicKey, seedKey: array[32, byte], - discriminator: string = "default", isSender: bool, deliveryAckCb: proc( - conversation: Conversation, - msgId: string): Future[void] {.async.} = nil): - PrivateV1 = - - var rm = newReliabilityManager().valueOr: - raise newException(ValueError, fmt"sds initialization: {repr(error)}") - - let dr = if isSender: - initDoubleratchetSender(seedKey, participant.bytes) - else: - initDoubleratchetRecipient(seedKey, owner.privateKey.bytes) - - result = PrivateV1( - ds: ds, - sdsClient: rm, - owner: owner, - participant: participant, - discriminator: discriminator, - doubleratchet: dr - ) - - result.wireCallbacks(deliveryAckCb) - - result.sdsClient.ensureChannel(result.getConvoId()).isOkOr: - raise newException(ValueError, "bad sds channel") - -proc encodeFrame*(self: PrivateV1, msg: PrivateV1Frame): (MessageId, EncryptedPayload) = - - let frameBytes = encode(msg) - let msgId = self.calcMsgId(frameBytes) - var sdsPayload = self.sdsClient.wrapOutgoingMessage(frameBytes, msgId, - self.getConvoId()).valueOr: - raise newException(ValueError, fmt"sds wrapOutgoingMessage failed: {repr(error)}") - - result = (msgId, self.encrypt(sdsPayload)) - -proc sendFrame(self: PrivateV1, ds: WakuClient, - msg: PrivateV1Frame): Future[MessageId]{.async.} = - let (msgId, encryptedPayload) = self.encodeFrame(msg) - discard ds.sendPayload(self.getTopicOutbound(), encryptedPayload.toEnvelope( - self.getConvoId())) - - result = msgId - - -method id*(self: PrivateV1): string = - return getConvoIdRaw(self.allParticipants(), self.discriminator) - -proc handleFrame*[T: ConversationStore](convo: PrivateV1, client: T, - encPayload: EncryptedPayload) = - ## Dispatcher for Incoming `PrivateV1Frames`. - ## Calls further processing depending on the kind of frame. - - if convo.doubleratchet.dhSelfPublic() == encPayload.doubleratchet.dh: - info "outgoing message, no need to handle", convo = convo.id() - return - - let plaintext = convo.decrypt(encPayload).valueOr: - error "decryption failed", error = error - return - - let (frameData, missingDeps, channelId) = convo.sdsClient.unwrapReceivedMessage( - plaintext).valueOr: - raise newException(ValueError, fmt"Failed to unwrap SDS message:{repr(error)}") - - debug "sds unwrap", convo = convo.id(), missingDeps = missingDeps, - channelId = channelId - - let frame = decode(frameData, PrivateV1Frame).valueOr: - raise newException(ValueError, "Failed to decode SdsM: " & error) - - if frame.sender == @(convo.owner.getPubkey().bytes()): - notice "Self Message", convo = convo.id() - return - - case frame.getKind(): - of typeContent: - # TODO: Using client.getId() results in an error in this context - client.notifyNewMessage(convo, initReceivedMessage(convo.participant, frame.timestamp, frame.content)) - - of typePlaceholder: - notice "Got Placeholder", text = frame.placeholder.counter - -proc handleFrame*[T: ConversationStore](convo: PrivateV1, client: T, - bytes: seq[byte]) = - ## Dispatcher for Incoming `PrivateV1Frames`. - ## Calls further processing depending on the kind of frame. - let encPayload = decode(bytes, EncryptedPayload).valueOr: - raise newException(ValueError, fmt"Failed to decode EncryptedPayload: {repr(error)}") - - convo.handleFrame(client,encPayload) - - -method sendMessage*(convo: PrivateV1, content_frame: Content) : Future[MessageId] {.async.} = - - try: - let frame = PrivateV1Frame(sender: @(convo.owner.getPubkey().bytes()), - timestamp: getCurrentTimestamp(), content: content_frame) - - result = await convo.sendFrame(convo.ds, frame) - except Exception as e: - error "Unknown error in PrivateV1:SendMessage" - - -## Encrypts content without sending it. -proc encryptMessage*(self: PrivateV1, content_frame: Content) : (MessageId, EncryptedPayload) = - - try: - let frame = PrivateV1Frame( - sender: @(self.owner.getPubkey().bytes()), - timestamp: getCurrentTimestamp(), - content: content_frame - ) - - result = self.encodeFrame(frame) - - except Exception as e: - error "Unknown error in PrivateV1:EncryptMessage" - -proc initPrivateV1Sender*(sender:Identity, - ds: WakuClient, - participant: PublicKey, - seedKey: array[32, byte], - content: Content, - deliveryAckCb: proc(conversation: Conversation, msgId: string): Future[void] {.async.} = nil): (PrivateV1, EncryptedPayload) = - let convo = initPrivateV1(sender, ds, participant, seedKey, "default", true, deliveryAckCb) - - # Encrypt Content with Convo - let contentFrame = PrivateV1Frame(sender: @(sender.getPubkey().bytes()), timestamp: getCurrentTimestamp(), content: content) - let (msg_id, encPayload) = convo.encryptMessage(content) - result = (convo, encPayload) - - -proc initPrivateV1Recipient*(owner:Identity,ds: WakuClient, participant: PublicKey, seedKey: array[32, byte], deliveryAckCb: proc( - conversation: Conversation, msgId: string): Future[void] {.async.} = nil): PrivateV1 = - initPrivateV1(owner,ds, participant, seedKey, "default", false, deliveryAckCb) diff --git a/src/chat/crypto.nim b/src/chat/crypto.nim deleted file mode 100644 index 1aa4cc5..0000000 --- a/src/chat/crypto.nim +++ /dev/null @@ -1,35 +0,0 @@ -import proto_types - -import strformat -import crypto/ecdh -import std/[sysrand] -import results -import utils - -export PublicKey, PrivateKey, bytes, createRandomKey, loadPrivateKeyFromBytes, loadPublicKeyFromBytes, - getPublicKey, Dh, Result, get_addr, `$` - - -proc encrypt_plain*[T: EncryptableTypes](frame: T): EncryptedPayload = - return EncryptedPayload( - plaintext: Plaintext(payload: encode(frame)), - ) - -proc decrypt_plain*[T: EncryptableTypes](ciphertext: Plaintext, t: typedesc[ - T]): Result[T, string] = - - let obj = decode(ciphertext.payload, T) - if obj.isErr: - return err("Protobuf decode failed: " & obj.error) - result = ok(obj.get()) - -proc generate_key*(): PrivateKey = - createRandomKey().get() - - -proc toHex*(key: PublicKey): string = - bytesToHex(key.bytes()) - -proc `$`*(key: PublicKey): string = - let byteStr = toHex(key) - fmt"{byteStr[0..3]}..{byteStr[^4 .. ^1]}" diff --git a/src/chat/delivery/waku_client.nim b/src/chat/delivery/waku_client.nim index 4662120..70f59a2 100644 --- a/src/chat/delivery/waku_client.nim +++ b/src/chat/delivery/waku_client.nim @@ -20,7 +20,6 @@ import waku_filter_v2/client, ] -import ../proto_types logScope: topics = "chat waku" @@ -72,9 +71,9 @@ proc DefaultConfig*(): WakuConfig = shardId: @[shardId], pubsubTopic: &"/waku/2/rs/{clusterId}/{shardId}", staticPeers: @[]) -proc sendPayload*(client: WakuClient, contentTopic: string, - env: WapEnvelopeV1) {.async.} = - let bytes = encode(env) + +proc sendBytes*(client: WakuClient, contentTopic: string, + bytes: seq[byte]) {.async.} = let msg = WakuMessage(contentTopic: contentTopic, payload: bytes) let res = await client.node.publish(some(PubsubTopic(client.cfg.pubsubTopic)), msg) diff --git a/src/chat/errors.nim b/src/chat/errors.nim index c74b5b2..f5b93e3 100644 --- a/src/chat/errors.nim +++ b/src/chat/errors.nim @@ -9,6 +9,7 @@ type errTypeError errWrapped errTopic + errLibChat proc `$`*(x: ChatError): string = fmt"ChatError(code={$x.code}, context: {x.context})" diff --git a/src/chat/identity.nim b/src/chat/identity.nim index 37b4267..794fcc1 100644 --- a/src/chat/identity.nim +++ b/src/chat/identity.nim @@ -1,6 +1,10 @@ -import crypto +import crypto/ecdh import results +import strformat +import utils + +export PublicKey, PrivateKey, loadPrivateKeyFromBytes, loadPublicKeyFromBytes type @@ -28,6 +32,13 @@ proc getPubkey*(self: Identity): PublicKey = proc getAddr*(self: Identity): string = result = get_addr(self.getPubKey()) - proc getName*(self: Identity): string = result = self.name + +proc toHex(key: PublicKey): string = + bytesToHex(key.bytes()) + +proc `$`*(key: PublicKey): string = + let byteStr = toHex(key) + fmt"{byteStr[0..3]}..{byteStr[^4 .. ^1]}" + diff --git a/src/chat/inbox.nim b/src/chat/inbox.nim deleted file mode 100644 index 7af319a..0000000 --- a/src/chat/inbox.nim +++ /dev/null @@ -1,171 +0,0 @@ -import std/[strutils] - -import - chronicles, - chronos, - results, - strformat - -import - conversations/convo_type, - conversations, - conversation_store, - crypto, - delivery/waku_client, - errors, - identity, - proto_types, - types - -logScope: - topics = "chat inbox" - -type - Inbox* = ref object of Conversation - identity: Identity - inbox_addr: string - -const - TopicPrefixInbox = "/inbox/" - -proc `$`*(conv: Inbox): string = - fmt"Inbox: addr->{conv.inbox_addr}" - - -proc initInbox*(ident: Identity): Inbox = - ## Initializes an Inbox object with the given address and invite callback. - return Inbox(identity: ident) - -proc encrypt*(frame: InboxV1Frame): EncryptedPayload = - return encrypt_plain(frame) - -proc decrypt*(inbox: Inbox, encbytes: EncryptedPayload): Result[InboxV1Frame, string] = - let res_frame = decrypt_plain(encbytes.plaintext, InboxV1Frame) - if res_frame.isErr: - error "Failed to decrypt frame: ", err = res_frame.error - return err("Failed to decrypt frame: " & res_frame.error) - result = res_frame - -proc wrap_env*(payload: EncryptedPayload, convo_id: string): WapEnvelopeV1 = - let bytes = encode(payload) - let salt = generateSalt() - - return WapEnvelopeV1( - payload: bytes, - salt: salt, - conversation_hint: convo_id, - ) - -proc conversation_id_for*(pubkey: PublicKey): string = - ## Generates a conversation ID based on the public key. - return "/convo/inbox/v1/" & pubkey.get_addr() - -# TODO derive this from instance of Inbox -proc topic_inbox*(client_addr: string): string = - return TopicPrefixInbox & client_addr - -proc parseTopic*(topic: string): Result[string, ChatError] = - if not topic.startsWith(TopicPrefixInbox): - return err(ChatError(code: errTopic, context: "Invalid inbox topic prefix")) - - let id = topic.split('/')[^1] - if id == "": - return err(ChatError(code: errTopic, context: "Empty inbox id")) - - return ok(id) - -method id*(convo: Inbox): string = - return conversation_id_for(convo.identity.getPubkey()) - -## Encrypt and Send a frame to the remote account -proc sendFrame(ds: WakuClient, remote: PublicKey, frame: InboxV1Frame ): Future[void] {.async.} = - let env = wrapEnv(encrypt(frame),conversation_id_for(remote) ) - await ds.sendPayload(topic_inbox(remote.get_addr()), env) - - -proc newPrivateInvite(initator_static: PublicKey, - initator_ephemeral: PublicKey, - recipient_static: PublicKey, - recipient_ephemeral: uint32, - payload: EncryptedPayload) : InboxV1Frame = - - let invite = InvitePrivateV1( - initiator: @(initator_static.bytes()), - initiatorEphemeral: @(initator_ephemeral.bytes()), - participant: @(recipient_static.bytes()), - participantEphemeralId: 0, - discriminator: "", - initial_message: payload - ) - result = InboxV1Frame(invitePrivateV1: invite, recipient: "") - -################################################# -# Conversation Creation -################################################# - -## Establish a PrivateConversation with a remote client -proc inviteToPrivateConversation*(self: Inbox, ds: Wakuclient, remote_static: PublicKey, remote_ephemeral: PublicKey, content: Content ) : Future[PrivateV1] {.async.} = - # Create SeedKey - # TODO: Update key derivations when noise is integrated - var local_ephemeral = generateKey() - var sk{.noInit.} : array[32, byte] = default(array[32, byte]) - - # Initialize PrivateConversation - let (convo, encPayload) = initPrivateV1Sender(self.identity, ds, remote_static, sk, content, nil) - result = convo - - # # Build Invite - let frame = newPrivateInvite(self.identity.getPubkey(), local_ephemeral.getPublicKey(), remote_static, 0, encPayload) - - # Send - await sendFrame(ds, remote_static, frame) - -## Receive am Invitation to create a new private conversation -proc createPrivateV1FromInvite*[T: ConversationStore](client: T, - invite: InvitePrivateV1) = - - let destPubkey = loadPublicKeyFromBytes(invite.initiator).valueOr: - raise newException(ValueError, "Invalid public key in intro bundle.") - - let deliveryAckCb = proc( - conversation: Conversation, - msgId: string): Future[void] {.async.} = - client.notifyDeliveryAck(conversation, msgId) - - # TODO: remove placeholder key - var key : array[32, byte] = default(array[32,byte]) - - let convo = initPrivateV1Recipient(client.identity(), client.ds, destPubkey, key, deliveryAckCb) - notice "Creating PrivateV1 conversation", client = client.getId(), - convoId = convo.getConvoId() - - convo.handleFrame(client, invite.initial_message) - - # Calling `addConversation` must only occur after the conversation is completely configured. - # The client calls the OnNewConversation callback, which returns execution to the application. - client.addConversation(convo) - -proc handleFrame*[T: ConversationStore](convo: Inbox, client: T, bytes: seq[ - byte]) = - ## Dispatcher for Incoming `InboxV1Frames`. - ## Calls further processing depending on the kind of frame. - - let enc = decode(bytes, EncryptedPayload).valueOr: - raise newException(ValueError, "Failed to decode payload") - - let frame = convo.decrypt(enc).valueOr: - error "Decrypt failed", client = client.getId(), error = error - raise newException(ValueError, "Failed to Decrypt MEssage: " & - error) - - case getKind(frame): - of typeInvitePrivateV1: - createPrivateV1FromInvite(client, frame.invitePrivateV1) - - of typeNote: - notice "Receive Note", client = client.getId(), text = frame.note.text - - -method sendMessage*(convo: Inbox, content_frame: Content) : Future[MessageId] {.async.} = - warn "Cannot send message to Inbox" - result = "program_error" diff --git a/src/chat/links.nim b/src/chat/links.nim deleted file mode 100644 index e877235..0000000 --- a/src/chat/links.nim +++ /dev/null @@ -1,58 +0,0 @@ -import base64 -import chronos -import strformat -import strutils - -import libp2p/crypto/crypto - -import ../content_types/all -import proto_types -import utils - - -################################################# -# Link Generation -################################################# - -proc toBundle*(link: string): Result[IntroBundle, string] = - # Check scheme - if not link.startsWith("wap://"): - return err("InvalidScheme") - - # Remove scheme - let path = link[6..^1] - - # Split by '/' - let parts = path.split('/') - - # Expected format: ident/{ident}/ephemeral/{ephemeral}/eid/{eid} - if parts.len != 6: - return err("InvalidFormat") - - # Validate structure - if parts[0] != "ident" or parts[2] != "ephemeral" or parts[4] != "eid": - return err("InvalidFormat") - - # Extract values - let ident = decode(parts[1]).toBytes() - let ephemeral = decode(parts[3]).toBytes() - - let eid = int32(parseInt(parts[5])) # TODO: catch parse error - - # Validate non-empty - if ident.len == 0: - return err("MissingIdent") - if ephemeral.len == 0: - return err("MissingEphemeral") - - return ok(IntroBundle( - ident: ident, - ephemeral: ephemeral, - ephemeral_id: eid - )) - - -proc toLink*(intro: IntroBundle): string = - let ident = encode(intro.ident, safe = true) - let ephemeral = intro.ephemeral.toHex() - result = fmt"wap://ident/{ident}/ephemeral/{ephemeral}/eid/{intro.ephemeral_id}" diff --git a/src/chat/proto_types.nim b/src/chat/proto_types.nim deleted file mode 100644 index a608046..0000000 --- a/src/chat/proto_types.nim +++ /dev/null @@ -1,104 +0,0 @@ -# Can this be an external package? It would be preferable to have these types -# easy to import and use. - -import protobuf_serialization # This import is needed or th macro will not work -import protobuf_serialization/proto_parser -import results -import std/random - -export protobuf_serialization - -import_proto3 "../../protos/inbox.proto" -# import_proto3 "../protos/invite.proto" // Import3 follows protobuf includes so this will result in a redefinition error -import_proto3 "../../protos/envelope.proto" - -import_proto3 "../../protos/private_v1.proto" - -type EncryptableTypes = InboxV1Frame | EncryptedPayload - -export EncryptedPayload -export InboxV1Frame -export PrivateV1Frame - -export EncryptableTypes - - - - -proc encode*(frame: object): seq[byte] = - ## Encodes the frame into a byte sequence using Protobuf serialization. - result = Protobuf.encode(frame) - - -proc decode*[T: object] (bytes: seq[byte], proto: typedesc[ - T]): Result[T, string] = - ## Encodes the frame into a byte sequence using Protobuf serialization. - - try: - result = ok(Protobuf.decode(bytes, proto)) - except ProtobufError as e: - result = err("Failed to decode payload: " & e.msg) - -type - IntroBundle {.proto3.} = object - ident* {.fieldNumber: 1.}: seq[byte] - ephemeral* {.fieldNumber: 2.}: seq[byte] - ephemeral_id* {.fieldNumber: 3.}: int32 - - -export IntroBundle - -proc generateSalt*(): uint64 = - randomize() - result = 0 - for i in 0 ..< 8: - result = result or (uint64(rand(255)) shl (i * 8)) - - -proc toEnvelope*(payload: EncryptedPayload, convo_id: string): WapEnvelopeV1 = - let bytes = encode(payload) - let salt = generateSalt() - - # TODO: Implement hinting - return WapEnvelopeV1( - payload: bytes, - salt: salt, - conversation_hint: convo_id, - ) - -########################################################### -# nim-serialize-protobuf does not support oneof fields. -# As a stop gap each object using oneof fields, needs -# a implementation to look up the type. -# -# The valid field is determined by the fields which -# is not set to the default value -########################################################### - -type - InboxV1FrameType* = enum - type_InvitePrivateV1, type_Note - -proc getKind*(obj: InboxV1Frame): InboxV1FrameType = - - if obj.invite_private_v1 != InvitePrivateV1(): - return type_InvitePrivateV1 - - if obj.note != Note(): - return type_Note - - raise newException(ValueError, "Un handled one of type") - -type - PrivateV1FrameType* = enum - type_Content, type_Placeholder - -proc getKind*(obj: PrivateV1Frame): PrivateV1FrameType = - - if obj.content != @[]: - return type_Content - - if obj.placeholder != Placeholder(): - return type_Placeholder - - raise newException(ValueError, "Un handled one of type") diff --git a/src/chat/utils.nim b/src/chat/utils.nim index d6b3a26..9be2dcf 100644 --- a/src/chat/utils.nim +++ b/src/chat/utils.nim @@ -6,7 +6,6 @@ import strutils proc getCurrentTimestamp*(): Timestamp = result = waku_core.getNanosecondTime(getTime().toUnix()) - proc hash_func*(s: string | seq[byte]): string = # This should be Blake2s but it does not exist so substituting with Blake2b result = getBlake2b(s, 4, "") @@ -17,17 +16,3 @@ proc bytesToHex*[T](bytes: openarray[T], lowercase: bool = false): string = for b in bytes: let hex = b.toHex(2) result.add(if lowercase: hex.toLower() else: hex) - -proc toBytes*(s: string): seq[byte] = - result = cast[seq[byte]](s) - -proc toUtfString*(b: seq[byte]): string = - result = cast[string](b) - -macro panic*(reason: string): untyped = - result = quote do: - let pos = instantiationInfo() - echo `reason` & " ($1:$2)" % [ - pos.filename, $pos.line] - echo "traceback:\n", getStackTrace() - quit(1) diff --git a/src/content_types/all.nim b/src/content_types/all.nim index f4b6425..0ced84a 100644 --- a/src/content_types/all.nim +++ b/src/content_types/all.nim @@ -6,8 +6,6 @@ import protobuf_serialization/proto_parser import results import strformat -import ../chat/proto_types - export protobuf_serialization diff --git a/vendor/libchat b/vendor/libchat new file mode 160000 index 0000000..d903eac --- /dev/null +++ b/vendor/libchat @@ -0,0 +1 @@ +Subproject commit d903eac011f31b9db83c0860235341d4340cf5f0