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

カレンダー連携 #77

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
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
3 changes: 3 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ POSTGRES_URL=""

GUILD_ID=""
UPDATE_QUERY_CACHE_CHANNEL_ID=""

AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
},
"dependencies": {
"@vercel/postgres": "^0.9.0",
"cron": "^3.1.7",
"discord.js": "^14.15.3",
"dotenv": "^16.4.5",
"googleapis": "^140.0.1",
"zlib-sync": "^0.1.9"
},
"devDependencies": {
Expand Down
345 changes: 345 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

112 changes: 112 additions & 0 deletions src/calendarService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Google Calendarの操作用

import { type calendar_v3, google } from 'googleapis';
import type { ScheduledEvent } from './types';

function createSchemaEvent(event: ScheduledEvent) {
const body: calendar_v3.Schema$Event = {
location: event.location,
id: event.id,
summary: event.name,
description: event.description,
start: {
dateTime: event.starttime.toISOString(),
timeZone: 'Asia/Tokyo',
},
end: {
// starttimeの1時間後
dateTime: new Date(
event.starttime.getTime() + 60 * 60 * 1000,
).toISOString(),
timeZone: 'Asia/Tokyo',
},
source: {
url: event.url ?? undefined,
title: event.name,
},
};

if (event.recurrence) {
body.recurrence = [event.recurrence];
}

return body;
}

export async function createCalEvent(
access_token: string,
event: ScheduledEvent,
) {
const body: calendar_v3.Schema$Event = createSchemaEvent(event);
const api = google.calendar({
version: 'v3',
headers: {
Authorization: `Bearer ${access_token}`,
},
errorRedactor: false,
});

try {
await api.events.insert({
calendarId: 'primary',
requestBody: body,
});
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
} catch (ex: any) {
if (ex.status === 409) {
// すでに存在する場合, UIから削除した場合、キャンセル扱いになる
await api.events.update({
calendarId: 'primary',
eventId: event.id,
requestBody: body,
});
}
}
}

export async function updateCalEvent(
access_token: string,
event: ScheduledEvent,
) {
const body: calendar_v3.Schema$Event = createSchemaEvent(event);
const api = google.calendar({
version: 'v3',
headers: {
Authorization: `Bearer ${access_token}`,
},
errorRedactor: false,
});

await api.events.update({
calendarId: 'primary',
eventId: event.id,
requestBody: body,
});
}

export async function removeCalEvent(
access_token: string,
event: Pick<ScheduledEvent, 'id'>,
) {
const api = google.calendar({
version: 'v3',
headers: {
Authorization: `Bearer ${access_token}`,
},
errorRedactor: false,
});

try {
await api.events.delete({
calendarId: 'primary',
eventId: event.id,
});
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
} catch (ex: any) {
if (ex.status === 410 || ex.status === 404) {
return;
}

throw new Error(`Failed to remove event: ${ex}`);
}
}
74 changes: 74 additions & 0 deletions src/dbService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { sql } from '@vercel/postgres';

export type UserWithGoogleToken = {
id: string;
refresh_token: string;
access_token: string;
id_token: string;
expires_at: number;
};

async function retrieveUsersLinkedToCalendar(): Promise<UserWithGoogleToken[]> {
const result = await sql<UserWithGoogleToken>`
SELECT users.id, accounts.refresh_token, accounts.access_token, accounts.id_token, accounts.expires_at FROM users
JOIN accounts ON accounts."userId" = users.id
WHERE users."isLinkedToCalendar" = true AND accounts.provider = 'google';
`;
return result.rows;
}

async function updateUserToken(user: UserWithGoogleToken) {
await sql`
UPDATE accounts SET
access_token = ${user.access_token},
expires_at = ${user.expires_at}
WHERE "userId" = ${user.id} AND provider = 'google';
`;
}

async function refreshAccessToken(refreshToken: string) {
const res = await fetch('https://www.googleapis.com/oauth2/v4/token', {
method: 'POST',
body: new URLSearchParams({
client_id: process.env.AUTH_GOOGLE_ID ?? '',
client_secret: process.env.AUTH_GOOGLE_SECRET ?? '',
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
});
const json = await res.json();
if (
res.status !== 200 ||
typeof json.access_token !== 'string' ||
typeof json.expires_in !== 'number'
) {
return null;
}

return {
access_token: json.access_token,
expires_at: Math.floor(Date.now() / 1000) + json.expires_in,
};
}

export async function retrieveUsersAndRefresh() {
let users = await retrieveUsersLinkedToCalendar();

// 少なくとも60秒後に期限切れなアクセストークンを更新
for (const user of users.filter(
(user) => user.expires_at < Math.floor(Date.now() / 1000) + 60,
)) {
const json = await refreshAccessToken(user.refresh_token);
if (!json) {
// usersから削除
users = users.splice(users.indexOf(user), 1);
continue;
}

user.access_token = json.access_token;
user.expires_at = json.expires_at;
await updateUserToken(user);
}

return users;
}
62 changes: 62 additions & 0 deletions src/eventSyncUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { ScheduledEvent } from './types';

export function getRemovedEvents(
oldEvents: ScheduledEvent[],
newEvents: ScheduledEvent[],
): ScheduledEvent[] {
return oldEvents.filter(
(oldEvent) =>
!newEvents.some((newEvent) => newEvent.id === oldEvent.id) &&
!(oldEvent.endtime && oldEvent.endtime.getTime() < Date.now()),
);
}

export function getUpdatedEvents(
oldEvents: ScheduledEvent[],
newEvents: ScheduledEvent[],
): ScheduledEvent[] {
return newEvents.filter((newEvent) =>
oldEvents.some((oldEvent) => {
if (oldEvent.id === newEvent.id) {
if (
oldEvent.name !== newEvent.name ||
oldEvent.description !== newEvent.description ||
oldEvent.creatorid !== newEvent.creatorid ||
oldEvent.location !== newEvent.location ||
oldEvent.recurrence !== newEvent.recurrence
) {
return true;
}

if (
oldEvent.starttime.toTimeString() !==
newEvent.starttime.toTimeString() ||
oldEvent.endtime?.toTimeString() !== newEvent.endtime?.toTimeString()
) {
return true;
}
// 繰り返しのイベントが終了して、日付だけが変更された場合は更新しない
if (newEvent.recurrence) {
if (
oldEvent.starttime.toDateString() !==
newEvent.starttime.toDateString() ||
oldEvent.endtime?.toDateString() !==
newEvent.endtime?.toDateString()
) {
return false;
}
}
}
return false;
}),
);
}

export function getAddedEvents(
oldEvents: ScheduledEvent[],
newEvents: ScheduledEvent[],
): ScheduledEvent[] {
return newEvents.filter(
(newEvent) => !oldEvents.some((oldEvent) => oldEvent.id === newEvent.id),
);
}
78 changes: 76 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { sql } from '@vercel/postgres';
import { ChannelType, Client, type Message, Partials } from 'discord.js';
import {
type APIGuildScheduledEvent,
ChannelType,
Client,
type GuildScheduledEvent,
GuildScheduledEventStatus,
type Message,
type PartialGuildScheduledEvent,
Partials,
Routes,
} from 'discord.js';
import dotenv from 'dotenv';

import type {
Expand All @@ -10,6 +20,14 @@ import type {
ReactionData,
} from './types';

import {
createCalEvent,
removeCalEvent,
updateCalEvent,
} from './calendarService';
import { retrieveUsersAndRefresh } from './dbService';
import { transformAPIGuildScheduledEventToScheduledEvent } from './mapping';

dotenv.config();

const regexCache = new Map<string, RegExp>();
Expand Down Expand Up @@ -168,8 +186,61 @@ export const handleMessageCreate =
}
};

export const handleEventCreate =
(client: Client) => async (event: GuildScheduledEvent) => {
console.log('Event created: ', event.name);
const users = await retrieveUsersAndRefresh();
const apiObj = (await client.rest.get(
Routes.guildScheduledEvent(event.guildId, event.id),
)) as APIGuildScheduledEvent;
const obj = transformAPIGuildScheduledEventToScheduledEvent(apiObj);
for (const user of users) {
createCalEvent(user.access_token, obj);
}
};

export const handleEventUpdate =
(client: Client) =>
async (
oldEvent: GuildScheduledEvent | PartialGuildScheduledEvent | null,
newEvent: GuildScheduledEvent,
) => {
if (
newEvent.status === GuildScheduledEventStatus.Completed ||
newEvent.status === GuildScheduledEventStatus.Canceled
) {
handleEventDelete()(newEvent);
}

console.log('Event updated: ', newEvent.name);
const users = await retrieveUsersAndRefresh();
// GuildScheduledEventにはrecurrence_ruleがないので、APIから取得する
const apiObj = (await client.rest.get(
Routes.guildScheduledEvent(newEvent.guildId, newEvent.id),
)) as APIGuildScheduledEvent;
const obj = transformAPIGuildScheduledEventToScheduledEvent(apiObj);
for (const user of users) {
updateCalEvent(user.access_token, obj);
}
};

export const handleEventDelete =
() => async (event: GuildScheduledEvent | PartialGuildScheduledEvent) => {
console.log('Event deleted: ', event.name);
const users = await retrieveUsersAndRefresh();
for (const user of users) {
removeCalEvent(user.access_token, event);
}
};
Comment on lines +227 to +234
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

これ


const client = new Client({
intents: ['DirectMessages', 'Guilds', 'GuildMessages', 'MessageContent'],
intents: [
'DirectMessages',
'Guilds',
'GuildMessages',
'MessageContent',
'GuildScheduledEvents',
],
partials: [Partials.Channel],
});

Expand All @@ -179,5 +250,8 @@ client.on(
'messageCreate',
handleMessageCreate({ client, regexCache, queryCache, updateQueryCache }),
);
client.on('guildScheduledEventCreate', handleEventCreate(client));
client.on('guildScheduledEventDelete', handleEventDelete());
client.on('guildScheduledEventUpdate', handleEventUpdate(client));

client.login(process.env.DISCORD_BOT_TOKEN);
26 changes: 26 additions & 0 deletions src/mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { APIGuildScheduledEvent } from 'discord.js';
import { convertRFC5545RecurrenceRule } from './recurrenceUtil';
import type { ScheduledEvent } from './types';

// APIGuildScheduledEvent -> ScheduledEvent
export function transformAPIGuildScheduledEventToScheduledEvent(
event: APIGuildScheduledEvent,
): ScheduledEvent {
return {
id: event.id,
name: event.name,
description: event.description ?? null,
starttime: new Date(event.scheduled_start_time),
endtime: event.scheduled_end_time
? new Date(event.scheduled_end_time)
: null,
creatorid: event.creator_id ?? null,
location: event.entity_metadata?.location ?? null,
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
recurrence: (event as any).recurrence_rule
? // biome-ignore lint/suspicious/noExplicitAny: <explanation>
convertRFC5545RecurrenceRule((event as any).recurrence_rule)
: null,
url: `https://discord.com/events/${event.guild_id}/${event.id}`,
};
}
Loading