Skip to content

Commit 284503d

Browse files
hubyrodclaude
andcommitted
Add Google Calendar reminders to Slashwork
Poll Google Calendar API every 60s, post "starting soon" reminders to Slashwork groups or chats. Per-user config maps a calendar ID to a target. Uses service account auth (JSON key via env var), dedicated Slashwork token, in-memory dedup, and is entirely optional (gated on GOOGLE_SERVICE_ACCOUNT_KEY). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5829208 commit 284503d

11 files changed

Lines changed: 606 additions & 0 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@ SLASHWORK_AUTH_TOKEN=your_application_auth_token
66
GITHUB_WEBHOOK_SECRET=your_github_webhook_secret
77

88
PORT=3000
9+
10+
# Google Calendar reminders (optional — omit to disable)
11+
# Service account JSON key (paste the full JSON content)
12+
GOOGLE_SERVICE_ACCOUNT_KEY={"type":"service_account","client_email":"...","private_key":"..."}
13+
# Slashwork auth token for posting calendar reminders
14+
SLASHWORK_AUTH_TOKEN_GOOGLE_CALENDAR=your_calendar_auth_token
15+
# Per-user calendar → group mapping is configured in config.ts

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
3131
# IntelliJ based IDEs
3232
.idea
3333

34+
# Google service account keys
35+
*.json
36+
!package.json
37+
!tsconfig.json
38+
3439
# Finder (MacOS) folder config
3540
.DS_Store
3641

config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ const config: SkiphooksConfig = {
1717
authToken: process.env.SLASHWORK_AUTH_TOKEN_SKJS!,
1818
},
1919
},
20+
...(process.env.GOOGLE_SERVICE_ACCOUNT_KEY
21+
? {
22+
calendar: {
23+
serviceAccountKey: process.env.GOOGLE_SERVICE_ACCOUNT_KEY,
24+
authToken: process.env.SLASHWORK_AUTH_TOKEN_GOOGLE_CALENDAR!,
25+
users: [
26+
// Add one entry per user: their calendar → a Slashwork group or chat
27+
// { name: "Alice", calendarId: "alice@example.com", targetId: "g_..." },
28+
],
29+
},
30+
}
31+
: {}),
2032
routes: {
2133
skipper: {
2234
group: "skipper",

src/calendar/auth.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { createSign } from "node:crypto";
2+
3+
interface ServiceAccountKey {
4+
client_email: string;
5+
private_key: string;
6+
token_uri: string;
7+
}
8+
9+
interface CachedToken {
10+
accessToken: string;
11+
expiresAt: number;
12+
}
13+
14+
let cachedToken: CachedToken | null = null;
15+
16+
function base64url(input: string | Buffer): string {
17+
const buf = typeof input === "string" ? Buffer.from(input) : input;
18+
return buf.toString("base64url");
19+
}
20+
21+
function createJwt(key: ServiceAccountKey): string {
22+
const now = Math.floor(Date.now() / 1000);
23+
const header = { alg: "RS256", typ: "JWT" };
24+
const payload = {
25+
iss: key.client_email,
26+
scope: "https://www.googleapis.com/auth/calendar.readonly",
27+
aud: key.token_uri,
28+
iat: now,
29+
exp: now + 3600,
30+
};
31+
32+
const segments = [
33+
base64url(JSON.stringify(header)),
34+
base64url(JSON.stringify(payload)),
35+
];
36+
const signingInput = segments.join(".");
37+
38+
const sign = createSign("RSA-SHA256");
39+
sign.update(signingInput);
40+
const signature = sign.sign(key.private_key);
41+
42+
return `${signingInput}.${base64url(signature)}`;
43+
}
44+
45+
export function parseServiceAccountKey(
46+
jsonString: string,
47+
): ServiceAccountKey {
48+
const content = JSON.parse(jsonString);
49+
if (!content.client_email || !content.private_key) {
50+
throw new Error(
51+
"Service account key missing client_email or private_key",
52+
);
53+
}
54+
return {
55+
client_email: content.client_email,
56+
private_key: content.private_key,
57+
token_uri: content.token_uri || "https://oauth2.googleapis.com/token",
58+
};
59+
}
60+
61+
export async function getAccessToken(serviceAccountKey: string): Promise<string> {
62+
// Return cached token if still valid (with 5 min buffer)
63+
if (cachedToken && cachedToken.expiresAt > Date.now() + 5 * 60 * 1000) {
64+
return cachedToken.accessToken;
65+
}
66+
67+
const key = parseServiceAccountKey(serviceAccountKey);
68+
const jwt = createJwt(key);
69+
70+
const response = await fetch(key.token_uri, {
71+
method: "POST",
72+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
73+
body: new URLSearchParams({
74+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
75+
assertion: jwt,
76+
}),
77+
});
78+
79+
if (!response.ok) {
80+
const body = await response.text();
81+
throw new Error(
82+
`Google OAuth token exchange failed: ${response.status} - ${body}`,
83+
);
84+
}
85+
86+
const result = (await response.json()) as {
87+
access_token: string;
88+
expires_in: number;
89+
};
90+
91+
cachedToken = {
92+
accessToken: result.access_token,
93+
expiresAt: Date.now() + result.expires_in * 1000,
94+
};
95+
96+
return cachedToken.accessToken;
97+
}
98+
99+
export async function validateCalendarAuth(
100+
serviceAccountKey: string,
101+
): Promise<void> {
102+
await getAccessToken(serviceAccountKey);
103+
}
104+
105+
/** Reset cached token (for tests). */
106+
export function _resetTokenCache(): void {
107+
cachedToken = null;
108+
}

src/calendar/calendar.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { test, expect } from "bun:test";
2+
import { formatCalendarReminder, isAllDayEvent, isCancelledEvent } from "./format.ts";
3+
import type { CalendarEvent } from "./types.ts";
4+
5+
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
6+
return {
7+
id: "event-1",
8+
status: "confirmed",
9+
summary: "Team Standup",
10+
htmlLink: "https://calendar.google.com/event?eid=abc123",
11+
start: { dateTime: "2026-03-03T10:00:00-05:00" },
12+
end: { dateTime: "2026-03-03T10:30:00-05:00" },
13+
...overrides,
14+
};
15+
}
16+
17+
// formatCalendarReminder tests
18+
19+
test("formatCalendarReminder: includes all fields", () => {
20+
const event = makeEvent({
21+
location: "Conference Room A",
22+
hangoutLink: "https://meet.google.com/abc-defg-hij",
23+
description: "Daily sync to discuss progress",
24+
});
25+
26+
const { markdown } = formatCalendarReminder(event, "Engineering", 5);
27+
28+
expect(markdown).toContain("Team Standup");
29+
expect(markdown).toContain("in 5 min");
30+
expect(markdown).toContain("Engineering");
31+
expect(markdown).toContain("Conference Room A");
32+
expect(markdown).toContain("https://meet.google.com/abc-defg-hij");
33+
expect(markdown).toContain("Join video call");
34+
expect(markdown).toContain("Daily sync to discuss progress");
35+
expect(markdown).toContain("https://calendar.google.com/event?eid=abc123");
36+
});
37+
38+
test("formatCalendarReminder: handles missing optional fields", () => {
39+
const event = makeEvent({
40+
summary: undefined,
41+
htmlLink: undefined,
42+
location: undefined,
43+
hangoutLink: undefined,
44+
description: undefined,
45+
});
46+
47+
const { markdown } = formatCalendarReminder(event, "Work", 3);
48+
49+
expect(markdown).toContain("(No title)");
50+
expect(markdown).toContain("in 3 min");
51+
expect(markdown).toContain("Work");
52+
expect(markdown).not.toContain("Location");
53+
expect(markdown).not.toContain("Join video call");
54+
});
55+
56+
test("formatCalendarReminder: truncates long description", () => {
57+
const event = makeEvent({
58+
description: "x".repeat(300),
59+
});
60+
61+
const { markdown } = formatCalendarReminder(event, "Cal", 2);
62+
63+
expect(markdown).toContain("…");
64+
// Should be truncated to ~200 chars + ellipsis
65+
const descLine = markdown.split("\n").find((l) => l.startsWith(">"));
66+
expect(descLine).toBeDefined();
67+
expect(descLine!.length).toBeLessThan(210);
68+
});
69+
70+
test("formatCalendarReminder: strips HTML from description", () => {
71+
const event = makeEvent({
72+
description: "<b>Important:</b> Bring <a href='#'>docs</a>",
73+
});
74+
75+
const { markdown } = formatCalendarReminder(event, "Cal", 4);
76+
77+
expect(markdown).toContain("Important:");
78+
expect(markdown).toContain("Bring");
79+
expect(markdown).toContain("docs");
80+
expect(markdown).not.toContain("<b>");
81+
expect(markdown).not.toContain("<a");
82+
});
83+
84+
test("formatCalendarReminder: shows 'starting now' for <=1 min", () => {
85+
const event = makeEvent();
86+
87+
const { markdown } = formatCalendarReminder(event, "Cal", 0.5);
88+
89+
expect(markdown).toContain("starting now");
90+
expect(markdown).not.toContain("in 0");
91+
});
92+
93+
test("formatCalendarReminder: uses conferenceData when no hangoutLink", () => {
94+
const event = makeEvent({
95+
hangoutLink: undefined,
96+
conferenceData: {
97+
entryPoints: [
98+
{ entryPointType: "phone", uri: "tel:+1234567890" },
99+
{ entryPointType: "video", uri: "https://zoom.us/j/123456" },
100+
],
101+
},
102+
});
103+
104+
const { markdown } = formatCalendarReminder(event, "Cal", 3);
105+
106+
expect(markdown).toContain("https://zoom.us/j/123456");
107+
expect(markdown).toContain("Join video call");
108+
});
109+
110+
// isAllDayEvent tests
111+
112+
test("isAllDayEvent: returns true for date-only events", () => {
113+
const event = makeEvent({
114+
start: { date: "2026-03-03" },
115+
end: { date: "2026-03-04" },
116+
});
117+
118+
expect(isAllDayEvent(event)).toBe(true);
119+
});
120+
121+
test("isAllDayEvent: returns false for timed events", () => {
122+
const event = makeEvent();
123+
124+
expect(isAllDayEvent(event)).toBe(false);
125+
});
126+
127+
// isCancelledEvent tests
128+
129+
test("isCancelledEvent: returns true for cancelled status", () => {
130+
const event = makeEvent({ status: "cancelled" });
131+
132+
expect(isCancelledEvent(event)).toBe(true);
133+
});
134+
135+
test("isCancelledEvent: returns false for confirmed status", () => {
136+
const event = makeEvent({ status: "confirmed" });
137+
138+
expect(isCancelledEvent(event)).toBe(false);
139+
});
140+
141+
// Dedup key logic (tested via Set behavior)
142+
143+
test("dedup: same event ID + start time is deduplicated", () => {
144+
const remindedKeys = new Set<string>();
145+
const key1 = "cal1:event-1:2026-03-03T10:00:00Z";
146+
const key2 = "cal1:event-1:2026-03-03T10:00:00Z";
147+
148+
remindedKeys.add(key1);
149+
expect(remindedKeys.has(key2)).toBe(true);
150+
});
151+
152+
test("dedup: different event IDs are not deduplicated", () => {
153+
const remindedKeys = new Set<string>();
154+
const key1 = "cal1:event-1:2026-03-03T10:00:00Z";
155+
const key2 = "cal1:event-2:2026-03-03T10:00:00Z";
156+
157+
remindedKeys.add(key1);
158+
expect(remindedKeys.has(key2)).toBe(false);
159+
});
160+
161+
test("dedup: same event at different times is not deduplicated", () => {
162+
const remindedKeys = new Set<string>();
163+
const key1 = "cal1:event-1:2026-03-03T10:00:00Z";
164+
const key2 = "cal1:event-1:2026-03-04T10:00:00Z";
165+
166+
remindedKeys.add(key1);
167+
expect(remindedKeys.has(key2)).toBe(false);
168+
});

src/calendar/fetch-events.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { CalendarEvent, CalendarEventsResponse } from "./types.ts";
2+
3+
export async function fetchUpcomingEvents(
4+
accessToken: string,
5+
calendarId: string,
6+
timeMin: Date,
7+
timeMax: Date,
8+
): Promise<CalendarEvent[]> {
9+
const params = new URLSearchParams({
10+
timeMin: timeMin.toISOString(),
11+
timeMax: timeMax.toISOString(),
12+
singleEvents: "true",
13+
orderBy: "startTime",
14+
maxResults: "50",
15+
});
16+
17+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`;
18+
19+
const response = await fetch(url, {
20+
headers: { Authorization: `Bearer ${accessToken}` },
21+
});
22+
23+
if (!response.ok) {
24+
return [];
25+
}
26+
27+
const data = (await response.json()) as CalendarEventsResponse;
28+
return data.items ?? [];
29+
}

0 commit comments

Comments
 (0)