PHP Classes

File: scripts/mrc_daemon.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/mrc_daemon.php   Download  
File: scripts/mrc_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: 39,824 bytes
 

Contents

Class file image Download
#!/usr/bin/env php <?php /* * MRC Daemon - Multi Relay Chat Multiplexer * * Maintains persistent connection to MRC server, processes incoming/outgoing * messages, and manages room state in database. * * Usage: php mrc_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\Database; use BinktermPHP\Binkp\Logger; use BinktermPHP\Mrc\MrcConfig; use BinktermPHP\Mrc\MrcClient; use BinktermPHP\Realtime\BinkStream; // ======================================== // CLI Functions // ======================================== function showUsage() { echo "Usage: php mrc_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/mrc_daemon.pid)\n"; echo " --log-level=LEVEL Log level (default: INFO)\n"; echo " --debug Enable protocol debug logging (raw send/recv)\n"; echo " --help Show this help message\n"; echo "\n"; } function parseArgs($argv) { $args = []; foreach ($argv as $arg) { if (strpos($arg, '--') === 0) { if (strpos($arg, '=') !== false) { list($key, $value) = explode('=', substr($arg, 2), 2); $args[$key] = $value; } else { $args[substr($arg, 2)] = true; } } } return $args; } function daemonize() { $pid = pcntl_fork(); if ($pid == -1) { die("Could not fork process\n"); } elseif ($pid) { echo "MRC 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 setConsoleTitle(string $title): void { echo "\033]0;{$title}\007"; } // ======================================== // BinkStream Helpers // ======================================== /** * Return the user IDs of all locally connected WebDoor users in a given room. * Only users whose heartbeat has been seen in the last 10 minutes are included. */ function getLocalUserIdsInRoom(PDO $db, string $room): array { $stmt = $db->prepare(" SELECT DISTINCT user_id AS id FROM mrc_local_presence WHERE room_name = :room AND user_id IS NOT NULL AND last_seen > CURRENT_TIMESTAMP - INTERVAL '10 minutes' "); $stmt->execute(['room' => $room]); return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'id'); } /** * Return local user IDs for a given MRC handle. * * Custom MRC handles do not necessarily match users.username, so targeted * replies from the server must first resolve against active local MRC presence. * Fall back to the account username only for legacy/default-handle sessions. * * @return int[] */ function getLocalUserIdsByMrcHandle(PDO $db, string $handle): array { $handle = trim($handle); if ($handle === '') { return []; } $stmt = $db->prepare(" SELECT DISTINCT id FROM ( SELECT user_id AS id FROM mrc_local_handles WHERE user_id IS NOT NULL AND last_seen > CURRENT_TIMESTAMP - INTERVAL '10 minutes' AND LOWER(username) = LOWER(:handle) UNION SELECT user_id AS id FROM mrc_local_presence WHERE user_id IS NOT NULL AND last_seen > CURRENT_TIMESTAMP - INTERVAL '10 minutes' AND LOWER(username) = LOWER(:handle) ) matches "); $stmt->execute(['handle' => $handle]); $ids = array_map('intval', array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'id')); if (!empty($ids)) { return $ids; } $stmt = $db->prepare("SELECT id FROM users WHERE LOWER(username) = LOWER(:username) LIMIT 1"); $stmt->execute(['username' => $handle]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ? [(int)$row['id']] : []; } /** * Emit an mrc_presence event with the current user list for a room * to every local WebDoor user in that room. */ function emitPresenceForRoom(PDO $db, string $room): void { $localUserIds = getLocalUserIdsInRoom($db, $room); if (empty($localUserIds)) { return; } $config = MrcConfig::getInstance(); $localBbs = MrcClient::sanitizeName($config->getBbsName()); $stmt = $db->prepare(" SELECT username, COALESCE(bbs_name, 'unknown') AS bbs_name, false AS is_afk FROM mrc_users WHERE room_name = :room UNION SELECT username, :local_bbs AS bbs_name, false AS is_afk FROM mrc_local_presence WHERE room_name = :room2 AND last_seen > CURRENT_TIMESTAMP - INTERVAL '10 minutes' "); $stmt->execute(['room' => $room, 'local_bbs' => $localBbs, 'room2' => $room]); $users = $stmt->fetchAll(PDO::FETCH_ASSOC); $payload = ['room' => $room, 'users' => $users]; foreach ($localUserIds as $userId) { BinkStream::emit($db, 'mrc_presence', $payload, (int)$userId); } } // ======================================== // Database Functions // ======================================== /** * Get database connection */ function getDb(): PDO { return Database::getInstance()->getPdo(); } function isValidMrcRoomName(string $room): bool { return preg_match('/^[A-Za-z0-9]{1,20}$/', $room) === 1; } /** * Update connection state in database */ function updateConnectionState(bool $connected): void { $db = getDb(); $stmt = $db->prepare(" INSERT INTO mrc_state (key, value, updated_at) VALUES ('connected', :value, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at "); $stmt->execute(['value' => $connected ? 'true' : 'false']); } /** * Write a daemon heartbeat timestamp so the web UI can detect if the daemon * has stopped unexpectedly (crash, kill -9, etc.) without a clean shutdown. */ function updateDaemonHeartbeat(): void { $db = getDb(); $db->prepare(" INSERT INTO mrc_state (key, value, updated_at) VALUES ('daemon_heartbeat', :value, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at ")->execute(['value' => (string)time()]); } /** * Update last ping timestamp in database */ function updateLastPing(int $timestamp): void { $db = getDb(); $stmt = $db->prepare(" INSERT INTO mrc_state (key, value, updated_at) VALUES ('last_ping', :value, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at "); $stmt->execute(['value' => (string)$timestamp]); } /** * Start a room list refresh window for pruning. */ function startRoomListRefresh(): void { $db = getDb(); $stmt = $db->prepare(" INSERT INTO mrc_state (key, value, updated_at) VALUES ('list_refresh_started', :value, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at "); $stmt->execute(['value' => (string)time()]); $stmt = $db->prepare(" INSERT INTO mrc_state (key, value, updated_at) VALUES ('list_refresh_pending', 'true', CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at "); $stmt->execute(); } /** * Prune rooms that were not seen during the last LIST refresh window. */ function pruneStaleRooms(): void { $db = getDb(); $started = $db->prepare("SELECT value FROM mrc_state WHERE key = 'list_refresh_started'"); $started->execute(); $startedAt = (int)($started->fetchColumn() ?: 0); if ($startedAt <= 0) { return; } $pending = $db->prepare("SELECT value FROM mrc_state WHERE key = 'list_refresh_pending'"); $pending->execute(); if ($pending->fetchColumn() !== 'true') { return; } // Wait a few seconds for LIST lines to arrive. if (time() - $startedAt < 3) { return; } $db->prepare(" DELETE FROM mrc_rooms WHERE last_list_seen IS NULL OR last_list_seen < to_timestamp(:started_at) ")->execute(['started_at' => $startedAt]); $db->prepare(" UPDATE mrc_state SET value = 'false', updated_at = CURRENT_TIMESTAMP WHERE key = 'list_refresh_pending' ")->execute(); } /** * Process incoming MRC packet */ function processIncomingPacket(array $packet): void { $db = getDb(); $f1 = $packet['f1']; $f2 = $packet['f2']; $f3 = $packet['f3']; $f4 = $packet['f4']; $f5 = $packet['f5']; $f6 = $packet['f6']; $f7 = $packet['f7']; // Handle SERVER commands - command verb is in f7, not f2 if ($f1 === 'SERVER') { $verb = explode(':', $f7, 2)[0]; handleServerCommand($verb, $f7, $packet); return; } // MRC room traffic can legally use f4=NOTME for non-private delivery // contexts (join/part and similar room-scoped traffic). Treat only an // actual target user as private; room-scoped traffic must still fan out to // local room subscribers via BinkStream. $targetUser = trim((string)$f4); $isPrivate = ($targetUser !== '' && strcasecmp($targetUser, 'NOTME') !== 0 && $f6 === ''); // Regular chat message - store in database $stmt = $db->prepare(" INSERT INTO mrc_messages (from_user, from_site, from_room, to_user, msg_ext, to_room, message_body, is_private, received_at) VALUES (:from_user, :from_site, :from_room, :to_user, :msg_ext, :to_room, :message_body, :is_private, CURRENT_TIMESTAMP) RETURNING id, received_at "); $stmt->execute([ 'from_user' => $f1, 'from_site' => $f2, 'from_room' => $f3, 'to_user' => $f4, 'msg_ext' => $f5, 'to_room' => $f6, 'message_body' => $f7, 'is_private' => $isPrivate ? 'true' : 'false' ]); $msgRow = $stmt->fetch(PDO::FETCH_ASSOC); $msgId = $msgRow ? (int)$msgRow['id'] : 0; $rcvdAt = $msgRow['received_at'] ?? null; // Notify connected WebDoor clients via BinkStream if ($msgId > 0) { $eventPayload = [ 'id' => $msgId, 'from_user' => $f1, 'from_site' => $f2, 'to_room' => $f6, 'to_user' => $isPrivate ? $targetUser : '', 'is_private' => $isPrivate, 'message_body' => $f7, 'received_at' => $rcvdAt, ]; if ($isPrivate) { foreach (getLocalUserIdsByMrcHandle($db, $targetUser) as $targetId) { BinkStream::emit($db, 'mrc_message', $eventPayload, $targetId); } } else { foreach (getLocalUserIdsInRoom($db, $f6) as $userId) { BinkStream::emit($db, 'mrc_message', $eventPayload, (int)$userId); } } } // Prune old messages (keep last 1000 per room) pruneOldMessages($f6); } /** * Handle a join announcement ? upsert the user as server-reported presence. */ function handleUserJoinAnnouncement(string $username, string $bbsName, string $room): void { $db = getDb(); $db->prepare(" INSERT INTO mrc_rooms (room_name, last_activity) VALUES (:room, CURRENT_TIMESTAMP) ON CONFLICT (room_name) DO UPDATE SET last_activity = CURRENT_TIMESTAMP ")->execute(['room' => $room]); $db->prepare(" INSERT INTO mrc_users (username, bbs_name, room_name, is_local, last_seen) VALUES (:username, :bbs_name, :room, false, CURRENT_TIMESTAMP) ON CONFLICT (username, bbs_name, room_name) DO UPDATE SET last_seen = CURRENT_TIMESTAMP ")->execute(['username' => $username, 'bbs_name' => $bbsName, 'room' => $room]); } /** * Handle a part announcement ? remove the user from the room. */ function handleUserPartAnnouncement(string $username, string $bbsName, string $room): void { $db = getDb(); $db->prepare(" DELETE FROM mrc_users WHERE username = :username AND bbs_name = :bbs_name AND room_name = :room AND is_local = false ")->execute(['username' => $username, 'bbs_name' => $bbsName, 'room' => $room]); } /** * Check if a join/part announcement belongs to a local webdoor user. * Uses BBS name comparison rather than username so same-named users on * other systems are not mistakenly treated as local. */ function isLocalAnnouncement(string $username, string $bbsName, string $room): bool { $config = MrcConfig::getInstance(); $ourBbs = MrcClient::sanitizeName($config->getBbsName()); return strcasecmp(MrcClient::sanitizeName($bbsName), $ourBbs) === 0; } /** * Handle SERVER commands (PING, ROOMTOPIC, NOTIFY, etc.) * * All server commands arrive with the verb (and optional params) in f7. * Format: VERB:param1:param2 or just VERB */ function handleServerCommand(string $verb, string $f7, array $packet): void { $db = getDb(); // If f7 starts with a pipe/MCI code or non-alpha character it's a display // message (join/part/timeout announcement, notice, etc.), not a command verb. if ($f7 === '' || !ctype_alpha($f7[0])) { $room = $packet['f6'] ?? ''; $clean = preg_replace('/\|[0-9]{2}/', '', $f7); mrcLog("MRC: Room message" . ($room ? " [{$room}]" : '') . ": {$clean}"); if ($room) { $suppressAnnouncement = false; $presenceChanged = false; // Parse join/part/timeout announcements to keep the user list accurate. // Join format: * (Joining) user@bbs just joined room #room // Part format: * (Parting) user@bbs has left room #room // Timeout format: * (Timeout) user@bbs client session has timed-out // Leave server: * (Leaving) user@bbs just left the server if (preg_match('/\(Joining\)\s+(\S+?)@(\S+?)\s+just joined/i', $clean, $m)) { handleUserJoinAnnouncement($m[1], $m[2], $room); $suppressAnnouncement = isLocalAnnouncement($m[1], $m[2], $room); $presenceChanged = true; } elseif (preg_match('/\((Parting|Timeout)\)\s+(\S+?)@(\S+?)\s/i', $clean, $m)) { handleUserPartAnnouncement($m[2], $m[3], $room); $suppressAnnouncement = isLocalAnnouncement($m[2], $m[3], $room); $presenceChanged = true; } elseif (preg_match('/(?:\(Leaving\)\s+)?(\S+?)@(\S+?)\s+just left the server/i', $clean, $m)) { // Remove from all rooms; server did not provide a room context. $db->prepare(" DELETE FROM mrc_users WHERE username = :username AND bbs_name = :bbs_name ")->execute(['username' => $m[1], 'bbs_name' => $m[2]]); $suppressAnnouncement = isLocalAnnouncement($m[1], $m[2], $room); $presenceChanged = true; } // Store announcement in mrc_messages so webdoor clients see it. // Suppress local join/part announcements to avoid spam when switching rooms. if (!$suppressAnnouncement) { $annStmt = $db->prepare(" INSERT INTO mrc_messages (from_user, from_site, from_room, to_user, msg_ext, to_room, message_body, is_private, received_at) VALUES ('SERVER', '', '', '', '', :room, :body, false, CURRENT_TIMESTAMP) RETURNING id, received_at "); $annStmt->execute(['room' => $room, 'body' => $f7]); $annRow = $annStmt->fetch(PDO::FETCH_ASSOC); if ($annRow) { $annPayload = [ 'id' => (int)$annRow['id'], 'from_user' => 'SERVER', 'from_site' => '', 'to_room' => $room, 'to_user' => '', 'is_private' => false, 'message_body' => $f7, 'received_at' => $annRow['received_at'], ]; foreach (getLocalUserIdsInRoom($db, $room) as $userId) { BinkStream::emit($db, 'mrc_message', $annPayload, (int)$userId); } } } if ($presenceChanged) { emitPresenceForRoom($db, $room); } } else { // No room context: may be a LIST response directed to this BBS. // Server sends one line per room: "*.: #roomname <count> <topic>" // After pipe-code stripping, match "#roomname <digits>" pattern. // Be tolerant of non-word chars (e.g. dashes) and missing counts. // // Check for LIST room-name pattern FIRST ? LIST lines carry f4=BBS-name // (non-empty), so testing targetUser before the regex would cause them // to be misrouted as private messages and never populate mrc_rooms. if ( preg_match('/#([A-Za-z0-9_-]{1,30})\s+\d+/', $clean, $m) || preg_match('/#([A-Za-z0-9_-]{1,30})\b/', $clean, $m) ) { $db->prepare(" INSERT INTO mrc_rooms (room_name, last_activity, last_list_seen) VALUES (:room, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (room_name) DO UPDATE SET last_list_seen = CURRENT_TIMESTAMP ")->execute(['room' => $m[1]]); mrcLog("MRC: Added room from LIST: {$m[1]}"); // Queue a USERLIST request so the room's user count is // populated as soon as the room is discovered, without waiting // for the next 60-second USERLIST refresh cycle. $config = MrcConfig::getInstance(); $bbsName = MrcClient::sanitizeName($config->getBbsName()); $newRoom = MrcClient::sanitizeName($m[1]); $db->prepare(" INSERT INTO mrc_outbound (field1, field2, field3, field4, field5, field6, field7, priority) VALUES ('CLIENT', :bbs, :room, 'SERVER', 'ALL', '', 'USERLIST', 5) ")->execute(['bbs' => $bbsName, 'room' => $newRoom]); return; } // Not a LIST line ? if targeted at a specific user, store as a // private server notice so the webdoor can display it. $targetUser = $packet['f4'] ?? ''; if ($targetUser !== '') { $targetUser = MrcClient::sanitizeName($targetUser); $fromSite = $packet['f5'] ?? ''; $privStmt = $db->prepare(" INSERT INTO mrc_messages (from_user, from_site, from_room, to_user, msg_ext, to_room, message_body, is_private, received_at) VALUES ('SERVER', :from_site, '', :to_user, '', '', :body, true, CURRENT_TIMESTAMP) RETURNING id, received_at "); $privStmt->execute([ 'from_site' => $fromSite, 'to_user' => $targetUser, 'body' => $f7 ]); $privRow = $privStmt->fetch(PDO::FETCH_ASSOC); if ($privRow) { foreach (getLocalUserIdsByMrcHandle($db, $targetUser) as $targetUserId) { BinkStream::emit($db, 'mrc_message', [ 'id' => (int)$privRow['id'], 'from_user' => 'SERVER', 'from_site' => $fromSite, 'to_room' => '', 'to_user' => $targetUser, 'is_private' => true, 'message_body' => $f7, 'received_at' => $privRow['received_at'], ], $targetUserId); } } } } return; } // Extract params portion (everything after first ':') $params = strpos($f7, ':') !== false ? substr($f7, strpos($f7, ':') + 1) : ''; $verb = strtoupper($verb); switch ($verb) { case 'PING': // Keepalive ping - response handled by main loop break; case 'ROOMTOPIC': // Format: ROOMTOPIC:room:topic $parts = explode(':', $params, 2); $room = $parts[0] ?? ''; $topic = $parts[1] ?? ''; if ($room) { $stmt = $db->prepare(" INSERT INTO mrc_rooms (room_name, topic, last_activity) VALUES (:room, :topic, CURRENT_TIMESTAMP) ON CONFLICT (room_name) DO UPDATE SET topic = EXCLUDED.topic, last_activity = CURRENT_TIMESTAMP "); $stmt->execute(['room' => $room, 'topic' => $topic]); } break; case 'USERROOM': // Server confirms which room the client is in // Format: USERROOM:room $room = $params; mrcLog("MRC: Server confirmed room: {$room}"); break; case 'USERNICK': // Server assigned/confirmed nick (may have suffix to resolve conflicts) // Format: USERNICK:nick mrcLog("MRC: Server assigned nick: {$params}"); break; case 'USERLIST': // Server sending current room user list (response to USERLIST or NEWROOM) // Format: USERLIST:user1,user2,... f6=room // // Sync strategy: never blindly wipe then refill. // - If the server returns a non-empty list: remove users no longer // present and upsert users that are, preserving any entries not // mentioned (e.g. local webdoor users whose NEWROOM hasn't echoed // back yet). // - Only when the server explicitly returns 0 users do we clear all // non-local entries for the room. $room = $packet['f6']; if (!$room) break; $users = ($params !== '') ? array_values(array_filter(array_map('trim', explode(',', $params)), 'strlen')) : []; // Ensure room exists (foreign key requirement for mrc_users). $db->prepare(" INSERT INTO mrc_rooms (room_name, last_activity) VALUES (:room, CURRENT_TIMESTAMP) ON CONFLICT (room_name) DO UPDATE SET last_activity = CURRENT_TIMESTAMP ")->execute(['room' => $room]); if (count($users) === 0) { // Server reports empty room ? remove all non-local presence. $db->prepare("DELETE FROM mrc_users WHERE room_name = :room AND is_local = false") ->execute(['room' => $room]); mrcLog("MRC: USERLIST empty for room {$room}, cleared remote users"); break; } // Sync: remove non-local users who are no longer in the server list, // then upsert the users who are present. try { $placeholders = implode(',', array_fill(0, count($users), '?')); $deleteStmt = $db->prepare(" DELETE FROM mrc_users WHERE room_name = ? AND is_local = false AND username NOT IN ({$placeholders}) "); $deleteStmt->execute(array_merge([$room], $users)); $insertStmt = $db->prepare(" INSERT INTO mrc_users (username, bbs_name, room_name, is_local, last_seen) VALUES (:username, :bbs_name, :room, false, CURRENT_TIMESTAMP) ON CONFLICT (username, bbs_name, room_name) DO UPDATE SET last_seen = CURRENT_TIMESTAMP "); foreach ($users as $u) { $insertStmt->execute([ 'username' => $u, 'bbs_name' => 'unknown', 'room' => $room, ]); } } catch (Throwable $e) { mrcLog("MRC: USERLIST sync failed for room {$room}: " . $e->getMessage(), 'ERROR'); } mrcLog("MRC: Room {$room} users (" . count($users) . "): {$params}"); emitPresenceForRoom($db, $room); break; case 'HELLO': // Server greeting after connect ? no action needed. break; case 'NOTIFY': // System notification - Format: NOTIFY:message mrcLog("MRC: Server notification: {$params}"); break; case 'TERMINATE': // Server requesting graceful shutdown - Format: TERMINATE:msg mrcLog("MRC: Server terminate request: {$params}"); break; case 'OLDVERSION': // Server rejected our version - Format: OLDVERSION:minversion mrcLog("MRC: Version rejected, server requires >= {$params}"); break; default: // If f4 is a username (not CLIENT/empty) this is a server-generated // display notice directed at a specific user, not a command verb. if (!empty($packet['f4']) && $packet['f4'] !== 'CLIENT') { $clean = preg_replace('/\|[0-9]{2}/', '', $f7); mrcLog("MRC: Server notice to {$packet['f4']}: {$clean}"); } else { mrcLog("MRC: Unhandled server verb: {$verb} (f7={$f7})"); } break; } } /** * Prune old messages from a room (keep last 1000) */ function pruneOldMessages(string $room): void { $db = getDb(); $config = MrcConfig::getInstance(); $limit = $config->getHistoryLimit(); $stmt = $db->prepare(" DELETE FROM mrc_messages WHERE id IN ( SELECT id FROM mrc_messages WHERE to_room = :room ORDER BY received_at DESC OFFSET :limit ) "); $stmt->execute(['room' => $room, 'limit' => $limit]); } /** * Process outbound message queue */ function processOutboundQueue(MrcClient $client): void { $db = getDb(); // Get unsent messages ordered by priority $stmt = $db->prepare(" SELECT * FROM mrc_outbound WHERE sent_at IS NULL ORDER BY priority DESC, created_at ASC LIMIT 10 "); $stmt->execute(); $messages = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($messages as $msg) { // Send packet $success = $client->sendPacket( $msg['field1'], $msg['field2'], $msg['field3'] ?? '', $msg['field4'] ?? '', $msg['field5'] ?? '', $msg['field6'] ?? '', $msg['field7'] ); if ($success) { // Mark as sent $updateStmt = $db->prepare(" UPDATE mrc_outbound SET sent_at = CURRENT_TIMESTAMP WHERE id = :id "); $updateStmt->execute(['id' => $msg['id']]); } } // Prune sent messages older than 24 hours $db->exec(" DELETE FROM mrc_outbound WHERE sent_at IS NOT NULL AND sent_at < CURRENT_TIMESTAMP - INTERVAL '24 hours' "); } /** * Maintain local (webdoor) user sessions: * - Send IAMHERE for active users so the server keeps them in room routing. * - Send LOGOFF + prune users whose browser heartbeat has expired (10 min). */ function maintainLocalUserSessions(MrcClient $client): void { $db = getDb(); // Collect stale and active users in one query $stmt = $db->query(" SELECT username, room_name, (last_seen < CURRENT_TIMESTAMP - INTERVAL '10 minutes') AS is_stale FROM mrc_local_presence "); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $staleCount = 0; foreach ($rows as $row) { if (!isValidMrcRoomName((string)$row['room_name'])) { $db->prepare(" DELETE FROM mrc_local_presence WHERE username = :username AND room_name = :room_name ")->execute([ 'username' => $row['username'], 'room_name' => $row['room_name'], ]); mrcLog("MRC: Pruned invalid local room '{$row['room_name']}' for {$row['username']}"); continue; } if ($row['is_stale']) { $client->sendLogoff($row['username'], $row['room_name']); mrcLog("MRC: LOGOFF stale user {$row['username']} from {$row['room_name']}"); $staleCount++; } else { $client->sendIamHere($row['username'], $row['room_name']); } } if ($staleCount > 0) { $db->exec(" DELETE FROM mrc_local_presence WHERE last_seen < CURRENT_TIMESTAMP - INTERVAL '10 minutes' "); mrcLog("MRC: Pruned {$staleCount} stale local user(s)"); } } /** * Clear foreign user presence records. * Called on (re)connect so stale remote-user entries are removed. * Local (webdoor) users are preserved so their rooms can be re-joined. */ function clearForeignUsers(): void { getDb()->exec("DELETE FROM mrc_users WHERE is_local = false"); } /** * Ensure the configured auto_join rooms exist in mrc_rooms so the * WebDoor room list is populated even before any user joins. * No NEWROOM packets are sent ? this is a DB-only operation. */ function seedAutoJoinRooms(): void { $db = getDb(); $config = MrcConfig::getInstance(); $rooms = $config->getAutoJoinRooms(); $stmt = $db->prepare(" INSERT INTO mrc_rooms (room_name, last_activity) VALUES (:room, CURRENT_TIMESTAMP) ON CONFLICT (room_name) DO NOTHING "); foreach ($rooms as $room) { $room = trim((string)$room); if ($room !== '') { $stmt->execute(['room' => $room]); } } } /** * Re-join each local (webdoor) user into their room after a reconnect. * Each user needs their own NEWROOM packet so the MRC server establishes * an individual session for them ? sending NEWROOM only as the BBS is not enough. */ function rejoinLocalUserRooms(MrcClient $client): void { $db = getDb(); $stmt = $db->query("SELECT username, room_name FROM mrc_local_presence"); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { if (!isValidMrcRoomName((string)$row['room_name'])) { $db->prepare(" DELETE FROM mrc_local_presence WHERE username = :username AND room_name = :room_name ")->execute([ 'username' => $row['username'], 'room_name' => $row['room_name'], ]); mrcLog("MRC: Skipped invalid rejoin room '{$row['room_name']}' for {$row['username']}"); continue; } $client->joinRoom($row['room_name'], $row['username']); mrcLog("MRC: Re-joined {$row['username']} into room: {$row['room_name']}"); } } /** * Log a message to the MRC daemon log. * Uses the global $logger once initialized; falls back to error_log() before that. */ function mrcLog(string $message, string $level = 'INFO'): void { global $logger; if ($logger instanceof Logger) { $logger->log($level, $message); } else { error_log($message); } } // ======================================== // Main Program // ======================================== $args = parseArgs($argv); if (isset($args['help'])) { showUsage(); exit(0); } try { // Initialize logger $logLevel = $args['log-level'] ?? 'INFO'; $logger = new Logger(\BinktermPHP\Config::getLogPath('mrc_daemon.log'), $logLevel, true); // Setup paths $defaultPidFile = __DIR__ . '/../data/run/mrc_daemon.pid'; $pidFile = $args['pid-file'] ?? $defaultPidFile; $masterPid = null; // Track parent PID for cleanup // Check if MRC is enabled $config = MrcConfig::getInstance(); if (!$config->isEnabled()) { echo "MRC is disabled in configuration\n"; exit(0); } // Daemonize if requested if (isset($args['daemon']) && function_exists('pcntl_fork')) { $masterPid = getmypid(); // Store parent PID daemonize(); // daemonize() closes STDERR; stop logger from writing to it $logger->setLogToConsole(false); } else { setConsoleTitle('BinktermPHP MRC Daemon'); } // Write PID file (only the forked child writes this) $pidDir = dirname($pidFile); if (!is_dir($pidDir)) { @mkdir($pidDir, 0755, true); } @file_put_contents($pidFile, (string)getmypid()); // Signal handling $shutdown = false; if (function_exists('pcntl_async_signals')) { pcntl_async_signals(true); } if (function_exists('pcntl_signal')) { pcntl_signal(SIGTERM, function() use (&$shutdown) { mrcLog("MRC: Received SIGTERM, shutting down..."); $shutdown = true; }); pcntl_signal(SIGINT, function() use (&$shutdown) { mrcLog("MRC: Received SIGINT, shutting down..."); $shutdown = true; }); } $debugMode = isset($args['debug']); mrcLog("MRC: Daemon started (PID: " . getmypid() . ")" . ($debugMode ? " [DEBUG MODE]" : "")); $client = new MrcClient($config); $client->setDebug($debugMode); $lastReconnectAttempt = 0; $lastKeepalive = 0; $lastIamHere = 0; $lastUserListRefresh = 0; $lastRoomListRefresh = 0; $lastHeartbeat = 0; // Write initial heartbeat immediately so the web UI knows we are alive. updateDaemonHeartbeat(); // Main loop while (!$shutdown) { // Check if still enabled $config->reloadConfig(); if (!$config->isEnabled()) { mrcLog("MRC: Disabled in config, shutting down"); break; } // Connect if not connected if (!$client->isConnected()) { $now = time(); if ($now - $lastReconnectAttempt >= $config->getReconnectDelay()) { mrcLog("MRC: Attempting to connect..."); if ($client->connect()) { updateConnectionState(true); // Send INFO* metadata to the hub on connect. $web = trim((string)$config->getWebsite()); $telnet = trim((string)$config->getTelnet()); $sysop = trim((string)$config->getSysop()); $desc = trim((string)$config->getDescription()); if ($web !== '') { $client->sendInfo('INFOWEB', $web); } if ($telnet !== '') { $client->sendInfo('INFOTEL', $telnet); } if ($sysop !== '') { $client->sendInfo('INFOSYS', $sysop); } if ($desc !== '') { $client->sendInfo('INFODSC', $desc); } // Clear stale foreign users; local (webdoor) users are kept // so their rooms can be re-established below. clearForeignUsers(); // Seed configured rooms into mrc_rooms so the WebDoor // room list is populated even before any user joins. seedAutoJoinRooms(); // Give server time to process handshake and INFO commands // before sending LIST or NEWROOM ? otherwise the server // may not have established our session yet and will ignore them. sleep(1); // Request full room list from server to populate mrc_rooms. startRoomListRefresh(); $client->requestRoomList(); // Re-join any rooms with active local (webdoor) users. // Individual room joins happen via the outbound queue when // users join from the web UI. We do NOT send a BBS-level // NEWROOM using the BBS name as a username ? that creates a // phantom user session the server will time out. rejoinLocalUserRooms($client); $lastKeepalive = time(); $lastIamHere = time(); // Delay first IAMHERE so NEWROOMs are processed first } else { updateConnectionState(false); } $lastReconnectAttempt = $now; } // Sleep before retry usleep(100000); // 100ms continue; } // Read incoming packets $packets = $client->readPackets(); foreach ($packets as $packet) { // Check for PING - command is in f7, not f2 if ($packet['f1'] === 'SERVER' && strtoupper($packet['f7']) === 'PING') { $client->sendKeepalive(); $client->updateLastPing(); updateLastPing(time()); $lastKeepalive = time(); } processIncomingPacket($packet); } // Process outbound queue processOutboundQueue($client); // Send IAMHERE for active local users; LOGOFF + prune stale ones if (time() - $lastIamHere >= 50) { maintainLocalUserSessions($client); $lastIamHere = time(); } // Refresh user list for all known rooms every 60 seconds. // Requesting USERLIST for every room (not just locally-occupied ones) // keeps remote user counts accurate for the room list display. if (time() - $lastUserListRefresh >= 60) { $db = getDb(); $stmt = $db->query("SELECT room_name FROM mrc_rooms"); foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $room) { $client->requestUserList($room); } $lastUserListRefresh = time(); } // Prune rooms after LIST refresh window. pruneStaleRooms(); // Heartbeat: let the web UI know the daemon is alive. if (time() - $lastHeartbeat >= 30) { updateDaemonHeartbeat(); $lastHeartbeat = time(); } // Check keepalive timeout if ($client->isKeepaliveExpired()) { mrcLog("MRC: Keepalive timeout, reconnecting..."); $client->disconnect(); updateConnectionState(false); continue; } // Sleep to avoid busy loop usleep(100000); // 100ms } // Cleanup mrcLog("MRC: Shutting down..."); if ($client->isConnected()) { $client->disconnect(); } updateConnectionState(false); // Delete PID file (only if we're the process that created it) if (file_exists($pidFile) && getmypid() == (int)file_get_contents($pidFile)) { @unlink($pidFile); } mrcLog("MRC: Daemon stopped"); exit(0); } catch (Exception $e) { mrcLog("MRC: Fatal error: " . $e->getMessage(), 'ERROR'); exit(1); }