Skip to content
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

✨ add support for gcloud service accounts and private calendars #50

Merged
merged 2 commits into from
Oct 12, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ DISCORD_EVENTS_SYNC_DISCORD_GUILD_ID=
DISCORD_EVENTS_SYNC_DISCORD_BOT_TOKEN=
DISCORD_EVENTS_SYNC_DISCORD_APPLICATION_ID=
DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_CALENDAR_ID=
DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_API_KEY=
DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_API_KEY=
DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON=
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ on:
workflow_dispatch:

jobs:
build:
calsync:
runs-on: ubuntu-latest
name: sync
name: 🔁 Sync Events
steps:
- uses: acm-uic/calsync@main
with:
Expand All @@ -18,3 +18,4 @@ jobs:
discord-application-id: ${{ secrets.DISCORD_EVENTS_SYNC_DISCORD_APPLICATION_ID }}
google-calendar-id: ${{ secrets.DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_CALENDAR_ID }}
google-api-key: ${{ secrets.DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_API_KEY }}
google-service-account-key-json: ${{ secrets.DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON }}
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ jobs:
discord-bot-token: ${{ secrets.DISCORD_BOT_TOKEN }} # needs "bot" scope and "View Channels", "Manage Events", "Create Events" bot permissions. permissions=17600775980032
discord-application-id: ${{ secrets.DISCORD_APPLICATION_ID }}
google-calendar-id: ${{ secrets.GOOGLE_CALENDAR_CALENDAR_ID }}
google-api-key: ${{ secrets.GOOGLE_CALENDAR_API_KEY }}
google-service-account-key-json: ${{ secrets.GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON }} # either use google-api-key or google-service-account-key-json
google-api-key: ${{ secrets.GOOGLE_CALENDAR_API_KEY }} # either use google-api-key or google-service-account-key-json
```

## How does it work?
Expand All @@ -47,3 +48,14 @@ variables or in `.env.` file in the root of the repository.
## Discord bot permissions

The Discord bot needs "Read Messages/View Channels", "Manage Events" permissions.

## Google Cloud setup

1. Enable the Google Calendar API for the Google Cloud project.
2. Create a service account and download the JSON key.
1. Credeintials -> Create Credentials -> Service Account -> JSON key
bmiddha marked this conversation as resolved.
Show resolved Hide resolved
2. In Step 2 (Grant this service account access to project): Add "Service Account Token Creator" role
3. After creation, click on the service account, go to "Keys" tab, and create a new JSON key.
4. Save the JSON key as a secret in the GitHub repository.
3. Share the Google Calendar with the service account email. "Settings and sharing" -> "Share with specific people" ->
Add the service account email. Give it "See all event details" permission.
1 change: 1 addition & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ runs:
DISCORD_EVENTS_SYNC_DISCORD_APPLICATION_ID: ${{ inputs.discord-application-id }}
DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_CALENDAR_ID: ${{ inputs.google-calendar-id }}
DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_API_KEY: ${{ inputs.google-api-key }}
DISCORD_EVENTS_SYNC_GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON: ${{ inputs.google-service-account-key-json }}
34 changes: 24 additions & 10 deletions envConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,43 @@ import "@std/dotenv/load";
/** Prefix used for environment variable config options */
const ENVIRONMENT_VARIABLE_PREFIX = "DISCORD_EVENTS_SYNC_";

const getEnv = (name: string) => {
const getEnv = (name: string, required: boolean = true) => {
const envName = ENVIRONMENT_VARIABLE_PREFIX + name;
const value = Deno.env.get(envName);
if (value) {
return value;
}

const message = `Environment {${envName}} not found.`;
console.error(message);
throw new Error(message);
if (required) {
const message = `Environment {${envName}} not found.`;
throw new Error(message);
}
};

export const loadEnvConfig = () => {
const serviceAccountKeyJson = getEnv(
"GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON",
false,
);

const apiKey = getEnv("GOOGLE_CALENDAR_API_KEY", false);

if (!serviceAccountKeyJson && !apiKey) {
throw new Error(
`Either {${ENVIRONMENT_VARIABLE_PREFIX}GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_JSON} or {${ENVIRONMENT_VARIABLE_PREFIX}GOOGLE_CALENDAR_API_KEY} must be provided.`,
);
}

const config = {
discord: {
guildId: getEnv("DISCORD_GUILD_ID"),
botToken: getEnv("DISCORD_BOT_TOKEN"),
applicationId: getEnv("DISCORD_APPLICATION_ID"),
guildId: getEnv("DISCORD_GUILD_ID")!,
botToken: getEnv("DISCORD_BOT_TOKEN")!,
applicationId: getEnv("DISCORD_APPLICATION_ID")!,
},
googleCalendar: {
calendarId: getEnv("GOOGLE_CALENDAR_CALENDAR_ID"),
apiKey: getEnv("GOOGLE_CALENDAR_API_KEY"),
calendarId: getEnv("GOOGLE_CALENDAR_CALENDAR_ID")!,
...(serviceAccountKeyJson ? { serviceAccountKeyJson: serviceAccountKeyJson! } : { apiKey: apiKey! }),
},
};

return config;
};
147 changes: 130 additions & 17 deletions gcal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,151 @@ export interface IGetCalendarEventsParams {

export class GoogleCalendarClient {
#calendarId: string;
#apiKey: string;
#apiKey: string | undefined;
#serviceAccountKeyJson:
| {
private_key: string;
client_email: string;
}
| undefined;

constructor({ calendarId, apiKey }: { calendarId: string; apiKey: string }) {
this.#calendarId = calendarId;
this.#apiKey = apiKey;
constructor(
options:
| { calendarId: string; apiKey: string }
| {
calendarId: string;
serviceAccountKeyJson: string;
},
) {
if ("serviceAccountKeyJson" in options) {
const { calendarId, serviceAccountKeyJson } = options;
this.#calendarId = calendarId;
this.#serviceAccountKeyJson = JSON.parse(serviceAccountKeyJson);
} else {
const { calendarId, apiKey } = options;
this.#calendarId = calendarId;
this.#apiKey = apiKey;
}
}

public async getEvents(
{ singleEvents, timeMax, timeMin, maxResults, orderBy }: IGetCalendarEventsParams,
) {
private async getAccessToken() {
if (!this.#serviceAccountKeyJson) {
throw new Error("Service Account Key JSON not provided.");
}

const { client_email, private_key } = this.#serviceAccountKeyJson;
console.log(`Getting access token for ${client_email}`);

const header = { alg: "RS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const claims = {
iss: client_email,
scope: "https://www.googleapis.com/auth/calendar.events.readonly",
aud: "https://oauth2.googleapis.com/token",
iat: now,
exp: now + 3600,
};

const encodeBase64Url = (data: string): string => {
return btoa(data)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
};
const base64UrlHeader = encodeBase64Url(JSON.stringify(header));
const base64UrlClaims = encodeBase64Url(JSON.stringify(claims));
const unsignedJwt = `${base64UrlHeader}.${base64UrlClaims}`;

const b64 = private_key
.replace(/-----\w+ PRIVATE KEY-----/g, "")
.replace(/\n/g, "");
const binary = atob(b64);
const buffer = new ArrayBuffer(binary.length);
const view = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) {
view[i] = binary.charCodeAt(i);
}
const cryptoKey = await crypto.subtle.importKey(
"pkcs8",
buffer,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
cryptoKey,
new TextEncoder().encode(unsignedJwt),
);
const signedJwt = `${unsignedJwt}.${
encodeBase64Url(
new Uint8Array(signature).reduce(
(str, byte) => str + String.fromCharCode(byte),
"",
),
)
}`;

const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: signedJwt,
}),
});

const tokenResponse = await response.json();
return tokenResponse as {
access_token: string;
expires_in: number;
token_type: string;
};
}

public async getEvents({
singleEvents,
timeMax,
timeMin,
maxResults,
orderBy,
}: IGetCalendarEventsParams) {
try {
const calendarRequestParams = {
const calendarRequestParams: Record<string, string> = {
singleEvents: singleEvents.toString(),
timeMin: timeMin.toISOString(),
timeMax: timeMax.toISOString(),
maxResults: `${maxResults}`,
orderBy,
key: this.#apiKey,
};

const calendarRequestHeaders: Record<string, string> = {
Referer: "discord-events-sync",
};

if (this.#serviceAccountKeyJson) {
const accessToken = await this.getAccessToken();
calendarRequestHeaders[
"Authorization"
] = `${accessToken.token_type} ${accessToken.access_token}`;
} else if (this.#apiKey) {
calendarRequestParams["key"] = this.#apiKey;
}

const calendarResponse = await fetch(
`${API_BASE_URL}/calendars/${this.#calendarId}/events?${
(new URLSearchParams(calendarRequestParams)).toString()
}`,
{ headers: { Referer: "discord-events-sync" } },
`${API_BASE_URL}/calendars/${this.#calendarId}/events?${new URLSearchParams(calendarRequestParams).toString()}`,
{ headers: calendarRequestHeaders },
);
if (!calendarResponse.ok) {
throw new Error(
`Error getting events from Google Calendar API. Status: ${calendarResponse.status}. Response: ${await calendarResponse
.text()}`,
);
}
const parsed = await calendarResponse.json();

return parsed;
} catch (e) {
console.error(
`Error getting events from Google Calendar API.`,
);
console.error(`Error getting events from Google Calendar API.`);
throw e;
}
}
Expand Down