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; +}