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,
|
2025-07-07 14:06:32 -07:00
|
|
|
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-07-07 14:06:32 -07:00
|
|
|
|
2025-08-15 07:31:19 -07:00
|
|
|
client.isRunning = true
|
2025-07-07 14:06:32 -07:00
|
|
|
|
2025-08-15 07:31:19 -07:00
|
|
|
asyncSpawn client.messageQueueConsumer()
|
|
|
|
|
asyncSpawn client.simulateMessages()
|
2025-07-07 14:06:32 -07:00
|
|
|
|
2025-08-15 07:31:19 -07:00
|
|
|
notice "Client start complete"
|
2025-07-07 14:06:32 -07:00
|
|
|
|
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"
|