mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 18:05:21 +00:00
Add geohash bulletin board: persistent signed notices over mesh sync
New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.
Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.
UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// GossipSyncBoardTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Board posts ride gossip sync through a provider that queries the board
|
||||
/// store, so retention (expiry, tombstones, caps) has a single owner.
|
||||
struct GossipSyncBoardTests {
|
||||
|
||||
private let myPeerID = PeerID(str: "0102030405060708")
|
||||
|
||||
private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: try #require(Data(hexString: "aabbccddeeff0011")),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data([0x42]),
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
}
|
||||
|
||||
private func quietConfig() -> GossipSyncManager.Config {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
return config
|
||||
}
|
||||
|
||||
@Test func boardRequestIsServedFromProvider() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
|
||||
manager.boardPacketsProvider = { return [boardPacket] }
|
||||
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
let sent = try #require(delegate.packets.first)
|
||||
#expect(sent.type == MessageType.boardPost.rawValue)
|
||||
#expect(sent.isRSR)
|
||||
}
|
||||
|
||||
@Test func nonBoardRequestDoesNotServeBoardPackets() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
|
||||
manager.boardPacketsProvider = { return [boardPacket] }
|
||||
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
// Follow with a board request; only its response should arrive, which
|
||||
// also proves the first request produced nothing.
|
||||
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
#expect(delegate.packets.count == 1)
|
||||
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
|
||||
}
|
||||
|
||||
@Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws {
|
||||
var config = quietConfig()
|
||||
config.boardSyncIntervalSeconds = 1
|
||||
|
||||
// Without a provider the board schedule stays silent.
|
||||
let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let unwiredDelegate = RecordingBoardDelegate()
|
||||
unwired.delegate = unwiredDelegate
|
||||
unwired._performMaintenanceSynchronously(now: Date())
|
||||
#expect(unwiredDelegate.packets.isEmpty)
|
||||
|
||||
// With a provider, maintenance sends a board-typed request.
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
manager.boardPacketsProvider = { return [] }
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
#expect(delegate.packets.count == 1)
|
||||
let payload = try #require(delegate.packets.first?.payload)
|
||||
let request = try #require(RequestSyncPacket.decode(from: payload))
|
||||
#expect(request.types == .board)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingBoardDelegate: GossipSyncManager.Delegate {
|
||||
private let lock = NSLock()
|
||||
private var _packets: [BitchatPacket] = []
|
||||
|
||||
var packets: [BitchatPacket] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _packets
|
||||
}
|
||||
|
||||
func sendPacket(_ packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
_packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
_packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
packet
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user