OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / cli / wiredove-cli / wiredove.js

831 lines · javascript ref:
/**
 * 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![]('+imageHash+')'
    }

    // 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![]('+imageHash+')'
    }

    // 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)
  }
}