chats_model.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. const pull = require("../server/node_modules/pull-stream")
  2. const crypto = require("crypto")
  3. const { getConfig } = require("../configs/config-manager.js")
  4. const logLimit = getConfig().ssbLogStream?.limit || 1000
  5. const safeArr = (v) => (Array.isArray(v) ? v : [])
  6. const safeText = (v) => String(v || "").trim()
  7. const normalizeTags = (raw) => {
  8. if (raw === undefined || raw === null) return []
  9. if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
  10. return String(raw).split(",").map(t => t.trim()).filter(Boolean)
  11. }
  12. const INVITE_CODE_BYTES = 16
  13. const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
  14. module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
  15. let ssb
  16. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
  17. const getTribeKeysFor = async (tribeId) => {
  18. if (!tribeCrypto || !tribesModel || !tribeId) return []
  19. try {
  20. const rootId = await tribesModel.getRootId(tribeId)
  21. return tribeCrypto.getKeys(rootId) || []
  22. } catch (_) { return [] }
  23. }
  24. const getTribeFirstKeyFor = async (tribeId) => {
  25. const ks = await getTribeKeysFor(tribeId)
  26. return ks.length ? ks[0] : null
  27. }
  28. const readAll = async (ssbClient) =>
  29. new Promise((resolve, reject) =>
  30. pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
  31. )
  32. const buildIndex = (messages) => {
  33. const tomb = new Set()
  34. const nodes = new Map()
  35. const parent = new Map()
  36. const child = new Map()
  37. const msgNodes = new Map()
  38. const authorByKey = new Map()
  39. const tombRequests = []
  40. for (const m of messages) {
  41. const k = m.key
  42. const v = m.value || {}
  43. const c = v.content
  44. if (!c) continue
  45. if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
  46. if (c.type === "chat") {
  47. nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
  48. authorByKey.set(k, v.author)
  49. if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
  50. } else if (c.type === "chatMessage") {
  51. msgNodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
  52. authorByKey.set(k, v.author)
  53. }
  54. }
  55. for (const t of tombRequests) {
  56. const targetAuthor = authorByKey.get(t.target)
  57. if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
  58. }
  59. const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
  60. const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
  61. const roots = new Set()
  62. for (const id of nodes.keys()) roots.add(rootOf(id))
  63. const tipByRoot = new Map()
  64. for (const r of roots) tipByRoot.set(r, tipOf(r))
  65. return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, msgNodes }
  66. }
  67. const resolveKeyChainSets = (chatRootId) => {
  68. if (!tribeCrypto) return []
  69. const keys = tribeCrypto.getKeys(chatRootId)
  70. return keys.map(k => [k])
  71. }
  72. const buildChat = (node, rootId) => {
  73. const rawC = node.c || {}
  74. if (rawC.type !== "chat") return null
  75. let c = rawC
  76. if (tribeCrypto && c.encryptedPayload) {
  77. const keyChainSets = resolveKeyChainSets(rootId)
  78. c = tribeCrypto.decryptContent(c, keyChainSets)
  79. }
  80. return {
  81. key: node.key,
  82. rootId,
  83. title: c.title || "",
  84. description: c.description || "",
  85. image: c.image || null,
  86. category: c.category || "",
  87. status: c.status || "OPEN",
  88. tags: safeArr(c.tags),
  89. members: safeArr(c.members),
  90. invites: safeArr(c.invites),
  91. author: c.author || node.author,
  92. createdAt: c.createdAt || new Date(node.ts).toISOString(),
  93. updatedAt: c.updatedAt || null,
  94. encrypted: !!c.encrypted,
  95. tribeId: c.tribeId || null
  96. }
  97. }
  98. const buildMessage = (node, chatRootId, tribeKeys = []) => {
  99. const c = node.c || {}
  100. if (c.type !== "chatMessage") return null
  101. let text = c.text || ""
  102. if (tribeCrypto && c.encryptedText) {
  103. const candidateKeys = [...tribeKeys, ...tribeCrypto.getKeys(chatRootId)]
  104. for (const keyHex of candidateKeys) {
  105. try {
  106. text = tribeCrypto.decryptWithKey(c.encryptedText, keyHex)
  107. break
  108. } catch (_) {}
  109. }
  110. }
  111. return {
  112. key: node.key,
  113. chatId: c.chatId || "",
  114. text,
  115. image: c.image || null,
  116. author: c.author || node.author,
  117. createdAt: c.createdAt || new Date(node.ts).toISOString()
  118. }
  119. }
  120. const publishTombstone = async (ssbClient, tipId) =>
  121. new Promise((resolve, reject) => {
  122. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
  123. ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
  124. })
  125. return {
  126. type: "chat",
  127. async resolveRootId(id) {
  128. const ssbClient = await openSsb()
  129. const messages = await readAll(ssbClient)
  130. const idx = buildIndex(messages)
  131. let tip = id
  132. while (idx.child.has(tip)) tip = idx.child.get(tip)
  133. if (idx.tomb.has(tip)) throw new Error("Not found")
  134. let root = tip
  135. while (idx.parent.has(root)) root = idx.parent.get(root)
  136. return root
  137. },
  138. async resolveCurrentId(id) {
  139. const ssbClient = await openSsb()
  140. const messages = await readAll(ssbClient)
  141. const idx = buildIndex(messages)
  142. let tip = id
  143. while (idx.child.has(tip)) tip = idx.child.get(tip)
  144. if (idx.tomb.has(tip)) throw new Error("Not found")
  145. return tip
  146. },
  147. async createChat(title, description, image, category, status, tagsRaw, tribeId) {
  148. const ssbClient = await openSsb()
  149. const userId = ssbClient.id
  150. const blobId = image ? String(image).trim() || null : null
  151. const tags = normalizeTags(tagsRaw)
  152. const st = VALID_STATUS.includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
  153. const now = new Date().toISOString()
  154. let content = {
  155. type: "chat",
  156. title: safeText(title),
  157. description: safeText(description),
  158. image: blobId,
  159. category: safeText(category),
  160. status: st,
  161. tags,
  162. members: [userId],
  163. invites: [],
  164. author: userId,
  165. createdAt: now,
  166. updatedAt: now,
  167. ...(tribeId ? { tribeId } : {})
  168. }
  169. if (!tribeCrypto) {
  170. return new Promise((resolve, reject) => {
  171. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  172. })
  173. }
  174. if (tribeId) {
  175. try {
  176. const ancestryIds = await tribesModel.getAncestryChain(tribeId)
  177. const chain = []
  178. for (const rid of ancestryIds || []) {
  179. const k = tribeCrypto.getKey(rid)
  180. if (!k) { chain.length = 0; break }
  181. chain.push(k)
  182. }
  183. if (chain.length) content = tribeCrypto.encryptContent(content, chain, true)
  184. } catch (_) {}
  185. return new Promise((resolve, reject) => {
  186. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  187. })
  188. }
  189. const chatKey = tribeCrypto.generateTribeKey()
  190. if (st === "OPEN") {
  191. const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
  192. const ek = tribeCrypto.encryptForInvite(chatKey, code)
  193. content.invites = [{ code, ek, gen: 1, public: true }]
  194. }
  195. content = tribeCrypto.encryptContent(content, [chatKey], true)
  196. const result = await new Promise((resolve, reject) => {
  197. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  198. })
  199. tribeCrypto.setKey(result.key, chatKey, 1)
  200. try {
  201. const ssbKeys = require("../server/node_modules/ssb-keys")
  202. const boxedKey = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys)
  203. await new Promise((resolve) => {
  204. ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve())
  205. })
  206. } catch (_) {}
  207. return result
  208. },
  209. async updateChatById(id, data, { skipAuthorCheck = false } = {}) {
  210. const tipId = await this.resolveCurrentId(id)
  211. const ssbClient = await openSsb()
  212. const userId = ssbClient.id
  213. const item = await new Promise((resolve, reject) => {
  214. ssbClient.get(tipId, (err, item) => err || !item?.content ? reject(new Error("Chat not found")) : resolve(item))
  215. })
  216. const c = item.content
  217. const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
  218. if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) throw new Error("Not the author")
  219. const messages = await readAll(ssbClient)
  220. const idx = buildIndex(messages)
  221. let rootId = tipId
  222. while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
  223. const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 }
  224. const chat = buildChat(node, rootId)
  225. if (!chat) throw new Error("Invalid chat")
  226. let updated = {
  227. type: "chat",
  228. replaces: tipId,
  229. title: data.title !== undefined ? safeText(data.title) : chat.title,
  230. description: data.description !== undefined ? safeText(data.description) : chat.description,
  231. image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image,
  232. category: data.category !== undefined ? safeText(data.category) : chat.category,
  233. status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
  234. tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
  235. members: data.members !== undefined ? safeArr(data.members) : chat.members,
  236. invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
  237. author: chat.author,
  238. createdAt: chat.createdAt,
  239. updatedAt: new Date().toISOString(),
  240. ...(chat.tribeId ? { tribeId: chat.tribeId } : {})
  241. }
  242. if (tribeCrypto) {
  243. if (chat.tribeId) {
  244. try {
  245. const ancestryIds = await tribesModel.getAncestryChain(chat.tribeId)
  246. const chain = []
  247. for (const rid of ancestryIds || []) {
  248. const k = tribeCrypto.getKey(rid)
  249. if (!k) { chain.length = 0; break }
  250. chain.push(k)
  251. }
  252. if (chain.length) updated = tribeCrypto.encryptContent(updated, chain, true)
  253. } catch (_) {}
  254. } else {
  255. const chatKey = tribeCrypto.getKey(rootId)
  256. if (chatKey) updated = tribeCrypto.encryptContent(updated, [chatKey], true)
  257. }
  258. }
  259. return new Promise((resolve, reject) => {
  260. ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => {
  261. if (e1) return reject(e1)
  262. ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
  263. })
  264. })
  265. },
  266. async deleteChatById(id) {
  267. const tipId = await this.resolveCurrentId(id)
  268. const ssbClient = await openSsb()
  269. const userId = ssbClient.id
  270. return new Promise((resolve, reject) => {
  271. ssbClient.get(tipId, (err, item) => {
  272. if (err || !item?.content) return reject(new Error("Chat not found"))
  273. if (item.content.author && item.content.author !== userId) return reject(new Error("Not the author"))
  274. ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
  275. })
  276. })
  277. },
  278. async closeChatById(id) {
  279. const ssbClient = await openSsb()
  280. const userId = ssbClient.id
  281. const messages = await readAll(ssbClient)
  282. const idx = buildIndex(messages)
  283. let tip = id
  284. while (idx.child.has(tip)) tip = idx.child.get(tip)
  285. if (idx.tomb.has(tip)) throw new Error("Not found")
  286. let root = tip
  287. while (idx.parent.has(root)) root = idx.parent.get(root)
  288. const node = idx.nodes.get(tip)
  289. if (!node) throw new Error("Not found")
  290. const chat = buildChat(node, root)
  291. if (!chat) throw new Error("Invalid chat")
  292. if (chat.author !== userId) throw new Error("Not the author")
  293. const updated = {
  294. type: "chat",
  295. replaces: tip,
  296. title: chat.title,
  297. description: chat.description,
  298. image: chat.image,
  299. category: chat.category,
  300. status: "CLOSED",
  301. tags: chat.tags,
  302. members: chat.members,
  303. invites: chat.invites,
  304. author: chat.author,
  305. createdAt: chat.createdAt,
  306. updatedAt: new Date().toISOString()
  307. }
  308. await publishTombstone(ssbClient, tip)
  309. return new Promise((resolve, reject) => {
  310. ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))
  311. })
  312. },
  313. async getChatById(id) {
  314. const ssbClient = await openSsb()
  315. const messages = await readAll(ssbClient)
  316. const idx = buildIndex(messages)
  317. let tip = id
  318. while (idx.child.has(tip)) tip = idx.child.get(tip)
  319. if (idx.tomb.has(tip)) return null
  320. const node = idx.nodes.get(tip)
  321. if (!node || node.c.type !== "chat") return null
  322. let root = tip
  323. while (idx.parent.has(root)) root = idx.parent.get(root)
  324. const chat = buildChat(node, root)
  325. if (!chat) return null
  326. return chat
  327. },
  328. async listAll({ filter = "all", q = "", sort = "recent", viewerId } = {}) {
  329. const ssbClient = await openSsb()
  330. const uid = viewerId || ssbClient.id
  331. const messages = await readAll(ssbClient)
  332. const idx = buildIndex(messages)
  333. const now = Date.now()
  334. const items = []
  335. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  336. if (idx.tomb.has(tipId)) continue
  337. const node = idx.nodes.get(tipId)
  338. if (!node || node.c.type !== "chat") continue
  339. const chat = buildChat(node, rootId)
  340. if (!chat) continue
  341. items.push(chat)
  342. }
  343. let list = items
  344. if (filter === "mine") list = list.filter(c => c.author === uid)
  345. else if (filter === "recent") list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
  346. else if (filter === "open") list = list.filter(c => c.status === "OPEN" || c.status === "INVITE-ONLY")
  347. else if (filter === "closed") list = list.filter(c => c.status === "CLOSED")
  348. if (q) {
  349. const qq = q.toLowerCase()
  350. list = list.filter(c => {
  351. const t = String(c.title || "").toLowerCase()
  352. const d = String(c.description || "").toLowerCase()
  353. const cat = String(c.category || "").toLowerCase()
  354. const tags = safeArr(c.tags).join(" ").toLowerCase()
  355. return t.includes(qq) || d.includes(qq) || cat.includes(qq) || tags.includes(qq)
  356. })
  357. }
  358. list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  359. return list
  360. },
  361. async generateInvite(chatId) {
  362. const ssbClient = await openSsb()
  363. const userId = ssbClient.id
  364. const chat = await this.getChatById(chatId)
  365. if (!chat) throw new Error("Chat not found")
  366. if (chat.author !== userId) throw new Error("Only the author can generate invites")
  367. const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
  368. let invite = code
  369. if (tribeCrypto) {
  370. const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code)
  371. if (ekChain) {
  372. invite = { code, ekChain, gen: tribeCrypto.getGen(chat.rootId) }
  373. } else {
  374. const chatKey = tribeCrypto.getKey(chat.rootId)
  375. if (chatKey) {
  376. const ek = tribeCrypto.encryptForInvite(chatKey, code)
  377. invite = { code, ek, gen: tribeCrypto.getGen(chat.rootId) }
  378. }
  379. }
  380. }
  381. const invites = [...chat.invites, invite]
  382. await this.updateChatById(chatId, { invites, members: chat.members, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags })
  383. return code
  384. },
  385. async joinByInvite(code) {
  386. const ssbClient = await openSsb()
  387. const userId = ssbClient.id
  388. const messages = await readAll(ssbClient)
  389. const idx = buildIndex(messages)
  390. let matchedChat = null
  391. let matchedInvite = null
  392. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  393. if (idx.tomb.has(tipId)) continue
  394. const node = idx.nodes.get(tipId)
  395. if (!node || node.c.type !== "chat") continue
  396. const chat = buildChat(node, rootId)
  397. if (!chat || !chat.invites.length) continue
  398. for (const inv of chat.invites) {
  399. if (typeof inv === "string" && inv === code) {
  400. matchedChat = chat; matchedInvite = inv; break
  401. }
  402. if (typeof inv === "object" && inv.code === code) {
  403. matchedChat = chat; matchedInvite = inv; break
  404. }
  405. }
  406. if (matchedChat) break
  407. }
  408. if (!matchedChat) throw new Error("Invalid or expired invite code")
  409. if (matchedChat.members.includes(userId)) throw new Error("Already a participant")
  410. let chatKey = null
  411. if (tribeCrypto && typeof matchedInvite === "object") {
  412. if (matchedInvite.ekChain) {
  413. const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code)
  414. if (Array.isArray(chain) && chain.length) {
  415. for (const entry of chain) {
  416. if (Array.isArray(entry.keys) && entry.keys.length) {
  417. tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
  418. } else if (entry.key) {
  419. tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1)
  420. }
  421. }
  422. chatKey = chain[0].key
  423. }
  424. } else if (matchedInvite.ek) {
  425. chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
  426. tribeCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
  427. }
  428. }
  429. const members = [...matchedChat.members, userId]
  430. const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
  431. const invites = isPublicInvite ? matchedChat.invites : matchedChat.invites.filter(inv => {
  432. if (typeof inv === "string") return inv !== code
  433. return inv.code !== code
  434. })
  435. await this.updateChatById(matchedChat.key, { members, invites, status: matchedChat.status, title: matchedChat.title, description: matchedChat.description, image: matchedChat.image, category: matchedChat.category, tags: matchedChat.tags }, { skipAuthorCheck: true })
  436. if (tribeCrypto && chatKey) {
  437. try {
  438. const ssbKeys = require("../server/node_modules/ssb-keys")
  439. const memberKeys = {}
  440. try { memberKeys[userId] = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys) } catch (_) {}
  441. if (matchedChat.author && matchedChat.author !== userId) {
  442. try { memberKeys[matchedChat.author] = tribeCrypto.boxKeyForMember(chatKey, matchedChat.author, ssbKeys) } catch (_) {}
  443. }
  444. if (Object.keys(memberKeys).length) {
  445. await new Promise((resolve) => {
  446. ssbClient.publish({ type: "tribe-keys", tribeId: matchedChat.rootId, generation: tribeCrypto.getGen(matchedChat.rootId) || 1, memberKeys }, () => resolve())
  447. })
  448. }
  449. } catch (_) {}
  450. }
  451. return matchedChat.key
  452. },
  453. async joinChat(chatId) {
  454. const ssbClient = await openSsb()
  455. const userId = ssbClient.id
  456. const chat = await this.getChatById(chatId)
  457. if (!chat) throw new Error("Chat not found")
  458. if (chat.status === "CLOSED") throw new Error("Chat is closed")
  459. if (chat.members.includes(userId)) return chat.key
  460. if (tribeCrypto && Array.isArray(chat.invites)) {
  461. const pub = chat.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain))
  462. if (pub) return await this.joinByInvite(pub.code)
  463. }
  464. const members = [...chat.members, userId]
  465. await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true })
  466. return chat.key
  467. },
  468. async leaveChat(chatId) {
  469. const ssbClient = await openSsb()
  470. const userId = ssbClient.id
  471. const chat = await this.getChatById(chatId)
  472. if (!chat) throw new Error("Chat not found")
  473. if (chat.author === userId) throw new Error("Author cannot leave their own chat")
  474. const members = chat.members.filter(m => m !== userId)
  475. await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true })
  476. },
  477. async sendMessage(chatId, text, image = null) {
  478. const ssbClient = await openSsb()
  479. const userId = ssbClient.id
  480. const chat = await this.getChatById(chatId)
  481. if (!chat) throw new Error("Chat not found")
  482. if (chat.status === "CLOSED") throw new Error("Chat is closed")
  483. if (!chat.members.includes(userId)) {
  484. if (chat.status === "OPEN") await this.joinChat(chatId)
  485. else throw new Error("Not a participant")
  486. }
  487. const messages = await readAll(ssbClient)
  488. const oneHourAgo = Date.now() - 60 * 60 * 1000
  489. const recentCount = messages.filter(m => {
  490. const c = m.value?.content
  491. return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo
  492. }).length
  493. if (recentCount >= 3) throw new Error("Rate limit: max 3 messages per hour")
  494. const now = new Date().toISOString()
  495. let content = {
  496. type: "chatMessage",
  497. chatId: chat.rootId,
  498. author: userId,
  499. createdAt: now
  500. }
  501. if (image) content.image = image
  502. if (tribeCrypto) {
  503. let encKey = null
  504. if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId)
  505. if (!encKey) encKey = tribeCrypto.getKey(chat.rootId)
  506. if (encKey) {
  507. content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey)
  508. if (chat.tribeId) content.tribeId = chat.tribeId
  509. } else {
  510. content.text = safeText(text)
  511. }
  512. } else {
  513. content.text = safeText(text)
  514. }
  515. return new Promise((resolve, reject) => {
  516. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  517. })
  518. },
  519. async listMessages(chatRootId) {
  520. const ssbClient = await openSsb()
  521. const messages = await readAll(ssbClient)
  522. const idx = buildIndex(messages)
  523. let tribeId = null
  524. const tipId = idx.tipByRoot.get(chatRootId) || chatRootId
  525. const chatNode = idx.nodes.get(tipId) || idx.nodes.get(chatRootId)
  526. if (chatNode?.c?.tribeId) tribeId = chatNode.c.tribeId
  527. const tribeKeys = tribeId ? await getTribeKeysFor(tribeId) : []
  528. const result = []
  529. for (const [k, node] of idx.msgNodes.entries()) {
  530. if (node.c.chatId !== chatRootId) continue
  531. const msg = buildMessage(node, chatRootId, tribeKeys)
  532. if (msg) result.push(msg)
  533. }
  534. result.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
  535. return result
  536. },
  537. async getParticipants(chatRootId) {
  538. const chat = await this.getChatById(chatRootId)
  539. if (!chat) return []
  540. return chat.members
  541. }
  542. }
  543. }