Merge pull request #24 from techops-services/fix/restore-checkins-extension
fix: restore checkins extension
This commit is contained in:
commit
231fc5434f
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