Skip to content
258 changes: 188 additions & 70 deletions packages/frontpage/app/api/receive_hook/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { getPdsUrl, type DID } from "@/lib/data/atproto/did";
import { type Operation } from "@/lib/data/atproto/event";
import { getDidFromHandleOrDid } from "@/lib/data/atproto/identity";
import { getAtprotoClient, nsids } from "@/lib/data/atproto/repo";
import {
type CommentCollectionType,
getAtprotoClient,
nsids,
type VoteCollectionType,
type PostCollectionType,
} from "@/lib/data/atproto/repo";
import * as dbComment from "@/lib/data/db/comment";
import * as dbNotification from "@/lib/data/db/notification";
import * as dbPost from "@/lib/data/db/post";
Expand All @@ -10,6 +16,10 @@ import { getBlueskyProfile } from "@/lib/data/user";
import { sendDiscordMessage } from "@/lib/discord";
import { invariant } from "@/lib/utils";
import { AtUri } from "@atproto/syntax";
import {
FyiFrontpageFeedPost,
FyiFrontpageRichtextBlock,
} from "@repo/frontpage-atproto-client";
import type z from "zod";

type HandlerInput = {
Expand All @@ -31,36 +41,67 @@ async function getAtprotoClientFromRepo(repo: DID) {
return getAtprotoClient(pds);
}

export async function handlePost({ op, repo, rkey }: HandlerInput) {
async function hydratePost(
repo: DID,
collection: string,
rkey: string,
): Promise<{
title: string;
url: string;
createdAt: Date;
cid: string;
$type: PostCollectionType;
}> {
const atproto = await getAtprotoClientFromRepo(repo);
if (collection === nsids.FyiUnravelFrontpagePost) {
const record = await atproto.fyi.unravel.frontpage.post.get({ repo, rkey });
return {
title: record.value.title,
url: record.value.url,
createdAt: new Date(record.value.createdAt),
cid: record.cid,
$type: record.value.$type,
};
} else if (collection === nsids.FyiFrontpageFeedPost) {
const record = await atproto.fyi.frontpage.feed.post.get({ repo, rkey });
const subject = record.value.subject;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is subject and how come we don't check it on old unravel ones? New for frontpage schema?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah have a read through #232 for context. Current types don't have a subject, just a url field.

invariant(
FyiFrontpageFeedPost.isUrlSubject(subject),
`Received non-url subject in frontpage feed post: at://${repo}/${collection}/${rkey}#${record.cid}`,
);
return {
title: record.value.title,
url: subject.url,
createdAt: new Date(record.value.createdAt),
cid: record.cid,
$type: record.value.$type,
};
} else {
throw new Error(`Unknown collection for post hydration: ${collection}`);
}
}

export async function handlePost({ op, repo, rkey }: HandlerInput) {
if (op.action === "create") {
const postRecord = await atproto.fyi.unravel.frontpage.post.get({
repo,
rkey,
});

invariant(postRecord, "atproto post record not found");

const post = await dbPost.uncached_doesPostExist(repo, rkey);
const { title, url, createdAt } = postRecord.value;
const post = await hydratePost(repo, op.path.collection, rkey);

if (post) {
if (await dbPost.uncached_doesPostExist(repo, rkey)) {
await dbPost.updatePost(repo, rkey, {
status: "live",
cid: postRecord.cid,
cid: post.cid,
});
} else {
await dbPost.createPost({
post: {
title,
url,
createdAt: new Date(createdAt),
title: post.title,
url: post.url,
createdAt: post.createdAt,
},
rkey,
cid: postRecord.cid,
cid: post.cid,
authorDid: repo,
status: "live",
collection: post.$type,
});
}

Expand All @@ -69,7 +110,7 @@ export async function handlePost({ op, repo, rkey }: HandlerInput) {
embeds: [
{
title: "New post on Frontpage",
description: title,
description: post.title,
url: `https://frontpage.fyi/post/${repo}/${rkey}`,
color: 10181046,
author: bskyProfile
Expand All @@ -82,7 +123,7 @@ export async function handlePost({ op, repo, rkey }: HandlerInput) {
fields: [
{
name: "Link",
value: url,
value: post.url,
},
],
},
Expand All @@ -96,43 +137,93 @@ export async function handlePost({ op, repo, rkey }: HandlerInput) {
}
}

export async function handleComment({ op, repo, rkey }: HandlerInput) {
async function hydrateComment(
repo: DID,
collection: string,
rkey: string,
): Promise<{
cid: string;
content: string;
createdAt: Date;
parentUri: AtUri | null;
postUri: AtUri;
$type: CommentCollectionType;
}> {
const atproto = await getAtprotoClientFromRepo(repo);

if (op.action === "create") {
const commentRecord = await atproto.fyi.unravel.frontpage.comment.get({
if (collection === nsids.FyiUnravelFrontpageComment) {
const record = await atproto.fyi.unravel.frontpage.comment.get({
repo,
rkey,
});
return {
cid: record.cid,
content: record.value.content,
createdAt: new Date(record.value.createdAt),
parentUri: record.value.parent
? new AtUri(record.value.parent.uri)
: null,
postUri: new AtUri(record.value.post.uri),
$type: record.value.$type,
};
} else if (collection === nsids.FyiFrontpageFeedComment) {
const record = await atproto.fyi.frontpage.feed.comment.get({
repo,
rkey,
});

invariant(commentRecord, "atproto comment record not found");
const blockContents = record.value.blocks.flatMap((block) => {
if (FyiFrontpageRichtextBlock.isPlaintextParagraph(block.content)) {
return [block.content.text];
} else {
return [];
}
});

const comment = await dbComment.uncached_doesCommentExist(repo, rkey);
invariant(
Comment thread
tom-sherman marked this conversation as resolved.
blockContents.length === record.value.blocks.length,
`Received non plaintext blocks in frontpage feed comment: at://${repo}/${collection}/${rkey}#${record.cid}`,
);
Comment thread
tom-sherman marked this conversation as resolved.

if (comment) {
console.log("comment already exists", commentRecord.value);
return {
cid: record.cid,
content: blockContents.join("\n\n"),
createdAt: new Date(record.value.createdAt),
parentUri: record.value.parent
? new AtUri(record.value.parent.uri)
: null,
postUri: new AtUri(record.value.post.uri),
$type: record.value.$type,
};
} else {
throw new Error(`Unknown collection for comment hydration: ${collection}`);
}
}

export async function handleComment({ op, repo, rkey }: HandlerInput) {
if (op.action === "create") {
const comment = await hydrateComment(repo, op.path.collection, rkey);

if (await dbComment.uncached_doesCommentExist(repo, rkey)) {
await dbComment.updateComment(repo, rkey, {
status: "live",
cid: commentRecord.cid,
cid: comment.cid,
});
} else {
const { content, createdAt, parent, post } = commentRecord.value;
const postUri = new AtUri(post.uri);
const parentData = parent
const parentData = comment.parentUri
? {
uri: new AtUri(parent.uri),
authorDid: await getDidOrThrow(new AtUri(parent.uri).host),
uri: comment.parentUri,
authorDid: await getDidOrThrow(comment.parentUri.host),
}
: null;

const postAuthorDid = await getDidOrThrow(postUri.host);
const postAuthorDid = await getDidOrThrow(comment.postUri.host);

const createdComment = await dbComment.createComment({
cid: commentRecord.cid,
cid: comment.cid,
authorDid: repo,
rkey,
content,
createdAt: new Date(createdAt),
content: comment.content,
createdAt: comment.createdAt,
parent: parentData
? {
authorDid: parentData.authorDid,
Expand All @@ -141,9 +232,10 @@ export async function handleComment({ op, repo, rkey }: HandlerInput) {
: undefined,
post: {
authorDid: postAuthorDid,
rkey: postUri.rkey,
rkey: comment.postUri.rkey,
},
status: "live",
collection: comment.$type,
});

if (!createdComment) {
Expand All @@ -165,40 +257,66 @@ export async function handleComment({ op, repo, rkey }: HandlerInput) {
}
}

export async function handleVote({ op, repo, rkey }: HandlerInput) {
async function hydrateVote(
repo: DID,
collection: string,
rkey: string,
): Promise<{
cid: string;
createdAt: Date;
subject: {
uri: AtUri;
cid: string;
};
$type: VoteCollectionType;
}> {
const atproto = await getAtprotoClientFromRepo(repo);
if (op.action === "create") {
const hydratedRecord = await atproto.fyi.unravel.frontpage.vote.get({
repo,
rkey,
});
let record;
if (collection === nsids.FyiUnravelFrontpageVote) {
record = await atproto.fyi.unravel.frontpage.vote.get({ repo, rkey });
} else if (collection === nsids.FyiFrontpageFeedVote) {
record = await atproto.fyi.frontpage.feed.vote.get({ repo, rkey });
} else {
throw new Error(`Unknown collection for vote hydration: ${collection}`);
}

invariant(hydratedRecord, "atproto vote record not found");
return {
cid: record.cid,
createdAt: new Date(record.value.createdAt),
subject: {
uri: new AtUri(record.value.subject.uri),
cid: record.value.subject.cid,
},
$type: record.value.$type,
};
}

const { subject } = hydratedRecord.value;
const subjectUri = new AtUri(subject.uri);
export async function handleVote({ op, repo, rkey }: HandlerInput) {
if (op.action === "create") {
const vote = await hydrateVote(repo, op.path.collection, rkey);

switch (subjectUri.collection) {
case nsids.FyiUnravelFrontpagePost: {
const postVote = await dbVote.uncached_doesPostVoteExist(repo, rkey);
if (postVote) {
switch (vote.subject.uri.collection) {
case nsids.FyiUnravelFrontpagePost:
case nsids.FyiFrontpageFeedPost: {
if (await dbVote.uncached_doesPostVoteExist(repo, rkey)) {
await dbVote.updatePostVote({
authorDid: repo,
rkey,
status: "live",
cid: hydratedRecord.cid,
cid: vote.cid,
});
} else {
const createdDbPostVote = await dbVote.createPostVote({
repo,
rkey,
cid: hydratedRecord.cid,
cid: vote.cid,
subject: {
rkey: subjectUri.rkey,
authorDid: await getDidOrThrow(subjectUri.host),
cid: subject.cid,
rkey: vote.subject.uri.rkey,
authorDid: await getDidOrThrow(vote.subject.uri.host),
cid: vote.subject.cid,
},
status: "live",
collection: vote.$type,
});

if (!createdDbPostVote) {
Expand All @@ -209,29 +327,27 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) {
}
break;
}
case nsids.FyiUnravelFrontpageComment: {
const commentVote = await dbVote.uncached_doesCommentVoteExist(
repo,
rkey,
);
if (commentVote) {
case nsids.FyiUnravelFrontpageComment:
case nsids.FyiFrontpageFeedComment: {
if (await dbVote.uncached_doesCommentVoteExist(repo, rkey)) {
await dbVote.updateCommentVote({
authorDid: repo,
rkey,
status: "live",
cid: hydratedRecord.cid,
cid: vote.cid,
});
} else {
const createdDbCommentVote = await dbVote.createCommentVote({
repo,
rkey,
cid: hydratedRecord.cid,
cid: vote.cid,
subject: {
rkey: subjectUri.rkey,
authorDid: await getDidOrThrow(subjectUri.host),
cid: subject.cid,
rkey: vote.subject.uri.rkey,
authorDid: await getDidOrThrow(vote.subject.uri.host),
cid: vote.subject.cid,
},
status: "live",
collection: vote.$type,
});

if (!createdDbCommentVote) {
Expand All @@ -242,11 +358,13 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) {
}
break;
}
default:
invariant(subjectUri.collection, "Unknown collection");
default: {
throw new Error(
`Unknown vote subject collection: ${vote.subject.uri.collection} received from at://${repo}/${op.path.collection}/${rkey}#${vote.cid}`,
);
}
}
} else if (op.action === "delete") {
console.log("deleting vote", rkey);
await dbVote.deleteVote({ authorDid: repo, rkey });
}
}
Expand Down
Loading
Loading