From 4ee0df1da1cfe4d865016be00dd933a8769825b6 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Mon, 26 Jan 2026 19:29:48 +1100 Subject: [PATCH] fix: restore checkins extension lost during fork sync --- extensions/checkins/README.md | 171 +++++ extensions/checkins/clawdbot.plugin.json | 53 ++ extensions/checkins/index.ts | 295 ++++++++ extensions/checkins/package.json | 24 + extensions/checkins/src/cleanup.ts | 84 +++ extensions/checkins/src/conversation.ts | 105 +++ extensions/checkins/src/dm-handler.ts | 315 +++++++++ extensions/checkins/src/formatter.ts | 38 + extensions/checkins/src/permissions.ts | 45 ++ extensions/checkins/src/questions.ts | 62 ++ extensions/checkins/src/scheduler.ts | 81 +++ extensions/checkins/src/storage.ts | 667 ++++++++++++++++++ extensions/checkins/src/timezone.ts | 67 ++ extensions/checkins/src/tools/member-add.ts | 173 +++++ extensions/checkins/src/tools/member-list.ts | 164 +++++ .../checkins/src/tools/member-remove.ts | 147 ++++ .../checkins/src/tools/member-timezone.ts | 150 ++++ extensions/checkins/src/tools/team-create.ts | 178 +++++ extensions/checkins/src/tools/team-delete.ts | 137 ++++ extensions/checkins/src/tools/team-list.ts | 137 ++++ extensions/checkins/src/tools/vacation.ts | 173 +++++ extensions/checkins/src/types.ts | 167 +++++ 22 files changed, 3433 insertions(+) create mode 100644 extensions/checkins/README.md create mode 100644 extensions/checkins/clawdbot.plugin.json create mode 100644 extensions/checkins/index.ts create mode 100644 extensions/checkins/package.json create mode 100644 extensions/checkins/src/cleanup.ts create mode 100644 extensions/checkins/src/conversation.ts create mode 100644 extensions/checkins/src/dm-handler.ts create mode 100644 extensions/checkins/src/formatter.ts create mode 100644 extensions/checkins/src/permissions.ts create mode 100644 extensions/checkins/src/questions.ts create mode 100644 extensions/checkins/src/scheduler.ts create mode 100644 extensions/checkins/src/storage.ts create mode 100644 extensions/checkins/src/timezone.ts create mode 100644 extensions/checkins/src/tools/member-add.ts create mode 100644 extensions/checkins/src/tools/member-list.ts create mode 100644 extensions/checkins/src/tools/member-remove.ts create mode 100644 extensions/checkins/src/tools/member-timezone.ts create mode 100644 extensions/checkins/src/tools/team-create.ts create mode 100644 extensions/checkins/src/tools/team-delete.ts create mode 100644 extensions/checkins/src/tools/team-list.ts create mode 100644 extensions/checkins/src/tools/vacation.ts create mode 100644 extensions/checkins/src/types.ts diff --git a/extensions/checkins/README.md b/extensions/checkins/README.md new file mode 100644 index 000000000..c157f304e --- /dev/null +++ b/extensions/checkins/README.md @@ -0,0 +1,171 @@ +# Checkins Extension + +A Discord standup/check-in management extension for Clawdbot. Automates daily team check-ins via DM conversations and posts summaries to designated channels. + +## Features + +- Scheduled check-in prompts via Discord DM +- 3-question standup format (yesterday, today, blockers) +- Per-member timezone support +- Vacation mode +- Auto-posting to team channels +- Admin-only team management + +## How It Works + +1. **Admin creates a team** and assigns a Discord channel for standup posts +2. **Admin adds members** with their timezone +3. **At each member's scheduled time** (default 5pm in their timezone), the bot DMs them +4. **Member answers 3 questions** via DM conversation +5. **Completed check-in is posted** to the team's channel + +## Check-in Questions + +1. "What did you accomplish today?" +2. "What will you do next?" +3. "Any blockers or anything to handover?" + +For question 3, members can reply with "none", "skip", "no", or "n/a" to indicate no blockers. + +## Admin Commands + +All admin commands require Discord server Administrator permission. Talk to the bot naturally - it will use the appropriate tools. + +### Team Management + +| Command | Example | +|---------|---------| +| Create team | "Create a team called Engineering with standups in #engineering-standup" | +| Delete team | "Delete the Engineering team" | +| List teams | "What teams are configured?" | + +### Member Management + +| Command | Example | +|---------|---------| +| Add member | "Add @john to the Engineering team, timezone EST" | +| Remove member | "Remove @john from the Engineering team" | +| Change timezone | "Change @john's timezone to PST" | +| List members | "Who is on the Engineering team?" | + +### Vacation + +| Command | Example | +|---------|---------| +| Set vacation | "Set @john on vacation until Friday" | +| Set indefinite vacation | "Put @john on vacation" | +| End vacation | "End @john's vacation" | + +Members can also set their own vacation by DMing the bot. + +## Member Self-Service + +Members can DM the bot directly for: + +- Setting their own vacation: "I'm on vacation until next Monday" +- Ending vacation: "I'm back from vacation" + +## Timezone Support + +The extension accepts flexible timezone formats: + +- IANA: `America/New_York`, `Europe/London`, `Asia/Tokyo` +- Abbreviations: `EST`, `PST`, `UTC`, `GMT` +- Common names: `Eastern`, `Pacific`, `Central` + +## Default Schedule + +- Check-in time: 5:00 PM (17:00) in member's timezone +- Skip weekends: Yes +- Reminder: Sent 1 hour after initial prompt if no response +- Abandon: Incomplete check-ins expire at midnight + +## Database + +Check-in data is stored in SQLite at `~/.clawdbot/checkins.db`: + +- **teams**: Team definitions with channel assignments +- **members**: Team membership with schedules +- **checkins**: Completed check-in records +- **conversation_state**: Active DM conversations + +## Configuration + +Add to your `clawdbot.json`: + +```json +{ + "plugins": { + "entries": { + "checkins": { + "enabled": true, + "config": { + "defaultTimezone": "America/New_York", + "defaultCheckInTime": "17:00", + "reminderDelayMinutes": 60, + "abandonAfterMinutes": 420 + } + } + } + } +} +``` + +## Discord Channel Setup + +1. Create channels for each team's standups (e.g., `#engineering-standup`) +2. Ensure the bot has permission to send messages in those channels +3. When creating teams, specify the channel ID or mention the channel + +## Permissions Required + +The bot needs these Discord permissions: + +- Send Messages (for standup posts) +- Read Message History (for context) +- Add Reactions (for acknowledgments) + +Plus the **Message Content Intent** must be enabled in Discord Developer Portal. + +## Example Setup Flow + +``` +Admin: Create a team called Frontend with standups posted to #frontend-standup +Bot: Created team "Frontend" + +Admin: Add @alice to Frontend, timezone PST +Bot: Added @alice to Frontend, timezone America/Los_Angeles + +Admin: Add @bob to Frontend, timezone EST +Bot: Added @bob to Frontend, timezone America/New_York + +Admin: Set @alice on vacation until Friday +Bot: @alice is now on vacation until Friday, January 31 +``` + +At 5pm PST, Alice would receive a DM (but won't because she's on vacation). +At 5pm EST, Bob receives: + +``` +Bot: What did you accomplish today? +Bob: Fixed the login bug and reviewed 2 PRs +Bot: What will you do next? +Bob: Working on the dashboard redesign +Bot: Any blockers or anything to handover? +Bob: None +Bot: Check-in complete! Posted to #frontend-standup +``` + +The summary appears in #frontend-standup: + +``` +Bob's Check-in + +**What did you accomplish today?** +Fixed the login bug and reviewed 2 PRs + +**What will you do next?** +Working on the dashboard redesign + +**Blockers:** None +``` diff --git a/extensions/checkins/clawdbot.plugin.json b/extensions/checkins/clawdbot.plugin.json new file mode 100644 index 000000000..7f9aef821 --- /dev/null +++ b/extensions/checkins/clawdbot.plugin.json @@ -0,0 +1,53 @@ +{ + "id": "checkins", + "name": "Team Check-ins", + "description": "Manage team standup check-ins via Discord DM", + "uiHints": { + "enabled": { + "label": "Enable Check-ins", + "help": "Enable/disable the check-ins extension" + }, + "defaultTimezone": { + "label": "Default Timezone", + "help": "IANA timezone for new members (e.g., America/New_York)" + }, + "defaultCheckInTime": { + "label": "Default Check-in Time", + "help": "24-hour format time for daily check-ins (e.g., 17:00)" + }, + "reminderDelayMinutes": { + "label": "Reminder Delay (minutes)", + "help": "Minutes to wait before sending reminder", + "advanced": true + }, + "abandonAfterMinutes": { + "label": "Abandon After (minutes)", + "help": "Minutes after which to abandon incomplete check-in", + "advanced": true + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "defaultTimezone": { + "type": "string" + }, + "defaultCheckInTime": { + "type": "string", + "pattern": "^([01]?[0-9]|2[0-3]):[0-5][0-9]$" + }, + "reminderDelayMinutes": { + "type": "integer", + "minimum": 1 + }, + "abandonAfterMinutes": { + "type": "integer", + "minimum": 1 + } + } + } +} diff --git a/extensions/checkins/index.ts b/extensions/checkins/index.ts new file mode 100644 index 000000000..c8a8fd4a8 --- /dev/null +++ b/extensions/checkins/index.ts @@ -0,0 +1,295 @@ +/** + * Check-ins Extension Entry Point + * + * Provides team standup check-ins via Discord DM. + * This plugin manages the storage lifecycle and registers team management tools. + */ + +import type { ClawdbotPluginApi, ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { z } from "zod"; + +import { createStorage, type CheckinsStorage } from "./src/storage.js"; +import type { CheckinsConfig, DiscordGuildConfig } from "./src/types.js"; + +// Tool imports +import { createTeamCreateTool } from "./src/tools/team-create.js"; +import { createTeamDeleteTool } from "./src/tools/team-delete.js"; +import { createTeamListTool } from "./src/tools/team-list.js"; +import { createMemberAddTool } from "./src/tools/member-add.js"; +import { createMemberRemoveTool } from "./src/tools/member-remove.js"; +import { createMemberListTool } from "./src/tools/member-list.js"; +import { createMemberTimezoneTool } from "./src/tools/member-timezone.js"; +import { createVacationTool } from "./src/tools/vacation.js"; + +// DM handler import +import { handleDmResponse, triggerCheckIn } from "./src/dm-handler.js"; + +// Scheduler and cleanup imports +import { scheduleAllMembers, buildCheckInCronJob, getUnscheduleJobId } from "./src/scheduler.js"; +import { buildCleanupCronJobs, cleanupExpiredConversations } from "./src/cleanup.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Config Schema +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Zod schema for plugin configuration. + * Matches the config defined in clawdbot.plugin.json. + */ +const CheckinsConfigSchema = z.object({ + enabled: z.boolean().default(true), + defaultTimezone: z.string().default("America/New_York"), + defaultCheckInTime: z + .string() + .regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "Invalid time format (HH:MM)") + .default("17:00"), + reminderDelayMinutes: z.number().int().min(1).default(60), + abandonAfterMinutes: z.number().int().min(1).default(120), +}); + +/** + * Config schema wrapper with parse method for plugin API compatibility. + */ +const checkinsConfigSchema = { + parse(value: unknown): CheckinsConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + return CheckinsConfigSchema.parse(raw); + }, + uiHints: { + enabled: { + label: "Enabled", + help: "Enable or disable the check-ins extension", + }, + defaultTimezone: { + label: "Default Timezone", + help: "IANA timezone for new team members (e.g., America/New_York)", + }, + defaultCheckInTime: { + label: "Default Check-in Time", + help: "Default time for check-in prompts (24-hour format, e.g., 17:00)", + }, + reminderDelayMinutes: { + label: "Reminder Delay (minutes)", + help: "Minutes to wait before sending a reminder for incomplete check-ins", + advanced: true, + }, + abandonAfterMinutes: { + label: "Abandon After (minutes)", + help: "Minutes after which to abandon incomplete check-in conversations", + advanced: true, + }, + }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helper Functions +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Extract Discord user ID from event.from field. + * Handles formats like "discord:user:123456", "user:123456", or just "123456". + */ +function extractDiscordUserId(from: string): string | null { + const match = from.match(/(?:discord:)?(?:user:)?(\d+)/); + return match ? match[1] : null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin Definition +// ───────────────────────────────────────────────────────────────────────────── + +const checkinsPlugin = { + id: "checkins", + name: "Team Check-ins", + description: "Manage team standup check-ins via Discord DM", + configSchema: checkinsConfigSchema, + + register(api: ClawdbotPluginApi) { + const cfg = checkinsConfigSchema.parse(api.pluginConfig); + + // Resolve database path in state directory + const stateDir = api.runtime?.state?.resolveStateDir?.() ?? process.env.HOME + "/.clawdbot"; + const dbPath = `${stateDir}/checkins.db`; + + // Create storage instance (not initialized until service starts) + let storage: CheckinsStorage | null = null; + + // Store clawdbot config reference for accessing Discord guilds + let clawdbotConfig: ClawdbotConfig | null = null; + + // Getter for Discord config from clawdbot.json + const getDiscordConfig = (): { guilds?: Record } | undefined => { + if (!clawdbotConfig) { + // Try to load config if not cached + try { + clawdbotConfig = api.runtime?.config?.loadConfig?.() ?? null; + } catch { + return undefined; + } + } + const discord = clawdbotConfig?.channels?.discord; + if (!discord || typeof discord !== "object") return undefined; + return discord as { guilds?: Record }; + }; + + // Register service for lifecycle management + api.registerService({ + id: "checkins", + start: async () => { + if (!cfg.enabled) { + api.logger.info("[checkins] Extension disabled"); + return; + } + + api.logger.info("[checkins] Starting service..."); + + // Create and initialize storage + storage = createStorage(dbPath, cfg); + storage.init(); + + api.logger.info(`[checkins] Database initialized at ${dbPath}`); + }, + stop: async () => { + api.logger.info("[checkins] Stopping service..."); + + // Close database connection + if (storage) { + storage.close(); + storage = null; + } + + api.logger.info("[checkins] Database closed"); + }, + }); + + // Register team management tools (Phase 2) + api.registerTool( + () => { + if (!storage) return null; + return [ + createTeamCreateTool(storage, getDiscordConfig), + createTeamDeleteTool(storage, getDiscordConfig), + createTeamListTool(storage, getDiscordConfig), + createMemberAddTool(storage, getDiscordConfig), + createMemberRemoveTool(storage, getDiscordConfig), + createMemberListTool(storage, getDiscordConfig), + createMemberTimezoneTool(storage, getDiscordConfig), + createVacationTool(storage, getDiscordConfig), + ]; + }, + { + names: [ + "checkins_team_create", + "checkins_team_delete", + "checkins_team_list", + "checkins_member_add", + "checkins_member_remove", + "checkins_member_list", + "checkins_member_timezone", + "checkins_vacation", + ], + }, + ); + + // Register gateway methods for cron job management (Phase 3) + api.registerGatewayMethod("checkins.getCronJobs", async () => { + if (!storage) return { jobs: [] }; + const memberJobs = scheduleAllMembers(storage); + const cleanupJobs = buildCleanupCronJobs(storage); + return { jobs: [...memberJobs, ...cleanupJobs] }; + }); + + api.registerGatewayMethod("checkins.scheduleMember", async (params) => { + if (!storage) return { success: false, error: "Storage not initialized" }; + const { memberId, teamId } = params as { memberId: string; teamId: string }; + const member = storage.getMember(memberId); + const team = storage.getTeam(teamId); + if (!member || !team) return { success: false, error: "Member or team not found" }; + const job = buildCheckInCronJob(member, team); + return { success: true, job }; + }); + + api.registerGatewayMethod("checkins.unscheduleMember", async (params) => { + const { memberId } = params as { memberId: string }; + return { success: true, jobId: getUnscheduleJobId(memberId) }; + }); + + // Register gateway_start hook to log scheduling info (Phase 3) + api.on("gateway_start", async () => { + if (!storage) return; + const memberJobs = scheduleAllMembers(storage); + const cleanupJobs = buildCleanupCronJobs(storage); + api.logger.info(`[checkins] Ready: ${memberJobs.length} member schedules, ${cleanupJobs.length} cleanup jobs`); + }); + + // Register before_message_dispatch hook for DM handling (Phase 3) + // This hook can intercept messages before they reach the agent + api.on("before_message_dispatch", async (event, context) => { + api.logger.info(`[checkins] before_message_dispatch: channelId=${context.channelId}, from=${event.from}, senderId=${event.metadata?.senderId}, content=${event.content.slice(0, 50)}`); + + // Only process Discord channels for DM handling + if (!context.channelId?.startsWith("discord")) { + api.logger.info(`[checkins] Skipping: not a discord channel`); + return; + } + + // Only handle if storage is initialized + if (!storage) { + api.logger.info(`[checkins] Skipping: storage not initialized`); + return; + } + + // Extract Discord user ID: + // - For channel messages: use event.metadata.senderId (from is discord:channel:{channelId}) + // - For DMs: extract from event.from (discord:{userId}) + const senderIdFromMeta = event.metadata?.senderId; + const discordUserId = + (typeof senderIdFromMeta === "string" ? senderIdFromMeta : null) ?? + extractDiscordUserId(event.from); + if (!discordUserId) { + api.logger.info(`[checkins] Skipping: could not extract discordUserId from from=${event.from} or senderId=${event.metadata?.senderId}`); + return; + } + + api.logger.info(`[checkins] Calling handleDmResponse for user ${discordUserId}`); + + // Handle DM response (returns true if message was handled) + const handled = await handleDmResponse(storage, discordUserId, event.content, context.accountId); + api.logger.info(`[checkins] handleDmResponse returned: ${handled}`); + + if (handled) { + return { handled: true }; + } + }); + + // Register message_received hook for system events from cron jobs (Phase 3) + api.on("message_received", async (event, context) => { + // Handle system messages from cron jobs + if (event.content.startsWith("[system] checkins:trigger:")) { + const parts = event.content.replace("[system] checkins:trigger:", "").split(":"); + const [memberId, teamId] = parts; + if (memberId && teamId && storage) { + await triggerCheckIn(storage, memberId, teamId, context.accountId); + } + return; + } + + if (event.content.startsWith("[system] checkins:cleanup:")) { + const timezone = event.content.replace("[system] checkins:cleanup:", ""); + if (timezone && storage) { + const count = cleanupExpiredConversations(storage, timezone); + api.logger.info(`[checkins] Cleaned up ${count} expired conversations for ${timezone}`); + } + return; + } + }); + + // Log registration + api.logger.info(`[checkins] Plugin registered (enabled: ${cfg.enabled})`); + }, +}; + +export default checkinsPlugin; diff --git a/extensions/checkins/package.json b/extensions/checkins/package.json new file mode 100644 index 000000000..221b6009c --- /dev/null +++ b/extensions/checkins/package.json @@ -0,0 +1,24 @@ +{ + "name": "@clawdbot/checkins", + "version": "2026.1.25", + "type": "module", + "description": "Team check-in management for Discord", + "scripts": { + "build": "esbuild index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:clawdbot --external:clawdbot/* --packages=external" + }, + "devDependencies": { + "esbuild": "^0.25.0" + }, + "dependencies": { + "@sinclair/typebox": "0.34.47", + "chrono-node": "^2.9.0", + "discord-api-types": "^0.37.119", + "timezone-search": "^0.1.5", + "zod": "^4.3.6" + }, + "clawdbot": { + "extensions": [ + "./dist/index.js" + ] + } +} diff --git a/extensions/checkins/src/cleanup.ts b/extensions/checkins/src/cleanup.ts new file mode 100644 index 000000000..1bae2a03e --- /dev/null +++ b/extensions/checkins/src/cleanup.ts @@ -0,0 +1,84 @@ +/** + * Midnight cleanup for expired check-in conversations. + * Runs at 00:05 in each unique timezone to clean up incomplete check-ins. + */ + +import type { CronJobCreate } from "../../../src/cron/types.js"; +import type { CheckinsStorage } from "./storage.js"; + +/** + * Get consistent cleanup job ID for a timezone. + * @param timezone - IANA timezone string (e.g., "America/New_York") + * @returns Job ID string (slashes replaced with dashes) + */ +export function getCleanupJobId(timezone: string): string { + return `checkins-cleanup-${timezone.replace(/\//g, "-")}`; +} + +/** + * Clean up expired check-in conversations for a specific timezone. + * Silently deletes conversation state without notifying members. + * + * @param storage - Storage instance + * @param timezone - Timezone to clean up (IANA format) + * @returns Count of deleted conversations + */ +export function cleanupExpiredConversations(storage: CheckinsStorage, timezone: string): number { + let deletedCount = 0; + + // Get all active conversations + const conversations = storage.listActiveConversations(); + + // Filter to this timezone and delete + for (const conv of conversations) { + const member = storage.getMember(conv.memberId); + if (member && member.schedule.timezone === timezone) { + storage.deleteConversationState(conv.memberId); + deletedCount++; + } + } + + return deletedCount; +} + +/** + * Build cron job configurations for midnight cleanup across all timezones. + * Creates one cleanup job per unique timezone found in members. + * + * @param storage - Storage instance + * @returns Array of cleanup cron job configurations + */ +export function buildCleanupCronJobs(storage: CheckinsStorage): CronJobCreate[] { + // Collect unique timezones from all members + const timezones = new Set(); + + for (const team of storage.getAllTeams()) { + for (const member of storage.listMembers(team.id)) { + timezones.add(member.schedule.timezone); + } + } + + // Build cleanup job for each timezone + const jobs: CronJobCreate[] = []; + for (const tz of timezones) { + jobs.push({ + name: `Midnight cleanup: ${tz}`, + description: "Clean up expired check-in conversations", + enabled: true, + schedule: { + kind: "cron", + expr: "5 0 * * *", // 00:05 (5 minutes past midnight per RESEARCH.md) + tz, + }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { + kind: "agentTurn", + message: `[system] checkins:cleanup:${tz}`, + deliver: false, + }, + }); + } + + return jobs; +} diff --git a/extensions/checkins/src/conversation.ts b/extensions/checkins/src/conversation.ts new file mode 100644 index 000000000..6827c1126 --- /dev/null +++ b/extensions/checkins/src/conversation.ts @@ -0,0 +1,105 @@ +/** + * Conversation state machine for multi-question check-in flows. + */ + +import type { CheckinsStorage } from "./storage.js"; +import type { Checkin, ConversationState } from "./types.js"; +import { isSkipResponse } from "./questions.js"; + +/** + * Initiate a new check-in conversation for a member. + * @param storage - Storage instance + * @param memberId - Member UUID + * @param teamId - Team UUID + * @returns New conversation state at question 1 + */ +export function initiateCheckIn( + storage: CheckinsStorage, + memberId: string, + teamId: string, +): ConversationState { + const now = Date.now(); + const state: ConversationState = { + memberId, + teamId, + currentQuestion: 1, + answers: {}, + startedAt: now, + lastActivityAt: now, + reminderSent: false, + }; + + storage.saveConversationState(state); + return state; +} + +/** + * Advance the conversation with a user's answer. + * @param storage - Storage instance + * @param memberId - Member UUID + * @param answer - User's answer text + * @returns Either next question or completed check-in + */ +export function advanceConversation( + storage: CheckinsStorage, + memberId: string, + answer: string, +): { done: false; nextQuestion: 2 | 3 } | { done: true; checkin: Checkin } { + const state = storage.getConversationState(memberId); + if (!state) { + throw new Error("No active conversation"); + } + + const now = Date.now(); + + // Handle based on current question + if (state.currentQuestion === 1) { + // Q1: What did you accomplish today? + state.answers.yesterday = answer; + state.currentQuestion = 2; + state.lastActivityAt = now; + storage.saveConversationState(state); + return { done: false, nextQuestion: 2 }; + } else if (state.currentQuestion === 2) { + // Q2: What will you do next? + state.answers.today = answer; + state.currentQuestion = 3; + state.lastActivityAt = now; + storage.saveConversationState(state); + return { done: false, nextQuestion: 3 }; + } else { + // Q3: Any blockers? + const blockers = isSkipResponse(answer) ? null : answer; + state.answers.blockers = blockers ?? undefined; + state.lastActivityAt = now; + + // Complete the check-in + const checkin = completeCheckIn(storage, state); + return { done: true, checkin }; + } +} + +/** + * Complete a check-in and save it to storage. + * @param storage - Storage instance + * @param state - Current conversation state + * @returns Saved check-in record + */ +export function completeCheckIn( + storage: CheckinsStorage, + state: ConversationState, +): Checkin { + // Save checkin record + const checkin = storage.saveCheckin({ + memberId: state.memberId, + teamId: state.teamId, + yesterday: state.answers.yesterday!, + today: state.answers.today!, + blockers: state.answers.blockers, + }); + + // Delete conversation state + storage.deleteConversationState(state.memberId); + + return checkin; +} diff --git a/extensions/checkins/src/dm-handler.ts b/extensions/checkins/src/dm-handler.ts new file mode 100644 index 000000000..1f56ab71d --- /dev/null +++ b/extensions/checkins/src/dm-handler.ts @@ -0,0 +1,315 @@ +/** + * DM response handling and check-in triggering for Discord conversations. + */ + +import type { CheckinsStorage, Member, Team } from "./storage.js"; +import { initiateCheckIn, advanceConversation } from "./conversation.js"; +import { formatCheckInPost, formatConfirmation } from "./formatter.js"; +import { getQuestion } from "./questions.js"; +import { sendMessageDiscord } from "clawdbot/plugin-sdk"; + +/** + * Get the current day-of-week (0=Sunday, 6=Saturday) in a specific timezone. + * @param timezone - IANA timezone string + * @returns Day of week number (0-6, where 0 is Sunday) + */ +function getDayOfWeekInTimezone(timezone: string): number { + const formatter = new Intl.DateTimeFormat("en-US", { + weekday: "short", + timeZone: timezone, + }); + const dayStr = formatter.format(new Date()); // "Sun", "Mon", "Tue", etc. + const days: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + }; + return days[dayStr] ?? 1; // Default to Monday if parse fails +} + +/** + * Get the start of the current day (midnight) in a specific timezone as a UTC timestamp. + * @param timezone - IANA timezone string + * @returns UTC timestamp of midnight in that timezone + */ +function getStartOfDayInTimezone(timezone: string): number { + const now = new Date(); + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const parts = formatter.formatToParts(now); + const year = parts.find((p) => p.type === "year")?.value ?? "2026"; + const month = parts.find((p) => p.type === "month")?.value ?? "01"; + const day = parts.find((p) => p.type === "day")?.value ?? "01"; + + // Create a date string and parse it in the target timezone + const dateStr = `${year}-${month}-${day}T00:00:00`; + const midnightLocal = new Date(dateStr); + + // Calculate the offset for the timezone + const utcFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour: "numeric", + minute: "numeric", + hour12: false, + }); + const nowInTz = utcFormatter.format(now); + const [hours, minutes] = nowInTz.split(":").map(Number); + const nowUtc = now.getUTCHours() * 60 + now.getUTCMinutes(); + const nowTz = hours * 60 + minutes; + const offsetMinutes = nowTz - nowUtc; + + // Adjust midnight to UTC + return midnightLocal.getTime() - offsetMinutes * 60 * 1000; +} + +/** + * Find a member by their Discord user ID across all teams. + * @param storage - Storage instance + * @param discordUserId - Discord user ID + * @returns Member and team if found, null otherwise + */ +export function findMemberByDiscordUserId( + storage: CheckinsStorage, + discordUserId: string, +): { member: Member; team: Team } | null { + for (const team of storage.getAllTeams()) { + const member = storage.getMemberByDiscordId(team.id, discordUserId); + if (member) { + return { member, team }; + } + } + return null; +} + +/** + * Trigger a check-in for a member (called by scheduler). + * Handles vacation status and weekend skipping in member's timezone. + * + * @param storage - Storage instance + * @param memberId - Member UUID + * @param teamId - Team UUID + * @param discordAccountId - Optional Discord account ID for sending + */ +export async function triggerCheckIn( + storage: CheckinsStorage, + memberId: string, + teamId: string, + discordAccountId?: string, +): Promise { + const member = storage.getMember(memberId); + if (!member) { + // Member was deleted, skip silently + return; + } + + // Check vacation status + if (member.vacationUntil && member.vacationUntil > Date.now()) { + // Member is on vacation, skip silently + return; + } + + // Check weekend skip (if enabled, check in member's timezone) + if (member.schedule.skipWeekends) { + const dayOfWeek = getDayOfWeekInTimezone(member.schedule.timezone); + if (dayOfWeek === 0 || dayOfWeek === 6) { + // Saturday or Sunday in member's timezone, skip silently + return; + } + } + + // Check if there's already an active conversation (e.g., manual check-in in progress) + const existingConversation = storage.getConversationState(memberId); + if (existingConversation) { + // Already in a check-in conversation, skip + return; + } + + // Check if member already checked in today (in their timezone) + const startOfDay = getStartOfDayInTimezone(member.schedule.timezone); + const todaysCheckins = storage.listCheckins({ + memberId, + teamId, + since: startOfDay, + }); + if (todaysCheckins.length > 0) { + // Already checked in today, skip scheduled check-in + return; + } + + // Initiate conversation state + initiateCheckIn(storage, memberId, teamId); + + // Send first question via DM + try { + await sendMessageDiscord(`user:${member.discordUserId}`, getQuestion(1), { + accountId: discordAccountId, + }); + } catch (err) { + // Log error but don't crash + console.error(`[checkins] Failed to send DM to user ${member.discordUserId}:`, err); + } +} + +/** + * Check if a message is a manual check-in trigger. + * Recognizes variations like "checkin", "check in", "standup", "start checkin", etc. + */ +function isManualCheckInTrigger(text: string): boolean { + const normalized = text.toLowerCase().trim(); + const triggers = [ + "checkin", + "check in", + "check-in", + "standup", + "stand up", + "stand-up", + "start checkin", + "start check-in", + "start standup", + "begin checkin", + "begin standup", + "do checkin", + "do standup", + "manual checkin", + "submit standup", + ]; + return triggers.some((t) => normalized === t || normalized.startsWith(t + " ")); +} + +/** + * Handle a DM response from a member and advance the check-in conversation. + * Also handles manual check-in triggers like "checkin" or "standup". + * + * @param storage - Storage instance + * @param discordUserId - Discord user ID who sent the message + * @param messageText - Message text content + * @param discordAccountId - Optional Discord account ID for sending + * @returns true if the message was handled, false otherwise + */ +export async function handleDmResponse( + storage: CheckinsStorage, + discordUserId: string, + messageText: string, + discordAccountId?: string, +): Promise { + // Find member by Discord user ID + const found = findMemberByDiscordUserId(storage, discordUserId); + if (!found) { + // Not a team member, ignore DM + return false; + } + + // Check for active conversation + const conversationState = storage.getConversationState(found.member.id); + + // If no active conversation, check for manual check-in trigger + if (!conversationState) { + if (isManualCheckInTrigger(messageText)) { + // Start a manual check-in + await startManualCheckIn(storage, found.member, found.team, discordAccountId); + return true; + } + // Not a trigger and no active conversation, ignore + return false; + } + + // Get team for channel posting + const team = storage.getTeam(found.team.id); + if (!team) { + // Team was deleted, cleanup conversation and return + storage.deleteConversationState(found.member.id); + return false; + } + + // Advance conversation with user's answer + const result = advanceConversation(storage, found.member.id, messageText); + + try { + if (!result.done) { + // More questions to go, send next question + await sendMessageDiscord(`user:${discordUserId}`, getQuestion(result.nextQuestion), { + accountId: discordAccountId, + }); + } else { + // Check-in complete, post to channel + const postResult = await sendMessageDiscord( + team.channelId ? `channel:${team.channelId}` : `discord:${team.serverId}`, + formatCheckInPost(found.member, result.checkin), + { accountId: discordAccountId }, + ); + + // Mark as posted + storage.markPosted(result.checkin.id); + + // Send confirmation to user + await sendMessageDiscord( + `user:${discordUserId}`, + formatConfirmation(postResult.channelId, postResult.messageId), + { accountId: discordAccountId }, + ); + } + } catch (err) { + // Log error but don't crash + console.error(`[checkins] Failed to send message during check-in flow:`, err); + } + + return true; +} + +/** + * Start a manual check-in for a member. + * Called when a member DMs a trigger like "checkin" or "standup". + * + * @param storage - Storage instance + * @param member - Member initiating the check-in + * @param team - Team the member belongs to + * @param discordAccountId - Optional Discord account ID for sending + */ +async function startManualCheckIn( + storage: CheckinsStorage, + member: Member, + team: Team, + discordAccountId?: string, +): Promise { + // Check if member is on vacation + if (member.vacationUntil && member.vacationUntil > Date.now()) { + const endDate = new Date(member.vacationUntil); + const formattedDate = endDate.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); + try { + await sendMessageDiscord( + `user:${member.discordUserId}`, + `You're currently on vacation until ${formattedDate}. End your vacation first if you want to check in.`, + { accountId: discordAccountId }, + ); + } catch (err) { + console.error(`[checkins] Failed to send vacation notice:`, err); + } + return; + } + + // Initiate conversation state + initiateCheckIn(storage, member.id, team.id); + + // Send first question + try { + await sendMessageDiscord( + `user:${member.discordUserId}`, + `Starting your check-in for ${team.name}!\n\n${getQuestion(1)}`, + { accountId: discordAccountId }, + ); + } catch (err) { + console.error(`[checkins] Failed to send manual check-in prompt:`, err); + } +} diff --git a/extensions/checkins/src/formatter.ts b/extensions/checkins/src/formatter.ts new file mode 100644 index 000000000..b747cc193 --- /dev/null +++ b/extensions/checkins/src/formatter.ts @@ -0,0 +1,38 @@ +/** + * Message formatting for check-in posts and confirmations. + */ + +import type { Checkin, Member } from "./types.js"; + +/** + * Format a completed check-in for channel posting. + * @param member - Member who submitted the check-in + * @param checkin - Completed check-in record + * @returns Formatted message for channel post (plain text, no embed) + */ +export function formatCheckInPost(member: Member, checkin: Checkin): string { + // Use display name if set, otherwise fallback (no Discord mention/tag) + const name = member.displayName ?? "Team Member"; + + // Build post content with newlines after each section + let post = `**${name}'s Check-in**\n\n`; + post += `**Done:**\n${checkin.yesterday}\n\n`; + post += `**Next:**\n${checkin.today}`; + + // Include blockers line only if present + if (checkin.blockers) { + post += `\n\n**🚧 Blockers:**\n${checkin.blockers}`; + } + + return post; +} + +/** + * Format a confirmation message after posting to channel. + * @param channelId - Discord channel ID where check-in was posted + * @param messageId - Discord message ID (currently unused, for future enhancement) + * @returns Confirmation message + */ +export function formatConfirmation(channelId: string, messageId: string): string { + return `Posted to <#${channelId}>!`; +} diff --git a/extensions/checkins/src/permissions.ts b/extensions/checkins/src/permissions.ts new file mode 100644 index 000000000..2b3266ca5 --- /dev/null +++ b/extensions/checkins/src/permissions.ts @@ -0,0 +1,45 @@ +/** + * Discord permission checking for the check-ins extension. + */ + +import { PermissionFlagsBits } from "discord-api-types/v10"; +import type { APIRole } from "discord-api-types/v10"; +import { fetchMemberInfoDiscord, fetchRoleInfoDiscord } from "clawdbot/plugin-sdk"; + +/** + * Check if a Discord user has Administrator permission in a guild. + * + * @param params.guildId - Discord server/guild ID + * @param params.userId - Discord user ID to check + * @param params.accountId - Optional Discord account ID for multi-account setups + * @returns true if user has Administrator permission + */ +export async function isDiscordAdmin(params: { + guildId: string; + userId: string; + accountId?: string; +}): Promise { + const { guildId, userId, accountId } = params; + + // Fetch member info and guild roles in parallel + const opts = accountId ? { accountId } : {}; + const [member, roles] = await Promise.all([ + fetchMemberInfoDiscord(guildId, userId, opts), + fetchRoleInfoDiscord(guildId, opts), + ]); + + // Build role permission map + const rolesById = new Map(roles.map((r) => [r.id, r])); + + // Calculate total permissions from member's roles + let permissions = 0n; + for (const roleId of member.roles ?? []) { + const role = rolesById.get(roleId); + if (role?.permissions) { + permissions |= BigInt(role.permissions); + } + } + + // Check Administrator bit (value 8) + return (permissions & PermissionFlagsBits.Administrator) !== 0n; +} diff --git a/extensions/checkins/src/questions.ts b/extensions/checkins/src/questions.ts new file mode 100644 index 000000000..8d9a4adcf --- /dev/null +++ b/extensions/checkins/src/questions.ts @@ -0,0 +1,62 @@ +/** + * Question content and skip detection for check-in conversations. + */ + +/** + * Fixed question content for the 3-question check-in flow. + */ +export const QUESTIONS = { + 1: "What did you accomplish today?", + 2: "What will you do next?", + 3: "Any blockers or anything to handover?", +} as const; + +/** + * Get question text by question number. + * @param questionNumber - Question number (1, 2, or 3) + * @returns Question text + */ +export function getQuestion(questionNumber: 1 | 2 | 3): string { + return QUESTIONS[questionNumber]; +} + +/** + * Detect if a response indicates the user wants to skip Q3 (no blockers). + * @param text - User's response text + * @returns true if skip intent detected + */ +export function isSkipResponse(text: string): boolean { + // Normalize input + const normalized = text.toLowerCase().trim(); + + // Skip keywords + const skipKeywords = [ + "skip", + "none", + "no", + "nope", + "nothing", + "n/a", + "na", + "no blockers", + "no blocker", + "-", + ]; + + // Check if input matches exact keyword or keyword followed by space/punctuation + for (const keyword of skipKeywords) { + // Exact match + if (normalized === keyword) { + return true; + } + + // Match keyword followed by space or punctuation + if (normalized.startsWith(keyword + " ") || + normalized.startsWith(keyword + ".") || + normalized.startsWith(keyword + "!")) { + return true; + } + } + + return false; +} diff --git a/extensions/checkins/src/scheduler.ts b/extensions/checkins/src/scheduler.ts new file mode 100644 index 000000000..5b5a6c11e --- /dev/null +++ b/extensions/checkins/src/scheduler.ts @@ -0,0 +1,81 @@ +/** + * Cron job scheduler for check-in triggers. + * Manages cron job configurations for individual member check-ins. + */ + +import type { CronJobCreate } from "../../../src/cron/types.js"; +import type { CheckinsStorage, Member, Team } from "./storage.js"; + +/** + * Convert "HH:MM" time string to cron expression. + * @param timeStr - Time in 24-hour format (e.g., "17:00") + * @returns Cron expression (e.g., "0 17 * * *") + */ +export function convertTimeToCronExpr(timeStr: string): string { + const [hour, minute] = timeStr.split(":"); + return `${minute} ${hour} * * *`; +} + +/** + * Get consistent cron job ID for a member. + * @param memberId - Member UUID + * @returns Job ID string + */ +export function getCheckInJobId(memberId: string): string { + return `checkins-trigger-${memberId}`; +} + +/** + * Build cron job configuration for a member's check-in. + * @param member - Member entity + * @param team - Team entity + * @returns CronJobCreate configuration + */ +export function buildCheckInCronJob(member: Member, team: Team): CronJobCreate { + return { + name: `Check-in: ${member.displayName ?? member.discordUserId}`, + description: `Daily check-in for team ${team.name}`, + enabled: true, + schedule: { + kind: "cron", + expr: convertTimeToCronExpr(member.schedule.checkInTime), + tz: member.schedule.timezone, + }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { + kind: "agentTurn", + message: `[system] checkins:trigger:${member.id}:${team.id}`, + deliver: false, + }, + }; +} + +/** + * Build cron job configurations for all members across all teams. + * Used during gateway startup to schedule all check-ins. + * + * @param storage - Storage instance + * @returns Array of cron job configurations + */ +export function scheduleAllMembers(storage: CheckinsStorage): CronJobCreate[] { + const jobs: CronJobCreate[] = []; + + // Get all teams and their members + for (const team of storage.getAllTeams()) { + for (const member of storage.listMembers(team.id)) { + jobs.push(buildCheckInCronJob(member, team)); + } + } + + return jobs; +} + +/** + * Get the job ID for unscheduling a member's check-in. + * @param memberId - Member UUID + * @returns Job ID to remove + */ +export function getUnscheduleJobId(memberId: string): string { + return getCheckInJobId(memberId); +} diff --git a/extensions/checkins/src/storage.ts b/extensions/checkins/src/storage.ts new file mode 100644 index 000000000..328594139 --- /dev/null +++ b/extensions/checkins/src/storage.ts @@ -0,0 +1,667 @@ +/** + * 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>, + ): 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; + }): 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>, + ): 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); + }, + }; +} diff --git a/extensions/checkins/src/timezone.ts b/extensions/checkins/src/timezone.ts new file mode 100644 index 000000000..9191fd6f4 --- /dev/null +++ b/extensions/checkins/src/timezone.ts @@ -0,0 +1,67 @@ +/** + * Timezone normalization and natural language date parsing for the check-ins extension. + */ + +import { searchTimezone } from "timezone-search"; +import * as chrono from "chrono-node"; + +/** + * Normalize flexible timezone input to an IANA identifier. + * + * Accepts: + * - IANA identifiers: "America/New_York" -> "America/New_York" + * - Abbreviations: "EST" -> "America/New_York" + * - City names: "New York" -> "America/New_York" + * + * @param input - Flexible timezone input + * @returns IANA timezone identifier or null if not found + */ +export function normalizeTimezone(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + // Try exact IANA match first + try { + Intl.DateTimeFormat(undefined, { timeZone: trimmed }); + return trimmed; // Valid IANA identifier + } catch { + // Not a valid IANA identifier, try fuzzy search + } + + // Fuzzy search for abbreviations and city names + const results = searchTimezone(trimmed); + if (results.length === 0) { + return null; + } + + // Return best match IANA identifier + return results[0].iana; +} + +/** + * Parse natural language date for vacation end time. + * + * Accepts: + * - Relative: "until Friday", "next Monday" + * - Absolute: "Jan 30", "2026-02-15" + * - Natural: "end of month", "in 2 weeks" + * + * @param input - Natural language date string + * @param referenceDate - Reference date for relative parsing (defaults to now) + * @returns Unix timestamp (ms) or null if parsing failed + */ +export function parseVacationEnd(input: string, referenceDate?: Date): number | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + const ref = referenceDate ?? new Date(); + + // Parse natural language date + const result = chrono.parseDate(trimmed, ref); + if (!result) { + return null; + } + + // Return Unix timestamp (ms) + return result.getTime(); +} diff --git a/extensions/checkins/src/tools/member-add.ts b/extensions/checkins/src/tools/member-add.ts new file mode 100644 index 000000000..d439c0b3b --- /dev/null +++ b/extensions/checkins/src/tools/member-add.ts @@ -0,0 +1,173 @@ +/** + * Member add tool for the check-ins extension. + * Uses configured guilds from clawdbot.json. + */ + +import { Type } from "@sinclair/typebox"; + +import type { CheckinsStorage } from "../storage.js"; +import type { DiscordGuildConfig } from "../types.js"; +import { normalizeTimezone } from "../timezone.js"; + +/** + * Create the member-add tool. + * + * @param storage - CheckinsStorage instance + * @param getDiscordConfig - Function to get Discord config from clawdbot.json + */ +export function createMemberAddTool( + storage: CheckinsStorage, + getDiscordConfig?: () => { guilds?: Record } | undefined, +) { + return { + name: "checkins_member_add", + description: + "Add a member to a team. Uses the Discord server configured in clawdbot.json. " + + "Timezone is required. One team per member per server.", + parameters: Type.Object({ + targetUserId: Type.String({ description: "Discord user ID to add (numeric ID)" }), + teamName: Type.String({ description: "Team name to add member to" }), + timezone: Type.String({ description: "Member timezone (EST, America/New_York, Eastern, etc.)" }), + guildSlug: Type.Optional( + Type.String({ + description: "Guild slug if multiple servers configured (optional if only one server)", + }), + ), + }), + async execute(_id: string, params: Record) { + const targetUserId = String(params.targetUserId).replace(/\D/g, ""); // Extract numeric ID + const teamName = String(params.teamName).trim(); + const timezoneInput = String(params.timezone).trim(); + const guildSlug = params.guildSlug ? String(params.guildSlug).trim() : undefined; + + // Validate target user ID + if (!targetUserId) { + return { + content: [{ type: "text", text: "Invalid user ID. Please provide the Discord user ID." }], + isError: true, + }; + } + + // Get Discord config + const discordConfig = getDiscordConfig?.(); + const guilds = discordConfig?.guilds; + + if (!guilds || Object.keys(guilds).length === 0) { + return { + content: [ + { + type: "text", + text: "No Discord servers configured. Add guilds to channels.discord.guilds in clawdbot.json", + }, + ], + isError: true, + }; + } + + // Find the target guild + const guildEntries = Object.entries(guilds); + let targetGuildId: string; + let targetGuild: DiscordGuildConfig; + + if (guildEntries.length === 1) { + [targetGuildId, targetGuild] = guildEntries[0]; + } else if (guildSlug) { + const match = guildEntries.find(([, g]) => g.slug === guildSlug); + if (!match) { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Guild with slug "${guildSlug}" not found. Available: ${availableSlugs || "none"}`, + }, + ], + isError: true, + }; + } + [targetGuildId, targetGuild] = match; + } else { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Multiple Discord servers configured. Please specify guildSlug. Available: ${availableSlugs || "use guild IDs"}`, + }, + ], + isError: true, + }; + } + + // Normalize timezone + const timezone = normalizeTimezone(timezoneInput); + if (!timezone) { + return { + content: [ + { + type: "text", + text: `Unknown timezone: "${timezoneInput}". Try "America/New_York", "EST", or "Eastern".`, + }, + ], + isError: true, + }; + } + + // Find target team + const team = storage.getTeamByName(targetGuildId, teamName); + if (!team) { + const teams = storage.listTeams(targetGuildId); + const available = teams.map((t) => t.name).join(", ") || "none"; + return { + content: [{ type: "text", text: `Team not found. Available teams: ${available}` }], + isError: true, + }; + } + + // Check if already on this team + const existingOnTeam = storage.getMemberByDiscordId(team.id, targetUserId); + if (existingOnTeam) { + return { + content: [{ type: "text", text: `<@${targetUserId}> is already on ${teamName}` }], + isError: true, + }; + } + + // One team per member per server - check if on another team + const allTeams = storage.listTeams(targetGuildId); + let removedFromTeam: string | null = null; + for (const t of allTeams) { + const existingMember = storage.getMemberByDiscordId(t.id, targetUserId); + if (existingMember) { + storage.removeMember(existingMember.id); + removedFromTeam = t.name; + break; + } + } + + // Add to team + storage.addMember({ + teamId: team.id, + discordUserId: targetUserId, + schedule: { timezone, checkInTime: "17:00", skipWeekends: true }, + }); + + // Build response + const guildLabel = targetGuild.slug || targetGuildId; + let message = `Added <@${targetUserId}> to ${teamName} in ${guildLabel}, timezone ${timezone}`; + if (removedFromTeam) { + message += ` (removed from ${removedFromTeam})`; + } + + return { + content: [{ type: "text", text: message }], + }; + }, + }; +} diff --git a/extensions/checkins/src/tools/member-list.ts b/extensions/checkins/src/tools/member-list.ts new file mode 100644 index 000000000..96f4b9120 --- /dev/null +++ b/extensions/checkins/src/tools/member-list.ts @@ -0,0 +1,164 @@ +/** + * Member listing tool for the check-ins extension. + */ + +import { Type } from "@sinclair/typebox"; + +import type { CheckinsStorage } from "../storage.js"; +import type { DiscordGuildConfig } from "../types.js"; + +/** + * Create the member-list tool. + * + * @param storage - CheckinsStorage instance + * @param getDiscordConfig - Function to get Discord config from clawdbot.json + */ +export function createMemberListTool( + storage: CheckinsStorage, + getDiscordConfig?: () => { guilds?: Record } | undefined, +) { + return { + name: "checkins_member_list", + description: + "List all members of a check-in team. Shows Discord user IDs, display names, timezones, and check-in times. " + + "Specify team by name. If multiple Discord servers are configured, also specify guildSlug.", + parameters: Type.Object({ + teamName: Type.String({ description: "Name of the team to list members for" }), + guildSlug: Type.Optional( + Type.String({ + description: "Guild slug if multiple servers configured (optional if only one server)", + }), + ), + }), + async execute(_id: string, params: Record) { + const teamName = String(params.teamName).trim(); + const guildSlug = params.guildSlug ? String(params.guildSlug).trim() : undefined; + + if (!teamName) { + return { + content: [{ type: "text", text: "Team name is required" }], + isError: true, + }; + } + + // Get Discord config + const discordConfig = getDiscordConfig?.(); + const guilds = discordConfig?.guilds; + + if (!guilds || Object.keys(guilds).length === 0) { + return { + content: [ + { + type: "text", + text: "No Discord servers configured. Add guilds to channels.discord.guilds in clawdbot.json", + }, + ], + isError: true, + }; + } + + // Find the target guild + const guildEntries = Object.entries(guilds); + let targetGuildId: string; + let targetGuild: DiscordGuildConfig; + + if (guildEntries.length === 1) { + // Only one guild configured, use it + [targetGuildId, targetGuild] = guildEntries[0]; + } else if (guildSlug) { + // Multiple guilds, find by slug + const match = guildEntries.find(([, g]) => g.slug === guildSlug); + if (!match) { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Guild with slug "${guildSlug}" not found. Available: ${availableSlugs || "none"}`, + }, + ], + isError: true, + }; + } + [targetGuildId, targetGuild] = match; + } else { + // Multiple guilds, no slug specified + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Multiple Discord servers configured. Please specify guildSlug. Available: ${availableSlugs || "use guild IDs"}`, + }, + ], + isError: true, + }; + } + + // Find team by name + const team = storage.getTeamByName(targetGuildId, teamName); + if (!team) { + const teams = storage.listTeams(targetGuildId); + const availableTeams = teams.map((t) => t.name).join(", "); + return { + content: [ + { + type: "text", + text: `Team "${teamName}" not found. Available teams: ${availableTeams || "none"}`, + }, + ], + isError: true, + }; + } + + // List members + const members = storage.listMembers(team.id); + + if (members.length === 0) { + return { + content: [ + { + type: "text", + text: `Team "${teamName}" has no members. Use checkins_member_add to add members.`, + }, + ], + }; + } + + // Build member list + const memberList = members.map((m) => ({ + id: m.id, + discordUserId: m.discordUserId, + displayName: m.displayName, + timezone: m.schedule.timezone, + checkInTime: m.schedule.checkInTime, + skipWeekends: m.schedule.skipWeekends, + vacationUntil: m.vacationUntil ? new Date(m.vacationUntil).toISOString().split("T")[0] : null, + })); + + const summary = memberList + .map((m) => { + const name = m.displayName || `<@${m.discordUserId}>`; + const vacation = m.vacationUntil ? ` (vacation until ${m.vacationUntil})` : ""; + return `• ${name} - ${m.checkInTime} ${m.timezone}${vacation}`; + }) + .join("\n"); + + return { + content: [ + { + type: "text", + text: `Members of "${teamName}" (${members.length}):\n${summary}`, + }, + ], + details: { members: memberList }, + }; + }, + }; +} diff --git a/extensions/checkins/src/tools/member-remove.ts b/extensions/checkins/src/tools/member-remove.ts new file mode 100644 index 000000000..603afd109 --- /dev/null +++ b/extensions/checkins/src/tools/member-remove.ts @@ -0,0 +1,147 @@ +/** + * Member remove tool for the check-ins extension. + * Uses configured guilds from clawdbot.json. + */ + +import { Type } from "@sinclair/typebox"; + +import type { CheckinsStorage } from "../storage.js"; +import type { DiscordGuildConfig } from "../types.js"; + +/** + * Create the member-remove tool. + * + * @param storage - CheckinsStorage instance + * @param getDiscordConfig - Function to get Discord config from clawdbot.json + */ +export function createMemberRemoveTool( + storage: CheckinsStorage, + getDiscordConfig?: () => { guilds?: Record } | undefined, +) { + return { + name: "checkins_member_remove", + description: + "Remove a member from a team. Uses the Discord server configured in clawdbot.json. " + + "Requires confirmation.", + parameters: Type.Object({ + targetUserId: Type.String({ description: "Discord user ID to remove (numeric ID)" }), + teamName: Type.String({ description: "Team name to remove member from" }), + confirm: Type.Optional(Type.Boolean({ description: "Set to true to confirm removal" })), + guildSlug: Type.Optional( + Type.String({ + description: "Guild slug if multiple servers configured (optional if only one server)", + }), + ), + }), + async execute(_id: string, params: Record) { + const targetUserId = String(params.targetUserId).replace(/\D/g, ""); + const teamName = String(params.teamName).trim(); + const confirm = params.confirm === true; + const guildSlug = params.guildSlug ? String(params.guildSlug).trim() : undefined; + + // Validate target user ID + if (!targetUserId) { + return { + content: [{ type: "text", text: "Invalid user ID. Please provide the Discord user ID." }], + isError: true, + }; + } + + // Get Discord config + const discordConfig = getDiscordConfig?.(); + const guilds = discordConfig?.guilds; + + if (!guilds || Object.keys(guilds).length === 0) { + return { + content: [ + { + type: "text", + text: "No Discord servers configured. Add guilds to channels.discord.guilds in clawdbot.json", + }, + ], + isError: true, + }; + } + + // Find the target guild + const guildEntries = Object.entries(guilds); + let targetGuildId: string; + + if (guildEntries.length === 1) { + [targetGuildId] = guildEntries[0]; + } else if (guildSlug) { + const match = guildEntries.find(([, g]) => g.slug === guildSlug); + if (!match) { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Guild with slug "${guildSlug}" not found. Available: ${availableSlugs || "none"}`, + }, + ], + isError: true, + }; + } + [targetGuildId] = match; + } else { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Multiple Discord servers configured. Please specify guildSlug. Available: ${availableSlugs || "use guild IDs"}`, + }, + ], + isError: true, + }; + } + + // Find team + const team = storage.getTeamByName(targetGuildId, teamName); + if (!team) { + const teams = storage.listTeams(targetGuildId); + const available = teams.map((t) => t.name).join(", ") || "none"; + return { + content: [{ type: "text", text: `Team not found. Available teams: ${available}` }], + isError: true, + }; + } + + // Find member + const member = storage.getMemberByDiscordId(team.id, targetUserId); + if (!member) { + return { + content: [{ type: "text", text: `<@${targetUserId}> is not on ${teamName}` }], + isError: true, + }; + } + + // Require confirmation + if (!confirm) { + return { + content: [ + { + type: "text", + text: `Are you sure you want to remove <@${targetUserId}> from ${teamName}? Set confirm=true to proceed.`, + }, + ], + details: { requiresConfirmation: true }, + }; + } + + // Remove member + storage.removeMember(member.id); + + return { + content: [{ type: "text", text: `Removed <@${targetUserId}> from ${teamName}` }], + }; + }, + }; +} diff --git a/extensions/checkins/src/tools/member-timezone.ts b/extensions/checkins/src/tools/member-timezone.ts new file mode 100644 index 000000000..432c0a362 --- /dev/null +++ b/extensions/checkins/src/tools/member-timezone.ts @@ -0,0 +1,150 @@ +/** + * Member timezone tool for the check-ins extension. + * Uses configured guilds from clawdbot.json. + */ + +import { Type } from "@sinclair/typebox"; + +import type { CheckinsStorage } from "../storage.js"; +import type { DiscordGuildConfig } from "../types.js"; +import { normalizeTimezone } from "../timezone.js"; + +/** + * Create the member-timezone tool. + * + * @param storage - CheckinsStorage instance + * @param getDiscordConfig - Function to get Discord config from clawdbot.json + */ +export function createMemberTimezoneTool( + storage: CheckinsStorage, + getDiscordConfig?: () => { guilds?: Record } | undefined, +) { + return { + name: "checkins_member_timezone", + description: + "Update a member's timezone. Uses the Discord server configured in clawdbot.json. " + + "Accepts flexible formats (EST, America/New_York, Eastern).", + parameters: Type.Object({ + targetUserId: Type.String({ description: "Discord user ID to update (numeric ID)" }), + timezone: Type.String({ description: "New timezone (EST, America/New_York, Eastern, etc.)" }), + guildSlug: Type.Optional( + Type.String({ + description: "Guild slug if multiple servers configured (optional if only one server)", + }), + ), + }), + async execute(_id: string, params: Record) { + const targetUserId = String(params.targetUserId).replace(/\D/g, ""); + const timezoneInput = String(params.timezone).trim(); + const guildSlug = params.guildSlug ? String(params.guildSlug).trim() : undefined; + + // Validate target user ID + if (!targetUserId) { + return { + content: [{ type: "text", text: "Invalid user ID. Please provide the Discord user ID." }], + isError: true, + }; + } + + // Get Discord config + const discordConfig = getDiscordConfig?.(); + const guilds = discordConfig?.guilds; + + if (!guilds || Object.keys(guilds).length === 0) { + return { + content: [ + { + type: "text", + text: "No Discord servers configured. Add guilds to channels.discord.guilds in clawdbot.json", + }, + ], + isError: true, + }; + } + + // Find the target guild + const guildEntries = Object.entries(guilds); + let targetGuildId: string; + + if (guildEntries.length === 1) { + [targetGuildId] = guildEntries[0]; + } else if (guildSlug) { + const match = guildEntries.find(([, g]) => g.slug === guildSlug); + if (!match) { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Guild with slug "${guildSlug}" not found. Available: ${availableSlugs || "none"}`, + }, + ], + isError: true, + }; + } + [targetGuildId] = match; + } else { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Multiple Discord servers configured. Please specify guildSlug. Available: ${availableSlugs || "use guild IDs"}`, + }, + ], + isError: true, + }; + } + + // Normalize timezone + const timezone = normalizeTimezone(timezoneInput); + if (!timezone) { + return { + content: [ + { + type: "text", + text: `Unknown timezone: "${timezoneInput}". Try "America/New_York", "EST", or "Eastern".`, + }, + ], + isError: true, + }; + } + + // Find member across all teams in server + const teams = storage.listTeams(targetGuildId); + let member = null; + let memberTeam = null; + for (const team of teams) { + member = storage.getMemberByDiscordId(team.id, targetUserId); + if (member) { + memberTeam = team; + break; + } + } + + if (!member || !memberTeam) { + return { + content: [{ type: "text", text: `<@${targetUserId}> is not a member of any team` }], + isError: true, + }; + } + + // Update timezone + storage.updateMember(member.id, { + schedule: { ...member.schedule, timezone }, + }); + + return { + content: [ + { type: "text", text: `Updated <@${targetUserId}>'s timezone to ${timezone}` }, + ], + }; + }, + }; +} diff --git a/extensions/checkins/src/tools/team-create.ts b/extensions/checkins/src/tools/team-create.ts new file mode 100644 index 000000000..24eb4629c --- /dev/null +++ b/extensions/checkins/src/tools/team-create.ts @@ -0,0 +1,178 @@ +/** + * Team creation tool for the check-ins extension. + * Uses configured guilds/channels from clawdbot.json. + */ + +import { Type } from "@sinclair/typebox"; + +import type { CheckinsStorage } from "../storage.js"; +import type { DiscordGuildConfig } from "../types.js"; + +/** + * Resolve a channel reference to a channel ID from configured channels. + * Accepts: channel ID, channel mention (#name), or plain channel name. + */ +function resolveChannelFromConfig( + channelRef: string, + configuredChannels: Record, +): string | null { + // Already a numeric ID that's in the config + if (/^\d+$/.test(channelRef) && configuredChannels[channelRef]) { + return channelRef; + } + + // For now, just check if it matches a configured channel ID + // Channel names aren't stored in config, so we can only match by ID + if (configuredChannels[channelRef]) { + return channelRef; + } + + return null; +} + +/** + * Create the team-create tool. + * + * @param storage - CheckinsStorage instance + * @param getDiscordConfig - Function to get Discord config from clawdbot.json + */ +export function createTeamCreateTool( + storage: CheckinsStorage, + getDiscordConfig?: () => { guilds?: Record } | undefined, +) { + return { + name: "checkins_team_create", + description: + "Create a new team for check-ins. Uses the Discord server configured in clawdbot.json. " + + "If multiple servers are configured, specify which one by slug. " + + "Channel must be one of the configured channel IDs.", + parameters: Type.Object({ + teamName: Type.String({ description: "Name for the new team" }), + channelId: Type.String({ + description: "Discord channel ID for standup posts (must be a configured channel)", + }), + guildSlug: Type.Optional( + Type.String({ + description: "Guild slug if multiple servers configured (optional if only one server)", + }), + ), + }), + async execute(_id: string, params: Record) { + const teamName = String(params.teamName).trim(); + const channelId = String(params.channelId); + const guildSlug = params.guildSlug ? String(params.guildSlug).trim() : undefined; + + // Validate team name + if (!teamName) { + return { + content: [{ type: "text", text: "Team name is required" }], + isError: true, + }; + } + + // Get Discord config + const discordConfig = getDiscordConfig?.(); + const guilds = discordConfig?.guilds; + + if (!guilds || Object.keys(guilds).length === 0) { + return { + content: [ + { + type: "text", + text: "No Discord servers configured. Add guilds to channels.discord.guilds in clawdbot.json", + }, + ], + isError: true, + }; + } + + // Find the target guild + const guildEntries = Object.entries(guilds); + let targetGuildId: string; + let targetGuild: DiscordGuildConfig; + + if (guildEntries.length === 1) { + // Only one guild configured, use it + [targetGuildId, targetGuild] = guildEntries[0]; + } else if (guildSlug) { + // Multiple guilds, find by slug + const match = guildEntries.find(([, g]) => g.slug === guildSlug); + if (!match) { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Guild with slug "${guildSlug}" not found. Available: ${availableSlugs || "none"}`, + }, + ], + isError: true, + }; + } + [targetGuildId, targetGuild] = match; + } else { + // Multiple guilds, no slug specified + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Multiple Discord servers configured. Please specify guildSlug. Available: ${availableSlugs || "use guild IDs"}`, + }, + ], + isError: true, + }; + } + + // Validate channel is configured + const configuredChannels = targetGuild.channels ?? {}; + const resolvedChannelId = resolveChannelFromConfig(channelId, configuredChannels); + + if (!resolvedChannelId) { + const availableChannels = Object.keys(configuredChannels).join(", "); + return { + content: [ + { + type: "text", + text: `Channel "${channelId}" is not configured for this server. Available channel IDs: ${availableChannels || "none"}`, + }, + ], + isError: true, + }; + } + + // Check for duplicate team + const existing = storage.getTeamByName(targetGuildId, teamName); + if (existing) { + return { + content: [{ type: "text", text: `Team "${teamName}" already exists` }], + isError: true, + }; + } + + // Create team (no permission check needed - config access implies admin) + const team = storage.createTeam({ + serverId: targetGuildId, + name: teamName, + channelId: resolvedChannelId, + }); + + const guildLabel = targetGuild.slug || targetGuildId; + return { + content: [ + { + type: "text", + text: `Created team "${teamName}" in ${guildLabel}, standups will post to channel ${resolvedChannelId}`, + }, + ], + details: { team }, + }; + }, + }; +} diff --git a/extensions/checkins/src/tools/team-delete.ts b/extensions/checkins/src/tools/team-delete.ts new file mode 100644 index 000000000..71b4ecb38 --- /dev/null +++ b/extensions/checkins/src/tools/team-delete.ts @@ -0,0 +1,137 @@ +/** + * Team deletion tool for the check-ins extension. + * Uses configured guilds from clawdbot.json. + */ + +import { Type } from "@sinclair/typebox"; + +import type { CheckinsStorage } from "../storage.js"; +import type { DiscordGuildConfig } from "../types.js"; + +/** + * Create the team-delete tool. + * + * @param storage - CheckinsStorage instance + * @param getDiscordConfig - Function to get Discord config from clawdbot.json + */ +export function createTeamDeleteTool( + storage: CheckinsStorage, + getDiscordConfig?: () => { guilds?: Record } | undefined, +) { + return { + name: "checkins_team_delete", + description: + "Delete a team. Uses the Discord server configured in clawdbot.json. " + + "Requires confirmation. Removes all members and check-in history.", + parameters: Type.Object({ + teamName: Type.String({ description: "Team name to delete" }), + confirm: Type.Optional(Type.Boolean({ description: "Set to true to confirm deletion" })), + guildSlug: Type.Optional( + Type.String({ + description: "Guild slug if multiple servers configured (optional if only one server)", + }), + ), + }), + async execute(_id: string, params: Record) { + const teamName = String(params.teamName).trim(); + const confirm = params.confirm === true; + const guildSlug = params.guildSlug ? String(params.guildSlug).trim() : undefined; + + // Validate team name + if (!teamName) { + return { + content: [{ type: "text", text: "Team name is required" }], + isError: true, + }; + } + + // Get Discord config + const discordConfig = getDiscordConfig?.(); + const guilds = discordConfig?.guilds; + + if (!guilds || Object.keys(guilds).length === 0) { + return { + content: [ + { + type: "text", + text: "No Discord servers configured. Add guilds to channels.discord.guilds in clawdbot.json", + }, + ], + isError: true, + }; + } + + // Find the target guild + const guildEntries = Object.entries(guilds); + let targetGuildId: string; + + if (guildEntries.length === 1) { + [targetGuildId] = guildEntries[0]; + } else if (guildSlug) { + const match = guildEntries.find(([, g]) => g.slug === guildSlug); + if (!match) { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Guild with slug "${guildSlug}" not found. Available: ${availableSlugs || "none"}`, + }, + ], + isError: true, + }; + } + [targetGuildId] = match; + } else { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Multiple Discord servers configured. Please specify guildSlug. Available: ${availableSlugs || "use guild IDs"}`, + }, + ], + isError: true, + }; + } + + // Find team + const team = storage.getTeamByName(targetGuildId, teamName); + if (!team) { + const teams = storage.listTeams(targetGuildId); + const available = teams.map((t) => t.name).join(", ") || "none"; + return { + content: [{ type: "text", text: `Team not found. Available teams: ${available}` }], + isError: true, + }; + } + + // Require confirmation + if (!confirm) { + const members = storage.listMembers(team.id); + return { + content: [ + { + type: "text", + text: `Are you sure you want to delete team "${teamName}"? This will remove ${members.length} member(s) and all their check-in history. Set confirm=true to proceed.`, + }, + ], + details: { requiresConfirmation: true, memberCount: members.length }, + }; + } + + // Delete team (cascades to members via foreign key) + storage.deleteTeam(team.id); + + return { + content: [{ type: "text", text: `Deleted team "${teamName}"` }], + }; + }, + }; +} diff --git a/extensions/checkins/src/tools/team-list.ts b/extensions/checkins/src/tools/team-list.ts new file mode 100644 index 000000000..5eedcdaa7 --- /dev/null +++ b/extensions/checkins/src/tools/team-list.ts @@ -0,0 +1,137 @@ +/** + * Team listing tool for the check-ins extension. + */ + +import { Type } from "@sinclair/typebox"; + +import type { CheckinsStorage } from "../storage.js"; +import type { DiscordGuildConfig } from "../types.js"; + +/** + * Create the team-list tool. + * + * @param storage - CheckinsStorage instance + * @param getDiscordConfig - Function to get Discord config from clawdbot.json + */ +export function createTeamListTool( + storage: CheckinsStorage, + getDiscordConfig?: () => { guilds?: Record } | undefined, +) { + return { + name: "checkins_team_list", + description: + "List all check-in teams. Shows team names, IDs, channel IDs, and member counts. " + + "If multiple Discord servers are configured, specify which one by slug.", + parameters: Type.Object({ + guildSlug: Type.Optional( + Type.String({ + description: "Guild slug if multiple servers configured (optional if only one server)", + }), + ), + }), + async execute(_id: string, params: Record) { + const guildSlug = params.guildSlug ? String(params.guildSlug).trim() : undefined; + + // Get Discord config + const discordConfig = getDiscordConfig?.(); + const guilds = discordConfig?.guilds; + + if (!guilds || Object.keys(guilds).length === 0) { + return { + content: [ + { + type: "text", + text: "No Discord servers configured. Add guilds to channels.discord.guilds in clawdbot.json", + }, + ], + isError: true, + }; + } + + // Find the target guild + const guildEntries = Object.entries(guilds); + let targetGuildId: string; + let targetGuild: DiscordGuildConfig; + + if (guildEntries.length === 1) { + // Only one guild configured, use it + [targetGuildId, targetGuild] = guildEntries[0]; + } else if (guildSlug) { + // Multiple guilds, find by slug + const match = guildEntries.find(([, g]) => g.slug === guildSlug); + if (!match) { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Guild with slug "${guildSlug}" not found. Available: ${availableSlugs || "none"}`, + }, + ], + isError: true, + }; + } + [targetGuildId, targetGuild] = match; + } else { + // Multiple guilds, no slug specified + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Multiple Discord servers configured. Please specify guildSlug. Available: ${availableSlugs || "use guild IDs"}`, + }, + ], + isError: true, + }; + } + + // List teams for this guild + const teams = storage.listTeams(targetGuildId); + + if (teams.length === 0) { + const guildLabel = targetGuild.slug || targetGuildId; + return { + content: [ + { + type: "text", + text: `No teams found in ${guildLabel}. Use checkins_team_create to create one.`, + }, + ], + }; + } + + // Build team list with member counts + const teamList = teams.map((team) => { + const members = storage.listMembers(team.id); + return { + id: team.id, + name: team.name, + channelId: team.channelId, + memberCount: members.length, + }; + }); + + const guildLabel = targetGuild.slug || targetGuildId; + const summary = teamList + .map((t) => `• ${t.name} (${t.memberCount} members) - channel: ${t.channelId || "none"}`) + .join("\n"); + + return { + content: [ + { + type: "text", + text: `Teams in ${guildLabel}:\n${summary}`, + }, + ], + details: { teams: teamList }, + }; + }, + }; +} diff --git a/extensions/checkins/src/tools/vacation.ts b/extensions/checkins/src/tools/vacation.ts new file mode 100644 index 000000000..96da2a1a1 --- /dev/null +++ b/extensions/checkins/src/tools/vacation.ts @@ -0,0 +1,173 @@ +/** + * Vacation tool for the check-ins extension. + * Uses configured guilds from clawdbot.json. + */ + +import { Type } from "@sinclair/typebox"; + +import type { CheckinsStorage } from "../storage.js"; +import type { DiscordGuildConfig } from "../types.js"; +import { parseVacationEnd } from "../timezone.js"; + +/** + * Create the vacation tool. + * + * @param storage - CheckinsStorage instance + * @param getDiscordConfig - Function to get Discord config from clawdbot.json + */ +export function createVacationTool( + storage: CheckinsStorage, + getDiscordConfig?: () => { guilds?: Record } | undefined, +) { + return { + name: "checkins_vacation", + description: + "Set vacation mode for a team member. Uses the Discord server configured in clawdbot.json.", + parameters: Type.Object({ + targetUserId: Type.String({ description: "Discord user ID to set vacation for (numeric ID)" }), + action: Type.Unsafe<"enable" | "disable">({ + type: "string", + enum: ["enable", "disable"], + description: "Enable or disable vacation mode", + }), + until: Type.Optional( + Type.String({ + description: "Vacation end date (natural language: 'Friday', 'Jan 30', etc.)", + }), + ), + guildSlug: Type.Optional( + Type.String({ + description: "Guild slug if multiple servers configured (optional if only one server)", + }), + ), + }), + async execute(_id: string, params: Record) { + const targetUserId = String(params.targetUserId).replace(/\D/g, ""); + const action = String(params.action) as "enable" | "disable"; + const until = params.until ? String(params.until).trim() : undefined; + const guildSlug = params.guildSlug ? String(params.guildSlug).trim() : undefined; + + // Validate target user ID + if (!targetUserId) { + return { + content: [{ type: "text", text: "Invalid user ID. Please provide the Discord user ID." }], + isError: true, + }; + } + + // Get Discord config + const discordConfig = getDiscordConfig?.(); + const guilds = discordConfig?.guilds; + + if (!guilds || Object.keys(guilds).length === 0) { + return { + content: [ + { + type: "text", + text: "No Discord servers configured. Add guilds to channels.discord.guilds in clawdbot.json", + }, + ], + isError: true, + }; + } + + // Find the target guild + const guildEntries = Object.entries(guilds); + let targetGuildId: string; + + if (guildEntries.length === 1) { + [targetGuildId] = guildEntries[0]; + } else if (guildSlug) { + const match = guildEntries.find(([, g]) => g.slug === guildSlug); + if (!match) { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Guild with slug "${guildSlug}" not found. Available: ${availableSlugs || "none"}`, + }, + ], + isError: true, + }; + } + [targetGuildId] = match; + } else { + const availableSlugs = guildEntries + .map(([, g]) => g.slug) + .filter(Boolean) + .join(", "); + return { + content: [ + { + type: "text", + text: `Multiple Discord servers configured. Please specify guildSlug. Available: ${availableSlugs || "use guild IDs"}`, + }, + ], + isError: true, + }; + } + + // Find member by Discord user ID across all teams + const teams = storage.listTeams(targetGuildId); + let member = null; + for (const team of teams) { + member = storage.getMemberByDiscordId(team.id, targetUserId); + if (member) break; + } + + if (!member) { + return { + content: [{ type: "text", text: `<@${targetUserId}> is not a member of any team` }], + isError: true, + }; + } + + if (action === "disable") { + storage.updateMember(member.id, { vacationUntil: null }); + return { + content: [{ type: "text", text: `Vacation disabled for <@${targetUserId}>. Check-ins resume.` }], + }; + } + + // Enable vacation + let vacationUntil: number | null = null; + if (until) { + vacationUntil = parseVacationEnd(until); + if (!vacationUntil) { + return { + content: [ + { + type: "text", + text: `Could not parse date: "${until}". Try "until Friday", "Jan 30", or "next Monday".`, + }, + ], + isError: true, + }; + } + } + + storage.updateMember(member.id, { vacationUntil }); + + // Build response + let message: string; + if (vacationUntil) { + const endDate = new Date(vacationUntil); + message = `<@${targetUserId}> is now on vacation until ${endDate.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + })}`; + } else { + message = `<@${targetUserId}> is now on vacation (indefinite)`; + } + + return { + content: [{ type: "text", text: message }], + }; + }, + }; +} diff --git a/extensions/checkins/src/types.ts b/extensions/checkins/src/types.ts new file mode 100644 index 000000000..74fdc7f90 --- /dev/null +++ b/extensions/checkins/src/types.ts @@ -0,0 +1,167 @@ +/** + * TypeScript types for the check-ins domain. + * Defines entities for teams, members, conversations, and check-in records. + */ + +// ----------------------------------------------------------------------------- +// Team Entity +// ----------------------------------------------------------------------------- + +/** + * A team within a Discord server. + * Teams group members who participate in standup check-ins together. + */ +export type Team = { + /** UUID identifier */ + id: string; + /** Discord server/guild ID */ + serverId: string; + /** Team name, e.g., "Engineering" */ + name: string; + /** Discord channel for standup posts (null if not configured) */ + channelId: string | null; + /** Which Discord account to use for DMs (null for default) */ + discordAccountId: string | null; + /** Creation timestamp (Unix ms) */ + createdAt: number; +}; + +// ----------------------------------------------------------------------------- +// Member Scheduling +// ----------------------------------------------------------------------------- + +/** + * Per-member scheduling configuration. + * Controls when and how check-in prompts are sent. + */ +export type MemberSchedule = { + /** 24-hour format time for check-in, e.g., "17:00" */ + checkInTime: string; + /** IANA timezone, e.g., "America/New_York" */ + timezone: string; + /** Whether to skip weekend check-ins (default true) */ + skipWeekends: boolean; +}; + +// ----------------------------------------------------------------------------- +// Member Entity +// ----------------------------------------------------------------------------- + +/** + * A team member who participates in check-ins. + * Each member belongs to one team and has their own schedule. + */ +export type Member = { + /** UUID identifier */ + id: string; + /** FK to Team */ + teamId: string; + /** Discord user ID */ + discordUserId: string; + /** Optional display name override */ + displayName: string | null; + /** Member's check-in schedule */ + schedule: MemberSchedule; + /** Unix timestamp (ms) until vacation ends, null if not on vacation */ + vacationUntil: number | null; + /** Creation timestamp (Unix ms) */ + createdAt: number; +}; + +// ----------------------------------------------------------------------------- +// Conversation State +// ----------------------------------------------------------------------------- + +/** + * Tracks an active check-in conversation via DM. + * Used to manage multi-question flows and reminders. + */ +export type ConversationState = { + /** FK to Member */ + memberId: string; + /** FK to Team */ + teamId: string; + /** Current question number (1 = yesterday, 2 = today, 3 = blockers) */ + currentQuestion: 1 | 2 | 3; + /** Accumulated answers */ + answers: { + yesterday?: string; + today?: string; + blockers?: string; + }; + /** When conversation started (Unix ms) */ + startedAt: number; + /** Last message timestamp (Unix ms) */ + lastActivityAt: number; + /** Whether the 1-hour reminder was sent */ + reminderSent: boolean; +}; + +// ----------------------------------------------------------------------------- +// Check-in Record +// ----------------------------------------------------------------------------- + +/** + * A completed check-in record. + * Represents a member's submitted standup answers. + */ +export type Checkin = { + /** UUID identifier */ + id: string; + /** FK to Member */ + memberId: string; + /** FK to Team */ + teamId: string; + /** Answer to "What did you do?" */ + yesterday: string; + /** Answer to "What's next?" */ + today: string; + /** Answer to "Any blockers?" (null if no blockers) */ + blockers: string | null; + /** Submission timestamp (Unix ms) */ + submittedAt: number; + /** Whether posted to the standup channel */ + postedToChannel: boolean; +}; + +// ----------------------------------------------------------------------------- +// Plugin Configuration +// ----------------------------------------------------------------------------- + +/** + * Plugin configuration type. + * Controls global defaults for the check-ins extension. + */ +export type CheckinsConfig = { + /** Enable/disable the extension */ + enabled: boolean; + /** Default timezone for new members */ + defaultTimezone: string; + /** Default check-in time for new members */ + defaultCheckInTime: string; + /** Minutes to wait before sending reminder */ + reminderDelayMinutes: number; + /** Minutes after which to abandon incomplete check-in */ + abandonAfterMinutes: number; +}; + +// ----------------------------------------------------------------------------- +// Discord Config Types (from clawdbot.json) +// ----------------------------------------------------------------------------- + +/** + * Channel configuration within a guild. + */ +export type DiscordChannelConfig = { + allow?: boolean; + requireMention?: boolean; +}; + +/** + * Guild configuration from channels.discord.guilds in clawdbot.json. + */ +export type DiscordGuildConfig = { + slug?: string; + requireMention?: boolean; + channels?: Record; +};