-
Notifications
You must be signed in to change notification settings - Fork 2
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
yuto-trd
wants to merge
14
commits into
main
Choose a base branch
from
link-calendar
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
カレンダー連携 #77
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
2a81fe1
chore: add cron and googleapis dependencies
yuto-trd 77c23a5
feat: add ScheduledEvent interface to types
yuto-trd 3a4374a
feat: add recurrenceUtil.ts file with APIRecurrenceRule types and uti…
yuto-trd b2ef43b
feat: event synchronization logic
yuto-trd 23981ea
feat: enable message reaction with reactionData values
yuto-trd d694aa3
refactor: dbService.ts
yuto-trd 89a82d1
feat: enforce event end time to be one hour after start time
yuto-trd c68b781
feat: optimize event synchronization logic
yuto-trd 0a9fec2
feat: Add url to the calendar
yuto-trd 261501a
feat: client.on('guildScheduledEvent*', ...) to detect changes in events
yuto-trd 45cdffb
fix: recurrenceUtil.ts
yuto-trd d40770c
refactor: index.ts
yuto-trd b61de5f
feat: Add test
yuto-trd 96ae9e9
feat: Add test
yuto-trd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,3 +7,6 @@ POSTGRES_URL="" | |
|
||
GUILD_ID="" | ||
UPDATE_QUERY_CACHE_CHANNEL_ID="" | ||
|
||
AUTH_GOOGLE_ID="" | ||
AUTH_GOOGLE_SECRET="" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`, | ||
}; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
これ