-
Notifications
You must be signed in to change notification settings - Fork 27
Microsoft Calendar (minor): Add EventCreated, EventUpdated, EventDeleted, EventStart triggers #1009
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
5d85cda
03117cd
a92ea67
778b4e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| 'use strict'; | ||
|
|
||
| const BASE_URL = 'https://graph.microsoft.com/v1.0'; | ||
| const CLIENT_STATE = 'appmixer.microsoft.calendar'; | ||
| const RENEW_BEFORE_MS = 300000; | ||
|
|
||
| const getSubscriptionExpirationDateTime = () => { | ||
| // Max expiration for calendar subscriptions is 4230 minutes (< 3 days). | ||
| // Renew every 2 days to stay safe. | ||
| return new Date(Date.now() + 3000 * 60 * 1000); | ||
| }; | ||
|
|
||
| module.exports = { | ||
|
|
||
| async start(context) { | ||
|
|
||
| const expirationDateTime = getSubscriptionExpirationDateTime(); | ||
| const { data } = await context.httpRequest({ | ||
| url: `${BASE_URL}/subscriptions`, | ||
| method: 'POST', | ||
| headers: { | ||
| Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`, | ||
| 'Content-Type': 'application/json', | ||
| accept: 'application/json' | ||
| }, | ||
| data: { | ||
| changeType: 'created', | ||
| notificationUrl: context.getWebhookUrl(), | ||
| resource: '/me/events', | ||
| expirationDateTime: expirationDateTime.toISOString(), | ||
| clientState: CLIENT_STATE | ||
| } | ||
| }); | ||
|
|
||
| await context.saveState({ subscriptionId: data.id }); | ||
| return context.setTimeout({}, expirationDateTime - Date.now() - RENEW_BEFORE_MS); | ||
| }, | ||
|
|
||
| async stop(context) { | ||
|
|
||
| const { subscriptionId } = context.state; | ||
| if (subscriptionId) { | ||
| await context.httpRequest({ | ||
| url: `${BASE_URL}/subscriptions/${subscriptionId}`, | ||
| method: 'DELETE', | ||
| headers: { | ||
| Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}` | ||
| } | ||
| }); | ||
| } | ||
| }, | ||
|
|
||
| async receive(context) { | ||
|
|
||
| if (context.messages.timeout) { | ||
|
|
||
| const { subscriptionId } = context.state; | ||
| const expirationDateTime = getSubscriptionExpirationDateTime(); | ||
|
|
||
| await context.httpRequest({ | ||
| url: `${BASE_URL}/subscriptions/${subscriptionId}`, | ||
| method: 'PATCH', | ||
| headers: { | ||
| Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`, | ||
| 'Content-Type': 'application/json', | ||
| accept: 'application/json' | ||
| }, | ||
| data: { expirationDateTime: expirationDateTime.toISOString() } | ||
| }); | ||
|
|
||
| return context.setTimeout({}, expirationDateTime - Date.now() - RENEW_BEFORE_MS); | ||
|
|
||
| } else if (context.messages.webhook) { | ||
|
|
||
| const { data, query } = context.messages.webhook.content; | ||
|
|
||
| if (query.validationToken) { | ||
| return context.response(query.validationToken, 200, { 'Content-type': 'text/plain' }); | ||
| } | ||
|
|
||
| const value = data.value || []; | ||
| for (const notification of value) { | ||
| if (notification.clientState === CLIENT_STATE) { | ||
| try { | ||
| const { data: event } = await context.httpRequest({ | ||
| url: `https://graph.microsoft.com/v1.0/me/events/${notification.resourceData.id}`, | ||
| method: 'GET', | ||
| headers: { | ||
| Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`, | ||
| accept: 'application/json' | ||
| } | ||
| }); | ||
| await context.sendJson(event, 'out'); | ||
| } catch (err) { | ||
| // Event may no longer exist (e.g. deleted). Log and continue. | ||
| const resId = notification.resourceData?.id; | ||
| await context.log({ error: err.message, resId }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return context.response('', 200); | ||
| } | ||
| } | ||
| }; |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| 'use strict'; | ||
|
|
||
| const BASE_URL = 'https://graph.microsoft.com/v1.0'; | ||
| const CLIENT_STATE = 'appmixer.microsoft.calendar'; | ||
| const RENEW_BEFORE_MS = 300000; | ||
|
|
||
| const getSubscriptionExpirationDateTime = () => { | ||
| // Max expiration for calendar subscriptions is 4230 minutes (< 3 days). | ||
| // Renew every 2 days to stay safe. | ||
| return new Date(Date.now() + 3000 * 60 * 1000); | ||
| }; | ||
|
|
||
| module.exports = { | ||
|
|
||
| async start(context) { | ||
|
|
||
| const expirationDateTime = getSubscriptionExpirationDateTime(); | ||
| const { data } = await context.httpRequest({ | ||
| url: `${BASE_URL}/subscriptions`, | ||
| method: 'POST', | ||
| headers: { | ||
| Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`, | ||
| 'Content-Type': 'application/json', | ||
| accept: 'application/json' | ||
| }, | ||
| data: { | ||
| changeType: 'deleted', | ||
| notificationUrl: context.getWebhookUrl(), | ||
| resource: '/me/events', | ||
| expirationDateTime: expirationDateTime.toISOString(), | ||
| clientState: CLIENT_STATE | ||
| } | ||
| }); | ||
|
|
||
| await context.saveState({ subscriptionId: data.id }); | ||
| return context.setTimeout({}, expirationDateTime - Date.now() - RENEW_BEFORE_MS); | ||
| }, | ||
|
|
||
| async stop(context) { | ||
|
|
||
| const { subscriptionId } = context.state; | ||
| if (subscriptionId) { | ||
| await context.httpRequest({ | ||
| url: `${BASE_URL}/subscriptions/${subscriptionId}`, | ||
| method: 'DELETE', | ||
| headers: { | ||
| Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}` | ||
| } | ||
| }); | ||
| } | ||
| }, | ||
|
|
||
| async receive(context) { | ||
|
|
||
| if (context.messages.timeout) { | ||
|
|
||
| const { subscriptionId } = context.state; | ||
| const expirationDateTime = getSubscriptionExpirationDateTime(); | ||
|
|
||
| await context.httpRequest({ | ||
| url: `${BASE_URL}/subscriptions/${subscriptionId}`, | ||
| method: 'PATCH', | ||
| headers: { | ||
| Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`, | ||
| 'Content-Type': 'application/json', | ||
| accept: 'application/json' | ||
| }, | ||
| data: { expirationDateTime: expirationDateTime.toISOString() } | ||
| }); | ||
|
|
||
| return context.setTimeout({}, expirationDateTime - Date.now() - RENEW_BEFORE_MS); | ||
|
|
||
| } else if (context.messages.webhook) { | ||
|
|
||
| const { data, query } = context.messages.webhook.content; | ||
|
|
||
| if (query.validationToken) { | ||
| return context.response(query.validationToken, 200, { 'Content-type': 'text/plain' }); | ||
| } | ||
|
|
||
| const value = data.value || []; | ||
| for (const notification of value) { | ||
| if (notification.clientState === CLIENT_STATE) { | ||
| try { | ||
| await context.sendJson({ | ||
| id: notification.resourceData.id, | ||
| changeType: notification.changeType, | ||
| resource: notification.resource, | ||
| subscriptionId: notification.subscriptionId, | ||
| clientState: notification.clientState, | ||
| tenantId: notification.tenantId | ||
| }, 'out'); | ||
| } catch (err) { | ||
| // Event may no longer exist (e.g. deleted). Log and continue. | ||
| const resId = notification.resourceData?.id; | ||
| await context.log({ error: err.message, resId }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return context.response('', 200); | ||
| } | ||
| } | ||
| }; |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,55 @@ | ||||||||||||||||||
| 'use strict'; | ||||||||||||||||||
|
|
||||||||||||||||||
| const BASE_URL = 'https://graph.microsoft.com/v1.0'; | ||||||||||||||||||
|
|
||||||||||||||||||
| module.exports = { | ||||||||||||||||||
|
|
||||||||||||||||||
| async tick(context) { | ||||||||||||||||||
|
|
||||||||||||||||||
| const minutesBefore = context.properties.minutesBefore || 0; | ||||||||||||||||||
|
|
||||||||||||||||||
| // Look ahead window: check events starting within the next 15 minutes (+ minutesBefore offset). | ||||||||||||||||||
| const now = new Date(); | ||||||||||||||||||
| const startDateTime = new Date(now.getTime() - minutesBefore * 60 * 1000); | ||||||||||||||||||
| const endDateTime = new Date(now.getTime() + 15 * 60 * 1000); | ||||||||||||||||||
|
Comment on lines
+9
to
+14
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — add validation and normalization for
Suggested change
This clamps negative values to 0, rounds to an integer, and handles non-numeric inputs gracefully. |
||||||||||||||||||
|
|
||||||||||||||||||
| const { data } = await context.httpRequest({ | ||||||||||||||||||
| url: `${BASE_URL}/me/calendarView`, | ||||||||||||||||||
| method: 'GET', | ||||||||||||||||||
| headers: { | ||||||||||||||||||
| Authorization: `Bearer ${context.auth?.accessToken || context.accessToken}`, | ||||||||||||||||||
| accept: 'application/json', | ||||||||||||||||||
| Prefer: 'outlook.timezone="UTC"' | ||||||||||||||||||
| }, | ||||||||||||||||||
| params: { | ||||||||||||||||||
| startDateTime: startDateTime.toISOString(), | ||||||||||||||||||
| endDateTime: endDateTime.toISOString() | ||||||||||||||||||
| } | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| const events = data.value || []; | ||||||||||||||||||
| const firedEvents = context.state.firedEvents || {}; | ||||||||||||||||||
|
|
||||||||||||||||||
| // Clean up old entries (older than 24h) to prevent state bloat. | ||||||||||||||||||
| const cutoff = Date.now() - 24 * 60 * 60 * 1000; | ||||||||||||||||||
| for (const key of Object.keys(firedEvents)) { | ||||||||||||||||||
| if (firedEvents[key] < cutoff) { | ||||||||||||||||||
| delete firedEvents[key]; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| for (const event of events) { | ||||||||||||||||||
| if (!firedEvents[event.id]) { | ||||||||||||||||||
| const eventStart = new Date(event.start.dateTime + 'Z'); | ||||||||||||||||||
| const triggerTime = new Date(eventStart.getTime() - minutesBefore * 60 * 1000); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (now >= triggerTime) { | ||||||||||||||||||
| firedEvents[event.id] = Date.now(); | ||||||||||||||||||
| await context.sendJson(event, 'out'); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| await context.saveState({ firedEvents }); | ||||||||||||||||||
| } | ||||||||||||||||||
| }; | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. The fix is to extend
endDateTimebyminutesBeforeso the query window always covers events that are eligible to fire in this tick.