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>
This commit is contained in:
Jazz Turner-Baggs 2026-02-16 10:16:16 -08:00 committed by Patryk Osmaczko
parent 1d727c1fcf
commit 3bfba7cf25
No known key found for this signature in database
GPG Key ID: 6A385380FD275B44
29 changed files with 143 additions and 1759 deletions

3
.gitmodules vendored
View File

@ -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

View File

@ -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" && \

View File

@ -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")

View File

@ -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()

View File

@ -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
)

View File

@ -1,7 +0,0 @@
import chronos
import tui/tui
when isMainModule:
waitFor main()

View File

@ -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
)

View File

@ -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

View File

@ -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:
"<Unhandled Message Type>"
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..<layout.xStart+layout.width:
tb.write(x, layout.yStart, charHoriz, color, bg)
tb.write(x, layout.yStart+layout.height-1, charHoriz, color, bg)
for y in layout.yStart+1..<layout.yStart+layout.height:
tb.write(layout.xStart, y, charVert, color, bg)
tb.write(layout.xStart+layout.width-1, y, charVert, color, bg)
tb.write(layout.xStart, layout.yStart, charTopLeft, color, bg)
tb.write(layout.xStart+layout.width-1, layout.yStart, charTopRight, color, bg)
tb.write(layout.xStart, layout.yStart+layout.height-1, charBottomLeft, color, bg)
tb.write(layout.xStart+layout.width-1, layout.yStart+layout.height-1,
charBottomRight, color, bgBlack)
proc drawStatusBar(app: ChatApp, layout: Pane , fg: ForegroundColor, bg: BackgroundColor) =
var tb = app.tb
tb.setForegroundColor(fg)
tb.setBackgroundColor(bg)
for x in layout.xStart..<layout.width:
for y in layout.yStart..<layout.height:
tb.write(x, y, " ")
var i = layout.yStart + 1
var chunk = layout.width - 9
tb.write(1, i, "Name: " & app.client.getName())
inc i
tb.write(1, i, fmt"PeerCount: {app.peerCount}")
inc i
tb.write(1, i, "Link: ")
for a in 0..(app.currentInviteLink.len div chunk) :
tb.write(1+6 , i, app.currentInviteLink[a*chunk ..< min((a+1)*chunk, app.currentInviteLink.len)])
inc i
resetTuiCursor(tb)
proc drawConvoItem(app: ChatApp,
layout: Pane, convo: ConvoInfo, isSelected: bool = false) =
var tb = app.tb
let xOffset = 3
let yOffset = 1
let dt = convo.lastMsgTime
let c = if isSelected: fgMagenta else: fgCyan
if isSelected:
tb.setForegroundColor(fgMagenta)
else:
tb.setForegroundColor(fgCyan)
drawOutline(tb, layout, c)
tb.write(layout.xStart+xOffset, layout.yStart+yOffset, convo.name)
tb.write(layout.xStart+xOffset, layout.yStart+yOffset+1,dt.format("yyyy-MM-dd HH:mm:ss"))
proc drawConversationPane( app: ChatApp, layout: Pane) =
var a = layout
a.width -= 2
a.height = 6
for convo in app.mostRecentConvos():
drawConvoItem(app, a, convo, app.selectedConv == convo.name)
a.yStart = a.yStart + a.height
proc splitAt(s: string, index: int): (string, string) =
let splitIndex = min(index, s.len)
return (s[0..<splitIndex], s[splitIndex..^1])
# Eww
proc drawMsgPane( app: ChatApp, layout: Pane) =
var tb = app.tb
drawOutline(tb, layout, fgYellow)
var convo = app.getSelectedConvo()
let xStart = layout.xStart+1
let yStart = layout.yStart+1
let w = layout.width
let h = layout.height
let maxContentWidth = w - 10
let xEnd = layout.xStart + maxContentWidth
let yEnd = layout.yStart + layout.height - 2
tb.setForegroundColor(fgGreen)
let x = xStart + 2
if not convo.isTooLong:
var y = yStart
for i in 0..convo.messages.len-1:
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)
if y > 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..<layout.xStart+layout.width:
for y in layout.yStart..<layout.yStart+layout.height:
tb.write(x, y, " ", color)
tb.setForegroundColor(fgBlack)
tb.setBackgroundColor(bgGreen)
tb.write(layout.xStart + 2, layout.yStart + 2, "Paste Invite")
tb.setForegroundColor(fgWhite)
tb.setBackgroundColor(bgBlack)
let inputLine = 5
for i in layout.xStart+3..<layout.xStart+layout.width-3:
for y in (layout.yStart + inputLine - 1)..<(layout.yStart+inputLine + 2):
tb.write(i, y, " ")
# Draw input prompt
tb.write(layout.xStart+5, layout.yStart+inputLine, "> " & 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

View File

@ -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 = "<anonymous>"
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")

View File

@ -21,6 +21,7 @@ requires "nim >= 2.2.4",
"regex",
"web3",
"https://github.com/jazzz/nim-sds#exports",
"libchat",
"waku",
"ffi"

View File

@ -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.`$`

View File

@ -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()

View File

@ -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)

View File

@ -1,5 +0,0 @@
import
./conversations/[convo_type, private_v1, message]
export private_v1, convo_type, message

View File

@ -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)

View File

@ -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")

View File

@ -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]

View File

@ -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)

View File

@ -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]}"

View File

@ -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)

View File

@ -9,6 +9,7 @@ type
errTypeError
errWrapped
errTopic
errLibChat
proc `$`*(x: ChatError): string =
fmt"ChatError(code={$x.code}, context: {x.context})"

View File

@ -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]}"

View File

@ -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"

View File

@ -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}"

View File

@ -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")

View File

@ -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)

View File

@ -6,8 +6,6 @@ import protobuf_serialization/proto_parser
import results
import strformat
import ../chat/proto_types
export protobuf_serialization

1
vendor/libchat vendored Submodule

@ -0,0 +1 @@
Subproject commit d903eac011f31b9db83c0860235341d4340cf5f0