buzz listener: instant emoji receipt (kind 7) + kind 20002 working indicator on every message

This commit is contained in:
zai-agent 2026-09-17 17:51:48 +00:00
parent db9a520dd3
commit ee331db179

View file

@ -13,8 +13,8 @@ import { execFileSync } from 'node:child_process'
const RELAY = 'wss://ch4t.buzz' const RELAY = 'wss://ch4t.buzz'
const ME = '4abe5fcdf9695be34bdfc8fe82297aaaf0cf65b5ca92b6c789f6f8783b5ee197' const ME = '4abe5fcdf9695be34bdfc8fe82297aaaf0cf65b5ca92b6c789f6f8783b5ee197'
const DM_CHANNEL = '4113fff6-f288-46af-927e-094b5a8386f2'
const INBOX = '/home/opc/.buzz-inbox' const INBOX = '/home/opc/.buzz-inbox'
const REACTED = '/home/opc/.buzz-reacted'
const STATE = '/home/opc/.buzz-last-seen' const STATE = '/home/opc/.buzz-last-seen'
const MAX_LIFETIME = 120 * 1000 // reconnect every 2 min: replays since lastSeen (zombie-socket proof) 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 ws = new WebSocket(RELAY)
const seen = new Set() 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 authEventId = null
let subscribed = false let subscribed = false
@ -74,19 +100,36 @@ ws.onmessage = (msg) => {
if (type === 'NOTICE') { console.error('NOTICE:', rest[0]); return } if (type === 'NOTICE') { console.error('NOTICE:', rest[0]); return }
if (type === 'EVENT') { if (type === 'EVENT') {
const evt = rest[1] 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) seen.add(evt.id)
saveLastSeen(evt.created_at + 1) saveLastSeen(evt.created_at + 1)
fs.appendFileSync(INBOX, JSON.stringify(evt) + '\n') fs.appendFileSync(INBOX, JSON.stringify(evt) + '\n')
console.error('message received from', evt.pubkey.slice(0, 10)) console.error('message received from', evt.pubkey.slice(0, 10))
// Instant DM receipt: the sender should never wonder if we're alive. const inCh = (evt.tags.find((t) => t[0] === 'h') || [])[1]
const chTag = (evt.tags.find((t) => t[0] === 'h') || [])[1] || '' if (inCh) startTyping(inCh)
if (chTag === DM_CHANNEL) { // 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 { try {
execFileSync(BUZZ_CLI, ['messages', 'send', '--channel', chTag, '--reply-to', evt.id, '--content', '✓ received — will respond in a moment'], { timeout: 15000, stdio: 'ignore' }) execFileSync(BUZZ_CLI, ['reactions', 'add', '--event', evt.id, '--emoji', '👀'], {
} catch {} 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') if (type === 'EOSE') console.error('EOSE — live')
} }