Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Development Commands

- `npm run dev` - Run app locally with watch mode using test data from eventData.json
- `npm run app` - Run app locally once
- `npm run offline` - Start serverless offline on port 4500
- `npm run refresh` - Test refresh token functionality
- `npm test` - Run Jest tests
- `npm run deploy` - Deploy to production AWS environment
- `npm run logs` - Tail production Lambda logs

## High-Level Architecture

This is a serverless notification service that retrieves random inspirational quotes from Microsoft OneNote and sends them to Telegram channels on scheduled intervals.

### Core Components

**handler.js** - Main Lambda entry point that orchestrates the entire flow:
1. Initializes MSAL token cache from DynamoDB
2. Refreshes Microsoft Graph API tokens
3. Retrieves random notes from OneNote sections
4. Sends formatted messages to Telegram channels

**lib/auth.js** - Microsoft Graph API authentication using MSAL Node:
- Device code flow for initial login (sends auth prompts to Telegram)
- Automatic token refresh with fallback to device login
- Token cache persistence to DynamoDB and local file system

**lib/onenote.js** - OneNote integration via Microsoft Graph API:
- Fetches notes from specified notebook sections
- Supports both random and sequential note selection
- Prevents recent note repeats using localStorage tracking
- Handles note preview and full content retrieval

**lib/notify.js** - Message formatting and delivery:
- Converts OneNote HTML content to Telegram MarkdownV2 format
- Handles image removal and source link extraction
- Manages message length limits and formatting

### Data Flow

1. EventBridge triggers Lambda on cron schedules defined in events.yml
2. Lambda restores authentication cache from DynamoDB
3. Microsoft Graph API calls retrieve notes from OneNote sections
4. HTML content is converted to Markdown and sent to Telegram channels

### Environment Configuration

- **dev**: Uses local tmp/ directory for cache, dev Telegram channels
- **prod**: Uses Lambda /tmp for cache, production channels and secrets from SSM Parameter Store

### Key Dependencies

- `@azure/msal-node` for Microsoft authentication
- `superagent` for HTTP requests
- `telegram-format` for MarkdownV2 formatting
- `serverless` framework for AWS deployment
1 change: 1 addition & 0 deletions env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ default_env: &default_env
STAGE: ${opt:stage, self:provider.stage}
TELEGRAM_URL: 'https://api.telegram.org/bot{NotifyerBotToken}'
TELEGRAM_BOT_TOKEN: ${ssm:/telegram-bot-token}
ADMIN_TELEGRAM_CHANNEL: '@notifyer_quotes_dev'

dev:
<<: *default_env
Expand Down
2 changes: 1 addition & 1 deletion eventData.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"onenoteSettings": {
"notebookName": "2nd Brain",
"sectionName": "Test",
"isSequential": false
"isSequential": true
},
"messageSettings": {
"channelHandle": "@notifyer_quotes_dev",
Expand Down
113 changes: 83 additions & 30 deletions handler.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { deviceLogin, hasValidToken, refreshToken } = require('./lib/auth')
const { deviceLogin, hasValidToken, refreshToken, persistCache } = require('./lib/auth')
const { getNote } = require('./lib/onenote')
const notify = require('./lib/notify')
const localStorage = require('./lib/store')
Expand All @@ -11,28 +11,35 @@ const { snakeCase } = require("snake-case");
* Seed the MSAL Key Cache and localStorage with the latest from the database
*/
async function initCache(sectionHandle) {
// populate cache with db contents
const data = await db.getItem('cache')
await fs
.writeFile(process.env.CACHE_PATH, data)
.then(console.log('Restore Cache'))
try {
// populate cache with db contents
const data = await db.getItem('cache')
const path = require('path')
// Resolve to absolute path to handle webpack bundling context
const cachePath = path.resolve(process.env.CACHE_PATH)
await fs.writeFile(cachePath, data)
console.log('Restore Cache')

// populate local storage with login contents
// coerced to json
localStorage.initStore();
const onenote = await db.getItem('onenote', true)
localStorage.setItem('onenote', onenote)
// populate local storage with login contents
// coerced to json
localStorage.initStore();
const onenote = await db.getItem('onenote', true)
localStorage.setItem('onenote', onenote)

const count = await db.getItem(`${sectionHandle}_section_count`)
localStorage.setItem(`${sectionHandle}_section_count`, count)
const count = await db.getItem(`${sectionHandle}_section_count`)
localStorage.setItem(`${sectionHandle}_section_count`, count)

const lastPage = await db.getItem(`${sectionHandle}_last_page`)
localStorage.setItem(`${sectionHandle}_last_page`, lastPage)
const lastPage = await db.getItem(`${sectionHandle}_last_page`)
localStorage.setItem(`${sectionHandle}_last_page`, lastPage)

const recent = (await db.getItem(`recent_${sectionHandle}`, true)) || []
localStorage.setItem(`recent_${sectionHandle}`, recent)
const recent = (await db.getItem(`recent_${sectionHandle}`, true)) || []
localStorage.setItem(`recent_${sectionHandle}`, recent)

console.log('Restore localStorage')
console.log('Restore localStorage')
} catch (err) {
console.error('Error initializing cache', err);
throw err;
}
}

const app = async (event, context) => {
Expand All @@ -45,12 +52,21 @@ const app = async (event, context) => {
}

const resp = await initCache(onenoteSettings.sectionHandle)
.then(() => refreshToken())
.then(tokenResponse => {
.then(() => {
console.log('Cache initialized, checking token validity');
return refreshToken();
})
.then(async tokenResponse => {
if (!tokenResponse || !hasValidToken()) {
console.log('Token still invalid after refresh, initiating device login');
return deviceLogin();
console.error('Token refresh returned invalid token');
throw new Error('Token refresh failed - device login required');
}
console.log('Token refresh successful, ensuring cache persistence');

// Ensure cache is persisted after successful refresh
await persistCache();

console.log('Proceeding with OneNote API calls');
return tokenResponse;
})
.then(() => getNote(onenoteSettings))
Expand All @@ -60,21 +76,58 @@ const app = async (event, context) => {
}
return notify.withTelegram(note, messageSettings);
})
.catch(err => {
console.log(
'Ooops!',
`Can't seem to find any notes here. Please check if you created a section called '${onenoteSettings.sectionName}', add some notes.`
);
.catch(async err => {
console.error('App: Check Logs', err);
const errorMessage = err.errorMessage || err.message || String(err);

if (err.message === 'Token refresh failed - device login required') {
try {
console.log('Attempting device login after token refresh failure');
await deviceLogin();
console.log('Device login successful, manually persisting cache');

// Ensure cache is persisted to DynamoDB
await persistCache();

await notify.sendNoteToTelegram(
'Device login completed successfully. Authentication restored.',
process.env.ADMIN_TELEGRAM_CHANNEL,
null,
true
);

// Return success after device login
return {
status: 200,
title: 'Authentication Restored',
body: 'Device login completed successfully'
};
} catch (loginErr) {
const loginErrorMsg = loginErr.errorMessage || loginErr.message || String(loginErr);
console.error('Device login failed:', loginErrorMsg);
await notify.sendNoteToTelegram(
`Device login failed: ${loginErrorMsg}`,
process.env.ADMIN_TELEGRAM_CHANNEL,
null,
true
);
}
} else {
await notify.sendNoteToTelegram(
errorMessage,
process.env.ADMIN_TELEGRAM_CHANNEL,
null,
true
);
}

return {
status: 400,
title: 'Error',
body: err
body: errorMessage
};
});



return {
status: resp.status,
title: resp.title,
Expand Down
Loading