bisq-bot/bot/src/bot.py
shakespeare.diy baf3a22c44 Phase 1: Core Bisq bot with multi-relay support
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
2025-11-01 07:53:03 +00:00

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())