squidbot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
squidbot/server.py ADDED
@@ -0,0 +1,487 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SquidBot Server
4
+
5
+ Runs the Telegram bot (optional) and exposes a local TCP port for client connections.
6
+ Supports multiple channels via the session/lane abstraction.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import signal
13
+
14
+ from .agent import run_agent_with_history
15
+ from .channels import (ChannelRouter, MessagePayload, TCPAdapter,
16
+ TelegramAdapter, get_channel_router)
17
+ from .config import (OPENAI_API_KEY, SQUID_PORT, TELEGRAM_BOT_TOKEN,
18
+ init_default_files, show_startup_info)
19
+ from .lanes import LANE_CRON, LANE_MAIN, CommandLane
20
+ from .playwright_check import require_playwright_or_exit
21
+ from .scheduler import Scheduler
22
+ from .session import (ChannelType, DeliveryContext, Session,
23
+ get_session_manager, record_inbound_session)
24
+
25
+ # Server configuration
26
+ SERVER_HOST = "127.0.0.1"
27
+ SERVER_PORT = SQUID_PORT
28
+
29
+ # Setup logging
30
+ logging.basicConfig(
31
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
32
+ )
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Connected TCP clients (for broadcasting scheduled messages)
36
+ connected_clients: dict[str, asyncio.StreamWriter] = {}
37
+
38
+ # Scheduler instance
39
+ scheduler: Scheduler | None = None
40
+
41
+ # Telegram app instance (for sending proactive messages)
42
+ telegram_app = None
43
+
44
+ # Server running flag
45
+ running = True
46
+
47
+ # Session manager
48
+ session_manager = get_session_manager()
49
+
50
+ # Channel router for multi-channel broadcasting
51
+ channel_router = get_channel_router()
52
+
53
+
54
+ # ============================================================
55
+ # Message broadcasting
56
+ # ============================================================
57
+
58
+
59
+ async def broadcast_to_clients(message: str):
60
+ """Send a message to all connected TCP clients via channel router."""
61
+ if not connected_clients:
62
+ logger.info(f"[Broadcast] No clients connected: {message[:50]}...")
63
+ return
64
+
65
+ payload = MessagePayload(text=message)
66
+ for client_id in list(connected_clients.keys()):
67
+ context = DeliveryContext(
68
+ channel=ChannelType.TCP,
69
+ recipient_id=client_id,
70
+ )
71
+ success = await channel_router.send(context, payload)
72
+ if success:
73
+ logger.info(f"[Broadcast] Sent to {client_id}")
74
+ else:
75
+ # Remove disconnected clients
76
+ connected_clients.pop(client_id, None)
77
+
78
+
79
+ async def send_to_telegram(message: str):
80
+ """Send a message to Telegram if connected."""
81
+ global telegram_app, scheduler
82
+
83
+ if not telegram_app or not scheduler or not scheduler.chat_id:
84
+ return
85
+
86
+ context = DeliveryContext(
87
+ channel=ChannelType.TELEGRAM,
88
+ recipient_id=str(scheduler.chat_id),
89
+ )
90
+ payload = MessagePayload(text=message)
91
+
92
+ success = await channel_router.send(context, payload)
93
+ if success:
94
+ logger.info(f"[Telegram] Sent message to chat {scheduler.chat_id}")
95
+
96
+
97
+ async def send_scheduled_message(message: str):
98
+ """Send scheduled message to all active channels."""
99
+ logger.info(f"[Scheduled] {message[:100]}...")
100
+
101
+ # Broadcast to all active sessions
102
+ contexts = session_manager.get_active_delivery_contexts()
103
+ payload = MessagePayload(text=message)
104
+
105
+ if contexts:
106
+ results = await channel_router.broadcast(contexts, payload)
107
+ success_count = sum(1 for v in results.values() if v)
108
+ logger.info(f"[Scheduled] Broadcast to {success_count}/{len(results)} channels")
109
+ else:
110
+ # Fallback to legacy broadcast
111
+ await broadcast_to_clients(message)
112
+ await send_to_telegram(message)
113
+
114
+
115
+ # ============================================================
116
+ # Local TCP server for client connections
117
+ # ============================================================
118
+
119
+
120
+ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
121
+ """Handle a local client connection."""
122
+ addr = writer.get_extra_info("peername")
123
+ client_id = f"{addr[0]}:{addr[1]}"
124
+ logger.info(f"Client connected: {client_id}")
125
+
126
+ # Register client for broadcasts
127
+ connected_clients[client_id] = writer
128
+
129
+ # Get or create session for this TCP client
130
+ session = record_inbound_session(
131
+ channel=ChannelType.TCP,
132
+ recipient_id=client_id,
133
+ lane=LANE_MAIN,
134
+ delivery_context=DeliveryContext(
135
+ channel=ChannelType.TCP,
136
+ recipient_id=client_id,
137
+ ),
138
+ )
139
+
140
+ try:
141
+ while True:
142
+ # Read message (newline-delimited JSON)
143
+ data = await reader.readline()
144
+ if not data:
145
+ break
146
+
147
+ try:
148
+ request = json.loads(data.decode().strip())
149
+ command = request.get("command", "chat")
150
+ message = request.get("message", "")
151
+
152
+ if command == "chat":
153
+ # Run agent with session history
154
+ response, updated_history = await run_agent_with_history(
155
+ message, session.history
156
+ )
157
+ session.history = updated_history
158
+ session_manager.update(session)
159
+
160
+ # Reload scheduler jobs in case new ones were created
161
+ if scheduler:
162
+ scheduler.reload_jobs()
163
+
164
+ reply = {"status": "ok", "response": response}
165
+
166
+ elif command == "clear":
167
+ session.clear_history()
168
+ session_manager.update(session)
169
+ reply = {"status": "ok", "response": "Conversation cleared."}
170
+
171
+ elif command == "ping":
172
+ reply = {"status": "ok", "response": "pong"}
173
+
174
+ else:
175
+ reply = {
176
+ "status": "error",
177
+ "response": f"Unknown command: {command}",
178
+ }
179
+
180
+ except json.JSONDecodeError:
181
+ reply = {"status": "error", "response": "Invalid JSON"}
182
+ except Exception as e:
183
+ logger.exception("Error processing client request")
184
+ reply = {"status": "error", "response": str(e)}
185
+
186
+ # Send response
187
+ response_data = json.dumps(reply) + "\n"
188
+ writer.write(response_data.encode())
189
+ await writer.drain()
190
+
191
+ except asyncio.CancelledError:
192
+ pass
193
+ except Exception as e:
194
+ logger.error(f"Client error: {e}")
195
+ finally:
196
+ logger.info(f"Client disconnected: {client_id}")
197
+ connected_clients.pop(client_id, None)
198
+ writer.close()
199
+ await writer.wait_closed()
200
+
201
+
202
+ async def run_tcp_server():
203
+ """Run the local TCP server."""
204
+ server = await asyncio.start_server(handle_client, SERVER_HOST, SERVER_PORT)
205
+ logger.info(f"TCP server listening on {SERVER_HOST}:{SERVER_PORT}")
206
+
207
+ async with server:
208
+ await server.serve_forever()
209
+
210
+
211
+ # ============================================================
212
+ # Telegram bot (optional)
213
+ # ============================================================
214
+
215
+
216
+ async def send_response_with_images(update, response: str):
217
+ """Send response to Telegram, extracting and sending any screenshots as photos."""
218
+ import os
219
+ import re
220
+
221
+ max_length = 4096
222
+
223
+ # Find all screenshots in response - multiple patterns
224
+ screenshots = []
225
+
226
+ # Pattern 1: [SCREENSHOT:path]
227
+ pattern1 = r"\[SCREENSHOT:([^\]]+)\]"
228
+ screenshots.extend(re.findall(pattern1, response))
229
+
230
+ # Pattern 2: backtick-wrapped paths like `/.../squidbot_screenshot_*.png`
231
+ pattern2 = r"`([^`]*squidbot_screenshot_[^`]+\.png)`"
232
+ screenshots.extend(re.findall(pattern2, response))
233
+
234
+ # Pattern 3: plain paths /tmp/squidbot_screenshot_*.png or /var/folders/.../squidbot_screenshot_*.png
235
+ pattern3 = r"(/(?:tmp|var/folders)[^\s`\)]*squidbot_screenshot_[^\s`\)]+\.png)"
236
+ screenshots.extend(re.findall(pattern3, response))
237
+
238
+ # Pattern 4: markdown image syntax ![...](path)
239
+ pattern4 = r"!\[[^\]]*\]\(([^)]*squidbot_screenshot_[^)]+\.png)\)"
240
+ screenshots.extend(re.findall(pattern4, response))
241
+
242
+ # Deduplicate
243
+ screenshots = list(set(screenshots))
244
+
245
+ # Remove screenshot paths from text response
246
+ text_response = response
247
+ text_response = re.sub(pattern1 + r"[^\n]*\n?", "", text_response)
248
+ text_response = re.sub(
249
+ r"Saved at:\s*\n?\s*" + pattern2 + r"\s*\n?", "", text_response
250
+ )
251
+ text_response = re.sub(pattern2, "", text_response)
252
+ # Remove markdown image syntax ![...](screenshot_path)
253
+ text_response = re.sub(
254
+ r"!\[[^\]]*\]\([^)]*squidbot_screenshot_[^)]+\.png\)\s*", "", text_response
255
+ )
256
+ text_response = text_response.strip()
257
+
258
+ # Send text response if any
259
+ if text_response:
260
+ if len(text_response) <= max_length:
261
+ await update.message.reply_text(text_response)
262
+ else:
263
+ for i in range(0, len(text_response), max_length):
264
+ chunk = text_response[i : i + max_length]
265
+ await update.message.reply_text(chunk)
266
+
267
+ # Send screenshots as photos
268
+ for screenshot_path in screenshots:
269
+ if os.path.exists(screenshot_path):
270
+ try:
271
+ with open(screenshot_path, "rb") as photo:
272
+ await update.message.reply_photo(photo=photo, caption="Screenshot")
273
+ # Clean up temp file
274
+ os.remove(screenshot_path)
275
+ logger.info(f"Sent screenshot: {screenshot_path}")
276
+ except Exception as e:
277
+ logger.error(f"Failed to send screenshot: {e}")
278
+
279
+
280
+ async def run_telegram_bot():
281
+ """Run the Telegram bot."""
282
+ global telegram_app
283
+
284
+ from telegram import Update
285
+ from telegram.ext import (Application, CommandHandler, ContextTypes,
286
+ MessageHandler, filters)
287
+
288
+ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
289
+ await update.message.reply_text(
290
+ "Hello! I'm SquidBot, your autonomous AI assistant.\n\n"
291
+ "I can:\n"
292
+ "- Remember things you tell me\n"
293
+ "- Search the web for information\n"
294
+ "- Browse websites\n"
295
+ "- Set reminders and scheduled tasks\n\n"
296
+ "Just send me a message!"
297
+ )
298
+
299
+ async def clear_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
300
+ chat_id = update.effective_chat.id
301
+ session = session_manager.get(ChannelType.TELEGRAM, str(chat_id))
302
+ if session:
303
+ session.clear_history()
304
+ session_manager.update(session)
305
+ await update.message.reply_text("Conversation history cleared.")
306
+
307
+ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
308
+ chat_id = update.effective_chat.id
309
+ user_message = update.message.text
310
+
311
+ global scheduler
312
+ if scheduler:
313
+ scheduler.set_chat_id(chat_id)
314
+
315
+ # Get or create session for this Telegram chat
316
+ session = record_inbound_session(
317
+ channel=ChannelType.TELEGRAM,
318
+ recipient_id=str(chat_id),
319
+ lane=LANE_MAIN,
320
+ delivery_context=DeliveryContext(
321
+ channel=ChannelType.TELEGRAM,
322
+ recipient_id=str(chat_id),
323
+ thread_id=(
324
+ str(update.message.message_thread_id)
325
+ if update.message.message_thread_id
326
+ else None
327
+ ),
328
+ ),
329
+ )
330
+
331
+ await context.bot.send_chat_action(chat_id=chat_id, action="typing")
332
+
333
+ try:
334
+ response, updated_history = await run_agent_with_history(
335
+ user_message, session.history
336
+ )
337
+ session.history = updated_history
338
+ session_manager.update(session)
339
+
340
+ # Check for screenshots in response
341
+ await send_response_with_images(update, response)
342
+
343
+ if scheduler:
344
+ scheduler.reload_jobs()
345
+
346
+ except Exception as e:
347
+ logger.exception("Error handling message")
348
+ await update.message.reply_text(f"Sorry, an error occurred: {str(e)}")
349
+
350
+ # Create application
351
+ telegram_app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
352
+ telegram_app.add_handler(CommandHandler("start", start_command))
353
+ telegram_app.add_handler(CommandHandler("clear", clear_command))
354
+ telegram_app.add_handler(
355
+ MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)
356
+ )
357
+
358
+ # Initialize and run
359
+ await telegram_app.initialize()
360
+ await telegram_app.start()
361
+
362
+ # Register Telegram channel adapter
363
+ channel_router.register(TelegramAdapter(telegram_app.bot))
364
+ await telegram_app.updater.start_polling(allowed_updates=Update.ALL_TYPES)
365
+
366
+ # Wait until stopped
367
+ while running:
368
+ await asyncio.sleep(1)
369
+
370
+ # Cleanup
371
+ await telegram_app.updater.stop()
372
+ await telegram_app.stop()
373
+ await telegram_app.shutdown()
374
+
375
+
376
+ # ============================================================
377
+ # Main
378
+ # ============================================================
379
+
380
+
381
+ async def async_main():
382
+ """Async main entry point."""
383
+ global running, scheduler
384
+
385
+ # Validate OpenAI key (required)
386
+ if not OPENAI_API_KEY:
387
+ logger.error("OPENAI_API_KEY not set")
388
+ return
389
+
390
+ # Register TCP channel adapter
391
+ def get_tcp_writer(client_id: str):
392
+ return connected_clients.get(client_id)
393
+
394
+ channel_router.register(TCPAdapter(get_tcp_writer))
395
+
396
+ # Setup scheduler with proper send_message callback
397
+ async def run_agent_for_scheduler(prompt: str) -> str:
398
+ response, _ = await run_agent_with_history(prompt, [])
399
+ return response
400
+
401
+ scheduler = Scheduler(
402
+ send_message=send_scheduled_message, run_agent=run_agent_for_scheduler
403
+ )
404
+ scheduler.start()
405
+
406
+ # Start tasks
407
+ tasks = []
408
+
409
+ # Always run TCP server
410
+ tasks.append(asyncio.create_task(run_tcp_server()))
411
+ logger.info("SquidBot server started")
412
+ logger.info(f" - Local client: {SERVER_HOST}:{SERVER_PORT}")
413
+
414
+ # Optionally run Telegram bot
415
+ if TELEGRAM_BOT_TOKEN:
416
+ tasks.append(asyncio.create_task(run_telegram_bot()))
417
+ logger.info(f" - Telegram bot: active")
418
+ else:
419
+ logger.info(f" - Telegram bot: disabled (no token)")
420
+
421
+ # Handle shutdown
422
+ def signal_handler():
423
+ global running
424
+ running = False
425
+ logger.info("Shutting down...")
426
+
427
+ loop = asyncio.get_event_loop()
428
+ for sig in (signal.SIGTERM, signal.SIGINT):
429
+ loop.add_signal_handler(sig, signal_handler)
430
+
431
+ # Wait for tasks
432
+ try:
433
+ await asyncio.gather(*tasks)
434
+ except asyncio.CancelledError:
435
+ pass
436
+ finally:
437
+ scheduler.stop()
438
+ logger.info("SquidBot server stopped")
439
+
440
+
441
+ def run_server():
442
+ """Run the server directly."""
443
+ # Show configuration and initialize defaults
444
+ show_startup_info()
445
+ init_default_files()
446
+
447
+ # Check Playwright browser is working before starting
448
+ # This will exit with error if browser is not installed/working
449
+ require_playwright_or_exit()
450
+
451
+ logger.info("Starting SquidBot server...")
452
+ asyncio.run(async_main())
453
+
454
+
455
+ def main():
456
+ """Main entry point with CLI support."""
457
+ import sys
458
+
459
+ if len(sys.argv) < 2:
460
+ # No arguments - run server directly
461
+ run_server()
462
+ return
463
+
464
+ command = sys.argv[1]
465
+
466
+ if command in ("start", "stop", "stopall", "restart", "status", "logs"):
467
+ # Delegate to daemon module
468
+ from .daemon import main as daemon_main
469
+
470
+ daemon_main()
471
+ else:
472
+ # Unknown command - show help
473
+ print("Usage: squidbot [command]")
474
+ print("")
475
+ print("Commands:")
476
+ print(" (none) Run server in foreground")
477
+ print(" start Start server as daemon")
478
+ print(" stop Stop the daemon")
479
+ print(" stopall Stop daemon and all clients")
480
+ print(" restart Restart the daemon")
481
+ print(" status Show daemon status")
482
+ print(" logs Show logs (use -f to follow)")
483
+ sys.exit(1)
484
+
485
+
486
+ if __name__ == "__main__":
487
+ main()