nim-chat-poc/src/client.nim

330 lines
10 KiB
Nim
Raw Normal View History

2025-08-15 08:37:53 -07:00
## Main Entry point to the ChatSDK.
## Clients are the primary manager of sending and receiving
## messages, and managing conversations.
2025-07-05 14:54:19 -07:00
2025-08-15 07:31:19 -07:00
import # Foreign
chronicles,
chronos,
std/sequtils,
strformat,
strutils,
tables
import #local
conversations/private_v1,
crypto,
identity,
inbox,
proto_types,
types,
utils,
waku_client
logScope:
topics = "chat client"
2025-07-05 14:54:19 -07:00
2025-08-15 08:05:59 -07:00
#################################################
# Definitions
#################################################
2025-07-05 14:54:19 -07:00
type KeyEntry* = object
2025-08-15 09:26:02 -07:00
keyType: string
2025-07-16 16:17:22 -07:00
privateKey: PrivateKey
2025-07-05 14:54:19 -07:00
timestamp: int64
type
2025-08-15 07:31:19 -07:00
SupportedConvoTypes* = Inbox | PrivateV1
2025-07-05 14:54:19 -07:00
ConvoType* = enum
InboxV1Type, PrivateV1Type
ConvoWrapper* = object
2025-08-15 09:26:02 -07:00
case convoType*: ConvoType
2025-07-05 14:54:19 -07:00
of InboxV1Type:
inboxV1*: Inbox
of PrivateV1Type:
privateV1*: PrivateV1
2025-08-15 09:26:02 -07:00
2025-08-15 07:31:19 -07:00
type Client* = ref object
ident: Identity
ds*: WakuClient
2025-08-15 09:26:02 -07:00
keyStore: Table[string, KeyEntry] # Keyed by HexEncoded Public Key
2025-08-15 07:31:19 -07:00
conversations: Table[string, ConvoWrapper] # Keyed by conversation ID
inboundQueue: QueueRef
isRunning: bool
2025-07-05 14:54:19 -07:00
2025-08-15 08:05:59 -07:00
#################################################
# Constructors
#################################################
2025-07-05 14:54:19 -07:00
2025-08-15 07:31:19 -07:00
proc newClient*(name: string, cfg: WakuConfig): Client =
2025-08-15 08:37:53 -07:00
## Creates new instance of a `Client` with a given `WakuConfig`
2025-08-15 07:31:19 -07:00
let waku = initWakuClient(cfg)
2025-07-05 14:54:19 -07:00
2025-08-15 07:31:19 -07:00
var q = QueueRef(queue: newAsyncQueue[ChatPayload](10))
2025-07-05 14:54:19 -07:00
var c = Client(ident: createIdentity(name),
2025-08-15 07:31:19 -07:00
ds: waku,
2025-08-15 09:26:02 -07:00
keyStore: initTable[string, KeyEntry](),
2025-08-15 07:31:19 -07:00
conversations: initTable[string, ConvoWrapper](),
inboundQueue: q,
isRunning: false)
2025-07-05 14:54:19 -07:00
2025-08-15 09:26:02 -07:00
let defaultInbox = initInbox(c.ident.getAddr())
c.conversations[conversationIdFor(c.ident.getPubkey(
))] = ConvoWrapper(convoType: InboxV1Type, inboxV1: defaultInbox)
2025-07-05 14:54:19 -07:00
2025-08-15 08:09:36 -07:00
notice "Client started", client = c.ident.getId(),
2025-08-15 09:26:02 -07:00
defaultInbox = defaultInbox
2025-08-15 07:31:19 -07:00
result = c
2025-07-05 14:54:19 -07:00
2025-08-15 08:05:59 -07:00
#################################################
# Parameter Access
#################################################
2025-08-15 07:38:31 -07:00
proc getId(client: Client): string =
2025-08-15 08:09:36 -07:00
result = client.ident.getId()
2025-08-15 07:38:31 -07:00
2025-08-15 09:26:02 -07:00
proc defaultInboxConversationId*(self: Client): string =
2025-07-05 14:54:19 -07:00
## Returns the default inbox address for the client.
2025-08-15 09:26:02 -07:00
result = conversationIdFor(self.ident.getPubkey())
2025-07-05 14:54:19 -07:00
2025-08-15 08:05:59 -07:00
proc getConversationFromHint(self: Client,
2025-08-15 09:26:02 -07:00
conversationHint: string): Result[Option[ConvoWrapper], string] =
2025-08-15 08:05:59 -07:00
# TODO: Implementing Hinting
2025-08-15 09:26:02 -07:00
if not self.conversations.hasKey(conversationHint):
2025-08-15 08:05:59 -07:00
ok(none(ConvoWrapper))
else:
2025-08-15 09:26:02 -07:00
ok(some(self.conversations[conversationHint]))
2025-08-15 08:05:59 -07:00
#################################################
# Functional
#################################################
2025-07-05 14:54:19 -07:00
proc createIntroBundle*(self: var Client): IntroBundle =
## Generates an IntroBundle for the client, which includes
## the required information to send a message.
# Create Ephemeral keypair, save it in the key store
2025-08-15 09:26:02 -07:00
let ephemeralKey = generateKey()
self.keyStore[ephemeralKey.getPublicKey().bytes().bytesToHex()] = KeyEntry(
keyType: "ephemeral",
privateKey: ephemeralKey,
2025-08-15 07:31:19 -07:00
timestamp: getTimestamp()
2025-07-05 14:54:19 -07:00
)
result = IntroBundle(
2025-07-16 16:17:22 -07:00
ident: @(self.ident.getPubkey().bytes()),
2025-08-15 09:26:02 -07:00
ephemeral: @(ephemeralKey.getPublicKey().bytes()),
2025-07-05 14:54:19 -07:00
)
2025-08-15 07:38:31 -07:00
notice "IntroBundleCreated", client = self.getId(),
2025-08-15 07:31:19 -07:00
pubBytes = result.ident
2025-08-15 08:05:59 -07:00
#################################################
# Conversation Initiation
#################################################
2025-08-15 07:31:19 -07:00
proc createPrivateConversation(client: Client, participant: PublicKey,
discriminator: string = "default"): Option[ChatError] =
2025-08-15 08:37:53 -07:00
## Creates a private conversation with the given participant and discriminator.
## Discriminator allows multiple conversations to exist between the same
## participants.
2025-08-15 07:31:19 -07:00
let convo = initPrivateV1(client.ident, participant, discriminator)
2025-07-07 14:20:46 -07:00
2025-08-15 07:31:19 -07:00
notice "Creating PrivateV1 conversation", topic = convo.getConvoId()
client.conversations[convo.getConvoId()] = ConvoWrapper(
2025-08-15 09:26:02 -07:00
convoType: PrivateV1Type,
2025-07-07 14:20:46 -07:00
privateV1: convo
)
2025-08-15 07:31:19 -07:00
return some(convo.getConvoId())
2025-07-07 14:20:46 -07:00
2025-08-15 07:31:19 -07:00
proc newPrivateConversation*(client: Client,
2025-08-15 09:26:02 -07:00
introBundle: IntroBundle): Future[Option[ChatError]] {.async.} =
2025-08-15 08:37:53 -07:00
## Creates a private conversation with the given `IntroBundle`.
## `IntroBundles` are provided out-of-band.
2025-07-05 14:54:19 -07:00
2025-08-15 07:38:31 -07:00
notice "New PRIVATE Convo ", clientId = client.getId(),
2025-08-15 09:26:02 -07:00
fromm = introBundle.ident.mapIt(it.toHex(2)).join("")
2025-08-15 07:31:19 -07:00
2025-08-15 10:37:07 -07:00
let destPubkey = loadPublicKeyFromBytes(introBundle.ident).valueOr:
2025-07-05 14:54:19 -07:00
raise newException(ValueError, "Invalid public key in intro bundle.")
2025-08-15 09:26:02 -07:00
let convoId = conversationIdFor(destPubkey)
let destConvoTopic = topicInbox(destPubkey.getAddr())
2025-07-05 14:54:19 -07:00
let invite = InvitePrivateV1(
2025-08-15 07:31:19 -07:00
initiator: @(client.ident.getPubkey().bytes()),
2025-08-15 09:26:02 -07:00
initiatorEphemeral: @[0, 0], # TODO: Add ephemeral
participant: @(destPubkey.bytes()),
participantEphemeralId: introBundle.ephemeralId,
discriminator: "test"
2025-07-05 14:54:19 -07:00
)
2025-08-15 09:26:02 -07:00
let env = wrapEnv(encrypt(InboxV1Frame(invitePrivateV1: invite,
recipient: "")), convoId)
2025-07-05 14:54:19 -07:00
2025-08-15 09:26:02 -07:00
discard createPrivateConversation(client, destPubkey)
2025-08-15 07:31:19 -07:00
# TODO: Subscribe to new content topic
2025-07-05 14:54:19 -07:00
2025-08-15 09:26:02 -07:00
await client.ds.sendPayload(destConvoTopic, env)
2025-08-15 07:31:19 -07:00
return none(ChatError)
2025-07-05 14:54:19 -07:00
2025-08-15 07:31:19 -07:00
proc acceptPrivateInvite(client: Client,
invite: InvitePrivateV1): Option[ChatError] =
2025-08-15 08:37:53 -07:00
## Allows Recipients to join the conversation.
2025-08-15 07:31:19 -07:00
2025-08-15 07:38:31 -07:00
notice "ACCEPT PRIVATE Convo ", clientId = client.getId(),
2025-08-15 07:31:19 -07:00
fromm = invite.initiator.mapIt(it.toHex(2)).join("")
2025-08-15 10:37:07 -07:00
let destPubkey = loadPublicKeyFromBytes(invite.initiator).valueOr:
2025-08-15 07:31:19 -07:00
raise newException(ValueError, "Invalid public key in intro bundle.")
2025-08-15 09:26:02 -07:00
discard createPrivateConversation(client, destPubkey)
2025-08-15 07:31:19 -07:00
# TODO: Subscribe to new content topic
result = none(ChatError)
2025-08-15 08:05:59 -07:00
#################################################
# Payload Handling
#################################################
2025-07-05 14:54:19 -07:00
2025-08-15 10:37:07 -07:00
proc handleInboxFrame(client: Client, convo: Inbox, bytes: seq[byte]) =
2025-08-15 08:37:53 -07:00
## Dispatcher for Incoming `InboxV1Frames`.
## Calls further processing depending on the kind of frame.
2025-08-15 10:37:07 -07:00
let enc = decode(bytes, EncryptedPayload).valueOr:
raise newException(ValueError, "Failed to decode payload")
let frame = convo.decrypt(enc).valueOr:
error "Decrypt failed", error = error
raise newException(ValueError, "Failed to Decrypt MEssage: " &
error)
2025-08-15 07:31:19 -07:00
case getKind(frame):
2025-08-15 09:26:02 -07:00
of typeInvitePrivateV1:
2025-08-15 07:38:31 -07:00
notice "Receive PrivateInvite", client = client.getId(),
2025-08-15 09:26:02 -07:00
frame = frame.invitePrivateV1
discard client.acceptPrivateInvite(frame.invitePrivateV1)
2025-08-15 07:31:19 -07:00
2025-08-15 09:26:02 -07:00
of typeNote:
2025-08-15 07:31:19 -07:00
notice "Receive Note", text = frame.note.text
proc handlePrivateFrame(client: Client, convo: PrivateV1, bytes: seq[byte]) =
2025-08-15 08:37:53 -07:00
## Dispatcher for Incoming `PrivateV1Frames`.
## Calls further processing depending on the kind of frame.
2025-08-15 07:31:19 -07:00
let enc = decode(bytes, EncryptedPayload).get() # TODO: handle result
let frame = convo.decrypt(enc) # TODO: handle result
case frame.getKind():
2025-08-15 09:26:02 -07:00
of typeContentFrame:
2025-08-15 07:38:31 -07:00
notice "Got Mail", client = client.getId(),
2025-08-15 07:31:19 -07:00
text = frame.content.bytes.toUtfString()
2025-08-15 09:26:02 -07:00
of typePlaceholder:
2025-08-15 07:38:31 -07:00
notice "Got Placeholder", client = client.getId(),
2025-08-15 07:31:19 -07:00
text = frame.placeholder.counter
proc parseMessage(client: Client, msg: ChatPayload) =
2025-08-15 08:37:53 -07:00
## Receives a incoming payload, decodes it, and processes it.
2025-08-15 07:38:31 -07:00
info "Parse", clientId = client.getId(), msg = msg,
2025-08-15 07:31:19 -07:00
contentTopic = msg.contentTopic
2025-08-15 10:37:07 -07:00
let envelope = decode(msg.bytes, WapEnvelopeV1).valueOr:
raise newException(ValueError, "Failed to decode WapEnvelopeV1: " & error)
2025-07-05 14:54:19 -07:00
2025-08-15 10:37:07 -07:00
let wrappedConvo = block:
let opt = client.getConversationFromHint(envelope.conversationHint).valueOr:
raise newException(ValueError, "Failed to get conversation: " & error)
2025-07-05 14:54:19 -07:00
2025-08-15 10:37:07 -07:00
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
2025-08-15 07:31:19 -07:00
2025-08-15 09:26:02 -07:00
case wrappedConvo.convoType:
2025-08-15 07:31:19 -07:00
of InboxV1Type:
2025-08-15 10:37:07 -07:00
client.handleInboxFrame(wrappedConvo.inboxV1, envelope.payload)
2025-08-15 07:31:19 -07:00
of PrivateV1Type:
2025-08-15 10:37:07 -07:00
client.handlePrivateFrame(wrappedConvo.privateV1, envelope.payload)
2025-08-15 07:31:19 -07:00
2025-08-15 08:05:59 -07:00
proc addMessage*(client: Client, convo: PrivateV1,
text: string = "") {.async.} =
2025-08-15 08:37:53 -07:00
## Test Function to send automatic messages. to be removed.
2025-08-15 08:05:59 -07:00
let message = PrivateV1Frame(content: ContentFrame(domain: 0, tag: 1,
bytes: text.toBytes()))
await convo.sendMessage(client.ds, message)
#################################################
# Async Tasks
#################################################
2025-08-15 07:31:19 -07:00
proc messageQueueConsumer(client: Client) {.async.} =
## Main message processing loop
info "Message listener started"
while client.isRunning:
let message = await client.inboundQueue.queue.get()
2025-08-15 07:38:31 -07:00
notice "Inbound Message Received", client = client.getId(),
2025-08-15 07:31:19 -07:00
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
proc simulateMessages(client: Client){.async.} =
2025-08-15 08:37:53 -07:00
## Test Task to generate messages after initialization. To be removed.
2025-08-15 07:31:19 -07:00
2025-08-15 08:37:53 -07:00
# TODO: FutureBug - This should wait for a privateV1 conversation.
2025-08-15 07:31:19 -07:00
while client.conversations.len() <= 1:
2025-08-15 09:26:02 -07:00
await sleepAsync(4.seconds)
2025-07-05 14:54:19 -07:00
2025-08-15 07:38:31 -07:00
notice "Starting Message Simulation", client = client.getId()
2025-08-15 07:31:19 -07:00
for a in 1..5:
2025-08-15 09:26:02 -07:00
await sleepAsync(4.seconds)
2025-07-05 14:54:19 -07:00
2025-08-15 07:31:19 -07:00
for convoWrapper in client.conversations.values():
2025-08-15 09:26:02 -07:00
if convoWrapper.convoType == PrivateV1Type:
2025-08-15 07:31:19 -07:00
await client.addMessage(convoWrapper.privateV1, fmt"message: {a}")
2025-07-05 14:54:19 -07:00
2025-08-15 08:05:59 -07:00
#################################################
# Control Functions
#################################################
2025-07-05 14:54:19 -07:00
2025-08-15 07:31:19 -07:00
proc start*(client: Client) {.async.} =
2025-08-15 08:37:53 -07:00
## Start `Client` and listens for incoming messages.
2025-08-15 07:31:19 -07:00
client.ds.addDispatchQueue(client.inboundQueue)
asyncSpawn client.ds.start()
2025-08-15 07:31:19 -07:00
client.isRunning = true
2025-08-15 07:31:19 -07:00
asyncSpawn client.messageQueueConsumer()
asyncSpawn client.simulateMessages()
2025-08-15 07:31:19 -07:00
notice "Client start complete"
2025-08-15 07:31:19 -07:00
proc stop*(client: Client) =
2025-08-15 08:37:53 -07:00
## Stop the client.
2025-08-15 07:31:19 -07:00
client.isRunning = false
notice "Client stopped"