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
26 changes: 24 additions & 2 deletions .github/ISSUE_TEMPLATE/add-skill.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ body:
merges it, and it ships to every station as an installable community skill.

**This path is for prompt-only skills** — a brief plus a few knobs. Skills that
run their own `tool.mjs` code aren't accepted here (yet). **You don't need to fork anything.**
run their own `tool.mjs` code aren't accepted here (yet). A skill *can* read a public
RSS/Atom feed for real data, though; that's a URL, not code. **You don't need to fork anything.**
- type: input
id: skill-name
attributes:
Expand Down Expand Up @@ -69,6 +70,26 @@ body:
default: 0
validations:
required: false
- type: input
id: feed
attributes:
label: Feed URL
description: >
Optional. An https:// RSS or Atom feed the skill reads before it speaks. Every station
that installs your skill fetches it and hands the DJ the fresh items (title + blurb), so
pick one that is public, stable and on-topic. If you set one, write the brief to speak
only from those items and to say nothing when none fit.
placeholder: https://pitchfork.com/feed/feed-news/rss
validations:
required: false
- type: input
id: feed-max-items
attributes:
label: Feed items to read
description: Optional, needs a feed URL. How much of the feed to read each time, 1–50. Defaults to the station's setting (10).
placeholder: '10'
validations:
required: false
- type: textarea
id: description
attributes:
Expand All @@ -86,7 +107,8 @@ body:

- The brief is your own work (or cleared for sharing), and contains nothing
unlawful, hateful, or designed to deceive listeners.
- It contains no secrets, personal data, or instructions to fetch untrusted code.
- It contains no secrets, personal data, or instructions to fetch untrusted code, and any
feed URL is a public feed you'd be happy for every installing station to fetch.
- Your GitHub username is recorded as the skill's submitter (shown as a credit
line in the community catalog), along with the dates it was added and last updated.
- SUB/WAVE may include, edit, or remove it from the catalog at its discretion.
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/skill-submission.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ jobs:
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
const COOLDOWN_RE = /^\d+\s*[smhd]?$/;
const CONTEXT_FIELDS = new Set(['date', 'clock', 'time', 'weather', 'festival', 'show', 'listeners']);
// Same bounds scripts/build-catalog.mjs enforces (and the validate job
// below re-checks): an https feed, and a read count of 1-50 that only
// means something alongside one.
const FEED_MAX_ITEMS_LIMIT = 50;
const FEED_URL_MAX = 500;

// --- 1. Parse the issue-form body into a { heading: value } map ---
// GitHub renders each field as "### <label>\n\n<value>"; empty optional
Expand Down Expand Up @@ -93,6 +98,8 @@ jobs:
const cooldown = get('Cooldown');
const contextRaw = get('Context fields');
const windowRaw = get('When it can air').toLowerCase();
const feed = get('Feed URL');
const feedMaxRaw = get('Feed items to read');

// --- 2. Validate ---
const problems = [];
Expand All @@ -102,6 +109,23 @@ jobs:
if (!brief) problems.push('**The brief** is required — it\'s what the DJ says on air.');
if (cooldown && !COOLDOWN_RE.test(cooldown)) problems.push('**Cooldown** must look like `45m`, `6h`, `2d`, or a bare number (minutes).');

// The feed is written into frontmatter verbatim and fetched by every
// station that installs the skill, so it must be one clean https URL:
// no whitespace (which would also break the frontmatter line).
if (feed) {
let url = null;
try { url = new URL(feed); } catch {}
if (!url || /\s/.test(feed) || feed.length > FEED_URL_MAX) problems.push(`**Feed URL** must be a single URL of up to ${FEED_URL_MAX} characters.`);
else if (url.protocol !== 'https:') problems.push('**Feed URL** must start with `https://`.');
}
let feedMaxItems = '';
if (feedMaxRaw) {
const n = Number(feedMaxRaw);
if (!feed) problems.push('**Feed items to read** needs a **Feed URL**.');
else if (!Number.isInteger(n) || n < 1 || n > FEED_MAX_ITEMS_LIMIT) problems.push(`**Feed items to read** must be a whole number from 1 to ${FEED_MAX_ITEMS_LIMIT}.`);
else feedMaxItems = String(n);
}

let contextList = [];
if (contextRaw) {
contextList = contextRaw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
Expand Down Expand Up @@ -175,6 +199,8 @@ jobs:
if (cooldown) fm.push(`cooldown: ${cooldown}`);
if (contextList.length) fm.push(`context: ${contextList.join(', ')}`);
if (windowRaw === 'commute') fm.push('window: commute');
if (feed) fm.push(`feed: ${feed}`);
if (feedMaxItems) fm.push(`feedMaxItems: ${feedMaxItems}`);
if (submittedBy) fm.push(`submittedBy: ${submittedBy}`);
fm.push(`dateAdded: ${dateAdded}`);
fm.push(`dateModified: ${today}`);
Expand All @@ -200,6 +226,9 @@ jobs:
const prBody =
`Adds the **${displayLabel}** community skill.\n\n` +
(summary ? `> ${summary}\n\n` : '') +
(feed ? `**Reads a feed:** every station that installs this fetches ${feed}. ` +
`Open it and check it is public, stable and on-topic, and that the brief ` +
`says nothing when no items fit.\n\n` : '') +
`Generated automatically from #${issue.number}. Closes #${issue.number}.\n\n` +
'```markdown\n' + content + '```\n';
if (!pr) {
Expand Down
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,26 @@ cooldown: 2h # min gap between airings: "90m" | "6h" | "2d"
context: clock, time # optional "right now" fields the segment may mention:
# date, clock, time, weather, festival, show, listeners
window: any # "any" (default) or "commute"
cron: 0 17 * * 5 # optional fixed schedule: 5 fields, or 6 with seconds
cronOnly: true # optional: fire ONLY on the cron (needs `cron`)
cohosts: true # optional: the host and every guest each speak
feed: https://example.com/rss # optional: an https RSS/Atom feed the skill reads first
feedMaxItems: 10 # optional: how much of the feed to read, 1–50 (needs `feed`)
---
Say one short line acknowledging that some listeners are probably in transit right now…
```

**Feeds give a skill real data without code.** A station that installs a skill with a `feed:`
fetches that feed before the DJ speaks and hands it the fresh items (title + blurb). Already-used
items are skipped, and a failed fetch means the skill stays quiet rather than making things up. So
write the brief to speak *only* from those items, and to **say nothing when none fit or none came
back** — a station on an older controller installs the brief without the feed. Pick a feed that is
public, stable and on-topic; a feed full of deals or promos gives the DJ nothing to work with.
`skills/music-press` and `skills/good-news` are worked examples.

`true`/`false` are the only values `cronOnly` and `cohosts` accept — anything else fails the build
rather than silently installing a different skill.

The slug must not shadow a built-in/reserved kind: `link`, `dj-speak`, `announcement`,
`station-id`, `hourly`, `hourly-check`, `album-anniversary`, `curiosity`, `library-deep-cut`,
`news`, `now-playing-dig`, `weather`, `web-search`.
Expand Down
48 changes: 48 additions & 0 deletions scripts/build-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ const APP_IMAGE_HOSTS = ['raw.githubusercontent.com', 'user-images.githubusercon
const MAX_PLATFORM_LEN = 32;
const MAX_PLATFORMS = 6;

// A skill's `cron:` must be 5 fields, or 6 with seconds — the controller's
// SKILL_CRON_RE. Field RANGES are node-cron's to judge, and the station checks
// them at install, so a shape check is all this layer can honestly make.
const SKILL_CRON_RE = /^\S+(?:\s+\S+){4,5}$/;

// A skill's `feed:` becomes a fetch on every station that installs it (the
// loader generates a fetch tool from it — no code ships). The station accepts
// http(s); the catalog asks for https, since this one URL is fetched by every
// installing station rather than one operator who typed it.
const FEED_MAX_ITEMS_LIMIT = 50;

const errors = [];
const fail = (where, msg) => errors.push(`${where}: ${msg}`);

Expand Down Expand Up @@ -115,6 +126,35 @@ const provenance = data => ({
});
const clean = obj => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));

// A frontmatter boolean the station reads with `=== true`. Anything other than
// true/false is refused rather than read as false: `cohosts: yes` silently
// installing a one-voice skill is the failure this prevents.
function flag(data, key, where) {
const raw = data[key];
if (raw == null || raw === '') return undefined;
const v = String(raw).trim().toLowerCase();
if (v === 'true') return true;
if (v === 'false') return undefined;
fail(where, `${key} must be true or false (is "${raw}")`);
return undefined;
}

function skillFeed(data, where) {
const raw = data.feed?.trim();
if (data.feedMaxItems && !raw) fail(where, 'feedMaxItems needs a feed');
if (!raw) return {};
let url;
try { url = new URL(raw); } catch { fail(where, `feed "${raw}" is not a URL`); return {}; }
if (url.protocol !== 'https:') { fail(where, `feed "${raw}" must be an https:// URL`); return {}; }
let feedMaxItems;
if (data.feedMaxItems) {
const n = Number(data.feedMaxItems);
if (Number.isInteger(n) && n >= 1 && n <= FEED_MAX_ITEMS_LIMIT) feedMaxItems = n;
else fail(where, `feedMaxItems must be a whole number from 1 to ${FEED_MAX_ITEMS_LIMIT} (is "${data.feedMaxItems}")`);
}
return { feed: raw, feedMaxItems };
}

async function buildSkills() {
const out = [];
for (const slug of await listDirs(join(ROOT, 'skills'))) {
Expand All @@ -126,13 +166,21 @@ async function buildSkills() {
const { data, body } = parseFrontmatter(raw);
if ((data.name || slug).trim() !== slug) { fail(where, `name "${data.name}" must equal folder slug`); continue; }
if (!body) { fail(where, 'brief (body) is required'); continue; }
const cron = data.cron?.trim() || undefined;
if (cron && !SKILL_CRON_RE.test(cron)) fail(where, `cron "${cron}" must be 5 fields, or 6 with seconds (e.g. "0 17 * * 5")`);
const cronOnly = flag(data, 'cronOnly', where);
if (cronOnly && !cron) fail(where, 'cronOnly needs a cron — without one the skill would never air');
out.push(clean({
slug,
label: (data.label || slug).trim(),
brief: body,
cooldown: data.cooldown ? String(data.cooldown).trim() : undefined,
cron,
cronOnly,
cohosts: flag(data, 'cohosts', where),
window: data.window === 'commute' ? 'commute' : undefined,
context: (data.context ?? data.contextFields)?.trim() || undefined,
...skillFeed(data, where),
...provenance(data),
}));
}
Expand Down
8 changes: 8 additions & 0 deletions skills/good-news/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
name: good-news
label: Good news
cooldown: 4h
feed: https://www.goodnewsnetwork.org/feed/
feedMaxItems: 10
---
Pass on one genuinely good story from the feed in a sentence or two: a rescue, a recovery, a kindness between strangers, a species coming back, a town fixing something together. Tell it like a friend sharing something that made their day, warm and plain. No "and now for some good news", and no moral tacked on the end. Choose a concrete story about real people or places over roundups, anniversaries, product news or opinion pieces. Use only what the item says, and add no names, numbers or places it doesn't give. If nothing on the feed is a real story, or no items came back, say nothing.
8 changes: 8 additions & 0 deletions skills/music-press/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
name: music-press
label: Music press bulletin
cooldown: 3h
feed: https://pitchfork.com/feed/feed-news/rss
feedMaxItems: 10
---
Pick one story from the music news feed and pass it on in a sentence or two, the way a DJ mentions something they read this morning. New records, tours, reunions, signings, reissues and live sessions are the beat; skip lawsuits, court cases, feuds and politics. If a story is about the artist on air, choose that one. If it is someone's death, say it plainly and with care, with no joke and no bright pivot back to the music. Use only what the headline and blurb say: add no dates, quotes, chart positions or details they don't give. Never read the headline out word for word, and no URLs. If nothing on the feed fits, or no headlines came back, say nothing.
Loading