From 5d668e45f793958890778e725bb3d31cdf8801ca Mon Sep 17 00:00:00 2001 From: Dusan Jovanovic Date: Tue, 18 Aug 2026 11:04:00 +0200 Subject: [PATCH] feat(meetup): attach speaker profiles on Pro network events Meetup Pro events carry a speaker profile, and NormalizedEvent already had a `speaker` that went nowhere. It now maps to CreateEventInput's speakerDetails: name and bioMarkdown to the API's required name and description, plus social links. speakerDetails is Pro-only and other groups reject it, so it is opt-in and resolved per group, matching how a network is configured in practice: `true` for every group, or a list of the urlnames that are in the network. GroupTarget carries the decision, so a multi-group run can send it to the Pro groups and omit it elsewhere in the same pass. A speaker with no bio is skipped rather than half-filled. The API requires a non-empty description, and an empty string would publish a hollow profile; the run logs a warning naming the reason instead. NormalizedSpeaker gains `socialUrls` as plain URLs to keep core platform-neutral, and @coopkit/meetup classifies them by host into Meetup's fixed service enum, with OTHER for personal sites. Host matching is anchored, so notlinkedin.com and linkedin.com.evil.test do not pass as LINKEDIN while de.linkedin.com does. Non-URLs are dropped, not guessed. The whole URL is kept as `identifier`. Meetup's field name suggests a bare handle, but the per-service shape is undocumented and a URL is unambiguous and lossless for every service including OTHER. photoId is left unsupported: it needs a prior photo upload, and the mutation for a speaker photo (as opposed to an event photo) is not something I could verify, so guessing at it had no place here. Co-Authored-By: Claude Opus 5 --- packages/core/src/normalized-event.ts | 6 ++ packages/meetup/README.md | 37 +++++++++++ packages/meetup/src/cli.ts | 15 +++-- packages/meetup/src/config.test.ts | 45 +++++++++++++- packages/meetup/src/config.ts | 33 +++++++++- packages/meetup/src/create-event.ts | 16 ++++- packages/meetup/src/index.ts | 4 ++ packages/meetup/src/payload.test.ts | 50 +++++++++++++++ packages/meetup/src/payload.ts | 14 ++++- packages/meetup/src/speaker.test.ts | 89 +++++++++++++++++++++++++++ packages/meetup/src/speaker.ts | 81 ++++++++++++++++++++++++ 11 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 packages/meetup/src/speaker.test.ts create mode 100644 packages/meetup/src/speaker.ts diff --git a/packages/core/src/normalized-event.ts b/packages/core/src/normalized-event.ts index 7ee5cdf..e10fba5 100644 --- a/packages/core/src/normalized-event.ts +++ b/packages/core/src/normalized-event.ts @@ -69,4 +69,10 @@ export interface NormalizedSpeaker { avatarUrl?: string; /** Speaker bio in Markdown. */ bioMarkdown?: string; + /** + * Links to the speaker's profiles — a personal site, LinkedIn, Mastodon, + * anything. Kept as plain URLs because this contract is platform-neutral; + * packages that publish speaker profiles classify them as needed. + */ + socialUrls?: string[]; } diff --git a/packages/meetup/README.md b/packages/meetup/README.md index f36ee24..539ae4a 100644 --- a/packages/meetup/README.md +++ b/packages/meetup/README.md @@ -154,6 +154,43 @@ the ones that worked. > reviewed or deleted on its own. Every account member must still have rights in > each target group — being a Pro network admin is not by itself enough. +### Speaker profiles (Pro only) + +Meetup Pro events can carry a speaker profile. It comes from the event's +`speaker`, and because non-Pro groups reject the field it is **opt-in**: + +```json +"speakerDetails": true +``` + +or, when only some of your groups are in the network: + +```json +"speakerDetails": ["chicago-c-cpp-users-group", "cpp-serbia"] +``` + +The mapping from `NormalizedEvent.speaker`: + +| Meetup field | Source | Notes | +| --- | --- | --- | +| `name` | `speaker.name` | Required by the API. | +| `description` | `speaker.bioMarkdown` | Required by the API — see below. | +| `socialNetworks` | `speaker.socialUrls` | Classified into Meetup's service enum. | +| `photoId` | — | Not supported; needs a separate photo upload. | + +**A speaker with no bio is skipped, with a warning.** Meetup requires a +non-empty `description`, and sending an empty string would publish a hollow +profile, so `speakerDetails` is omitted entirely rather than half-filled. Give +the speaker a `bioMarkdown` to have it appear. + +`socialUrls` are plain URLs — `NormalizedEvent` stays platform-neutral — and get +classified by host into `LINKEDIN`, `TWITTER` (including `x.com`), `INSTAGRAM`, +`FACEBOOK`, `TIKTOK`, `TUMBLR`, `FLICKR`, or `OTHER` for anything else such as a +personal site. Entries that are not URLs are dropped rather than guessed at. The +full URL is kept as the `identifier`: Meetup's name for the field suggests a bare +handle, but the per-service shape is undocumented, and a URL is unambiguous and +lossless for every service including `OTHER`. + ### Hosts `eventHosts` takes Meetup **member IDs**, and the account must be a member of diff --git a/packages/meetup/src/cli.ts b/packages/meetup/src/cli.ts index 37daa66..f212f72 100644 --- a/packages/meetup/src/cli.ts +++ b/packages/meetup/src/cli.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import type { NormalizedEvent } from "@coopkit/core"; import { defineCommand, runMain } from "citty"; -import { loadMeetupConfig, resolveGroupTargets, resolveHostIds } from "./config.js"; +import { loadMeetupConfig, resolveGroupTargets } from "./config.js"; import { type CreateMeetupDraftsResult, createMeetupDraft, @@ -113,13 +113,19 @@ const createCmd = defineCommand({ async run({ args }) { loadEnvFile(); const config = loadMeetupConfig(args.config); - const hosts = resolveHostIds(config, parseHostArg(args.host)); + const hostNames = parseHostArg(args.host); + const [target] = resolveGroupTargets(config, { + only: [config.groupUrlname], + ...(hostNames !== undefined ? { hostNames } : {}), + }); + if (!target) throw new Error("No group resolved from the config."); const result = await createMeetupDraftFromFile({ eventFile: args.eventFile, - groupUrlname: config.groupUrlname, + groupUrlname: target.urlname, venues: config.venues, ...(config.timezone !== undefined ? { timezone: config.timezone } : {}), - ...(hosts.length > 0 ? { hosts } : {}), + ...(target.hosts.length > 0 ? { hosts: target.hosts } : {}), + includeSpeaker: target.includeSpeaker, dryRun: Boolean(args["dry-run"]), }); writeResultFile(args.output, result); @@ -216,6 +222,7 @@ const createFromJsonCmd = defineCommand({ venues: config.venues, ...(config.timezone !== undefined ? { timezone: config.timezone } : {}), ...(target.hosts.length > 0 ? { hosts: target.hosts } : {}), + includeSpeaker: target.includeSpeaker, dryRun: Boolean(args["dry-run"]), }); writeResultFile(args.output, result); diff --git a/packages/meetup/src/config.test.ts b/packages/meetup/src/config.test.ts index acc927e..4126048 100644 --- a/packages/meetup/src/config.test.ts +++ b/packages/meetup/src/config.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { type MeetupConfig, + groupAcceptsSpeaker, loadMeetupConfig, resolveGroupTargets, resolveHostIds, @@ -191,7 +192,7 @@ describe("resolveGroupTargets", () => { groupUrlname: "solo-group", venues: { online: "online" }, }); - expect(targets).toEqual([{ urlname: "solo-group", hosts: [] }]); + expect(targets).toEqual([{ urlname: "solo-group", hosts: [], includeSpeaker: false }]); }); it("lets an explicit hostNames override apply to groups without a groupHosts entry", () => { @@ -199,3 +200,45 @@ describe("resolveGroupTargets", () => { expect(targets.every((t) => t.hosts[0] === 256192100)).toBe(true); }); }); + +describe("speakerDetails gating", () => { + const base: MeetupConfig = { groupUrlname: "a-group", venues: { online: "online" } }; + + it("is off when unset", () => { + expect(groupAcceptsSpeaker(base, "a-group")).toBe(false); + }); + + it("is off when explicitly false", () => { + expect(groupAcceptsSpeaker({ ...base, speakerDetails: false }, "a-group")).toBe(false); + }); + + it("is on for every group when true", () => { + const cfg = { ...base, speakerDetails: true }; + expect(groupAcceptsSpeaker(cfg, "a-group")).toBe(true); + expect(groupAcceptsSpeaker(cfg, "anything-else")).toBe(true); + }); + + it("is on only for listed groups, case-insensitively", () => { + const cfg = { ...base, speakerDetails: ["CPPTORONTO"] }; + expect(groupAcceptsSpeaker(cfg, "cpptoronto")).toBe(true); + expect(groupAcceptsSpeaker(cfg, "cpp-serbia")).toBe(false); + }); + + it("flows onto resolved group targets", () => { + const targets = resolveGroupTargets({ + ...base, + groups: ["b-group"], + speakerDetails: ["b-group"], + }); + expect(targets.map((t) => [t.urlname, t.includeSpeaker])).toEqual([ + ["a-group", false], + ["b-group", true], + ]); + }); + + it("rejects a malformed speakerDetails value", () => { + expect(() => + loadMeetupConfig(writeConfig({ meetup: { ...BASE, speakerDetails: 42 } })) + ).toThrow(/speakerDetails must be true\/false or an array of group urlnames/); + }); +}); diff --git a/packages/meetup/src/config.ts b/packages/meetup/src/config.ts index b69d257..65fac48 100644 --- a/packages/meetup/src/config.ts +++ b/packages/meetup/src/config.ts @@ -27,6 +27,12 @@ export interface MeetupConfig { * single `defaultHosts` rarely fits all of them. */ groupHosts?: Record; + /** + * Attach the event's speaker as Meetup's speaker profile. This is a **Pro** + * feature and groups outside a Pro network reject it, so it is opt-in: + * `true` for every group, or a list of the group urlnames that support it. + */ + speakerDetails?: boolean | string[]; } export interface CoopkitConfig { @@ -111,6 +117,17 @@ export function loadMeetupConfig(configPath?: string): MeetupConfig { } } } + if (meetup.speakerDetails !== undefined) { + const sd = meetup.speakerDetails; + const ok = + typeof sd === "boolean" || + (Array.isArray(sd) && sd.every((g) => typeof g === "string" && g !== "")); + if (!ok) { + throw new Error( + `${resolved}: meetup.speakerDetails must be true/false or an array of group urlnames.` + ); + } + } if (meetup.defaultHosts !== undefined) { if (!Array.isArray(meetup.defaultHosts)) { throw new Error( @@ -153,6 +170,16 @@ export function resolveHostIds(config: MeetupConfig, names?: string[]): number[] export interface GroupTarget { urlname: string; hosts: number[]; + /** Whether this group accepts Meetup's Pro speaker profile. */ + includeSpeaker: boolean; +} + +/** Does this group opt into speakerDetails? See `MeetupConfig.speakerDetails`. */ +export function groupAcceptsSpeaker(config: MeetupConfig, urlname: string): boolean { + const sd = config.speakerDetails; + if (sd === undefined || sd === false) return false; + if (sd === true) return true; + return sd.some((g) => g.toLowerCase() === urlname.toLowerCase()); } /** @@ -201,6 +228,10 @@ export function resolveGroupTargets( return selected.map((urlname) => { const perGroup = hostsByGroup.get(urlname.toLowerCase()); const names = perGroup ?? options.hostNames; - return { urlname, hosts: resolveHostIds(config, names) }; + return { + urlname, + hosts: resolveHostIds(config, names), + includeSpeaker: groupAcceptsSpeaker(config, urlname), + }; }); } diff --git a/packages/meetup/src/create-event.ts b/packages/meetup/src/create-event.ts index 56fbbad..5c3cd6c 100644 --- a/packages/meetup/src/create-event.ts +++ b/packages/meetup/src/create-event.ts @@ -149,6 +149,8 @@ export interface CreateMeetupDraftOptions { timezone?: string; /** Meetup member IDs to list as event hosts. */ hosts?: number[]; + /** Attach `event.speaker` as Meetup's Pro speaker profile. */ + includeSpeaker?: boolean; dryRun?: boolean; credentials?: MeetupCredentials; log?: (message: string) => void; @@ -184,8 +186,16 @@ export async function createMeetupDraft( resolveVenue: (name) => resolveVenueId(name, options.venues), ...(options.timezone !== undefined ? { timezone: options.timezone } : {}), ...(options.hosts !== undefined ? { hosts: options.hosts } : {}), + ...(options.includeSpeaker !== undefined ? { includeSpeaker: options.includeSpeaker } : {}), }); + if (options.includeSpeaker && !payload.speakerDetails) { + log( + "[warn] speakerDetails requested but not sent: the event has no speaker with a bio " + + "(Meetup requires a non-empty speaker description)." + ); + } + if (options.dryRun) { log(`--- DRY RUN: would create Meetup draft for ${options.event.id} ---`); console.log(JSON.stringify(payload, null, 2)); @@ -227,6 +237,8 @@ export interface CreateMeetupDraftFromFileOptions { timezone?: string; /** Meetup member IDs to list as event hosts. */ hosts?: number[]; + /** Attach `event.speaker` as Meetup's Pro speaker profile. */ + includeSpeaker?: boolean; dryRun?: boolean; credentials?: MeetupCredentials; log?: (message: string) => void; @@ -272,6 +284,7 @@ export async function createMeetupDraftFromFile( venues: options.venues, ...(options.timezone !== undefined ? { timezone: options.timezone } : {}), ...(options.hosts !== undefined ? { hosts: options.hosts } : {}), + ...(options.includeSpeaker !== undefined ? { includeSpeaker: options.includeSpeaker } : {}), dryRun: options.dryRun, credentials: options.credentials, log, @@ -287,7 +300,7 @@ export async function createMeetupDraftFromFile( export interface CreateMeetupDraftsOptions { event: NormalizedEvent; /** Groups to create the event in, in order, each with its own hosts. */ - groups: Array<{ urlname: string; hosts?: number[] }>; + groups: Array<{ urlname: string; hosts?: number[]; includeSpeaker?: boolean }>; venues: VenueMap; /** IANA timezone of the groups. See `BuildPayloadInput.timezone`. */ timezone?: string; @@ -339,6 +352,7 @@ export async function createMeetupDrafts( venues: options.venues, ...(options.timezone !== undefined ? { timezone: options.timezone } : {}), ...(group.hosts !== undefined ? { hosts: group.hosts } : {}), + ...(group.includeSpeaker !== undefined ? { includeSpeaker: group.includeSpeaker } : {}), ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}), ...(options.credentials !== undefined ? { credentials: options.credentials } : {}), log, diff --git a/packages/meetup/src/index.ts b/packages/meetup/src/index.ts index 8be1033..0ccd3cf 100644 --- a/packages/meetup/src/index.ts +++ b/packages/meetup/src/index.ts @@ -37,8 +37,12 @@ export type { ListVenuesOptions, MeetupVenue } from "./list-venues.js"; export { DEFAULT_CONFIG_FILENAME, + groupAcceptsSpeaker, loadMeetupConfig, resolveGroupTargets, resolveHostIds, } from "./config.js"; export type { CoopkitConfig, GroupTarget, MeetupConfig } from "./config.js"; + +export { classifySocialUrl, speakerDetailsFrom } from "./speaker.js"; +export type { SocialNetworkService, SpeakerDetailsInput } from "./speaker.js"; diff --git a/packages/meetup/src/payload.test.ts b/packages/meetup/src/payload.test.ts index 42609e3..c588422 100644 --- a/packages/meetup/src/payload.test.ts +++ b/packages/meetup/src/payload.test.ts @@ -352,3 +352,53 @@ describe("resolveVenueId online support", () => { expect(resolveVenueId("Franklin Tap", { "Franklin Tap": 6500002 })).toBe(6500002); }); }); + +describe("buildCreateEventPayload speakerDetails", () => { + const resolveVenue = () => ONLINE_VENUE_ID; + const withSpeaker: NormalizedEvent = { + ...UTC_EVENT, + speaker: { name: "Andy Soffer", bioMarkdown: "A lapsed mathematician." }, + }; + + it("omits speakerDetails unless the group opts in", () => { + const payload = buildCreateEventPayload({ + event: withSpeaker, + groupUrlname: "g", + resolveVenue, + }); + expect("speakerDetails" in payload).toBe(false); + }); + + it("includes speakerDetails when opted in", () => { + const payload = buildCreateEventPayload({ + event: withSpeaker, + groupUrlname: "g", + resolveVenue, + includeSpeaker: true, + }); + expect(payload.speakerDetails).toEqual({ + name: "Andy Soffer", + description: "A lapsed mathematician.", + }); + }); + + it("omits speakerDetails when opted in but the speaker has no bio", () => { + const payload = buildCreateEventPayload({ + event: { ...UTC_EVENT, speaker: { name: "Andy Soffer" } }, + groupUrlname: "g", + resolveVenue, + includeSpeaker: true, + }); + expect("speakerDetails" in payload).toBe(false); + }); + + it("omits speakerDetails when opted in but there is no speaker at all", () => { + const payload = buildCreateEventPayload({ + event: UTC_EVENT, + groupUrlname: "g", + resolveVenue, + includeSpeaker: true, + }); + expect("speakerDetails" in payload).toBe(false); + }); +}); diff --git a/packages/meetup/src/payload.ts b/packages/meetup/src/payload.ts index c87c631..ff8caec 100644 --- a/packages/meetup/src/payload.ts +++ b/packages/meetup/src/payload.ts @@ -1,4 +1,5 @@ import type { NormalizedEvent } from "@coopkit/core"; +import { type SpeakerDetailsInput, speakerDetailsFrom } from "./speaker.js"; import { type VenueId, type VenueMap, resolveVenueId } from "./venues.js"; export interface CreateEventPayload { @@ -11,6 +12,8 @@ export interface CreateEventPayload { publishStatus: "DRAFT"; /** Meetup member IDs to list as event hosts. Omitted when none are configured. */ eventHosts?: number[]; + /** Pro-only speaker profile. Omitted unless the group opts in and a bio exists. */ + speakerDetails?: SpeakerDetailsInput; } export interface BuildPayloadInput { @@ -38,6 +41,11 @@ export interface BuildPayloadInput { timezone?: string; /** Meetup member IDs to list as event hosts. */ hosts?: number[]; + /** + * Attach `event.speaker` as Meetup's Pro speaker profile. Off by default: + * `speakerDetails` is a Pro-network feature and other groups reject it. + */ + includeSpeaker?: boolean; } const PLACEHOLDER_RE = /^<.*>$/; @@ -122,6 +130,10 @@ export function buildCreateEventPayload(input: BuildPayloadInput): CreateEventPa if (input.hosts && input.hosts.length > 0) { payload.eventHosts = [...input.hosts]; } + if (input.includeSpeaker) { + const speakerDetails = speakerDetailsFrom(event.speaker); + if (speakerDetails) payload.speakerDetails = speakerDetails; + } return payload; } @@ -133,7 +145,7 @@ export function buildCreateEventPayloadWithMap( event: NormalizedEvent, groupUrlname: string, venues: VenueMap, - options: { timezone?: string; hosts?: number[] } = {} + options: { timezone?: string; hosts?: number[]; includeSpeaker?: boolean } = {} ): CreateEventPayload { return buildCreateEventPayload({ event, diff --git a/packages/meetup/src/speaker.test.ts b/packages/meetup/src/speaker.test.ts new file mode 100644 index 0000000..6774420 --- /dev/null +++ b/packages/meetup/src/speaker.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import type { NormalizedSpeaker } from "@coopkit/core"; +import { classifySocialUrl, speakerDetailsFrom } from "./speaker.js"; + +describe("classifySocialUrl", () => { + it("maps known hosts to Meetup's service enum", () => { + expect(classifySocialUrl("https://www.linkedin.com/in/ahmadsharif/")?.service).toBe("LINKEDIN"); + expect(classifySocialUrl("https://twitter.com/someone")?.service).toBe("TWITTER"); + expect(classifySocialUrl("https://x.com/someone")?.service).toBe("TWITTER"); + expect(classifySocialUrl("https://www.instagram.com/someone")?.service).toBe("INSTAGRAM"); + }); + + it("falls back to OTHER for a personal site", () => { + expect(classifySocialUrl("https://www.spertus.edu/")?.service).toBe("OTHER"); + expect(classifySocialUrl("https://twoscomplement.org/")?.service).toBe("OTHER"); + }); + + it("keeps the whole URL as the identifier", () => { + const url = "https://www.linkedin.com/in/duxi90/"; + expect(classifySocialUrl(url)?.identifier).toBe(url); + }); + + it("does not match a host that merely contains a service name", () => { + expect(classifySocialUrl("https://notlinkedin.com/x")?.service).toBe("OTHER"); + expect(classifySocialUrl("https://linkedin.com.evil.test/x")?.service).toBe("OTHER"); + }); + + it("matches subdomains of a service", () => { + expect(classifySocialUrl("https://de.linkedin.com/in/someone")?.service).toBe("LINKEDIN"); + }); + + it("returns undefined for something that is not a URL", () => { + expect(classifySocialUrl("@handle")).toBeUndefined(); + expect(classifySocialUrl("")).toBeUndefined(); + }); +}); + +describe("speakerDetailsFrom", () => { + const speaker: NormalizedSpeaker = { + name: "Andy Soffer", + bioMarkdown: "A lapsed mathematician turned software engineer.", + }; + + it("returns undefined when there is no speaker", () => { + expect(speakerDetailsFrom(undefined)).toBeUndefined(); + }); + + it("returns undefined without a bio, since Meetup requires a description", () => { + expect(speakerDetailsFrom({ name: "Andy Soffer" })).toBeUndefined(); + expect(speakerDetailsFrom({ name: "Andy Soffer", bioMarkdown: " " })).toBeUndefined(); + }); + + it("returns undefined without a name", () => { + expect(speakerDetailsFrom({ name: " ", bioMarkdown: "bio" })).toBeUndefined(); + }); + + it("maps name and bio across", () => { + expect(speakerDetailsFrom(speaker)).toEqual({ + name: "Andy Soffer", + description: "A lapsed mathematician turned software engineer.", + }); + }); + + it("omits socialNetworks when there are none", () => { + const details = speakerDetailsFrom(speaker); + expect(details && "socialNetworks" in details).toBe(false); + }); + + it("includes classified social networks", () => { + const details = speakerDetailsFrom({ + ...speaker, + socialUrls: ["https://www.linkedin.com/in/someone/", "https://example.test/"], + }); + expect(details?.socialNetworks).toEqual([ + { service: "LINKEDIN", identifier: "https://www.linkedin.com/in/someone/" }, + { service: "OTHER", identifier: "https://example.test/" }, + ]); + }); + + it("drops unclassifiable entries rather than failing", () => { + const details = speakerDetailsFrom({ + ...speaker, + socialUrls: ["not a url", "https://www.linkedin.com/in/someone/"], + }); + expect(details?.socialNetworks).toEqual([ + { service: "LINKEDIN", identifier: "https://www.linkedin.com/in/someone/" }, + ]); + }); +}); diff --git a/packages/meetup/src/speaker.ts b/packages/meetup/src/speaker.ts new file mode 100644 index 0000000..3126f4d --- /dev/null +++ b/packages/meetup/src/speaker.ts @@ -0,0 +1,81 @@ +import type { NormalizedSpeaker } from "@coopkit/core"; + +/** + * Meetup's speaker profile on an event — a Meetup **Pro** feature, which is why + * sending it is opt-in per group (see `MeetupConfig.speakerDetails`). Groups + * outside a Pro network reject it. + */ +export interface SpeakerDetailsInput { + name: string; + description: string; + socialNetworks?: Array<{ service: SocialNetworkService; identifier: string }>; +} + +/** The services Meetup's SocialNetworkService enum accepts. */ +export type SocialNetworkService = + | "FACEBOOK" + | "FLICKR" + | "INSTAGRAM" + | "LINKEDIN" + | "OTHER" + | "TIKTOK" + | "TUMBLR" + | "TWITTER"; + +const HOST_SERVICES: Array<[RegExp, SocialNetworkService]> = [ + [/(^|\.)linkedin\.com$/i, "LINKEDIN"], + [/(^|\.)(twitter\.com|x\.com)$/i, "TWITTER"], + [/(^|\.)instagram\.com$/i, "INSTAGRAM"], + [/(^|\.)facebook\.com$/i, "FACEBOOK"], + [/(^|\.)tiktok\.com$/i, "TIKTOK"], + [/(^|\.)tumblr\.com$/i, "TUMBLR"], + [/(^|\.)flickr\.com$/i, "FLICKR"], +]; + +/** + * Classify a profile URL into Meetup's fixed service enum, falling back to + * OTHER — which is what a personal site or a Mastodon instance is. + * + * The whole URL is kept as the `identifier`. Meetup calls the field an + * identifier, suggesting a bare handle, but the shape it wants per service is + * undocumented, and a URL is unambiguous and lossless for every service + * including OTHER. + */ +export function classifySocialUrl( + url: string +): { service: SocialNetworkService; identifier: string } | undefined { + let host: string; + try { + host = new URL(url).hostname; + } catch { + return undefined; // not a URL we can classify; drop it rather than guess + } + + const matched = HOST_SERVICES.find(([re]) => re.test(host)); + return { service: matched ? matched[1] : "OTHER", identifier: url }; +} + +/** + * Build Meetup's speakerDetails from a NormalizedEvent's speaker. + * + * Returns undefined when there is no speaker, or no bio: Meetup requires a + * non-empty `description`, and inventing one (or sending an empty string) would + * publish a hollow speaker profile. The caller logs the skip. + */ +export function speakerDetailsFrom( + speaker: NormalizedSpeaker | undefined +): SpeakerDetailsInput | undefined { + if (!speaker?.name?.trim()) return undefined; + + const description = speaker.bioMarkdown?.trim(); + if (!description) return undefined; + + const details: SpeakerDetailsInput = { name: speaker.name.trim(), description }; + + const socialNetworks = (speaker.socialUrls ?? []) + .map((url) => classifySocialUrl(url)) + .filter((entry): entry is { service: SocialNetworkService; identifier: string } => !!entry); + if (socialNetworks.length > 0) details.socialNetworks = socialNetworks; + + return details; +}