PHP Classes

File: scripts/ai_bot_daemon.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/ai_bot_daemon.php   Download  
File: scripts/ai_bot_daemon.php
Role: Example script
Content type: text/plain
Description: Example script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 8,157 bytes
 

Contents

Class file image Download
#!/usr/bin/env php
<?php

/*
 * AI Bot Daemon
 *
 * Listens for chat events via PostgreSQL NOTIFY and dispatches AI responses
 * for configured bots. Reactive to live chat; not a polling loop.
 *
 * Usage: php ai_bot_daemon.php [--daemon] [--pid-file=path] [--log-level=LEVEL]
 */

chdir(__DIR__ . "/../");

require_once
__DIR__ . '/../vendor/autoload.php';
require_once
__DIR__ . '/../src/functions.php';

use
BinktermPHP\AiBot\AiBotRepository;
use
BinktermPHP\AiBot\ActivityEvent;
use
BinktermPHP\AiBot\LocalChatActivityHandler;
use
BinktermPHP\AI\AiService;
use
BinktermPHP\Binkp\Logger;
use
BinktermPHP\Config;
use
BinktermPHP\Database;
use
BinktermPHP\Realtime\PostgresEventListener;

// ========================================
// CLI Helpers
// ========================================

function showUsage(): void
{
    echo
"Usage: php ai_bot_daemon.php [options]\n";
    echo
"Options:\n";
    echo
" --daemon Run as daemon (detach from terminal)\n";
    echo
" --pid-file=FILE Write PID file (default: data/run/ai_bot_daemon.pid)\n";
    echo
" --log-level=LEVEL Log level (default: INFO)\n";
    echo
" --help Show this help message\n\n";
}

function
parseArgs(array $argv): array
{
   
$args = [];
    foreach (
$argv as $arg) {
        if (
strpos($arg, '--') === 0) {
            if (
strpos($arg, '=') !== false) {
                [
$key, $value] = explode('=', substr($arg, 2), 2);
               
$args[$key] = $value;
            } else {
               
$args[substr($arg, 2)] = true;
            }
        }
    }
    return
$args;
}

function
daemonize(): void
{
   
$pid = pcntl_fork();
    if (
$pid === -1) {
        die(
"Could not fork process\n");
    } elseif (
$pid > 0) {
        echo
"AI Bot daemon started with PID: $pid\n";
        exit(
0);
    }
    if (
posix_setsid() === -1) {
        die(
"Could not detach from terminal\n");
    }
   
chdir('/');
   
umask(0);
   
fclose(STDIN);
   
fclose(STDOUT);
   
fclose(STDERR);
}

function
writePidFile(string $pidFile): void
{
   
$dir = dirname($pidFile);
    if (!
is_dir($dir)) {
       
mkdir($dir, 0755, true);
    }
   
file_put_contents($pidFile, (string)getmypid());
}

function
removePidFile(string $pidFile): void
{
    if (
file_exists($pidFile)) {
       
unlink($pidFile);
    }
}

// ========================================
// Main
// ========================================

$args = parseArgs($argv);

if (isset(
$args['help'])) {
   
showUsage();
    exit(
0);
}

$logLevel = strtoupper((string)($args['log-level'] ?? 'INFO'));
$pidFile = (string)($args['pid-file'] ?? Config::env('AI_BOT_DAEMON_PID_FILE') ?: __DIR__ . '/../data/run/ai_bot_daemon.pid');

$logger = new Logger(
   
Config::getLogPath('ai_bot_daemon.log'),
   
$logLevel,
    !isset(
$args['daemon']) // echo to console when not daemonizing
);

if (isset(
$args['daemon'])) {
    if (!
function_exists('pcntl_fork')) {
       
$logger->error('Cannot daemonize: pcntl extension not available');
        exit(
1);
    }
   
daemonize();
}

writePidFile($pidFile);

$logger->info('AI bot daemon starting');

// Signal handling
$shutdown = false;
if (
function_exists('pcntl_signal')) {
   
pcntl_signal(SIGTERM, function () use (&$shutdown) { $shutdown = true; });
   
pcntl_signal(SIGINT, function () use (&$shutdown) { $shutdown = true; });
}

// Database (PDO for application queries)
$db = Database::getInstance()->getPdo();

// AI service
$ai = AiService::create();

// Activity handler
$chatHandler = new LocalChatActivityHandler($db, $ai, $logger);

// Repository
$repo = new AiBotRepository($db);

// Open realtime listener for LISTEN/NOTIFY wake-ups
$eventListener = PostgresEventListener::fromConfiguredDatabase($logger);
if (!
$eventListener->listen('binkstream')) {
   
$logger->error('AI bot daemon could not establish PostgreSQL LISTEN connection; exiting');
   
removePidFile($pidFile);
    exit(
1);
}
$logger->info('AI bot daemon: listening on binkstream channel');

// Capture starting cursor so we do not replay old events on startup
$stmt = $db->query("SELECT COALESCE(MAX(id), 0) FROM sse_events");
$lastSseId = (int)$stmt->fetchColumn();
$logger->info("AI bot daemon: starting from sse_events id={$lastSseId}");

// Pre-load bots (refresh periodically)
$bots = $repo->getActiveChatBotsByUserId();
$botUserIds = array_keys($bots);
$lastBotRefresh = time();
$botRefreshEvery = 60; // seconds

$logger->info('AI bot daemon ready', ['active_bots' => count($bots)]);

// ========================================
// Event loop
// ========================================

while (!$shutdown) {
    if (
function_exists('pcntl_signal_dispatch')) {
       
pcntl_signal_dispatch();
    }
    if (
$shutdown) {
        break;
    }

   
// Refresh bot list periodically
   
if (time() - $lastBotRefresh >= $botRefreshEvery) {
       
$bots = $repo->getActiveChatBotsByUserId();
       
$botUserIds = array_keys($bots);
       
$lastBotRefresh = time();
       
$logger->debug('AI bot daemon: refreshed bot list', ['active_bots' => count($bots)]);
    }

    if (!
$eventListener->isHealthy()) {
       
$logger->warning('AI bot daemon: pg connection lost, reconnecting...');
       
sleep(2);
        if (!
$eventListener->reconnect()) {
           
$logger->error('AI bot daemon: reconnect failed; exiting');
            break;
        }
    }

   
$notifications = $eventListener->wait(500);
    if (
$notifications === []) {
        continue;
    }

   
// Consume all pending notifications
   
foreach ($notifications as $notificationPayload) {
       
$evtId = (int)$notificationPayload;
        if (
$evtId <= $lastSseId) {
            continue;
        }
       
$lastSseId = $evtId;

       
// Fetch sse_events row
       
$evtStmt = $db->prepare("
            SELECT event_type, payload::text AS event_data, user_id
            FROM sse_events
            WHERE id = ?
        "
);
       
$evtStmt->execute([$evtId]);
       
$evt = $evtStmt->fetch(\PDO::FETCH_ASSOC);

        if (!
$evt || $evt['event_type'] !== 'chat_message') {
            continue;
        }

       
$payload = json_decode($evt['event_data'], true);
        if (!
is_array($payload)) {
            continue;
        }

       
$msgFromUserId = (int)($payload['from_user_id'] ?? 0);
       
$msgToUserId = isset($payload['to_user_id']) && $payload['to_user_id'] !== null
           
? (int)$payload['to_user_id']
            :
null;
       
$msgBody = (string)($payload['body'] ?? '');
       
$msgType = (string)($payload['type'] ?? '');

       
// Skip messages sent by a bot itself (prevent loops)
       
if (in_array($msgFromUserId, $botUserIds, true)) {
            continue;
        }

        if (
$msgType === 'dm' && $msgToUserId !== null && isset($bots[$msgToUserId])) {
           
// Direct message to a bot
           
$bot = $bots[$msgToUserId];
           
$event = new ActivityEvent('chat_direct', $payload);
            try {
               
$chatHandler->handle($bot, $event);
            } catch (\
Throwable $e) {
               
$logger->error('AI bot handler error (dm)', [
                   
'bot_id' => $bot->id,
                   
'error' => $e->getMessage(),
                ]);
            }
        } elseif (
$msgType === 'room') {
           
// Room message ? check for @mentions
           
foreach ($bots as $botUserId => $bot) {
                if (!
preg_match('/\B@' . preg_quote($bot->name, '/') . '\b/i', $msgBody)) {
                    continue;
                }
               
$event = new ActivityEvent('chat_mention', $payload);
                try {
                   
$chatHandler->handle($bot, $event);
                } catch (\
Throwable $e) {
                   
$logger->error('AI bot handler error (mention)', [
                       
'bot_id' => $bot->id,
                       
'error' => $e->getMessage(),
                    ]);
                }
            }
        }
    }
}

// ========================================
// Shutdown
// ========================================

$logger->info('AI bot daemon shutting down');
$eventListener->close();
removePidFile($pidFile);