diff --git a/README.md b/README.md index 62e6887..60387f0 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,20 @@ If you never set a time, the default of `17:00` is used. (`-t` is shorthand for Days are case-insensitive and accept `Monday` through `Sunday`. The `on`/`off` commands accept one or more days at a time. +#### `conversation-style` — choose how reminder DMs sound + +Pick the tone the bot uses when it privately reminds you to post your handover. +This setting is per user and only affects reminder DMs. + +| Command | Description | +| ------------------------------------- | --------------------------------------------- | +| `@handover conversation-style` | Show your current style and available styles | +| `@handover conversation-style basic` | Use a simple reminder without calling the LLM | +| `@handover conversation-style kiwi` | Use casual Kiwi slang in reminder DMs | + +Available styles are `standard`, `basic`, `humorous`, `british`, `kiwi`, and +`unhinged`. Style names are case-insensitive. + #### `history` — review past handovers Fetch a private copy of your previous handover posts. diff --git a/prisma/migrations/20260611000000_add_user_conversation_style/migration.sql b/prisma/migrations/20260611000000_add_user_conversation_style/migration.sql new file mode 100644 index 0000000..0206f9f --- /dev/null +++ b/prisma/migrations/20260611000000_add_user_conversation_style/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "ConversationStyle" AS ENUM ('standard', 'basic', 'humorous', 'british', 'kiwi', 'unhinged'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "conversationStyle" "ConversationStyle" NOT NULL DEFAULT 'standard'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d8a68a2..88af7e6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,14 +10,24 @@ datasource db { url = env("DATABASE_URL") } +enum ConversationStyle { + standard + basic + humorous + british + kiwi + unhinged +} + model User { - id String @id - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt name String timeZone String dailyReminderTime String? - workdays Int[] @default([1, 2, 3, 4, 5]) + workdays Int[] @default([1, 2, 3, 4, 5]) + conversationStyle ConversationStyle @default(standard) posts Post[] Reminder Reminder[] diff --git a/src/ai.test.ts b/src/ai.test.ts new file mode 100644 index 0000000..84fb4b5 --- /dev/null +++ b/src/ai.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test, vi } from 'vitest' +import { + createReminderPrompt, + generateReminder, + getConversationStyleInstruction, + type CreateReminderCompletion, +} from './ai.js' +import type { ConversationStyle } from './conversation-style.js' + +describe('generateReminder', () => { + test('basic style returns fixed text without hitting the LLM', async () => { + const createReminderCompletion = vi.fn() + + const reminder = await generateReminder( + { + name: 'Sam', + daysSinceLastPost: 1, + conversationStyle: 'basic', + }, + { + createReminderCompletion, + }, + ) + + expect(reminder).toBe('What did you work on today?') + expect(createReminderCompletion).not.toHaveBeenCalled() + }) + + test('standard style preserves the current generated reminder path', async () => { + const createReminderCompletion = vi.fn( + async () => ({ + choices: [ + { + message: { + content: 'Generated reminder', + }, + }, + ], + }), + ) + + const reminder = await generateReminder( + { + name: 'Sam', + daysSinceLastPost: 2, + conversationStyle: 'standard', + }, + { + createReminderCompletion, + }, + ) + + expect(reminder).toBe('Generated reminder') + expect(createReminderCompletion).toHaveBeenCalledTimes(1) + }) +}) + +describe('createReminderPrompt', () => { + test.each([ + ['humorous', 'light slapstick-style humor'], + ['british', 'old-timey British colloquial tone'], + ['kiwi', 'casual New Zealand slang'], + ['unhinged', 'chaotic, absurd energy'], + ] satisfies Array<[ConversationStyle, string]>)( + 'adds the %s style instruction', + (conversationStyle, expectedInstruction) => { + const { userPrompt } = createReminderPrompt({ + name: 'Sam', + daysSinceLastPost: 2, + conversationStyle, + }) + + expect(userPrompt).toContain(expectedInstruction) + expect(getConversationStyleInstruction(conversationStyle)).toContain( + expectedInstruction, + ) + }, + ) +}) diff --git a/src/ai.ts b/src/ai.ts index 8bd2261..9d0ab50 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -1,9 +1,8 @@ -import OpenAI from 'openai' import { OPENAI_API_KEY } from './constants.js' - -const openai = new OpenAI({ - apiKey: OPENAI_API_KEY, -}) +import { + defaultConversationStyle, + type ConversationStyle, +} from './conversation-style.js' const pick = (array: string[]): string => { if (array.length === 0) { @@ -16,12 +15,102 @@ const pick = (array: string[]): string => { type GenerateReminderOptions = { name: string daysSinceLastPost: number + conversationStyle?: ConversationStyle } -const generateReminder = async ( - options: GenerateReminderOptions, -): Promise => { - const { name, daysSinceLastPost } = options +type ReminderMessage = { + role: 'system' | 'user' + content: string +} + +type ReminderCompletionRequest = { + model: string + messages: ReminderMessage[] +} + +type ReminderCompletionResponse = { + choices?: Array<{ + message: { + content?: string + } + }> +} + +type CreateReminderCompletion = ( + request: ReminderCompletionRequest, +) => Promise + +type GenerateReminderDependencies = { + createReminderCompletion?: CreateReminderCompletion +} + +type ReminderPrompt = { + systemPrompt: string + userPrompt: string +} + +let openaiClient: + | { + chat: { + completions: { + create: CreateReminderCompletion + } + } + } + | undefined + +const createOpenAIReminderCompletion: CreateReminderCompletion = async ( + request, +) => { + const { default: OpenAI } = await import('openai') + + openaiClient ??= new OpenAI({ + apiKey: OPENAI_API_KEY, + }) as { + chat: { + completions: { + create: CreateReminderCompletion + } + } + } + + return openaiClient.chat.completions.create(request) +} + +const getConversationStyleInstruction = ( + conversationStyle: ConversationStyle, +): string | undefined => { + switch (conversationStyle) { + case 'standard': { + return undefined + } + + case 'humorous': { + return 'Use light slapstick-style humor, as if the workday briefly tripped over its own shoelaces.' + } + + case 'british': { + return 'Use an old-timey British colloquial tone, warm and quaint without being hard to understand.' + } + + case 'kiwi': { + return 'Use casual New Zealand slang and phrasing, friendly and relaxed without overdoing it.' + } + + case 'unhinged': { + return 'Use chaotic, absurd energy that feels completely bananas, while staying workplace-safe and kind.' + } + + case 'basic': { + return undefined + } + } +} + +const createReminderPrompt = ( + options: Required, +): ReminderPrompt => { + const { name, daysSinceLastPost, conversationStyle } = options const systemPrompt = `You are a helpful assistant.` @@ -60,15 +149,43 @@ const generateReminder = async ( 'what they would like to share with the team', ]) - const task = + let task = 'Could you please write a very short message (2 sentences max) that I can ask them? Please be informal and casual, but also funny.' - const defaultPrompt = `Hey ${name}, what have you been working today?` + const styleInstruction = getConversationStyleInstruction(conversationStyle) + if (styleInstruction) { + task += ` ${styleInstruction}` + } const userPrompt = `${concern} ${relation}, ${name}, ${role}. ${action} ${question}. ${task}` + return { + systemPrompt, + userPrompt, + } +} + +const generateReminder = async ( + options: GenerateReminderOptions, + dependencies: GenerateReminderDependencies = {}, +): Promise => { + const conversationStyle = + options.conversationStyle ?? defaultConversationStyle + + const defaultPrompt = `Hey ${options.name}, what have you been working today?` + if (conversationStyle === 'basic') { + return 'What did you work on today?' + } + + const { systemPrompt, userPrompt } = createReminderPrompt({ + ...options, + conversationStyle, + }) + const createReminderCompletion = + dependencies.createReminderCompletion ?? createOpenAIReminderCompletion + try { - const response = await openai.chat.completions.create({ + const response = await createReminderCompletion({ model: 'gpt-3.5-turbo', messages: [ { @@ -98,4 +215,9 @@ const generateReminder = async ( } } -export { generateReminder } +export { + createReminderPrompt, + generateReminder, + getConversationStyleInstruction, +} +export type { CreateReminderCompletion } diff --git a/src/command/cli.ts b/src/command/cli.ts index e35088c..70d10ed 100644 --- a/src/command/cli.ts +++ b/src/command/cli.ts @@ -5,6 +5,7 @@ import { createHistoryCmd } from './history/index.js' import type { CreateCmdFunction } from './_utils/types.js' import { createHelpHandler } from './_utils/create-help-handler.js' import { createWorkdaysCmd } from './workdays/index.js' +import { createConversationStyleCmd } from './conversation-style/index.js' const createHandoverCommand: CreateCmdFunction = (context) => { const { web, userId } = context @@ -13,9 +14,16 @@ const createHandoverCommand: CreateCmdFunction = (context) => { const remindCmd = createRemindCmd(context) const historyCmd = createHistoryCmd(context) const workdaysCmn = createWorkdaysCmd(context) + const conversationStyleCmd = createConversationStyleCmd(context) const handoverCmd = new CliCommand('handover') - .withSubCommands(formatCmd, remindCmd, historyCmd, workdaysCmn) + .withSubCommands( + formatCmd, + remindCmd, + historyCmd, + workdaysCmn, + conversationStyleCmd, + ) .withHelpHandler(createHelpHandler({ web, userId })) .withHandler(() => { handoverCmd.help() diff --git a/src/command/conversation-style/index.test.ts b/src/command/conversation-style/index.test.ts new file mode 100644 index 0000000..e885c20 --- /dev/null +++ b/src/command/conversation-style/index.test.ts @@ -0,0 +1,135 @@ +import { randomUUID } from 'node:crypto' +import type { WebClient } from '@slack/web-api' +import { assertOk } from '@stayradiated/error-boundary' +import { describe, expect, test, vi } from 'vitest' +import { execCommand } from '../index.js' +import { deleteUser, getUser, upsertUser } from '../../db/index.js' + +type PostedMessage = { + channel: string + text: string + unfurl_links: false +} + +const createMockWeb = () => { + const messages: PostedMessage[] = [] + const postMessage = vi.fn(async (message: PostedMessage) => { + messages.push(message) + return { ts: '123.456' } + }) + + const web = { + chat: { + postMessage, + }, + } as unknown as WebClient + + return { + messages, + web, + } +} + +const runConversationStyleCommand = async (options: { + userId: string + text: string + web: WebClient +}) => { + const { userId, text, web } = options + + await execCommand({ + web, + action: { + type: 'ADD', + userId, + channel: 'C123', + ts: '123.456', + text: `<@BOTUSER1234> conversation-style${text ? ` ${text}` : ''}`, + }, + }) +} + +describe('conversation-style command', () => { + test('shows the current default style and options', async () => { + const userId = randomUUID() + const { messages, web } = createMockWeb() + + const user = await upsertUser({ + id: userId, + name: 'Test User', + timeZone: 'UTC', + }) + assertOk(user) + + try { + await runConversationStyleCommand({ userId, text: '', web }) + + expect(messages).toHaveLength(1) + expect(messages[0]?.channel).toBe(userId) + expect(messages[0]?.text).toContain( + 'Your reminder conversation style is currently `standard`.', + ) + expect(messages[0]?.text).toContain('• `basic`: simple fixed reminder') + } finally { + assertOk(await deleteUser({ userId })) + } + }) + + test('updates the user conversation style', async () => { + const userId = randomUUID() + const { messages, web } = createMockWeb() + + const user = await upsertUser({ + id: userId, + name: 'Test User', + timeZone: 'UTC', + }) + assertOk(user) + + try { + await runConversationStyleCommand({ userId, text: 'KiWi', web }) + + const updatedUser = await getUser({ userId }) + assertOk(updatedUser) + + expect(updatedUser.conversationStyle).toBe('kiwi') + expect(messages).toEqual([ + { + channel: userId, + text: '✅ Sounds good, your reminder conversation style is now `kiwi`.', + unfurl_links: false, + }, + ]) + } finally { + assertOk(await deleteUser({ userId })) + } + }) + + test('returns a private error for invalid styles', async () => { + const userId = randomUUID() + const { messages, web } = createMockWeb() + + const user = await upsertUser({ + id: userId, + name: 'Test User', + timeZone: 'UTC', + }) + assertOk(user) + + try { + await runConversationStyleCommand({ userId, text: 'pirate', web }) + + const updatedUser = await getUser({ userId }) + assertOk(updatedUser) + + expect(updatedUser.conversationStyle).toBe('standard') + expect(messages).toHaveLength(1) + expect(messages[0]?.channel).toBe(userId) + expect(messages[0]?.text).toContain( + '⚠️ Invalid conversation style "pirate"', + ) + } finally { + assertOk(await deleteUser({ userId })) + } + }) +}) diff --git a/src/command/conversation-style/index.ts b/src/command/conversation-style/index.ts new file mode 100644 index 0000000..70e4ea7 --- /dev/null +++ b/src/command/conversation-style/index.ts @@ -0,0 +1,68 @@ +import { CliCommand } from 'cilly' +import { z } from 'zod' +import { publishPrivateContentToSlack } from '../../publish-to-slack.js' +import { getUser, updateUser } from '../../db/index.js' +import type { CreateCmdFunction } from '../_utils/types.js' +import { createHelpHandler } from '../_utils/create-help-handler.js' +import { + formatConversationStyleList, + parseConversationStyle, +} from '../../conversation-style.js' + +const $ConversationStyleCmdArguments = z.object({ + style: z.string().optional(), +}) + +const createConversationStyleCmd: CreateCmdFunction = (context) => { + const { web, userId } = context + + const conversationStyleCmd = new CliCommand('conversation-style') + .withHelpHandler(createHelpHandler(context)) + .withDescription('Choose the tone used for your reminder DMs') + .withArguments({ + name: 'style', + required: false, + }) + .withHandler(async (anyArguments) => { + const { style } = $ConversationStyleCmdArguments.parse(anyArguments) + + if (style) { + const conversationStyle = parseConversationStyle(style) + if (conversationStyle instanceof Error) { + throw conversationStyle + } + + const user = await updateUser({ + userId, + data: { + conversationStyle, + }, + }) + if (user instanceof Error) { + throw user + } + + await publishPrivateContentToSlack({ + web, + userId, + text: `✅ Sounds good, your reminder conversation style is now \`${user.conversationStyle}\`.`, + }) + return + } + + const user = await getUser({ userId }) + if (user instanceof Error) { + throw user + } + + await publishPrivateContentToSlack({ + web, + userId, + text: `Your reminder conversation style is currently \`${user.conversationStyle}\`.\n\nAvailable styles:\n${formatConversationStyleList()}`, + }) + }) + + return conversationStyleCmd +} + +export { createConversationStyleCmd } diff --git a/src/conversation-style.test.ts b/src/conversation-style.test.ts new file mode 100644 index 0000000..21a81ff --- /dev/null +++ b/src/conversation-style.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest' +import { + formatConversationStyleList, + parseConversationStyle, +} from './conversation-style.js' + +describe('parseConversationStyle', () => { + test('normalizes valid conversation styles', () => { + expect(parseConversationStyle('BASIC')).toBe('basic') + expect(parseConversationStyle(' kiwi ')).toBe('kiwi') + }) + + test('returns a useful error for invalid conversation styles', () => { + const result = parseConversationStyle('pirate') + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toContain( + 'Please use one of [ standard | basic | humorous | british | kiwi | unhinged ].', + ) + }) +}) + +describe('formatConversationStyleList', () => { + test('lists every supported conversation style', () => { + expect(formatConversationStyleList()).toMatchInlineSnapshot(` + "• \`standard\`: current casual funny reminder + • \`basic\`: simple fixed reminder without AI + • \`humorous\`: light slapstick humor + • \`british\`: old-timey British colloquial tone + • \`kiwi\`: casual Kiwi slang + • \`unhinged\`: chaotic and absurd, but workplace-safe" + `) + }) +}) diff --git a/src/conversation-style.ts b/src/conversation-style.ts new file mode 100644 index 0000000..e643aec --- /dev/null +++ b/src/conversation-style.ts @@ -0,0 +1,56 @@ +const conversationStyleList = [ + 'standard', + 'basic', + 'humorous', + 'british', + 'kiwi', + 'unhinged', +] as const + +type ConversationStyle = (typeof conversationStyleList)[number] + +const defaultConversationStyle = 'standard' satisfies ConversationStyle + +const conversationStyleDescriptionMap: Record = { + standard: 'current casual funny reminder', + basic: 'simple fixed reminder without AI', + humorous: 'light slapstick humor', + british: 'old-timey British colloquial tone', + kiwi: 'casual Kiwi slang', + unhinged: 'chaotic and absurd, but workplace-safe', +} + +const validConversationStyleList = conversationStyleList.join(' | ') + +const isConversationStyle = (value: string): value is ConversationStyle => { + return conversationStyleList.includes(value as ConversationStyle) +} + +const parseConversationStyle = (value: string): ConversationStyle | Error => { + const normalizedValue = value.trim().toLowerCase() + + if (isConversationStyle(normalizedValue)) { + return normalizedValue + } + + return new Error( + `Invalid conversation style "${value}". Please use one of [ ${validConversationStyleList} ].`, + ) +} + +const formatConversationStyleList = (): string => { + return conversationStyleList + .map((style) => { + return `• \`${style}\`: ${conversationStyleDescriptionMap[style]}` + }) + .join('\n') +} + +export { + conversationStyleList, + defaultConversationStyle, + conversationStyleDescriptionMap, + formatConversationStyleList, + parseConversationStyle, +} +export type { ConversationStyle } diff --git a/src/lib/remind/send-reminder-to-user.ts b/src/lib/remind/send-reminder-to-user.ts index 821978b..edfc181 100644 --- a/src/lib/remind/send-reminder-to-user.ts +++ b/src/lib/remind/send-reminder-to-user.ts @@ -36,6 +36,7 @@ const sendReminderToUser = async ( let reminderText = await generateReminder({ name: user.name, daysSinceLastPost, + conversationStyle: user.conversationStyle, }) if (daysSinceLastPost >= daysSinceLastPostCutOff) {