Implements the foundation for a Nostr-based Bisq marketplace bot:
Core Components:
- NostrHandler: Multi-relay connection management with simultaneous subscribe/publish
- BisqClient: Async wrapper around bisq-cli for marketplace queries
- MessageParser: Flexible command parsing with multiple input formats
- Formatter: Response formatting for Nostr publication
- BisqBot: Main orchestration class coordinating all components
Features:
- Multiple relay support (parallel connections)
- Event deduplication across relays
- Async/await architecture for non-blocking operations
- Comprehensive error handling and recovery
- Flexible command syntax (e.g., "USD BUY", "stats", "help")
Configuration:
- Environment-based configuration with sensible defaults
- Support for N relays via comma-separated config
- Bisq daemon connection configuration
Documentation:
- README.md: Complete user guide with installation and usage
- QUICKSTART.md: 10-minute setup guide
- ARCHITECTURE.md: Detailed technical architecture and design
- RELAY_STRATEGY.md: Multi-relay configuration and optimization
Deployment:
- systemd service file for production deployment on Debian
- setup.sh automated installation script
- .env.example configuration template
Phase 1 Status: ✅ COMPLETE
- Core bot skeleton
- Multi-relay support (no relay dependency needed)
- Nostr subscription and publishing
- Bisq query integration
- Basic command parsing and response
246 lines
7.5 KiB
Python
246 lines
7.5 KiB
Python
"""
|
|
Main Bisq bot service.
|
|
|
|
Orchestrates Bisq daemon queries, Nostr event subscription,
|
|
and message response handling.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from .config import get_config
|
|
from .nostr_handler import NostrHandler, NostrEvent
|
|
from .bisq_client import BisqClient, Direction
|
|
from .message_parser import MessageParser, CommandType, get_help_text
|
|
from .formatter import Formatter
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BisqBot:
|
|
"""
|
|
Main bot class that coordinates all components.
|
|
|
|
Connects to Nostr relays, listens for mentions, queries Bisq,
|
|
and publishes responses.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize the bot."""
|
|
self.config = get_config()
|
|
|
|
self.nostr_handler = NostrHandler(
|
|
private_key_hex=self.config.bot_private_key,
|
|
relays=self.config.relays,
|
|
)
|
|
|
|
self.bisq_client = BisqClient(
|
|
host=self.config.bisq_host,
|
|
port=self.config.bisq_port,
|
|
)
|
|
|
|
# Register event handlers
|
|
self.nostr_handler.on_event(self.handle_message)
|
|
|
|
logger.info(
|
|
f"Bot initialized. "
|
|
f"Bot pubkey: {self.nostr_handler.keys.public_key} "
|
|
f"Relays: {len(self.config.relays)}"
|
|
)
|
|
|
|
async def start(self) -> None:
|
|
"""
|
|
Start the bot service.
|
|
|
|
Connects to Nostr relays and subscribes to mentions.
|
|
Runs indefinitely until interrupted.
|
|
"""
|
|
try:
|
|
logger.info("Starting Bisq bot...")
|
|
|
|
# Connect to relays
|
|
await self.nostr_handler.connect()
|
|
logger.info(f"Connected to {len(self.config.relays)} relays")
|
|
|
|
# Subscribe to mentions of this bot
|
|
bot_pubkey = str(self.nostr_handler.keys.public_key)
|
|
logger.info(f"Subscribing to mentions of: {bot_pubkey}")
|
|
|
|
await self.nostr_handler.subscribe_to_mentions(bot_pubkey)
|
|
|
|
except KeyboardInterrupt:
|
|
logger.info("Shutting down...")
|
|
await self.stop()
|
|
except Exception as e:
|
|
logger.error(f"Fatal error: {e}", exc_info=True)
|
|
raise
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the bot service."""
|
|
await self.nostr_handler.disconnect()
|
|
logger.info("Bot stopped")
|
|
|
|
async def handle_message(self, event: NostrEvent) -> None:
|
|
"""
|
|
Handle incoming Nostr message.
|
|
|
|
Args:
|
|
event: Received Nostr event
|
|
"""
|
|
try:
|
|
logger.info(
|
|
f"Received message from {event.author[:12]}... "
|
|
f"kind={event.kind}"
|
|
)
|
|
|
|
# Parse command
|
|
bot_pubkey = str(self.nostr_handler.keys.public_key)
|
|
parsed_cmd = MessageParser.parse_command(event, bot_pubkey)
|
|
|
|
if not parsed_cmd:
|
|
logger.debug("Message is not a command, ignoring")
|
|
return
|
|
|
|
logger.info(
|
|
f"Parsed command: {parsed_cmd.command_type.value} - {parsed_cmd}"
|
|
)
|
|
|
|
# Handle command
|
|
response = await self._process_command(parsed_cmd)
|
|
|
|
if response:
|
|
# Publish response
|
|
await self.nostr_handler.publish_event(
|
|
content=response,
|
|
kind=1,
|
|
tags=[["e", str(event.event.id)], ["p", event.author]],
|
|
)
|
|
logger.info("Response published")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error handling message: {e}", exc_info=True)
|
|
|
|
# Try to publish error response
|
|
try:
|
|
error_response = Formatter.format_error(
|
|
"An error occurred processing your request"
|
|
)
|
|
await self.nostr_handler.publish_event(
|
|
content=error_response,
|
|
kind=1,
|
|
tags=[["e", str(event.event.id)], ["p", event.author]],
|
|
)
|
|
except Exception as e2:
|
|
logger.error(f"Failed to publish error response: {e2}")
|
|
|
|
async def _process_command(self, parsed_cmd) -> Optional[str]:
|
|
"""
|
|
Process a parsed command and generate response.
|
|
|
|
Args:
|
|
parsed_cmd: ParsedCommand object
|
|
|
|
Returns:
|
|
Response string to publish, or None if no response needed
|
|
"""
|
|
try:
|
|
if parsed_cmd.command_type == CommandType.GET_OFFERS:
|
|
return await self._handle_get_offers(
|
|
currency_code=parsed_cmd.currency_code,
|
|
direction=parsed_cmd.direction,
|
|
limit=parsed_cmd.limit,
|
|
)
|
|
|
|
elif parsed_cmd.command_type == CommandType.GET_STATS:
|
|
return await self._handle_get_stats()
|
|
|
|
elif parsed_cmd.command_type == CommandType.HELP:
|
|
return Formatter.format_help_message()
|
|
|
|
else:
|
|
return Formatter.format_error("Unknown command")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing command: {e}")
|
|
return Formatter.format_error(str(e))
|
|
|
|
async def _handle_get_offers(
|
|
self,
|
|
currency_code: str,
|
|
direction: str,
|
|
limit: int,
|
|
) -> str:
|
|
"""
|
|
Handle get offers command.
|
|
|
|
Args:
|
|
currency_code: Currency code (e.g., "USD")
|
|
direction: "BUY" or "SELL"
|
|
limit: Max number of offers to return
|
|
|
|
Returns:
|
|
Formatted response
|
|
"""
|
|
try:
|
|
logger.info(f"Querying {currency_code} {direction} offers...")
|
|
|
|
# Convert direction string to enum
|
|
direction_enum = Direction.BUY if direction == "BUY" else Direction.SELL
|
|
|
|
# Query Bisq
|
|
offers = await self.bisq_client.get_offers(
|
|
direction=direction_enum,
|
|
currency_code=currency_code,
|
|
limit=limit,
|
|
)
|
|
|
|
# Format response
|
|
response = Formatter.format_offers(
|
|
offers=offers,
|
|
currency_code=currency_code,
|
|
direction=direction_enum,
|
|
requested_limit=limit,
|
|
)
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get offers: {e}")
|
|
return Formatter.format_error(f"Failed to query offers: {str(e)}")
|
|
|
|
async def _handle_get_stats(self) -> str:
|
|
"""
|
|
Handle get stats command.
|
|
|
|
Returns:
|
|
Formatted response
|
|
"""
|
|
try:
|
|
logger.info("Querying market stats...")
|
|
|
|
stats = await self.bisq_client.get_market_stats()
|
|
|
|
response = Formatter.format_market_stats(stats)
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get stats: {e}")
|
|
return Formatter.format_error(f"Failed to query market stats: {str(e)}")
|
|
|
|
|
|
async def main():
|
|
"""Main entry point."""
|
|
bot = BisqBot()
|
|
await bot.start()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|