main.mx raw
1 package main
2
3 // MLS Worker - marmot (MLS-over-Nostr) messaging.
4 //
5 // Owns marmot state: KeyPairPackage, group states, peer key package cache.
6 // Communicates through supervisor (wasm-host.mjs) via typed JSON messages.
7 //
8 // supervisor -> worker:
9 // ["M_SET_PUBKEY", pk]
10 // ["M_INIT", urlsJSON] -- generate/load KPP, load groups, subscribe
11 // ["M_SEND_DM", peer, content, urlsJSON] -- send MLS DM
12 // ["M_INCOMING", evJSON] -- raw kind 443/445/1059 from fetch worker
13 // ["M_CRYPTO_RESULT", reqID, result, err] -- signer response
14 // ["M_STORE_RESULT", tag, reqID, data] -- store response
15 // ["M_FETCH_KP_RESULT", peer, evJSON] -- key package fetch result
16 //
17 // worker -> supervisor:
18 // ["M_CRYPTO_REQ", reqID, method, peer, data] -- to signer
19 // ["M_STORE_REQ", tag, reqID, ...] -- to store worker
20 // ["M_PUBLISH", evJSON, urlsJSON] -- publish via relay-proxy
21 // ["M_FETCH_KP", peer, urlsJSON] -- request key package from fetch worker
22 // ["M_SUBSCRIBE", urlsJSON, groupIDsJSON] -- trigger fetch worker subscription
23 // ["M_UPDATE_GROUPS", groupIDsJSON] -- update fetch worker kind 445 filter
24 // ["MLS_DM_RECEIVED", dmJSON] -- to app worker
25 // ["MLS_DM_SENT", peer, ok, err] -- to app worker
26 // ["MLS_UNREAD_COUNT", count] -- to app worker
27 // ["MLS_DM_LIST", listJSON] -- to app worker
28 // ["MLS_DM_HISTORY", peer, msgsJSON] -- to app worker
29
30 import (
31 "git.smesh.lol/smesh/web/common/helpers"
32 "git.smesh.lol/smesh/web/common/jsbridge/mlsw"
33 "git.smesh.lol/smesh/web/common/marmot"
34 "git.smesh.lol/smesh/web/common/mls"
35 "git.smesh.lol/smesh/web/common/mw"
36 "git.smesh.lol/smesh/web/common/nostr"
37 )
38
39 var (
40 myPK string
41 myKPP *mls.KeyPairPackage
42 peerKPs map[string]*mls.KeyPackage
43 mlsGroups map[string]*marmot.GroupState // nostrGroupIDHex -> GroupState
44 initStarted bool
45 initDone bool
46 relayURLs []string
47 unreadCount int32
48
49 pendingIncoming []string // events queued before initDone
50
51 cryptoCBs map[int32]func(string, string)
52 nextCryptoID int32
53 storeCBs map[int32]func(string, string) // reqID -> fn(data, err)
54 nextStoreID int32
55
56 // peer -> queued (content, urls) waiting for KP fetch
57 pendingKPFetch map[string][]pendingSend
58 kpFetchTimers map[string]int32
59
60 // groupIDHex -> queued event JSONs for messages that arrived before Welcome
61 pendingGroupMsgs map[string][]string
62
63 // peer -> recent DM records (JSON strings), ring buffer per peer
64 dmHistory map[string][]string
65 )
66
67 type pendingSend struct {
68 content string
69 urls []string
70 }
71
72 func main() {
73 peerKPs = map[string]*mls.KeyPackage{}
74 mlsGroups = map[string]*marmot.GroupState{}
75 cryptoCBs = map[int32]func(string, string){}
76 storeCBs = map[int32]func(string, string){}
77 pendingKPFetch = map[string][]pendingSend{}
78 kpFetchTimers = map[string]int32{}
79 pendingGroupMsgs = map[string][]string{}
80 dmHistory = map[string][]string{}
81 mlsw.WorkerOnMessage(handleMessage)
82 }
83
84 func handleMessage(msg string) {
85 w := mw.New(msg)
86 cmd := w.Str()
87 println("[mls-worker] handleMessage cmd=" | cmd)
88 switch cmd {
89 case "M_SET_PUBKEY":
90 myPK = w.Str()
91 println("[mls-worker] SET_PUBKEY pk=" | myPK[:16] | "...")
92 case "M_INIT", "MLS_INIT":
93 urlsJSON := w.Raw()
94 relayURLs = parseStrArr(urlsJSON)
95 println("[mls-worker] INIT urls=" | urlsJSON)
96 handleInit(relayURLs)
97 case "M_SEND_DM", "MLS_SEND_DM":
98 peer := w.Str()
99 content := w.Str()
100 urlsJSON := w.Raw()
101 urls := parseStrArr(urlsJSON)
102 if len(urls) == 0 {
103 urls = relayURLs
104 }
105 println("[mls-worker] SEND_DM peer=" | peer[:16] | "... content_len=" | itoi(len(content)) | " urls=" | itoi(len(urls)))
106 handleSendDM(peer, content, urls)
107 case "M_INCOMING":
108 evJSON := w.Raw()
109 if !initDone {
110 println("[mls-worker] INCOMING queued (initDone=false), pending=" | itoi(len(pendingIncoming)+1))
111 pendingIncoming = append(pendingIncoming, evJSON)
112 return
113 }
114 println("[mls-worker] INCOMING processing")
115 processIncoming(evJSON)
116 case "M_CRYPTO_RESULT":
117 reqID := int32(w.Num())
118 result := mw.Unescape(w.Str())
119 errMsg := mw.Unescape(w.Str())
120 println("[mls-worker] CRYPTO_RESULT reqID=" | itoi(reqID) | " err=" | errMsg)
121 if fn, ok := cryptoCBs[reqID]; ok {
122 delete(cryptoCBs, reqID)
123 fn(result, errMsg)
124 }
125 case "M_STORE_RESULT":
126 _ = w.Str() // tag (unused - keyed by reqID)
127 reqID := int32(w.Num())
128 data := w.Str()
129 println("[mls-worker] STORE_RESULT reqID=" | itoi(reqID) | " data_len=" | itoi(len(data)))
130 if fn, ok := storeCBs[reqID]; ok {
131 delete(storeCBs, reqID)
132 fn(data, "")
133 } else {
134 println("[mls-worker] STORE_RESULT: no callback for reqID=" | itoi(reqID))
135 }
136 case "M_FETCH_KP_RESULT":
137 peer := w.Str()
138 evJSON := w.Raw()
139 println("[mls-worker] FETCH_KP_RESULT peer=" | peer[:16] | "... evJSON_len=" | itoi(len(evJSON)))
140 handleFetchKPResult(peer, evJSON)
141 case "MLS_DM_LIST":
142 println("[mls-worker] DM_LIST requested")
143 handleDMList()
144 case "MLS_DM_HISTORY":
145 peer := w.Str()
146 println("[mls-worker] DM_HISTORY peer=" | peer[:16] | "...")
147 handleDMHistory(peer)
148 default:
149 println("[mls-worker] UNHANDLED cmd=" | cmd)
150 }
151 }
152
153 // --- Init ---
154
155 func handleInit(urls []string) {
156 if initStarted {
157 println("[mls-worker] handleInit: already started, skipping")
158 return
159 }
160 if myPK == "" {
161 println("[mls-worker] handleInit: no pubkey, aborting")
162 return
163 }
164 initStarted = true
165 println("[mls-worker] handleInit: loading KPP from store")
166 storeReq("S_MLS_LOAD_KPP", "", func(kppBase64, _ string) {
167 if kppBase64 != "" {
168 println("[mls-worker] handleInit: got stored KPP, len=" | itoi(len(kppBase64)))
169 raw := helpers.Base64Decode(kppBase64)
170 if raw != nil {
171 if kpp, err := mls.UnmarshalKeyPairPackage(raw); err == nil {
172 myKPP = kpp
173 println("[mls-worker] handleInit: KPP loaded from store")
174 } else {
175 println("[mls-worker] handleInit: KPP unmarshal err=" | err.Error())
176 }
177 }
178 }
179 if myKPP == nil {
180 println("[mls-worker] handleInit: generating new KPP")
181 pubBytes := helpers.HexDecode(myPK)
182 if pubBytes == nil {
183 println("[mls-worker] handleInit: invalid pubkey hex")
184 return
185 }
186 now := mlsw.WorkerNowSeconds()
187 kpp, err := marmot.GenerateKeyPackage(pubBytes, now)
188 if err != nil {
189 println("[mls-worker] handleInit: generate KPP err=" | err.Error())
190 return
191 }
192 myKPP = kpp
193 println("[mls-worker] handleInit: KPP generated, saving")
194 raw, err := kpp.Marshal()
195 if err == nil {
196 storeFireForget("S_MLS_SAVE_KPP", jstr(helpers.Base64Encode(raw)))
197 }
198 }
199 println("[mls-worker] handleInit: publishing key package")
200 publishKeyPackage(urls, func() {
201 println("[mls-worker] handleInit: KP published, loading groups")
202 loadGroups(func() {
203 initDone = true
204 println("[mls-worker] handleInit: INIT DONE, groups=" | itoi(len(mlsGroups)) | " pending=" | itoi(len(pendingIncoming)))
205 pending := pendingIncoming
206 pendingIncoming = nil
207 for _, ev := range pending {
208 processIncoming(ev)
209 }
210 groupIDsJSON := buildGroupIDsJSON()
211 urlsJSON := buildURLsJSON(urls)
212 println("[mls-worker] handleInit: subscribing groups=" | groupIDsJSON)
213 mlsw.WorkerPost(`["M_SUBSCRIBE",` | urlsJSON | `,` | groupIDsJSON | `]`)
214 mlsw.WorkerPost(`["MLS_UNREAD_COUNT",0]`)
215 })
216 })
217 })
218 }
219
220 func publishKeyPackage(urls []string, done func()) {
221 now := mlsw.WorkerNowSeconds()
222 ev, err := marmot.BuildKeyPackageEvent(myKPP, myPK, now, urls)
223 if err != nil {
224 println("[mls-worker] publishKP: build err=" | err.Error())
225 done()
226 return
227 }
228 evJSON := ev.ToJSON()
229 println("[mls-worker] publishKP: unsigned kind=" | itoi(int32(ev.Kind)) | " evJSON(80)=" | evJSON[:80])
230 requestCrypto("signEvent", "", evJSON, func(signed, errMsg string) {
231 if errMsg != "" {
232 println("[mls-worker] publishKP: sign err=" | errMsg)
233 } else if signed != "" {
234 println("[mls-worker] publishKP: signed(80)=" | signed[:80])
235 mlsw.WorkerPost(`["M_PUBLISH",` | signed | `,` | buildURLsJSON(urls) | `]`)
236 } else {
237 println("[mls-worker] publishKP: sign returned empty")
238 }
239 done()
240 })
241 }
242
243 func loadGroups(done func()) {
244 storeReq("S_MLS_LIST_GROUPS", "", func(groupsJSON, _ string) {
245 ids := parseStrArr(groupsJSON)
246 if len(ids) == 0 {
247 done()
248 return
249 }
250 remaining := len(ids)
251 for _, id := range ids {
252 gid := id
253 storeReq("S_MLS_LOAD_GROUP", jstr(gid), func(stateBase64, _ string) {
254 if stateBase64 != "" {
255 raw := helpers.Base64Decode(stateBase64)
256 if raw != nil {
257 if gs, err := marmot.UnmarshalGroupState(raw); err == nil {
258 mlsGroups[gid] = gs
259 }
260 }
261 }
262 remaining--
263 if remaining == 0 {
264 done()
265 }
266 })
267 }
268 })
269 }
270
271 // --- Incoming events ---
272
273 func processIncoming(evJSON string) {
274 ev := nostr.ParseEvent(evJSON)
275 if ev == nil {
276 println("[mls-worker] processIncoming: parse failed")
277 return
278 }
279 println("[mls-worker] processIncoming: kind=" | itoi(int32(ev.Kind)) | " from=" | ev.PubKey[:16] | "... id=" | ev.ID[:16] | "...")
280 switch ev.Kind {
281 case marmot.KindKeyPackage:
282 if kp, err := marmot.EventToKeyPackage(ev); err == nil {
283 cs := kp.CipherSuiteValue()
284 if cs != 3 {
285 println("[mls-worker] processIncoming: skip KP cs=" | itoi(int32(cs)) | " from " | ev.PubKey[:16] | "...")
286 break
287 }
288 if len(peerKPs) > 200 {
289 for k := range peerKPs {
290 delete(peerKPs, k)
291 break
292 }
293 }
294 peerKPs[ev.PubKey] = kp
295 println("[mls-worker] processIncoming: cached KP for " | ev.PubKey[:16] | "...")
296 } else {
297 println("[mls-worker] processIncoming: KP parse err=" | err.Error())
298 }
299 case marmot.KindGiftWrap:
300 println("[mls-worker] processIncoming: gift wrap, decrypting")
301 requestCrypto("nip44.decrypt", myPK, ev.Content, func(plaintext, errMsg string) {
302 if errMsg != "" {
303 println("[mls-worker] processIncoming: gift wrap decrypt err=" | errMsg)
304 return
305 }
306 if plaintext == "" {
307 println("[mls-worker] processIncoming: gift wrap decrypt empty")
308 return
309 }
310 inner := nostr.ParseEvent(plaintext)
311 if inner == nil {
312 println("[mls-worker] processIncoming: inner event parse failed")
313 return
314 }
315 println("[mls-worker] processIncoming: inner kind=" | itoi(int32(inner.Kind)))
316 switch inner.Kind {
317 case marmot.KindWelcome:
318 processWelcome(inner, ev.PubKey)
319 case marmot.KindGroupMessage:
320 processGroupMessage(inner)
321 default:
322 println("[mls-worker] processIncoming: unexpected inner kind=" | itoi(int32(inner.Kind)))
323 }
324 })
325 case marmot.KindGroupMessage:
326 processGroupMessage(ev)
327 default:
328 println("[mls-worker] processIncoming: unhandled kind=" | itoi(int32(ev.Kind)))
329 }
330 }
331
332 func processWelcome(rumorEv *nostr.Event, senderPubHex string) {
333 println("[mls-worker] processWelcome: from=" | senderPubHex[:16] | "...")
334 if myKPP == nil {
335 println("[mls-worker] processWelcome: no KPP, aborting")
336 return
337 }
338 welcome, err := marmot.RumorToWelcome(rumorEv)
339 if err != nil {
340 println("[mls-worker] processWelcome: parse err=" | err.Error())
341 return
342 }
343 peerPub := helpers.HexDecode(senderPubHex)
344 if peerPub == nil {
345 println("[mls-worker] processWelcome: bad sender hex")
346 return
347 }
348 now := mlsw.WorkerNowSeconds()
349 gs, err := marmot.JoinDMGroup(welcome, myKPP, peerPub, now)
350 if err != nil {
351 println("[mls-worker] processWelcome: join err=" | err.Error())
352 return
353 }
354 groupIDHex := helpers.HexEncode(gs.NostrGroupID)
355 println("[mls-worker] processWelcome: joined group=" | groupIDHex[:16] | "...")
356 mlsGroups[groupIDHex] = gs
357 saveGroup(groupIDHex, gs)
358 mlsw.WorkerPost(`["M_UPDATE_GROUPS",` | buildGroupIDsJSON() | `]`)
359
360 if queued, ok := pendingGroupMsgs[groupIDHex]; ok {
361 println("[mls-worker] processWelcome: draining " | itoi(len(queued)) | " pending messages")
362 delete(pendingGroupMsgs, groupIDHex)
363 for _, evJSON := range queued {
364 processIncoming(evJSON)
365 }
366 }
367 }
368
369 func processGroupMessage(ev *nostr.Event) {
370 hTag := ev.Tags.GetFirst("h")
371 if hTag == nil {
372 println("[mls-worker] processGroupMessage: no 'h' tag")
373 return
374 }
375 groupIDHex := string(hTag.Value())
376 println("[mls-worker] processGroupMessage: group=" | groupIDHex[:16] | "... from=" | ev.PubKey[:16] | "...")
377 gs, ok := mlsGroups[groupIDHex]
378 if !ok {
379 q := pendingGroupMsgs[groupIDHex]
380 if len(q) >= 64 {
381 println("[mls-worker] processGroupMessage: unknown group, pending queue full, dropping")
382 return
383 }
384 pendingGroupMsgs[groupIDHex] = append(q, ev.ToJSON())
385 println("[mls-worker] processGroupMessage: unknown group, queued (pending=" | itoi(len(pendingGroupMsgs[groupIDHex])) | ")")
386 return
387 }
388 secret, err := marmot.DeriveExporterSecret(gs.Group)
389 if err != nil {
390 println("[mls-worker] processGroupMessage: derive secret err=" | err.Error())
391 return
392 }
393 _, mlsCipher, err := marmot.EventToMessage(ev, secret)
394 if err != nil {
395 println("[mls-worker] processGroupMessage: EventToMessage err=" | err.Error())
396 return
397 }
398 plaintext, _, err := gs.Group.UnmarshalAndProcessMessage(mlsCipher)
399 if err != nil {
400 println("[mls-worker] processGroupMessage: process err=" | err.Error())
401 return
402 }
403 saveGroup(groupIDHex, gs)
404
405 peer := helpers.HexEncode(gs.PeerPub)
406 println("[mls-worker] processGroupMessage: decrypted msg from " | peer[:16] | "... len=" | itoi(len(plaintext)))
407 dmJSON := buildDMRecord(peer, ev.PubKey, plaintext, ev.CreatedAt, "mls", ev.ID)
408 storeDM(peer, dmJSON)
409 mlsw.WorkerPost(`["MLS_DM_RECEIVED",` | dmJSON | `]`)
410 unreadCount++
411 mlsw.WorkerPost(`["MLS_UNREAD_COUNT",` | itoi(unreadCount) | `]`)
412 }
413
414 // --- Send DM ---
415
416 func handleSendDM(peer, content string, urls []string) {
417 groupIDHex := dmGroupIDHex(peer)
418 println("[mls-worker] handleSendDM: peer=" | peer[:16] | "... groupID=" | groupIDHex[:16] | "...")
419 if gs, ok := mlsGroups[groupIDHex]; ok {
420 println("[mls-worker] handleSendDM: existing group found, sending")
421 sendToExistingGroup(peer, content, urls, gs, groupIDHex)
422 return
423 }
424 if kp, ok := peerKPs[peer]; ok {
425 println("[mls-worker] handleSendDM: have peer KP, creating group")
426 createGroupAndSend(peer, content, urls, kp)
427 return
428 }
429 println("[mls-worker] handleSendDM: no KP, fetching from relay")
430 pendingKPFetch[peer] = append(pendingKPFetch[peer], pendingSend{content: content, urls: urls})
431 if _, fetching := kpFetchTimers[peer]; !fetching {
432 startKPFetch(peer, urls)
433 }
434 }
435
436 func startKPFetch(peer string, urls []string) {
437 mlsw.WorkerPost(`["M_FETCH_KP",` | jstr(peer) | `,` | buildURLsJSON(urls) | `]`)
438 p := peer
439 handle := mlsw.WorkerSetTimeout(15000, func() {
440 delete(kpFetchTimers, p)
441 if pending, ok := pendingKPFetch[p]; ok {
442 delete(pendingKPFetch, p)
443 for range pending {
444 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(p) | `,0,"peer does not support MLS messaging"]`)
445 }
446 }
447 })
448 kpFetchTimers[peer] = handle
449 }
450
451 func handleFetchKPResult(peer, evJSON string) {
452 if h, ok := kpFetchTimers[peer]; ok {
453 mlsw.WorkerClearTimeout(h)
454 delete(kpFetchTimers, peer)
455 }
456 if evJSON != "" && evJSON != "null" {
457 ev := nostr.ParseEvent(evJSON)
458 if ev != nil {
459 if kp, err := marmot.EventToKeyPackage(ev); err == nil {
460 cs := kp.CipherSuiteValue()
461 if cs != 3 {
462 println("[mls-worker] handleFetchKPResult: skip KP cs=" | itoi(int32(cs)) | " for " | peer[:16] | "...")
463 } else {
464 peerKPs[peer] = kp
465 println("[mls-worker] handleFetchKPResult: got KP for " | peer[:16] | "...")
466 }
467 } else {
468 println("[mls-worker] handleFetchKPResult: parse KP err=" | err.Error())
469 }
470 } else {
471 println("[mls-worker] handleFetchKPResult: parse event failed")
472 }
473 } else {
474 println("[mls-worker] handleFetchKPResult: no event returned for " | peer[:16] | "...")
475 }
476 pending, ok := pendingKPFetch[peer]
477 if !ok {
478 println("[mls-worker] handleFetchKPResult: no pending sends")
479 return
480 }
481 delete(pendingKPFetch, peer)
482 kp, hasKP := peerKPs[peer]
483 println("[mls-worker] handleFetchKPResult: hasKP=" | bstr(hasKP) | " pending=" | itoi(len(pending)))
484 for _, ps := range pending {
485 if !hasKP {
486 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"peer does not support MLS messaging"]`)
487 continue
488 }
489 createGroupAndSend(peer, ps.content, ps.urls, kp)
490 }
491 }
492
493 func bstr(b bool) (s string) {
494 if b {
495 return "true"
496 }
497 return "false"
498 }
499
500 func createGroupAndSend(peer, content string, urls []string, peerKP *mls.KeyPackage) {
501 if myKPP == nil {
502 println("[mls-worker] createGroupAndSend: no KPP")
503 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"not initialized"]`)
504 return
505 }
506 selfPub := helpers.HexDecode(myPK)
507 peerPub := helpers.HexDecode(peer)
508 if selfPub == nil || peerPub == nil {
509 println("[mls-worker] createGroupAndSend: bad pubkey hex")
510 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"invalid pubkey"]`)
511 return
512 }
513 now := mlsw.WorkerNowSeconds()
514 peerCS := peerKP.CipherSuiteValue()
515 println("[mls-worker] createGroupAndSend: selfCS=" | itoi(int32(myKPP.Public.CipherSuiteValue())) | " peerCS=" | itoi(int32(peerCS)))
516 if peerCS != 3 {
517 println("[mls-worker] createGroupAndSend: unsupported peer cipher suite " | itoi(int32(peerCS)))
518 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"unsupported peer cipher suite"]`)
519 return
520 }
521 gs, welcome, err := marmot.CreateDMGroup(myKPP, peerKP, selfPub, peerPub, "", urls)
522 if err != nil {
523 println("[mls-worker] createGroupAndSend: create err=" | err.Error())
524 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
525 return
526 }
527 groupIDHex := helpers.HexEncode(gs.NostrGroupID)
528 println("[mls-worker] createGroupAndSend: group=" | groupIDHex[:16] | "... sending welcome")
529 mlsGroups[groupIDHex] = gs
530
531 rumorEv := marmot.WelcomeToRumor(welcome, selfPub, now, "", urls)
532 rumorJSON := rumorEv.ToJSON()
533 requestCrypto("nip44.encrypt", peer, rumorJSON, func(sealed, errMsg string) {
534 if errMsg != "" {
535 println("[mls-worker] createGroupAndSend: gift-wrap encrypt err=" | errMsg)
536 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"gift-wrap encrypt failed"]`)
537 return
538 }
539 println("[mls-worker] createGroupAndSend: gift-wrap encrypted, signing")
540 wrapEv := &nostr.Event{
541 PubKey: myPK,
542 CreatedAt: mlsw.WorkerNowSeconds(),
543 Kind: marmot.KindGiftWrap,
544 Tags: nostr.Tags{nostr.Tag{"p", peer}},
545 Content: sealed,
546 }
547 wrapJSON := wrapEv.ToJSON()
548 requestCrypto("signEvent", "", wrapJSON, func(signedWrap, errMsg string) {
549 if errMsg != "" {
550 println("[mls-worker] createGroupAndSend: sign wrap err=" | errMsg)
551 } else if signedWrap != "" {
552 println("[mls-worker] createGroupAndSend: publishing welcome (kind 1059)")
553 mlsw.WorkerPost(`["M_PUBLISH",` | signedWrap | `,` | buildURLsJSON(urls) | `]`)
554 }
555 p := peer
556 sendToExistingGroup(p, content, urls, gs, groupIDHex)
557 })
558 })
559
560 saveGroup(groupIDHex, gs)
561 mlsw.WorkerPost(`["M_UPDATE_GROUPS",` | buildGroupIDsJSON() | `]`)
562 }
563
564 func sendToExistingGroup(peer, content string, urls []string, gs *marmot.GroupState, groupIDHex string) {
565 now := mlsw.WorkerNowSeconds()
566 println("[mls-worker] sendToExistingGroup: creating app message")
567 appMsg, err := gs.Group.CreateApplicationMessage([]byte(content))
568 if err != nil {
569 println("[mls-worker] sendToExistingGroup: createAppMsg err=" | err.Error())
570 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
571 return
572 }
573 secret, err := marmot.DeriveExporterSecret(gs.Group)
574 if err != nil {
575 println("[mls-worker] sendToExistingGroup: deriveSecret err=" | err.Error())
576 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
577 return
578 }
579 ev445, err := marmot.MessageToEvent(gs.NostrGroupID, appMsg, secret, now)
580 if err != nil {
581 println("[mls-worker] sendToExistingGroup: MessageToEvent err=" | err.Error())
582 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
583 return
584 }
585 println("[mls-worker] sendToExistingGroup: publishing kind 445 id=" | ev445.ID[:16] | "...")
586 mlsw.WorkerPost(`["M_PUBLISH",` | ev445.ToJSON() | `,` | buildURLsJSON(urls) | `]`)
587 saveGroup(groupIDHex, gs)
588
589 dmJSON := buildDMRecord(peer, myPK, content, now, "mls", ev445.ID)
590 storeDM(peer, dmJSON)
591 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,1,""]`)
592 mlsw.WorkerPost(`["MLS_DM_RECEIVED",` | dmJSON | `]`)
593 }
594
595 // --- Store helpers ---
596
597 func saveGroup(groupIDHex string, gs *marmot.GroupState) {
598 raw, err := marmot.MarshalGroupState(gs)
599 if err != nil {
600 return
601 }
602 b64 := helpers.Base64Encode(raw)
603 storeFireForget("S_MLS_SAVE_GROUP", jstr(groupIDHex) | `,` | jstr(b64))
604 }
605
606 func storeReq(op, extraArgs string, fn func(string, string)) {
607 nextStoreID++
608 id := nextStoreID
609 storeCBs[id] = fn
610 msg := `["M_STORE_REQ",` | jstr(op) | `,` | itoi(id)
611 if extraArgs != "" {
612 msg = msg | `,` | extraArgs
613 }
614 mlsw.WorkerPost(msg | `]`)
615 }
616
617 func storeFireForget(op, args string) {
618 nextStoreID++
619 id := nextStoreID
620 msg := `["M_STORE_REQ",` | jstr(op) | `,` | itoi(id)
621 if args != "" {
622 msg = msg | `,` | args
623 }
624 mlsw.WorkerPost(msg | `]`)
625 }
626
627 func requestCrypto(method, peer, data string, fn func(string, string)) {
628 nextCryptoID++
629 id := nextCryptoID
630 cryptoCBs[id] = fn
631 mlsw.WorkerPost(`["M_CRYPTO_REQ",` | itoi(id) | `,` | jstr(method) | `,` | jstr(peer) | `,` | jstr(data) | `]`)
632 cbID := id
633 mlsw.WorkerSetTimeout(15000, func() {
634 if _, ok := cryptoCBs[cbID]; ok {
635 delete(cryptoCBs, cbID)
636 fn("", "timeout")
637 }
638 })
639 }
640
641 // --- DM list / history ---
642
643 func handleDMList() {
644 // Build conversation list from in-memory group states.
645 list := "["
646 first := true
647 for _, gs := range mlsGroups {
648 if !first {
649 list = list | ","
650 }
651 first = false
652 peer := helpers.HexEncode(gs.PeerPub)
653 list = list | `{"peer":` | jstr(peer) | `}`
654 }
655 list = list | "]"
656 mlsw.WorkerPost(`["MLS_DM_LIST",` | list | `]`)
657 }
658
659 func handleDMHistory(peer string) {
660 msgs := dmHistory[peer]
661 if len(msgs) == 0 {
662 mlsw.WorkerPost(`["MLS_DM_HISTORY",` | jstr(peer) | `,[]` | `]`)
663 return
664 }
665 // renderer expects newest-first (reverses to oldest-first for display)
666 arr := "["
667 for i := len(msgs) - 1; i >= 0; i-- {
668 if i < len(msgs)-1 {
669 arr = arr | ","
670 }
671 arr = arr | msgs[i]
672 }
673 arr = arr | "]"
674 mlsw.WorkerPost(`["MLS_DM_HISTORY",` | jstr(peer) | `,` | arr | `]`)
675 }
676
677 func storeDM(peer, dmJSON string) {
678 h := dmHistory[peer]
679 if len(h) >= 256 {
680 h = h[1:]
681 }
682 dmHistory[peer] = append(h, dmJSON)
683 }
684
685 // --- Helpers ---
686
687 func dmGroupIDHex(peer string) (s string) {
688 selfPub := helpers.HexDecode(myPK)
689 peerPub := helpers.HexDecode(peer)
690 if selfPub == nil || peerPub == nil {
691 return ""
692 }
693 id := marmot.DMGroupID(selfPub, peerPub)
694 return helpers.HexEncode(id)
695 }
696
697 func buildGroupIDsJSON() (s string) {
698 s := "["
699 first := true
700 for id := range mlsGroups {
701 if !first {
702 s = s | ","
703 }
704 first = false
705 s = s | jstr(id)
706 }
707 return s | "]"
708 }
709
710 func buildURLsJSON(urls []string) (s string) {
711 s := "["
712 for i, u := range urls {
713 if i > 0 {
714 s = s | ","
715 }
716 s = s | jstr(u)
717 }
718 return s | "]"
719 }
720
721 func buildDMRecord(peer, from, content string, createdAt int64, protocol, eventID string) (s string) {
722 return `{"id":` | jstr(eventID) |
723 `,"peer":` | jstr(peer) |
724 `,"from":` | jstr(from) |
725 `,"content":` | jstr(content) |
726 `,"created_at":` | itoa(createdAt) |
727 `,"protocol":` | jstr(protocol) |
728 `,"eventId":` | jstr(eventID) |
729 `}`
730 }
731
732 func parseStrArr(json string) (ss []string) {
733 var out []string
734 i := 0
735 for i < len(json) && json[i] != '[' {
736 i++
737 }
738 if i >= len(json) {
739 return nil
740 }
741 i++
742 for {
743 for i < len(json) && (json[i] == ' ' || json[i] == ',' || json[i] == '\n') {
744 i++
745 }
746 if i >= len(json) || json[i] == ']' {
747 break
748 }
749 if json[i] != '"' {
750 break
751 }
752 i++
753 start := i
754 for i < len(json) && json[i] != '"' {
755 if json[i] == '\\' {
756 i++
757 }
758 i++
759 }
760 if i >= len(json) {
761 break
762 }
763 out = append(out, json[start:i])
764 i++
765 }
766 return out
767 }
768
769 func itoa(n int64) (s string) { return helpers.Itoa(n) }
770 func itoi(n int32) (s string) { return helpers.Itoa(int64(n)) }
771 func jstr(s string) (sv string) { return helpers.JsonString(s) }
772
773