Skip to content
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
12 changes: 10 additions & 2 deletions src/at-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,15 @@ const AtmoEventSchema = v.object({
rsvpMode: v.literalEnum(['atmo_too', 'external_only']),
}),
),
attendeeCount: v.optional(v.integer()),
}),
),
});

export type AtmoEvent = v.InferOutput<typeof AtmoEventSchema>;

export async function fetchAtmoEvents(client: Client, repo: Did) {
const events: (AtmoEvent & { rkey: RecordKey })[] = [];
const events: (AtmoEvent & { rkey: RecordKey; cid: string })[] = [];
let cursor: string | undefined;

const s = spinner();
Expand Down Expand Up @@ -92,7 +93,11 @@ export async function fetchAtmoEvents(client: Client, repo: Did) {
process.exit(1);
}

events.push({ ...record.value, rkey: parsed.rkey });
events.push({
...record.value,
rkey: parsed.rkey,
cid: record.cid,
});
}
} while (cursor);

Expand All @@ -104,8 +109,10 @@ export async function guildEventToAtmosphere(
client: Client,
event: GuildEvent,
existing?: AtmoEvent,
attendeeCount?: number,
): Promise<AtmoEvent> {
const media = await getEventMedia(client, event, existing);
const attendees = attendeeCount ?? existing?.additionalData?.attendeeCount;

let mode: AtmoEvent['mode'] = 'community.lexicon.calendar.event#inperson';
if (event.hasExternalUrl && !event.hasVenue) {
Expand Down Expand Up @@ -147,6 +154,7 @@ export async function guildEventToAtmosphere(
rsvpMode: 'external_only',
url: event.fullUrl,
},
...(attendees === undefined ? {} : { attendeeCount: attendees }),
},
};
}
Expand Down
55 changes: 55 additions & 0 deletions src/guild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,58 @@ export async function fetchGuildEvents(slug: string): Promise<GuildEvent[]> {
throw error;
}
}

const PresentationSchema = v.object({
title: v.string(),
description: v.nullish(v.string()),
videoSourceUrl: v.nullish(v.string()),
presenter: v.nullish(
v.object({ firstName: v.string(), lastName: v.string() }),
),
presenterFirstName: v.nullish(v.string()),
presenterLastName: v.nullish(v.string()),
});

export type GuildPresentation = v.InferOutput<typeof PresentationSchema>;

const EventDetailSchema = v.object({
presentations: v.object({
edges: v.array(v.object({ node: PresentationSchema })),
}),
});

export async function fetchGuildPresentations(
slug: string,
): Promise<GuildPresentation[]> {
const url = new URL(GUILD_API_BASE);
url.pathname += `/events/${slug}`;

const response = await fetch(url);

if (!response.ok) {
throw new Error(`Failed to fetch event: ${response.statusText}`);
}

const result = v.parse(EventDetailSchema, await response.json());
return result.presentations.edges.map((edge) => edge.node);
}

const AttendeesResponseSchema = v.object({ totalCount: v.number() });

export async function fetchGuildAttendeeCount(
slug: string,
accessToken: string,
): Promise<number | null> {
const url = new URL(GUILD_API_BASE);
url.pathname += `/events/${slug}/attendees`;
url.searchParams.set('first', '1');

const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!response.ok) return null;

const result = v.parse(AttendeesResponseSchema, await response.json());
return result.totalCount;
}
98 changes: 75 additions & 23 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { intro, outro, group, text, spinner } from '@clack/prompts';
import { type Handle, isHandle } from '@atcute/lexicons/syntax';
// import { authenticateWithGuild } from './guild-oauth.ts';
import { authenticateWithGuild, getGuildAccessToken } from './guild-oauth.ts';
import { exit, selectEvents } from './prompts.ts';
import { fetchGuildEvents } from './guild.ts';
import {
fetchGuildEvents,
fetchGuildPresentations,
fetchGuildAttendeeCount,
} from './guild.ts';
import { login } from './oauth.ts';
import {
guildEventToAtmosphere,
fetchAtmoEvents,
eventsAreEqual,
isOnGuild,
} from './at-events.ts';
import { fetchAtmoTalks, syncTalks, type StrongRef } from './talks.ts';
import {
ComAtprotoRepoCreateRecord,
ComAtprotoRepoPutRecord,
Expand Down Expand Up @@ -59,8 +64,10 @@ const choices = await group(
);

const session = await login(choices.handle as Handle);
const guildAuth = await authenticateWithGuild();

const atmoEvents = await fetchAtmoEvents(session.client, session.actor);
const atmoTalks = await fetchAtmoTalks(session.client, session.actor);
const guildEvents = await fetchGuildEvents(choices.guildSlug);

for (const guildEvent of await selectEvents(atmoEvents, guildEvents)) {
Expand All @@ -69,12 +76,25 @@ for (const guildEvent of await selectEvents(atmoEvents, guildEvents)) {

const existingAtmoEvent = atmoEvents.find((e) => isOnGuild(e, guildEvent));

let attendeeCount: number | undefined;
if (guildAuth) {
const token = await getGuildAccessToken();
if (token) {
attendeeCount =
(await fetchGuildAttendeeCount(guildEvent.slug, token)) ??
undefined;
}
}

const newAtmoEvent = await guildEventToAtmosphere(
session.client,
guildEvent,
existingAtmoEvent,
attendeeCount,
);

let eventRef: StrongRef;

if (!existingAtmoEvent) {
const response = await session.client.call(ComAtprotoRepoCreateRecord, {
input: {
Expand All @@ -91,31 +111,63 @@ for (const guildEvent of await selectEvents(atmoEvents, guildEvents)) {

// prettier-ignore
s.stop(`Created atmosphere event for ${guildEvent.name} (https://pds.ls/${response.data.uri})`);
continue;
}
eventRef = {
$type: 'com.atproto.repo.strongRef',
uri: response.data.uri,
cid: response.data.cid,
};
} else if (eventsAreEqual(existingAtmoEvent, newAtmoEvent)) {
const uri = `at://${session.actor}/community.lexicon.calendar.event/${existingAtmoEvent.rkey}`;
s.stop(
`No changes needed for ${guildEvent.name} (https://pds.ls/${uri})`,
);
eventRef = {
$type: 'com.atproto.repo.strongRef',
uri,
cid: existingAtmoEvent.cid,
};
} else {
const response = await session.client.call(ComAtprotoRepoPutRecord, {
input: {
collection: 'community.lexicon.calendar.event',
rkey: existingAtmoEvent.rkey,
record: newAtmoEvent,
repo: session.actor,
},
});

if (eventsAreEqual(existingAtmoEvent, newAtmoEvent)) {
const pdsls = `https://pds.ls/at://${session.actor}/community.lexicon.calendar.event/${existingAtmoEvent.rkey}`;
s.stop(`No changes needed for ${guildEvent.name} (${pdsls})`);
continue;
}
if (!response.ok) {
s.stop(`Failed to update atmosphere event for ${guildEvent.name}`);
process.exit(1);
}

const response = await session.client.call(ComAtprotoRepoPutRecord, {
input: {
collection: 'community.lexicon.calendar.event',
rkey: existingAtmoEvent.rkey,
record: newAtmoEvent,
repo: session.actor,
},
});

if (!response.ok) {
s.stop(`Failed to update atmosphere event for ${guildEvent.name}`);
process.exit(1);
// prettier-ignore
s.stop(`Updated atmosphere event for ${guildEvent.name} (https://pds.ls/${response.data.uri})`);
eventRef = {
$type: 'com.atproto.repo.strongRef',
uri: response.data.uri,
cid: response.data.cid,
};
}

// prettier-ignore
s.stop(`Updated atmosphere event for ${guildEvent.name} (https://pds.ls/${response.data.uri})`);
const presentations = await fetchGuildPresentations(guildEvent.slug);

if (presentations.length > 0) {
const ts = spinner();
ts.start(
`Syncing ${presentations.length} talks for ${guildEvent.name}`,
);
const { created, updated } = await syncTalks(
session.client,
session.actor,
eventRef,
presentations,
atmoTalks,
);
ts.stop(
`Talks for ${guildEvent.name}: ${created} created, ${updated} updated`,
);
}
}

outro('Sync complete!');
Expand Down
Loading