feat: add Nostr chat via CLI#2
Conversation
PR Review:
|
1. chat listen --contact: decode npub to hex before filtering 2. chat open: mark messages as read (persist readMessageIds) 3. All catch blocks: add console.error with descriptive prefix 4. hexToBytes: deduplicate via import from nostr-tools/utils 5. relay sub onclose: per-relay tracking with _activeSubRelays set 6. Remove unused RESUBSCRIBE_BASE_MS constant
PR Review:
|
| Issue | Status | Evidence |
|---|---|---|
chat listen --contact npub never matched |
Fixed | chat.ts lines 572–578 now properly nip19Decode the npub to hex |
| Unread count never persisted | Fixed | chat.ts lines 192–196 now save readMessageIds on chat open |
| Silent error swallowing | Partially fixed | Most catch blocks now log to stderr with console.error |
hexToBytes duplicated |
Fixed | All three files import from nostr-tools/utils |
Unused RESUBSCRIBE_BASE_MS |
Fixed | Constant no longer present in the file |
✅ Positive Notes
- NIP-17 gift wraps: Correctly implemented. I verified
nostr-tools'swrapManyEventsalways prepends a self-wrap at index 0, and thetagSelfGiftWrapslogic correctly tags the self-wrap plus any duplicate sender entries in the recipients list. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - Build & tests:
tscpasses cleanly, all 20 existing tests pass.
Verdict
Approve with requested changes — the two critical bugs from the prior review are already resolved in the current code. The two new functional bugs (chat open prefix matching and silent edits) are straightforward fixes and should be addressed before merge. The moderate issues (JSON consistency, dead code, --tail parsing) are also quick wins. The remaining structural issues (singleton state, dual publish functions) can be addressed in follow-up PRs if preferred.
- Prefix matching in getRoom() (r.id.startsWith) - Message edits now bump room.updatedAt and notify subscribers - JSON outputs include unreadCount and editOf - Remove unused shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps - --tail 0 works correctly (Number.isNaN check) - Cache short display names (use ... heuristic instead of length check) - Validate BCH cashaddr format before publishing - --tail only marks viewed messages as read - Add cashaddr format validation to set-bch-address
Co-authored-by: joemarct <joemarct@users.noreply.github.com>
PR Review:
|
| Issue | Fix |
|---|---|
Critical: chat open with a prefix returned empty messages |
getMessages now uses the resolved room.id instead of the raw user-provided prefix |
Moderate: chat send allowed prefix lookup in getRoom but then failed in sendMessage with exact-match lookup |
sendMessage now receives room.id, and the argument help is updated to (full or prefix) |
Moderate: chat listen JSON output omitted replyTo and editOf |
Added both fields to JSON lines |
Minor: chat listen non-JSON output did not show (edited) for edits |
Added the (edited) annotation |
🔴 Critical Issues (from prior reviews) — Verified Fixed
chat listen --contact <npub>now correctly decodesnpub1...to hex before filtering.- Unread count persistence now saves
readMessageIdsafterchat open. chat openprefix matching works at the room level (store.getRoomsupports prefixes).- Message edits now bump
room.updatedAtand trigger theonNewMessagecallback. --tail 0parsing is nowNumber.isNaN-safe.resolveDisplayNameshort-name caching now uses!includes('...')instead of a length gate.hexToBytesduplication removed; all files import fromnostr-tools/utils.- Silent error swallowing largely resolved;
catchblocks now log tostderr.
🟡 Moderate Issues (remaining)
-
publishandpublishEventstill coexist
src/nostr/relay.tsexports two publish functions with slightly different return shapes. Unifying them into a single API would reduce surface area and test complexity. -
Global singleton state in
relay.ts
All subscriptions, pools, and timers are module-level. This is acceptable for a short-lived CLI process, but it makes the module hard to unit test and can cause unnecessary churn if multiple relays are in use. -
Edit race condition
receiveMessagesilently drops edits when the target message hasn't been fetched yet. If the original message arrives after the edit, the edit is lost. Buffering unapplied edits would be more robust.
🟢 Minor / Suggestions
add-contactnpub validation is robust becausenip19Decodethrows on invalid data, but the user gets a generic error. Proactivetry/catcharoundnip19Decodein the command handler would produce a clearer message.- Heavy
process.exitusage inchat.tscommand handlers makes unit testing impossible. Consider throwing typed errors and letting the top-levelprogram.parse()catch block handle exit codes. chat openmarks only the viewed tail as read — this is actually the correct behavior, but worth noting that the unread badge reflects the exact subset displayed.
✅ Positive Notes
- NIP-17 gift wraps: Correctly implemented with
wrapManyEventsandtagSelfGiftWraps. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
- Build & tests:
tscpasses with zero errors, all 20 existing tests pass.
Verdict
Approve with minor follow-ups — the critical functional issues from this and prior reviews are now resolved. The two publish-function duplication and the singleton state in relay.ts are worth cleaning up in a follow-up PR, but they don't block merge.
|
User opencode-agent[bot] does not have write permissions |
- RelayService class with proper instance state (testable, no globals) - Single publish() API that accepts NostrEvent | NostrEvent[] - Buffered pending edits to fix race condition (edit arriving before target)
Co-authored-by: joemarct <joemarct@users.noreply.github.com>
PR Review:
|
| Issue | Status | Evidence |
|---|---|---|
chat listen --contact <npub> never matched |
Fixed | chat.ts lines 580–586 now properly decodes the npub to hex before filtering |
| Unread count never persisted | Fixed | chat.ts lines 195–199 now save readMessageIds after chat open |
chat open prefix matching |
Fixed | store.ts getRoom() supports prefix matching via r.id.startsWith(roomId) |
| Message edits silent / not bumping room | Fixed | store.ts lines 291–307 now updates room.updatedAt and triggers onNewMessage for edits; unapplied edits are buffered in pendingEdits |
--tail 0 parsing |
Fixed | chat.ts line 135 uses Number.isNaN |
resolveDisplayName short-name caching |
Fixed | store.ts line 406 uses !includes('...') instead of a length gate |
hexToBytes duplication |
Fixed | All files import from nostr-tools/utils |
| Silent error swallowing | Fixed | catch blocks now log to stderr with console.error |
Global singleton state in relay.ts |
Fixed | relay.ts is now a proper RelayService class with instance-level state |
publish + publishEvent duplication |
Fixed | Single publish() API that accepts NostrEvent | NostrEvent[] |
Dead code (shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps) |
Fixed | Removed in prior commits |
JSON outputs missing unread / editOf / replyTo |
Fixed | Added to chat list and chat open JSON outputs |
🟡 Minor Issues (remaining, non-blocking)
-
Unhandled rejections in most command handlers
chat send,chat open,chat list,chat listen, etc. do not wrap their async bodies intry/catch. Ifstore.initializeor network calls throw unexpectedly, the process exits with an ugly stack trace andstore.cleanup()is skipped. This is acceptable for a v1 CLI but makes the module harder to test. -
SIGTERMnot handled inchat listen
The long-runninglistencommand installs aSIGINThandler but notSIGTERM. In Docker/systemd environments, the process may not shut down cleanly. -
Unused
lastSubscribeTimeinRelayService
The property is assigned insubscribeGiftWrapsbut never read.
✅ Positive Notes
- NIP-17 gift wraps: Correctly implemented with
wrapManyEventsandtagSelfGiftWraps. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
- Build & tests:
tscpasses cleanly, all 20 existing tests pass.
Verdict
Approve — the critical functional issues from prior reviews are resolved, the structural refactor is clean, and the remaining items are minor code-quality improvements that can be addressed in follow-up PRs.
Only triggers on /oc or /opencode comments to avoid bot permission errors.
Co-authored-by: joemarct <joemarct@users.noreply.github.com>
PR Review:
|
| Issue | Fix |
|---|---|
--tail 0 shows all messages |
Array.prototype.slice(-0) is equivalent to slice(0) and returns the entire array. Added a guard so tail === 0 returns an empty array. |
| Sent messages loaded out of order | loadPersistedData appended persisted sent messages at the end of the array without sorting. Fixed by inserting them in chronological order (matching receiveMessage). |
SIGTERM not handled |
Added a SIGTERM handler to chat listen so the process shuts down cleanly in Docker/systemd environments. |
Unused lastSubscribeTime |
Removed the dead property and its assignments from RelayService. |
| State file permissions | chat-state.json now writes with mode 0o600 so it isn't world-readable. |
✅ Verification of Prior Review Fixes
| Issue | Status | Evidence |
|---|---|---|
chat listen --contact npub never matched |
Fixed | chat.ts decodes npub1... to hex before filtering |
| Unread count never persisted | Fixed | chat.ts saves readMessageIds after chat open |
chat open prefix matching |
Fixed | store.ts getRoom() supports prefix matching |
| Message edits silent / not bumping room | Fixed | store.ts updates room.updatedAt and triggers onNewMessage for edits; buffers unapplied edits |
--tail 0 parsing |
Fixed | chat.ts uses Number.isNaN |
resolveDisplayName short-name caching |
Fixed | store.ts uses !includes('...') |
hexToBytes duplication |
Fixed | All files import from nostr-tools/utils |
| Silent error swallowing | Fixed | catch blocks now log to stderr |
| Global singleton state | Fixed | relay.ts is now a proper RelayService class |
publish + publishEvent duplication |
Fixed | Single publish() API |
🟡 Minor Issues (remaining, non-blocking)
-
unwrapGiftWrapswallows seal verification errors
Thetry/catcharound the manual seal check only re-throws the specific "Seal pubkey does not match rumor pubkey" error. Othernip44.decryptfailures are silently ignored. -
pendingEditsgrows unbounded in long-runninglisten
If an edit arrives but the original message never does, the buffered edit stays inpendingEditsforever. For a short-lived CLI process this is negligible. -
No unit tests for the new chat feature
The 20 existing tests still pass, but there are no new tests forChatStore,RelayService, or the command handlers. Consider adding unit tests for the store/room logic (relay interaction can be mocked).
✅ Positive Notes
- NIP-17 gift wraps: Correctly implemented with
wrapManyEventsandtagSelfGiftWraps. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
- Build & tests:
tscpasses cleanly, all 20 existing tests pass.
Verdict
Approve — the critical functional issues from prior reviews are resolved, the new fixes I applied during review are minor and clean, and the remaining items are code-quality improvements that can be addressed in follow-up PRs.
|
User opencode-agent[bot] does not have write permissions |
PR Review:
|
| Issue | Status | Evidence |
|---|---|---|
chat listen --contact <npub> never matched |
Fixed | chat.ts lines 579–585 decodes npub1... to hex before filtering |
| Unread count never persisted | Fixed | chat.ts lines 195–199 save readMessageIds after chat open |
chat open prefix matching |
Fixed | store.ts getRoom() supports prefix matching via startsWith |
| Message edits silent / not bumping room | Fixed | store.ts lines 294–310 updates room.updatedAt and triggers onNewMessage for edits; buffers unapplied edits |
--tail 0 parsing |
Fixed | chat.ts line 135 uses Number.isNaN with a guard for tail === 0 |
resolveDisplayName short-name caching |
Fixed | store.ts line 409 uses !includes('...') |
hexToBytes duplication |
Fixed | All files import from nostr-tools/utils |
| Silent error swallowing | Fixed | catch blocks now log to stderr with console.error |
| Global singleton state | Fixed | relay.ts is now a proper RelayService class |
publish + publishEvent duplication |
Fixed | Single publish() API accepting NostrEvent | NostrEvent[] |
| Dead code | Fixed | shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed |
JSON outputs missing unread / editOf / replyTo |
Fixed | Added to chat list and chat open JSON outputs |
fetchKind10050 missing timeout |
Fixed | maxWait: 10000 added |
SIGTERM not handled |
Fixed | Added to chat listen |
| Sent messages loaded out of order | Fixed | loadPersistedData inserts in chronological order |
| State file permissions | Fixed | writeFileSync uses mode: 0o600 |
🟡 Minor Issues (remaining, non-blocking)
-
chat open --jsondoesn't mark messages as read
The non-JSON path savesreadMessageIdsafter displaying messages (lines 195–199), but the--jsonpath exits before doing so. This means JSON consumers won't get the unread badge cleared. Consider moving the read-marking logic before the JSON exit, or explicitly documenting that--jsonis read-only. -
unwrapGiftWrapstill swallows non-specific seal verification errors
src/nostr/chat.tslines 108–110 only re-throws the exact string "Seal pubkey does not match rumor pubkey". Othernip44.decryptfailures (e.g., corrupted seal, wrong key) are silently ignored and the rumor is returned anyway. Sincenip59.unwrapEventalready handles the primary unwrap, this is a secondary sanity check, but silently dropping verification failures is a minor security gap. -
publishcan report duplicate errors for multi-wrap publishes
RelayService.publishiterates overeventsand accumulateserrorswithout deduplication. When publishinggiftWraps(one per recipient), the same relay can appear inerrorsmultiple times. Theacceptedlist usesseenRelaysto deduplicate, buterrorsdoes not, making the success denominator (accepted.length + errors.length) potentially misleading. -
chat listenprints "Connected" before actual connection
TheConnected.message is logged immediately afterstore.subscribe(), which is synchronous. The WebSocket connection is still establishing in the background. If the relay is unreachable, the user sees "Connected" followed by silence. A small UX papercut. -
editOfsemantic inversion on edited messages
InreceiveMessage, when an edit is applied to an existing message, the original message'seditOffield is set to the edit's rumor ID (target.editOf = rumor.id). Semantically,editOfshould mean "this message is an edit of X", not "this message was edited by X". Since the edit itself is not stored as a separate message, this pragmatic inversion is accepted, but the data model is slightly confusing. -
Group room display names are arbitrary
getOtherMemberreturns the alphabetically first non-self member for any room. For group chats, this is a poor room name (e.g., showing "Alice" for a group with Alice, Bob, and Charlie). Theroom.nameis actually set to the first sender's display name inreceiveMessage, which is also not ideal for groups. This is acceptable for a v1, but group chat UX could be improved later.
🟢 Positive Notes
- NIP-17 gift wraps: Correctly implemented with
wrapManyEventsandtagSelfGiftWraps. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
- BCH address validation: Basic cashaddr format check is present in
set-bch-address. - Build & tests:
tscpasses cleanly, all 20 existing tests pass.
Verdict
Approve — all critical and moderate functional issues from previous review rounds are resolved. The remaining items are minor code-quality and UX improvements that can be addressed in follow-up PRs.
- Max 1000 messages per room (trim oldest) - Max 1000 readMessageIds per room - Max 100 pending edit entries, 10 per target
Co-authored-by: joemarct <joemarct@users.noreply.github.com>
PR Review:
|
| Issue | Fix |
|---|---|
chat listen --contact <name> filter mismatch |
When a contact name was not found, the code logged "Watching all conversations" but filterPubKey remained set to the user's own pubkey, so it still filtered by self. Now filterPubKey is correctly set to null. |
chat open --json doesn't mark messages as read |
The non-JSON path saved readMessageIds after displaying messages, but the --json path exited before doing so. Moved the read-marking logic before the JSON exit so JSON consumers also clear the unread badge. |
relay.ts onclose crash risk |
Added !reasons guard before reasons.includes('closed by caller') to prevent a potential runtime crash if the callback receives an undefined value. |
unwrapGiftWrap swallowed seal verification errors |
Removed the overly broad try/catch that only re-threw one specific error string. All seal verification failures (corrupted seal, wrong key, pubkey mismatch) now propagate correctly. |
✅ Verification of Prior Review Fixes
| Issue | Status | Evidence |
|---|---|---|
chat listen --contact <npub> never matched |
Fixed | chat.ts decodes npub1... to hex before filtering |
| Unread count never persisted | Fixed | chat.ts saves readMessageIds after chat open |
chat open prefix matching |
Fixed | store.ts getRoom() supports prefix matching |
| Message edits silent / not bumping room | Fixed | store.ts updates room.updatedAt and triggers onNewMessage for edits; buffers unapplied edits |
--tail 0 parsing |
Fixed | chat.ts uses Number.isNaN with a guard for tail === 0 |
resolveDisplayName short-name caching |
Fixed | store.ts uses !includes('...') |
hexToBytes duplication |
Fixed | All files import from nostr-tools/utils |
| Silent error swallowing | Fixed | catch blocks now log to stderr with console.error |
| Global singleton state | Fixed | relay.ts is now a proper RelayService class |
publish + publishEvent duplication |
Fixed | Single publish() API accepting NostrEvent | NostrEvent[] |
| Dead code | Fixed | shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed |
JSON outputs missing unread / editOf / replyTo |
Fixed | Added to chat list and chat open JSON outputs |
fetchKind10050 missing timeout |
Fixed | maxWait: 10000 added |
SIGTERM not handled |
Fixed | Added to chat listen |
| Sent messages loaded out of order | Fixed | loadPersistedData inserts in chronological order |
| State file permissions | Fixed | writeFileSync uses mode: 0o600 |
🟡 Minor Issues (remaining, non-blocking)
-
publishcan report duplicate errors for multi-wrap publishes
RelayService.publishiterates overeventsand accumulateserrorswithout deduplication. When publishinggiftWraps(one per recipient), the same relay can appear inerrorsmultiple times. Theacceptedlist usesseenRelaysto deduplicate, buterrorsdoes not, making the success denominator (accepted.length + errors.length) potentially misleading. -
chat listenprints "Connected" before actual connection
TheConnected.message is logged immediately afterstore.subscribe(), which is synchronous. The WebSocket connection is still establishing in the background. If the relay is unreachable, the user sees "Connected" followed by silence. A small UX papercut. -
editOfsemantic inversion on edited messages
InreceiveMessage, when an edit is applied to an existing message, the original message'seditOffield is set to the edit's rumor ID (target.editOf = rumor.id). Semantically,editOfshould mean "this message is an edit of X", not "this message was edited by X". Since the edit itself is not stored as a separate message, this pragmatic inversion is accepted, but the data model is slightly confusing. -
Group room display names are arbitrary
getOtherMemberreturns the alphabetically first non-self member for any room. For group chats, this is a poor room name (e.g., showing "Alice" for a group with Alice, Bob, and Charlie). Theroom.nameis actually set to the first sender's display name inreceiveMessage, which is also not ideal for groups. This is acceptable for a v1, but group chat UX could be improved later. -
No unit tests for the new chat feature
The 20 existing tests still pass, but there are no new tests forChatStore,RelayService, or the command handlers. Consider adding unit tests for the store/room logic (relay interaction can be mocked).
🟢 Positive Notes
- NIP-17 gift wraps: Correctly implemented with
wrapManyEventsandtagSelfGiftWraps. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
- BCH address validation: Basic cashaddr format check is present in
set-bch-address. - Build & tests:
tscpasses cleanly, all 20 existing tests pass.
Verdict
Approve — all critical and moderate functional issues from previous reviews are resolved, the new fixes I applied during review are clean and minimal, and the remaining items are minor code-quality and UX improvements that can be addressed in follow-up PRs.
|
User opencode-agent[bot] does not have write permissions |
|
Author identity unknown *** Please tell me who you are. Run git config --global user.email "you@example.com" to set your account's default identity. fatal: empty ident name (for runner@runnervm7b5n9.xzi531oxwoauxd4ge3usvb0tpb.bx.internal.cloudapp.net) not allowed |
|
fatal: could not read Username for 'https://github.com': No such device or address |
PR Review:
|
| Issue | Status | Evidence |
|---|---|---|
chat listen --contact <npub> never matched |
Fixed | chat.ts lines 581–586 decodes npub1... to hex |
| Unread count never persisted | Fixed | chat.ts lines 145–149 save readMessageIds after chat open |
chat open prefix matching |
Fixed | store.ts getRoom() supports prefix matching |
| Message edits silent / not bumping room | Fixed | store.ts lines 294–310 updates room.updatedAt and triggers onNewMessage for edits |
--tail 0 parsing |
Fixed | chat.ts line 135 uses Number.isNaN with a guard for tail === 0 |
resolveDisplayName short-name caching |
Fixed | store.ts line 428 uses !includes('...') |
hexToBytes duplication |
Fixed | All files import from nostr-tools/utils |
| Silent error swallowing (outer catches) | Fixed | catch blocks now log to stderr with console.error |
| Global singleton state | Fixed | relay.ts is now a proper RelayService class with instance-level state |
publish + publishEvent duplication |
Fixed | Single publish() API accepting NostrEvent | NostrEvent[] |
| Dead code | Fixed | shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed |
JSON outputs missing unread / editOf / replyTo |
Fixed | Added to chat list and chat open JSON outputs |
fetchKind10050 missing timeout |
Fixed | maxWait: 10000 added |
SIGTERM not handled |
Fixed | Added to chat listen |
| Sent messages loaded out of order | Fixed | loadPersistedData inserts in chronological order |
| State file permissions | Fixed | writeFileSync uses mode: 0o600 |
✅ Positive Notes
- NIP-17 gift wraps: Correctly implemented with
wrapManyEventsandtagSelfGiftWraps. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - BCH address validation: Basic cashaddr format check is present in
set-bch-address. - Build & tests:
tscpasses cleanly, all 20 existing tests pass.
Verdict
Approve with requested changes — the codebase is solid and the feature is well-architected. Only one critical issue remains: the empty catch blocks in store.ts that silently swallow unwrapGiftWrap errors. The RelayService.publish deduplication issue and the chat listen UX papercut are also worth fixing. The remaining items are minor code-quality improvements that can be addressed in follow-up PRs.
1. Log unwrapGiftWrap failures, skip known self-wraps 2. Deduplicate publish errors per relay 3. Remove misleading 'Connected' message 4. Accept editOf semantic inversion 5. Accept no chat start command 6. Accept no unit tests 7. Accept group room name heuristic 8. Extract trimSeenEventIds private method 9. Remove redundant startsWith npub check (addContact validates)
PR Review:
|
| Issue | Status | Evidence |
|---|---|---|
chat listen --contact <npub> never matched |
Fixed | chat.ts lines 576–582 decode npub1... to hex |
| Unread count never persisted | Fixed | chat.ts lines 145–149 save readMessageIds after chat open |
chat open prefix matching |
Fixed | store.ts getRoom() supports prefix matching via startsWith |
| Message edits silent / not bumping room | Fixed | store.ts lines 294–310 updates room.updatedAt and triggers onNewMessage for edits |
--tail 0 parsing |
Fixed | chat.ts line 135 uses Number.isNaN with a guard for tail === 0 |
resolveDisplayName short-name caching |
Fixed | store.ts line 432 uses !includes('...') |
hexToBytes duplication |
Fixed | All files import from nostr-tools/utils |
| Silent error swallowing | Fixed | catch blocks now log to stderr with console.error |
| Global singleton state | Fixed | relay.ts is now a proper RelayService class with instance-level state |
publish + publishEvent duplication |
Fixed | Single publish() API accepting NostrEvent | NostrEvent[] |
| Dead code | Fixed | shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed |
JSON outputs missing unread / editOf / replyTo |
Fixed | Added to chat list and chat open JSON outputs |
fetchKind10050 missing timeout |
Fixed | maxWait: 10000 added |
SIGTERM not handled |
Fixed | Added to chat listen |
| Sent messages loaded out of order | Fixed | loadPersistedData inserts in chronological order |
| State file permissions | Fixed | writeFileSync uses mode: 0o600 |
chat listen --contact <name> filter mismatch |
Fixed | filterPubKey correctly set to null when contact not found |
chat open --json doesn't mark messages as read |
Fixed | Read-marking logic moved before JSON exit |
onclose crash risk |
Fixed | !reasons guard added |
unwrapGiftWrap swallowed seal errors |
Fixed | Broad try/catch removed |
✅ Positive Notes
- NIP-17 gift wraps: Correctly implemented with
wrapManyEventsandtagSelfGiftWraps. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - BCH address validation: Basic cashaddr format check is present in
set-bch-address. - Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
- Build & tests:
tscpasses cleanly, all 20 existing tests pass.
Verdict
Approve with requested changes — the codebase is solid and the feature is well-architected. Only one critical issue remains: the chat listen crash when --contact is set to an unknown name. The fix is a one-line change (or a small ternary rewrite). Once that is addressed, the PR is ready to merge.
PR Review:
|
| Issue | Status | Evidence |
|---|---|---|
chat listen --contact <npub> never matched |
Fixed | chat.ts lines 576–582 decode npub1... to hex |
| Unread count never persisted | Fixed | chat.ts lines 145–149 save readMessageIds after chat open |
chat open prefix matching |
Fixed | store.ts getRoom() supports prefix matching |
| Message edits silent / not bumping room | Fixed | store.ts lines 294–310 updates room.updatedAt and triggers onNewMessage for edits |
--tail 0 parsing |
Fixed | chat.ts line 135 uses Number.isNaN with a guard for tail === 0 |
resolveDisplayName short-name caching |
Fixed | store.ts line 432 uses !includes('...') |
hexToBytes duplication |
Fixed | All files import from nostr-tools/utils |
| Silent error swallowing | Fixed | catch blocks now log to stderr with console.error |
| Global singleton state | Fixed | relay.ts is now a proper RelayService class |
publish + publishEvent duplication |
Fixed | Single publish() API |
| Dead code | Fixed | shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed |
JSON outputs missing unread / editOf / replyTo |
Fixed | Added to chat list and chat open JSON outputs |
fetchKind10050 missing timeout |
Fixed | maxWait: 10000 added |
SIGTERM not handled |
Fixed | Added to chat listen |
| Sent messages loaded out of order | Fixed | loadPersistedData inserts in chronological order |
| State file permissions | Fixed | writeFileSync uses mode: 0o600 |
chat listen --contact <name> filter mismatch |
Fixed | filterPubKey correctly set to null when contact not found |
chat open --json doesn't mark messages as read |
Fixed | Read-marking logic moved before JSON exit |
onclose crash risk |
Fixed | !reasons guard added |
unwrapGiftWrap swallowed seal errors |
Fixed | Broad try/catch removed |
✅ Positive Notes
- NIP-17 gift wraps: Correctly implemented with
wrapManyEventsandtagSelfGiftWraps. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
- BCH address validation: Basic cashaddr format check is present in
set-bch-address. - Build & tests:
tscpasses cleanly, all 20 existing tests pass.
Verdict
Approve with requested changes — the codebase is solid and the feature is well-architected. The remaining functional issues are moderate and straightforward to fix:
- Invalid npub filter fallback (small
catchblock fix). - Publish duplicate errors (per-relay tracking).
- Stale display name cache (check contact name before cache return).
- Missing
subjectextraction (one-line tag read).
All four are quick wins and would make the CLI more robust. The remaining structural items (group chat UX, unit tests, chat start command) can be addressed in follow-up PRs.
…ct, cashaddr, chat start)
PR Review:
|
| Issue | Status | Evidence |
|---|---|---|
chat listen --contact <npub> never matched |
Fixed | chat.ts decodes npub1... to hex before filtering |
chat listen --contact <invalid-npub> fallback |
Fixed | Invalid npub now exits with error instead of filtering to self |
chat listen --contact <name> crash |
Fixed | filterPubKey correctly set to null when contact not found |
| Unread count never persisted | Fixed | chat.ts saves readMessageIds after chat open |
chat open prefix matching |
Fixed | store.ts getRoom() supports prefix matching |
chat open --json doesn't mark messages as read |
Fixed | Read-marking logic moved before JSON exit |
| Message edits silent / not bumping room | Fixed | store.ts updates room.updatedAt and triggers onNewMessage for edits |
--tail 0 parsing |
Fixed | chat.ts uses Number.isNaN with a guard for tail === 0 |
resolveDisplayName short-name caching |
Fixed | store.ts updates cache when contact name changes |
hexToBytes duplication |
Fixed | All files import from nostr-tools/utils |
| Silent error swallowing | Fixed | catch blocks now log to stderr with console.error |
| Global singleton state | Fixed | relay.ts is now a proper RelayService class |
publish + publishEvent duplication |
Fixed | Single publish() API with relayStatus Map for deduplication |
| Dead code | Fixed | shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed |
JSON outputs missing unread / editOf / replyTo |
Fixed | Added to chat list, chat open, and chat listen JSON outputs |
fetchKind10050 missing timeout |
Fixed | maxWait: 10000 added |
SIGTERM not handled |
Fixed | Added to chat listen |
| Sent messages loaded out of order | Fixed | loadPersistedData inserts in chronological order |
| State file permissions | Fixed | writeFileSync uses mode: 0o600 |
onclose crash risk |
Fixed | !reasons guard added |
unwrapGiftWrap swallowed seal errors |
Fixed | Broad try/catch removed; failures propagate |
room.subject missing |
Fixed | receiveMessage extracts subject tag |
set-bch-address uppercase rejection |
Fixed | Input is lowercased before regex validation |
| No way to start new conversation | Fixed | New chat start <npub> command added |
🟡 Minor Issues (remaining, non-blocking)
-
chat listenprints "Listening" before the WebSocket handshake completes
The log is emitted immediately after the synchronousstore.subscribe()call. The actual connection is still opening asynchronously. If the relay is unreachable, the user sees the listening message followed by silence. Consider deferring the log until the firstoneventfires, or removing it. -
editOfsemantic inversion is slightly confusing
InreceiveMessage, when an edit is applied to an existing message, the original message'seditOffield is set to the edit rumor's ID (target.editOf = rumor.id). SemanticallyeditOfshould mean "this message is an edit of X", not "this message was edited by X". The pragmatic inversion is acceptable for a v1, but consider documenting it for JSON consumers. -
Group room display names are arbitrary
getOtherMemberreturns the first non-self member (alphabetically first hex pubkey). For group chats,room.nameis set to the first sender's display name. Both are poor heuristics for group chat UX. Acceptable for a v1, but worth improving later. -
No unit tests for the new chat feature
The 20 existing tests still pass, butChatStore,RelayService, and the chat command handlers have zero test coverage. The store/room logic is testable with mocked relay interaction.
✅ Positive Notes
- NIP-17 gift wraps: Correctly implemented with
wrapManyEventsandtagSelfGiftWraps. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
- BCH address validation: Basic cashaddr format check is present in
set-bch-address. - Build & tests:
tscpasses cleanly, all 20 existing tests pass.
Verdict
Approve — all critical and moderate functional issues from previous reviews are resolved. The codebase is solid, the feature is well-architected, and the remaining items are minor UX improvements and test coverage that can be addressed in follow-up PRs.
Co-authored-by: joemarct <joemarct@users.noreply.github.com>
PR Review:
|
| Issue | Fix |
|---|---|
Build failure: store.test.ts TypeScript errors (4 errors) |
importOriginal cast to any; added import type { ChatStore as ChatStoreType } to avoid value/type conflict; added missing nsec to mockKeys |
Test failure: loadPersistedData tests failed because os.homedir() was not mocked |
Added vi.mock('os', ...) to return a deterministic test home directory so STATE_FILE resolves correctly |
| Test count mismatch | With the fixes, all 70 tests pass (up from 68 in the last review, because the previously broken store.test.ts tests now run) |
✅ Verification of Prior Review Fixes
| Issue | Status | Evidence |
|---|---|---|
chat listen --contact <npub> never matched |
Fixed | chat.ts lines 603–609 decode npub1... to hex |
chat listen --contact <invalid-npub> fallback |
Fixed | Invalid npub exits with error (line 608) |
chat listen --contact <name> crash |
Fixed | filterPubKey set to null when contact not found (line 613) |
| Unread count never persisted | Fixed | chat.ts lines 145–149 save readMessageIds after chat open |
chat open prefix matching |
Fixed | store.ts getRoom() supports prefix matching (line 389) |
chat open --json doesn't mark messages as read |
Fixed | Read-marking logic moved before JSON exit (lines 144–149) |
| Message edits silent / not bumping room | Fixed | store.ts updates room.updatedAt and triggers onNewMessage (lines 301–310) |
--tail 0 parsing |
Fixed | chat.ts line 135 uses Number.isNaN with a guard for tail === 0 |
resolveDisplayName short-name caching |
Fixed | store.ts line 455 uses !includes('...') |
hexToBytes duplication |
Fixed | All files import from nostr-tools/utils |
| Silent error swallowing | Fixed | catch blocks now log to stderr with console.error |
| Global singleton state | Fixed | relay.ts is now a proper RelayService class |
publish + publishEvent duplication |
Fixed | Single publish() API with per-relay status tracking |
| Dead code | Fixed | Removed unused functions |
JSON outputs missing unread / editOf / replyTo |
Fixed | Added to all JSON outputs |
fetchKind10050 missing timeout |
Fixed | maxWait: 10000 added |
SIGTERM not handled |
Fixed | Added to chat listen (line 673) |
| Sent messages loaded out of order | Fixed | loadPersistedData inserts in chronological order (lines 111–113) |
| State file permissions | Fixed | writeFileSync uses mode: 0o600 (line 135) |
onclose crash risk |
Fixed | !reasons guard added (line 153) |
unwrapGiftWrap swallowed seal errors |
Fixed | Failures propagate correctly |
room.subject missing |
Fixed | receiveMessage extracts subject tag (line 285) |
set-bch-address uppercase rejection |
Fixed | Input is lowercased before regex validation (line 491) |
| No way to start new conversation | Fixed | New chat start <npub> command added (lines 247–271) |
🟡 Minor Issues (remaining, non-blocking)
-
chat listenprints "Listening" before the WebSocket handshake completes
The log is emitted immediately after the synchronousstore.subscribe()call. The connection is still opening asynchronously. If the relay is unreachable, the user sees the listening message followed by silence. This is a minor UX papercut. -
editOfsemantic inversion is slightly confusing
InreceiveMessage, when an edit is applied to an existing message, the original message'seditOffield is set to the edit rumor's ID (target.editOf = rumor.id). SemanticallyeditOfshould mean "this message is an edit of X", not "this message was edited by X". The pragmatic inversion is acceptable for a v1, but worth documenting for JSON consumers. -
Group room display names are arbitrary
getOtherMemberreturns the first non-self member (alphabetically first hex pubkey). For group chats,room.nameis set to the first sender's display name. Both are poor heuristics for group chat UX. Acceptable for a v1. -
Heavy
process.exitusage inchat.tsmakes unit testing impossible
All command handlers callprocess.exitdirectly. Consider throwing typed errors and letting the top-levelprogram.parse()catch block handle exit codes in a follow-up.
✅ Positive Notes
- NIP-17 gift wraps: Correctly implemented with
wrapManyEventsandtagSelfGiftWraps. - NIP-06 derivation: Correct path
m/44'/1237'/0'/0/0. - NIP-42 AUTH: Properly wired via
SimplePoolautomaticallyAuth. - NIP-78 profile events: Correct parameterized replaceable format with
dtags. - Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
- BCH address validation: Basic cashaddr format check is present in
set-bch-address. - Test coverage: New
chat.test.tsandstore.test.tsadd 50 tests covering room IDs, kind 14 creation, gift wrap tagging, kind 10050, store persistence, message receiving, edits, contacts, and subscription lifecycle. - Build & tests:
tscpasses with zero errors, all 70 tests pass.
Verdict
Approve — the critical functional issues from previous reviews are resolved, and the TypeScript compilation and test failures I found during this review are now fixed. The codebase is solid, the feature is well-architected, and the remaining items are minor UX improvements that can be addressed in follow-up PRs.
|
User github-actions[bot] does not have write permissions |












Add Nostr chat CLI commands using NIP-17 gift-wrapped messaging, matching the paytaca-app protocol.
New Commands
Chat
chat list— list conversations with latest message and unread countchat open <room-id>— view messages (fetched fresh from relay)chat send <room-id> <text>— send a messagechat listen— subscribe to new messages (long-running)chat add-contact <npub> [name]— add a contactchat contacts— list contacts with resolved names and BCH addresseschat identity— show your Nostr npub and pubkeyProfile
chat set-display-name <name>— publish display name (kind 30078)chat remove-display-name— remove published display namechat set-bch-address <address>— publish BCH address (kind 30078)chat remove-bch-address— remove published BCH addressArchitecture
nostr-tools['self']tag is relay-rejected but sent messages survive via disk persistence)~/.paytaca/chat-state.jsonskills/remain as checked-in files (not symlinks to gitignored.agents/)