├── .env.example ├── .gitignore ├── Dockerfile ├── README.md ├── bun.lockb ├── docker-compose.yml ├── package-lock.json ├── package.json ├── src ├── api │ ├── hoarder.ts │ └── types.ts ├── index.ts ├── services │ ├── discord.ts │ ├── email.ts │ ├── mattermost.ts │ ├── rss.ts │ └── scheduler.ts └── utils │ └── config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Hoarder API (required) 2 | HOARDER_API_KEY=your_api_key_here 3 | HOARDER_SERVER_URL=https://your-hoarder-instance.com 4 | 5 | # Notification settings 6 | NOTIFICATION_METHOD=email # Options: email, discord, mattermost, or rss 7 | NOTIFICATION_FREQUENCY=daily # or weekly, monthly 8 | BOOKMARKS_COUNT=3 9 | SPECIFIC_LIST_ID='' # Leave empty for all bookmarks 10 | TIMEZONE=America/New_York # Your local timezone (e.g., America/New_York, Europe/London, etc.) 11 | TIME_TO_SEND=09:00 # Time to send in 24-hour format (HH:MM) 12 | 13 | # Email configuration (required if NOTIFICATION_METHOD=email) 14 | EMAIL_SERVICE=gmail 15 | EMAIL_USER=your_email@example.com 16 | EMAIL_PASS=your_app_password 17 | EMAIL_RECIPIENT=recipient@example.com 18 | 19 | # Discord configuration (required if NOTIFICATION_METHOD=discord) 20 | DISCORD_BOT_TOKEN=your_discord_bot_token 21 | DISCORD_CHANNEL_ID=your_discord_channel_id 22 | 23 | # Mattermost configuration (required if NOTIFICATION_METHOD=mattermost) 24 | MATTERMOST_WEBHOOK_URL=your_mattermost_webhook_url 25 | MATTERMOST_CHANNEL=your_mattermost_channel 26 | 27 | # RSS configuration (no additional settings needed if NOTIFICATION_METHOD=rss) 28 | # Feed will be available at http://localhost:8080/rss/feed -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:latest 2 | 3 | WORKDIR /app 4 | 5 | # Copy package files 6 | COPY package.json bun.lockb ./ 7 | 8 | # Install dependencies 9 | RUN bun install --production 10 | 11 | # Copy source code 12 | COPY . . 13 | 14 | # Build TypeScript code 15 | RUN bun build ./src/index.ts --outdir ./dist --target=bun 16 | 17 | # Start the application 18 | CMD ["bun", "src/index.ts"] 19 | 20 | # Expose the port 21 | EXPOSE 8080 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Karakeep Random Bookmark 2 | 3 | This application sends random bookmarks from your Karakeep (formerly Hoarder) account to your email, Discord, Mattermost, or RSS feed at scheduled intervals. This is a way to remember and discover all the bookmarks you've saved. 4 | Send from a specific list or all bookmarks, daily, weekly, or monthly. 5 | 6 | ## Features 7 | 8 | - Sends random bookmarks on a daily, weekly, or monthly schedule 9 | - Supports email, Discord, Mattermost, and RSS feed notifications 10 | - Configurable number of bookmarks to send 11 | - Option to select bookmarks from all lists or a specific list 12 | - Self-host with Docker 13 | 14 | ## Getting Started 15 | 16 | ### Obtaining a Karakeep API Key 17 | 18 | To use this application, you'll need an API key from your Karakeep account: 19 | 20 | 1. Log in to your Karakeep account and go to settings 21 | 2. Click 'Api keys' 22 | 3. Create a new key and copy it 23 | 4. This is your `HOARDER_API_KEY` for the application 24 | 25 | If you have the [hoarder CLI](https://docs.hoarder.app/command-line/) installed, you can validate that your API key is working by using the following command: 26 | 27 | ```bash 28 | hoarder --api-key --server-addr whoami 29 | ``` 30 | 31 | ### Configuring Your Hoarder Server URL 32 | 33 | Set the `HOARDER_SERVER_URL` environment variable to point to your server: 34 | 35 | ``` 36 | HOARDER_SERVER_URL=https://your-hoarder-instance.com 37 | ``` 38 | 39 | ### Getting Your List IDs 40 | 41 | If you want to send bookmarks from a specific list, you'll need to get the list ID. You can retrieve your list IDs using a curl command: 42 | 43 | ```bash 44 | curl -L "/api/v1/lists" \ 45 | -H "Accept: application/json" \ 46 | -H "Authorization: Bearer " | jq 47 | ``` 48 | 49 | This will return a JSON response containing all your lists with their IDs. Copy the ID of the list you want to use and set it as the `SPECIFIC_LIST_ID` in your `.env` file. 50 | 51 | ## Deployment 52 | 53 | ### 1. Clone the repository 54 | 55 | ```bash 56 | git clone https://github.com/treyg/hoarder-random-bookmark.git 57 | cd hoarder-random-bookmark 58 | ``` 59 | 60 | ### 2. Configure environment variables 61 | 62 | Create a `.env` file based on the provided `.env.example`: 63 | 64 | ```bash 65 | cp .env.example .env 66 | ``` 67 | 68 | Edit the `.env` file with your configuration: 69 | 70 | - Add your Karakeep API key 71 | - Set your Karakeep Server URL 72 | - Choose notification method (email or discord) 73 | - Set frequency (daily, weekly, monthly) 74 | - Configure your timezone and preferred time for notifications 75 | - Configure notification-specific settings 76 | 77 | > If you need to change the server port mapping from `8080`, you can do that in the `docker-compose.yml` file. 78 | 79 | ### 3. Deploy with Docker Compose 80 | 81 | ```bash 82 | docker-compose up -d 83 | ``` 84 | 85 | ### 4. Verify the deployment 86 | 87 | Check the application status: 88 | 89 | ```bash 90 | docker-compose logs -f 91 | ``` 92 | 93 | Visit http://localhost:8080 to see the application status. 94 | 95 | ## Development 96 | 97 | To install dependencies: 98 | 99 | ```bash 100 | bun install 101 | ``` 102 | 103 | To run in development mode: 104 | 105 | ```bash 106 | bun run --watch src/index.ts 107 | ``` 108 | 109 | ## Testing 110 | 111 | ### Testing General Functionality 112 | 113 | To trigger an immediate send for testing: 114 | 115 | ```bash 116 | curl -X POST http://localhost:8080/send-now 117 | ``` 118 | 119 | ### Testing Email Specifically 120 | 121 | To test the email functionality with a sample bookmark: 122 | 123 | ```bash 124 | curl http://localhost:8080/test-email 125 | ``` 126 | 127 | This will send a test email with a sample bookmark to your configured email recipient. 128 | 129 | ### Testing RSS Feed 130 | 131 | To access your RSS feed (when configured): 132 | 133 | ```bash 134 | curl http://localhost:8080/rss/feed 135 | ``` 136 | 137 | Or simply open `http://localhost:8080/rss/feed` in your browser or RSS reader. The feed will update according to your configured schedule (daily, weekly, or monthly). 138 | 139 | ### Testing Discord Specifically 140 | 141 | To test the Discord functionality with a sample bookmark: 142 | 143 | ```bash 144 | curl http://localhost:8080/test-discord 145 | ``` 146 | 147 | ## Scheduling Configuration 148 | 149 | ### Timezone and Time Settings 150 | 151 | By default, notifications are sent at 9:00 AM UTC. You can customize both the time and timezone: 152 | 153 | ``` 154 | # In your .env file 155 | TIMEZONE=America/New_York # Your local timezone 156 | TIME_TO_SEND=09:00 # Time in 24-hour format (HH:MM) 157 | ``` 158 | 159 | #### Available Options: 160 | 161 | - **TIMEZONE**: Any valid IANA timezone name (e.g., `America/New_York`, `Europe/London`, `Asia/Tokyo`) 162 | - **TIME_TO_SEND**: Time in 24-hour format (HH:MM), such as `09:00` for 9 AM or `21:30` for 9:30 PM 163 | 164 | These settings apply to all notification frequencies (daily, weekly, monthly). For example: 165 | 166 | - With `NOTIFICATION_FREQUENCY=daily` and `TIME_TO_SEND=21:30`, you'll receive notifications every day at 9:30 PM 167 | - With `NOTIFICATION_FREQUENCY=weekly` and `TIME_TO_SEND=18:00`, you'll receive notifications every Monday at 6:00 PM 168 | 169 | ## Troubleshooting 170 | 171 | ### Email Notifications 172 | 173 | **Important Note for Gmail Users**: Gmail (and many other email providers) no longer allows less secure apps to access your account using your regular password. You must use an App Password instead: 174 | 175 | 1. Enable 2-factor authentication on your Google account (if not already enabled) 176 | - Go to your Google Account > Security > 2-Step Verification 177 | 2. Go to https://myaccount.google.com/apppasswords 178 | 3. Select "App" dropdown and choose "Other (Custom name)" 179 | 4. Enter a name like "Hoarder Random Bookmark" 180 | 5. Click "Generate" 181 | 6. Copy the password that appears 182 | 7. Use this generated password as your `EMAIL_PASS` in the `.env` file (not your regular Gmail password) 183 | 8. For `EMAIL_SERVICE`, use "gmail" 184 | 185 | This is required for security reasons and cannot be bypassed. Similar steps may be required for other email providers like Outlook, Yahoo, etc. 186 | 187 | ### Discord Notifications 188 | 189 | #### Getting a Discord Bot Token: 190 | 191 | 1. **Create a Discord Application**: 192 | 193 | - Go to the [Discord Developer Portal](https://discord.com/developers/applications) 194 | - Click on "New Application" in the top right corner 195 | - Give your application a name (e.g., "Hoarder Random Bookmark") and click "Create" 196 | 197 | 2. **Create a Bot**: 198 | 199 | - In your application page, click on the "Bot" tab in the left sidebar 200 | - Click "Add Bot" and confirm by clicking "Yes, do it!" 201 | - Under the bot's username, you'll see a section for the token 202 | - Click "Reset Token" and confirm to generate a new token 203 | - Copy this token - this is your `DISCORD_BOT_TOKEN` 204 | - Make sure to enable the "Message Content Intent" under "Privileged Gateway Intents" 205 | - Under "Bot Permissions", select the following permissions: 206 | - **Text Permissions**: 207 | - "Send Messages" 208 | - "Embed Links" (needed for formatted bookmark links) 209 | - "Read Message History" 210 | - "View Channels" 211 | 212 | - Save your changes 213 | 214 | 3. **Add Bot to Your Server**: 215 | - Go to the "OAuth2" tab in the left sidebar 216 | - Click on "URL Generator" 217 | - Under "Scopes", select "bot" 218 | - Copy the generated URL at the bottom of the page 219 | - Open this URL in your browser and select the server where you want to add the bot 220 | - Authorize the bot 221 | 222 | - Under "Bot Permissions", select the following permissions: 223 | - **Text Permissions**: 224 | - "Send Messages" 225 | - "Embed Links" (needed for formatted bookmark links) 226 | - "Read Message History" 227 | - "View Channels" 228 | - Save your changes and copy the generated URL at the bottom of the page 229 | - Open this URL in your browser and select the server where you want to add the bot 230 | 231 | #### Getting a Discord Channel ID: 232 | 233 | 1. **Enable Developer Mode in Discord**: 234 | 235 | - Open Discord 236 | - Go to User Settings (gear icon near your username) 237 | - Go to "Advanced" in the left sidebar 238 | - Enable "Developer Mode" 239 | 240 | 2. **Get the Channel ID**: 241 | - Right-click on the channel where you want to receive bookmark notifications 242 | - Select "Copy ID" from the context menu 243 | - This is your `DISCORD_CHANNEL_ID` 244 | > **Note**: You can only copy the channel ID if you have Developer Mode enabled. If you don't: 245 | - Right click on the channel 246 | - Select "Copy Link" 247 | - Paste the link into your browser 248 | - Look at the URL: https://discord.com/channels/[SERVER-ID]/[CHANNEL-ID] 249 | 250 | #### Additional Discord Troubleshooting: 251 | 252 | Ensure your bot: 253 | 254 | 1. Is added to the server where you want to send messages 255 | 2. Has permissions to view and send messages in the target channel 256 | 3. Has the correct intents enabled (MESSAGE CONTENT) 257 | 4. Has all the required permissions: 258 | - Send Messages 259 | - Embed Links 260 | - Read Message History 261 | - View Channels 262 | 5. If messages aren't being sent, check if your bot has permission conflicts with channel-specific permissions or role hierarchy issues 263 | 264 | ### Mattermost Troubleshooting 265 | 266 | Common reasons why the channel field is ignored: 267 | 268 | - The webhook integration user does not have permission to post in the target channel. 269 | - The webhook was not created by a system admin. 270 | - The channel name is not correct (should be the channel’s name without the #). 271 | - The webhook is restricted to a specific channel in its Mattermost configuration. 272 | 273 | For more information on incoming webhooks, see the [Mattermost documentation](https://developers.mattermost.com/integrate/webhooks/incoming/). 274 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treyg/karakeep-random-bookmark/a193906cf832c7e4f14f9db757b88660ae317361/bun.lockb -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | hoarder-bookmark-sender: 5 | build: . 6 | container_name: hoarder-bookmark-sender 7 | restart: unless-stopped 8 | ports: 9 | - '8080:8080' 10 | environment: 11 | - NODE_ENV=production 12 | - PORT=8080 13 | # Hoarder API 14 | - HOARDER_API_KEY=${HOARDER_API_KEY} 15 | - HOARDER_SERVER_URL=${HOARDER_SERVER_URL} 16 | # Notification settings 17 | - NOTIFICATION_METHOD=${NOTIFICATION_METHOD:-email} 18 | - NOTIFICATION_FREQUENCY=${NOTIFICATION_FREQUENCY:-daily} 19 | - BOOKMARKS_COUNT=${BOOKMARKS_COUNT:-3} 20 | - SPECIFIC_LIST_ID=${SPECIFIC_LIST_ID} 21 | # Time zone configuration 22 | - TIMEZONE=${TIMEZONE:-UTC} 23 | - TIME_TO_SEND=${TIME_TO_SEND:-09:00} 24 | # Email configuration 25 | - EMAIL_SERVICE=${EMAIL_SERVICE} 26 | - EMAIL_USER=${EMAIL_USER} 27 | - EMAIL_PASS=${EMAIL_PASS} 28 | - EMAIL_RECIPIENT=${EMAIL_RECIPIENT} 29 | # Discord configuration 30 | - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN} 31 | - DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID} 32 | # Mattermost configuration 33 | - MATTERMOST_WEBHOOK_URL=${MATTERMOST_WEBHOOK_URL} 34 | - MATTERMOST_CHANNEL=${MATTERMOST_CHANNEL} 35 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hoarder-random-bookmark", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "hoarder-random-bookmark", 8 | "dependencies": { 9 | "@hoarderapp/sdk": "^0.22.0", 10 | "axios": "^1.9.0", 11 | "discord.js": "^14.18.0", 12 | "dotenv": "^16.4.7", 13 | "feed": "^4.2.2", 14 | "hono": "^4.7.4", 15 | "node-cron": "^3.0.3", 16 | "nodemailer": "^6.10.0", 17 | "zod": "^3.24.2" 18 | }, 19 | "devDependencies": { 20 | "@types/bun": "latest", 21 | "@types/node": "^22.13.10", 22 | "@types/node-cron": "^3.0.11", 23 | "@types/nodemailer": "^6.4.17", 24 | "bun-types": "^1.2.4" 25 | }, 26 | "peerDependencies": { 27 | "typescript": "^5.8.2" 28 | } 29 | }, 30 | "node_modules/@discordjs/builders": { 31 | "version": "1.10.1", 32 | "license": "Apache-2.0", 33 | "dependencies": { 34 | "@discordjs/formatters": "^0.6.0", 35 | "@discordjs/util": "^1.1.1", 36 | "@sapphire/shapeshift": "^4.0.0", 37 | "discord-api-types": "^0.37.119", 38 | "fast-deep-equal": "^3.1.3", 39 | "ts-mixer": "^6.0.4", 40 | "tslib": "^2.6.3" 41 | }, 42 | "engines": { 43 | "node": ">=16.11.0" 44 | }, 45 | "funding": { 46 | "url": "https://github.com/discordjs/discord.js?sponsor" 47 | } 48 | }, 49 | "node_modules/@discordjs/collection": { 50 | "version": "1.5.3", 51 | "license": "Apache-2.0", 52 | "engines": { 53 | "node": ">=16.11.0" 54 | } 55 | }, 56 | "node_modules/@discordjs/formatters": { 57 | "version": "0.6.0", 58 | "license": "Apache-2.0", 59 | "dependencies": { 60 | "discord-api-types": "^0.37.114" 61 | }, 62 | "engines": { 63 | "node": ">=16.11.0" 64 | }, 65 | "funding": { 66 | "url": "https://github.com/discordjs/discord.js?sponsor" 67 | } 68 | }, 69 | "node_modules/@discordjs/rest": { 70 | "version": "2.4.3", 71 | "license": "Apache-2.0", 72 | "dependencies": { 73 | "@discordjs/collection": "^2.1.1", 74 | "@discordjs/util": "^1.1.1", 75 | "@sapphire/async-queue": "^1.5.3", 76 | "@sapphire/snowflake": "^3.5.3", 77 | "@vladfrangu/async_event_emitter": "^2.4.6", 78 | "discord-api-types": "^0.37.119", 79 | "magic-bytes.js": "^1.10.0", 80 | "tslib": "^2.6.3", 81 | "undici": "6.21.1" 82 | }, 83 | "engines": { 84 | "node": ">=18" 85 | }, 86 | "funding": { 87 | "url": "https://github.com/discordjs/discord.js?sponsor" 88 | } 89 | }, 90 | "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { 91 | "version": "2.1.1", 92 | "license": "Apache-2.0", 93 | "engines": { 94 | "node": ">=18" 95 | }, 96 | "funding": { 97 | "url": "https://github.com/discordjs/discord.js?sponsor" 98 | } 99 | }, 100 | "node_modules/@discordjs/util": { 101 | "version": "1.1.1", 102 | "license": "Apache-2.0", 103 | "engines": { 104 | "node": ">=18" 105 | }, 106 | "funding": { 107 | "url": "https://github.com/discordjs/discord.js?sponsor" 108 | } 109 | }, 110 | "node_modules/@discordjs/ws": { 111 | "version": "1.2.1", 112 | "license": "Apache-2.0", 113 | "dependencies": { 114 | "@discordjs/collection": "^2.1.0", 115 | "@discordjs/rest": "^2.4.3", 116 | "@discordjs/util": "^1.1.0", 117 | "@sapphire/async-queue": "^1.5.2", 118 | "@types/ws": "^8.5.10", 119 | "@vladfrangu/async_event_emitter": "^2.2.4", 120 | "discord-api-types": "^0.37.119", 121 | "tslib": "^2.6.2", 122 | "ws": "^8.17.0" 123 | }, 124 | "engines": { 125 | "node": ">=16.11.0" 126 | }, 127 | "funding": { 128 | "url": "https://github.com/discordjs/discord.js?sponsor" 129 | } 130 | }, 131 | "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { 132 | "version": "2.1.1", 133 | "license": "Apache-2.0", 134 | "engines": { 135 | "node": ">=18" 136 | }, 137 | "funding": { 138 | "url": "https://github.com/discordjs/discord.js?sponsor" 139 | } 140 | }, 141 | "node_modules/@hoarderapp/sdk": { 142 | "version": "0.22.0", 143 | "resolved": "https://registry.npmjs.org/@hoarderapp/sdk/-/sdk-0.22.0.tgz", 144 | "integrity": "sha512-DZYfhfTEkAx2YJikTRx25MhQ8S4oHdgxmu93Y5vbvjslBMTeWn5NEwdDf/pPdv4zwa5dAPvz0bfi9OgnWP0DoA==", 145 | "license": "GNU Affero General Public License version 3", 146 | "dependencies": { 147 | "openapi-fetch": "^0.13.3" 148 | } 149 | }, 150 | "node_modules/@sapphire/async-queue": { 151 | "version": "1.5.5", 152 | "license": "MIT", 153 | "engines": { 154 | "node": ">=v14.0.0", 155 | "npm": ">=7.0.0" 156 | } 157 | }, 158 | "node_modules/@sapphire/shapeshift": { 159 | "version": "4.0.0", 160 | "license": "MIT", 161 | "dependencies": { 162 | "fast-deep-equal": "^3.1.3", 163 | "lodash": "^4.17.21" 164 | }, 165 | "engines": { 166 | "node": ">=v16" 167 | } 168 | }, 169 | "node_modules/@sapphire/snowflake": { 170 | "version": "3.5.3", 171 | "license": "MIT", 172 | "engines": { 173 | "node": ">=v14.0.0", 174 | "npm": ">=7.0.0" 175 | } 176 | }, 177 | "node_modules/@types/bun": { 178 | "version": "1.2.5", 179 | "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.2.5.tgz", 180 | "integrity": "sha512-w2OZTzrZTVtbnJew1pdFmgV99H0/L+Pvw+z1P67HaR18MHOzYnTYOi6qzErhK8HyT+DB782ADVPPE92Xu2/Opg==", 181 | "dev": true, 182 | "license": "MIT", 183 | "dependencies": { 184 | "bun-types": "1.2.5" 185 | } 186 | }, 187 | "node_modules/@types/node": { 188 | "version": "22.13.10", 189 | "license": "MIT", 190 | "dependencies": { 191 | "undici-types": "~6.20.0" 192 | } 193 | }, 194 | "node_modules/@types/node-cron": { 195 | "version": "3.0.11", 196 | "dev": true, 197 | "license": "MIT" 198 | }, 199 | "node_modules/@types/nodemailer": { 200 | "version": "6.4.17", 201 | "dev": true, 202 | "license": "MIT", 203 | "dependencies": { 204 | "@types/node": "*" 205 | } 206 | }, 207 | "node_modules/@types/ws": { 208 | "version": "8.5.14", 209 | "license": "MIT", 210 | "dependencies": { 211 | "@types/node": "*" 212 | } 213 | }, 214 | "node_modules/@vladfrangu/async_event_emitter": { 215 | "version": "2.4.6", 216 | "license": "MIT", 217 | "engines": { 218 | "node": ">=v14.0.0", 219 | "npm": ">=7.0.0" 220 | } 221 | }, 222 | "node_modules/asynckit": { 223 | "version": "0.4.0", 224 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 225 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", 226 | "license": "MIT" 227 | }, 228 | "node_modules/axios": { 229 | "version": "1.9.0", 230 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", 231 | "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", 232 | "license": "MIT", 233 | "dependencies": { 234 | "follow-redirects": "^1.15.6", 235 | "form-data": "^4.0.0", 236 | "proxy-from-env": "^1.1.0" 237 | } 238 | }, 239 | "node_modules/bun-types": { 240 | "version": "1.2.5", 241 | "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.5.tgz", 242 | "integrity": "sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg==", 243 | "dev": true, 244 | "license": "MIT", 245 | "dependencies": { 246 | "@types/node": "*", 247 | "@types/ws": "~8.5.10" 248 | } 249 | }, 250 | "node_modules/call-bind-apply-helpers": { 251 | "version": "1.0.2", 252 | "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", 253 | "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 254 | "license": "MIT", 255 | "dependencies": { 256 | "es-errors": "^1.3.0", 257 | "function-bind": "^1.1.2" 258 | }, 259 | "engines": { 260 | "node": ">= 0.4" 261 | } 262 | }, 263 | "node_modules/combined-stream": { 264 | "version": "1.0.8", 265 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 266 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 267 | "license": "MIT", 268 | "dependencies": { 269 | "delayed-stream": "~1.0.0" 270 | }, 271 | "engines": { 272 | "node": ">= 0.8" 273 | } 274 | }, 275 | "node_modules/delayed-stream": { 276 | "version": "1.0.0", 277 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 278 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 279 | "license": "MIT", 280 | "engines": { 281 | "node": ">=0.4.0" 282 | } 283 | }, 284 | "node_modules/discord-api-types": { 285 | "version": "0.37.119", 286 | "license": "MIT" 287 | }, 288 | "node_modules/discord.js": { 289 | "version": "14.18.0", 290 | "license": "Apache-2.0", 291 | "dependencies": { 292 | "@discordjs/builders": "^1.10.1", 293 | "@discordjs/collection": "1.5.3", 294 | "@discordjs/formatters": "^0.6.0", 295 | "@discordjs/rest": "^2.4.3", 296 | "@discordjs/util": "^1.1.1", 297 | "@discordjs/ws": "^1.2.1", 298 | "@sapphire/snowflake": "3.5.3", 299 | "discord-api-types": "^0.37.119", 300 | "fast-deep-equal": "3.1.3", 301 | "lodash.snakecase": "4.1.1", 302 | "tslib": "^2.6.3", 303 | "undici": "6.21.1" 304 | }, 305 | "engines": { 306 | "node": ">=18" 307 | }, 308 | "funding": { 309 | "url": "https://github.com/discordjs/discord.js?sponsor" 310 | } 311 | }, 312 | "node_modules/dotenv": { 313 | "version": "16.4.7", 314 | "license": "BSD-2-Clause", 315 | "engines": { 316 | "node": ">=12" 317 | }, 318 | "funding": { 319 | "url": "https://dotenvx.com" 320 | } 321 | }, 322 | "node_modules/dunder-proto": { 323 | "version": "1.0.1", 324 | "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", 325 | "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 326 | "license": "MIT", 327 | "dependencies": { 328 | "call-bind-apply-helpers": "^1.0.1", 329 | "es-errors": "^1.3.0", 330 | "gopd": "^1.2.0" 331 | }, 332 | "engines": { 333 | "node": ">= 0.4" 334 | } 335 | }, 336 | "node_modules/es-define-property": { 337 | "version": "1.0.1", 338 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", 339 | "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", 340 | "license": "MIT", 341 | "engines": { 342 | "node": ">= 0.4" 343 | } 344 | }, 345 | "node_modules/es-errors": { 346 | "version": "1.3.0", 347 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 348 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", 349 | "license": "MIT", 350 | "engines": { 351 | "node": ">= 0.4" 352 | } 353 | }, 354 | "node_modules/es-object-atoms": { 355 | "version": "1.1.1", 356 | "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", 357 | "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 358 | "license": "MIT", 359 | "dependencies": { 360 | "es-errors": "^1.3.0" 361 | }, 362 | "engines": { 363 | "node": ">= 0.4" 364 | } 365 | }, 366 | "node_modules/es-set-tostringtag": { 367 | "version": "2.1.0", 368 | "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", 369 | "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", 370 | "license": "MIT", 371 | "dependencies": { 372 | "es-errors": "^1.3.0", 373 | "get-intrinsic": "^1.2.6", 374 | "has-tostringtag": "^1.0.2", 375 | "hasown": "^2.0.2" 376 | }, 377 | "engines": { 378 | "node": ">= 0.4" 379 | } 380 | }, 381 | "node_modules/fast-deep-equal": { 382 | "version": "3.1.3", 383 | "license": "MIT" 384 | }, 385 | "node_modules/feed": { 386 | "version": "4.2.2", 387 | "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", 388 | "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", 389 | "license": "MIT", 390 | "dependencies": { 391 | "xml-js": "^1.6.11" 392 | }, 393 | "engines": { 394 | "node": ">=0.4.0" 395 | } 396 | }, 397 | "node_modules/follow-redirects": { 398 | "version": "1.15.9", 399 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", 400 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", 401 | "funding": [ 402 | { 403 | "type": "individual", 404 | "url": "https://github.com/sponsors/RubenVerborgh" 405 | } 406 | ], 407 | "license": "MIT", 408 | "engines": { 409 | "node": ">=4.0" 410 | }, 411 | "peerDependenciesMeta": { 412 | "debug": { 413 | "optional": true 414 | } 415 | } 416 | }, 417 | "node_modules/form-data": { 418 | "version": "4.0.2", 419 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", 420 | "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", 421 | "license": "MIT", 422 | "dependencies": { 423 | "asynckit": "^0.4.0", 424 | "combined-stream": "^1.0.8", 425 | "es-set-tostringtag": "^2.1.0", 426 | "mime-types": "^2.1.12" 427 | }, 428 | "engines": { 429 | "node": ">= 6" 430 | } 431 | }, 432 | "node_modules/function-bind": { 433 | "version": "1.1.2", 434 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 435 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 436 | "license": "MIT", 437 | "funding": { 438 | "url": "https://github.com/sponsors/ljharb" 439 | } 440 | }, 441 | "node_modules/get-intrinsic": { 442 | "version": "1.3.0", 443 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", 444 | "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 445 | "license": "MIT", 446 | "dependencies": { 447 | "call-bind-apply-helpers": "^1.0.2", 448 | "es-define-property": "^1.0.1", 449 | "es-errors": "^1.3.0", 450 | "es-object-atoms": "^1.1.1", 451 | "function-bind": "^1.1.2", 452 | "get-proto": "^1.0.1", 453 | "gopd": "^1.2.0", 454 | "has-symbols": "^1.1.0", 455 | "hasown": "^2.0.2", 456 | "math-intrinsics": "^1.1.0" 457 | }, 458 | "engines": { 459 | "node": ">= 0.4" 460 | }, 461 | "funding": { 462 | "url": "https://github.com/sponsors/ljharb" 463 | } 464 | }, 465 | "node_modules/get-proto": { 466 | "version": "1.0.1", 467 | "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", 468 | "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 469 | "license": "MIT", 470 | "dependencies": { 471 | "dunder-proto": "^1.0.1", 472 | "es-object-atoms": "^1.0.0" 473 | }, 474 | "engines": { 475 | "node": ">= 0.4" 476 | } 477 | }, 478 | "node_modules/gopd": { 479 | "version": "1.2.0", 480 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", 481 | "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", 482 | "license": "MIT", 483 | "engines": { 484 | "node": ">= 0.4" 485 | }, 486 | "funding": { 487 | "url": "https://github.com/sponsors/ljharb" 488 | } 489 | }, 490 | "node_modules/has-symbols": { 491 | "version": "1.1.0", 492 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", 493 | "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", 494 | "license": "MIT", 495 | "engines": { 496 | "node": ">= 0.4" 497 | }, 498 | "funding": { 499 | "url": "https://github.com/sponsors/ljharb" 500 | } 501 | }, 502 | "node_modules/has-tostringtag": { 503 | "version": "1.0.2", 504 | "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", 505 | "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", 506 | "license": "MIT", 507 | "dependencies": { 508 | "has-symbols": "^1.0.3" 509 | }, 510 | "engines": { 511 | "node": ">= 0.4" 512 | }, 513 | "funding": { 514 | "url": "https://github.com/sponsors/ljharb" 515 | } 516 | }, 517 | "node_modules/hasown": { 518 | "version": "2.0.2", 519 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 520 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 521 | "license": "MIT", 522 | "dependencies": { 523 | "function-bind": "^1.1.2" 524 | }, 525 | "engines": { 526 | "node": ">= 0.4" 527 | } 528 | }, 529 | "node_modules/hono": { 530 | "version": "4.7.4", 531 | "license": "MIT", 532 | "engines": { 533 | "node": ">=16.9.0" 534 | } 535 | }, 536 | "node_modules/lodash": { 537 | "version": "4.17.21", 538 | "license": "MIT" 539 | }, 540 | "node_modules/lodash.snakecase": { 541 | "version": "4.1.1", 542 | "license": "MIT" 543 | }, 544 | "node_modules/magic-bytes.js": { 545 | "version": "1.10.0", 546 | "license": "MIT" 547 | }, 548 | "node_modules/math-intrinsics": { 549 | "version": "1.1.0", 550 | "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", 551 | "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", 552 | "license": "MIT", 553 | "engines": { 554 | "node": ">= 0.4" 555 | } 556 | }, 557 | "node_modules/mime-db": { 558 | "version": "1.52.0", 559 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 560 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 561 | "license": "MIT", 562 | "engines": { 563 | "node": ">= 0.6" 564 | } 565 | }, 566 | "node_modules/mime-types": { 567 | "version": "2.1.35", 568 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 569 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 570 | "license": "MIT", 571 | "dependencies": { 572 | "mime-db": "1.52.0" 573 | }, 574 | "engines": { 575 | "node": ">= 0.6" 576 | } 577 | }, 578 | "node_modules/node-cron": { 579 | "version": "3.0.3", 580 | "license": "ISC", 581 | "dependencies": { 582 | "uuid": "8.3.2" 583 | }, 584 | "engines": { 585 | "node": ">=6.0.0" 586 | } 587 | }, 588 | "node_modules/nodemailer": { 589 | "version": "6.10.0", 590 | "license": "MIT-0", 591 | "engines": { 592 | "node": ">=6.0.0" 593 | } 594 | }, 595 | "node_modules/openapi-fetch": { 596 | "version": "0.13.4", 597 | "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.13.4.tgz", 598 | "integrity": "sha512-JHX7UYjLEiHuQGCPxa3CCCIqe/nc4bTIF9c4UYVC8BegAbWoS3g4gJxKX5XcG7UtYQs2060kY6DH64KkvNZahg==", 599 | "license": "MIT", 600 | "dependencies": { 601 | "openapi-typescript-helpers": "^0.0.15" 602 | } 603 | }, 604 | "node_modules/openapi-typescript-helpers": { 605 | "version": "0.0.15", 606 | "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", 607 | "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", 608 | "license": "MIT" 609 | }, 610 | "node_modules/proxy-from-env": { 611 | "version": "1.1.0", 612 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 613 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", 614 | "license": "MIT" 615 | }, 616 | "node_modules/sax": { 617 | "version": "1.4.1", 618 | "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", 619 | "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", 620 | "license": "ISC" 621 | }, 622 | "node_modules/ts-mixer": { 623 | "version": "6.0.4", 624 | "license": "MIT" 625 | }, 626 | "node_modules/tslib": { 627 | "version": "2.8.1", 628 | "license": "0BSD" 629 | }, 630 | "node_modules/typescript": { 631 | "version": "5.8.2", 632 | "license": "Apache-2.0", 633 | "peer": true, 634 | "bin": { 635 | "tsc": "bin/tsc", 636 | "tsserver": "bin/tsserver" 637 | }, 638 | "engines": { 639 | "node": ">=14.17" 640 | } 641 | }, 642 | "node_modules/undici": { 643 | "version": "6.21.1", 644 | "license": "MIT", 645 | "engines": { 646 | "node": ">=18.17" 647 | } 648 | }, 649 | "node_modules/undici-types": { 650 | "version": "6.20.0", 651 | "license": "MIT" 652 | }, 653 | "node_modules/uuid": { 654 | "version": "8.3.2", 655 | "license": "MIT", 656 | "bin": { 657 | "uuid": "dist/bin/uuid" 658 | } 659 | }, 660 | "node_modules/ws": { 661 | "version": "8.18.1", 662 | "license": "MIT", 663 | "engines": { 664 | "node": ">=10.0.0" 665 | }, 666 | "peerDependencies": { 667 | "bufferutil": "^4.0.1", 668 | "utf-8-validate": ">=5.0.2" 669 | }, 670 | "peerDependenciesMeta": { 671 | "bufferutil": { 672 | "optional": true 673 | }, 674 | "utf-8-validate": { 675 | "optional": true 676 | } 677 | } 678 | }, 679 | "node_modules/xml-js": { 680 | "version": "1.6.11", 681 | "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", 682 | "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", 683 | "license": "MIT", 684 | "dependencies": { 685 | "sax": "^1.2.4" 686 | }, 687 | "bin": { 688 | "xml-js": "bin/cli.js" 689 | } 690 | }, 691 | "node_modules/zod": { 692 | "version": "3.24.2", 693 | "license": "MIT", 694 | "funding": { 695 | "url": "https://github.com/sponsors/colinhacks" 696 | } 697 | } 698 | } 699 | } 700 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hoarder-random-bookmark", 3 | "module": "index.ts", 4 | "type": "module", 5 | "scripts": { 6 | "start": "bun run src/index.ts", 7 | "dev": "bun --watch run src/index.ts", 8 | "build": "bun build ./src/index.ts --outdir ./dist", 9 | "docker:build": "docker build -t hoarder-bookmark-sender .", 10 | "docker:run": "docker run -p 8080:8080 --env-file .env hoarder-bookmark-sender" 11 | }, 12 | "devDependencies": { 13 | "@types/bun": "latest", 14 | "@types/node": "^22.13.10", 15 | "@types/node-cron": "^3.0.11", 16 | "@types/nodemailer": "^6.4.17", 17 | "bun-types": "^1.2.4" 18 | }, 19 | "peerDependencies": { 20 | "typescript": "^5.8.2" 21 | }, 22 | "dependencies": { 23 | "@hoarderapp/sdk": "^0.22.0", 24 | "discord.js": "^14.18.0", 25 | "dotenv": "^16.4.7", 26 | "feed": "^4.2.2", 27 | "hono": "^4.7.4", 28 | "node-cron": "^3.0.3", 29 | "nodemailer": "^6.10.0", 30 | "zod": "^3.24.2", 31 | "axios": "^1.9.0" 32 | } 33 | } -------------------------------------------------------------------------------- /src/api/hoarder.ts: -------------------------------------------------------------------------------- 1 | import { config } from '../utils/config' 2 | import { createHoarderClient } from '@hoarderapp/sdk' 3 | import type { Bookmark, List, SdkBookmark, SdkList } from './types' 4 | 5 | const client = createHoarderClient({ 6 | baseUrl: `${config.HOARDER_SERVER_URL}/api/v1`, 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | authorization: `Bearer ${config.HOARDER_API_KEY}` 10 | } 11 | }) 12 | 13 | // Helper function to transform SDK bookmark to our simplified Bookmark type 14 | function transformBookmark(bookmark: SdkBookmark): Bookmark { 15 | const content = bookmark.content || {} 16 | 17 | let url = '' 18 | if (content && 'url' in content) { 19 | url = content.url || '' 20 | } 21 | 22 | const title = 23 | (content && 'title' in content ? content.title : null) || 24 | bookmark.title || 25 | 'Untitled Bookmark' 26 | 27 | const description = 28 | (content && 'description' in content ? content.description : null) || 29 | bookmark.summary || 30 | bookmark.note || 31 | '' 32 | 33 | const tags = bookmark.tags 34 | ? bookmark.tags.map((tag: { name: string }) => tag.name) 35 | : [] 36 | 37 | return { 38 | id: bookmark.id, 39 | url, 40 | title, 41 | description, 42 | tags, 43 | created_at: bookmark.createdAt, 44 | updated_at: bookmark.modifiedAt || '' 45 | } 46 | } 47 | 48 | // Helper function to transform SDK list to a simplified List type 49 | function transformList(list: SdkList): List { 50 | return { 51 | id: list.id, 52 | name: list.name, 53 | description: list.description || '', 54 | created_at: list.createdAt, 55 | updated_at: list.modifiedAt || '' 56 | } 57 | } 58 | 59 | export async function getAllBookmarks(): Promise { 60 | try { 61 | const path = '/bookmarks' as any 62 | const result = await client.GET(path, { params: {} }) 63 | const { data, error } = result 64 | if (error) throw error 65 | 66 | let sdkBookmarks: SdkBookmark[] = [] 67 | if (Array.isArray(data)) { 68 | sdkBookmarks = data as SdkBookmark[] 69 | } else if (data && 'bookmarks' in data && Array.isArray(data.bookmarks)) { 70 | sdkBookmarks = data.bookmarks as SdkBookmark[] 71 | } else if (data) { 72 | sdkBookmarks = [data as SdkBookmark] 73 | } 74 | 75 | return sdkBookmarks.map(transformBookmark) 76 | } catch (error) { 77 | console.error('Error fetching bookmarks:', error) 78 | throw error 79 | } 80 | } 81 | 82 | // Get all lists 83 | export async function getAllLists(): Promise { 84 | try { 85 | // Use type assertion to bypass TypeScript path checking 86 | const path = '/lists' as any 87 | const result = await client.GET(path, { params: {} }) 88 | const { data, error } = result 89 | if (error) throw error 90 | 91 | // The API returns lists directly or in a lists array 92 | let sdkLists: SdkList[] = [] 93 | if (Array.isArray(data)) { 94 | sdkLists = data as SdkList[] 95 | } else if (data && 'lists' in data && Array.isArray(data.lists)) { 96 | sdkLists = data.lists as SdkList[] 97 | } else if (data) { 98 | sdkLists = [data as SdkList] 99 | } 100 | 101 | return sdkLists.map(transformList) 102 | } catch (error) { 103 | console.error('Error fetching lists:', error) 104 | throw error 105 | } 106 | } 107 | 108 | // Get a single list by ID 109 | export async function getList(listId: string): Promise { 110 | try { 111 | // Use type assertion to bypass TypeScript path checking 112 | const path = `/lists/${listId}` as any 113 | const result = await client.GET(path, { params: {} }) 114 | const { data, error } = result 115 | if (error) throw error 116 | 117 | if (!data) throw new Error('List not found') 118 | return transformList(data as SdkList) 119 | } catch (error) { 120 | console.error('Error fetching list:', error) 121 | throw error 122 | } 123 | } 124 | 125 | // Get bookmarks in a specific list 126 | export async function getBookmarksInList(listId: string): Promise { 127 | try { 128 | // Use type assertion to bypass TypeScript path checking 129 | const path = `/lists/${listId}/bookmarks` as any 130 | const result = await client.GET(path, { params: {} }) 131 | const { data, error } = result 132 | if (error) throw error 133 | 134 | // The API returns bookmarks directly or in a bookmarks array 135 | let sdkBookmarks: SdkBookmark[] = [] 136 | if (Array.isArray(data)) { 137 | sdkBookmarks = data as SdkBookmark[] 138 | } else if (data && 'bookmarks' in data && Array.isArray(data.bookmarks)) { 139 | sdkBookmarks = data.bookmarks as SdkBookmark[] 140 | } else if (data) { 141 | sdkBookmarks = [data as SdkBookmark] 142 | } 143 | 144 | return sdkBookmarks.map(transformBookmark) 145 | } catch (error) { 146 | console.error('Error fetching bookmarks in list:', error) 147 | throw error 148 | } 149 | } 150 | 151 | // Get random bookmarks (either from all bookmarks or a specific list) 152 | export async function getRandomBookmarks( 153 | count: number, 154 | listId?: string 155 | ): Promise { 156 | try { 157 | const bookmarks = listId 158 | ? await getBookmarksInList(listId) 159 | : await getAllBookmarks() 160 | 161 | // Shuffle the array using Fisher-Yates algorithm 162 | for (let i = bookmarks.length - 1; i > 0; i--) { 163 | const j = Math.floor(Math.random() * (i + 1)) 164 | ;[bookmarks[i], bookmarks[j]] = [bookmarks[j], bookmarks[i]] 165 | } 166 | 167 | // Return the requested number of bookmarks 168 | return bookmarks.slice(0, count) 169 | } catch (error) { 170 | console.error('Error getting random bookmarks:', error) 171 | throw error 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/api/types.ts: -------------------------------------------------------------------------------- 1 | // SDK types used internally for API communication 2 | export type SdkBookmark = { 3 | id: string 4 | createdAt: string 5 | modifiedAt: string | null 6 | title?: string | null 7 | archived: boolean 8 | favourited: boolean 9 | taggingStatus?: 'success' | 'failure' | 'pending' | null 10 | note?: string | null 11 | summary?: string | null 12 | tags: { 13 | id: string 14 | name: string 15 | attachedBy: 'ai' | 'human' 16 | }[] 17 | content?: { 18 | type?: string 19 | url?: string 20 | title?: string | null 21 | description?: string | null 22 | } 23 | } 24 | 25 | export type SdkList = { 26 | id: string 27 | name: string 28 | description?: string 29 | createdAt: string 30 | modifiedAt?: string 31 | } 32 | 33 | // Simplified types used by our application 34 | export type Bookmark = { 35 | id: string 36 | url: string 37 | title: string 38 | description: string 39 | tags: string[] 40 | created_at: string 41 | updated_at: string 42 | } 43 | 44 | export type List = { 45 | id: string 46 | name: string 47 | description: string 48 | created_at: string 49 | updated_at: string 50 | } 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { config } from './utils/config' 3 | import { startScheduler, sendImmediate } from './services/scheduler' 4 | import { initDiscordClient, sendBookmarksDiscord } from './services/discord' 5 | import { getRandomBookmarks } from './api/hoarder' 6 | import { generateBookmarksRSS } from './services/rss' 7 | 8 | const app = new Hono() 9 | 10 | app.get('/', c => { 11 | return c.json({ 12 | status: 'ok', 13 | notification_method: config.NOTIFICATION_METHOD, 14 | frequency: config.NOTIFICATION_FREQUENCY, 15 | count: config.BOOKMARKS_COUNT, 16 | timezone: config.TIMEZONE, 17 | time_to_send: config.TIME_TO_SEND, 18 | specific_list: config.SPECIFIC_LIST_ID ? true : false, 19 | rss_feed_url: config.NOTIFICATION_METHOD === 'rss' ? 'http://localhost:8080/rss/feed' : null 20 | }) 21 | }) 22 | 23 | // Add endpoint to trigger immediate send (useful for testing) 24 | app.post('/send-now', async c => { 25 | try { 26 | await sendImmediate() 27 | return c.json({ 28 | success: true, 29 | message: 'Notification sent successfully' 30 | }) 31 | } catch (error) { 32 | console.error('Error sending immediate notification:', error) 33 | return c.json({ success: false, error: 'Failed to send notification' }, 500) 34 | } 35 | }) 36 | 37 | // Add endpoint to test email functionality 38 | app.get('/test-email', async c => { 39 | try { 40 | if (config.NOTIFICATION_METHOD !== 'email') { 41 | return c.json( 42 | { 43 | success: false, 44 | error: 'Email is not configured as the notification method' 45 | }, 46 | 400 47 | ) 48 | } 49 | 50 | // Import email service 51 | const { sendBookmarksEmail } = await import('./services/email') 52 | 53 | // Create a test bookmark 54 | const testBookmark = { 55 | id: 'test-id', 56 | url: 'https://example.com', 57 | title: 'Test Email Bookmark', 58 | description: 'This is a test bookmark to verify email integration', 59 | tags: ['test', 'email'], 60 | created_at: new Date().toISOString(), 61 | updated_at: new Date().toISOString() 62 | } 63 | 64 | // Send test email 65 | await sendBookmarksEmail([testBookmark]) 66 | 67 | return c.json({ 68 | success: true, 69 | message: 'Test email sent successfully', 70 | recipient: config.EMAIL_RECIPIENT 71 | }) 72 | } catch (error: any) { 73 | console.error('Error sending test email:', error) 74 | return c.json( 75 | { 76 | success: false, 77 | error: `Failed to send test email: ${error.message || 'Unknown error'}` 78 | }, 79 | 500 80 | ) 81 | } 82 | }) 83 | 84 | // RSS feed endpoint 85 | app.get('/rss/feed', async c => { 86 | try { 87 | // Get random bookmarks based on configuration 88 | const bookmarks = await getRandomBookmarks( 89 | config.BOOKMARKS_COUNT, 90 | config.SPECIFIC_LIST_ID 91 | ) 92 | 93 | // Generate RSS feed 94 | const rssContent = await generateBookmarksRSS(bookmarks) 95 | 96 | // Set content type to XML 97 | c.header('Content-Type', 'application/rss+xml') 98 | return c.body(rssContent) 99 | } catch (error: any) { 100 | console.error('Error generating RSS feed:', error) 101 | return c.json( 102 | { 103 | success: false, 104 | error: `Failed to generate RSS feed: ${error.message || 'Unknown error'}` 105 | }, 106 | 500 107 | ) 108 | } 109 | }) 110 | 111 | // Add endpoint to test Discord bot directly 112 | app.get('/test-discord', async c => { 113 | try { 114 | if (config.NOTIFICATION_METHOD !== 'discord') { 115 | return c.json( 116 | { 117 | success: false, 118 | error: 'Discord is not configured as the notification method' 119 | }, 120 | 400 121 | ) 122 | } 123 | 124 | // Create a test bookmark 125 | const testBookmark = { 126 | id: 'test-id', 127 | url: 'https://example.com', 128 | title: 'Test Bookmark', 129 | description: 'This is a test bookmark to verify Discord integration', 130 | tags: ['test', 'discord'], 131 | created_at: new Date().toISOString(), 132 | updated_at: new Date().toISOString() 133 | } 134 | 135 | // Send test message 136 | await sendBookmarksDiscord([testBookmark]) 137 | 138 | return c.json({ 139 | success: true, 140 | message: 'Test message sent to Discord', 141 | channel_id: config.DISCORD_CHANNEL_ID 142 | }) 143 | } catch (error: any) { 144 | console.error('Error sending test Discord message:', error) 145 | return c.json( 146 | { 147 | success: false, 148 | error: `Failed to send test message: ${ 149 | error.message || 'Unknown error' 150 | }` 151 | }, 152 | 500 153 | ) 154 | } 155 | }) 156 | 157 | // Initialize application 158 | async function initApp() { 159 | console.log('Starting Hoarder Bookmark Sender...') 160 | 161 | // Initialize Discord client if using Discord 162 | if (config.NOTIFICATION_METHOD === 'discord') { 163 | await initDiscordClient() 164 | } else if (config.NOTIFICATION_METHOD === 'rss') { 165 | console.log('RSS feed is ready!') 166 | console.log('Feed URL: http://localhost:8080/rss/feed') 167 | } 168 | 169 | // Start the scheduler 170 | startScheduler() 171 | 172 | const port = parseInt(process.env.PORT || '8080') 173 | console.log(`Server starting on port ${port}`) 174 | 175 | // Explicitly start the server 176 | Bun.serve({ 177 | port: port, 178 | fetch: app.fetch 179 | }) 180 | } 181 | 182 | // Run the app 183 | initApp().catch(console.error) 184 | -------------------------------------------------------------------------------- /src/services/discord.ts: -------------------------------------------------------------------------------- 1 | import { Client, GatewayIntentBits, TextChannel } from 'discord.js' 2 | import { config } from '../utils/config' 3 | import type { Bookmark } from '../api/types' 4 | 5 | // Create Discord client 6 | const client = new Client({ 7 | intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] 8 | }) 9 | 10 | // Format bookmarks for Discord message 11 | export function formatDiscordMessage(bookmarks: Bookmark[]): string { 12 | console.log( 13 | 'Formatting Discord message for bookmarks:', 14 | JSON.stringify(bookmarks, null, 2) 15 | ) 16 | 17 | let message = '# Your Random Bookmarks from Hoarder \n\n' 18 | 19 | if (bookmarks.length === 0) { 20 | message += 21 | 'No bookmarks found. Please check your Hoarder API configuration.' 22 | return message 23 | } 24 | 25 | bookmarks.forEach((bookmark, index) => { 26 | const title = bookmark.title || 'Untitled Bookmark' 27 | const url = bookmark.url || '' 28 | 29 | if (url) { 30 | message += `**${index + 1}. [${title}](${url})**\n` 31 | } else { 32 | message += `**${index + 1}. ${title}**\n` 33 | } 34 | 35 | if (bookmark.description) { 36 | message += `> ${bookmark.description}\n\n` 37 | } 38 | 39 | if (bookmark.tags && bookmark.tags.length > 0) { 40 | message += '**Tags:** ' 41 | bookmark.tags.forEach(tag => { 42 | message += `\`${tag}\` ` 43 | }) 44 | message += '\n\n' 45 | } else { 46 | message += '\n' 47 | } 48 | 49 | message += '▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬\n\n' 50 | }) 51 | 52 | return message 53 | } 54 | 55 | // Send Discord message with bookmarks 56 | export async function sendBookmarksDiscord( 57 | bookmarks: Bookmark[] 58 | ): Promise { 59 | console.log(`Attempting to send ${bookmarks.length} bookmarks to Discord`) 60 | 61 | if (!client.isReady()) { 62 | console.log('Discord client not ready, attempting to login...') 63 | await client.login(config.DISCORD_BOT_TOKEN) 64 | } 65 | 66 | try { 67 | if (!config.DISCORD_CHANNEL_ID) { 68 | throw new Error('Discord channel ID is not defined') 69 | } 70 | 71 | console.log( 72 | `Fetching Discord channel with ID: ${config.DISCORD_CHANNEL_ID}` 73 | ) 74 | const channel = (await client.channels.fetch( 75 | config.DISCORD_CHANNEL_ID 76 | )) as TextChannel 77 | 78 | if (!channel || !channel.isTextBased()) { 79 | throw new Error('Invalid Discord channel or not a text channel') 80 | } 81 | 82 | console.log(`Successfully fetched channel: ${channel.name}`) 83 | 84 | const message = formatDiscordMessage(bookmarks) 85 | console.log( 86 | `Sending message to Discord channel ${channel.name} (${channel.id})` 87 | ) 88 | 89 | const sentMessage = await channel.send(message) 90 | console.log( 91 | `Message successfully sent to Discord. Message ID: ${sentMessage.id}` 92 | ) 93 | } catch (error: any) { 94 | console.error('Error sending Discord message:', error) 95 | console.error('Error details:', error.message) 96 | if (error.code) { 97 | console.error('Discord error code:', error.code) 98 | } 99 | throw error 100 | } 101 | } 102 | 103 | // Initialize Discord client 104 | export async function initDiscordClient(): Promise { 105 | if (config.NOTIFICATION_METHOD === 'discord') { 106 | client.once('ready', () => { 107 | console.log('Discord bot is ready') 108 | }) 109 | 110 | await client.login(config.DISCORD_BOT_TOKEN) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/services/email.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer' 2 | import { config } from '../utils/config' 3 | import type { Bookmark } from '../api/types' 4 | 5 | // Create transporter object for sending emails 6 | const transporter = nodemailer.createTransport({ 7 | service: config.EMAIL_SERVICE, 8 | auth: { 9 | user: config.EMAIL_USER, 10 | pass: config.EMAIL_PASS 11 | } 12 | }) 13 | 14 | // Format bookmarks into HTML for email 15 | function formatBookmarksHtml(bookmarks: Bookmark[]): string { 16 | if (bookmarks.length === 0) { 17 | return ` 18 |

📚 Your Random Bookmarks from Hoarder 📚

19 |

No bookmarks found. Please check your Hoarder API configuration.

20 | ` 21 | } 22 | 23 | return ` 24 |

📚 Your Random Bookmarks from Hoarder 📚

25 |
26 | ${bookmarks 27 | .map((bookmark, index) => { 28 | const title = bookmark.title || 'Untitled Bookmark' 29 | const url = bookmark.url || '' 30 | 31 | return ` 32 |
33 |

34 | ${index + 1}. ${ 35 | url 36 | ? `${title}` 37 | : title 38 | } 39 |

40 | ${ 41 | bookmark.description 42 | ? `
${bookmark.description}
` 43 | : '' 44 | } 45 | ${ 46 | bookmark.tags && bookmark.tags.length > 0 47 | ? `

Tags: ${bookmark.tags 48 | .map( 49 | tag => 50 | `${tag}` 51 | ) 52 | .join(' ')}

` 53 | : '' 54 | } 55 |
56 |
57 | ` 58 | }) 59 | .join('')} 60 |

Sent by your Hoarder Bookmark Sender

61 |
62 | ` 63 | } 64 | 65 | // Format bookmarks into plain text for email 66 | function formatBookmarksText(bookmarks: Bookmark[]): string { 67 | if (bookmarks.length === 0) { 68 | return '📚 Your Random Bookmarks from Hoarder 📚\n\nNo bookmarks found. Please check your Hoarder API configuration.' 69 | } 70 | 71 | let message = '📚 Your Random Bookmarks from Hoarder 📚\n\n' 72 | 73 | bookmarks.forEach((bookmark, index) => { 74 | const title = bookmark.title || 'Untitled Bookmark' 75 | const url = bookmark.url || '' 76 | 77 | message += `${index + 1}. ${title}\n` 78 | if (url) { 79 | message += ` ${url}\n` 80 | } 81 | if (bookmark.description) { 82 | message += ` > ${bookmark.description}\n\n` 83 | } 84 | if (bookmark.tags && bookmark.tags.length > 0) { 85 | message += ' Tags: ' 86 | bookmark.tags.forEach(tag => { 87 | message += `[${tag}] ` 88 | }) 89 | message += '\n' 90 | } 91 | message += '\n----------------------------------------\n\n' 92 | }) 93 | 94 | return message 95 | } 96 | 97 | // Send email with bookmarks 98 | export async function sendBookmarksEmail(bookmarks: Bookmark[]): Promise { 99 | try { 100 | const mailOptions = { 101 | from: config.EMAIL_USER, 102 | to: config.EMAIL_RECIPIENT, 103 | subject: 'Your Random Bookmarks from Hoarder', 104 | text: formatBookmarksText(bookmarks), 105 | html: formatBookmarksHtml(bookmarks) 106 | } 107 | 108 | await transporter.sendMail(mailOptions) 109 | console.log('Email sent successfully') 110 | } catch (error) { 111 | console.error('Error sending email:', error) 112 | throw error 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/services/mattermost.ts: -------------------------------------------------------------------------------- 1 | import { config } from '../utils/config' 2 | import type { Bookmark } from '../api/types' 3 | 4 | // Format bookmarks for Mattermost message (Markdown) 5 | export function formatMattermostMessage(bookmarks: Bookmark[]): string { 6 | let message = '#### Your Random Bookmarks from Hoarder\n\n' 7 | if (bookmarks.length === 0) { 8 | message += 9 | 'No bookmarks found. Please check your Hoarder API configuration.' 10 | return message 11 | } 12 | bookmarks.forEach((bookmark, index) => { 13 | const title = bookmark.title || 'Untitled Bookmark' 14 | const url = bookmark.url || '' 15 | if (url) { 16 | message += `${index + 1}. [${title}](${url})\n` 17 | } else { 18 | message += `${index + 1}. ${title}\n` 19 | } 20 | if (bookmark.description) { 21 | message += `> ${bookmark.description}\n` 22 | } 23 | if (bookmark.tags && bookmark.tags.length > 0) { 24 | message += 'Tags: ' 25 | message += bookmark.tags.map((tag) => `\`${tag}\``).join(', ') 26 | message += '\n' 27 | } 28 | message += '-----------------------------\n' 29 | }) 30 | return message 31 | } 32 | 33 | // Send bookmarks to Mattermost via webhook 34 | export async function sendBookmarksMattermost( 35 | bookmarks: Bookmark[] 36 | ): Promise { 37 | const webhookUrl = config.MATTERMOST_WEBHOOK_URL 38 | if (!webhookUrl) { 39 | throw new Error('Mattermost webhook URL is not defined in config') 40 | } 41 | const text = formatMattermostMessage(bookmarks) 42 | const payload: Record = { text } 43 | if (config.MATTERMOST_CHANNEL) { 44 | payload.channel = config.MATTERMOST_CHANNEL 45 | } 46 | try { 47 | const response = await fetch(webhookUrl, { 48 | method: 'POST', 49 | headers: { 50 | 'Content-Type': 'application/json' 51 | }, 52 | body: JSON.stringify(payload) 53 | }) 54 | 55 | if (!response.ok) { 56 | throw new Error(`HTTP error! Status: ${response.status}`) 57 | } 58 | 59 | console.log('Successfully sent bookmarks to Mattermost.') 60 | } catch (error: any) { 61 | console.error('Error sending Mattermost notification:', error?.message) 62 | throw error 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/services/rss.ts: -------------------------------------------------------------------------------- 1 | import { Feed } from 'feed' 2 | import type { Bookmark } from '../api/types' 3 | 4 | // Format bookmarks into RSS feed 5 | export function formatBookmarksRSS(bookmarks: Bookmark[]): string { 6 | const feed = new Feed({ 7 | title: "Hoarder Random RSS", 8 | description: "Your random bookmarks from Hoarder", 9 | id: "hoarder-random-bookmarks", 10 | link: "http://localhost:8080/rss/feed", 11 | updated: new Date(), 12 | copyright: "", 13 | generator: "Hoarder Random Bookmark Sender", 14 | feedLinks: { 15 | rss2: "http://localhost:8080/rss/feed" 16 | }, 17 | author: { 18 | name: "Hoarder Random Bookmark Sender" 19 | } 20 | }) 21 | 22 | if (bookmarks.length === 0) { 23 | feed.addItem({ 24 | title: "No Bookmarks Available", 25 | id: "no-bookmarks", 26 | link: "http://localhost:8080", 27 | description: "No bookmarks found. Please check your Hoarder API configuration.", 28 | date: new Date() 29 | }) 30 | } else { 31 | bookmarks.forEach((bookmark) => { 32 | const title = bookmark.title || 'Untitled Bookmark' 33 | const url = bookmark.url || 'No URL' 34 | 35 | // Create description with tags if present 36 | let description = bookmark.description || '' 37 | if (bookmark.tags && bookmark.tags.length > 0) { 38 | description += `\n\nTags: ${bookmark.tags.join(', ')}` 39 | } 40 | 41 | feed.addItem({ 42 | title: title, 43 | id: bookmark.id, 44 | link: url, 45 | description: description, 46 | date: new Date(bookmark.created_at) 47 | }) 48 | }) 49 | } 50 | 51 | return feed.rss2() 52 | } 53 | 54 | // Generate RSS feed with bookmarks 55 | export async function generateBookmarksRSS(bookmarks: Bookmark[]): Promise { 56 | try { 57 | console.log('Generating RSS feed...') 58 | const rssContent = formatBookmarksRSS(bookmarks) 59 | console.log('RSS feed generated successfully') 60 | return rssContent 61 | } catch (error) { 62 | console.error('Error generating RSS feed:', error) 63 | throw error 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/services/scheduler.ts: -------------------------------------------------------------------------------- 1 | import cron from 'node-cron' 2 | import { config } from '../utils/config' 3 | import { getRandomBookmarks } from '../api/hoarder' 4 | import { sendBookmarksEmail } from './email' 5 | import { sendBookmarksDiscord } from './discord' 6 | import { sendBookmarksMattermost } from './mattermost' 7 | import { generateBookmarksRSS } from './rss' 8 | 9 | // Function to convert time string (HH:MM) to cron time format (MM HH) 10 | function timeToCron(timeString: string): { minute: string; hour: string } { 11 | // Default to 9:00 AM if format is invalid 12 | const defaultTime = { minute: '0', hour: '9' } 13 | 14 | // Validate time format (HH:MM in 24-hour format) 15 | const timeRegex = /^([01]?[0-9]|2[0-3]):([0-5][0-9])$/ 16 | const match = timeString.match(timeRegex) 17 | 18 | if (!match) { 19 | console.warn(`Invalid time format: ${timeString}, using default 09:00`) 20 | return defaultTime 21 | } 22 | 23 | return { 24 | minute: match[2], 25 | hour: match[1].padStart(2, '0') 26 | } 27 | } 28 | 29 | // Get time components from config 30 | const { minute, hour } = timeToCron(config.TIME_TO_SEND) 31 | 32 | // Define cron expressions for different frequencies 33 | const cronExpressions = { 34 | daily: `${minute} ${hour} * * *`, // Every day at configured time 35 | weekly: `${minute} ${hour} * * 1`, // Every Monday at configured time 36 | monthly: `${minute} ${hour} 1 * *` // First day of every month at configured time 37 | } 38 | 39 | // Send notifications with bookmarks 40 | async function sendNotification() { 41 | try { 42 | console.log('=== STARTING BOOKMARK NOTIFICATION PROCESS ===') 43 | console.log('Preparing to send bookmarks notification...') 44 | console.log(`Using notification method: ${config.NOTIFICATION_METHOD}`) 45 | console.log( 46 | `Requesting ${config.BOOKMARKS_COUNT} random bookmarks${ 47 | config.SPECIFIC_LIST_ID ? ` from list ${config.SPECIFIC_LIST_ID}` : '' 48 | }` 49 | ) 50 | 51 | // Get random bookmarks based on configuration 52 | const bookmarks = await getRandomBookmarks( 53 | config.BOOKMARKS_COUNT, 54 | config.SPECIFIC_LIST_ID 55 | ) 56 | 57 | console.log(`Retrieved ${bookmarks.length} bookmarks`) 58 | 59 | if (bookmarks.length === 0) { 60 | console.log('No bookmarks available to send') 61 | return 62 | } 63 | 64 | // Log bookmark details 65 | bookmarks.forEach((bookmark, index) => { 66 | console.log(`Bookmark ${index + 1}:`, { 67 | id: bookmark.id, 68 | title: bookmark.title || 'Untitled', 69 | url: bookmark.url || 'No URL', 70 | tags: bookmark.tags || [], 71 | }) 72 | }) 73 | 74 | // Send notifications based on configured method 75 | if (config.NOTIFICATION_METHOD === 'email') { 76 | console.log('Sending bookmarks via email...') 77 | await sendBookmarksEmail(bookmarks) 78 | } else if (config.NOTIFICATION_METHOD === 'discord') { 79 | console.log('Sending bookmarks via Discord...') 80 | await sendBookmarksDiscord(bookmarks) 81 | } else if (config.NOTIFICATION_METHOD === 'mattermost') { 82 | console.log('Sending bookmarks via Mattermost...') 83 | await sendBookmarksMattermost(bookmarks) 84 | } else if (config.NOTIFICATION_METHOD === 'rss') { 85 | console.log('Updating RSS feed with new bookmarks...') 86 | await generateBookmarksRSS(bookmarks) 87 | console.log( 88 | 'RSS feed updated successfully - available at http://localhost:8080/rss/feed' 89 | ) 90 | } 91 | 92 | console.log( 93 | `Successfully sent ${bookmarks.length} bookmarks via ${config.NOTIFICATION_METHOD}` 94 | ) 95 | console.log('=== BOOKMARK NOTIFICATION PROCESS COMPLETED ===') 96 | } catch (error) { 97 | console.error('=== ERROR IN BOOKMARK NOTIFICATION PROCESS ===') 98 | console.error('Error sending notification:', error) 99 | console.error( 100 | 'Stack trace:', 101 | error instanceof Error ? error.stack : 'No stack trace available' 102 | ) 103 | console.error('=== END OF ERROR REPORT ===') 104 | } 105 | } 106 | 107 | // Start the scheduler 108 | export function startScheduler() { 109 | const cronExpression = cronExpressions[config.NOTIFICATION_FREQUENCY] 110 | 111 | if (!cronExpression) { 112 | throw new Error( 113 | `Invalid notification frequency: ${config.NOTIFICATION_FREQUENCY}` 114 | ) 115 | } 116 | 117 | console.log( 118 | `Scheduler started with ${config.NOTIFICATION_FREQUENCY} frequency (${cronExpression})` 119 | ) 120 | 121 | // Schedule the job using node-cron with timezone 122 | cron.schedule(cronExpression, sendNotification, { 123 | timezone: config.TIMEZONE 124 | }) 125 | 126 | console.log(`Using timezone: ${config.TIMEZONE}`) 127 | console.log(`Scheduled to run at: ${config.TIME_TO_SEND} (${hour}:${minute})`) 128 | } 129 | 130 | // Trigger an immediate send (for testing) 131 | export async function sendImmediate() { 132 | console.log('Triggering immediate notification send') 133 | await sendNotification() 134 | } 135 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import dotenv from 'dotenv' 3 | 4 | dotenv.config() 5 | 6 | // Define configuration schema with validation 7 | const ConfigSchema = z.object({ 8 | // Hoarder API 9 | HOARDER_API_KEY: z.string().min(1, 'Hoarder API key is required'), 10 | HOARDER_SERVER_URL: z.string().default('https://api.hoarder.app'), 11 | 12 | // Notification settings 13 | NOTIFICATION_METHOD: z.enum(['email', 'discord', 'mattermost', 'rss']), 14 | NOTIFICATION_FREQUENCY: z.enum(['daily', 'weekly', 'monthly']), 15 | BOOKMARKS_COUNT: z.coerce.number().int().positive(), 16 | SPECIFIC_LIST_ID: z.string().optional(), 17 | TIMEZONE: z.string().default('UTC'), 18 | TIME_TO_SEND: z.string().regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/).default('09:00'), 19 | 20 | // Email configuration 21 | EMAIL_SERVICE: z.string().optional(), 22 | EMAIL_USER: z.string().optional(), 23 | EMAIL_PASS: z.string().optional(), 24 | EMAIL_RECIPIENT: z.string().optional(), 25 | 26 | // Discord configuration 27 | DISCORD_BOT_TOKEN: z.string().optional(), 28 | DISCORD_CHANNEL_ID: z.string().optional(), 29 | 30 | // Mattermost configuration 31 | MATTERMOST_WEBHOOK_URL: z.string().optional(), 32 | MATTERMOST_CHANNEL: z.string().optional(), 33 | }); 34 | 35 | // Create config object and validate based on notification method 36 | function validateConfig() { 37 | try { 38 | const config = ConfigSchema.parse(process.env) 39 | 40 | // Additional validation based on notification method 41 | if (config.NOTIFICATION_METHOD === 'email') { 42 | if ( 43 | !config.EMAIL_SERVICE || 44 | !config.EMAIL_USER || 45 | !config.EMAIL_PASS || 46 | !config.EMAIL_RECIPIENT 47 | ) { 48 | throw new Error('Email configuration is incomplete') 49 | } 50 | } else if (config.NOTIFICATION_METHOD === 'discord') { 51 | if (!config.DISCORD_BOT_TOKEN || !config.DISCORD_CHANNEL_ID) { 52 | throw new Error('Discord configuration is incomplete') 53 | } 54 | } else if (config.NOTIFICATION_METHOD === 'mattermost') { 55 | if (!config.MATTERMOST_WEBHOOK_URL) { 56 | throw new Error( 57 | 'Mattermost webhook URL is required for Mattermost notifications' 58 | ) 59 | } 60 | } 61 | 62 | return config 63 | } catch (error) { 64 | console.error('Configuration validation failed:', error) 65 | process.exit(1) 66 | } 67 | } 68 | 69 | export const config = validateConfig() 70 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | --------------------------------------------------------------------------------