From ee331db179c7bf433a4e0d2ab55860f963c3dded Mon Sep 17 00:00:00 2001 From: zai-agent Date: Thu, 17 Sep 2026 17:51:48 +0000 Subject: [PATCH] buzz listener: instant emoji receipt (kind 7) + kind 20002 working indicator on every message --- buzz/ws-listener/listener-raw.mjs | 59 ++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/buzz/ws-listener/listener-raw.mjs b/buzz/ws-listener/listener-raw.mjs index 2d53ec9..db122e0 100644 --- a/buzz/ws-listener/listener-raw.mjs +++ b/buzz/ws-listener/listener-raw.mjs @@ -13,8 +13,8 @@ import { execFileSync } from 'node:child_process' const RELAY = 'wss://ch4t.buzz' const ME = '4abe5fcdf9695be34bdfc8fe82297aaaf0cf65b5ca92b6c789f6f8783b5ee197' -const DM_CHANNEL = '4113fff6-f288-46af-927e-094b5a8386f2' const INBOX = '/home/opc/.buzz-inbox' +const REACTED = '/home/opc/.buzz-reacted' const STATE = '/home/opc/.buzz-last-seen' const MAX_LIFETIME = 120 * 1000 // reconnect every 2 min: replays since lastSeen (zombie-socket proof) @@ -32,6 +32,32 @@ function saveLastSeen(ts) { const ws = new WebSocket(RELAY) const seen = new Set() +const typingIntervals = new Map() // channel -> interval publishing kind 20002 +const TYPING_PUBLISH_MS = 3000 +const TYPING_MAX_MS = 120 * 1000 // hard stop: never type longer than this + +function startTyping(ch) { + stopTyping(ch) // reset both the interval and the max-duration timer + const publish = () => { + try { + const ev = finalizeEvent( + { kind: 20002, created_at: Math.floor(Date.now() / 1000), tags: [['h', ch]], content: '' }, + SK + ) + ws.send(JSON.stringify(['EVENT', ev])) + } catch {} + } + publish() + typingIntervals.set(ch, { + iv: setInterval(publish, TYPING_PUBLISH_MS), + max: setTimeout(() => stopTyping(ch), TYPING_MAX_MS), + }) + console.error('typing indicator on:', ch.slice(0, 8)) +} +function stopTyping(ch) { + const t = typingIntervals.get(ch) + if (t) { clearInterval(t.iv); clearTimeout(t.max); typingIntervals.delete(ch) } +} let authEventId = null let subscribed = false @@ -74,19 +100,36 @@ ws.onmessage = (msg) => { if (type === 'NOTICE') { console.error('NOTICE:', rest[0]); return } if (type === 'EVENT') { const evt = rest[1] - if (!evt || evt.pubkey === ME || seen.has(evt.id)) return + if (!evt) return + if (evt.pubkey === ME) { + const chTag = (evt.tags.find((t) => t[0] === 'h') || [])[1] + if (chTag) stopTyping(chTag) + return + } + if (seen.has(evt.id)) return seen.add(evt.id) saveLastSeen(evt.created_at + 1) fs.appendFileSync(INBOX, JSON.stringify(evt) + '\n') console.error('message received from', evt.pubkey.slice(0, 10)) - // Instant DM receipt: the sender should never wonder if we're alive. - const chTag = (evt.tags.find((t) => t[0] === 'h') || [])[1] || '' - if (chTag === DM_CHANNEL) { + const inCh = (evt.tags.find((t) => t[0] === 'h') || [])[1] + if (inCh) startTyping(inCh) + // Instant receipt: emoji reaction (NIP-25 kind 7) on the sender's message, + // so the sender always sees that zai is alive — before any LLM work starts. + let reacted = false + try { reacted = fs.readFileSync(REACTED, 'utf8').split('\n').includes(evt.id) } catch {} + if (!reacted) { try { - execFileSync(BUZZ_CLI, ['messages', 'send', '--channel', chTag, '--reply-to', evt.id, '--content', '✓ received — will respond in a moment'], { timeout: 15000, stdio: 'ignore' }) - } catch {} + execFileSync(BUZZ_CLI, ['reactions', 'add', '--event', evt.id, '--emoji', '👀'], { + env: { ...process.env, BUZZ_RELAY_URL: 'https://ch4t.buzz', BUZZ_PRIVATE_KEY: keyText.match(/SECRET:\s*(\S+)/)[1] }, + stdio: 'ignore', + timeout: 15000, + }) + fs.appendFileSync(REACTED, evt.id + '\n') + console.error('👀 receipt sent') + } catch (e) { + console.error('reaction failed:', String(e).slice(0, 100)) + } } - process.exit(0) } if (type === 'EOSE') console.error('EOSE — live') }