/**
* Wiredove CLI Implementation
*
* Connects to wiredove.net network using:
* - ANProto protocol for messages
* - Trystero for WebRTC P2P
* - Same appId/password as wiredove.net
*/
import fs from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import chalk from 'chalk'
import nacl from 'tweetnacl'
import { joinRoom } from 'trystero/torrent'
import wrtc from 'wrtc'
import WebSocket from 'ws'
// Make WebSocket and WebRTC available globally for Trystero
global.WebSocket = WebSocket
global.RTCPeerConnection = wrtc.RTCPeerConnection
global.RTCSessionDescription = wrtc.RTCSessionDescription
global.RTCIceCandidate = wrtc.RTCIceCandidate
// wiredove network configuration (same as wiredove.net)
const WIREDOVE_CONFIG = {
appId: 'wiredovetestnet',
password: 'iajwoiejfaowiejfoiwajfe',
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
}
}
const CONFIG_DIR = join(homedir(), '.wiredove-cli')
const KEYPAIR_FILE = join(CONFIG_DIR, 'keypair.json')
const FOLLOWING_FILE = join(CONFIG_DIR, 'following.json')
const POSTS_FILE = join(CONFIG_DIR, 'posts.json')
export class WiredoveCLI {
constructor() {
this.keypair = null
this.name = null
this.following = []
this.posts = []
}
/**
* Initialize config directory
*/
async ensureConfigDir() {
if (!existsSync(CONFIG_DIR)) {
await fs.mkdir(CONFIG_DIR, { recursive: true })
}
}
/**
* Load identity from disk
*/
async loadIdentity() {
await this.ensureConfigDir()
if (existsSync(KEYPAIR_FILE)) {
const data = JSON.parse(await fs.readFile(KEYPAIR_FILE, 'utf8'))
this.keypair = {
publicKey: Buffer.from(data.publicKey, 'base64'),
secretKey: Buffer.from(data.secretKey, 'base64')
}
this.name = data.name || 'anonymous'
return true
}
return false
}
/**
* Load following list
*/
async loadFollowing() {
if (existsSync(FOLLOWING_FILE)) {
this.following = JSON.parse(await fs.readFile(FOLLOWING_FILE, 'utf8'))
}
}
/**
* Save following list
*/
async saveFollowing() {
await fs.writeFile(FOLLOWING_FILE, JSON.stringify(this.following, null, 2))
}
/**
* Load posts history
*/
async loadPosts() {
if (existsSync(POSTS_FILE)) {
this.posts = JSON.parse(await fs.readFile(POSTS_FILE, 'utf8'))
}
}
/**
* Save posts history
*/
async savePosts() {
await fs.writeFile(POSTS_FILE, JSON.stringify(this.posts, null, 2))
}
/**
* Generate base64 from buffer (compatible with wiredove)
*/
toBase64(buffer) {
return Buffer.from(buffer).toString('base64')
}
/**
* Get public key in base64 format (44 chars)
*/
getPubkey() {
return this.toBase64(this.keypair.publicKey)
}
/**
* Get latest post hash (for prevHash)
*/
getLatestHash() {
if (this.posts.length === 0) return null
return this.posts[this.posts.length - 1].hash
}
/**
* Hash a message using SHA-256 (ANProto compatible)
*/
async hash(message) {
const crypto = await import('crypto')
const hash = crypto.createHash('sha256').update(message, 'utf8').digest()
return this.toBase64(hash)
}
/**
* Sign a message (ANProto compatible)
* Signs timestamp+hash with nacl.sign (not detached)
*/
async sign(messageHash) {
const timestamp = Date.now().toString()
const timestampAndHash = timestamp + messageHash
// Use nacl.sign() not detached - includes message in output
const messageBytes = Buffer.from(timestampAndHash, 'utf8')
const signed = nacl.sign(messageBytes, this.keypair.secretKey)
// Return pubkey + signed_data
return this.getPubkey() + this.toBase64(signed)
}
/**
* Verify and open a signature (ANProto compatible)
*/
async open(sig) {
try {
const publicKey = sig.substring(0, 44)
const signedData = sig.substring(44)
const publicKeyBytes = Buffer.from(publicKey, 'base64')
const signedBytes = Buffer.from(signedData, 'base64')
const opened = nacl.sign.open(signedBytes, publicKeyBytes)
if (!opened) return null
return Buffer.from(opened).toString('utf8')
} catch (err) {
return null
}
}
/**
* Upload an image and get its hash
* Converts to data URL format like wiredove's browser upload
*/
async uploadImage(imagePath) {
const imageData = await fs.readFile(imagePath)
// Detect MIME type from file extension
const ext = imagePath.toLowerCase().split('.').pop()
const mimeTypes = {
'webp': 'image/webp',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif'
}
const mimeType = mimeTypes[ext] || 'image/png'
// Create data URL like wiredove does (data:image/webp;base64,...)
const base64Data = imageData.toString('base64')
const dataUrl = `data:${mimeType};base64,${base64Data}`
// Hash the data URL (not just the base64 part)
const imageHash = await this.hash(dataUrl)
return {
hash: imageHash,
data: dataUrl // Send data URL string, not raw binary
}
}
/**
* Create ANProto message
*/
async createMessage(text, imageHashOrPath = null) {
const prevHash = this.getLatestHash()
// If imageHashOrPath is a file path, upload it first
let imageHash = null
let imageData = null
if (imageHashOrPath) {
if (existsSync(imageHashOrPath)) {
// It's a file path - upload it
const uploaded = await this.uploadImage(imageHashOrPath)
imageHash = uploaded.hash
imageData = uploaded.data
} else {
// It's already a hash
imageHash = imageHashOrPath
}
}
// If image provided and text doesn't have markdown syntax, append it
let messageText = text
if (imageHash && !text.includes(') {
messageText = text + '\n'
}
// Create YAML format message
let yamlContent = '---\n'
yamlContent += `name: ${this.name}\n`
if (imageHash) yamlContent += `image: ${imageHash}\n`
if (prevHash) yamlContent += `previous: ${prevHash}\n`
yamlContent += '---\n'
yamlContent += messageText
// Hash the message content
const messageHash = await this.hash(yamlContent)
// Sign timestamp+hash (returns pubkey + signedData)
const sig = await this.sign(messageHash)
// Extract opened (timestamp+hash) from signature
const opened = await this.open(sig)
return {
hash: messageHash,
sig: sig,
opened: opened,
text: yamlContent,
author: this.getPubkey(),
imageData: imageData // Include image data if we uploaded one
}
}
/**
* Initialize identity
*/
async init(name = 'anonymous') {
await this.ensureConfigDir()
if (existsSync(KEYPAIR_FILE)) {
console.log(chalk.yellow('Identity already exists!'))
await this.whoami()
return
}
console.log(chalk.blue('Generating new identity...'))
// Generate Ed25519 keypair
const keypair = nacl.sign.keyPair()
// Save to disk
const data = {
publicKey: this.toBase64(keypair.publicKey),
secretKey: this.toBase64(keypair.secretKey),
name: name
}
await fs.writeFile(KEYPAIR_FILE, JSON.stringify(data, null, 2))
this.keypair = keypair
this.name = name
console.log(chalk.green('✓ Identity created!'))
console.log(chalk.gray(` Name: ${name}`))
console.log(chalk.gray(` Public Key: ${this.getPubkey()}`))
console.log(chalk.gray(` Config: ${KEYPAIR_FILE}`))
}
/**
* Show current identity
*/
async whoami() {
if (!await this.loadIdentity()) {
console.log(chalk.red('No identity found. Run: wiredove init'))
process.exit(1)
}
console.log(chalk.bold('Your Identity:'))
console.log(chalk.gray(` Name: ${this.name}`))
console.log(chalk.gray(` Public Key: ${this.getPubkey()}`))
console.log(chalk.gray(` Posts: ${this.posts.length}`))
}
/**
* Post a message to wiredove network
*/
async post(text, image = null) {
if (!await this.loadIdentity()) {
console.log(chalk.red('No identity found. Run: wiredove init'))
process.exit(1)
}
await this.loadPosts()
console.log(chalk.blue('Creating post...'))
// Create ANProto message
const message = await this.createMessage(text, image)
// Save to posts history
this.posts.push({
hash: message.hash,
sig: message.sig,
text: message.text,
timestamp: Date.now()
})
await this.savePosts()
console.log(chalk.green('✓ Post created locally'))
console.log(chalk.gray(` Hash: ${message.hash.substring(0, 16)}...`))
// Connect to 7rnx.net/relay WebSocket
console.log(chalk.blue('Connecting to 7rnx.net/relay...'))
try {
const ws = new WebSocket('wss://7rnx.net/relay/')
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Connection timeout'))
}, 10000)
ws.on('open', async () => {
clearTimeout(timeout)
console.log(chalk.green('✓ Connected'))
// Send our pubkey
ws.send(this.getPubkey())
console.log(chalk.gray(' Sent pubkey'))
// Send the message content (YAML text)
ws.send(message.text)
console.log(chalk.gray(' Sent message content'))
// Send image data if present
if (message.imageData) {
ws.send(message.imageData)
console.log(chalk.gray(' Sent image data'))
}
// Send the signature
ws.send(message.sig)
console.log(chalk.gray(' Sent signature'))
// Wait a bit for acknowledgment
setTimeout(() => {
ws.close()
resolve()
}, 2000)
})
ws.on('message', (data) => {
console.log(chalk.gray(` Received: ${data.toString().substring(0, 50)}...`))
})
ws.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
console.log(chalk.green('✓ Post broadcasted to wiredove network'))
} catch (err) {
console.error(chalk.red('Error posting to network:'), err.message)
console.log(chalk.yellow('Post saved locally but not broadcasted'))
}
process.exit(0)
}
/**
* Listen for messages from the network
*/
async listen(followList = [], timeout = 60) {
if (!await this.loadIdentity()) {
console.log(chalk.red('No identity found. Run: wiredove init'))
process.exit(1)
}
await this.loadFollowing()
// Use provided list or following list
const pubkeys = followList && followList.length > 0 ? followList : this.following
if (pubkeys.length === 0) {
console.log(chalk.yellow('No one to follow. Add someone with: wiredove follow <pubkey>'))
process.exit(1)
}
console.log(chalk.blue(`Listening to ${pubkeys.length} author(s) for ${timeout} seconds...`))
console.log(chalk.gray('Press Ctrl+C to stop\n'))
const rooms = []
// Join room for each author
for (const pubkey of pubkeys) {
console.log(chalk.gray(`Joining room: ${pubkey.substring(0, 16)}...`))
try {
const room = joinRoom(
{
...WIREDOVE_CONFIG,
rtcConfig: {
...WIREDOVE_CONFIG.rtcConfig,
wrtc: wrtc
}
},
pubkey
)
const [sendHash, onHash] = room.makeAction('hash')
const [sendBlob, onBlob] = room.makeAction('blob')
// Request latest from this author
sendHash(pubkey)
// Handle received posts
onBlob((blob) => {
try {
// Parse the message
// In wiredove, blob can be either hash (44 chars) or full signature (88 chars)
console.log(chalk.green(`\n📨 New message`))
console.log(chalk.gray(` From room: ${pubkey.substring(0, 16)}...`))
console.log(chalk.gray(` Hash: ${blob.substring(0, 32)}...`))
// TODO: Parse and display full message content
// This would require implementing full ANProto message parsing
} catch (err) {
console.log(chalk.red(`Error parsing message: ${err.message}`))
}
})
room.onPeerJoin((peerId) => {
console.log(chalk.green(` ✓ Peer joined room ${pubkey.substring(0, 8)}...: ${peerId.substring(0, 8)}...`))
// Request their latest
sendHash(pubkey)
})
rooms.push(room)
} catch (err) {
console.log(chalk.red(` Error joining room: ${err.message}`))
}
}
// Listen for specified time
await new Promise(resolve => setTimeout(resolve, timeout * 1000))
console.log(chalk.blue('\nStopping...'))
rooms.forEach(room => room.leave())
process.exit(0)
}
/**
* Add someone to following list
*/
async follow(pubkey) {
if (!await this.loadIdentity()) {
console.log(chalk.red('No identity found. Run: wiredove init'))
process.exit(1)
}
await this.loadFollowing()
// Validate pubkey format (44 chars base64)
if (!pubkey || pubkey.length !== 44) {
console.log(chalk.red('Invalid public key format (must be 44 characters)'))
process.exit(1)
}
if (this.following.includes(pubkey)) {
console.log(chalk.yellow('Already following this user'))
return
}
this.following.push(pubkey)
await this.saveFollowing()
console.log(chalk.green('✓ Added to following list'))
console.log(chalk.gray(` Public Key: ${pubkey}`))
console.log(chalk.gray(` Total following: ${this.following.length}`))
}
/**
* Show following list
*/
async showFollowing() {
if (!await this.loadIdentity()) {
console.log(chalk.red('No identity found. Run: wiredove init'))
process.exit(1)
}
await this.loadFollowing()
if (this.following.length === 0) {
console.log(chalk.yellow('Not following anyone yet'))
console.log(chalk.gray('Add someone with: wiredove follow <pubkey>'))
return
}
console.log(chalk.bold(`Following ${this.following.length} user(s):\n`))
this.following.forEach((pubkey, i) => {
console.log(chalk.gray(` ${i + 1}. ${pubkey}`))
})
}
/**
* Fetch users from wiredove network
*/
async fetchUsers(searchName = null) {
console.log(chalk.blue('Fetching users from 7rnx.net/relay...'))
try {
const https = await import('https')
const response = await new Promise((resolve, reject) => {
https.get('https://7rnx.net/relay/all', (res) => {
let data = ''
res.on('data', chunk => data += chunk)
res.on('end', () => resolve(JSON.parse(data)))
res.on('error', reject)
})
})
// Extract unique users
const usersMap = new Map()
response.forEach(post => {
if (!post.text) return
// Parse YAML frontmatter for name
const lines = post.text.split('\n')
let name = null
for (const line of lines) {
if (line.startsWith('name:')) {
name = line.substring(5).trim().replace(/'/g, '')
break
}
}
if (name && (!searchName || name.toLowerCase().includes(searchName.toLowerCase()))) {
if (!usersMap.has(post.author)) {
usersMap.set(post.author, {
author: post.author,
name,
postCount: 1
})
} else {
usersMap.get(post.author).postCount++
}
}
})
const users = Array.from(usersMap.values()).sort((a, b) => b.postCount - a.postCount)
if (users.length === 0) {
console.log(chalk.yellow('No users found'))
return
}
console.log(chalk.bold(`\nFound ${users.length} user(s):\n`))
users.forEach((user, i) => {
console.log(chalk.green(`${i + 1}. ${user.name}`) + chalk.gray(` (${user.postCount} posts)`))
console.log(chalk.gray(` ${user.author}\n`))
})
} catch (err) {
console.error(chalk.red('Error fetching users:'), err.message)
process.exit(1)
}
}
/**
* Fetch posts from wiredove network
*/
async fetchPosts(options = {}) {
console.log(chalk.blue('Fetching posts from 7rnx.net/relay...'))
try {
const https = await import('https')
const response = await new Promise((resolve, reject) => {
https.get('https://7rnx.net/relay/all', (res) => {
let data = ''
res.on('data', chunk => data += chunk)
res.on('end', () => resolve(JSON.parse(data)))
res.on('error', reject)
})
})
let posts = response.filter(post => post.text)
// Filter by user
if (options.user) {
posts = posts.filter(p => p.author === options.user)
}
// Filter by name
if (options.name) {
posts = posts.filter(p => {
const lines = p.text.split('\n')
for (const line of lines) {
if (line.startsWith('name:')) {
const name = line.substring(5).trim().replace(/'/g, '')
return name.toLowerCase().includes(options.name.toLowerCase())
}
}
return false
})
}
// Sort by timestamp (newest first)
posts.sort((a, b) => parseInt(b.ts) - parseInt(a.ts))
// Limit results
posts = posts.slice(0, options.limit || 10)
if (posts.length === 0) {
console.log(chalk.yellow('No posts found'))
return
}
console.log(chalk.bold(`\nShowing ${posts.length} post(s):\n`))
posts.forEach((post, i) => {
const lines = post.text.split('\n')
let name = 'anonymous'
let content = post.text
// Parse YAML frontmatter
if (lines[0] === '---') {
const yamlEnd = lines.indexOf('---', 1)
if (yamlEnd > 0) {
for (let j = 1; j < yamlEnd; j++) {
if (lines[j].startsWith('name:')) {
name = lines[j].substring(5).trim().replace(/'/g, '')
}
}
content = lines.slice(yamlEnd + 1).join('\n').trim()
}
}
const date = new Date(parseInt(post.ts))
console.log(chalk.cyan(`[${i + 1}] ${name}`) + chalk.gray(` - ${date.toLocaleString()}`))
console.log(chalk.gray(` Hash: ${post.hash}`))
// Show full content (no truncation)
console.log(chalk.white(` ${content}\n`))
})
} catch (err) {
console.error(chalk.red('Error fetching posts:'), err.message)
process.exit(1)
}
}
/**
* Reply to a post via WebSocket
*/
async reply(replyToHash, text, image = null) {
if (!await this.loadIdentity()) {
console.log(chalk.red('No identity found. Run: wiredove init'))
process.exit(1)
}
await this.loadPosts()
console.log(chalk.blue('Creating reply...'))
// Fetch the original post to get author
const https = await import('https')
const response = await new Promise((resolve, reject) => {
https.get('https://7rnx.net/relay/all', (res) => {
let data = ''
res.on('data', chunk => data += chunk)
res.on('end', () => resolve(JSON.parse(data)))
res.on('error', reject)
})
})
const originalPost = response.find(p => p.hash === replyToHash)
if (!originalPost) {
console.log(chalk.red('Original post not found'))
process.exit(1)
}
const prevHash = this.getLatestHash()
// Handle image upload if path provided
let imageHash = null
let imageData = null
if (image) {
if (existsSync(image)) {
const uploaded = await this.uploadImage(image)
imageHash = uploaded.hash
imageData = uploaded.data
} else {
imageHash = image
}
}
// If image provided and text doesn't have markdown syntax, append it
let replyText = text
if (imageHash && !text.includes(') {
replyText = text + '\n'
}
// Create YAML format
let yamlContent = '---\n'
yamlContent += `reply: ${replyToHash}\n`
yamlContent += `replyto: ${originalPost.author}\n`
yamlContent += `name: ${this.name}\n`
if (imageHash) yamlContent += `image: ${imageHash}\n`
if (prevHash) yamlContent += `previous: ${prevHash}\n`
yamlContent += '---\n'
yamlContent += replyText
// Hash the message content
const messageHash = await this.hash(yamlContent)
// Sign timestamp+hash (returns pubkey + signedData)
const sig = await this.sign(messageHash)
// Extract opened (timestamp+hash) from signature
const opened = await this.open(sig)
// Save to posts history
this.posts.push({
hash: messageHash,
sig: sig,
text: yamlContent,
timestamp: Date.now()
})
await this.savePosts()
console.log(chalk.green('✓ Reply created locally'))
console.log(chalk.gray(` Hash: ${messageHash.substring(0, 16)}...`))
// Connect to 7rnx.net/relay WebSocket
console.log(chalk.blue('Connecting to 7rnx.net/relay...'))
try {
const ws = new WebSocket('wss://7rnx.net/relay/')
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Connection timeout'))
}, 10000)
ws.on('open', async () => {
clearTimeout(timeout)
console.log(chalk.green('✓ Connected'))
// Send our pubkey
ws.send(this.getPubkey())
console.log(chalk.gray(' Sent pubkey'))
// Send the message content (YAML)
ws.send(yamlContent)
console.log(chalk.gray(' Sent message content'))
// Send image data if present
if (imageData) {
ws.send(imageData)
console.log(chalk.gray(' Sent image data'))
}
// Send the signature
ws.send(sig)
console.log(chalk.gray(' Sent signature'))
// Wait a bit for acknowledgment
setTimeout(() => {
ws.close()
resolve()
}, 2000)
})
ws.on('message', (data) => {
console.log(chalk.gray(` Received: ${data.toString().substring(0, 50)}...`))
})
ws.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
console.log(chalk.green('✓ Reply posted to wiredove network'))
} catch (err) {
console.error(chalk.red('Error posting reply:'), err.message)
console.log(chalk.yellow('Reply saved locally but not broadcasted'))
}
process.exit(0)
}
}