Merge pull request #24 from techops-services/fix/restore-checkins-extension

fix: restore checkins extension
This commit is contained in:
Simon KP 2026-01-26 19:44:13 +11:00 committed by GitHub
commit 231fc5434f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 3433 additions and 0 deletions

View File

@ -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
```

View File

@ -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
}
}
}
}

View File

@ -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<string, unknown>)
: {};
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<string, DiscordGuildConfig> } | 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<string, DiscordGuildConfig> };
};
// 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;

View File

@ -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"
]
}
}

View File

@ -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<string>();
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;
}

View File

@ -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;
}

View File

@ -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<string, number> = {
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<void> {
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<boolean> {
// 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<void> {
// 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);
}
}

View File

@ -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}>!`;
}

View File

@ -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<boolean> {
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<string, APIRole>(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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

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

View File

@ -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();
}

View File

@ -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<string, DiscordGuildConfig> } | 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<string, unknown>) {
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 }],
};
},
};
}

View File

@ -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<string, DiscordGuildConfig> } | 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<string, unknown>) {
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 },
};
},
};
}

View File

@ -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<string, DiscordGuildConfig> } | 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<string, unknown>) {
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}` }],
};
},
};
}

View File

@ -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<string, DiscordGuildConfig> } | 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<string, unknown>) {
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}` },
],
};
},
};
}

View File

@ -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, { allow?: boolean }>,
): 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<string, DiscordGuildConfig> } | 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<string, unknown>) {
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 },
};
},
};
}

View File

@ -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<string, DiscordGuildConfig> } | 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<string, unknown>) {
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}"` }],
};
},
};
}

View File

@ -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<string, DiscordGuildConfig> } | 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<string, unknown>) {
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 },
};
},
};
}

View File

@ -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<string, DiscordGuildConfig> } | 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<string, unknown>) {
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 }],
};
},
};
}

View File

@ -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<string, DiscordChannelConfig>;
};