diff --git a/community/period-tracker/.gitignore b/community/period-tracker/.gitignore new file mode 100644 index 0000000..1058e90 --- /dev/null +++ b/community/period-tracker/.gitignore @@ -0,0 +1,181 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + + +# ignore dbs +*.db +*.db-* + diff --git a/community/period-tracker/README.md b/community/period-tracker/README.md new file mode 100644 index 0000000..d48f577 --- /dev/null +++ b/community/period-tracker/README.md @@ -0,0 +1,206 @@ +# Period Tracker + +Period Tracker is a Discord bot designed to help anyone track their menstrual cycle in greater detail and more observability for specific events. +Try the bot out by [adding it](https://discord.com/oauth2/authorize?client_id=1287253133105430548&permissions=563347237972992&integration_type=0&scope=bot) to any server and sending a DM to the bot. + +## Table of Contents + +- [Features](#features) +- [Configuration](#configuration) +- [Commands](#commands) +- [Data Architecture](#data-architecture) +- [Development Setup](#development-setup) +- [Contributing](#contributing) +- [License](#license) + +## Features + +- **Cycle Management**: Start, end, and manage menstrual cycles with ease. +- **Daily Entries**: Log daily entries to track mood, symptoms, and other relevant information. +- **Automated Reminders**: Receive periodic reminders to log entries, ensuring consistent tracking. +- **Data Validation**: Robust schema validation ensures data integrity and consistency. +- **Tune Integration**: Utilize AI-powered summaries and intelligent prompts to enhance user interactions. + +## Configuration + +Configure secretes by `mv sample.en .env.local`. +Customize the bot's behavior by updating the `config.ts` file. Below is a sample configuration: + +```typescript +export const allowed_menstrual_users = ["509004765380739107"]; // User IDs authorized to interact with the bot via DM +export const system_logs_channel = "1177561269780361248"; // Channel ID for system and usage logs +export const discord_chat_history_limit = 50; // Total number of messages to process +export const discord_max_chat_messages = 10; // Number of latest messages to exclude from summarization (the rest will be summarized for context) +export const discord_reminder_cron = "0 */4 * * *"; // Cron schedule for sending periodic reminders +``` + +### Configuration Parameters + +- **allowed_menstrual_users**: An array of Discord user IDs permitted to interact with the bot via direct messages. +- **system_logs_channel**: The Discord channel ID where system and usage logs will be posted. +- **discord_chat_history_limit**: Defines the total number of messages the bot will process from chat history. +- **discord_max_chat_messages**: Specifies the number of recent messages that will not be summarized, providing context without redundancy. +- **discord_reminder_cron**: Cron expression determining the schedule for sending periodic reminders to users. + +## Commands + +Period Tracker supports the following commands: + +- **`stop` / `reset`**: Resets the starting point of the chat messages context, allowing users to clear previous interactions and start fresh. + +## Data Architecture + +Simple data management to allow for even smaller llms to understand. + +### 1. Database Setup + +#### **SQLite with Bun:sqlite** + +- **Database Instances**: + - **Production Database**: `period.db` + - **Test Database**: `test_period.db` + +#### **Database Initialization** + +- **Journal Mode**: Configured to `WAL` (Write-Ahead Logging) to improve concurrency and performance. +- **Table Creation**: On initialization, the database ensures the existence of necessary tables (`period_cycles` and `period_entries`), creating them if they do not already exist. + +### 2. Database Schema + +#### **Tables** + +1. **`period_cycles`** + + | Column | Type | Constraints | Description | + | ------------- | ------- | ----------- | ----------------------------------------------- | + | `id` | TEXT | PRIMARY KEY | Unique identifier for each period cycle. | + | `startDate` | TEXT | NOT NULL | ISO string representing the cycle's start date. | + | `endDate` | TEXT | NOT NULL | ISO string representing the cycle's end date. | + | `description` | TEXT | NOT NULL | Description or notes about the cycle. | + | `ended` | BOOLEAN | NOT NULL | Indicates whether the cycle has ended. | + +2. **`period_entries`** + + | Column | Type | Constraints | Description | + | ------------- | ---- | ----------- | --------------------------------------------- | + | `id` | TEXT | PRIMARY KEY | Unique identifier for each period entry. | + | `date` | TEXT | NOT NULL | ISO string representing the entry date. | + | `description` | TEXT | NOT NULL | Description of the user's feelings or events. | + +#### **Schema Validation with Zod** + +- **Purpose**: Ensures data integrity and type safety across the application. +- **Schemas Defined**: + - `PeriodCycleSchema` + - `PeriodEntrySchema` + - Additional schemas for tool parameters (e.g., `CreatePeriodCycleParams`, `CreatePeriodEntryParams`, etc.) + +### 3. Data Access Layer + +#### **Database Access Functions** + +- **Connection Management**: + + - **`usePrdDb()`**: Connects to the production database (`period.db`). + - **`useTestDb()`**: Connects to the test database (`test_period.db`). + +- **CRUD Operations for Period Cycles**: + + - **Create**: `createPeriodCycle()`, `createOldPeriodCycle()` + - **Read**: `getPeriodCycles()`, `getPeriodCyclesByMonth()`, `getPeriodCycleByDateRange()`, `getOngoingPeriodCycle()`, `getCurrentPeriodCycleTool()` + - **Update**: `updateEndDatePeriodCycle()`, `updateDescriptionPeriodCycle()`, `endPeriodCycle()` + - **Delete**: `clearprdandtestdb()`, `populateExampleData()` + +- **CRUD Operations for Period Entries**: + - **Create**: `createPeriodEntry()` + - **Read**: `getPeriodEntries()`, `getLatestPeriodEntry()`, `getPeriodEntriesByDateRange()`, `getPeriodEntryByDate()` + - **Update**: `updatePeriodEntryByDate()` + - **Delete**: _(Not explicitly defined, but can be implemented similarly)_ + +### 4. Model Tools + +#### **Runnable Tools** + +- **Purpose**: Facilitate interactions between the application and OpenAI's models to manage period data intelligently. +- **Tools Defined**: + + - `startNewPeriodCycle` + - `createOldPeriodCycle` + - `addOrUpdatePeriodEntryTool` + - `endPeriodCycleTool` + - `getCurrentPeriodCycleTool` + - `getPeriodEntriesTool` + - `getPeriodCycleByDateRangeTool` + - `getLatestPeriodEntryTool` + - `getVibeByDateRangeTool` + +- **Functionality**: + - **Creating and Managing Cycles**: Tools to start, create old cycles, and end cycles with user interactions. + - **Managing Entries**: Add or update period entries based on user input. + - **Retrieving Data**: Fetch current cycles, entries within date ranges, latest entries, and summarize user vibes. + +#### **Schema Definitions for Tools** + +- **Zod Schemas**: Each tool has an associated Zod schema defining the expected input parameters, ensuring that the data passed to the tools adheres to the required structure. + +### 5. Cron Jobs + +#### **Automated Period Entry Checks** + +- **Library Used**: `node-cron` +- **Purpose**: Periodically checks for recent period entries and sends reminders if no entry has been made within the last 4 hours. +- **Configuration**: + + - **Cron Schedule**: Defined in `discord_reminder_cron` (default: every 4 hours). + - **Timezone**: Set to `Asia/Kolkata`. + +- **Job Workflow**: + 1. **Check Ongoing Cycle**: Determines if there is an active period cycle. + 2. **Validate Latest Entry**: Verifies if the latest period entry is older than 4 hours. + 3. **Send Reminders**: For users in `discord_allowed_menstrual_users`, generates and sends a reminder message using OpenAI to encourage logging a new period entry. + 4. **Logging**: Records job execution details and any actions taken for monitoring purposes. + +### 6. Data Flow Overview + +1. **User Interaction**: + + - Users interact with the system via Discord or other interfaces, providing information about their menstrual cycles and daily entries. + +2. **Data Validation**: + + - Incoming data is validated against defined Zod schemas to ensure correctness and consistency. + +3. **Database Operations**: + + - Validated data is stored, updated, or retrieved from the SQLite databases (`period.db` for production and `test_period.db` for testing). + +4. **OpenAI Processing**: + + - Certain operations leverage OpenAI's capabilities to generate summaries, reminders, and intelligent prompts based on user data. + +5. **Automated Monitoring**: + - Cron jobs run at scheduled intervals to monitor data activity and prompt users as needed, ensuring timely and accurate data logging. + +--- + +## Development Setup + +To set up the development environment, follow the steps below: + +### Prerequisites + +- **Bun**: Ensure you have [Bun](https://bun.sh) installed. This project was created using `bun init` in Bun v1.1.8. + +### Installation + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` diff --git a/community/period-tracker/bun.lockb b/community/period-tracker/bun.lockb new file mode 100755 index 0000000..d65fda6 Binary files /dev/null and b/community/period-tracker/bun.lockb differ diff --git a/community/period-tracker/config.ts b/community/period-tracker/config.ts new file mode 100644 index 0000000..e32e25b --- /dev/null +++ b/community/period-tracker/config.ts @@ -0,0 +1,5 @@ +export const discord_allowed_menstrual_users = ["509004765380739107"]; +export const discord_system_logs_channel = "1177561269780361247"; +export const discord_chat_history_limit = 50; +export const discord_max_chat_messages = 10; +export const discord_reminder_cron = "0 */4 * * *" diff --git a/community/period-tracker/index.ts b/community/period-tracker/index.ts new file mode 100644 index 0000000..65cd173 --- /dev/null +++ b/community/period-tracker/index.ts @@ -0,0 +1,3 @@ +import { startDiscord } from "./interfaces/discord"; + +startDiscord(); diff --git a/community/period-tracker/interfaces/discord.ts b/community/period-tracker/interfaces/discord.ts new file mode 100644 index 0000000..b7d0ce2 --- /dev/null +++ b/community/period-tracker/interfaces/discord.ts @@ -0,0 +1,452 @@ +import YAML from "yaml"; +import { + ActivityType, + ChannelType, + Client, + GatewayIntentBits, + Message, + Partials, +} from "discord.js"; +import OpenAI from "openai"; +import { format } from "date-fns"; +import { getTools, zodFunction } from "../tools"; +import { createHash } from "crypto"; +import { startPeriodJob } from "../tools/period"; +import { z } from "zod"; +import { ask } from "../tools/ask"; +import { + discord_allowed_menstrual_users, + discord_chat_history_limit, + discord_max_chat_messages, + discord_system_logs_channel, +} from "../config"; + +export const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: [Partials.Channel], +}); +client.on("ready", () => { + console.log(`Logged in as ${client.user?.tag}!`); + client.user?.setActivity("as Human", { + type: Number(ActivityType.Playing), + }); +}); + +const ai_token = process.env.OPENAI_API_KEY?.trim(); +const api_base = process.env.OPENAI_BASE_URL?.trim(); + +if (!api_base) { + throw new Error("Missing OPENAI_BASE_URL"); +} + +let message_que: { + status_message: Message; + abort_controller: AbortController; + channel: string; + running_tools?: boolean; +}[] = []; + +function set_running_tools(channelId: string, state: boolean) { + message_que = message_que.map((m) => { + if (m.channel === channelId) { + m.running_tools = state; + } + return m; + }); +} + +let model = "openai/gpt-4o-mini"; + +const ChangeModelParam = z.object({ + model: z.string(z.enum(["openai/gpt-4o", "openai/gpt-4o-mini"])), +}); + +type ChangeModelParam = z.infer; + +export async function changeModel(param: ChangeModelParam) { + model = param.model; + return { + response: `Model changed to ${model}`, + }; +} + +client.on("messageCreate", async (message) => { + if (message.author.bot && message.author.id === client.user?.id) return; + + if ( + !( + message.channel.type === ChannelType.DM || + message.channel.type === ChannelType.GuildText + ) + ) { + return; + } + + const isDm = message.channel?.type === ChannelType.DM; + + if (!isDm) return; + + if (["stop", "reset"].includes(message.content.toLowerCase())) { + await message.channel.send("---setting this point as the start---"); + // clear maps + const hashes = channel_id_hash_maps.get(message.channel.id) ?? []; + hashes.forEach((hash) => { + tools_call_map.delete(hash); + }); + + return; + } + + const mque = message_que.find((m) => m.channel === message.channelId); + if (mque) { + if (!mque.running_tools) { + mque.abort_controller.abort(); + mque.status_message.deletable && mque.status_message.delete(); + message_que = message_que.filter((m) => m.channel !== message.channelId); + } else { + return; + } + } + + const temp_messages = await message.channel.messages.fetch({ limit: 20 }); + const stop_message = temp_messages.find( + (m) => m.content.toLowerCase() === "---setting this point as the start---", + ); + + // get last 10 messages from the channel + const messages: OpenAI.ChatCompletionMessageParam[] = await Promise.all( + ( + await message.channel.messages.fetch( + stop_message + ? { after: stop_message?.id } + : { limit: discord_chat_history_limit }, + ) + ) + .reverse() + .map(async (m) => { + const file = m.attachments.map((a) => a.url); + const embeds = m.embeds.map((e) => JSON.stringify(e)).join("\n"); + let content = m.content; + + const context_message = m.reference?.messageId + ? await message.channel.messages.fetch(m.reference.messageId) + : null; + const context_file = m.attachments.map((a) => a.url); + const context_embeds = m.embeds + .map((e) => JSON.stringify(e)) + .join("\n"); + + const context_as_json = JSON.stringify({ + embeds: embeds || undefined, + file: file || undefined, + user_message: content, + created_at: format(m.createdAt, "yyyy-MM-dd HH:mm:ss") + " IST", + context_message: context_message + ? { + author: context_message?.author.username, + created_at: + format(context_message.createdAt, "yyyy-MM-dd HH:mm:ss") + + " IST", + content: context_message?.content, + } + : undefined, + context_file: context_file || undefined, + context_embeds: context_embeds || undefined, + }); + + return { + role: m.author.id === client.user?.id ? "assistant" : "user", + content: context_as_json, + name: + m.author.id === client.user?.id + ? undefined + : m.author.username.replaceAll(".", "_").trim(), + }; + }), + ); + + let toolsmessages: OpenAI.ChatCompletionMessageParam[] = []; + let skipNext = false; + messages.forEach((m) => { + if (skipNext && m.role === "assistant") { + skipNext = false; + return; + } + const hash = generateHash(m.content?.toString() ?? ""); + const calls = tools_call_map.get(hash); + if (calls) { + toolsmessages.push(m); + toolsmessages = toolsmessages.concat(calls); + console.log("calls", calls); + console.log("updated toolsmessages", toolsmessages); + } else { + toolsmessages.push(m); + } + }); + + const admin_system_messages: OpenAI.ChatCompletionSystemMessageParam[] = [ + { + role: "system", + content: `Your name is PTracker + + You are designed to help anyone track their menstrual cycle in greater detail. + + Interaction Guidelines: + - Focused Responses: Address user queries directly, avoiding unnecessary information. + - Brevity: Keep responses concise and to the point. + + When you see context given inside the JSON of a message, it means that the message is a reply to the mentioned context. + + Always reply in plain text or markdown unless running a tool. + Make sure not to exceed 1500 characters in a single message. + You can send messages in multiple parts by breaking them down into smaller messages using multiple send message tool calls. + Use the "send message" tool to send long links or download links to the user. + + ${isDm ? `The current user's name is ${message.author.displayName}` : ""} + `, + }, + ]; + + const menstural_tracker_system_messages: OpenAI.ChatCompletionSystemMessageParam[] = + [ + { + role: "system", + content: `This is a private conversation between you and the user ${[ + message.author.username, + ]}. + + You need to help them track and manage their menstrual cycle. + Answer their queries and provide them with the necessary information. + Point out any irregularities in their cycle and suggest possible causes, but DO NOT DIAGNOSE. + + Current Date: ${format(new Date(), "yyyy-MM-dd HH:mm:ss")} IST + `, + }, + ]; + + let final_system_messages = + admin_system_messages as OpenAI.ChatCompletionMessageParam[]; + + if (discord_allowed_menstrual_users.includes(message.author.id)) { + final_system_messages = final_system_messages.concat( + menstural_tracker_system_messages, + ); + } + + final_system_messages = final_system_messages.concat(admin_system_messages); + + if (toolsmessages.length > discord_chat_history_limit) { + let lastten = toolsmessages.slice( + toolsmessages.length - discord_max_chat_messages, + ); + let firstten = toolsmessages.slice(0, discord_max_chat_messages); + console.log("Summerizing messages"); + message.channel.sendTyping(); + const waitMessage = await message.channel.send("Focusing..."); + const summary = await ask({ + model: "openai/gpt-4o-mini", + prompt: `Summarize the below conversation into 2 sections: + 1. General info about the conversation + 2. Tools used in the conversation and their data in relation to the conversation. + + Conversation: + ---- + ${YAML.stringify(firstten)} + ---- + + Notes: + - Keep only important information and points, remove anything repetitive. + - Keep tools information if they are relevant. + - The summary is to give context about the conversation that was happening previously. + `, + }); + waitMessage.deletable && (await waitMessage.delete()); + console.log("Summary:", summary.choices[0].message.content?.slice(0, 100)); + toolsmessages = [ + { + role: "system", + content: `Previous messages summarized: + ${summary.choices[0].message.content} + `, + } as OpenAI.ChatCompletionMessageParam, + ].concat(lastten); + } + let final_messages = final_system_messages.concat(toolsmessages); + + const reply = await message.channel.send({ + content: "thinking...", + }); + + const abort_controller = new AbortController(); + + message_que.push({ + status_message: reply, + abort_controller, + channel: message.channelId, + }); + + let done = false; + setTimeout(() => { + abort_controller.abort(); + !done && (reply.deletable ? reply.delete() : reply.edit("Timed out")); + message_que = message_que.filter((m) => m.channel !== message.channelId); + }, 600000); + + const openai = new OpenAI({ + apiKey: ai_token, + baseURL: api_base, + }); + + const hash = generateHash( + final_messages[final_messages.length - 1].content?.toString() ?? "", + ); + + const old_hashs = channel_id_hash_maps.get(message.channel.id) ?? []; + channel_id_hash_maps.set(message.channel.id, [...old_hashs, hash]); + + const tool_calls: Array< + | OpenAI.ChatCompletionAssistantMessageParam + | OpenAI.ChatCompletionToolMessageParam + > = []; + + console.log("final_messages", final_messages); + + const runner = openai.beta.chat.completions + .runTools( + { + model, + temperature: 0.6, + user: message.author.username, + messages: final_messages, + stream: true, + tools: [ + ...getTools(message.author.username, message), + zodFunction({ + name: "changeModel", + schema: ChangeModelParam, + function: changeModel, + description: `You have the ability to change the model between "openai/gpt-4o" and "openai/gpt-4o-mini". + The current model is ${model} + + "openai/gpt-4o" is a larger model and is more accurate but slower. (expensive) + "openai/gpt-4o-mini" is a smaller model and is faster but less accurate. (cheaper) + + Switching between models can be useful when you need faster responses or more accurate responses. + + You can also switch to the cheaper model when there are a lot of messages to deal with. + + Try to keep the model as "openai/gpt-4o-mini" to save cost unless you need the extra accuracy. + `, + }), + ], + }, + { signal: abort_controller.signal }, + ) + .on("functionCall", async (fnc) => { + console.log("calling: ", fnc); + + set_running_tools(message.channelId, true); + + await reply.edit(`running ${fnc.name}(${fnc.arguments.slice(20)})...`); + }) + .on("finalContent", (content) => { + console.log("replied: ", content); + }) + .on("error", (err) => { + console.log("error: ", err); + reply.edit("Error: " + JSON.stringify(err)); + message_que = message_que.filter((m) => m.channel !== message.channelId); + done = true; + }) + .on("abort", () => { + console.log("aborted"); + }) + .on("message", (m) => { + console.log(m); + message.channel.sendTyping(); + if (m.role === "assistant" && m.tool_calls?.length) { + tool_calls.push(m); + } + if (m.role === "tool" && m.tool_call_id) { + tool_calls.push(m); + } + }); + + const final = await runner.finalContent(); + + tools_call_map.set(hash, tool_calls); + + try { + reply.deletable && reply.delete(); + } catch (error) { + console.log("failed to delete a status message"); + } + + if (final && !final.includes("")) { + const content = isJsonParseable(final); + if (content.user_message) { + await message.channel.send(content.user_message); + } + if (content === false) { + await message.channel.send(final); + } + message_que = message_que.filter((m) => m.channel !== message.channelId); + } + done = true; +}); + +function isJsonParseable(str: string) { + try { + return JSON.parse(str); + } catch (e) { + return false; + } +} + +function generateHash(input: string): string { + const hash = createHash("sha256"); + hash.update(input); + return hash.digest("hex"); +} + +const tools_call_map = new Map< + string, + ( + | OpenAI.ChatCompletionAssistantMessageParam + | OpenAI.ChatCompletionToolMessageParam + )[] +>(); + +const channel_id_hash_maps = new Map(); + +export function startDiscord() { + client.login(process.env.DISCORD_BOT_TOKEN).then(() => { + startPeriodJob(); + send_system_log("ptracker is online"); + }); +} + +export function send_message_to_user( + user_id: string, + content: string, + embeds?: any, + files?: any, +) { + client.users.fetch(user_id).then((user) => { + user.send({ content, embeds, files }); + }); +} + +export function send_system_log(content: string) { + client.channels.fetch(discord_system_logs_channel).then((channel) => { + if (channel?.type !== ChannelType.GuildText) { + return; + } + channel.send(content); + }); +} diff --git a/community/period-tracker/package.json b/community/period-tracker/package.json new file mode 100644 index 0000000..7a6bea2 --- /dev/null +++ b/community/period-tracker/package.json @@ -0,0 +1,22 @@ +{ + "name": "ptrack", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@types/node-cron": "^3.0.11", + "date-fns": "^4.1.0", + "discord.js": "^14.16.2", + "mathjs": "^13.1.1", + "node-cron": "^3.0.3", + "openai": "^4.63.0", + "yaml": "^2.5.1", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.3" + } +} \ No newline at end of file diff --git a/community/period-tracker/sample.en b/community/period-tracker/sample.en new file mode 100644 index 0000000..6282c2f --- /dev/null +++ b/community/period-tracker/sample.en @@ -0,0 +1,3 @@ +DISCORD_TOKEN= +OPENAI_API_KEY= +OPENAI_BASE_URL=https://proxy.tune.app diff --git a/community/period-tracker/tools/ask.ts b/community/period-tracker/tools/ask.ts new file mode 100644 index 0000000..370acf0 --- /dev/null +++ b/community/period-tracker/tools/ask.ts @@ -0,0 +1,29 @@ +import OpenAI from "openai"; + +const ai_token = process.env.OPENAI_API_KEY?.trim(); +const ai_baseurl = process.env.OPENAI_API_BASEURL?.trim(); + +export async function ask({ + model = "openai/gpt-4o-mini", + prompt, +}: { + model?: string; + prompt: string; +}) { + const openai = new OpenAI({ + apiKey: ai_token, + baseURL: ai_baseurl, + }); + + const res = await openai.chat.completions.create({ + model, + messages: [ + { + role: "system", + content: prompt, + }, + ], + }); + + return res; +} diff --git a/community/period-tracker/tools/index.ts b/community/period-tracker/tools/index.ts new file mode 100644 index 0000000..b6d225a --- /dev/null +++ b/community/period-tracker/tools/index.ts @@ -0,0 +1,65 @@ +import { Message } from "discord.js"; +import type { + RunnableToolFunction, + RunnableToolFunctionWithParse, +} from "openai/lib/RunnableFunction.mjs"; +import type { JSONSchema } from "openai/lib/jsonschema.mjs"; +import { z, ZodSchema } from "zod"; +import zodToJsonSchema from "zod-to-json-schema"; +import { evaluate } from "mathjs"; +import { getPeriodTools } from "./period"; +import { discord_allowed_menstrual_users } from "../config"; + +// calculator function +const CalculatorParams = z.object({ + expression: z.string().describe("mathjs expression"), +}); +type CalculatorParams = z.infer; +async function calculator({ expression }: CalculatorParams) { + return { response: evaluate(expression) }; +} + +export function getTools(username: string, context_message: Message) { + const isPeriodUser = discord_allowed_menstrual_users.includes(context_message.author.id); + + let tools: RunnableToolFunction[] = [ + zodFunction({ + function: calculator, + schema: CalculatorParams, + description: "This can be used to evaluate exact date time durations.", + }), + ]; + + if (isPeriodUser) { + const period_tools = getPeriodTools(); + tools = tools.concat(period_tools); + } + + return tools; +} + +export function zodFunction({ + function: fn, + schema, + description = "", + name, +}: { + function: (args: T) => Promise; + schema: ZodSchema; + description?: string; + name?: string; +}): RunnableToolFunctionWithParse { + return { + type: "function", + function: { + function: fn, + name: name ?? fn.name, + description: description, + parameters: zodToJsonSchema(schema) as JSONSchema, + parse(input: string): T { + const obj = JSON.parse(input); + return schema.parse(obj); + }, + }, + }; +} diff --git a/community/period-tracker/tools/period.ts b/community/period-tracker/tools/period.ts new file mode 100644 index 0000000..e064a36 --- /dev/null +++ b/community/period-tracker/tools/period.ts @@ -0,0 +1,664 @@ +import { Database } from "bun:sqlite"; +import type { RunnableToolFunction } from "openai/lib/RunnableFunction.mjs"; +import { z } from "zod"; +import { ask } from "./ask"; + +import cron from "node-cron"; +import { zodFunction } from "."; +import { + discord_allowed_menstrual_users, + discord_reminder_cron, +} from "../config"; +import { send_message_to_user, send_system_log } from "../interfaces/discord"; + +// pupulate example data function +export function populateExampleData() { + db.query("DELETE FROM period_cycles").run(); + db.query("DELETE FROM period_entries").run(); +} + +export function clearprdandtestdb() { + if (db) db.close(); + const prddb = usePrdDb(); + const testdb = useTestDb(); + prddb.query("DELETE FROM period_cycles").run(); + prddb.query("DELETE FROM period_entries").run(); + + testdb.query("DELETE FROM period_cycles").run(); + testdb.query("DELETE FROM period_entries").run(); +} + +// util functions for managing menstrual cycle + +const PeriodCycleSchema = z.object({ + id: z.string(), + startDate: z.string(), + endDate: z.string(), + description: z.string(), + ended: z.boolean(), +}); + +const PeriodEntrySchema = z.object({ + id: z.string(), + date: z.string(), + description: z.string(), +}); + +export type PeriodCycleType = z.infer; +export type PeriodEntryType = z.infer; + +let db = usePrdDb(); + +function usePrdDb() { + const db_url = "period.db"; + const db = new Database(db_url, { create: true }); + // setup the tables, create if not existing and auto migrate if any change in schema + db.exec("PRAGMA journal_mode = WAL;"); + db.query( + `CREATE TABLE IF NOT EXISTS period_cycles ( + id TEXT PRIMARY KEY, + startDate TEXT NOT NULL, + endDate TEXT NOT NULL, + description TEXT NOT NULL, + ended BOOLEAN NOT NULL + )`, + ).run(); + + db.query( + `CREATE TABLE IF NOT EXISTS period_entries ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + description TEXT NOT NULL + )`, + ).run(); + return db; +} + +function useTestDb() { + const db_url = "test_period.db"; + const db = new Database(db_url, { create: true }); + // setup the tables, create if not existing and auto migrate if any change in schema + db.exec("PRAGMA journal_mode = WAL;"); + db.query( + `CREATE TABLE IF NOT EXISTS period_cycles ( + id TEXT PRIMARY KEY, + startDate TEXT NOT NULL, + endDate TEXT NOT NULL, + description TEXT NOT NULL, + ended BOOLEAN NOT NULL + )`, + ).run(); + + db.query( + `CREATE TABLE IF NOT EXISTS period_entries ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + description TEXT NOT NULL + )`, + ).run(); + return db; +} + +export function getPeriodCycles() { + const cycles = db.query("SELECT * FROM period_cycles").all(); + return cycles as PeriodCycleType[]; +} + +// get period cycles for a given month +export function getPeriodCyclesByMonth(month_index: number, year: number) { + const startDate = new Date(year, month_index, 1).toISOString(); + const endDate = new Date(year, month_index + 1, 1).toISOString(); + const cycles = db + .query( + "SELECT * FROM period_cycles WHERE startDate >= $startDate AND startDate < $endDate", + ) + .all({ + $startDate: startDate, + $endDate: endDate, + }); + return cycles as PeriodCycleType[]; +} + +export function getPeriodCycleByDateRange(startDate: Date, endDate: Date) { + const cycles = db + .query( + "SELECT * FROM period_cycles WHERE startDate >= $startDate AND startDate < $endDate", + ) + .all({ + $startDate: startDate.toISOString(), + $endDate: endDate.toISOString(), + }); + return cycles as PeriodCycleType[]; +} + +export function createPeriodCycle( + startDate: Date, + endDate: Date, + ended?: boolean, +) { + db.query( + `INSERT INTO period_cycles (id, startDate, endDate, description, ended) VALUES + ($id, $startDate, $endDate, $description, $ended)`, + ).run({ + $id: Math.random().toString(36).substring(2, 15), + $startDate: startDate.toISOString(), + $endDate: endDate.toISOString(), + $description: `Started on ${startDate.toISOString()}`, + $ended: ended ? 1 : 0, + }); +} + +export function getAverageCycleLength() { + const cycles = getPeriodCycles(); + const totalLength = cycles.reduce((acc, cycle) => { + const startDate = new Date(cycle.startDate); + const endDate = new Date(cycle.endDate); + return acc + (endDate.getTime() - startDate.getTime()) / 86400000; + }, 0); + return totalLength / cycles.length; +} + +export function updateEndDatePeriodCycle(id: string, endDate: Date) { + db.query("UPDATE period_cycles SET endDate = $endDate WHERE id = $id").run({ + $id: id, + $endDate: endDate.toISOString(), + }); +} + +export function updateDiscriptionPeriodCycle(id: string, discription: string) { + db.query( + "UPDATE period_cycles SET description = $description WHERE id = $id", + ).run({ + $id: id, + $description: discription, + }); +} + +export function endPeriodCycle(id: string, discription?: string) { + db.query("UPDATE period_cycles SET ended = 1 WHERE id = $id").run({ + $id: id, + }); + updateEndDatePeriodCycle(id, new Date()); + if (discription) { + updateDiscriptionPeriodCycle(id, discription); + } +} + +export function getOngoingPeriodCycle() { + const cycle = db.query("SELECT * FROM period_cycles WHERE ended = 0").get(); + return cycle as PeriodCycleType; +} + +export function getPeriodEntries() { + const entries = db.query("SELECT * FROM period_entries").get(); + return entries as PeriodEntryType[]; +} + +export function getLatestPeriodEntry() { + const entry = db + .query("SELECT * FROM period_entries ORDER BY date DESC") + .get(); + return entry as PeriodEntryType; +} + +export function getPeriodEntriesByDateRange(startDate: Date, endDate: Date) { + const entries = db + .query( + "SELECT * FROM period_entries WHERE date >= $startDate AND date < $endDate", + ) + .all({ + $startDate: startDate.toISOString(), + $endDate: endDate.toISOString(), + }); + return entries as PeriodEntryType[]; +} + +export function getPeriodEntryByDate(date: Date) { + const entry = db + .query("SELECT * FROM period_entries WHERE date = $date") + .get({ $date: date.toISOString() }); + return entry as PeriodEntryType; +} + +export function updatePeriodEntryByDate(date: Date, description: string) { + db.query( + "UPDATE period_entries SET description = $description WHERE date = $date", + ).run({ + $date: date.toISOString(), + $description: description, + }); +} + +export function createPeriodEntry(date: Date, description: string) { + db.query( + `INSERT INTO period_entries (id, date, description) VALUES + ($id, $date, $description)`, + ).run({ + $id: Math.random().toString(36).substring(2, 15), + $date: date.toISOString(), + $description: description, + }); +} + +// open ai tools to manage the cycles + +// create cycle tool +export const CreatePeriodCycleParams = z.object({ + startDate: z + .string() + .describe("Date of the start of the period cycle in ISO string format IST"), + endDate: z + .string() + .describe( + "The esimated end date of the period cycle, ask user how long does their period usually last and use that data to calculate this. This has to be in ISO string format IST", + ), +}); + +export type CreatePeriodCycleParamsType = z.infer< + typeof CreatePeriodCycleParams +>; + +export async function startNewPeriodCycle({ + startDate, + endDate, +}: CreatePeriodCycleParamsType) { + if (!startDate || !endDate) { + return { error: "startDate and endDate are required" }; + } + + // check if there is an ongoing cycle + const ongoing = getOngoingPeriodCycle(); + if (ongoing) { + return { + error: "There is already an ongoing cycle", + ongoingCycle: ongoing, + }; + } + + createPeriodCycle(new Date(startDate), new Date(endDate)); + return { message: "Started a new period cycle" }; +} + +// create old period cycle tool +export const CreateOldPeriodCycleParams = z.object({ + startDate: z + .string() + .describe("Date of the start of the period cycle in ISO string format IST"), + endDate: z + .string() + .describe( + "When did this cycle end. This has to be in ISO string format IST", + ), +}); + +export type CreateOldPeriodCycleParamsType = z.infer< + typeof CreateOldPeriodCycleParams +>; + +export async function createOldPeriodCycle({ + startDate, + endDate, +}: CreateOldPeriodCycleParamsType) { + if (!startDate || !endDate) { + return { error: "startDate and endDate are required" }; + } + + createPeriodCycle(new Date(startDate), new Date(endDate), true); + return { message: "Started a new period cycle" }; +} + +// create entry tool +export const CreatePeriodEntryParams = z.object({ + date: z + .string() + .describe( + "Specify a date & time to add a past entry, no need to specify for a new entry", + ) + .default(new Date().toISOString()) + .optional(), + description: z + .string() + .describe("description of the vibe the user felt on the day"), +}); + +export type CreatePeriodEntryParamsType = z.infer< + typeof CreatePeriodEntryParams +>; + +export async function addOrUpdatePeriodEntryTool({ + date, + description, +}: CreatePeriodEntryParamsType) { + date = date || new Date().toISOString(); + + try { + const cycles = getPeriodCycleByDateRange( + new Date(new Date().setFullYear(new Date().getFullYear() - 1)), + new Date(), + ); + if (cycles.length === 0) { + return { + error: + "You cannot update or add to a cycle that's more than a year old", + }; + } + + const cycle = cycles.find( + (cycle) => + new Date(date) >= new Date(cycle.startDate) && + new Date(date) <= new Date(cycle.endDate), + ); + + if (!cycle) { + return { + error: + "The specified date does not seem to be part of any existing cycle. Please check the date and or start a new cycle from this date and try again.", + }; + } + + createPeriodEntry(new Date(date), description); + return { + message: "Added a new entry", + }; + } catch (error) { + return { + error: "An error occurred while processing the request", + }; + } +} + +// end cycle tool + +export const EndPeriodCycleParams = z.object({ + description: z + .string() + .describe("How did the user feel during this cycle on average"), +}); + +export type EndPeriodCycleParamsType = z.infer; + +export async function endPeriodCycleTool({ + description, +}: EndPeriodCycleParamsType) { + const ongoingCycle = getOngoingPeriodCycle(); + const id = ongoingCycle ? ongoingCycle.id : null; + + if (!id) { + return { error: "There is no ongoing cycle" }; + } + + endPeriodCycle(id, description); + return { message: "Ended the period cycle" }; +} + +// get current cycle tool +export const GetCurrentPeriodCycleParams = z.object({}); + +export type GetCurrentPeriodCycleParamsType = z.infer< + typeof GetCurrentPeriodCycleParams +>; + +export async function getCurrentPeriodCycleTool() { + try { + const cycle = getOngoingPeriodCycle(); + + console.log(cycle); + + // days since period started + const noOfDaysSinceStart = Math.floor( + (new Date().getTime() - new Date(cycle.startDate).getTime()) / 86400000, + ); + + const averageCycleLength = getAverageCycleLength(); + + let note = + averageCycleLength > 5 + ? noOfDaysSinceStart > averageCycleLength + ? "Cycle is overdue" + : "" + : undefined; + + if (cycle.ended) { + note = + "There are no ongoing cycles. this is just the last cycle that ended."; + } + + if (!cycle.ended) { + const endDate = new Date(cycle.endDate); + if (endDate < new Date()) { + note = "Cycle is overdue, or you forgot to end the cycle."; + } + } + + const response = { + cycle, + todaysDate: new Date().toISOString(), + noOfDaysSinceStart: cycle.ended ? undefined : noOfDaysSinceStart, + averageCycleLength, + note, + }; + + return response; + } catch (error) { + return { + error: "No ongoing cycle", + }; + } +} + +// get entries in a date range tool +export const GetPeriodEntriesParams = z.object({ + startDate: z.string().describe("Start date in ISO string format IST"), + endDate: z.string().describe("End date in ISO string format IST"), +}); + +export type GetPeriodEntriesParamsType = z.infer; + +export async function getPeriodEntriesTool({ + startDate, + endDate, +}: GetPeriodEntriesParamsType) { + const entries = getPeriodEntriesByDateRange( + new Date(startDate), + new Date(endDate), + ); + return entries; +} + +// get vibe by date range tool +export const GetVibeByDateRangeParams = z.object({ + startDate: z.string().describe("Start date in ISO string format IST"), + endDate: z.string().describe("End date in ISO string format IST"), +}); + +export type GetVibeByDateRangeParamsType = z.infer< + typeof GetVibeByDateRangeParams +>; + +export async function getVibeByDateRangeTool({ + startDate, + endDate, +}: GetVibeByDateRangeParamsType) { + const entries = getPeriodEntriesByDateRange( + new Date(startDate), + new Date(endDate), + ); + + ask({ + prompt: `Give me the general summary from the below entries that are a part of a period cycle: + ---- + [${entries.map((entry) => entry.description).join("\n")}] + ---- + + the above are entries from ${startDate} to ${endDate} + you need to give a general short summary of how the user felt during this period. + `, + }); + + return entries; +} + +// get cycle by date range tool +export const GetPeriodCycleByDateRangeParams = z.object({ + startDate: z.string().describe("Start date in ISO string format IST"), + endDate: z.string().describe("End date in ISO string format IST"), +}); + +export type GetPeriodCycleByDateRangeParamsType = z.infer< + typeof GetPeriodCycleByDateRangeParams +>; + +export async function getPeriodCycleByDateRangeTool({ + startDate, + endDate, +}: GetPeriodCycleByDateRangeParamsType) { + const cycles = getPeriodCycleByDateRange( + new Date(startDate), + new Date(endDate), + ); + return cycles; +} + +// get latest period entry tool +export const GetLatestPeriodEntryParams = z.object({}); +export type GetLatestPeriodEntryParamsType = z.infer< + typeof GetLatestPeriodEntryParams +>; + +export async function getLatestPeriodEntryTool() { + const entry = getLatestPeriodEntry(); + return entry; +} + +export function getPeriodTools(): RunnableToolFunction[] { + db.close(); + db = usePrdDb(); + + return [ + zodFunction({ + function: startNewPeriodCycle, + name: "startNewPeriodCycle", + schema: CreatePeriodCycleParams, + description: `Start a new period cycle. + You can specify the start date, end date. + You need to ask how the user is feeling and make a period entry about this. + `, + }), + zodFunction({ + function: createOldPeriodCycle, + name: "createOldPeriodCycle", + schema: CreateOldPeriodCycleParams, + description: `Create a period cycle that has already ended. + if the user wants to add entries of older period cycles, you can create a cycle that has already ended. + ask the user for the start date and end date of the cycle in natural language. + `, + }), + zodFunction({ + function: addOrUpdatePeriodEntryTool, + name: "addOrUpdatePeriodEntry", + schema: CreatePeriodEntryParams, + description: `Add or update a period entry. If the entry for the date already exists, it will be updated.`, + }), + zodFunction({ + function: endPeriodCycleTool, + name: "endPeriodCycle", + schema: EndPeriodCycleParams, + description: `End ongoing period cycle. make sure to confirm with the user before ending the cycle. + Ask the user if their cycle needs to be ended if its been more than 7 days since the start date of the cycle.`, + }), + zodFunction({ + function: getCurrentPeriodCycleTool, + name: "getCurrentPeriodCycle", + schema: GetCurrentPeriodCycleParams, + description: `Get the ongoing period cycle. + This returns the ongoing period cycle, the number of days since the cycle started & the average cycle length. + `, + }), + zodFunction({ + function: getPeriodEntriesTool, + name: "getPeriodEntriesByDateRange", + schema: GetPeriodEntriesParams, + description: "Get period entries in a date range", + }), + zodFunction({ + function: getPeriodCycleByDateRangeTool, + name: "getPeriodCycleByDateRange", + schema: GetPeriodCycleByDateRangeParams, + description: "Get period cycles in a date range", + }), + zodFunction({ + function: getLatestPeriodEntryTool, + name: "getLatestPeriodEntry", + schema: GetLatestPeriodEntryParams, + description: "Get the latest period entry", + }), + zodFunction({ + function: getVibeByDateRangeTool, + name: "getVibeByDateRange", + schema: GetVibeByDateRangeParams, + description: `Get the general vibe of the user in a date range. + This will ask the user to give a general summary of how they felt during this period. + `, + }), + ]; +} + +// cron job that checks if there is an ongoing cycle and does a console.log if there is no period entry in the last 4 hours +var jobStarted = false; +export function startPeriodJob() { + const timezone = "Asia/Kolkata"; + cron.schedule( + discord_reminder_cron ?? "0 */4 * * *", + async () => { + console.log("Checking for period entries in the last 2 hours"); + send_system_log("Running job"); + + const cycle = getOngoingPeriodCycle(); + console.log("cycle", cycle); + + if (!cycle) { + return; + } + + const entry = getLatestPeriodEntry(); + console.log("entry", entry); + + const isOldEntry = + new Date(entry.date) < new Date(new Date().getTime() - 14400000); + + if (isOldEntry) { + console.log("No period entry in the last 2 hours"); + discord_allowed_menstrual_users.forEach(async (user) => { + const message_for_user = await ask({ + prompt: `Generate a message to remind the user to make a period entry. + + Ask user how they are feelig about their period cycle and as its been a while since they updated how they felt. + Do not exlplicitly ask them to make an entry, just ask them how they are feeling about their period. + + todays date: ${new Date().toISOString()} + + ongoing cycle: ${JSON.stringify(cycle)} + + Note: if the end date is in the past then ask the user if the cycle is still going on or is it ok to end the cycle. + + last entry: ${JSON.stringify(entry)}`, + }); + if (message_for_user.choices[0].message.content) { + send_message_to_user( + user, + message_for_user.choices[0].message.content, + ); + } else { + console.log("No message generated"); + } + }); + } + }, + { + timezone, + recoverMissedExecutions: true, + runOnInit: true, + }, + ); + jobStarted = true; +} diff --git a/community/period-tracker/tsconfig.json b/community/period-tracker/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/community/period-tracker/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}