diff --git a/pocketbase/migrations/1740067530_created_guildEventSync.go b/pocketbase/migrations/1740067530_created_guildEventSync.go new file mode 100644 index 0000000..18e87c4 --- /dev/null +++ b/pocketbase/migrations/1740067530_created_guildEventSync.go @@ -0,0 +1,79 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" +) + +func init() { + m.Register(func(db dbx.Builder) error { + jsonData := `{ + "id": "qnx3lm934i0qiu9", + "created": "2025-02-20 16:05:30.595Z", + "updated": "2025-02-20 16:05:30.595Z", + "name": "guildEventSync", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "9gnhopdz", + "name": "event_slug", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 1, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "lt2o1zuz", + "name": "discord_event_id", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 1, + "max": null, + "pattern": "" + } + } + ], + "indexes": [ + "CREATE UNIQUE INDEX ` + "`" + `idx_oH0YuDm` + "`" + ` ON ` + "`" + `guildEventSync` + "`" + ` (` + "`" + `event_slug` + "`" + `)", + "CREATE UNIQUE INDEX ` + "`" + `idx_SliJ9g4` + "`" + ` ON ` + "`" + `guildEventSync` + "`" + ` (` + "`" + `discord_event_id` + "`" + `)" + ], + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": {} + }` + + collection := &models.Collection{} + if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { + return err + } + + return daos.New(db).SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("qnx3lm934i0qiu9") + if err != nil { + return err + } + + return dao.DeleteCollection(collection) + }) +} diff --git a/src/db/pocketbase.ts b/src/db/pocketbase.ts index 9731aed..48680a9 100644 --- a/src/db/pocketbase.ts +++ b/src/db/pocketbase.ts @@ -34,6 +34,11 @@ interface AnalyticsCollection extends BaseModel { presence_count: number; } +interface GuildEventSyncCollection extends BaseModel { + event_slug: string; + discord_event_id: string; +} + type LeaderboardView = Pick; interface TypedPocketbase extends Pocketbase { @@ -41,4 +46,7 @@ interface TypedPocketbase extends Pocketbase { collection(idOrName: 'threadSolves'): RecordService; collection(idOrName: 'leaderboard'): RecordService; collection(idOrName: 'analytics'): RecordService; + collection( + idOrName: 'guildEventSync', + ): RecordService; } diff --git a/src/index.ts b/src/index.ts index 01fcdc8..b86e694 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import 'dotenv/config'; +import { guildEventsTask } from './scheduled/guild-events'; import { analyticsTask } from './scheduled/analytics'; import { DEV_MODE, TEST_GUILD_ID } from './config'; import { Scheduler } from './scheduled/_scheduler'; @@ -32,7 +33,7 @@ const client = new JellyCommands({ cache: DEV_MODE, }); -new Scheduler(client).addTask(analyticsTask); +new Scheduler(client).addTask(analyticsTask).addTask(guildEventsTask); // Auto reads the DISCORD_TOKEN environment variable client.login(); diff --git a/src/scheduled/guild-events.ts b/src/scheduled/guild-events.ts new file mode 100644 index 0000000..5035ffb --- /dev/null +++ b/src/scheduled/guild-events.ts @@ -0,0 +1,187 @@ +import { DEV_MODE, TEST_GUILD_ID } from '../config'; +import { ScheduledTask } from './_scheduler'; +import { pb } from '../db/pocketbase'; +import { + GuildScheduledEventEntityType, + GuildScheduledEventPrivacyLevel, +} from 'discord.js'; +import { ClientResponseError } from 'pocketbase'; + +interface ResponseData { + __typename: string; + events: Events; + __isNode: string; + id: string; +} + +interface Events { + edges: Edge[]; + pageInfo: PageInfo; +} + +interface Edge { + node: Node; + cursor: string; +} + +interface Node { + id: string; + slug: string; + prettyUrl: string; + fullUrl: string; + name: string; + description: string; + startAt: string; + endAt: string; + timeZone: string; + visibility: string; + hasVenue: boolean; + hasExternalUrl: boolean; + owner: Owner; + uploadedSocialCard: any; + generatedSocialCardURL: string; + presentations: Presentations; + venue: Venue; + createdAt: string; + updatedAt: string; +} + +interface Owner { + __typename: string; + id: string; + name: string; + __isNode: string; +} + +interface Presentations { + edges: Edge2[]; +} + +interface Edge2 { + node: Node2; + cursor: string; +} + +interface Node2 { + id: string; + slug: string; + prettyUrl: string; + presenter?: Presenter; + presenterFirstName?: string; + presenterLastName?: string; + title: string; + description: string; + videoSourceUrl: string; +} + +interface Presenter { + id: string; + firstName: string; + lastName: string; +} + +interface Venue { + address: Address; + id: string; +} + +interface Address { + location: Location; + id: string; +} + +interface Location { + __typename: string; + geojson: Geojson; +} + +interface Geojson { + type: string; + coordinates: number[]; +} + +interface PageInfo { + hasPreviousPage: boolean; + hasNextPage: boolean; + startCursor: string; + endCursor: string; +} + +// todo doesn't handle pagination + +export const guildEventsTask: ScheduledTask = { + interval: 86400, + name: 'guild-events', + async handle(client) { + try { + console.log('Fetching guild events'); + + const response = await fetch( + 'https://guild.host/api/next/svelte-society-london/events/upcoming', + { + headers: { + 'User-Agent': + 'Svelte Bot (+https://github.com/sveltejs/discord-bot)', + Accept: 'application/json', + }, + }, + ); + + const data: ResponseData = await response.json(); + + const guild = await client.guilds.fetch( + DEV_MODE ? TEST_GUILD_ID : '457912077277855764', + ); + + if (!guild) { + throw new Error('Failed to fetch guild'); + } + + for (const event of data.events.edges) { + const event_slug = event.node.prettyUrl; + + const exists = await pb + .collection('guildEventSync') + .getFirstListItem( + pb.filter('event_slug = {:event_slug}', { event_slug }), + ) + .then(() => true) + .catch((e: ClientResponseError) => { + if (e.status == 404) return false; + else throw e; + }); + + if (exists) { + // prettier-ignore + console.log(` Skipping ${event.node.name} as it already exists`); + continue; + } + + console.log(` Creating ${event.node.name}`); + + const discordEvent = await guild.scheduledEvents.create({ + name: event.node.name, + image: event.node.generatedSocialCardURL.replace( + /\.svg$/, + '.png', + ), + description: event.node.description, + scheduledStartTime: event.node.startAt, + scheduledEndTime: event.node.endAt, + entityType: GuildScheduledEventEntityType.External, + privacyLevel: GuildScheduledEventPrivacyLevel.GuildOnly, + entityMetadata: { + location: event.node.fullUrl, + }, + }); + + await pb.collection('guildEventSync').create({ + event_slug, + discord_event_id: discordEvent.id, + }); + } + } catch (error) { + console.error('failed to save analytics', error); + } + }, +};