add chat system

This commit is contained in:
KingKDot
2026-04-14 21:56:18 -05:00
parent 6f14bf35e2
commit eb6ec86773
20 changed files with 1211 additions and 5 deletions
+47
View File
@@ -61,6 +61,9 @@ export interface Config {
plugins: {
trustedKeys: string[];
};
chat: {
retentionDays: number;
};
}
const DEFAULT_CONFIG: Config = {
@@ -120,6 +123,9 @@ const DEFAULT_CONFIG: Config = {
plugins: {
trustedKeys: [],
},
chat: {
retentionDays: 30,
},
};
type SaveSecrets = {
@@ -447,6 +453,12 @@ export function loadConfig(): Config {
return fileConfig.plugins?.trustedKeys || DEFAULT_CONFIG.plugins.trustedKeys;
})(),
},
chat: {
retentionDays:
Number(process.env.OVERLORD_CHAT_RETENTION_DAYS) ||
fileConfig.chat?.retentionDays ||
DEFAULT_CONFIG.chat.retentionDays,
},
};
if (saveChanged) {
@@ -656,6 +668,35 @@ export async function updatePluginsConfig(
return next;
}
export async function updateChatConfig(
updates: Partial<Config["chat"]>,
): Promise<Config["chat"]> {
const current = getConfig();
const toNumberOr = (value: unknown, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const next: Config["chat"] = {
...current.chat,
...updates,
};
const raw = toNumberOr(next.retentionDays, 30);
next.retentionDays = raw === 0 ? 0 : Math.min(365, Math.max(1, raw));
configCache = {
...current,
chat: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.chat = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export function getExportableConfig(serverVersion: string): Record<string, unknown> {
const config = getConfig();
return {
@@ -673,6 +714,7 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
enrollment: config.enrollment,
appearance: config.appearance,
plugins: config.plugins,
chat: config.chat,
};
}
@@ -731,6 +773,11 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
applied.push("plugins");
}
if (data.chat && typeof data.chat === "object") {
await updateChatConfig(data.chat);
applied.push("chat");
}
if (data.auth && typeof data.auth === "object") {
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
+84
View File
@@ -293,6 +293,81 @@ db.run(
`CREATE INDEX IF NOT EXISTS idx_auto_deploy_runs_ts ON auto_deploy_runs(ts DESC);`,
);
db.run(`
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
user_role TEXT NOT NULL,
message TEXT NOT NULL,
created_at INTEGER NOT NULL
);
`);
db.run(
`CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at DESC);`,
);
export type ChatMessageRecord = {
id: number;
userId: number;
username: string;
userRole: string;
message: string;
createdAt: number;
};
export function insertChatMessage(userId: number, username: string, userRole: string, message: string): ChatMessageRecord {
const createdAt = Date.now();
const result = db.run(
`INSERT INTO chat_messages (user_id, username, user_role, message, created_at) VALUES (?, ?, ?, ?, ?)`,
userId, username, userRole, message, createdAt,
);
const id = Number((result as any).lastInsertRowid);
return { id, userId, username, userRole, message, createdAt };
}
export function getChatHistory(before?: number, limit: number = 50, retentionMs?: number): ChatMessageRecord[] {
const maxLimit = Math.min(Math.max(1, limit), 200);
const cutoff = retentionMs && retentionMs > 0 ? Date.now() - retentionMs : 0;
let rows: any[];
if (before) {
if (cutoff > 0) {
rows = db.query<any>(
`SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at < ? AND created_at > ? ORDER BY created_at DESC LIMIT ?`,
).all(before, cutoff, maxLimit);
} else {
rows = db.query<any>(
`SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at < ? ORDER BY created_at DESC LIMIT ?`,
).all(before, maxLimit);
}
} else {
if (cutoff > 0) {
rows = db.query<any>(
`SELECT id, user_id, username, user_role, message, created_at FROM chat_messages WHERE created_at > ? ORDER BY created_at DESC LIMIT ?`,
).all(cutoff, maxLimit);
} else {
rows = db.query<any>(
`SELECT id, user_id, username, user_role, message, created_at FROM chat_messages ORDER BY created_at DESC LIMIT ?`,
).all(maxLimit);
}
}
return rows.map((r: any) => ({
id: r.id,
userId: r.user_id,
username: r.username,
userRole: r.user_role,
message: r.message,
createdAt: r.created_at,
})).reverse();
}
export function deleteExpiredChatMessages(retentionMs: number): number {
if (retentionMs <= 0) return 0;
const cutoff = Date.now() - retentionMs;
const result = db.run(`DELETE FROM chat_messages WHERE created_at < ?`, cutoff);
return Number((result as any).changes ?? 0);
}
db.run(`
CREATE TABLE IF NOT EXISTS shared_files (
id TEXT PRIMARY KEY,
@@ -1164,6 +1239,15 @@ export function getClientMetricsSummaryForUser(userId: number): ClientMetricsSum
};
}
export function getOnlineClientCountForUser(userId: number): number {
const row = db
.query<{ online: number }>(
`SELECT SUM(CASE WHEN online=1 THEN 1 ELSE 0 END) as online FROM clients WHERE built_by_user_id = ?`,
)
.get(userId);
return Number(row?.online) || 0;
}
export function countBuildsForUser(userId: number): number {
const row = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM builds WHERE built_by_user_id = ?`)
+68 -2
View File
@@ -4,7 +4,7 @@ import { logger } from "./logger";
import { fileURLToPath } from "url";
import path from "path";
import fs from "fs/promises";
import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles } from "./db";
import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages } from "./db";
import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers";
import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
import { ClientInfo, ClientRole } from "./types";
@@ -12,13 +12,14 @@ import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "./auth";
import { loadConfig, getConfig } from "./config";
import { flushAuditLogsSync } from "./auditLog";
import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClient, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users";
import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClient, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope, hasPermission } from "./users";
import { requireAuth, requirePermission } from "./rbac";
import { metrics } from "./metrics";
import { ensureDataDir } from "./paths";
import { handleAuthRoutes } from "./server/routes/auth-routes";
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
import { handleEnrollmentRoutes, setPostApproveHook } from "./server/routes/enrollment-routes";
import { handleChatRoutes } from "./server/routes/chat-routes";
import { handleBuildRoutes } from "./server/routes/build-routes";
import { handleSolRoutes } from "./server/routes/sol-routes";
import { handleAssetsRoutes } from "./server/routes/assets-routes";
@@ -507,6 +508,54 @@ async function startServer() {
});
},
handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen,
handleChatViewerOpen: (ws: import("bun").ServerWebSocket<SocketData>) => {
const id = crypto.randomUUID();
ws.data.sessionId = id;
const userId = ws.data.userId;
const userRole = ws.data.userRole || "viewer";
const user = userId ? getUserById(userId) : null;
const username = user?.username || "Unknown";
sessionManager.addChatSession({
id,
viewer: ws,
createdAt: Date.now(),
userId: userId || 0,
username,
userRole,
});
const retDays = getConfig().chat?.retentionDays ?? 30;
const retMs = retDays > 0 ? retDays * 24 * 60 * 60 * 1000 : undefined;
const history = getChatHistory(undefined, 50, retMs);
const enrichedHistory = history.map((m) => ({
...m,
onlineClients: m.userId ? getOnlineClientCountForUser(m.userId) : 0,
}));
const canWrite = userId ? hasPermission(userRole as any, "chat:write", userId) : false;
ws.send(JSON.stringify({ type: "chat_ready", history: enrichedHistory, canWrite, userId: userId || 0 }));
},
handleChatViewerMessage: (ws: import("bun").ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => {
try {
const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw as ArrayBuffer);
const parsed = JSON.parse(text);
if (parsed.type !== "chat_send" || typeof parsed.message !== "string") return;
const userId = ws.data.userId;
const userRole = ws.data.userRole || "viewer";
if (!userId || !hasPermission(userRole as any, "chat:write", userId)) {
ws.send(JSON.stringify({ type: "chat_error", error: "Permission denied" }));
return;
}
const msg = parsed.message.trim();
if (!msg || msg.length > 2000) {
ws.send(JSON.stringify({ type: "chat_error", error: msg ? "Message too long (max 2000 chars)" : "Message is empty" }));
return;
}
const user = getUserById(userId);
const username = user?.username || "Unknown";
const record = insertChatMessage(userId, username, userRole, msg);
const onlineClients = getOnlineClientCountForUser(userId);
sessionManager.broadcastChatMessage(JSON.stringify({ type: "chat_message", ...record, onlineClients }));
} catch {}
},
handleConsoleViewerMessage,
handleRemoteDesktopViewerMessage,
handleWebcamViewerMessage,
@@ -586,6 +635,7 @@ async function startServer() {
handleAutoScriptsRoutes,
handleAutoDeployRoutes,
handleEnrollmentRoutes,
handleChatRoutes,
handleSolRoutes,
handleUsersRoutes,
handleBuildRoutes,
@@ -625,6 +675,22 @@ async function startServer() {
}
if (expiredPaths.length) logger.info(`[db] Cleaned up ${expiredPaths.length} expired shared files`);
const chatRetentionDays = getConfig().chat?.retentionDays ?? 30;
if (chatRetentionDays > 0) {
const retentionMs = chatRetentionDays * 24 * 60 * 60 * 1000;
const purged = deleteExpiredChatMessages(retentionMs);
if (purged > 0) logger.info(`[db] Cleaned up ${purged} expired chat messages (retention: ${chatRetentionDays}d)`);
}
setInterval(() => {
const days = getConfig().chat?.retentionDays ?? 30;
if (days > 0) {
const ms = days * 24 * 60 * 60 * 1000;
const count = deleteExpiredChatMessages(ms);
if (count > 0) logger.info(`[chat] Purged ${count} expired messages (retention: ${days}d)`);
}
}, 60 * 60 * 1000); // every hour
startMaintenanceLoops({
getClients: clientManager.getAllClients,
setOnlineState,
+4 -1
View File
@@ -7,7 +7,8 @@ export type Permission =
| "clients:view"
| "clients:build"
| "clients:enroll"
| "audit:view";
| "audit:view"
| "chat:write";
export function checkPermission(
user: AuthenticatedUser | null,
@@ -80,6 +81,8 @@ export function getPermissionDescription(permission: Permission): string {
return "Manage client enrollment approvals";
case "audit:view":
return "View audit logs";
case "chat:write":
return "Send messages in team chat";
default:
return "Unknown permission";
}
@@ -41,6 +41,7 @@ type HttpDispatchDeps<
handleAutoScriptsRoutes: RouteHandler;
handleAutoDeployRoutes: RouteHandlerWithDeps<TAutoDeploy>;
handleEnrollmentRoutes: RouteHandler;
handleChatRoutes: RouteHandler;
handleSolRoutes: RouteHandler;
handleUsersRoutes: RouteHandlerWithServer<TServer>;
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
@@ -128,6 +129,9 @@ export function createHttpFetchHandler<
const enrollmentResponse = await deps.handleEnrollmentRoutes(req, url);
if (enrollmentResponse) return enrollmentResponse;
const chatResponse = await deps.handleChatRoutes(req, url);
if (chatResponse) return chatResponse;
const solResponse = await deps.handleSolRoutes(req, url);
if (solResponse) return solResponse;
@@ -0,0 +1,43 @@
import { authenticateRequest } from "../../auth";
import { getConfig } from "../../config";
import { getChatHistory, getOnlineClientCountForUser } from "../../db";
import { hasPermission } from "../../users";
export async function handleChatRoutes(
req: Request,
url: URL,
): Promise<Response | null> {
if (req.method === "GET" && url.pathname === "/api/chat/history") {
const user = await authenticateRequest(req);
if (!user) {
return new Response(JSON.stringify({ error: "Not authenticated" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const beforeParam = url.searchParams.get("before");
const limitParam = url.searchParams.get("limit");
const before = beforeParam ? Number(beforeParam) : undefined;
const limit = limitParam ? Number(limitParam) : 50;
if (before !== undefined && (isNaN(before) || before <= 0)) {
return Response.json({ error: "Invalid 'before' parameter" }, { status: 400 });
}
if (isNaN(limit) || limit < 1 || limit > 200) {
return Response.json({ error: "Invalid 'limit' parameter (1-200)" }, { status: 400 });
}
const retDays = getConfig().chat?.retentionDays ?? 30;
const retMs = retDays > 0 ? retDays * 24 * 60 * 60 * 1000 : undefined;
const messages = getChatHistory(before, limit, retMs);
const enriched = messages.map((m) => ({
...m,
onlineClients: m.userId ? getOnlineClientCountForUser(m.userId) : 0,
}));
const canWrite = hasPermission(user.role, "chat:write", user.userId);
return Response.json({ messages: enriched, canWrite });
}
return null;
}
@@ -850,6 +850,44 @@ export async function handleClientRoutes(
}
}
if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-group") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
try { body = await req.json(); } catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const clientIds = body?.clientIds;
if (!Array.isArray(clientIds) || clientIds.length === 0 || clientIds.some((id: any) => typeof id !== "string")) {
return Response.json({ error: "clientIds must be a non-empty array of strings" }, { status: 400 });
}
if (clientIds.length > 500) {
return Response.json({ error: "Too many clients (max 500)" }, { status: 400 });
}
const groupId = body?.groupId === null ? null : Number(body?.groupId);
if (groupId !== null && (isNaN(groupId) || groupId < 1)) {
return Response.json({ error: "Invalid groupId" }, { status: 400 });
}
let updated = 0;
for (const cid of clientIds) {
if (!canUserAccessClient(user.userId, user.role, cid)) continue;
if (setClientGroup(cid, groupId)) updated++;
}
notifyDashboardViewers();
const ip = server.requestIP(req)?.address || "unknown";
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `bulk_set_group:${groupId ?? "none"}:${updated}/${clientIds.length}`, success: true });
return Response.json({ ok: true, updated, total: clientIds.length, groupId }, { headers: deps.CORS_HEADERS });
}
const clientGroupMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/group$/);
if (req.method === "PATCH" && clientGroupMatch) {
const user = await authenticateRequest(req);
@@ -1,6 +1,6 @@
import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, getExportableConfig, importFullConfig } from "../../config";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig } from "../../config";
import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
@@ -493,6 +493,45 @@ export async function handleMiscRoutes(
}
}
if (url.pathname === "/api/settings/chat") {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
}
if (req.method === "GET") {
return Response.json({ chat: getConfig().chat }, { headers: deps.CORS_HEADERS });
}
if (req.method === "PUT") {
let body: any = {};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const updated = await updateChatConfig({
retentionDays: Number(body?.retentionDays),
});
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Updated chat settings: retention=${updated.retentionDays} days`,
success: true,
});
return Response.json({ ok: true, chat: updated }, { headers: deps.CORS_HEADERS });
}
}
if (url.pathname === "/api/settings/appearance") {
const user = await authenticateRequest(req);
if (!user) {
@@ -45,6 +45,8 @@ type WsLifecycleDeps = {
handleKeyloggerViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleVoiceViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleChatViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleChatViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleConsoleViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleRemoteDesktopViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
handleWebcamViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
@@ -151,6 +153,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
if (role === "keylogger_viewer") return deps.handleKeyloggerViewerOpen(ws);
if (role === "voice_viewer") return deps.handleVoiceViewerOpen(ws);
if (role === "notifications_viewer") return deps.handleNotificationViewerOpen(ws);
if (role === "chat_viewer") return deps.handleChatViewerOpen(ws);
const id = clientId || uuidv4();
ws.data.clientId = id;
@@ -202,6 +205,7 @@ export async function handleWebSocketMessage(
if (socketRole === "voice_viewer") return deps.handleVoiceViewerMessage(ws, message);
if (socketRole === "notifications_viewer") return;
if (socketRole === "dashboard_viewer") return;
if (socketRole === "chat_viewer") return deps.handleChatViewerMessage(ws, message);
const { clientId, ip } = ws.data;
@@ -740,6 +744,13 @@ export function handleWebSocketClose(
return;
}
if (role === "chat_viewer") {
if (sessionId) {
sessionManager.deleteChatSession(sessionId);
}
return;
}
const currentClient = clientManager.getClient(clientId);
if (currentClient && currentClient.ws !== ws) {
logger.info(`[close] ${clientId} code=${code} (superseded socket, skipping cleanup)`);
@@ -271,5 +271,17 @@ export async function handleWsUpgradeRoutes(
return new Response("Upgrade failed", { status: 500 });
}
if (req.method === "GET" && url.pathname === "/api/chat/ws") {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const ip = server.requestIP(req)?.address || "";
if (server.upgrade(req, { data: { role: "chat_viewer", clientId: "", ip, userRole: user.role, userId: user.userId } })) {
return new Response();
}
return new Response("Upgrade failed", { status: 500 });
}
return null;
}
@@ -7,6 +7,7 @@ import type {
VoiceViewer,
NotificationsViewer,
KeyloggerViewer,
ChatViewer,
SocketData,
} from "./types";
@@ -36,6 +37,7 @@ const keyloggerSessionsByClient = new Map<string, Set<string>>();
const voiceSessions = new Map<string, VoiceViewer>();
const voiceSessionsByClient = new Map<string, Set<string>>();
const dashboardSessions = new Map<string, DashboardViewer>();
const chatSessions = new Map<string, ChatViewer>();
function addSessionToClientIndex(
index: Map<string, Set<string>>,
@@ -495,3 +497,29 @@ export function notifyDashboardViewers(): void {
}
}, DASHBOARD_DEBOUNCE_MS);
}
export function addChatSession(session: ChatViewer): void {
chatSessions.set(session.id, session);
}
export function deleteChatSession(sessionId: string): boolean {
return chatSessions.delete(sessionId);
}
export function getAllChatSessions(): Map<string, ChatViewer> {
return chatSessions;
}
export function getChatSessionCount(): number {
return chatSessions.size;
}
export function broadcastChatMessage(msg: string): void {
for (const [id, session] of chatSessions) {
try {
session.viewer.send(msg);
} catch {
chatSessions.delete(id);
}
}
}
+11 -1
View File
@@ -11,7 +11,8 @@ export type SocketRole =
| "notifications_viewer"
| "keylogger_viewer"
| "voice_viewer"
| "dashboard_viewer";
| "dashboard_viewer"
| "chat_viewer";
export type SocketData = {
role: SocketRole;
@@ -77,3 +78,12 @@ export type VoiceViewer = {
viewer: ServerWebSocket<SocketData>;
createdAt: number;
};
export type ChatViewer = {
id: string;
viewer: ServerWebSocket<SocketData>;
createdAt: number;
userId: number;
username: string;
userRole: string;
};
+36
View File
@@ -202,6 +202,15 @@ for (const col of clientEventColumns) {
}
}
try {
db.exec(`ALTER TABLE users ADD COLUMN chat_write INTEGER DEFAULT NULL`);
logger.info("[users] Added chat_write column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as {
count: number;
};
@@ -747,6 +756,9 @@ export function hasPermission(role: UserRole, permission: string, userId?: numbe
return canManageEnrollment(role);
case "audit:view":
return canViewAuditLogs(role);
case "chat:write":
if (userId !== undefined) return canChatWrite(userId, role);
return role === "admin" || role === "operator";
default:
return false;
}
@@ -784,6 +796,30 @@ export function setUserCanUploadFiles(
}
}
export function canChatWrite(userId: number, role: UserRole): boolean {
if (role === "admin") return true;
const user = getUserById(userId);
if (!user) return false;
const chatWrite = (user as any).chat_write;
if (chatWrite === null || chatWrite === undefined) {
return role === "operator";
}
return chatWrite === 1;
}
export function setUserChatWrite(
userId: number,
canWrite: boolean | null,
): { success: boolean; error?: string } {
try {
db.prepare("UPDATE users SET chat_write = ? WHERE id = ?").run(canWrite === null ? null : (canWrite ? 1 : 0), userId);
return { success: true };
} catch (err: any) {
logger.error("[users] setUserChatWrite error:", err);
return { success: false, error: err.message || "Failed to update chat write permission" };
}
}
export function setUserTelegramChatId(
userId: number,
chatId: string | null,