Skip to content

Commit 9fa37a0

Browse files
* Centralize creator post logic
* Change RSS contentId calculation
1 parent 9515fb3 commit 9fa37a0

File tree

5 files changed

+126
-121
lines changed

5 files changed

+126
-121
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- content_id (creator_post) calculation for RSS feeds was changed
2+
-- Prevent reposting content by updating subscription's created_at
3+
update creator_subscription as cs
4+
set created_at = now()
5+
from creator as c
6+
where c.id = cs.creator_id
7+
and c.type = 'RSS';

src/commands/rss/post.ts

Lines changed: 27 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,32 @@
1-
import type { Item } from "rss-parser";
2-
31
import assert from "node:assert";
42

53
import { CreatorType, registerPoster } from "../../creators";
6-
import { byDate } from "../../helpers";
74
import * as rss from "./rss.manager";
85

9-
registerPoster(
10-
CreatorType.RSS,
11-
// eslint-disable-next-line sonarjs/cognitive-complexity
12-
async ({ createdAt, creatorDomainId, lastContentId }) => {
13-
const feed = await rss.getFeed(creatorDomainId);
14-
const { image, items, title: feedName } = feed;
15-
16-
// All other properties are optional and cannot be asserted
17-
// https://www.rssboard.org/rss-specification
18-
assert(feedName !== undefined);
19-
20-
const { url: avatarURL } = image ?? {};
21-
const orderedItems = items.sort(
22-
byDate(({ pubDate }: Item) => pubDate, "desc"),
23-
);
24-
25-
const options = [];
26-
for (const [index, { link, pubDate, title }] of orderedItems.entries()) {
27-
if (title === undefined) break;
28-
// Do not post items created before the last posted item
29-
if (link === undefined || link === lastContentId) break;
30-
31-
if (pubDate === undefined) {
32-
// If no item has been posted yet then only post the 1st one
33-
if (lastContentId === null && index > 0) break;
34-
} else {
35-
// Do not post items created before the subscription was created
36-
const itemDate = new Date(pubDate);
37-
if (itemDate < createdAt) break;
38-
}
39-
40-
options.push({
41-
avatarURL,
42-
contentId: link,
43-
title,
44-
url: link,
45-
username: feedName,
46-
});
47-
}
48-
49-
// Reverse options so the oldest items are posted first
50-
return options.reverse();
51-
},
52-
);
6+
registerPoster(CreatorType.RSS, async (creatorDomainId) => {
7+
const feed = await rss.getFeed(creatorDomainId);
8+
const { image, items, title: feedName } = feed;
9+
10+
// All other properties are optional and cannot be asserted
11+
// https://www.rssboard.org/rss-specification
12+
assert(feedName !== undefined);
13+
14+
const { url: avatarURL } = image ?? {};
15+
16+
const options = [];
17+
for (const { guid, link, pubDate, title } of items) {
18+
if (link === undefined || title === undefined) break;
19+
const contentId = guid ?? link;
20+
21+
options.push({
22+
avatarURL,
23+
contentId,
24+
timestamp: pubDate,
25+
title,
26+
url: link,
27+
username: feedName,
28+
});
29+
}
30+
31+
return options;
32+
});

src/commands/youtube/post/index.ts

Lines changed: 31 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,38 @@
11
import assert from "node:assert";
22

33
import { CreatorType, registerPoster } from "../../../creators";
4-
import { byDate, isNonNullable } from "../../../helpers";
4+
import { isNonNullable } from "../../../helpers";
55
import { getThumbnailUrl, getVideoUrl } from "../../../services/youtube";
66
import * as youtube from "../youtube.manager";
77
import UI from "./ui";
88

9-
registerPoster(
10-
CreatorType.YouTube,
11-
async ({ createdAt, creatorDomainId, lastContentId }) => {
12-
const { contentDetails, snippet } =
13-
await youtube.getChannel(creatorDomainId);
14-
15-
const { relatedPlaylists } = contentDetails ?? {};
16-
const { uploads } = relatedPlaylists ?? {};
17-
assert(uploads !== undefined);
18-
19-
const { thumbnails, title: channelName } = snippet ?? {};
20-
assert(isNonNullable(channelName));
21-
22-
const videos = await youtube.getVideos(uploads);
23-
const orderedVideos = videos.sort(
24-
byDate(({ publishedAt }) => publishedAt, "desc"),
25-
);
26-
27-
const options = [];
28-
for (const video of orderedVideos) {
29-
const { publishedAt, resourceId, title } = video;
30-
assert(isNonNullable(title));
31-
32-
// Do not post videos created before the subscription was created
33-
assert(isNonNullable(publishedAt));
34-
const videoDate = new Date(publishedAt);
35-
if (videoDate < createdAt) break;
36-
// Do not post videos created before the last posted video
37-
const { videoId } = resourceId ?? {};
38-
assert(isNonNullable(videoId));
39-
if (videoId === lastContentId) break;
40-
41-
options.push({
42-
avatarURL: getThumbnailUrl(thumbnails),
43-
components: UI.viewDescription(videoId),
44-
contentId: videoId,
45-
title,
46-
url: getVideoUrl(videoId),
47-
username: channelName,
48-
});
49-
}
50-
51-
// Reverse options so the oldest videos are posted first
52-
return options.reverse();
53-
},
54-
);
9+
registerPoster(CreatorType.YouTube, async (creatorDomainId) => {
10+
const { contentDetails, snippet } = await youtube.getChannel(creatorDomainId);
11+
const { thumbnails, title: channelName } = snippet ?? {};
12+
assert(isNonNullable(channelName));
13+
14+
const { relatedPlaylists } = contentDetails ?? {};
15+
const { uploads } = relatedPlaylists ?? {};
16+
assert(uploads !== undefined);
17+
const videos = await youtube.getVideos(uploads);
18+
19+
const options = [];
20+
for (const video of videos) {
21+
const { publishedAt, resourceId, title } = video;
22+
assert(isNonNullable(title));
23+
const { videoId } = resourceId ?? {};
24+
assert(isNonNullable(videoId));
25+
26+
options.push({
27+
avatarURL: getThumbnailUrl(thumbnails),
28+
components: UI.viewDescription(videoId),
29+
contentId: videoId,
30+
timestamp: publishedAt,
31+
title,
32+
url: getVideoUrl(videoId),
33+
username: channelName,
34+
});
35+
}
36+
37+
return options;
38+
});

src/creators/post/database.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import type { BaseMessageOptions } from "discord.js";
2-
31
import type { CreatorType } from "../constants";
42

53
import { caller } from "../../helpers";
@@ -27,16 +25,7 @@ export type CreatorPost = {
2725
id: string;
2826
};
2927

30-
export type Option = {
31-
avatarURL?: string;
32-
components?: BaseMessageOptions["components"];
33-
contentId: string;
34-
title: string;
35-
url: string;
36-
username: string;
37-
};
38-
39-
type ContentIdRow = {
28+
type ContentId = {
4029
contentId: string;
4130
};
4231
// endregion
@@ -110,11 +99,11 @@ export const createCreatorPosts = (creatorPosts: CreatorPost[]) =>
11099
return client.query(query, values);
111100
});
112101

113-
export const getInvalidContentIds = (
102+
export const getPostedContentIds = (
114103
{ creatorChannelId, creatorDomainId, creatorType }: CreatorSubscription,
115104
contentIds: string[],
116105
) =>
117-
useClient(caller(module, getInvalidContentIds), async (client) => {
106+
useClient(caller(module, getPostedContentIds), async (client) => {
118107
const query = `
119108
select cp.content_id as "contentId"
120109
from creator_post as cp
@@ -129,6 +118,6 @@ export const getInvalidContentIds = (
129118
`;
130119

131120
const values = [creatorChannelId, creatorDomainId, creatorType, contentIds];
132-
const { rows } = await client.query<ContentIdRow>(query, values);
121+
const { rows } = await client.query<ContentId>(query, values);
133122
return rows.map(({ contentId }) => contentId);
134123
});

src/creators/post/index.ts

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { APIMessage } from "discord.js";
1+
import type { APIMessage, BaseMessageOptions } from "discord.js";
22

33
import { CronJob } from "cron";
44
import {
@@ -11,16 +11,27 @@ import {
1111
import assert from "node:assert";
1212
import loggerFactory from "pino";
1313

14+
import type { Nullable } from "../../helpers";
1415
import type { CreatorType } from "../constants";
15-
import type { CreatorSubscription, Option } from "./database";
16+
import type { CreatorSubscription } from "./database";
1617

17-
import { isUnique } from "../../helpers";
18+
import { byDate, isNullable, isUnique } from "../../helpers";
1819
import discord from "../../services/discord";
1920
import * as creatorsDatabase from "../database";
2021
import * as postDatabase from "./database";
2122

2223
// region Types
23-
type Poster = (creatorSubscription: CreatorSubscription) => Promise<Option[]>;
24+
export type Option = {
25+
avatarURL?: string;
26+
components?: BaseMessageOptions["components"];
27+
contentId: string;
28+
timestamp: Date | Nullable | number | string;
29+
title: string;
30+
url: string;
31+
username: string;
32+
};
33+
34+
type Poster = (creatorDomainId: string) => Promise<Option[]>;
2435
// endregion
2536

2637
const logger = loggerFactory({
@@ -45,18 +56,52 @@ const getChannel = async ({
4556
};
4657

4758
const getOptions = async (creatorSubscription: CreatorSubscription) => {
48-
const poster = posters.get(creatorSubscription.creatorType);
59+
const { createdAt, creatorDomainId, creatorType, lastContentId } =
60+
creatorSubscription;
61+
62+
const poster = posters.get(creatorType);
4963
assert(poster !== undefined);
5064

5165
try {
52-
const options = await poster(creatorSubscription);
53-
const contentIds = options.map(({ contentId }) => contentId);
54-
const invalidContentIds =
55-
// prettier-ignore
56-
await postDatabase.getInvalidContentIds(creatorSubscription, contentIds);
66+
const options = await poster(creatorDomainId);
67+
// Iterate options from latest to oldest
68+
const orderedOptions = options.sort(
69+
byDate(({ timestamp }) => timestamp, "desc"),
70+
);
71+
72+
const optionsToPost = [];
73+
for (const [index, option] of orderedOptions.entries()) {
74+
const { contentId, timestamp } = option;
75+
76+
// Do not post options created before the last posted option
77+
if (contentId === lastContentId) break;
5778

58-
return options.filter(
59-
({ contentId }) => !invalidContentIds.includes(contentId),
79+
if (isNullable(timestamp)) {
80+
// Only post latest option if none has been previously posted
81+
if (lastContentId === null && index > 0) break;
82+
} else {
83+
// Do not post options created before the subscription
84+
const timestampDate = new Date(timestamp);
85+
if (timestampDate < createdAt) break;
86+
}
87+
88+
optionsToPost.push(option);
89+
}
90+
91+
// If no options will be posted skip checking for posted content
92+
if (optionsToPost.length === 0) return optionsToPost;
93+
94+
const contentIds = optionsToPost.map(({ contentId }) => contentId);
95+
const postedContentIds =
96+
// prettier-ignore
97+
await postDatabase.getPostedContentIds(creatorSubscription, contentIds);
98+
99+
return (
100+
optionsToPost
101+
// Do not post options previously posted (handles some edge cases)
102+
.filter(({ contentId }) => !postedContentIds.includes(contentId))
103+
// Post options from oldest to latest
104+
.reverse()
60105
);
61106
} catch (error) {
62107
const childLogger = logger.child({ creatorSubscription });

0 commit comments

Comments
 (0)