astro-buffalo 0.2.0 → 0.3.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 +5 -4
- package/dist/git.d.ts +3 -0
- package/dist/git.js +43 -0
- package/dist/index.js +23 -205
- package/dist/publish.d.ts +14 -0
- package/dist/publish.js +175 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,11 +62,12 @@ The endpoint must return a JSON array with these fields on each article:
|
|
|
62
62
|
| --- | --- | --- |
|
|
63
63
|
| `filePath` | Yes | Source file path relative to the Astro project root, using forward slashes; no absolute paths or `.`/`..` segments. Must match the Git path, e.g. `src/content/blog/hello.md`. |
|
|
64
64
|
| `title` | Yes | Non-empty article title, also used as the default announcement. |
|
|
65
|
-
| `url` | Yes | Absolute HTTP(S) canonical production URL, without whitespace
|
|
65
|
+
| `url` | Yes | Absolute HTTP(S) canonical production URL, without whitespace or a fragment. Must match the deployed URL without a redirect. |
|
|
66
66
|
| `text` | No | Non-empty custom announcement text; the article URL is always appended. |
|
|
67
67
|
| `mode` | No | `shareNow` or `addToQueue`; overrides the integration's default for this article. |
|
|
68
|
+
| `channelIds` | No | Non-empty array of Buffer channel IDs; overrides the integration's selection for this article. |
|
|
68
69
|
|
|
69
|
-
An empty array is valid; invalid entries stop the build before any Buffer requests.
|
|
70
|
+
An empty array is valid; invalid entries or duplicate URLs stop the build before any Buffer requests.
|
|
70
71
|
Adapt the collection name and routes to your site. Include only public articles, filtering drafts
|
|
71
72
|
and future dates if your schema defines them:
|
|
72
73
|
|
|
@@ -106,8 +107,8 @@ mode: "addToQueue",
|
|
|
106
107
|
`mode` supports `shareNow` (default) and `addToQueue`; both use automatic delivery.
|
|
107
108
|
Disconnected or locked channels are reported and skipped.
|
|
108
109
|
|
|
109
|
-
For different choices per article, return `mode` in each manifest entry.
|
|
110
|
-
|
|
110
|
+
For different choices per article, return `mode` and/or `channelIds` in each manifest entry.
|
|
111
|
+
Articles without these fields use the integration settings.
|
|
111
112
|
|
|
112
113
|
Announcements use `title + "\n\n" + url`. Add `text: post.data.description` to an article's manifest
|
|
113
114
|
entry for custom copy; the content item's title stays unchanged. Custom text is trimmed and must
|
package/dist/git.d.ts
ADDED
package/dist/git.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
function isDraft(source) {
|
|
5
|
+
const lines = (source.startsWith("\uFEFF") ? source.slice(1) : source).split("\n");
|
|
6
|
+
if (lines.length < 2 || lines[0].trimEnd() !== "---")
|
|
7
|
+
return false;
|
|
8
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trimEnd() === "---");
|
|
9
|
+
if (end === -1)
|
|
10
|
+
throw new Error("Missing closing YAML frontmatter delimiter.");
|
|
11
|
+
const data = parse(lines.slice(1, end).join("\n"));
|
|
12
|
+
return typeof data === "object" && data !== null && "draft" in data && data.draft === true;
|
|
13
|
+
}
|
|
14
|
+
export function findNewArticles(articles, root, contentDirectory) {
|
|
15
|
+
const base = process.env.BUFFER_BASE_REF;
|
|
16
|
+
if (!base || !/^[a-f0-9]{40}$/i.test(base) || /^0+$/.test(base)) {
|
|
17
|
+
throw new Error("Set BUFFER_BASE_REF to the full commit SHA before the new posts.");
|
|
18
|
+
}
|
|
19
|
+
const git = (...args) => execFileSync("git", args, { cwd: fileURLToPath(root), encoding: "utf8" });
|
|
20
|
+
const changes = git("diff", "--name-status", "--relative", "-z", "--diff-filter=AMR", "--find-renames", base, "HEAD", "--", contentDirectory)
|
|
21
|
+
.split("\0")
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
const previousPaths = new Map();
|
|
24
|
+
for (let index = 0; index < changes.length;) {
|
|
25
|
+
const status = changes[index++];
|
|
26
|
+
const path = changes[index++];
|
|
27
|
+
if (status.startsWith("R")) {
|
|
28
|
+
previousPaths.set(changes[index++], path);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
previousPaths.set(path, status === "A" ? undefined : path);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return articles.filter(({ filePath }) => {
|
|
35
|
+
if (!previousPaths.has(filePath))
|
|
36
|
+
return false;
|
|
37
|
+
const previousPath = previousPaths.get(filePath);
|
|
38
|
+
if (previousPath && !isDraft(git("show", `${base}:./${previousPath}`))) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return !isDraft(git("show", `HEAD:./${filePath}`));
|
|
42
|
+
});
|
|
43
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
1
|
import { readFile, unlink } from "node:fs/promises";
|
|
3
|
-
import timers from "node:timers/promises";
|
|
4
2
|
import { fileURLToPath } from "node:url";
|
|
5
3
|
import { z } from "astro/zod";
|
|
6
|
-
import {
|
|
7
|
-
|
|
4
|
+
import { findNewArticles } from "./git.js";
|
|
5
|
+
import { publishPosts } from "./publish.js";
|
|
6
|
+
const articlesSchema = z
|
|
7
|
+
.array(z.object({
|
|
8
8
|
filePath: z
|
|
9
9
|
.string()
|
|
10
10
|
.refine((path) => path.trim() &&
|
|
@@ -17,76 +17,31 @@ const articlesSchema = z.array(z.object({
|
|
|
17
17
|
if (/\s/.test(value) || !URL.canParse(value))
|
|
18
18
|
return false;
|
|
19
19
|
const url = new URL(value);
|
|
20
|
-
return
|
|
21
|
-
}, "url must be an absolute HTTP(S) URL without whitespace
|
|
20
|
+
return ["http:", "https:"].includes(url.protocol) && !url.hash;
|
|
21
|
+
}, "url must be an absolute HTTP(S) URL without whitespace or a fragment."),
|
|
22
22
|
text: z
|
|
23
23
|
.string({ message: "announcement text must be a non-empty string." })
|
|
24
24
|
.refine((text) => text.trim(), "announcement text must be a non-empty string.")
|
|
25
25
|
.optional(),
|
|
26
26
|
mode: z.enum(["shareNow", "addToQueue"]).optional(),
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
while (Date.now() < deadline) {
|
|
41
|
-
let response;
|
|
42
|
-
try {
|
|
43
|
-
response = await fetch(url, {
|
|
44
|
-
method: "HEAD",
|
|
45
|
-
signal: AbortSignal.timeout(10_000),
|
|
27
|
+
channelIds: z
|
|
28
|
+
.array(z.string().refine((id) => id.trim(), "channel ID must be non-empty."))
|
|
29
|
+
.min(1)
|
|
30
|
+
.optional(),
|
|
31
|
+
}, { message: "must be an object." }), { message: "Buffer manifest must be an array of articles." })
|
|
32
|
+
.superRefine((articles, context) => {
|
|
33
|
+
const urls = new Set();
|
|
34
|
+
for (const [index, { url }] of articles.entries()) {
|
|
35
|
+
if (urls.has(url)) {
|
|
36
|
+
context.addIssue({
|
|
37
|
+
code: "custom",
|
|
38
|
+
path: [index, "url"],
|
|
39
|
+
message: `Duplicate article URL: ${url}.`,
|
|
46
40
|
});
|
|
47
41
|
}
|
|
48
|
-
|
|
49
|
-
if (!(error instanceof TypeError) &&
|
|
50
|
-
!(error instanceof Error && error.name === "TimeoutError")) {
|
|
51
|
-
throw error;
|
|
52
|
-
}
|
|
53
|
-
logger.warn(`Cannot reach ${url}: ${error.message}. Retrying.`);
|
|
54
|
-
}
|
|
55
|
-
if (response) {
|
|
56
|
-
if (response.ok && response.url === url)
|
|
57
|
-
return;
|
|
58
|
-
if (![404, 502, 503, 504].includes(response.status)) {
|
|
59
|
-
throw new Error(`Cannot verify ${url}: HTTP ${response.status}.`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
logger.info(`Waiting for ${url} to be deployed...`);
|
|
63
|
-
await timers.setTimeout(10_000);
|
|
64
|
-
}
|
|
65
|
-
throw new Error(`${url} is not live yet. Rerun after deployment succeeds.`);
|
|
66
|
-
}
|
|
67
|
-
async function request(token, query, variables) {
|
|
68
|
-
const response = await fetch("https://api.buffer.com", {
|
|
69
|
-
method: "POST",
|
|
70
|
-
headers: {
|
|
71
|
-
Authorization: `Bearer ${token}`,
|
|
72
|
-
"Content-Type": "application/json",
|
|
73
|
-
},
|
|
74
|
-
body: JSON.stringify({ query, variables }),
|
|
75
|
-
signal: AbortSignal.timeout(30_000),
|
|
76
|
-
});
|
|
77
|
-
if (!response.ok) {
|
|
78
|
-
const retryAfter = response.headers.get("Retry-After");
|
|
79
|
-
throw new Error(`Buffer request failed: HTTP ${response.status}.${retryAfter ? ` Retry after ${retryAfter} seconds.` : ""}`);
|
|
80
|
-
}
|
|
81
|
-
const result = await response.json();
|
|
82
|
-
if (result.errors?.length) {
|
|
83
|
-
throw new Error(`Buffer: ${result.errors.map((error) => error.message).join("; ")}`);
|
|
42
|
+
urls.add(url);
|
|
84
43
|
}
|
|
85
|
-
|
|
86
|
-
throw new Error("Buffer returned no data.");
|
|
87
|
-
}
|
|
88
|
-
return result.data;
|
|
89
|
-
}
|
|
44
|
+
});
|
|
90
45
|
export default function buffer({ posts: entrypoint, contentDirectory, channelIds, mode = "shareNow", }) {
|
|
91
46
|
if (mode !== "shareNow" && mode !== "addToQueue") {
|
|
92
47
|
throw new Error('mode must be "shareNow" or "addToQueue".');
|
|
@@ -132,36 +87,7 @@ export default function buffer({ posts: entrypoint, contentDirectory, channelIds
|
|
|
132
87
|
.join("\n"));
|
|
133
88
|
}
|
|
134
89
|
const articles = result.data;
|
|
135
|
-
const
|
|
136
|
-
if (!base || !/^[a-f0-9]{40}$/i.test(base) || /^0+$/.test(base)) {
|
|
137
|
-
throw new Error("Set BUFFER_BASE_REF to the full commit SHA before the new posts.");
|
|
138
|
-
}
|
|
139
|
-
const git = (...args) => execFileSync("git", args, { cwd: fileURLToPath(root), encoding: "utf8" });
|
|
140
|
-
const changes = git("diff", "--name-status", "--relative", "-z", "--diff-filter=AMR", "--find-renames", base, "HEAD", "--", contentDirectory)
|
|
141
|
-
.split("\0")
|
|
142
|
-
.filter(Boolean);
|
|
143
|
-
const previousPaths = new Map();
|
|
144
|
-
for (let index = 0; index < changes.length;) {
|
|
145
|
-
const status = changes[index++];
|
|
146
|
-
const path = changes[index++];
|
|
147
|
-
if (status.startsWith("R")) {
|
|
148
|
-
previousPaths.set(changes[index++], path);
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
previousPaths.set(path, status === "A" ? undefined : path);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const posts = articles
|
|
155
|
-
.filter(({ filePath }) => {
|
|
156
|
-
if (!previousPaths.has(filePath))
|
|
157
|
-
return false;
|
|
158
|
-
const previousPath = previousPaths.get(filePath);
|
|
159
|
-
if (previousPath && !isDraft(git("show", `${base}:./${previousPath}`))) {
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
return !isDraft(git("show", `HEAD:./${filePath}`));
|
|
163
|
-
})
|
|
164
|
-
.map((article) => ({
|
|
90
|
+
const posts = findNewArticles(articles, root, contentDirectory).map((article) => ({
|
|
165
91
|
...article,
|
|
166
92
|
text: `${article.text?.trim() ?? article.title}\n\n${article.url}`,
|
|
167
93
|
mode: article.mode ?? mode,
|
|
@@ -170,115 +96,7 @@ export default function buffer({ posts: entrypoint, contentDirectory, channelIds
|
|
|
170
96
|
logger.info("No new blog posts to share.");
|
|
171
97
|
return;
|
|
172
98
|
}
|
|
173
|
-
|
|
174
|
-
const organizationId = process.env.BUFFER_ORGANIZATION_ID;
|
|
175
|
-
if (!token || !organizationId) {
|
|
176
|
-
throw new Error("Set BUFFER_API_KEY and BUFFER_ORGANIZATION_ID before publishing.");
|
|
177
|
-
}
|
|
178
|
-
const { channels } = await request(token, `query Channels($input: ChannelsInput!) {
|
|
179
|
-
channels(input: $input) { id name isDisconnected isLocked }
|
|
180
|
-
}`, { input: { organizationId } });
|
|
181
|
-
if (channelIds) {
|
|
182
|
-
const unknown = channelIds.filter((id) => !channels.some((channel) => channel.id === id));
|
|
183
|
-
if (unknown.length) {
|
|
184
|
-
throw new Error(`Unknown Buffer channel IDs: ${unknown.join(", ")}.`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
const connected = channels.filter((channel) => {
|
|
188
|
-
if (channelIds && !channelIds.includes(channel.id))
|
|
189
|
-
return false;
|
|
190
|
-
if (channel.isDisconnected || channel.isLocked) {
|
|
191
|
-
logger.warn(`Cannot publish to "${channel.name}": disconnected or locked in Buffer.`);
|
|
192
|
-
return false;
|
|
193
|
-
}
|
|
194
|
-
return true;
|
|
195
|
-
});
|
|
196
|
-
if (!connected.length) {
|
|
197
|
-
throw new Error("No connected, unlocked Buffer channels found.");
|
|
198
|
-
}
|
|
199
|
-
const existing = [];
|
|
200
|
-
let after = null;
|
|
201
|
-
do {
|
|
202
|
-
const { posts: page } = await request(token, `query Posts($input: PostsInput!, $after: String) {
|
|
203
|
-
posts(input: $input, first: 100, after: $after) {
|
|
204
|
-
edges { node { id text channelId status } }
|
|
205
|
-
pageInfo { hasNextPage endCursor }
|
|
206
|
-
}
|
|
207
|
-
}`, {
|
|
208
|
-
input: { organizationId, filter: { channelIds: connected.map(({ id }) => id) } },
|
|
209
|
-
after,
|
|
210
|
-
});
|
|
211
|
-
if (!page.edges) {
|
|
212
|
-
throw new Error("Buffer returned no post history.");
|
|
213
|
-
}
|
|
214
|
-
existing.push(...page.edges.map(({ node }) => node));
|
|
215
|
-
if (page.pageInfo.hasNextPage &&
|
|
216
|
-
(!page.pageInfo.endCursor || page.pageInfo.endCursor === after)) {
|
|
217
|
-
throw new Error("Buffer returned an invalid pagination cursor.");
|
|
218
|
-
}
|
|
219
|
-
after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
|
|
220
|
-
} while (after);
|
|
221
|
-
const failures = [];
|
|
222
|
-
for (const article of posts) {
|
|
223
|
-
const pending = [];
|
|
224
|
-
for (const channel of connected) {
|
|
225
|
-
const shared = existing.find((post) => post.channelId === channel.id && post.text.split(/\s+/).includes(article.url));
|
|
226
|
-
if (shared) {
|
|
227
|
-
if (!["sent", "scheduled", "sending"].includes(shared.status)) {
|
|
228
|
-
failures.push(new Error(`Review Buffer post ${shared.id} for "${channel.name}": ${shared.status}.`));
|
|
229
|
-
}
|
|
230
|
-
logger.info(`Already in Buffer: "${article.title}" -> "${channel.name}".`);
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
pending.push(channel);
|
|
234
|
-
}
|
|
235
|
-
if (!pending.length)
|
|
236
|
-
continue;
|
|
237
|
-
if (dryRun) {
|
|
238
|
-
logger.info(`Dry run: ${JSON.stringify({
|
|
239
|
-
title: article.title,
|
|
240
|
-
text: article.text,
|
|
241
|
-
channels: pending.map(({ id, name }) => ({ id, name })),
|
|
242
|
-
mode: article.mode,
|
|
243
|
-
})}`);
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
await waitForDeployment(article.url, logger);
|
|
247
|
-
const { createContentItem } = await request(token, `mutation Publish($input: CreateContentItemInput!) {
|
|
248
|
-
createContentItem(input: $input) {
|
|
249
|
-
... on CreateContentItemSuccess { content { id } }
|
|
250
|
-
... on CreateContentItemFailure {
|
|
251
|
-
message
|
|
252
|
-
errors { ... on MutationError { message } }
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}`, {
|
|
256
|
-
input: {
|
|
257
|
-
organizationId,
|
|
258
|
-
title: article.title,
|
|
259
|
-
posts: pending.map(({ id }) => ({
|
|
260
|
-
channelId: id,
|
|
261
|
-
text: article.text,
|
|
262
|
-
schedulingType: "automatic",
|
|
263
|
-
mode: article.mode,
|
|
264
|
-
})),
|
|
265
|
-
},
|
|
266
|
-
});
|
|
267
|
-
if (!createContentItem.content?.id) {
|
|
268
|
-
failures.push(new Error(`"${article.title}": ${[
|
|
269
|
-
createContentItem.message ?? "No content item ID returned.",
|
|
270
|
-
...(createContentItem.errors ?? []).map(({ message }) => message),
|
|
271
|
-
].join("; ")}`));
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
logger.info(`Submitted "${article.title}" to ${pending.length} channels (${createContentItem.content.id}).`);
|
|
275
|
-
}
|
|
276
|
-
if (failures.length) {
|
|
277
|
-
throw new AggregateError(failures, [
|
|
278
|
-
"Some Buffer posts need attention; reruns skip existing posts.",
|
|
279
|
-
...failures.map((error) => error.message),
|
|
280
|
-
].join("\n"));
|
|
281
|
-
}
|
|
99
|
+
await publishPosts(posts, { channelIds, dryRun, logger });
|
|
282
100
|
},
|
|
283
101
|
},
|
|
284
102
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AstroIntegrationLogger } from "astro";
|
|
2
|
+
interface Article {
|
|
3
|
+
title: string;
|
|
4
|
+
url: string;
|
|
5
|
+
text: string;
|
|
6
|
+
mode: "shareNow" | "addToQueue";
|
|
7
|
+
channelIds?: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function publishPosts(posts: Article[], { channelIds, dryRun, logger, }: {
|
|
10
|
+
channelIds?: string[];
|
|
11
|
+
dryRun: boolean;
|
|
12
|
+
logger: AstroIntegrationLogger;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
export {};
|
package/dist/publish.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import timers from "node:timers/promises";
|
|
2
|
+
async function waitForDeployment(url, logger) {
|
|
3
|
+
const deadline = Date.now() + 300_000;
|
|
4
|
+
while (Date.now() < deadline) {
|
|
5
|
+
let response;
|
|
6
|
+
try {
|
|
7
|
+
response = await fetch(url, {
|
|
8
|
+
method: "HEAD",
|
|
9
|
+
signal: AbortSignal.timeout(10_000),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (!(error instanceof TypeError) &&
|
|
14
|
+
!(error instanceof Error && error.name === "TimeoutError")) {
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
logger.warn(`Cannot reach ${url}: ${error.message}. Retrying.`);
|
|
18
|
+
}
|
|
19
|
+
if (response) {
|
|
20
|
+
if (response.ok && response.url === url)
|
|
21
|
+
return;
|
|
22
|
+
if (![404, 502, 503, 504].includes(response.status)) {
|
|
23
|
+
throw new Error(`Cannot verify ${url}: HTTP ${response.status}.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
logger.info(`Waiting for ${url} to be deployed...`);
|
|
27
|
+
await timers.setTimeout(10_000);
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`${url} is not live yet. Rerun after deployment succeeds.`);
|
|
30
|
+
}
|
|
31
|
+
async function request(token, query, variables) {
|
|
32
|
+
const response = await fetch("https://api.buffer.com", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: `Bearer ${token}`,
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({ query, variables }),
|
|
39
|
+
signal: AbortSignal.timeout(30_000),
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
43
|
+
throw new Error(`Buffer request failed: HTTP ${response.status}.${retryAfter ? ` Retry after ${retryAfter} seconds.` : ""}`);
|
|
44
|
+
}
|
|
45
|
+
const result = await response.json();
|
|
46
|
+
if (result.errors?.length) {
|
|
47
|
+
throw new Error(`Buffer: ${result.errors.map((error) => error.message).join("; ")}`);
|
|
48
|
+
}
|
|
49
|
+
if (!result.data) {
|
|
50
|
+
throw new Error("Buffer returned no data.");
|
|
51
|
+
}
|
|
52
|
+
return result.data;
|
|
53
|
+
}
|
|
54
|
+
export async function publishPosts(posts, { channelIds, dryRun, logger, }) {
|
|
55
|
+
const token = process.env.BUFFER_API_KEY;
|
|
56
|
+
const organizationId = process.env.BUFFER_ORGANIZATION_ID;
|
|
57
|
+
if (!token || !organizationId) {
|
|
58
|
+
throw new Error("Set BUFFER_API_KEY and BUFFER_ORGANIZATION_ID before publishing.");
|
|
59
|
+
}
|
|
60
|
+
const { channels } = await request(token, `query Channels($input: ChannelsInput!) {
|
|
61
|
+
channels(input: $input) { id name isDisconnected isLocked }
|
|
62
|
+
}`, { input: { organizationId } });
|
|
63
|
+
const selections = posts.map((article) => ({
|
|
64
|
+
article,
|
|
65
|
+
ids: article.channelIds ?? channelIds,
|
|
66
|
+
}));
|
|
67
|
+
const unknown = [
|
|
68
|
+
...new Set(selections.flatMap(({ ids }) => ids?.filter((id) => !channels.some((channel) => channel.id === id)) ?? [])),
|
|
69
|
+
];
|
|
70
|
+
if (unknown.length) {
|
|
71
|
+
throw new Error(`Unknown Buffer channel IDs: ${unknown.join(", ")}.`);
|
|
72
|
+
}
|
|
73
|
+
const connected = channels.filter((channel) => {
|
|
74
|
+
if (!selections.some(({ ids }) => !ids || ids.includes(channel.id)))
|
|
75
|
+
return false;
|
|
76
|
+
if (channel.isDisconnected || channel.isLocked) {
|
|
77
|
+
logger.warn(`Cannot publish to "${channel.name}": disconnected or locked in Buffer.`);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
});
|
|
82
|
+
if (!connected.length) {
|
|
83
|
+
throw new Error("No connected, unlocked Buffer channels found.");
|
|
84
|
+
}
|
|
85
|
+
const postChannels = selections.map(({ article, ids }) => {
|
|
86
|
+
const selected = connected.filter((channel) => !ids || ids.includes(channel.id));
|
|
87
|
+
if (!selected.length) {
|
|
88
|
+
throw new Error(`No connected, unlocked Buffer channels found for "${article.title}".`);
|
|
89
|
+
}
|
|
90
|
+
return { article, channels: selected };
|
|
91
|
+
});
|
|
92
|
+
const existing = [];
|
|
93
|
+
let after = null;
|
|
94
|
+
do {
|
|
95
|
+
const { posts: page } = await request(token, `query Posts($input: PostsInput!, $after: String) {
|
|
96
|
+
posts(input: $input, first: 100, after: $after) {
|
|
97
|
+
edges { node { id text channelId status } }
|
|
98
|
+
pageInfo { hasNextPage endCursor }
|
|
99
|
+
}
|
|
100
|
+
}`, {
|
|
101
|
+
input: { organizationId, filter: { channelIds: connected.map(({ id }) => id) } },
|
|
102
|
+
after,
|
|
103
|
+
});
|
|
104
|
+
if (!page.edges) {
|
|
105
|
+
throw new Error("Buffer returned no post history.");
|
|
106
|
+
}
|
|
107
|
+
existing.push(...page.edges.map(({ node }) => node));
|
|
108
|
+
if (page.pageInfo.hasNextPage &&
|
|
109
|
+
(!page.pageInfo.endCursor || page.pageInfo.endCursor === after)) {
|
|
110
|
+
throw new Error("Buffer returned an invalid pagination cursor.");
|
|
111
|
+
}
|
|
112
|
+
after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
|
|
113
|
+
} while (after);
|
|
114
|
+
const failures = [];
|
|
115
|
+
for (const { article, channels } of postChannels) {
|
|
116
|
+
const pending = [];
|
|
117
|
+
for (const channel of channels) {
|
|
118
|
+
const shared = existing.find((post) => post.channelId === channel.id && post.text.split(/\s+/).includes(article.url));
|
|
119
|
+
if (shared) {
|
|
120
|
+
if (!["sent", "scheduled", "sending"].includes(shared.status)) {
|
|
121
|
+
failures.push(new Error(`Review Buffer post ${shared.id} for "${channel.name}": ${shared.status}.`));
|
|
122
|
+
}
|
|
123
|
+
logger.info(`Already in Buffer: "${article.title}" -> "${channel.name}".`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
pending.push(channel);
|
|
127
|
+
}
|
|
128
|
+
if (!pending.length)
|
|
129
|
+
continue;
|
|
130
|
+
if (dryRun) {
|
|
131
|
+
logger.info(`Dry run: ${JSON.stringify({
|
|
132
|
+
title: article.title,
|
|
133
|
+
text: article.text,
|
|
134
|
+
channels: pending.map(({ id, name }) => ({ id, name })),
|
|
135
|
+
mode: article.mode,
|
|
136
|
+
})}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
await waitForDeployment(article.url, logger);
|
|
140
|
+
const { createContentItem } = await request(token, `mutation Publish($input: CreateContentItemInput!) {
|
|
141
|
+
createContentItem(input: $input) {
|
|
142
|
+
... on CreateContentItemSuccess { content { id } }
|
|
143
|
+
... on CreateContentItemFailure {
|
|
144
|
+
message
|
|
145
|
+
errors { ... on MutationError { message } }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}`, {
|
|
149
|
+
input: {
|
|
150
|
+
organizationId,
|
|
151
|
+
title: article.title,
|
|
152
|
+
posts: pending.map(({ id }) => ({
|
|
153
|
+
channelId: id,
|
|
154
|
+
text: article.text,
|
|
155
|
+
schedulingType: "automatic",
|
|
156
|
+
mode: article.mode,
|
|
157
|
+
})),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
if (!createContentItem.content?.id) {
|
|
161
|
+
failures.push(new Error(`"${article.title}": ${[
|
|
162
|
+
createContentItem.message ?? "No content item ID returned.",
|
|
163
|
+
...(createContentItem.errors ?? []).map(({ message }) => message),
|
|
164
|
+
].join("; ")}`));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
logger.info(`Submitted "${article.title}" to ${pending.length} channels (${createContentItem.content.id}).`);
|
|
168
|
+
}
|
|
169
|
+
if (failures.length) {
|
|
170
|
+
throw new AggregateError(failures, [
|
|
171
|
+
"Some Buffer posts need attention; reruns skip existing posts.",
|
|
172
|
+
...failures.map((error) => error.message),
|
|
173
|
+
].join("\n"));
|
|
174
|
+
}
|
|
175
|
+
}
|