openclaw/extensions/checkins/src/storage.ts

668 lines
23 KiB
TypeScript

/**
* SQLite storage module for the check-ins extension.
* Uses Node 22+ built-in node:sqlite for persistence.
*/
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type {
Checkin,
CheckinsConfig,
ConversationState,
Member,
MemberSchedule,
Team,
} from "./types.js";
// Re-export types for consumers
export type { Checkin, ConversationState, Member, MemberSchedule, Team };
/**
* Storage interface for check-ins data.
* All methods are synchronous (using DatabaseSync).
*/
export interface CheckinsStorage {
/** Initialize the database (create tables, open connection) */
init(): void;
/** Close the database connection */
close(): void;
// ─────────────────────────────────────────────────────────────────────────
// Teams
// ─────────────────────────────────────────────────────────────────────────
/** Create a new team */
createTeam(params: {
serverId: string;
name: string;
channelId?: string;
discordAccountId?: string;
}): Team;
/** Get a team by ID */
getTeam(id: string): Team | null;
/** Get a team by name within a server */
getTeamByName(serverId: string, name: string): Team | null;
/** List all teams in a server */
listTeams(serverId: string): Team[];
/** Update a team's properties */
updateTeam(
id: string,
updates: Partial<Pick<Team, "name" | "channelId" | "discordAccountId">>,
): boolean;
/** Delete a team (cascades to members and checkins) */
deleteTeam(id: string): boolean;
/** List all teams across all servers (for scheduler bootstrap and cross-team member lookup) */
getAllTeams(): Team[];
// ─────────────────────────────────────────────────────────────────────────
// Members
// ─────────────────────────────────────────────────────────────────────────
/** Add a member to a team */
addMember(params: {
teamId: string;
discordUserId: string;
displayName?: string;
schedule?: Partial<MemberSchedule>;
}): Member;
/** Get a member by ID */
getMember(id: string): Member | null;
/** Get a member by Discord user ID within a team */
getMemberByDiscordId(teamId: string, discordUserId: string): Member | null;
/** List all members of a team */
listMembers(teamId: string): Member[];
/** Update a member's properties */
updateMember(
id: string,
updates: Partial<Pick<Member, "displayName" | "schedule" | "vacationUntil">>,
): boolean;
/** Remove a member from a team */
removeMember(id: string): boolean;
// ─────────────────────────────────────────────────────────────────────────
// Check-ins
// ─────────────────────────────────────────────────────────────────────────
/** Save a completed check-in */
saveCheckin(params: {
memberId: string;
teamId: string;
yesterday: string;
today: string;
blockers?: string;
}): Checkin;
/** Get a check-in by ID */
getCheckin(id: string): Checkin | null;
/** List check-ins with optional filters */
listCheckins(params: {
teamId?: string;
memberId?: string;
since?: number;
until?: number;
}): Checkin[];
/** Mark a check-in as posted to the channel */
markPosted(checkinId: string): boolean;
// ─────────────────────────────────────────────────────────────────────────
// Conversation State
// ─────────────────────────────────────────────────────────────────────────
/** Get active conversation state for a member */
getConversationState(memberId: string): ConversationState | null;
/** Save/update conversation state */
saveConversationState(state: ConversationState): void;
/** Delete conversation state (on completion or timeout) */
deleteConversationState(memberId: string): boolean;
/** List all active conversations (for scheduler/timeout checks) */
listActiveConversations(): ConversationState[];
}
// ─────────────────────────────────────────────────────────────────────────────
// Schema Definition
// ─────────────────────────────────────────────────────────────────────────────
const SCHEMA_SQL = `
-- Enable foreign keys
PRAGMA foreign_keys = ON;
-- Teams table
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
name TEXT NOT NULL,
channel_id TEXT,
discord_account_id TEXT,
created_at INTEGER NOT NULL,
UNIQUE(server_id, name)
);
CREATE INDEX IF NOT EXISTS idx_teams_server ON teams(server_id);
-- Members table
CREATE TABLE IF NOT EXISTS members (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
discord_user_id TEXT NOT NULL,
display_name TEXT,
check_in_time TEXT NOT NULL DEFAULT '17:00',
timezone TEXT NOT NULL DEFAULT 'America/New_York',
skip_weekends INTEGER NOT NULL DEFAULT 1,
vacation_until INTEGER,
created_at INTEGER NOT NULL,
UNIQUE(team_id, discord_user_id)
);
CREATE INDEX IF NOT EXISTS idx_members_team ON members(team_id);
CREATE INDEX IF NOT EXISTS idx_members_discord ON members(discord_user_id);
-- Checkins table
CREATE TABLE IF NOT EXISTS checkins (
id TEXT PRIMARY KEY,
member_id TEXT NOT NULL REFERENCES members(id) ON DELETE CASCADE,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
yesterday TEXT NOT NULL,
today TEXT NOT NULL,
blockers TEXT,
submitted_at INTEGER NOT NULL,
posted_to_channel INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_checkins_member ON checkins(member_id);
CREATE INDEX IF NOT EXISTS idx_checkins_team ON checkins(team_id);
CREATE INDEX IF NOT EXISTS idx_checkins_submitted ON checkins(submitted_at);
-- Conversation state table (for active check-in flows)
CREATE TABLE IF NOT EXISTS conversation_state (
member_id TEXT PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
current_question INTEGER NOT NULL,
answer_yesterday TEXT,
answer_today TEXT,
answer_blockers TEXT,
started_at INTEGER NOT NULL,
last_activity_at INTEGER NOT NULL,
reminder_sent INTEGER NOT NULL DEFAULT 0
);
`;
// ─────────────────────────────────────────────────────────────────────────────
// Row Types (raw database rows)
// ─────────────────────────────────────────────────────────────────────────────
type TeamRow = {
id: string;
server_id: string;
name: string;
channel_id: string | null;
discord_account_id: string | null;
created_at: number;
};
type MemberRow = {
id: string;
team_id: string;
discord_user_id: string;
display_name: string | null;
check_in_time: string;
timezone: string;
skip_weekends: number;
vacation_until: number | null;
created_at: number;
};
type CheckinRow = {
id: string;
member_id: string;
team_id: string;
yesterday: string;
today: string;
blockers: string | null;
submitted_at: number;
posted_to_channel: number;
};
type ConversationStateRow = {
member_id: string;
team_id: string;
current_question: number;
answer_yesterday: string | null;
answer_today: string | null;
answer_blockers: string | null;
started_at: number;
last_activity_at: number;
reminder_sent: number;
};
// ─────────────────────────────────────────────────────────────────────────────
// Row to Entity Conversions
// ─────────────────────────────────────────────────────────────────────────────
function rowToTeam(row: TeamRow): Team {
return {
id: row.id,
serverId: row.server_id,
name: row.name,
channelId: row.channel_id,
discordAccountId: row.discord_account_id,
createdAt: row.created_at,
};
}
function rowToMember(row: MemberRow): Member {
return {
id: row.id,
teamId: row.team_id,
discordUserId: row.discord_user_id,
displayName: row.display_name,
schedule: {
checkInTime: row.check_in_time,
timezone: row.timezone,
skipWeekends: row.skip_weekends === 1,
},
vacationUntil: row.vacation_until,
createdAt: row.created_at,
};
}
function rowToCheckin(row: CheckinRow): Checkin {
return {
id: row.id,
memberId: row.member_id,
teamId: row.team_id,
yesterday: row.yesterday,
today: row.today,
blockers: row.blockers,
submittedAt: row.submitted_at,
postedToChannel: row.posted_to_channel === 1,
};
}
function rowToConversationState(row: ConversationStateRow): ConversationState {
return {
memberId: row.member_id,
teamId: row.team_id,
currentQuestion: row.current_question as 1 | 2 | 3,
answers: {
yesterday: row.answer_yesterday ?? undefined,
today: row.answer_today ?? undefined,
blockers: row.answer_blockers ?? undefined,
},
startedAt: row.started_at,
lastActivityAt: row.last_activity_at,
reminderSent: row.reminder_sent === 1,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Storage Implementation
// ─────────────────────────────────────────────────────────────────────────────
/**
* Create a storage instance for the check-ins extension.
*
* @param dbPath - Path to the SQLite database file
* @param config - Optional plugin configuration for defaults
* @returns CheckinsStorage instance
*/
export function createStorage(dbPath: string, config?: CheckinsConfig): CheckinsStorage {
// Lazy-load node:sqlite to avoid issues on Node < 22
type DatabaseSync = import("node:sqlite").DatabaseSync;
let db: DatabaseSync | null = null;
// Default schedule values from config or hardcoded defaults
const defaultTimezone = config?.defaultTimezone ?? "America/New_York";
const defaultCheckInTime = config?.defaultCheckInTime ?? "17:00";
function getDb(): DatabaseSync {
if (!db) {
throw new Error("Database not initialized. Call init() first.");
}
return db;
}
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
return {
init() {
if (db) return; // Already initialized
// Ensure parent directory exists
ensureDir(path.dirname(dbPath));
// Dynamically require node:sqlite (Node 22+)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite");
db = new DatabaseSync(dbPath);
// Enable foreign keys and create schema
db.exec("PRAGMA foreign_keys = ON;");
// Execute schema (CREATE IF NOT EXISTS is safe to run multiple times)
for (const statement of SCHEMA_SQL.split(";").filter((s) => s.trim())) {
db.exec(statement + ";");
}
},
close() {
if (db) {
db.close();
db = null;
}
},
// ─────────────────────────────────────────────────────────────────────────
// Teams
// ─────────────────────────────────────────────────────────────────────────
createTeam({ serverId, name, channelId, discordAccountId }) {
const d = getDb();
const id = randomUUID();
const now = Date.now();
const stmt = d.prepare(`
INSERT INTO teams (id, server_id, name, channel_id, discord_account_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
stmt.run(id, serverId, name, channelId ?? null, discordAccountId ?? null, now);
return {
id,
serverId,
name,
channelId: channelId ?? null,
discordAccountId: discordAccountId ?? null,
createdAt: now,
};
},
getTeam(id) {
const d = getDb();
const stmt = d.prepare("SELECT * FROM teams WHERE id = ?");
const row = stmt.get(id) as TeamRow | undefined;
return row ? rowToTeam(row) : null;
},
getTeamByName(serverId, name) {
const d = getDb();
const stmt = d.prepare("SELECT * FROM teams WHERE server_id = ? AND name = ?");
const row = stmt.get(serverId, name) as TeamRow | undefined;
return row ? rowToTeam(row) : null;
},
listTeams(serverId) {
const d = getDb();
const stmt = d.prepare("SELECT * FROM teams WHERE server_id = ? ORDER BY name");
const rows = stmt.all(serverId) as TeamRow[];
return rows.map(rowToTeam);
},
updateTeam(id, updates) {
const d = getDb();
const sets: string[] = [];
const params: unknown[] = [];
if (updates.name !== undefined) {
sets.push("name = ?");
params.push(updates.name);
}
if (updates.channelId !== undefined) {
sets.push("channel_id = ?");
params.push(updates.channelId);
}
if (updates.discordAccountId !== undefined) {
sets.push("discord_account_id = ?");
params.push(updates.discordAccountId);
}
if (sets.length === 0) return false;
params.push(id);
const stmt = d.prepare(`UPDATE teams SET ${sets.join(", ")} WHERE id = ?`);
const result = stmt.run(...params);
return result.changes > 0;
},
deleteTeam(id) {
const d = getDb();
const stmt = d.prepare("DELETE FROM teams WHERE id = ?");
const result = stmt.run(id);
return result.changes > 0;
},
getAllTeams() {
const d = getDb();
const stmt = d.prepare("SELECT * FROM teams ORDER BY name");
const rows = stmt.all() as TeamRow[];
return rows.map(rowToTeam);
},
// ─────────────────────────────────────────────────────────────────────────
// Members
// ─────────────────────────────────────────────────────────────────────────
addMember({ teamId, discordUserId, displayName, schedule }) {
const d = getDb();
const id = randomUUID();
const now = Date.now();
const checkInTime = schedule?.checkInTime ?? defaultCheckInTime;
const timezone = schedule?.timezone ?? defaultTimezone;
const skipWeekends = schedule?.skipWeekends ?? true;
const stmt = d.prepare(`
INSERT INTO members (id, team_id, discord_user_id, display_name, check_in_time, timezone, skip_weekends, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(id, teamId, discordUserId, displayName ?? null, checkInTime, timezone, skipWeekends ? 1 : 0, now);
return {
id,
teamId,
discordUserId,
displayName: displayName ?? null,
schedule: { checkInTime, timezone, skipWeekends },
vacationUntil: null,
createdAt: now,
};
},
getMember(id) {
const d = getDb();
const stmt = d.prepare("SELECT * FROM members WHERE id = ?");
const row = stmt.get(id) as MemberRow | undefined;
return row ? rowToMember(row) : null;
},
getMemberByDiscordId(teamId, discordUserId) {
const d = getDb();
const stmt = d.prepare("SELECT * FROM members WHERE team_id = ? AND discord_user_id = ?");
const row = stmt.get(teamId, discordUserId) as MemberRow | undefined;
return row ? rowToMember(row) : null;
},
listMembers(teamId) {
const d = getDb();
const stmt = d.prepare("SELECT * FROM members WHERE team_id = ? ORDER BY display_name, discord_user_id");
const rows = stmt.all(teamId) as MemberRow[];
return rows.map(rowToMember);
},
updateMember(id, updates) {
const d = getDb();
const sets: string[] = [];
const params: unknown[] = [];
if (updates.displayName !== undefined) {
sets.push("display_name = ?");
params.push(updates.displayName);
}
if (updates.vacationUntil !== undefined) {
sets.push("vacation_until = ?");
params.push(updates.vacationUntil);
}
if (updates.schedule !== undefined) {
if (updates.schedule.checkInTime !== undefined) {
sets.push("check_in_time = ?");
params.push(updates.schedule.checkInTime);
}
if (updates.schedule.timezone !== undefined) {
sets.push("timezone = ?");
params.push(updates.schedule.timezone);
}
if (updates.schedule.skipWeekends !== undefined) {
sets.push("skip_weekends = ?");
params.push(updates.schedule.skipWeekends ? 1 : 0);
}
}
if (sets.length === 0) return false;
params.push(id);
const stmt = d.prepare(`UPDATE members SET ${sets.join(", ")} WHERE id = ?`);
const result = stmt.run(...params);
return result.changes > 0;
},
removeMember(id) {
const d = getDb();
const stmt = d.prepare("DELETE FROM members WHERE id = ?");
const result = stmt.run(id);
return result.changes > 0;
},
// ─────────────────────────────────────────────────────────────────────────
// Check-ins
// ─────────────────────────────────────────────────────────────────────────
saveCheckin({ memberId, teamId, yesterday, today, blockers }) {
const d = getDb();
const id = randomUUID();
const now = Date.now();
const stmt = d.prepare(`
INSERT INTO checkins (id, member_id, team_id, yesterday, today, blockers, submitted_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(id, memberId, teamId, yesterday, today, blockers ?? null, now);
return {
id,
memberId,
teamId,
yesterday,
today,
blockers: blockers ?? null,
submittedAt: now,
postedToChannel: false,
};
},
getCheckin(id) {
const d = getDb();
const stmt = d.prepare("SELECT * FROM checkins WHERE id = ?");
const row = stmt.get(id) as CheckinRow | undefined;
return row ? rowToCheckin(row) : null;
},
listCheckins({ teamId, memberId, since, until }) {
const d = getDb();
const conditions: string[] = [];
const params: unknown[] = [];
if (teamId !== undefined) {
conditions.push("team_id = ?");
params.push(teamId);
}
if (memberId !== undefined) {
conditions.push("member_id = ?");
params.push(memberId);
}
if (since !== undefined) {
conditions.push("submitted_at >= ?");
params.push(since);
}
if (until !== undefined) {
conditions.push("submitted_at <= ?");
params.push(until);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const stmt = d.prepare(`SELECT * FROM checkins ${whereClause} ORDER BY submitted_at DESC`);
const rows = stmt.all(...params) as CheckinRow[];
return rows.map(rowToCheckin);
},
markPosted(checkinId) {
const d = getDb();
const stmt = d.prepare("UPDATE checkins SET posted_to_channel = 1 WHERE id = ?");
const result = stmt.run(checkinId);
return result.changes > 0;
},
// ─────────────────────────────────────────────────────────────────────────
// Conversation State
// ─────────────────────────────────────────────────────────────────────────
getConversationState(memberId) {
const d = getDb();
const stmt = d.prepare("SELECT * FROM conversation_state WHERE member_id = ?");
const row = stmt.get(memberId) as ConversationStateRow | undefined;
return row ? rowToConversationState(row) : null;
},
saveConversationState(state) {
const d = getDb();
const stmt = d.prepare(`
INSERT OR REPLACE INTO conversation_state
(member_id, team_id, current_question, answer_yesterday, answer_today, answer_blockers, started_at, last_activity_at, reminder_sent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
state.memberId,
state.teamId,
state.currentQuestion,
state.answers.yesterday ?? null,
state.answers.today ?? null,
state.answers.blockers ?? null,
state.startedAt,
state.lastActivityAt,
state.reminderSent ? 1 : 0,
);
},
deleteConversationState(memberId) {
const d = getDb();
const stmt = d.prepare("DELETE FROM conversation_state WHERE member_id = ?");
const result = stmt.run(memberId);
return result.changes > 0;
},
listActiveConversations() {
const d = getDb();
const stmt = d.prepare("SELECT * FROM conversation_state ORDER BY last_activity_at");
const rows = stmt.all() as ConversationStateRow[];
return rows.map(rowToConversationState);
},
};
}