@alexkroman1/aai-cli 6.11.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/_resource-commands.d.ts +23 -0
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
- package/dist/_slug-api.d.ts +5 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
- package/dist/_templates.d.ts +3 -4
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
- package/dist/cli.mjs +346 -349
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
- package/dist/eval-DfQ8cm5p.mjs +56 -0
- package/dist/eval.d.ts +43 -0
- package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +442 -80
- package/dist/scaffold/package.json +6 -3
- package/dist/scaffold/server.mjs +11 -4
- package/dist/scaffold/tsconfig.json +0 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
- package/dist/templates/briefing-desk/agent.test.ts +350 -0
- package/dist/templates/briefing-desk/agent.ts +30 -0
- package/dist/templates/briefing-desk/shared.ts +200 -0
- package/dist/templates/briefing-desk/system-prompt.md +39 -0
- package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
- package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
- package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
- package/dist/templates/call-audit/agent.eval.test.ts +474 -0
- package/dist/templates/call-audit/agent.test.ts +57 -204
- package/dist/templates/call-audit/agent.ts +32 -19
- package/dist/templates/call-audit/client.tsx +17 -54
- package/dist/templates/call-audit/workflows/audit.ts +11 -18
- package/dist/templates/call-audit/workflows/ingest.ts +106 -114
- package/dist/templates/call-audit/workflows/media.ts +2 -12
- package/dist/templates/call-audit/workflows/summarize.ts +47 -52
- package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
- package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
- package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
- package/dist/templates/infocom-adventure/agent.test.ts +53 -24
- package/dist/templates/infocom-adventure/agent.ts +21 -0
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/infocom-adventure/shared.ts +20 -9
- package/dist/templates/infocom-adventure/system-prompt.md +1 -1
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
- package/dist/templates/link-digest/agent.eval.test.ts +195 -0
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
- package/dist/templates/night-owl/agent.eval.test.ts +272 -0
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
- package/dist/templates/personal-finance/system-prompt.md +4 -0
- package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
- package/dist/templates/pipeline-simple/agent.ts +2 -2
- package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
- package/dist/templates/pizza-ordering/agent.test.ts +42 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/pizza-ordering/shared.ts +20 -1
- package/dist/templates/pizza-ordering/system-prompt.md +6 -0
- package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
- package/dist/templates/podcast-digest/agent.test.ts +717 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
- package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
- package/dist/templates/recap-workflow/agent.test.ts +97 -94
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
- package/dist/templates/redline/agent.eval.test.ts +209 -0
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/address.ts +87 -1
- package/dist/templates/retail/agent.eval.test.ts +314 -0
- package/dist/templates/retail/agent.test.ts +195 -112
- package/dist/templates/retail/agent.ts +35 -1
- package/dist/templates/retail/cancel.ts +116 -0
- package/dist/templates/retail/client.tsx +265 -117
- package/dist/templates/retail/payment.ts +107 -0
- package/dist/templates/retail/pending.test.ts +196 -0
- package/dist/templates/retail/pending.ts +137 -0
- package/dist/templates/retail/registry.test.ts +150 -7
- package/dist/templates/retail/returns.ts +102 -0
- package/dist/templates/retail/shared.test.ts +42 -0
- package/dist/templates/retail/shared.ts +35 -0
- package/dist/templates/retail/store.test.ts +84 -15
- package/dist/templates/retail/store.ts +231 -48
- package/dist/templates/retail/swap.test.ts +4 -4
- package/dist/templates/retail/swap.ts +248 -15
- package/dist/templates/retail/system-prompt.md +69 -28
- package/dist/templates/retail/tools/cancel_change.ts +33 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
- package/dist/templates/retail/tools/confirm_change.ts +42 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
- package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
- package/dist/templates/retail/tools/modify_user_address.ts +13 -16
- package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
- package/dist/templates/simple/agent.eval.test.ts +54 -0
- package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
- package/dist/templates/spoken-summary/agent.test.ts +68 -97
- package/dist/templates/spoken-summary/agent.ts +30 -17
- package/dist/templates/spoken-summary/client.tsx +10 -50
- package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
- package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
- package/dist/templates/support-line/agent.eval.test.ts +273 -0
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/system-prompt.md +2 -1
- package/dist/templates/support-line/tools/answer_question.ts +66 -4
- package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
- package/dist/templates/transcription-workflow/agent.test.ts +94 -194
- package/dist/templates/transcription-workflow/agent.ts +1 -1
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
- package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
- package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
- package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
- package/dist/templates/travel-concierge/agent.test.ts +174 -43
- package/dist/templates/travel-concierge/agent.ts +18 -9
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +180 -12
- package/dist/templates/travel-concierge/system-prompt.md +7 -1
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
- package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
- package/dist/templates/travel-concierge/tools/update_ticket.ts +8 -5
- package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
- package/dist/templates/web-researcher/system-prompt.md +15 -4
- package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
- package/dist/test.d.ts +29 -5
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/eject-31gjtaHF.mjs +0 -116
- package/dist/eject.d.ts +0 -60
- package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Turning what a person PASTES into a list of episodes with audio.
|
|
4
|
+
*
|
|
5
|
+
* The input field says "podcast links" because that is what somebody has in
|
|
6
|
+
* their clipboard — an Apple Podcasts page, a Spotify show, the show's own
|
|
7
|
+
* homepage, or, if they are the sort of person who knows, the RSS feed itself.
|
|
8
|
+
* Exactly one of those four is directly usable. This module is the funnel that
|
|
9
|
+
* turns the other three into the fourth, and then reads it.
|
|
10
|
+
*
|
|
11
|
+
* ## Why the scraping lives in the TEMPLATE and not the SDK
|
|
12
|
+
*
|
|
13
|
+
* Every function here encodes what a particular vendor's HTML looked like when
|
|
14
|
+
* this was written: the `id(\d+)` in an Apple URL, the `serialized-server-data`
|
|
15
|
+
* script tag, `og:title` on a Spotify page. That is the class of knowledge with
|
|
16
|
+
* the shortest half-life in the file, and the SDK's rule — "the SDK owns
|
|
17
|
+
* never-guess, the template owns what an order id looks like" — puts it here on
|
|
18
|
+
* purpose. A reader who needs a fifth source edits this module; nobody has to
|
|
19
|
+
* wait for an SDK release to do it.
|
|
20
|
+
*
|
|
21
|
+
* ## The one dependency decision worth copying
|
|
22
|
+
*
|
|
23
|
+
* The studio app this template came from reached for two npm packages here:
|
|
24
|
+
* `spotify-uri` to answer "is this a show link", and `@extractus/feed-extractor`
|
|
25
|
+
* to parse RSS — which it then never imported, parsing the XML by hand anyway.
|
|
26
|
+
* Both are gone. `spotify-uri` was replaced by {@link isSpotifyShowUrl}, six
|
|
27
|
+
* lines that ask the `URL` parser the same question, and the unused parser was
|
|
28
|
+
* simply deleted.
|
|
29
|
+
*
|
|
30
|
+
* That is not a general "avoid dependencies" position, it is what a TEMPLATE
|
|
31
|
+
* is: a starter is scaffolded against `scaffold/package.json`, so an import it
|
|
32
|
+
* does not list resolves to nothing and the project fails to build the moment
|
|
33
|
+
* somebody runs it. A template may import the SDK, `workflow`, `zod` and React.
|
|
34
|
+
* Anything else has to earn a place in the scaffold manifest first.
|
|
35
|
+
*
|
|
36
|
+
* ## Regex over a real XML parser, deliberately
|
|
37
|
+
*
|
|
38
|
+
* There is no DOM in a step artifact and no XML parser in the scaffold, so the
|
|
39
|
+
* feed reading below is regular expressions over the raw body. This is the
|
|
40
|
+
* honest trade rather than a shortcut: a podcast feed is machine-generated and
|
|
41
|
+
* the two fields that matter (`<enclosure url>` and `<item>`) are stable across
|
|
42
|
+
* every generator in the wild, so the failure mode is a missing episode rather
|
|
43
|
+
* than a corrupted one. {@link episodeFromItem} drops an item it cannot read a
|
|
44
|
+
* media URL from instead of inventing one.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { report } from "@alexkroman1/aai/step";
|
|
48
|
+
import { stepFetchOk } from "@alexkroman1/aai/step-errors";
|
|
49
|
+
import { decodeHtmlEntities, isRecord, omitUndefined, safeJsonParse } from "@alexkroman1/aai/utils";
|
|
50
|
+
import { FatalError } from "workflow";
|
|
51
|
+
|
|
52
|
+
/** How long any one of these lookups may take before it is a failure. */
|
|
53
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A show, reduced to what the rest of the run needs — plus the body, when
|
|
57
|
+
* resolving it already had to download one.
|
|
58
|
+
*
|
|
59
|
+
* `xml` is the difference between one request per feed and two. Both web paths
|
|
60
|
+
* fetch the feed to decide whether it IS a feed (`looksLikePodcastFeed`), and
|
|
61
|
+
* without somewhere to put that body the reader downloads the identical
|
|
62
|
+
* document a second time. The Apple and Spotify paths resolve a URL without
|
|
63
|
+
* ever reading the feed, so they leave it unset and the reader fetches once.
|
|
64
|
+
*/
|
|
65
|
+
export type PodcastFeed = {
|
|
66
|
+
feedUrl: string;
|
|
67
|
+
title: string;
|
|
68
|
+
xml?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** One episode with audio attached — the unit everything downstream works on. */
|
|
72
|
+
export type Episode = {
|
|
73
|
+
id: string;
|
|
74
|
+
feedUrl: string;
|
|
75
|
+
podcastTitle: string;
|
|
76
|
+
title: string;
|
|
77
|
+
url: string;
|
|
78
|
+
audioUrl: string;
|
|
79
|
+
published: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The step: links in, the newest `maxEpisodes` episodes out.
|
|
84
|
+
*
|
|
85
|
+
* One step rather than one per feed, and that is a judgement about REPLAY
|
|
86
|
+
* rather than about speed. A step's result is journaled, so making this one
|
|
87
|
+
* step means a crash anywhere later in the run replays a single recorded array
|
|
88
|
+
* instead of re-scraping Apple and Spotify — who both rate-limit — to rebuild
|
|
89
|
+
* an identical list. The cost is that a crash INSIDE it redoes all of it, which
|
|
90
|
+
* is seconds of HTTP against work measured in transcription minutes.
|
|
91
|
+
*/
|
|
92
|
+
export async function discoverEpisodes(
|
|
93
|
+
podcastChannels: string,
|
|
94
|
+
maxEpisodes: number,
|
|
95
|
+
): Promise<Episode[]> {
|
|
96
|
+
"use step";
|
|
97
|
+
|
|
98
|
+
await report("Finding recent podcast episodes.");
|
|
99
|
+
const links = parsePodcastChannels(podcastChannels);
|
|
100
|
+
if (links.length === 0) throw new FatalError("Add at least one podcast link.");
|
|
101
|
+
|
|
102
|
+
const feeds: PodcastFeed[] = [];
|
|
103
|
+
for (const url of links) feeds.push(await resolvePodcastFeed(url));
|
|
104
|
+
|
|
105
|
+
const episodes = (await Promise.all(feeds.map((feed) => readPodcastFeed(feed))))
|
|
106
|
+
.flat()
|
|
107
|
+
.sort((a, b) => publishedAt(b) - publishedAt(a))
|
|
108
|
+
.slice(0, maxEpisodes);
|
|
109
|
+
|
|
110
|
+
if (episodes.length === 0) {
|
|
111
|
+
// Fatal, not retryable: the same feeds return the same empty list on the
|
|
112
|
+
// next attempt. The message names the four accepted shapes because "no
|
|
113
|
+
// episodes found" on its own does not tell anyone what to paste instead.
|
|
114
|
+
throw new FatalError(
|
|
115
|
+
"No podcast episodes with audio were found. Paste an Apple Podcasts show link, " +
|
|
116
|
+
"a Spotify show link, an RSS feed URL, or a podcast homepage that advertises one.",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return episodes;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- Resolving a link to a feed ---------------------------------------------
|
|
124
|
+
|
|
125
|
+
/** The funnel: whatever was pasted becomes a feed URL, or the run stops. */
|
|
126
|
+
async function resolvePodcastFeed(url: string): Promise<PodcastFeed> {
|
|
127
|
+
if (isApplePodcastUrl(url)) return await resolveApplePodcastFeed(url);
|
|
128
|
+
if (isSpotifyShowUrl(url)) return await resolveSpotifyPodcastFeed(url);
|
|
129
|
+
return await resolveWebPodcastFeed(url);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The plain-web case, which is two cases: the URL already IS a feed, or it is a
|
|
134
|
+
* page that advertises one in a `<link rel=alternate>`.
|
|
135
|
+
*/
|
|
136
|
+
async function resolveWebPodcastFeed(url: string): Promise<PodcastFeed> {
|
|
137
|
+
const body = await fetchText(url);
|
|
138
|
+
if (looksLikePodcastFeed(body)) return feedFrom(url, body);
|
|
139
|
+
|
|
140
|
+
const discovered = discoverFeedUrl(body, url);
|
|
141
|
+
if (!discovered) {
|
|
142
|
+
throw new FatalError(
|
|
143
|
+
`Could not find a podcast RSS feed at ${url}. Use an Apple Podcasts link, a ` +
|
|
144
|
+
"Spotify show link, or the podcast's RSS feed URL.",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The advertised URL is verified rather than trusted: plenty of pages point
|
|
149
|
+
// `application/rss+xml` at a blog feed with no audio in it, and finding that
|
|
150
|
+
// out here names the page, where finding it out later names an empty digest.
|
|
151
|
+
// The body is carried forward rather than re-fetched — see `PodcastFeed.xml`.
|
|
152
|
+
const verified = await fetchText(discovered);
|
|
153
|
+
if (!looksLikePodcastFeed(verified)) {
|
|
154
|
+
throw new FatalError(`The feed at ${discovered} does not look like a podcast RSS feed.`);
|
|
155
|
+
}
|
|
156
|
+
return feedFrom(discovered, verified);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Apple, which has a public lookup API and — when that misses — a page with the
|
|
161
|
+
* answer serialized into it.
|
|
162
|
+
*
|
|
163
|
+
* The fallback exists because the lookup API omits `feedUrl` for some shows
|
|
164
|
+
* while the page still carries it, and losing a show to that is a worse outcome
|
|
165
|
+
* than one extra request on a path that rarely runs.
|
|
166
|
+
*/
|
|
167
|
+
async function resolveApplePodcastFeed(url: string): Promise<PodcastFeed> {
|
|
168
|
+
const id = /\/id(\d+)/.exec(url)?.[1];
|
|
169
|
+
if (!id) throw new FatalError(`Could not read an Apple Podcasts id from ${url}.`);
|
|
170
|
+
|
|
171
|
+
const lookup = await fetchJson(`https://itunes.apple.com/lookup?id=${id}&entity=podcast`);
|
|
172
|
+
const found = appleResults(lookup).find((result) => result.feedUrl);
|
|
173
|
+
if (found?.feedUrl)
|
|
174
|
+
return { feedUrl: found.feedUrl, title: found.title ?? hostOf(found.feedUrl) };
|
|
175
|
+
|
|
176
|
+
const html = await fetchText(url);
|
|
177
|
+
const fallback = extractAppleSerializedFeed(html, id);
|
|
178
|
+
if (!fallback) {
|
|
179
|
+
throw new FatalError(
|
|
180
|
+
`Apple Podcasts did not expose a public RSS feed for ${url}. Use the show's RSS feed URL.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return { feedUrl: fallback.feedUrl, title: fallback.title ?? hostOf(fallback.feedUrl) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Spotify, which publishes no feed URL at all — so this maps the show back to
|
|
188
|
+
* its feed THROUGH Apple's search, and refuses a low-confidence match.
|
|
189
|
+
*
|
|
190
|
+
* {@link titleMatchesSpotify} is the load-bearing half. A search for a show's
|
|
191
|
+
* title returns neighbours, and silently digesting the wrong podcast is the
|
|
192
|
+
* kind of failure nobody reports as a bug — they just stop trusting the output.
|
|
193
|
+
*/
|
|
194
|
+
async function resolveSpotifyPodcastFeed(url: string): Promise<PodcastFeed> {
|
|
195
|
+
const html = await fetchText(url);
|
|
196
|
+
const title = metaContent(html, "og:title") ?? textBetween(html, "<title>", "</title>") ?? "";
|
|
197
|
+
const description = metaContent(html, "og:description") ?? "";
|
|
198
|
+
const query = `${title} ${description}`.replace(/\s+/g, " ").trim();
|
|
199
|
+
if (!query) throw new FatalError(`Could not read podcast metadata from ${url}.`);
|
|
200
|
+
|
|
201
|
+
const search = await fetchJson(
|
|
202
|
+
`https://itunes.apple.com/search?media=podcast&limit=5&term=${encodeURIComponent(query)}`,
|
|
203
|
+
);
|
|
204
|
+
const match = appleResults(search).find(
|
|
205
|
+
(result) => result.feedUrl && titleMatchesSpotify(title, description, result),
|
|
206
|
+
);
|
|
207
|
+
if (!match?.feedUrl) {
|
|
208
|
+
throw new FatalError(`Could not map the Spotify show ${title || url} to a public RSS feed.`);
|
|
209
|
+
}
|
|
210
|
+
return { feedUrl: match.feedUrl, title: match.title ?? title };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- Reading a feed ---------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/** Every item in the feed that has audio attached, newest first by the caller. */
|
|
216
|
+
async function readPodcastFeed(feed: PodcastFeed): Promise<Episode[]> {
|
|
217
|
+
const xml = feed.xml ?? (await fetchText(feed.feedUrl));
|
|
218
|
+
const podcastTitle = decodeHtmlEntities(textBetween(xml, "<title>", "</title>") ?? feed.title);
|
|
219
|
+
|
|
220
|
+
return [...xml.matchAll(/<item[\s\S]*?<\/item>/g)]
|
|
221
|
+
.map((match, index) => episodeFromItem(match[0], feed.feedUrl, podcastTitle, index))
|
|
222
|
+
.filter((episode): episode is Episode => episode !== undefined);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** One `<item>`, or nothing when it carries no audio to transcribe. */
|
|
226
|
+
export function episodeFromItem(
|
|
227
|
+
item: string,
|
|
228
|
+
feedUrl: string,
|
|
229
|
+
podcastTitle: string,
|
|
230
|
+
index: number,
|
|
231
|
+
): Episode | undefined {
|
|
232
|
+
const audioUrl = enclosureUrl(item);
|
|
233
|
+
// Not an error: a feed legitimately mixes text posts in with episodes, and
|
|
234
|
+
// there is nothing for this run to do with one.
|
|
235
|
+
if (!audioUrl) return undefined;
|
|
236
|
+
|
|
237
|
+
const guid = stripCdata(
|
|
238
|
+
textBetween(item, "<guid", "</guid>")?.replace(/^[^>]*>/, "") ?? "",
|
|
239
|
+
).trim();
|
|
240
|
+
const link = decodeHtmlEntities(stripCdata(textBetween(item, "<link>", "</link>") ?? audioUrl));
|
|
241
|
+
return {
|
|
242
|
+
id: stableEpisodeId(feedUrl, guid || link || audioUrl),
|
|
243
|
+
feedUrl,
|
|
244
|
+
podcastTitle,
|
|
245
|
+
title: decodeHtmlEntities(
|
|
246
|
+
stripCdata(textBetween(item, "<title>", "</title>") ?? `Episode ${index + 1}`),
|
|
247
|
+
),
|
|
248
|
+
url: link,
|
|
249
|
+
audioUrl: decodeHtmlEntities(audioUrl),
|
|
250
|
+
published: decodeHtmlEntities(
|
|
251
|
+
stripCdata(
|
|
252
|
+
textBetween(item, "<pubDate>", "</pubDate>") ??
|
|
253
|
+
textBetween(item, "<published>", "</published>") ??
|
|
254
|
+
"",
|
|
255
|
+
),
|
|
256
|
+
),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---- Pure helpers -----------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/** Split the field, trim, drop blanks, and keep the first of any duplicate. */
|
|
263
|
+
export function parsePodcastChannels(value: string): string[] {
|
|
264
|
+
return value
|
|
265
|
+
.split(",")
|
|
266
|
+
.map((url) => url.trim())
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
.filter((url, index, all) => all.indexOf(url) === index);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A Spotify show link, without the `spotify-uri` package the studio app used.
|
|
273
|
+
*
|
|
274
|
+
* The package answers a much bigger question — it parses every Spotify URI and
|
|
275
|
+
* URL shape there is — and this file asks one bit of it. Six lines against a
|
|
276
|
+
* dependency the scaffold does not ship is not a close call.
|
|
277
|
+
*/
|
|
278
|
+
export function isSpotifyShowUrl(url: string): boolean {
|
|
279
|
+
try {
|
|
280
|
+
const { hostname, pathname } = new URL(url);
|
|
281
|
+
return isHost(hostname, "spotify.com") && /^\/show\/[^/]+/.test(pathname);
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** An Apple Podcasts show page, which always carries an `/id<digits>` segment. */
|
|
288
|
+
export function isApplePodcastUrl(url: string): boolean {
|
|
289
|
+
try {
|
|
290
|
+
const { hostname, pathname } = new URL(url);
|
|
291
|
+
return isHost(hostname, "podcasts.apple.com") && /\/id\d+/.test(pathname);
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The domain, or a subdomain of it — never a host that merely ENDS with it.
|
|
299
|
+
*
|
|
300
|
+
* A bare `hostname.endsWith("spotify.com")` also matches `notspotify.com`, and
|
|
301
|
+
* `endsWith("podcasts.apple.com")` matches `evilpodcasts.apple.com`. Getting
|
|
302
|
+
* this wrong routes somebody else's host into the vendor-specific branch, which
|
|
303
|
+
* then scrapes it as though the vendor had written it. The dot is the fix, and
|
|
304
|
+
* it is the same shape as the Slack host check in `slack.ts`.
|
|
305
|
+
*/
|
|
306
|
+
function isHost(hostname: string, domain: string): boolean {
|
|
307
|
+
const host = hostname.toLowerCase();
|
|
308
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The feed URL Apple serializes into its own show page.
|
|
313
|
+
*
|
|
314
|
+
* Exported because it is the one piece of scraping here with a shape worth
|
|
315
|
+
* pinning in a test — the walk is recursive and the `adamId` match is what
|
|
316
|
+
* stops it returning a RELATED show's feed from the same blob.
|
|
317
|
+
*/
|
|
318
|
+
export function extractAppleSerializedFeed(
|
|
319
|
+
html: string,
|
|
320
|
+
podcastId: string,
|
|
321
|
+
): { feedUrl: string; title?: string } | undefined {
|
|
322
|
+
const script = /<script\b[^>]*id=["']serialized-server-data["'][^>]*>([\s\S]*?)<\/script>/i.exec(
|
|
323
|
+
html,
|
|
324
|
+
);
|
|
325
|
+
if (!script?.[1]) return undefined;
|
|
326
|
+
return findAppleFeed(safeJsonParse(script[1]), podcastId);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Depth-first for the record that is BOTH a feed and the show we asked for. */
|
|
330
|
+
function findAppleFeed(
|
|
331
|
+
value: unknown,
|
|
332
|
+
podcastId: string,
|
|
333
|
+
): { feedUrl: string; title?: string } | undefined {
|
|
334
|
+
if (Array.isArray(value)) {
|
|
335
|
+
for (const item of value) {
|
|
336
|
+
const found = findAppleFeed(item, podcastId);
|
|
337
|
+
if (found) return found;
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!isRecord(value)) return undefined;
|
|
343
|
+
if (typeof value.feedUrl === "string" && value.adamId === podcastId) {
|
|
344
|
+
return typeof value.title === "string"
|
|
345
|
+
? { feedUrl: value.feedUrl, title: value.title }
|
|
346
|
+
: { feedUrl: value.feedUrl };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const item of Object.values(value)) {
|
|
350
|
+
const found = findAppleFeed(item, podcastId);
|
|
351
|
+
if (found) return found;
|
|
352
|
+
}
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Is this Apple search hit the Spotify show we started from?
|
|
358
|
+
*
|
|
359
|
+
* Three ways to say yes, because the two catalogues disagree about where the
|
|
360
|
+
* network's name goes — sometimes in the title, sometimes only in the artist.
|
|
361
|
+
*/
|
|
362
|
+
export function titleMatchesSpotify(
|
|
363
|
+
spotifyTitle: string,
|
|
364
|
+
spotifyArtist: string,
|
|
365
|
+
result: { title?: string; artist?: string },
|
|
366
|
+
): boolean {
|
|
367
|
+
const collection = normalizeTitle(result.title ?? "");
|
|
368
|
+
const wanted = normalizeTitle(spotifyTitle);
|
|
369
|
+
if (!(collection && wanted)) return false;
|
|
370
|
+
return (
|
|
371
|
+
collection === wanted ||
|
|
372
|
+
normalizeTitle(`${result.title ?? ""} ${result.artist ?? ""}`).includes(wanted) ||
|
|
373
|
+
normalizeTitle(`${spotifyTitle} ${spotifyArtist}`).includes(collection)
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** An RSS document that actually carries audio — both halves are required. */
|
|
378
|
+
export function looksLikePodcastFeed(xml: string): boolean {
|
|
379
|
+
return /<rss\b/i.test(xml) && /<enclosure\b[^>]*\burl=/i.test(xml);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The feed a page advertises, in either attribute order. */
|
|
383
|
+
export function discoverFeedUrl(html: string, pageUrl: string): string | undefined {
|
|
384
|
+
const match =
|
|
385
|
+
/<link\b[^>]*type=["']application\/(?:rss|atom)\+xml["'][^>]*href=["']([^"']+)["'][^>]*>/i.exec(
|
|
386
|
+
html,
|
|
387
|
+
) ??
|
|
388
|
+
/<link\b[^>]*href=["']([^"']+)["'][^>]*type=["']application\/(?:rss|atom)\+xml["'][^>]*>/i.exec(
|
|
389
|
+
html,
|
|
390
|
+
);
|
|
391
|
+
return match?.[1] ? new URL(decodeHtmlEntities(match[1]), pageUrl).toString() : undefined;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* An id that is the SAME on every run for the same episode.
|
|
396
|
+
*
|
|
397
|
+
* Deliberately derived rather than random: the page keys episode cards by it,
|
|
398
|
+
* and a replayed step that produced fresh ids would remount every card. A hash
|
|
399
|
+
* rather than the raw guid because a guid is arbitrary text and this ends up in
|
|
400
|
+
* markup.
|
|
401
|
+
*/
|
|
402
|
+
export function stableEpisodeId(feedUrl: string, source: string): string {
|
|
403
|
+
let hash = 0;
|
|
404
|
+
for (const char of `${feedUrl}:${source}`) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
|
405
|
+
return `episode-${hash.toString(16)}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Unparseable dates sort last rather than throwing off the whole ordering. */
|
|
409
|
+
function publishedAt(episode: Episode): number {
|
|
410
|
+
const parsed = Date.parse(episode.published);
|
|
411
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function feedFrom(feedUrl: string, body: string): PodcastFeed {
|
|
415
|
+
return {
|
|
416
|
+
feedUrl,
|
|
417
|
+
title: decodeHtmlEntities(textBetween(body, "<title>", "</title>") ?? hostOf(feedUrl)),
|
|
418
|
+
xml: body,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function hostOf(url: string): string {
|
|
423
|
+
try {
|
|
424
|
+
return new URL(url).hostname;
|
|
425
|
+
} catch {
|
|
426
|
+
return url;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function enclosureUrl(item: string): string | undefined {
|
|
431
|
+
return /<enclosure\b[^>]*\burl=["']([^"']+)["'][^>]*>/i.exec(item)?.[1];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function normalizeTitle(value: string): string {
|
|
435
|
+
return value
|
|
436
|
+
.toLowerCase()
|
|
437
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
438
|
+
.trim();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The iTunes payload, reduced to the three fields this file reads.
|
|
443
|
+
*
|
|
444
|
+
* `omitUndefined` rather than a conditional spread per field: under
|
|
445
|
+
* `exactOptionalPropertyTypes` an absent field and a field set to `undefined`
|
|
446
|
+
* are different types, and this is the SDK's one spelling for the difference.
|
|
447
|
+
*/
|
|
448
|
+
function appleResults(body: unknown): Array<{ feedUrl?: string; title?: string; artist?: string }> {
|
|
449
|
+
if (!(isRecord(body) && Array.isArray(body.results))) return [];
|
|
450
|
+
return body.results.filter(isRecord).map((result) =>
|
|
451
|
+
omitUndefined({
|
|
452
|
+
feedUrl: asString(result.feedUrl),
|
|
453
|
+
title: asString(result.collectionName) ?? asString(result.trackName),
|
|
454
|
+
artist: asString(result.artistName),
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** A JSON field, when the far side really did send a string. */
|
|
460
|
+
function asString(value: unknown): string | undefined {
|
|
461
|
+
return typeof value === "string" ? value : undefined;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function textBetween(text: string, start: string, end: string): string | undefined {
|
|
465
|
+
const from = text.indexOf(start);
|
|
466
|
+
if (from < 0) return undefined;
|
|
467
|
+
const to = text.indexOf(end, from + start.length);
|
|
468
|
+
return to < 0 ? undefined : text.slice(from + start.length, to);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function metaContent(html: string, property: string): string | undefined {
|
|
472
|
+
const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
473
|
+
const match =
|
|
474
|
+
new RegExp(`<meta[^>]+property=["']${escaped}["'][^>]+content=["']([^"']+)["']`, "i").exec(
|
|
475
|
+
html,
|
|
476
|
+
) ??
|
|
477
|
+
new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${escaped}["']`, "i").exec(
|
|
478
|
+
html,
|
|
479
|
+
);
|
|
480
|
+
return decodeHtmlEntities(match?.[1] ?? "").trim() || undefined;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function stripCdata(text: string): string {
|
|
484
|
+
return text.replace(/^<!\[CDATA\[/, "").replace(/\]\]>$/, "");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---- HTTP -------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Every outbound call in this file, with one timeout and one failure policy.
|
|
491
|
+
*
|
|
492
|
+
* `stepFetchOk` is `stepFetch` plus the non-2xx branch — see its own doc. Three
|
|
493
|
+
* things come with it that a bare `fetch` here would each have to re-derive:
|
|
494
|
+
* HTTP/1.1 (so a rate limit arrives as a `503` with `Retry-After` rather than
|
|
495
|
+
* an h2 stream reset carrying no status), the transient/terminal verdict the
|
|
496
|
+
* DevKit reads, and the far side's own error text in the message — which for
|
|
497
|
+
* the iTunes endpoints is the difference between "403" and a sentence.
|
|
498
|
+
*/
|
|
499
|
+
async function fetchText(url: string): Promise<string> {
|
|
500
|
+
const response = await stepFetchOk(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
501
|
+
return await response.text();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The same, for the two iTunes endpoints that answer JSON. */
|
|
505
|
+
async function fetchJson(url: string): Promise<unknown> {
|
|
506
|
+
return safeJsonParse(await fetchText(url));
|
|
507
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Delivering the digest — which is now almost entirely a question of what the
|
|
4
|
+
* MESSAGE says, because where it goes is the SDK's job.
|
|
5
|
+
*
|
|
6
|
+
* This module used to carry the whole third-party contract: Slack's two
|
|
7
|
+
* webhook shapes and the branch between them, Block Kit assembly, mrkdwn
|
|
8
|
+
* escaping, the 4xx/5xx split and the advice each refusal deserves. All of it
|
|
9
|
+
* is `@alexkroman1/aai/channels` now — `slackChannel()` names the destination,
|
|
10
|
+
* `sendToChannelClassified` posts and classifies — and what is left here is the
|
|
11
|
+
* part that is actually about podcasts: turning episode digests into a
|
|
12
|
+
* {@link ChannelMessage}.
|
|
13
|
+
*
|
|
14
|
+
* That split is the point of the channel concept. Every one of those rules is
|
|
15
|
+
* about SLACK rather than about this template, and a template is the wrong
|
|
16
|
+
* place to learn them: the trigger-vs-incoming-webhook distinction alone is
|
|
17
|
+
* the most common way a run of this ends in a red 400 with nobody able to say
|
|
18
|
+
* why.
|
|
19
|
+
*
|
|
20
|
+
* ## What a step still owns
|
|
21
|
+
*
|
|
22
|
+
* The `"use step"` stays HERE, and it has to. The Workflow DevKit's builder
|
|
23
|
+
* rewrites step bodies it finds in this directory; one written inside the SDK
|
|
24
|
+
* would be transformed by nothing, so it would run inline with no journal and
|
|
25
|
+
* no retry while looking durable at the call site.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { type ChannelMessage, slackChannel } from "@alexkroman1/aai/channels";
|
|
29
|
+
import { report } from "@alexkroman1/aai/step";
|
|
30
|
+
import { sendToChannelClassified } from "@alexkroman1/aai/step-errors";
|
|
31
|
+
import type { EpisodeDigest } from "./digest.ts";
|
|
32
|
+
|
|
33
|
+
/** Everything the message needs, so rendering can stay a pure function. */
|
|
34
|
+
export type SlackDigestInput = {
|
|
35
|
+
slackWebhookUrl: string;
|
|
36
|
+
slackWorkflowTextParam: string;
|
|
37
|
+
podcastChannels: string;
|
|
38
|
+
episodes: EpisodeDigest[];
|
|
39
|
+
digestNumber: number;
|
|
40
|
+
totalDigests: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The step: post one digest.
|
|
45
|
+
*
|
|
46
|
+
* It is three lines because the interesting decisions moved. `slackChannel()` builds
|
|
47
|
+
* the descriptor, {@link renderDigestMessage} says what the message contains,
|
|
48
|
+
* and `sendToChannelClassified` does the render-post-classify round — throwing
|
|
49
|
+
* a `FatalError` on a 4xx (a revoked webhook and a wrong variable name answer
|
|
50
|
+
* identically on every retry, so retrying only delays the real error) and a
|
|
51
|
+
* `RetryableError` carrying Slack's own `Retry-After` on a 5xx.
|
|
52
|
+
*/
|
|
53
|
+
export async function sendDigestToSlack(input: SlackDigestInput): Promise<string> {
|
|
54
|
+
"use step";
|
|
55
|
+
|
|
56
|
+
await report("Posting the digest to Slack.");
|
|
57
|
+
return await sendToChannelClassified(
|
|
58
|
+
slackChannel({ webhookUrl: input.slackWebhookUrl, textParam: input.slackWorkflowTextParam }),
|
|
59
|
+
renderDigestMessage(input),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The digest as a channel message — PURE, so a spec asserts what a run would
|
|
65
|
+
* post without a network, and without knowing Slack's payload shape.
|
|
66
|
+
*
|
|
67
|
+
* `text` is the notification line and, on a Slack workflow trigger, the whole
|
|
68
|
+
* message: the SDK folds the rest into it when the destination has no rich
|
|
69
|
+
* format. So it says how many episodes rather than repeating the headline.
|
|
70
|
+
*/
|
|
71
|
+
export function renderDigestMessage(input: SlackDigestInput): ChannelMessage {
|
|
72
|
+
return {
|
|
73
|
+
text: `${digestHeadline(input)}: ${input.episodes.length} episode summaries`,
|
|
74
|
+
heading: digestHeadline(input),
|
|
75
|
+
subtitle: `Feeds: ${input.podcastChannels}`,
|
|
76
|
+
sections: input.episodes.map((episode) => ({
|
|
77
|
+
title: episode.title,
|
|
78
|
+
url: episode.url,
|
|
79
|
+
subtitle: episode.podcastTitle,
|
|
80
|
+
body: episode.summary,
|
|
81
|
+
bullets: episode.keyPoints,
|
|
82
|
+
})),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function digestHeadline(input: SlackDigestInput): string {
|
|
87
|
+
return `Podcast digest ${input.digestNumber}/${input.totalDigests}`;
|
|
88
|
+
}
|