Skip to content

Commit 70eb39c

Browse files
authored
Merge pull request #29 from komplexb/fix/login-issues-v2
Fix/login issues v2
2 parents 33e9e18 + d969f81 commit 70eb39c

File tree

10 files changed

+397
-264
lines changed

10 files changed

+397
-264
lines changed

CLAUDE.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Development Commands
6+
7+
- `npm run dev` - Run app locally with watch mode using test data from eventData.json
8+
- `npm run app` - Run app locally once
9+
- `npm run offline` - Start serverless offline on port 4500
10+
- `npm run refresh` - Test refresh token functionality
11+
- `npm test` - Run Jest tests
12+
- `npm run deploy` - Deploy to production AWS environment
13+
- `npm run logs` - Tail production Lambda logs
14+
15+
## High-Level Architecture
16+
17+
This is a serverless notification service that retrieves random inspirational quotes from Microsoft OneNote and sends them to Telegram channels on scheduled intervals.
18+
19+
### Core Components
20+
21+
**handler.js** - Main Lambda entry point that orchestrates the entire flow:
22+
1. Initializes MSAL token cache from DynamoDB
23+
2. Refreshes Microsoft Graph API tokens
24+
3. Retrieves random notes from OneNote sections
25+
4. Sends formatted messages to Telegram channels
26+
27+
**lib/auth.js** - Microsoft Graph API authentication using MSAL Node:
28+
- Device code flow for initial login (sends auth prompts to Telegram)
29+
- Automatic token refresh with fallback to device login
30+
- Token cache persistence to DynamoDB and local file system
31+
32+
**lib/onenote.js** - OneNote integration via Microsoft Graph API:
33+
- Fetches notes from specified notebook sections
34+
- Supports both random and sequential note selection
35+
- Prevents recent note repeats using localStorage tracking
36+
- Handles note preview and full content retrieval
37+
38+
**lib/notify.js** - Message formatting and delivery:
39+
- Converts OneNote HTML content to Telegram MarkdownV2 format
40+
- Handles image removal and source link extraction
41+
- Manages message length limits and formatting
42+
43+
### Data Flow
44+
45+
1. EventBridge triggers Lambda on cron schedules defined in events.yml
46+
2. Lambda restores authentication cache from DynamoDB
47+
3. Microsoft Graph API calls retrieve notes from OneNote sections
48+
4. HTML content is converted to Markdown and sent to Telegram channels
49+
50+
### Environment Configuration
51+
52+
- **dev**: Uses local tmp/ directory for cache, dev Telegram channels
53+
- **prod**: Uses Lambda /tmp for cache, production channels and secrets from SSM Parameter Store
54+
55+
### Key Dependencies
56+
57+
- `@azure/msal-node` for Microsoft authentication
58+
- `superagent` for HTTP requests
59+
- `telegram-format` for MarkdownV2 formatting
60+
- `serverless` framework for AWS deployment

db/persist.js

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,24 @@ async function getItem(itemName, parse = false) {
3232

3333
try {
3434
const data = await documentClient.get(params).promise()
35-
console.log(`Getting '${itemName}'`)
36-
return parse ? JSON.parse(data.Item[itemName]) : data.Item[itemName]
35+
36+
// Check if item exists and has the requested attribute
37+
if (!data.Item || data.Item[itemName] === undefined) {
38+
return null
39+
}
40+
41+
const itemValue = data.Item[itemName]
42+
43+
// Handle empty or null values
44+
if (itemValue === null || itemValue === undefined) {
45+
return null
46+
}
47+
48+
return parse ? JSON.parse(itemValue) : itemValue
3749
} catch (err) {
3850
console.error(`Error getting db item: '${itemName}'`)
3951
console.error(err)
40-
return err
52+
throw err // Throw error for consistency with setItem
4153
}
4254
}
4355

@@ -61,12 +73,12 @@ async function setItem(itemName, data) {
6173
}
6274

6375
try {
64-
const data = await documentClient.update(params).promise()
65-
console.log(`Attribute '${itemName}' Updated`)
76+
const result = await documentClient.update(params).promise()
77+
return result
6678
} catch (err) {
6779
console.error(`Error setting db item: '${itemName}'`)
6880
console.error(err)
69-
return err
81+
throw err // Throw error instead of returning it
7082
}
7183
}
7284

env.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ default_env: &default_env
1919
STAGE: ${opt:stage, self:provider.stage}
2020
TELEGRAM_URL: 'https://api.telegram.org/bot{NotifyerBotToken}'
2121
TELEGRAM_BOT_TOKEN: ${ssm:/telegram-bot-token}
22+
ADMIN_TELEGRAM_CHANNEL: '@notifyer_quotes_dev'
2223

2324
dev:
2425
<<: *default_env

handler.js

Lines changed: 128 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,156 @@
1-
const { deviceLogin, hasValidToken, refreshToken } = require('./lib/auth')
2-
const { getNote } = require('./lib/onenote')
3-
const notify = require('./lib/notify')
4-
const localStorage = require('./lib/store')
5-
const { promises: fs } = require('fs')
6-
const db = require('./db/persist')
1+
const { hasValidToken, refreshToken, deviceLogin } = require("./lib/auth");
2+
const { getNote } = require("./lib/onenote");
3+
const notify = require("./lib/notify");
4+
const localStorage = require("./lib/store");
5+
const { promises: fs } = require("fs");
6+
const db = require("./db/persist");
77
const { snakeCase } = require("snake-case");
88

99
/**
1010
* Lambda functions have ephemeral storage on the server in /tmp.
1111
* Seed the MSAL Key Cache and localStorage with the latest from the database
1212
*/
1313
async function initCache(sectionHandle) {
14-
// populate cache with db contents
15-
const data = await db.getItem('cache')
16-
await fs
17-
.writeFile(process.env.CACHE_PATH, data)
18-
.then(console.log('Restore Cache'))
14+
try {
15+
// populate cache with db contents
16+
let cacheData;
17+
try {
18+
cacheData = await db.getItem("cache");
19+
} catch (error) {
20+
// Cache not found in DB, will start fresh
21+
cacheData = null;
22+
}
23+
24+
// Handle corrupted cache data (object instead of string)
25+
if (
26+
cacheData &&
27+
typeof cacheData === "object" &&
28+
Object.keys(cacheData).length === 0
29+
) {
30+
await db.setItem("cache", null); // Clear the corrupted cache
31+
cacheData = null;
32+
}
33+
34+
if (cacheData && typeof cacheData === "string" && cacheData.trim()) {
35+
// Validate that we have proper MSAL cache structure
36+
try {
37+
const parsed = JSON.parse(cacheData);
38+
if (parsed.Account && parsed.RefreshToken && parsed.AccessToken) {
39+
const path = require("path");
40+
const cachePath = path.resolve(process.env.CACHE_PATH);
41+
await fs.writeFile(cachePath, cacheData);
42+
}
43+
} catch (parseError) {
44+
// Cache data is not valid JSON, will start fresh
45+
}
46+
}
1947

20-
// populate local storage with login contents
21-
// coerced to json
22-
localStorage.initStore();
23-
const onenote = await db.getItem('onenote', true)
24-
localStorage.setItem('onenote', onenote)
48+
// populate local storage with login contents
49+
// coerced to json
50+
localStorage.initStore();
2551

26-
const count = await db.getItem(`${sectionHandle}_section_count`)
27-
localStorage.setItem(`${sectionHandle}_section_count`, count)
52+
try {
53+
const onenote = await db.getItem("onenote", true);
54+
if (onenote) {
55+
localStorage.setItem("onenote", onenote);
56+
}
57+
} catch (error) {
58+
// OneNote data not found or corrupted, will be recreated on next auth
59+
}
2860

29-
const lastPage = await db.getItem(`${sectionHandle}_last_page`)
30-
localStorage.setItem(`${sectionHandle}_last_page`, lastPage)
61+
try {
62+
const count = await db.getItem(`${sectionHandle}_section_count`);
63+
localStorage.setItem(`${sectionHandle}_section_count`, count);
64+
} catch (error) {
65+
// Section count not found, will start from 0
66+
}
3167

32-
const recent = (await db.getItem(`recent_${sectionHandle}`, true)) || []
33-
localStorage.setItem(`recent_${sectionHandle}`, recent)
68+
try {
69+
const lastPage = await db.getItem(`${sectionHandle}_last_page`);
70+
localStorage.setItem(`${sectionHandle}_last_page`, lastPage);
71+
} catch (error) {
72+
// Last page not found, will start fresh
73+
}
3474

35-
console.log('Restore localStorage')
75+
try {
76+
const recent = (await db.getItem(`recent_${sectionHandle}`, true)) || [];
77+
// Ensure recent is always an array
78+
const recentArray = Array.isArray(recent) ? recent : [];
79+
localStorage.setItem(`recent_${sectionHandle}`, recentArray);
80+
} catch (error) {
81+
// Recent data not found, will start with empty array
82+
localStorage.setItem(`recent_${sectionHandle}`, []);
83+
}
84+
} catch (err) {
85+
console.error("Error initializing cache", err);
86+
throw err;
87+
}
3688
}
3789

38-
const app = async (event, context) => {
39-
let { onenoteSettings, messageSettings } = event
90+
const app = async (event) => {
91+
let { onenoteSettings, messageSettings } = event;
4092

4193
onenoteSettings = {
4294
sectionHandle: snakeCase(onenoteSettings.sectionName),
4395
isSequential: false,
44-
...onenoteSettings
45-
}
96+
...onenoteSettings,
97+
};
4698

4799
const resp = await initCache(onenoteSettings.sectionHandle)
48-
.then(() => refreshToken())
49-
.then(tokenResponse => {
50-
if (!tokenResponse || !hasValidToken()) {
51-
console.log('Token still invalid after refresh, initiating device login');
52-
return deviceLogin();
53-
}
54-
return tokenResponse;
55-
})
56-
.then(() => getNote(onenoteSettings))
57-
.then(note => {
58-
if (typeof note === 'undefined') {
59-
throw new Error('Note is undefined');
60-
}
61-
return notify.withTelegram(note, messageSettings);
62-
})
63-
.catch(err => {
64-
console.log(
65-
'Ooops!',
66-
`Can't seem to find any notes here. Please check if you created a section called '${onenoteSettings.sectionName}', add some notes.`
67-
);
68-
console.error('App: Check Logs', err);
69-
return {
70-
status: 400,
71-
title: 'Error',
72-
body: err
73-
};
74-
});
100+
.then(() => refreshToken())
101+
.then((tokenResponse) => {
102+
if (!tokenResponse || !hasValidToken()) {
103+
throw new Error("Token refresh failed - device login required");
104+
}
105+
return tokenResponse;
106+
})
107+
.then(() => getNote(onenoteSettings))
108+
.then((note) => {
109+
if (typeof note === "undefined") {
110+
throw new Error("Note is undefined");
111+
}
112+
return notify.withTelegram(note, messageSettings);
113+
})
114+
.catch(async (err) => {
115+
console.error("App: Check Logs", err);
116+
const errorMessage = err.errorMessage || err.message || String(err);
75117

118+
if (err.message === "Token refresh failed - device login required") {
119+
try {
120+
await deviceLogin();
121+
} catch (loginErr) {
122+
const loginErrorMsg =
123+
loginErr.errorMessage || loginErr.message || String(loginErr);
124+
await notify.sendNoteToTelegram(
125+
`Device login failed: ${loginErrorMsg}`,
126+
process.env.ADMIN_TELEGRAM_CHANNEL,
127+
null,
128+
true
129+
);
130+
}
131+
} else {
132+
await notify.sendNoteToTelegram(
133+
errorMessage,
134+
process.env.ADMIN_TELEGRAM_CHANNEL,
135+
null,
136+
true
137+
);
138+
}
76139

140+
return {
141+
status: 400,
142+
title: "Error",
143+
body: errorMessage,
144+
};
145+
});
77146

78147
return {
79148
status: resp.status,
80149
title: resp.title,
81-
body: resp.body
82-
}
83-
}
150+
body: resp.body,
151+
};
152+
};
84153

85154
module.exports = {
86-
app
87-
}
155+
app,
156+
};

0 commit comments

Comments
 (0)