fix: restore checkins extension lost during fork sync
This commit is contained in:
parent
68a82225d6
commit
4ee0df1da1
171
extensions/checkins/README.md
Normal file
171
extensions/checkins/README.md
Normal 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
|
||||
```
|
||||
53
extensions/checkins/clawdbot.plugin.json
Normal file
53
extensions/checkins/clawdbot.plugin.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
295
extensions/checkins/index.ts
Normal file
295
extensions/checkins/index.ts
Normal 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;
|
||||
24
extensions/checkins/package.json
Normal file
24
extensions/checkins/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
84
extensions/checkins/src/cleanup.ts
Normal file
84
extensions/checkins/src/cleanup.ts
Normal 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;
|
||||
}
|
||||
105
extensions/checkins/src/conversation.ts
Normal file
105
extensions/checkins/src/conversation.ts
Normal 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;
|
||||
}
|
||||
315
extensions/checkins/src/dm-handler.ts
Normal file
315
extensions/checkins/src/dm-handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
extensions/checkins/src/formatter.ts
Normal file
38
extensions/checkins/src/formatter.ts
Normal 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}>!`;
|
||||
}
|
||||
45
extensions/checkins/src/permissions.ts
Normal file
45
extensions/checkins/src/permissions.ts
Normal 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;
|
||||
}
|
||||
62
extensions/checkins/src/questions.ts
Normal file
62
extensions/checkins/src/questions.ts
Normal 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;
|
||||
}
|
||||
81
extensions/checkins/src/scheduler.ts
Normal file
81
extensions/checkins/src/scheduler.ts
Normal 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);
|
||||
}
|
||||
667
extensions/checkins/src/storage.ts
Normal file
667
extensions/checkins/src/storage.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
67
extensions/checkins/src/timezone.ts
Normal file
67
extensions/checkins/src/timezone.ts
Normal 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();
|
||||
}
|
||||
173
extensions/checkins/src/tools/member-add.ts
Normal file
173
extensions/checkins/src/tools/member-add.ts
Normal 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 }],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
164
extensions/checkins/src/tools/member-list.ts
Normal file
164
extensions/checkins/src/tools/member-list.ts
Normal 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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
147
extensions/checkins/src/tools/member-remove.ts
Normal file
147
extensions/checkins/src/tools/member-remove.ts
Normal 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}` }],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
150
extensions/checkins/src/tools/member-timezone.ts
Normal file
150
extensions/checkins/src/tools/member-timezone.ts
Normal 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}` },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
178
extensions/checkins/src/tools/team-create.ts
Normal file
178
extensions/checkins/src/tools/team-create.ts
Normal 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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
137
extensions/checkins/src/tools/team-delete.ts
Normal file
137
extensions/checkins/src/tools/team-delete.ts
Normal 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}"` }],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
137
extensions/checkins/src/tools/team-list.ts
Normal file
137
extensions/checkins/src/tools/team-list.ts
Normal 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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
173
extensions/checkins/src/tools/vacation.ts
Normal file
173
extensions/checkins/src/tools/vacation.ts
Normal 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 }],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
167
extensions/checkins/src/types.ts
Normal file
167
extensions/checkins/src/types.ts
Normal 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>;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user