YoAkatsuki commited on
Commit
0efdc4a
·
verified ·
1 Parent(s): a2eb77a

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +17 -0
  2. README.md +6 -10
  3. requirements.txt +5 -0
  4. server.py +536 -0
  5. start.sh +2 -0
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use a stable Python base
2
+ FROM python:3.10-slim
3
+
4
+ # set working dir
5
+ WORKDIR /app
6
+
7
+ # copy files
8
+ COPY . /app
9
+
10
+ # install dependencies
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ # expose default Space port (7860 is HF default)
14
+ EXPOSE 7860
15
+
16
+ # start the app (use PORT env var set by HF - default 7860)
17
+ CMD ["sh", "-c", "uvicorn server:app --host 0.0.0.0 --port ${PORT:-7860} --proxy-headers"]
README.md CHANGED
@@ -1,10 +1,6 @@
1
- ---
2
- title: Server
3
- emoji: 🏆
4
- colorFrom: green
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ ---
2
+ sdk: docker
3
+ app_port: 7860
4
+ ---
5
+ # Akatsuki (FastAPI WebSocket chat)
6
+ ... normal README content ...
 
 
 
 
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ httpx
4
+ python-dotenv
5
+ websockets
server.py ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Akatsuki - simple ephemeral single-room WebSocket chat server.
4
+ """
5
+ import os
6
+ import asyncio
7
+ import json
8
+ import uuid
9
+ import secrets
10
+ import re
11
+ import logging
12
+ from datetime import datetime, timedelta, timezone
13
+ from typing import Dict, Optional, Any, Set, List
14
+
15
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, status
16
+ from fastapi.responses import HTMLResponse, JSONResponse
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ import httpx
19
+ from dotenv import load_dotenv
20
+
21
+ load_dotenv()
22
+
23
+ # --- Basic Logging Setup ---
24
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
25
+
26
+ APP_NAME = "Akatsuki"
27
+ ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "Aadish")
28
+ ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "Aadish20m")
29
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
30
+ GROQ_ENDPOINT = "https://api.groq.com/openai/v1/chat/completions"
31
+
32
+ # --- AI Model Configuration ---
33
+ MODEL_ALIASES = {
34
+ "gpt": "openai/gpt-oss-120b",
35
+ "llama": "meta-llama/llama-4-maverick-17b-128e-instruct",
36
+ "deepseek": "deepseek-r1-distill-llama-70b",
37
+ "qwen": "qwen/qwen3-32b",
38
+ "compound": "compound-beta",
39
+ }
40
+ DEFAULT_MODEL = "compound-beta"
41
+
42
+ # --- Whitelist Configuration ---
43
+ WHITELISTED_USERS = {"Prakhar", "Priyanshu", "Summit", "Aditya", "Yuvraj", "Yash"}
44
+ TEMPORARY_WHITELIST = set()
45
+
46
+ app = FastAPI(title=APP_NAME)
47
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
48
+
49
+ # --- State Management ---
50
+ STATE_FILE = "server_state.json"
51
+ TEMP_STATE_FILE = "server_state.json.tmp"
52
+ LATEST_BROADCAST: Optional[Dict[str, Any]] = None
53
+
54
+ class User:
55
+ def __init__(self, websocket: WebSocket, username: str, role: str):
56
+ self.ws = websocket
57
+ self.username = username
58
+ self.role = role
59
+ self.session_id = uuid.uuid4().hex
60
+ self.last_pong_at = datetime.now(timezone.utc)
61
+
62
+ connected_users: Dict[str, User] = {}
63
+ bans: Dict[str, Optional[str]] = {}
64
+ mutes: Dict[str, str] = {}
65
+ user_tags: Dict[str, str] = {} # username.lower() -> tag
66
+ connected_lock = asyncio.Lock()
67
+ state_lock = asyncio.Lock()
68
+
69
+ def ts_iso(dt: Optional[datetime] = None) -> str:
70
+ dt = dt or datetime.now(timezone.utc)
71
+ return dt.isoformat()
72
+
73
+ def ts_from_iso(iso_str: str) -> Optional[datetime]:
74
+ try:
75
+ return datetime.fromisoformat(iso_str.replace('Z', '+00:00'))
76
+ except (ValueError, TypeError):
77
+ return None
78
+
79
+ # --- Persistence ---
80
+ def save_state():
81
+ logging.info("Attempting to save state to disk...")
82
+ try:
83
+ with open(TEMP_STATE_FILE, "w") as f:
84
+ json.dump({"bans": bans, "mutes": mutes, "tags": user_tags}, f, indent=2)
85
+ os.replace(TEMP_STATE_FILE, STATE_FILE)
86
+ logging.info("State saved successfully.")
87
+ except (IOError, os.error) as e:
88
+ logging.error(f"Failed to save state: {e}")
89
+
90
+ async def async_save_state():
91
+ async with state_lock:
92
+ loop = asyncio.get_running_loop()
93
+ await loop.run_in_executor(None, save_state)
94
+
95
+ def cleanup_expired_state():
96
+ now = datetime.now(timezone.utc)
97
+ changed = False
98
+ for u, exp_str in list(bans.items()):
99
+ if exp_str and (expiry := ts_from_iso(exp_str)) and expiry <= now:
100
+ del bans[u]
101
+ changed = True
102
+ for u, exp_str in list(mutes.items()):
103
+ if (expiry := ts_from_iso(exp_str)) and expiry <= now:
104
+ del mutes[u]
105
+ changed = True
106
+ if changed: save_state()
107
+
108
+ def load_state():
109
+ global bans, mutes, user_tags
110
+ if not os.path.exists(STATE_FILE): return
111
+ try:
112
+ with open(STATE_FILE, "r") as f:
113
+ data = json.load(f)
114
+ bans = data.get("bans", {})
115
+ mutes = data.get("mutes", {})
116
+ user_tags = data.get("tags", {})
117
+ cleanup_expired_state()
118
+ logging.info("Server state loaded successfully.")
119
+ except (IOError, json.JSONDecodeError) as e:
120
+ logging.error(f"Failed to load state: {e}")
121
+
122
+ async def state_cleanup_task():
123
+ while True:
124
+ await asyncio.sleep(60 * 5)
125
+ logging.info("Running periodic state cleanup task...")
126
+ try:
127
+ loop = asyncio.get_running_loop()
128
+ await loop.run_in_executor(None, cleanup_expired_state)
129
+ except Exception as e:
130
+ logging.error(f"Error in state_cleanup_task: {e}", exc_info=True)
131
+
132
+ # --- Business Logic Helpers ---
133
+ async def is_banned(username: str) -> Optional[str]:
134
+ username_lower = username.lower()
135
+ for ban_user, ban_expiry_val in bans.items():
136
+ if ban_user.lower() == username_lower:
137
+ if ban_expiry_val is None: return "You are permanently banned."
138
+ expiry_dt = ts_from_iso(ban_expiry_val)
139
+ if expiry_dt and expiry_dt > datetime.now(timezone.utc):
140
+ remaining = expiry_dt - datetime.now(timezone.utc)
141
+ return f"You are banned for another {int(remaining.total_seconds() / 60)} minutes."
142
+ elif expiry_dt:
143
+ del bans[ban_user]
144
+ await async_save_state()
145
+ return None
146
+ return None
147
+
148
+ async def is_muted(username: str) -> Optional[str]:
149
+ username_lower = username.lower()
150
+ for mute_user, mute_expiry_val in mutes.items():
151
+ if mute_user.lower() == username_lower:
152
+ expiry_dt = ts_from_iso(mute_expiry_val)
153
+ if expiry_dt and expiry_dt > datetime.now(timezone.utc):
154
+ remaining = expiry_dt - datetime.now(timezone.utc)
155
+ return f"You are muted for another {int(remaining.total_seconds())}s."
156
+ elif expiry_dt:
157
+ del mutes[mute_user]
158
+ await async_save_state()
159
+ return None
160
+ return None
161
+
162
+ # --- User & Session Helpers ---
163
+ async def find_user_by_name(username: str) -> Optional[User]:
164
+ username_lower = username.lower()
165
+ async with connected_lock:
166
+ for user in connected_users.values():
167
+ if user.username.lower() == username_lower:
168
+ return user
169
+ return None
170
+
171
+ async def is_username_in_use(username: str) -> bool:
172
+ return await find_user_by_name(username) is not None
173
+
174
+ # --- Messaging Helpers ---
175
+ async def safe_send(ws: WebSocket, obj: dict):
176
+ try: await ws.send_text(json.dumps(obj))
177
+ except (WebSocketDisconnect, ConnectionError, RuntimeError): pass
178
+
179
+ async def broadcast(obj: dict, exclude_ids: Optional[Set[str]] = None):
180
+ exclude = exclude_ids or set()
181
+ connections_to_send = []
182
+ async with connected_lock:
183
+ for sid, u in connected_users.items():
184
+ if sid not in exclude:
185
+ connections_to_send.append(u.ws)
186
+
187
+ tasks = [safe_send(ws, obj) for ws in connections_to_send]
188
+ if tasks: await asyncio.gather(*tasks)
189
+
190
+ async def send_to_user(username: str, obj: dict) -> bool:
191
+ user = await find_user_by_name(username)
192
+ if user:
193
+ await safe_send(user.ws, obj)
194
+ return True
195
+ return False
196
+
197
+ def get_users_list() -> List[Dict[str, Any]]:
198
+ return [{"name": u.username, "role": u.role, "tag": user_tags.get(u.username.lower())} for u in connected_users.values()]
199
+
200
+ # --- Groq AI Helper ---
201
+ async def call_groq_api(user_prompt: str, system_prompt: Optional[str] = None, model: str = DEFAULT_MODEL) -> str:
202
+ if not GROQ_API_KEY:
203
+ await asyncio.sleep(0.5)
204
+ return f"[Simulated AI Response for {model}] You asked: '{user_prompt[:100]}...'"
205
+
206
+ logging.info(f"Making Groq API call. Model: {model}, Endpoint: {GROQ_ENDPOINT}")
207
+ headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}
208
+
209
+ payload = {
210
+ "messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}],
211
+ "model": model
212
+ }
213
+
214
+ if model == "openai/gpt-oss-120b":
215
+ payload["tools"] = [{"type": "browser_search"}, {"type": "code_interpreter"}]
216
+
217
+ logging.info(f"Request Payload: {json.dumps(payload)}")
218
+
219
+ try:
220
+ async with httpx.AsyncClient(timeout=60.0) as client:
221
+ resp = await client.post(GROQ_ENDPOINT, headers=headers, json=payload)
222
+ logging.info(f"Groq API Response Status: {resp.status_code}")
223
+ resp.raise_for_status()
224
+ content = resp.json()["choices"][0]["message"]["content"]
225
+ # Strip out the <think>...</think> block before returning
226
+ cleaned_content = re.sub(r'<think>.*?</think>\s*', '', content, flags=re.DOTALL)
227
+ return cleaned_content.strip()
228
+ except httpx.HTTPStatusError as e:
229
+ logging.error(f"Groq API HTTP Status Error: {e.response.status_code} Body: {e.response.text}")
230
+ return f"[AI API Error] Status {e.response.status_code}"
231
+ except Exception as e:
232
+ logging.error(f"Groq API call failed: {e}", exc_info=True)
233
+ return f"[AI API Error] Could not connect: {e}"
234
+
235
+ # --- Command Handlers ---
236
+ def parse_command_args(args: List[str]) -> (Set[str], List[str]):
237
+ users, remaining_args = set(), []
238
+ for arg in args:
239
+ if arg.startswith('@'): users.add(arg[1:])
240
+ else: remaining_args.append(arg)
241
+ return users, remaining_args
242
+
243
+ async def handle_admin_command(admin_user: User, raw_cmd: str):
244
+ global LATEST_BROADCAST
245
+ parts = raw_cmd.strip().split()
246
+ cmd, args = parts[0].lower(), parts[1:]
247
+ changed_state = False
248
+
249
+ if cmd == "/login":
250
+ target_users, _ = parse_command_args(args)
251
+ if not target_users:
252
+ return await safe_send(admin_user.ws, {"type": "system", "text": "Usage: /login @username"})
253
+ for username in target_users:
254
+ TEMPORARY_WHITELIST.add(username.lower())
255
+ await safe_send(admin_user.ws, {"type": "system", "text": f"User '{username}' has been temporarily whitelisted to join."})
256
+ return
257
+
258
+ if cmd == "/clearall":
259
+ LATEST_BROADCAST = None
260
+ await broadcast({"type": "clear_chat"})
261
+ await broadcast({"type": "system", "text": f"Chat history cleared by {admin_user.username}."})
262
+ return
263
+
264
+ if cmd in ("/broadcast", "/b"):
265
+ message = " ".join(args)
266
+ if not message:
267
+ await safe_send(admin_user.ws, {"type": "system", "text": "Usage: /broadcast <message>"})
268
+ else:
269
+ broadcast_payload = {"type": "broadcast", "from": admin_user.username, "text": message, "ts": ts_iso()}
270
+ LATEST_BROADCAST = broadcast_payload
271
+ await broadcast(broadcast_payload)
272
+ return
273
+
274
+ if cmd == "/clearbroadcast":
275
+ LATEST_BROADCAST = None
276
+ await broadcast({"type": "clear_broadcast"})
277
+ return
278
+
279
+ if cmd == "/tag":
280
+ target_users, tag_parts = parse_command_args(args)
281
+ tag_arg = next((arg for arg in tag_parts if arg.startswith('--')), None)
282
+
283
+ if not target_users or not tag_arg:
284
+ return await safe_send(admin_user.ws, {"type": "system", "text": "Usage: /tag @user --tagname"})
285
+
286
+ tag = tag_arg[2:]
287
+ if not tag:
288
+ return await safe_send(admin_user.ws, {"type": "system", "text": "Tag name cannot be empty."})
289
+
290
+ for username in target_users:
291
+ user_tags[username.lower()] = tag
292
+ target_user_obj = await find_user_by_name(username)
293
+ role = target_user_obj.role if target_user_obj else "user"
294
+
295
+ await broadcast({"type": "system", "text": f"{admin_user.username} gave the tag '{tag}' to {username}."})
296
+ await broadcast({"type": "user_update", "user": {"name": username, "role": role, "tag": tag}})
297
+
298
+ if target_users:
299
+ await async_save_state()
300
+ return
301
+
302
+ if cmd == "/removetag":
303
+ target_users, _ = parse_command_args(args)
304
+ if not target_users:
305
+ return await safe_send(admin_user.ws, {"type": "system", "text": "Usage: /removetag @user"})
306
+
307
+ a_tag_was_removed = False
308
+ for username in target_users:
309
+ if username.lower() in user_tags:
310
+ del user_tags[username.lower()]
311
+ a_tag_was_removed = True
312
+ target_user_obj = await find_user_by_name(username)
313
+ role = target_user_obj.role if target_user_obj else "user"
314
+
315
+ await broadcast({"type": "system", "text": f"{admin_user.username} removed the tag from {username}."})
316
+ await broadcast({"type": "user_update", "user": {"name": username, "role": role, "tag": None}})
317
+
318
+ if a_tag_was_removed:
319
+ await async_save_state()
320
+ return
321
+
322
+ target_users, remaining_args = parse_command_args(args)
323
+ if not target_users:
324
+ if cmd in ("/kick", "/ban", "/unban", "/mute", "/unmute"):
325
+ return await safe_send(admin_user.ws, {"type": "system", "text": "You must specify at least one user with @mention."})
326
+ else:
327
+ return # Other commands might not need a user
328
+
329
+ duration_min = int(remaining_args.pop(0)) if remaining_args and remaining_args[0].isdigit() else None
330
+ reason = " ".join(remaining_args) or "No reason specified."
331
+
332
+ for username in target_users:
333
+ target_user = await find_user_by_name(username)
334
+ if cmd == "/kick" and target_user:
335
+ await safe_send(target_user.ws, {"type": "system", "text": f"Kicked by admin. Reason: {reason}"})
336
+ await target_user.ws.close(code=status.WS_1008_POLICY_VIOLATION)
337
+ await broadcast({"type": "system", "text": f"{username} was kicked by {admin_user.username}."})
338
+ elif cmd == "/ban":
339
+ changed_state = True
340
+ bans[username] = ts_iso(datetime.now(timezone.utc) + timedelta(minutes=duration_min)) if duration_min else None
341
+ d_str = f"for {duration_min} minutes" if duration_min else "permanently"
342
+ await broadcast({"type": "system", "text": f"{username} was banned {d_str} by {admin_user.username}."})
343
+ if target_user:
344
+ await safe_send(target_user.ws, {"type": "system", "text": f"You have been banned {d_str}."})
345
+ await target_user.ws.close(code=status.WS_1008_POLICY_VIOLATION)
346
+ elif cmd == "/unban":
347
+ user_to_unban = next((u for u in bans if u.lower() == username.lower()), None)
348
+ if user_to_unban:
349
+ del bans[user_to_unban]
350
+ changed_state = True
351
+ await broadcast({"type": "system", "text": f"{username} was unbanned by {admin_user.username}."})
352
+ elif cmd == "/mute":
353
+ changed_state = True
354
+ d_min = duration_min or 5
355
+ mutes[username] = ts_iso(datetime.now(timezone.utc) + timedelta(minutes=d_min))
356
+ await broadcast({"type": "system", "text": f"{username} was muted for {d_min} min by {admin_user.username}."})
357
+ if target_user: await send_to_user(username, {"type": "system", "text": f"You are muted for {d_min} min."})
358
+ elif cmd == "/unmute":
359
+ user_to_unmute = next((u for u in mutes if u.lower() == username.lower()), None)
360
+ if user_to_unmute:
361
+ del mutes[user_to_unmute]
362
+ changed_state = True
363
+ await broadcast({"type": "system", "text": f"{username} was unmuted by {admin_user.username}."})
364
+
365
+ if changed_state: await async_save_state()
366
+
367
+ async def handle_message(user: User, data: dict):
368
+ typ = data.get("type")
369
+
370
+ if typ == "pong":
371
+ user.last_pong_at = datetime.now(timezone.utc)
372
+ return
373
+
374
+ if typ in ("message", "pm"):
375
+ if mute_reason := await is_muted(user.username):
376
+ await safe_send(user.ws, {"type": "system", "text": mute_reason})
377
+ return
378
+
379
+ if typ == "message":
380
+ await broadcast({"type": "message", "id": data.get("id", str(uuid.uuid4())), "from": user.username, "text": data.get("text", ""), "ts": ts_iso()})
381
+ elif typ == "command":
382
+ if user.role == "admin": await handle_admin_command(user, data.get("raw", ""))
383
+ else: await safe_send(user.ws, {"type": "system", "text": "You do not have permission to use admin commands."})
384
+ elif typ == "nick":
385
+ new_nick = (data.get("toNick") or "").strip()
386
+ if new_nick and 1 <= len(new_nick) <= 32 and not await is_username_in_use(new_nick) and new_nick.lower() != ADMIN_USERNAME.lower():
387
+ old_nick = user.username
388
+ user.username = new_nick
389
+ users_list = []
390
+ async with connected_lock:
391
+ users_list = get_users_list()
392
+ await broadcast({"type": "system", "text": f"{old_nick} is now known as {new_nick}."})
393
+ await broadcast({"type": "users", "users": users_list})
394
+ else:
395
+ await safe_send(user.ws, {"type": "system", "text": "Invalid or taken nickname."})
396
+ elif typ == "pm":
397
+ recipients, text = data.get("to", []), data.get("text", "")
398
+ if recipients and text:
399
+ pm_payload = {**data, "from": user.username}
400
+ for r_name in recipients:
401
+ if not await send_to_user(r_name, pm_payload):
402
+ await safe_send(user.ws, {"type": "system", "text": f"User '{r_name}' not found."})
403
+ await safe_send(user.ws, pm_payload)
404
+ elif typ == "ai":
405
+ prompt_parts = data.get("text", "").split()
406
+ model_id = DEFAULT_MODEL
407
+ model_alias = "compound"
408
+ final_prompt_list = prompt_parts
409
+
410
+ if prompt_parts and prompt_parts[0].startswith('--'):
411
+ flag = prompt_parts[0][2:].lower()
412
+ if flag in MODEL_ALIASES:
413
+ model_alias = flag
414
+ model_id = MODEL_ALIASES[flag]
415
+ final_prompt_list = prompt_parts[1:]
416
+
417
+ prompt_text = " ".join(final_prompt_list)
418
+
419
+ if not prompt_text:
420
+ return await safe_send(user.ws, {"type": "system", "text": "Usage: /ai [--model_alias] <prompt>"})
421
+
422
+ await broadcast({"type": "system", "text": f"{user.username} is asking the AI ({model_alias})..."})
423
+ system_prompt = "You are a helpful and concise assistant integrated into a terminal-based chat application. Your answers should be brief and to the point. Use markdown for formatting if necessary."
424
+ response = await call_groq_api(prompt_text, system_prompt=system_prompt, model=model_id)
425
+ await broadcast({"type": "ai_resp", "id": str(uuid.uuid4()), "from": "AI", "text": response, "ts": ts_iso()})
426
+
427
+ @app.get("/stats")
428
+ async def get_stats(): return JSONResponse({"active_users": len(connected_users)})
429
+
430
+ @app.websocket("/ws")
431
+ async def websocket_endpoint(ws: WebSocket):
432
+ user: Optional[User] = None
433
+ await ws.accept()
434
+ try:
435
+ auth_msg = await asyncio.wait_for(ws.receive_json(), timeout=15.0)
436
+ if auth_msg.get("type") != "auth":
437
+ return await safe_send(ws, {"type": "auth_failed", "reason": "First message must be auth"})
438
+
439
+ req_name = (auth_msg.get("username") or "").strip()
440
+ req_name_lower = req_name.lower()
441
+
442
+ if not req_name or len(req_name) > 32:
443
+ return await safe_send(ws, {"type": "auth_failed", "reason": "Username must be 1-32 characters"})
444
+ if ban_reason := await is_banned(req_name):
445
+ return await safe_send(ws, {"type": "auth_failed", "reason": ban_reason})
446
+ if await is_username_in_use(req_name):
447
+ return await safe_send(ws, {"type": "auth_failed", "reason": "Username is already in use"})
448
+
449
+ is_whitelisted = any(u.lower() == req_name_lower for u in WHITELISTED_USERS)
450
+ is_temp_whitelisted = req_name_lower in TEMPORARY_WHITELIST
451
+ is_test_user = re.match(r'^test\d+$', req_name_lower) is not None
452
+ is_admin_user = req_name_lower == ADMIN_USERNAME.lower()
453
+
454
+ if not (is_whitelisted or is_temp_whitelisted or is_test_user or is_admin_user):
455
+ return await safe_send(ws, {"type": "auth_failed", "reason": "You are not authorized to join this server."})
456
+
457
+ role = "user"
458
+ if is_admin_user:
459
+ password = auth_msg.get("password")
460
+ if secrets.compare_digest(password or "", ADMIN_PASSWORD):
461
+ role = "admin"
462
+ else: return await safe_send(ws, {"type": "auth_failed", "reason": "Invalid admin credentials"})
463
+
464
+ user = User(websocket=ws, username=req_name, role=role)
465
+
466
+ if req_name_lower in TEMPORARY_WHITELIST:
467
+ TEMPORARY_WHITELIST.remove(req_name_lower)
468
+
469
+ users_list = []
470
+ async with connected_lock:
471
+ connected_users[user.session_id] = user
472
+ users_list = get_users_list()
473
+
474
+ await safe_send(ws, {"type": "auth_ok", "username": user.username, "role": user.role})
475
+ await broadcast({"type": "user_join", "user": {"name": user.username, "role": user.role, "tag": user_tags.get(user.username.lower())}}, exclude_ids={user.session_id})
476
+ await safe_send(ws, {"type": "users", "users": users_list})
477
+
478
+ if LATEST_BROADCAST:
479
+ await safe_send(ws, LATEST_BROADCAST)
480
+
481
+ welcome_system_prompt = f"You are a witty and welcoming chatbot for the '{APP_NAME}' chat server. Your task is to generate a very short, cool, and slightly mysterious welcome message for a new user named '{user.username}'. Keep it under 15 words. Be creative."
482
+ welcome_message = await call_groq_api(f"A user named {user.username} has joined.", system_prompt=welcome_system_prompt, model="llama-3.1-8b-instant")
483
+ await safe_send(ws, {"type": "system", "text": welcome_message})
484
+
485
+ while True:
486
+ data = await ws.receive_json()
487
+ await handle_message(user, data)
488
+ except (WebSocketDisconnect, asyncio.TimeoutError, json.JSONDecodeError): pass
489
+ finally:
490
+ if user:
491
+ async with connected_lock:
492
+ if user.session_id in connected_users: del connected_users[user.session_id]
493
+
494
+ user_list_payload = {"type": "users", "users": get_users_list()}
495
+ leave_payload = {"type": "system", "text": f"{user.username} has left the chat."}
496
+
497
+ await broadcast(user_list_payload)
498
+ await broadcast(leave_payload)
499
+
500
+ # --- Heartbeat Task ---
501
+ PING_INTERVAL = 30
502
+ PONG_TIMEOUT = 65
503
+
504
+ async def heartbeat_task():
505
+ while True:
506
+ await asyncio.sleep(PING_INTERVAL)
507
+ now = datetime.now(timezone.utc)
508
+
509
+ users_to_check = []
510
+ async with connected_lock:
511
+ users_to_check = list(connected_users.values())
512
+
513
+ for user in users_to_check:
514
+ if (now - user.last_pong_at).total_seconds() > PONG_TIMEOUT:
515
+ logging.warning(f"User {user.username} timed out due to no pong. Disconnecting.")
516
+ try:
517
+ await user.ws.close(code=status.WS_1008_POLICY_VIOLATION, reason="Connection timed out")
518
+ except RuntimeError:
519
+ pass # Already closed
520
+ else:
521
+ try:
522
+ await user.ws.send_text(json.dumps({"type": "ping"}))
523
+ except (WebSocketDisconnect, ConnectionError, RuntimeError):
524
+ pass
525
+
526
+
527
+ @app.on_event("startup")
528
+ async def startup_event():
529
+ load_state()
530
+ asyncio.create_task(state_cleanup_task())
531
+ asyncio.create_task(heartbeat_task())
532
+
533
+ @app.on_event("shutdown")
534
+ def shutdown_event(): save_state()
535
+ @app.get("/")
536
+ async def root(): return HTMLResponse("<h1>Akatsuki Server</h1>")
start.sh ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ #!/bin/sh
2
+ exec uvicorn server:app --host 0.0.0.0 --port ${PORT:-7860} --proxy-headers