@vornrun/connector-substack 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to `@vornrun/connector-substack`.
4
4
 
5
+ ## 0.2.0
6
+
7
+ Adds the whole archive and a single post's body, so a workflow can check what
8
+ a publication already said, plus Notes, likes and restacks on posts, updating
9
+ a draft, and the subscriber count.
10
+
11
+ - **Actions:** `listPosts` (the archive, newest first, up to 500), `getPost`
12
+ (one post's full body as HTML and text), `updateDraft` (replace a draft's
13
+ title, subtitle and body; a published post is refused), `postNote`,
14
+ `readNotes`, `deleteNote`, `setPostLike`, `setPostRestack` (posts only) and
15
+ `readSubscriberCount`.
16
+ - **The publishing guard** still refuses every request path containing
17
+ `publish` or `schedule`, with one exception: a `GET` to
18
+ `/api/v1/publish-dashboard/`, the dashboard's read-only figures. Nothing
19
+ here publishes or schedules.
20
+
21
+ Each new request was made from a signed-in browser on 2026-09-12 against the
22
+ author's own publication, and then every new action ran live through the built
23
+ package against it, with every write undone: a draft saved, updated and
24
+ deleted, a Note posted and deleted, a like and a restack each made and taken
25
+ back. `updateDraft` changed the draft's title, subtitle and body as sent.
26
+ `readSubscriberCount` reports the dashboard's total as `subscribers`, from the
27
+ summary's `totalEmail`, and the summary's own `subscribers`, which counts paid
28
+ subscribers only, as `paidSubscribers`.
29
+
5
30
  ## 0.1.0
6
31
 
7
32
  First release.
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # @vornrun/connector-substack
2
2
 
3
3
  Trigger Vorn workflows from new posts on a Substack publication, and read a
4
- feed, search posts, read comments, save a draft, comment, and like or delete
5
- comments from a workflow step. Substack publishes no API for any of this, so
4
+ feed or the whole archive, search and read posts, read comments, save and
5
+ update drafts, comment, like and restack posts, and post and read Notes from a
6
+ workflow step. Substack publishes no API for any of this, so
6
7
  the connector reads the public RSS feed and asks the same web endpoints
7
8
  Substack's own pages use, from inside a window you signed in to.
8
9
 
@@ -22,22 +23,24 @@ When Substack signs the window out, a workflow step that needed it waits as
22
23
  "waiting for sign-in" until you sign in again, then runs again. When Vorn is
23
24
  closed on the desktop that signed in, the step fails and says to open it.
24
25
 
25
- Reading a feed, searching posts and reading the comments on a public post
26
- need no sign-in.
26
+ Reading a feed or the archive, searching posts, reading one post and reading
27
+ the comments on a public post need no sign-in.
27
28
 
28
29
  ## Settings
29
30
 
30
31
  | Setting | Env | What it is |
31
32
  | --- | --- | --- |
32
- | `publication` | `SUBSTACK_PUBLICATION` | Your publication's substack.com subdomain, such as `novumai`. The `newPost` trigger reads its feed, and a step that names no publication uses it. When it is empty, signed-in actions use the account's primary publication. |
33
+ | `publication` | `SUBSTACK_PUBLICATION` | Your publication's substack.com subdomain, such as `exampleletter`. The `newPost` trigger reads its feed, and a step that names no publication uses it. When it is empty, signed-in actions use the account's primary publication. |
33
34
 
34
35
  ## It never publishes
35
36
 
36
37
  Publishing a post emails every subscriber, so nothing here publishes or
37
38
  schedules. `createDraft` saves a draft and returns the address to open it in
38
39
  the editor, where you publish it yourself. Any request path containing
39
- `publish` or `schedule` is refused before it leaves, and `deleteDraft` reads
40
- the draft first and refuses a published post.
40
+ `publish` or `schedule` is refused before it leaves, and `deleteDraft` and
41
+ `updateDraft` read the draft first and refuse a published post. The one
42
+ exception to the guard is a `GET` to `/api/v1/publish-dashboard/`, the
43
+ dashboard's read-only figures that `readSubscriberCount` reads.
41
44
 
42
45
  ## Substack's terms
43
46
 
@@ -64,11 +67,20 @@ for templates.
64
67
  | `readFeed` | yes | no | `GET https://<publication>/feed`, for any publication, a custom domain included. `limit` from 1 to 20, default 10. Returns `publication`, `count` and `posts` (`id`, `title`, `subtitle`, `url`, `author`, `publishedAt`, `html`, `text`). |
65
68
  | `searchPosts` | yes | no | `GET substack.com/api/v1/post/search` with `query` and `page` (from 0). Returns `count`, `more` and `posts` (`id`, `title`, `subtitle`, `url`, `publishedAt`, `author`, `authorHandle`, `likes`, `comments`, `publicationId`). |
66
69
  | `readComments` | yes | no | Looks the post up by address or slug (`GET /api/v1/posts/<slug>`) unless given `postId`, then `GET /api/v1/post/<id>/comments?all_comments=true&sort=newest_first`. Returns `postId`, `count` and `comments` (`id`, `body`, `author`, `handle`, `date`, `likes`, `parentId`, `replies`), each reply after the comment it answers. |
70
+ | `listPosts` | yes | no | `GET /api/v1/archive?sort=new&limit=25&offset=<n>` on the publication, a page at a time until a page comes back short or `limit` is reached (1 to 500, default 50). Returns `publication`, `count` and `posts` (`id`, `title`, `subtitle`, `slug`, `url`, `publishedAt`, `audience`). |
71
+ | `getPost` | yes | no | `GET /api/v1/posts/<slug>` for the post given by address, or by slug on the publication. Returns the fields `listPosts` gives plus `wordcount`, `html`, `text`, `likes` and `restacks`. |
67
72
  | `createDraft` | no | yes | `GET substack.com/api/v1/user/profile/self` for the byline, then `POST /api/v1/drafts` on the publication with the markdown body converted to the editor's document. Returns `id`, `title` and `editUrl`. |
73
+ | `updateDraft` | no | yes | `GET substack.com/api/v1/user/profile/self` for the byline, `GET /api/v1/drafts/<id>` to refuse a published post, then `PUT /api/v1/drafts/<id>` with the new title, subtitle and markdown body. Returns `updated`, `id`, `title` and `editUrl`. |
68
74
  | `deleteDraft` | no | yes | `GET`, then `DELETE /api/v1/drafts/<id>`; a published post is refused. Returns `deleted`. |
69
75
  | `commentOnPost` | no | yes | `POST /api/v1/post/<id>/comment` with `{ body }`. Returns `id` and `postId`. |
70
76
  | `setCommentLike` | yes | yes | `POST` to like or `DELETE` to unlike `/api/v1/comment/<id>/reaction`, with `{ reaction: "❤" }`. Returns `liked`. |
71
77
  | `deleteComment` | no | yes | `DELETE /api/v1/comment/<id>`. Returns `deleted`. |
78
+ | `setPostLike` | no | yes | `POST` to like or `DELETE` to unlike `/api/v1/post/<id>/reaction`, with `{ reaction: "❤" }`. Returns `postId` and `liked`. |
79
+ | `setPostRestack` | no | yes | `POST` to restack or `DELETE` to undo `/api/v1/restack/feed`, with `{ postId, commentId: null }`. Posts only. Returns `postId` and `restacked`. |
80
+ | `postNote` | no | yes | `GET substack.com/api/v1/user/profile/self` for the handle, then `POST substack.com/api/v1/comment/feed` with the markdown as the editor's document under `attrs.schemaVersion: "v1"`, plus `tabId: "for-you"`, `surface: "feed"` and `replyMinimumRole: "everyone"`. Returns `id` and `url`. |
81
+ | `readNotes` | yes | yes | Resolves a handle with `GET substack.com/api/v1/user/<handle>/public_profile`, or takes the signed-in account, then follows `nextCursor` through `GET substack.com/api/v1/reader/feed/profile/<userId>` for up to ten pages, keeping the items that are Notes. `limit` from 1 to 100, default 20. Returns `profile`, `count` and `notes` (`id`, `body`, `url`, `date`, `likes`, `restacks`). |
82
+ | `deleteNote` | no | yes | `DELETE substack.com/api/v1/comment/<id>`. Returns `deleted`. |
83
+ | `readSubscriberCount` | yes | yes | `GET /api/v1/publish-dashboard/summary` on your publication. Returns `subscribers` (every subscriber, free and paid, from the summary's `totalEmail`, the count the dashboard shows), `paidSubscribers` (from the summary's `subscribers`, which counts paid ones only), `appSubscribers`, `views` and `openRate`, as the summary reports them. |
72
84
 
73
85
  Writes stay on `*.substack.com`. A publication on a custom domain can be read
74
86
  at its own address, but a comment on one of its posts is refused; write to it
@@ -97,8 +109,8 @@ check skips.
97
109
  ## Built from
98
110
 
99
111
  Substack publishes no API reference. Every request here was made from a
100
- signed-in browser on 2026-09-10 against the author's own publication, and
101
- each write was undone.
112
+ signed-in browser against the author's own publication, and each write was
113
+ undone. On 2026-09-10:
102
114
 
103
115
  - `GET substack.com/api/v1/user/profile/self` answers 200 with `id`, `name`,
104
116
  `handle` and `publicationUsers` when signed in, and 401 when not.
@@ -108,3 +120,19 @@ each write was undone.
108
120
  - `POST /api/v1/drafts` answers 400 `draft_bylines Invalid value` without a
109
121
  byline and 200 with one. A deleted draft then reads back 404.
110
122
  - Commenting, liking and deleting a comment are the requests listed above.
123
+
124
+ On 2026-09-12:
125
+
126
+ - The archive pages with `offset`, and `/api/v1/posts/<slug>` carries the
127
+ whole `body_html`, `reactions` and `restacks`.
128
+ - A Note posted with `POST /api/v1/comment/feed` showed on the profile feed as
129
+ a `comment` item whose `context.type` is `note`, and
130
+ `DELETE substack.com/api/v1/comment/<id>` removed it.
131
+ - Liking a post and restacking it, then taking both back, are the requests
132
+ listed above; the post read back unliked and unrestacked.
133
+ - The dashboard reads its figures with `GET /api/v1/publish-dashboard/summary`.
134
+ Its `subscribers` is the paid count (0 on the author's publication) and its
135
+ `totalEmail` the total (29, the dashboard's "29 subscribers").
136
+ - Then all nine new actions ran through the built package against the same
137
+ publication, and everything was put back. `updateDraft`'s `PUT` changed the
138
+ draft's title, subtitle and body as sent.
package/dist/index.js CHANGED
@@ -1465,7 +1465,7 @@ function markdownToDoc(markdown) {
1465
1465
  var SUBDOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
1466
1466
  function publicationHost(value) {
1467
1467
  const raw = String(value ?? "").trim();
1468
- if (raw === "") throw new Error('publication is required: its substack.com subdomain, such as "novumai"');
1468
+ if (raw === "") throw new Error('publication is required: its substack.com subdomain, such as "exampleletter"');
1469
1469
  if (SUBDOMAIN.test(raw)) return `${raw.toLowerCase()}.substack.com`;
1470
1470
  try {
1471
1471
  return new URL(raw.includes("://") ? raw : `https://${raw}`).hostname.toLowerCase();
@@ -1477,7 +1477,7 @@ function substackHost(value) {
1477
1477
  const host = publicationHost(value);
1478
1478
  if (!host.endsWith(".substack.com")) {
1479
1479
  throw new Error(
1480
- `${host} is a custom domain; use the publication's substack.com address instead, such as novumai.substack.com`
1480
+ `${host} is a custom domain; use the publication's substack.com address instead, such as exampleletter.substack.com`
1481
1481
  );
1482
1482
  }
1483
1483
  return host;
@@ -1485,7 +1485,7 @@ function substackHost(value) {
1485
1485
  function postRef(value) {
1486
1486
  const raw = String(value ?? "").trim();
1487
1487
  const notAPost = new Error(
1488
- `post must be a post's address, such as https://novumai.substack.com/p/its-slug; got "${raw}"`
1488
+ `post must be a post's address, such as https://exampleletter.substack.com/p/its-slug; got "${raw}"`
1489
1489
  );
1490
1490
  let url;
1491
1491
  try {
@@ -1501,8 +1501,8 @@ function postRef(value) {
1501
1501
  // package.json
1502
1502
  var package_default = {
1503
1503
  name: "@vornrun/connector-substack",
1504
- version: "0.1.0",
1505
- description: "Trigger workflows from new posts on a Substack publication, and read, search and comment on posts or save a draft from a step.",
1504
+ version: "0.2.0",
1505
+ description: "Trigger workflows from new posts on a Substack publication, and read, search, like, restack and comment on posts, post and read Notes, or save and update a draft from a step.",
1506
1506
  type: "module",
1507
1507
  license: "MIT",
1508
1508
  author: "Javier Canizalez <javier-canizalez@outlook.com>",
@@ -1558,6 +1558,7 @@ var package_default = {
1558
1558
  "posts",
1559
1559
  "comments",
1560
1560
  "drafts",
1561
+ "notes",
1561
1562
  "writing"
1562
1563
  ],
1563
1564
  auth: "Sign in to Substack in the window Vorn opens; reading a publication's feed and searching posts need no sign-in.",
@@ -1569,11 +1570,20 @@ var package_default = {
1569
1570
  var ORIGINS = ["https://substack.com", "https://*.substack.com"];
1570
1571
  var PROFILE_URL = "https://substack.com/api/v1/user/profile/self";
1571
1572
  var SEARCH_URL = "https://substack.com/api/v1/post/search";
1573
+ var NOTES_URL = "https://substack.com/api/v1/comment/feed";
1572
1574
  var MAX_FEED_POSTS = 20;
1573
1575
  var DEFAULT_FEED_POSTS = 10;
1576
+ var MAX_POSTS = 500;
1577
+ var DEFAULT_POSTS = 50;
1578
+ var MAX_NOTES = 100;
1579
+ var DEFAULT_NOTES = 20;
1580
+ var ARCHIVE_PAGE = 25;
1581
+ var NOTE_PAGES = 10;
1574
1582
  var REACTION = "\u2764";
1575
1583
  var SLUG = /^[a-z0-9][a-z0-9-]*$/i;
1584
+ var HANDLE = /^[\w.-]+$/;
1576
1585
  var NEVER = /publish|schedul/i;
1586
+ var DASHBOARD = /^\/api\/v1\/publish-dashboard\//;
1577
1587
  function text2(value) {
1578
1588
  const trimmed = String(value ?? "").trim();
1579
1589
  return trimmed || void 0;
@@ -1610,8 +1620,10 @@ async function call(fetchImpl, url, init = {}) {
1610
1620
  }
1611
1621
  function neverPublishing(fetchImpl) {
1612
1622
  return ((input, init) => {
1613
- const { pathname } = new URL(input instanceof Request ? input.url : String(input));
1614
- if (NEVER.test(pathname)) {
1623
+ const request = input instanceof Request ? input : void 0;
1624
+ const { pathname } = new URL(request ? request.url : String(input));
1625
+ const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
1626
+ if (NEVER.test(pathname) && !(method === "GET" && DASHBOARD.test(pathname))) {
1615
1627
  return Promise.reject(new Error(`Refused ${pathname}: this connector never publishes or schedules`));
1616
1628
  }
1617
1629
  return fetchImpl(input, init);
@@ -1639,15 +1651,18 @@ async function readPosts(fetchImpl, host) {
1639
1651
  if (!res.ok) throw new Error(`GET /feed on ${host} answered ${res.status}`);
1640
1652
  return parseFeed(await res.text());
1641
1653
  }
1642
- function feedLimit(value) {
1654
+ function countLimit(value, fallback, max) {
1643
1655
  const raw = text2(value);
1644
- if (raw === void 0) return DEFAULT_FEED_POSTS;
1656
+ if (raw === void 0) return fallback;
1645
1657
  const parsed = Number(raw);
1646
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_FEED_POSTS) {
1647
- throw new Error(`limit must be a whole number from 1 to ${MAX_FEED_POSTS}`);
1658
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {
1659
+ throw new Error(`limit must be a whole number from 1 to ${max}`);
1648
1660
  }
1649
1661
  return parsed;
1650
1662
  }
1663
+ function feedLimit(value) {
1664
+ return countLimit(value, DEFAULT_FEED_POSTS, MAX_FEED_POSTS);
1665
+ }
1651
1666
  function wholeId(value, name) {
1652
1667
  const parsed = Number(text2(value));
1653
1668
  if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a Substack id, a whole number`);
@@ -1710,11 +1725,54 @@ function flattenComments(comments) {
1710
1725
  return [record, ...flattenComments(comment.children)];
1711
1726
  });
1712
1727
  }
1728
+ function listedPost(post) {
1729
+ return {
1730
+ id: Number(post.id ?? 0),
1731
+ title: String(post.title ?? ""),
1732
+ subtitle: String(post.subtitle ?? ""),
1733
+ slug: String(post.slug ?? ""),
1734
+ url: String(post.canonical_url ?? ""),
1735
+ publishedAt: String(post.post_date ?? ""),
1736
+ audience: String(post.audience ?? "")
1737
+ };
1738
+ }
1739
+ function likesOf(entry) {
1740
+ return Number(entry.reaction_count ?? entry.reactions?.[REACTION] ?? 0);
1741
+ }
1742
+ function noteUrl(handle, id) {
1743
+ return `https://substack.com/@${handle}/note/c-${id}`;
1744
+ }
1745
+ function noteRecord(note, handle) {
1746
+ const id = Number(note.id ?? 0);
1747
+ return {
1748
+ id,
1749
+ body: String(note.body ?? ""),
1750
+ url: noteUrl(text2(note.handle) ?? handle, id),
1751
+ date: String(note.date ?? ""),
1752
+ likes: likesOf(note),
1753
+ restacks: Number(note.restacks ?? 0)
1754
+ };
1755
+ }
1756
+ async function unpublishedDraft(session, host, id, verb) {
1757
+ const draft = await call(session.fetch, `https://${host}/api/v1/drafts/${id}`);
1758
+ if (draft.is_published === true) {
1759
+ throw new Error(`${id} is a published post, not a draft; this action ${verb} only drafts`);
1760
+ }
1761
+ }
1762
+ function draftFields(me2, title, subtitle, body) {
1763
+ return {
1764
+ draft_title: title,
1765
+ draft_subtitle: text2(subtitle) ?? "",
1766
+ draft_body: JSON.stringify(markdownToDoc(body)),
1767
+ // Substack refuses a draft without a byline and says so; only a profile with no id leaves it out.
1768
+ ...typeof me2.id === "number" && { draft_bylines: [{ id: me2.id, is_guest: false }] }
1769
+ };
1770
+ }
1713
1771
  var SAMPLE_POST = {
1714
- id: "https://novumai.substack.com/p/anthropic-passed-openai-this-week",
1715
- title: "Anthropic passed OpenAI this week. The $65 billion shows what the lead costs.",
1772
+ id: "https://exampleletter.substack.com/p/the-weekly-letter",
1773
+ title: "A weekly letter about the week in software.",
1716
1774
  subtitle: "Both moves are really about one question: who can pay for compute.",
1717
- url: "https://novumai.substack.com/p/anthropic-passed-openai-this-week",
1775
+ url: "https://exampleletter.substack.com/p/the-weekly-letter",
1718
1776
  author: "Javier Canizalez",
1719
1777
  publishedAt: "2026-05-29T20:03:32.000Z",
1720
1778
  html: "<p>Both moves are really about one question.</p>",
@@ -1750,7 +1808,7 @@ var connector = defineConnector({
1750
1808
  id: "substack",
1751
1809
  name: "Substack",
1752
1810
  version: package_default.version,
1753
- description: "Trigger workflows from new posts on a Substack publication, and read, search and comment on posts or save a draft from a step.",
1811
+ description: "Trigger workflows from new posts on a Substack publication, and read, search, like, restack and comment on posts, post and read Notes, or save and update a draft from a step.",
1754
1812
  icon: {
1755
1813
  viewBox: "0 0 24 24",
1756
1814
  paths: [
@@ -1770,7 +1828,7 @@ var connector = defineConnector({
1770
1828
  key: "publication",
1771
1829
  label: "Publication",
1772
1830
  env: "SUBSTACK_PUBLICATION",
1773
- description: "Your publication's substack.com subdomain, such as novumai. The new-post trigger reads its feed, and a step that names no publication uses it.",
1831
+ description: "Your publication's substack.com subdomain, such as exampleletter. The new-post trigger reads its feed, and a step that names no publication uses it.",
1774
1832
  builderHint: "The part before .substack.com in the publication address; a custom domain works for reading only."
1775
1833
  }
1776
1834
  ],
@@ -1878,6 +1936,79 @@ var connector = defineConnector({
1878
1936
  return { postId: id, count: comments.length, comments };
1879
1937
  }
1880
1938
  },
1939
+ {
1940
+ type: "listPosts",
1941
+ label: "List posts",
1942
+ description: `A publication's archive, newest first, up to ${MAX_POSTS} posts, where the feed holds only the last twenty. Needs no sign-in.`,
1943
+ idempotent: true,
1944
+ inputs: [
1945
+ PUBLICATION_INPUT,
1946
+ {
1947
+ key: "limit",
1948
+ label: "Posts",
1949
+ type: "number",
1950
+ description: `How many of the newest posts, from 1 to ${MAX_POSTS}; ${DEFAULT_POSTS} when empty.`
1951
+ }
1952
+ ],
1953
+ outputs: [
1954
+ { key: "publication", type: "string", description: "The host the archive was read from" },
1955
+ { key: "count", type: "number", description: "How many posts came back, in `posts`" }
1956
+ ],
1957
+ run: async (args, ctx) => {
1958
+ const host = publicationHost(stepPublication(args.publication, ctx.config));
1959
+ const limit = countLimit(args.limit, DEFAULT_POSTS, MAX_POSTS);
1960
+ const posts = [];
1961
+ for (let offset = 0; posts.length < limit; offset += ARCHIVE_PAGE) {
1962
+ const page = await call(
1963
+ ctx.fetch,
1964
+ `https://${host}/api/v1/archive?sort=new&limit=${ARCHIVE_PAGE}&offset=${offset}`
1965
+ );
1966
+ const rows = Array.isArray(page) ? page : [];
1967
+ posts.push(...rows);
1968
+ if (rows.length < ARCHIVE_PAGE) break;
1969
+ }
1970
+ const listed = posts.slice(0, limit).map(listedPost);
1971
+ return { publication: host, count: listed.length, posts: listed };
1972
+ }
1973
+ },
1974
+ {
1975
+ type: "getPost",
1976
+ label: "Get post",
1977
+ description: "One post with its whole body as HTML and as text, and its likes and restacks. Needs no sign-in for a public post.",
1978
+ idempotent: true,
1979
+ inputs: [
1980
+ {
1981
+ key: "post",
1982
+ label: "Post",
1983
+ required: true,
1984
+ description: "The post's address, or its slug on the publication."
1985
+ },
1986
+ PUBLICATION_INPUT
1987
+ ],
1988
+ outputs: [
1989
+ { key: "id", type: "number", description: "The post's id" },
1990
+ { key: "title", type: "string", description: "The post's title" },
1991
+ { key: "url", type: "string", description: "The post's address" },
1992
+ { key: "text", type: "string", description: "The body as plain text" }
1993
+ ],
1994
+ run: async (args, ctx) => {
1995
+ const raw = text2(args.post);
1996
+ if (raw === void 0) throw new Error("post is required");
1997
+ const host = postHost(raw, stepPublication(args.publication, ctx.config));
1998
+ const slug = raw.includes("/") ? postRef(raw).slug : raw;
1999
+ if (!SLUG.test(slug)) throw new Error(`"${slug}" is not a post slug`);
2000
+ const post = await call(ctx.fetch, `https://${host}/api/v1/posts/${slug}`);
2001
+ const html = String(post.body_html ?? "");
2002
+ return {
2003
+ ...listedPost(post),
2004
+ wordcount: Number(post.wordcount ?? 0),
2005
+ html,
2006
+ text: plainText(html),
2007
+ likes: likesOf(post),
2008
+ restacks: Number(post.restacks ?? 0)
2009
+ };
2010
+ }
2011
+ },
1881
2012
  {
1882
2013
  type: "createDraft",
1883
2014
  label: "Save draft",
@@ -1909,11 +2040,7 @@ var connector = defineConnector({
1909
2040
  const draft = await call(session.fetch, `https://${host}/api/v1/drafts`, {
1910
2041
  method: "POST",
1911
2042
  body: {
1912
- draft_title: title,
1913
- draft_subtitle: text2(args.subtitle) ?? "",
1914
- draft_body: JSON.stringify(markdownToDoc(body)),
1915
- // Substack refuses a draft without a byline and says so; only a profile with no id leaves it out.
1916
- ...typeof me2.id === "number" && { draft_bylines: [{ id: me2.id, is_guest: false }] },
2043
+ ...draftFields(me2, title, args.subtitle, body),
1917
2044
  type: "newsletter",
1918
2045
  audience: "everyone",
1919
2046
  section_chosen: false,
@@ -1924,6 +2051,51 @@ var connector = defineConnector({
1924
2051
  return { title, ...id !== void 0 && { id, editUrl: `https://${host}/publish/post/${id}` } };
1925
2052
  }
1926
2053
  },
2054
+ {
2055
+ type: "updateDraft",
2056
+ label: "Update draft",
2057
+ description: "Replace a draft's title, subtitle and body with new markdown. A published post is refused, and it never publishes.",
2058
+ idempotent: false,
2059
+ inputs: [
2060
+ {
2061
+ key: "draftId",
2062
+ label: "Draft id",
2063
+ type: "number",
2064
+ required: true,
2065
+ description: "The id Save draft returned."
2066
+ },
2067
+ { key: "title", label: "Title", required: true, description: "The draft's title." },
2068
+ { key: "subtitle", label: "Subtitle", description: "The line under the title; empty leaves the draft without one." },
2069
+ {
2070
+ key: "body",
2071
+ label: "Body",
2072
+ required: true,
2073
+ description: "The whole post in markdown, replacing what the draft held."
2074
+ },
2075
+ { ...OWN_PUBLICATION_INPUT, type: "select", loadOptions: "publications" }
2076
+ ],
2077
+ outputs: [
2078
+ { key: "updated", type: "boolean", description: "Whether the draft was saved" },
2079
+ { key: "id", type: "number", description: "The draft's id" },
2080
+ { key: "editUrl", type: "string", description: "Where to open the draft in the Substack editor" }
2081
+ ],
2082
+ run: async (args, ctx) => {
2083
+ const session = signedIn(ctx.session);
2084
+ const id = wholeId(args.draftId, "draftId");
2085
+ const title = text2(args.title);
2086
+ const body = text2(args.body);
2087
+ if (title === void 0) throw new Error("title is required");
2088
+ if (body === void 0) throw new Error("body is required");
2089
+ const me2 = await call(session.fetch, PROFILE_URL);
2090
+ const host = namedPublication(args.publication, ctx.config) ?? primaryPublication(me2);
2091
+ await unpublishedDraft(session, host, id, "updates");
2092
+ await call(session.fetch, `https://${host}/api/v1/drafts/${id}`, {
2093
+ method: "PUT",
2094
+ body: draftFields(me2, title, args.subtitle, body)
2095
+ });
2096
+ return { updated: true, id, title, editUrl: `https://${host}/publish/post/${id}` };
2097
+ }
2098
+ },
1927
2099
  {
1928
2100
  type: "deleteDraft",
1929
2101
  label: "Delete draft",
@@ -1944,10 +2116,7 @@ var connector = defineConnector({
1944
2116
  const session = signedIn(ctx.session);
1945
2117
  const id = wholeId(args.draftId, "draftId");
1946
2118
  const host = await ownPublication(args.publication, ctx.config, session);
1947
- const draft = await call(session.fetch, `https://${host}/api/v1/drafts/${id}`);
1948
- if (draft.is_published === true) {
1949
- throw new Error(`${id} is a published post, not a draft; this action deletes only drafts`);
1950
- }
2119
+ await unpublishedDraft(session, host, id, "deletes");
1951
2120
  await call(session.fetch, `https://${host}/api/v1/drafts/${id}`, { method: "DELETE" });
1952
2121
  return { deleted: true, id };
1953
2122
  }
@@ -2038,6 +2207,208 @@ var connector = defineConnector({
2038
2207
  await call(session.fetch, `https://${host}/api/v1/comment/${id}`, { method: "DELETE" });
2039
2208
  return { deleted: true, commentId: id };
2040
2209
  }
2210
+ },
2211
+ {
2212
+ type: "setPostLike",
2213
+ label: "Like or unlike post",
2214
+ description: "Like a post as the signed-in account, or take the like back.",
2215
+ idempotent: false,
2216
+ inputs: [
2217
+ ...POST_INPUTS,
2218
+ {
2219
+ key: "liked",
2220
+ label: "Liked",
2221
+ type: "boolean",
2222
+ required: true,
2223
+ description: "true to like it, false to take the like back."
2224
+ },
2225
+ PUBLICATION_INPUT
2226
+ ],
2227
+ outputs: [{ key: "liked", type: "boolean", description: "The state now set" }],
2228
+ run: async (args, ctx) => {
2229
+ const session = signedIn(ctx.session);
2230
+ const liked = args.liked === true;
2231
+ const publication = stepPublication(args.publication, ctx.config);
2232
+ const host = substackHost(postHost(args.post, publication));
2233
+ const { id } = await resolvePost(ctx.fetch, args.post, args.postId, publication);
2234
+ await call(session.fetch, `https://${host}/api/v1/post/${id}/reaction`, {
2235
+ method: liked ? "POST" : "DELETE",
2236
+ body: { reaction: REACTION }
2237
+ });
2238
+ return { postId: id, liked };
2239
+ }
2240
+ },
2241
+ {
2242
+ type: "setPostRestack",
2243
+ label: "Restack or undo restack post",
2244
+ description: "Restack a post to the signed-in account's followers, or take the restack back. Posts only, not Notes.",
2245
+ idempotent: false,
2246
+ inputs: [
2247
+ ...POST_INPUTS,
2248
+ {
2249
+ key: "restacked",
2250
+ label: "Restacked",
2251
+ type: "boolean",
2252
+ required: true,
2253
+ description: "true to restack it, false to take the restack back."
2254
+ },
2255
+ PUBLICATION_INPUT
2256
+ ],
2257
+ outputs: [{ key: "restacked", type: "boolean", description: "The state now set" }],
2258
+ run: async (args, ctx) => {
2259
+ const session = signedIn(ctx.session);
2260
+ const restacked = args.restacked === true;
2261
+ const publication = stepPublication(args.publication, ctx.config);
2262
+ const host = substackHost(postHost(args.post, publication));
2263
+ const { id } = await resolvePost(ctx.fetch, args.post, args.postId, publication);
2264
+ await call(session.fetch, `https://${host}/api/v1/restack/feed`, {
2265
+ method: restacked ? "POST" : "DELETE",
2266
+ body: { postId: id, commentId: null }
2267
+ });
2268
+ return { postId: id, restacked };
2269
+ }
2270
+ },
2271
+ {
2272
+ type: "postNote",
2273
+ label: "Post a note",
2274
+ description: "Post a Note as the signed-in account. Followers see it in their feeds as soon as it posts; Delete note takes it down.",
2275
+ idempotent: false,
2276
+ inputs: [
2277
+ {
2278
+ key: "body",
2279
+ label: "Note",
2280
+ required: true,
2281
+ description: "The Note in markdown, converted the way a draft's body is."
2282
+ }
2283
+ ],
2284
+ outputs: [
2285
+ { key: "id", type: "number", description: "The Note's id" },
2286
+ { key: "url", type: "string", description: "The Note's address" }
2287
+ ],
2288
+ run: async (args, ctx) => {
2289
+ const session = signedIn(ctx.session);
2290
+ const body = text2(args.body);
2291
+ if (body === void 0) throw new Error("body is required");
2292
+ const me2 = await call(session.fetch, PROFILE_URL);
2293
+ const note = await call(session.fetch, NOTES_URL, {
2294
+ method: "POST",
2295
+ body: {
2296
+ bodyJson: { ...markdownToDoc(body), attrs: { schemaVersion: "v1" } },
2297
+ tabId: "for-you",
2298
+ surface: "feed",
2299
+ replyMinimumRole: "everyone"
2300
+ }
2301
+ });
2302
+ if (typeof note.id !== "number") return {};
2303
+ const handle = text2(me2.handle);
2304
+ return { id: note.id, ...handle !== void 0 && { url: noteUrl(handle, note.id) } };
2305
+ }
2306
+ },
2307
+ {
2308
+ type: "readNotes",
2309
+ label: "Read notes",
2310
+ description: `A profile's Notes, newest first, up to ${MAX_NOTES}, from its activity feed as the signed-in account sees it.`,
2311
+ idempotent: true,
2312
+ inputs: [
2313
+ {
2314
+ key: "profile",
2315
+ label: "Profile",
2316
+ description: "A Substack handle, with or without @; the signed-in account when empty."
2317
+ },
2318
+ {
2319
+ key: "limit",
2320
+ label: "Notes",
2321
+ type: "number",
2322
+ description: `How many of the newest Notes, from 1 to ${MAX_NOTES}; ${DEFAULT_NOTES} when empty.`
2323
+ }
2324
+ ],
2325
+ outputs: [
2326
+ { key: "profile", type: "string", description: "The handle the Notes are from" },
2327
+ { key: "count", type: "number", description: "How many Notes came back, in `notes`" }
2328
+ ],
2329
+ run: async (args, ctx) => {
2330
+ const session = signedIn(ctx.session);
2331
+ const limit = countLimit(args.limit, DEFAULT_NOTES, MAX_NOTES);
2332
+ const named = text2(args.profile)?.replace(/^@/, "");
2333
+ if (named !== void 0 && !HANDLE.test(named)) throw new Error(`"${named}" is not a Substack handle`);
2334
+ const who = await call(
2335
+ session.fetch,
2336
+ named === void 0 ? PROFILE_URL : `https://substack.com/api/v1/user/${named}/public_profile`
2337
+ );
2338
+ const handle = text2(who.handle) ?? named ?? "";
2339
+ const notes = [];
2340
+ let cursor;
2341
+ for (let page = 0; typeof who.id === "number" && page < NOTE_PAGES && notes.length < limit; page++) {
2342
+ const url = new URL(`https://substack.com/api/v1/reader/feed/profile/${who.id}`);
2343
+ if (cursor !== void 0) url.searchParams.set("cursor", cursor);
2344
+ const feed = await call(session.fetch, url.href);
2345
+ for (const entry of feed.items ?? []) {
2346
+ if (entry.type === "comment" && entry.context?.type === "note" && entry.comment) {
2347
+ notes.push(noteRecord(entry.comment, handle));
2348
+ }
2349
+ }
2350
+ cursor = text2(feed.nextCursor);
2351
+ if (cursor === void 0) break;
2352
+ }
2353
+ const kept = notes.slice(0, limit);
2354
+ return { profile: handle, count: kept.length, notes: kept };
2355
+ }
2356
+ },
2357
+ {
2358
+ type: "deleteNote",
2359
+ label: "Delete note",
2360
+ description: "Delete one of the signed-in account's Notes.",
2361
+ idempotent: false,
2362
+ inputs: [
2363
+ {
2364
+ key: "noteId",
2365
+ label: "Note id",
2366
+ type: "number",
2367
+ required: true,
2368
+ description: "The id Post a note or Read notes returned."
2369
+ }
2370
+ ],
2371
+ outputs: [{ key: "deleted", type: "boolean", description: "Whether the Note was deleted" }],
2372
+ run: async (args, ctx) => {
2373
+ const session = signedIn(ctx.session);
2374
+ const id = wholeId(args.noteId, "noteId");
2375
+ await call(session.fetch, `https://substack.com/api/v1/comment/${id}`, { method: "DELETE" });
2376
+ return { deleted: true, noteId: id };
2377
+ }
2378
+ },
2379
+ {
2380
+ type: "readSubscriberCount",
2381
+ label: "Read subscriber count",
2382
+ description: "Your publication's subscriber, email and view figures, as its dashboard shows them. Needs the signed-in account to run the publication.",
2383
+ idempotent: true,
2384
+ inputs: [{ ...OWN_PUBLICATION_INPUT, type: "select", loadOptions: "publications" }],
2385
+ outputs: [
2386
+ {
2387
+ key: "subscribers",
2388
+ type: "number",
2389
+ description: "Every subscriber, free and paid: the count the dashboard's subscribers page shows"
2390
+ },
2391
+ { key: "paidSubscribers", type: "number", description: "Paid subscribers only" },
2392
+ { key: "appSubscribers", type: "number", description: "The dashboard summary\u2019s app subscriber figure" },
2393
+ { key: "views", type: "number", description: "The dashboard summary\u2019s view count" },
2394
+ { key: "openRate", type: "number", description: "Email open rate, as the dashboard summary reports it" }
2395
+ ],
2396
+ run: async (args, ctx) => {
2397
+ const session = signedIn(ctx.session);
2398
+ const host = await ownPublication(args.publication, ctx.config, session);
2399
+ const summary = await call(
2400
+ session.fetch,
2401
+ `https://${host}/api/v1/publish-dashboard/summary`
2402
+ );
2403
+ return {
2404
+ publication: host,
2405
+ subscribers: Number(summary.totalEmail ?? 0),
2406
+ paidSubscribers: Number(summary.subscribers ?? 0),
2407
+ appSubscribers: Number(summary.appSubscribers ?? 0),
2408
+ views: Number(summary.views ?? 0),
2409
+ openRate: Number(summary.openRate ?? 0)
2410
+ };
2411
+ }
2041
2412
  }
2042
2413
  ]
2043
2414
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vornrun/connector-substack",
3
- "version": "0.1.0",
4
- "description": "Trigger workflows from new posts on a Substack publication, and read, search and comment on posts or save a draft from a step.",
3
+ "version": "0.2.0",
4
+ "description": "Trigger workflows from new posts on a Substack publication, and read, search, like, restack and comment on posts, post and read Notes, or save and update a draft from a step.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Javier Canizalez <javier-canizalez@outlook.com>",
@@ -57,6 +57,7 @@
57
57
  "posts",
58
58
  "comments",
59
59
  "drafts",
60
+ "notes",
60
61
  "writing"
61
62
  ],
62
63
  "auth": "Sign in to Substack in the window Vorn opens; reading a publication's feed and searching posts need no sign-in.",