Build reusable bot framework
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled

This commit is contained in:
Chelsea Lee
2026-07-19 21:53:24 -05:00
parent 7ecc1107b2
commit fbdf33e894
66 changed files with 8428 additions and 0 deletions

205
core/users.py Normal file
View File

@@ -0,0 +1,205 @@
import os
import uuid
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import bcrypt
from psycopg2.errors import UniqueViolation
import core.postgres as postgres
MIN_PASSWORD_LENGTH = 8
MAX_PASSWORD_BYTES = 72
REGISTRATION_FIELDS = {"timezone"}
USER_UPDATE_FIELDS = {"timezone"}
def getUser(userUUID):
return postgres.select_one("users", {"id": userUUID})
def getUserUUID(username):
if not username:
return False
userRecord = postgres.select_one("users", {"username": username})
if userRecord:
return userRecord["id"]
return False
def getUserFirstName(userUUID):
userRecord = getUser(userUUID)
if userRecord:
return userRecord.get("username")
return None
def getUserTimezone(userUUID):
userRecord = getUser(userUUID)
if userRecord:
return userRecord.get("timezone") or "UTC"
return None
def isUsernameAvailable(username):
if not username:
return False
return not postgres.exists("users", {"username": username})
def doesUserUUIDExist(userUUID):
if not userUUID:
return False
return postgres.exists("users", {"id": userUUID})
def isValidTimezone(timezoneName):
if not isinstance(timezoneName, str) or not timezoneName.strip():
return False
try:
ZoneInfo(timezoneName.strip())
return True
except (ZoneInfoNotFoundError, ValueError):
return False
def normalizeTimezone(timezoneName):
timezoneName = timezoneName.strip() if isinstance(timezoneName, str) else ""
if not isValidTimezone(timezoneName):
raise ValueError("timezone must be a valid IANA timezone name")
return timezoneName
def getDefaultTimezone():
return normalizeTimezone(os.getenv("DEFAULT_TIMEZONE", "UTC"))
def validatePassword(password):
errors = []
if not isinstance(password, str) or not password:
return False, ["password"]
encodedPassword = password.encode("utf-8")
if len(password) < MIN_PASSWORD_LENGTH:
errors.append(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
if len(encodedPassword) > MAX_PASSWORD_BYTES:
errors.append(f"password must be at most {MAX_PASSWORD_BYTES} UTF-8 bytes")
return not errors, errors
def registerUser(username, password, data=None):
if not isinstance(username, str) or not username.strip():
raise ValueError("username is required")
username = username.strip()
if len(username) > 255:
raise ValueError("username must be at most 255 characters")
isValid, errors = validatePassword(password)
if not isValid:
raise ValueError(f"Invalid password: {', '.join(errors)}")
if not isUsernameAvailable(username):
return False
userData = {
"id": str(uuid.uuid4()),
"username": username,
"password_hashed": bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()),
}
if data is not None:
if not isinstance(data, dict):
raise ValueError("registration data must be an object")
for key in REGISTRATION_FIELDS:
if key in data:
userData[key] = data[key]
try:
createUser(userData)
except UniqueViolation:
return False
return True
def updateUser(userUUID, data_dict):
if not getUser(userUUID) or not isinstance(data_dict, dict):
return False
updates = {key: data_dict[key] for key in USER_UPDATE_FIELDS if key in data_dict}
if "timezone" in updates:
updates["timezone"] = normalizeTimezone(updates["timezone"])
if not updates:
return False
postgres.update("users", updates, {"id": userUUID})
return True
def changePassword(userUUID, new_password):
if not getUser(userUUID):
return False
isValid, errors = validatePassword(new_password)
if not isValid:
raise ValueError(f"Invalid password: {', '.join(errors)}")
hashedPassword = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt())
postgres.update("users", {"password_hashed": hashedPassword}, {"id": userUUID})
return True
def deleteUser(userUUID):
if not getUser(userUUID):
return False
postgres.delete("users", {"id": userUUID})
return True
def createUser(data_dict):
if not isinstance(data_dict, dict):
raise ValueError("user data must be an object")
allowedFields = {"id", "username", "password_hashed", "timezone", "created_at"}
userData = {key: value for key, value in data_dict.items() if key in allowedFields}
if "timezone" not in userData:
userData["timezone"] = getDefaultTimezone()
isValid, errors = validateUser(userData)
if not isValid:
raise ValueError(f"Invalid user data: {', '.join(errors)}")
return postgres.insert("users", userData)
def validateUser(user):
errors = []
if not isinstance(user, dict):
return False, ["user"]
userUUID = user.get("id")
if not userUUID:
errors.append("id")
else:
try:
uuid.UUID(str(userUUID))
except (TypeError, ValueError, AttributeError):
errors.append("id must be a valid UUID")
username = user.get("username")
passwordHash = user.get("password_hashed")
if username is None and passwordHash is not None:
errors.append("username")
elif username is not None and passwordHash is None:
errors.append("password_hashed")
elif username is not None:
if not isinstance(username, str) or not username.strip():
errors.append("username")
elif len(username) > 255:
errors.append("username must be at most 255 characters")
if isinstance(passwordHash, memoryview):
passwordHash = bytes(passwordHash)
if not isinstance(passwordHash, bytes) or not passwordHash:
errors.append("password_hashed")
timezoneName = user.get("timezone", "UTC")
if not isValidTimezone(timezoneName):
errors.append("timezone must be a valid IANA timezone name")
return not errors, errors