Skip to content
Merged
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
6 changes: 6 additions & 0 deletions packages/core/src/normalized-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
37 changes: 37 additions & 0 deletions packages/meetup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions packages/meetup/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
45 changes: 44 additions & 1 deletion packages/meetup/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import {
type MeetupConfig,
groupAcceptsSpeaker,
loadMeetupConfig,
resolveGroupTargets,
resolveHostIds,
Expand Down Expand Up @@ -191,11 +192,53 @@ 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", () => {
const targets = resolveGroupTargets({ ...cfg, groupHosts: {} }, { hostNames: ["Alex Smith"] });
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/);
});
});
33 changes: 32 additions & 1 deletion packages/meetup/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export interface MeetupConfig {
* single `defaultHosts` rarely fits all of them.
*/
groupHosts?: Record<string, string[]>;
/**
* 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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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());
}

/**
Expand Down Expand Up @@ -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),
};
});
}
16 changes: 15 additions & 1 deletion packages/meetup/src/create-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/meetup/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
50 changes: 50 additions & 0 deletions packages/meetup/src/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
14 changes: 13 additions & 1 deletion packages/meetup/src/payload.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 = /^<.*>$/;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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,
Expand Down
Loading
Loading