Skip to content

Commit

Permalink
feat(devvit): add new and improved version of mediareliability devvit…
Browse files Browse the repository at this point in the history
… app
  • Loading branch information
virtuallyunknown committed Jul 22, 2024
1 parent eaeeb8c commit 37416f2
Show file tree
Hide file tree
Showing 15 changed files with 1,344 additions and 12 deletions.
4 changes: 2 additions & 2 deletions packages/devvit/devvit.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
name: test-app-4
version: 0.0.1.3
name: mediareliability
version: 0.0.5.27
2 changes: 1 addition & 1 deletion packages/devvit/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default [
...eslintPluginReactConfig,
...eslintPluginStylisticConfig,
{
files: ["src/**/*.{ts,tsx}"],
files: ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}"],
languageOptions: {
globals: {
...globals.browser,
Expand Down
11 changes: 8 additions & 3 deletions packages/devvit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
"type": "module",
"scripts": {
"check": "tsc --noEmit",
"lint": "eslint --max-warnings 0 src"
"lint": "eslint --max-warnings 0 src",
"test": "vitest --reporter=verbose --run"
},
"dependencies": {
"@devvit/public-api": "^0.10.22"
"@devvit/public-api": "^0.10.22",
"linkify-it": "^5.0.0",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
"devDependencies": {
"@devvit/protos": "^0.10.22",
"@repo/db": "workspace:*",
"vitest": "^1.6.0"
"@types/linkify-it": "^5.0.0",
"vitest": "^2.0.3"
}
}
105 changes: 105 additions & 0 deletions packages/devvit/src/comment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { SetPostFlairOptions, TriggerContext } from '@devvit/public-api';
import { getTierDetails } from './helpers.js';
import type { AppSettings, PostData, Source } from './types.js';

/**
* Matched sources are sorted by tier.
* Don't flair the post if:
* - The first source is official or an aggregator.
* - The post is a self-post.
*/
function shouldFlairPost(postData: PostData, sources: Source[]) {
if (postData.url?.hostname && ['reddit.com', 'www.reddit.com'].includes(postData.url.hostname)) {
return false;
}

const { postFlairText } = getTierDetails(sources[0].tier);

return postFlairText !== null;
}

type FlairPostProps = {
postId: string;
sources: Source[];
subredditName: Exclude<SetPostFlairOptions['subredditName'], undefined>;
flairCssClass: Exclude<SetPostFlairOptions['cssClass'], undefined>;
flairTemplateId: Exclude<SetPostFlairOptions['flairTemplateId'], undefined>;
context: TriggerContext;
};

export async function flairPost({ postId, sources, subredditName, flairCssClass, flairTemplateId, context }: FlairPostProps) {
const { postFlairText } = getTierDetails(sources[0].tier);

if (!postFlairText) {
return;
}

await context.reddit.setPostFlair({
postId,
text: postFlairText,
cssClass: flairCssClass,
flairTemplateId: flairTemplateId,
subredditName: subredditName
});
}

function getSourceLine(source: Source) {
const { name, handles, tier, domains } = source;
const { commentText, reliabilityText } = getTierDetails(tier);

const handle = handles?.at(0) ?? null;
const domain = domains?.at(0) ?? null;

const sourceName = handle
? `${name} ([@${handle}](https://twitter.com/${handle}))`
: domain
? `${name} ([${domain}](https://${domain}))`
: name;

return `**${commentText}**: ${sourceName}${reliabilityText ? ` - ${reliabilityText}` : ''}`;
}

type SubmitCommentProps = {
postData: PostData;
sources: Source[];
settings: AppSettings;
context: TriggerContext;
};

export async function submitComment({ postData, sources, settings, context }: SubmitCommentProps) {
if (shouldFlairPost(postData, sources)) {
await flairPost({
postId: postData.id,
sources: sources,
subredditName: postData.subredditName,
flairCssClass: settings.flairCssClass,
flairTemplateId: settings.flairTemplateId,
context: context
});
}

const header = `**Media reliability report:**`;
const warningForUnreliable = sources.some(source => getTierDetails(source.tier).unreliable)
? settings.unreliableSourcesWarning
: null;
const footer = settings.commentFooter;

const markdown = [
header,
...sources.map(source => `- ${getSourceLine(source)}`),
warningForUnreliable,
footer
]
.filter(Boolean)
.join('\n\n');

const comment = await context.reddit.submitComment({
id: postData.id,
text: markdown
});

await Promise.all([
comment.distinguish(true),
comment.lock()
]);
}
170 changes: 170 additions & 0 deletions packages/devvit/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { Context, TriggerContext } from '@devvit/public-api';
import linkifyit from 'linkify-it';
import { fromZodError } from 'zod-validation-error';
import { settingsSchema } from './schema.js';
import type { AppSettings, RedditPostV1, Source, TierDetails } from './types.js';

const linkify = new linkifyit();

/**
* Remove diacritics and convert to lowercase
*
* @note There is a slight performance penalty when using modern unicode
* property escapes so prefer using the old method with character class
* range for now.
*
* @see https://stackoverflow.com/a/37511463/3258251
*/
export function normalizeText(text: string) {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}

export function capitalizeString(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}

/**
* Details:
*
* order: used for sorting tiers
* commentText: text to display in the comment
* postFlairText: text to display in the post flair
* reliabilityText: text to display in the comment after the source name
* unreliable: whether the source is unreliable
*/
const tierData: Record<Source['tier'], TierDetails> = {
official: {
order: 0,
commentText: 'Official',
postFlairText: 'Official',
reliabilityText: 'official source',
unreliable: false
},
1: {
order: 1,
commentText: 'Tier 1',
postFlairText: 'Tier 1',
reliabilityText: 'very reliable',
unreliable: false
},
2: {
order: 2,
commentText: 'Tier 2',
postFlairText: 'Tier 2',
reliabilityText: 'reliable',
unreliable: false
},
3: {
order: 3,
commentText: 'Tier 3',
postFlairText: 'Tier 3',
reliabilityText: '❗ unreliable',
unreliable: true
},
4: {
order: 4,
commentText: 'Tier 4',
postFlairText: 'Tier 4',
reliabilityText: '❗ very unreliable',
unreliable: true
},
5: {
order: 5,
commentText: 'Tier 5',
postFlairText: 'Tier 5',
reliabilityText: '❗ extremely unrialable',
unreliable: true
},
aggregator: {
order: 6,
commentText: 'Aggregator',
postFlairText: null,
reliabilityText: null,
unreliable: false
},
};

export function getTierDetails(tier: Source['tier']) {
return tierData[tier];
}

export function sortTiers(a: Source, b: Source) {
return getTierDetails(a.tier).order - getTierDetails(b.tier).order;
}

/**
* Devvit onValidate is a bit weird, if you return string it assumes an error,
* if you return undefined it assumes success, so here we return accordingly.
*/
export function validateSetting(key: keyof AppSettings, value: unknown) {
const parsed = settingsSchema.shape[key].safeParse(value);

return parsed.success
? undefined
: `Invalid value for "${key}" setting. Error:\n ${fromZodError(parsed.error)}`;
}

export async function getAllSettings(context: Context | TriggerContext) {
return settingsSchema.parse(await context.settings.getAll<AppSettings>());
}

export function isIgnoredUser(username: string, settings: AppSettings) {
return settings.ignoredUsers
.some(ignoredUser => ignoredUser.toLowerCase() === username.toLowerCase());
}

/**
* This can be removed when the devvit remote tsc compiler is updated to ts 5.5+
* @see https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/#inferred-type-predicates
*/
function nonNullable<T>(value: T): value is NonNullable<T> {
return value !== null;
}

/**
* Return all links in the post body, ignoring ones from reddit
*/
function getPostLinks(body: string) {
const links = (linkify.match(body) ?? [])
.map(link => {
try {
const url = new URL(link.url, 'https://www.reddit.com');
return ['v.redd.it', 'i.redd.it', 'reddit.com', 'www.reddit.com'].includes(url.hostname) ? null : url;
}
catch (error) {
return null;
}
})
.filter(nonNullable);

return links.length > 0 ? links : null;
}

export function processPost(post: RedditPostV1) {
const url = new URL(post.url, 'https://www.reddit.com');
const links = post.body ? getPostLinks(post.body) : null;

return ({
id: post.id,
subredditName: post.subredditName,
titleNormalized: normalizeText(post.title),
bodyNormalized: post.body && post.body.length > 0 ? normalizeText(post.body) : null,
url: !['v.redd.it', 'i.redd.it', 'reddit.com', 'www.reddit.com'].includes(url.hostname) ? url : null,
links: links,
});
}

export async function trySendPostErrorModmail(context: TriggerContext, postId: string, error: Error) {
const { errorReportSubredditName } = await getAllSettings(context);

if (errorReportSubredditName) {
await context.reddit.sendPrivateMessage({
subject: 'An error occurred with the media reliability app',
text: `An error occurred with this post: https://redd.it/${postId.replace(/^t3_/, '')}\n\n${String(error)}`,
to: errorReportSubredditName
});
}
}
4 changes: 4 additions & 0 deletions packages/devvit/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './comment.js';
export * from './helpers.js';
export * from './matcher.js';
export * from './schema.js';
Loading

0 comments on commit 37416f2

Please sign in to comment.