Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update outbox format #19

Merged
merged 35 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a2c8b4d
Enhance post serialization with ActivityStreams format and update Pos…
0marSalah Jan 16, 2025
44d5805
Refactor importOutbox method to handle ActivityStreams format and imp…
0marSalah Jan 16, 2025
513ed9a
Simplify outbox URL in AccountExporter by using a static string
0marSalah Jan 16, 2025
90bf081
Implement outbox generation and fetching for ActivityStreams in Accou…
0marSalah Jan 22, 2025
d7dd978
Add logging to fetchOutbox and clean up commented code in AccountExpo…
0marSalah Jan 22, 2025
2e523a7
Refactor generateOutbox to simplify orderedItems mapping and improve …
0marSalah Jan 22, 2025
eeeb885
Enhance generateOutbox to include detailed logging and improve ordere…
0marSalah Jan 22, 2025
efcf4d9
Refactor fetchOutbox to improve item retrieval and add logging for be…
0marSalah Jan 22, 2025
b3d2634
Refactor fetchOutbox to use for-await loop for item retrieval and imp…
0marSalah Jan 22, 2025
45221f5
Refactor fetchOutbox to use iterateCollection for item retrieval and …
0marSalah Jan 22, 2025
69178cd
Refactor generateOutbox to enhance orderedItems mapping and improve o…
0marSalah Jan 22, 2025
79b5fbd
Refactor generateOutbox to change object type to 'OrderedCollection' …
0marSalah Jan 22, 2025
1bc88ef
Refactor generateOutbox to enhance object mapping and improve handlin…
0marSalah Jan 22, 2025
75bc7d8
Refactor generateOutbox to improve object logging and ensure full obj…
0marSalah Jan 22, 2025
567afc0
Refactor generateOutbox to enhance object serialization by converting…
0marSalah Jan 22, 2025
97b5505
Refactor generateOutbox to improve object serialization by simplifyin…
0marSalah Jan 22, 2025
7433d5c
Refactor generateOutbox to implement activity serialization, converti…
0marSalah Jan 23, 2025
53c2761
Refactor generateOutbox to improve activity serialization by directly…
0marSalah Jan 23, 2025
1a2b97c
Refactor generateOutbox to enhance object serialization by simplifyin…
0marSalah Jan 23, 2025
c6043ec
Refactor generateOutbox to enhance object serialization by improving …
0marSalah Jan 23, 2025
6ce0c53
Refactor outbox handling by implementing helper functions for tags, t…
0marSalah Jan 23, 2025
6b82907
Refactor generateOutbox by removing unused helper functions and addin…
0marSalah Jan 23, 2025
ff55ed3
Refactor generateOutbox by commenting out unused replies handling cod…
0marSalah Jan 23, 2025
1394a5a
Refactor generateOutbox by commenting out unused replies handling cod…
0marSalah Jan 23, 2025
1e22ea3
Refactor generateOutbox by commenting out unused shares, likes, and a…
0marSalah Jan 23, 2025
3879dc9
Refactor generateOutbox by cleaning up object properties and ensuring…
0marSalah Jan 23, 2025
25c6f80
Refactor generateOutbox by introducing safeToString function to ensur…
0marSalah Jan 23, 2025
ee14af2
Refactor generateOutbox by adding logging for debugging and commentin…
0marSalah Jan 23, 2025
b99f2f4
Refactor generateOutbox by removing unused helper functions for handl…
0marSalah Jan 23, 2025
9b4b81a
Refactor outbox handling by improving type definitions, enhancing typ…
0marSalah Jan 23, 2025
8e866e2
Refactor generateOutbox by adding getRepliesAsArray function to handl…
0marSalah Jan 23, 2025
9ba53b8
Refactor generateOutbox by replacing getRepliesAsArray with direct ca…
0marSalah Jan 23, 2025
51400c5
Refactor generateOutbox by adding logging for replies, likes, and sha…
0marSalah Jan 23, 2025
75d93ec
Refactor generateOutbox to restructure replies object, improving data…
0marSalah Jan 23, 2025
b040444
Refactor generateOutbox to enhance likes and shares structure, improv…
0marSalah Jan 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/entities/export.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { type Actor, lookupObject } from "@fedify/fedify";
import { exportActorProfile } from "@interop/wallet-export-ts";
import { eq } from "drizzle-orm";
import type { Context } from "hono";
import db from "../db";
import * as schema from "../schema";
import { serializeAccount } from "./account";
import { serializeList } from "./list";
import { getPostRelations, serializePost } from "./status";
import { generateOutbox } from "./outbox";
import { getPostRelations } from "./status";

const homeUrl = process.env["HOME_URL"] || "http://localhost:3000";

// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const homeUrl = process.env["HOME_URL"] || "http://localhost:3000/";
// Account Exporter class to handle data loading and serialization
export class AccountExporter {
actorId: ActorIdType;
Expand Down Expand Up @@ -111,9 +113,13 @@ export class AccountExporter {
if (!account) return c.json({ error: "Actor not found" }, 404);

const postsData = await this.loadPosts();
const serializedPosts = postsData.map((post) =>
serializePost(post, { id: account.owner.id }, c.req.url),
);
console.log("🚀 ~ AccountExporter ~ exportData ~ postsData:", postsData);

const actor = (await lookupObject(account.iri)) as Actor;

const outbox = await generateOutbox(actor, new URL(homeUrl));

console.log("🚀 ~ AccountExporter ~ exportData ~ outbox:", outbox);

const lists = await this.loadLists();
const serializedLists = lists.map((list) => serializeList(list));
Expand All @@ -133,7 +139,7 @@ export class AccountExporter {
{ ...account, successor: null },
c.req.url,
),
outbox: serializedPosts,
outbox: outbox,

Choose a reason for hiding this comment

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

Suggested change
outbox: outbox,
outbox,

lists: serializedLists,
followers: serializedFollowers,
followingAccounts: serializedFollowing,
Expand Down
61 changes: 37 additions & 24 deletions src/entities/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,19 +223,30 @@ export class AccountImporter {
);
}

async importOutbox(post: Post) {
console.log("🚀 ~ AccountImporter ~ importOutbox ~ post:", post);
async importOutbox(activity: any) {
console.log("🚀 ~ AccountImporter ~ importOutbox ~ activity:", activity);
try {
// Validate the activity object
if (!activity.object || activity.type !== "Create") {
console.error(
"Skipping activity due to invalid type or missing object:",
activity,
);
return;
}

const post = activity.object; // The `Note` object inside the `Create` activity

// Validate the post object
if (!post.url || !post.type || !post.created_at || !post.content) {
if (!post.id || !post.type || !post.published || !post.content) {
console.error("Skipping post due to missing required fields:", post);
return;
}

// Generate a new post ID using cuuid
const postDataCanonical = canonicalize({
uri: post.url, // Use post.url instead of post.iri
createdAt: post.created_at,
uri: post.id, // Use post.id as the unique identifier
createdAt: post.published,
accountId: this.actorId, // Use the new account ID
});

Expand All @@ -247,41 +258,43 @@ export class AccountImporter {
const newPostId = await cuuid.toString();

// Log the post URL for debugging
console.log("🚀 ~ AccountImporter ~ importOutbox ~ post.url:", post.url);
console.log("🚀 ~ AccountImporter ~ importOutbox ~ post.id:", post.id);

// Check if the post already exists
const isExistingPost = await db.query.posts.findFirst({
where: eq(schema.posts.iri, post.url), // Check by URL (iri)
where: eq(schema.posts.iri, post.id), // Check by post.id (iri)
});

if (isExistingPost) {
console.warn(
`Post with URL ${post.url} already exists, updating instead of skipping`,
`Post with ID ${post.id} already exists, updating instead of skipping`,
);
}

const postData = {
id: newPostId,
iri: post.url,
iri: post.id, // Use post.id as the unique identifier
type: post.type,
accountId: this.actorId,
createdAt: new Date(post.created_at),
inReplyToId: post.in_reply_to_id || null,
createdAt: new Date(post.published),
inReplyToId: post.inReplyTo
? new URL(post.inReplyTo).pathname.split("/").pop()
: null, // Extract ID from inReplyTo URL
sensitive: post.sensitive || false,
spoilerText: post.spoiler_text || "",
visibility: post.visibility || "public",
language: post.language || "en",
url: post.url,
repliesCount: post.replies_count || 0,
reblogsCount: post.reblogs_count || 0,
favouritesCount: post.favourites_count || 0,
favourited: post.favourited || false,
reblogged: post.reblogged || false,
muted: post.muted || false,
bookmarked: post.bookmarked || false,
pinned: post.pinned || false,
spoilerText: post.summary || "", // Use post.summary as spoilerText
visibility: "public" as "public" | "unlisted" | "private" | "direct",
language: post.contentMap?.en ? "en" : "und",
url: post.url || post.id,
repliesCount: post.replies?.totalItems || 0,
reblogsCount: post.shares?.totalItems || 0,
favouritesCount: post.likes?.totalItems || 0,
favourited: false, // Default value
reblogged: false, // Default value
muted: false, // Default value
bookmarked: false, // Default value
pinned: false, // Default value
contentHtml: post.content,
quoteId: post.quote_id || null,
quoteId: null, // Default value (no quote support yet)
};

// Insert or update the post
Expand Down
149 changes: 149 additions & 0 deletions src/entities/outbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {
Activity,
type Actor,
type Object as FedifyObject,
} from "@fedify/fedify";
import { iterateCollection } from "../federation/collection";

// Helper to get tags as an array
async function getTagsAsArray(
object: FedifyObject,
): Promise<Array<{ type: string; href: string; name: string }>> {
const tags = [];
for await (const tag of object.getTags() as any) {
tags.push({
type: tag.id?.toString(),
href: tag.href?.toString(),
name: tag.name,
});
}
return tags;
}

async function getRepliesAsArray(object: any): Promise<Array<{ id: string; type: string, totalItems: number }>> {

Check failure on line 23 in src/entities/outbox.ts

View workflow job for this annotation

GitHub Actions / check

'getRepliesAsArray' is declared but its value is never read.
if (!object?.getReplies) return [];
const replies = [];
for await (const reply of object.getReplies()) {
const id = safeToString(reply.id);
const type = safeToString(reply.typeId);
if (id && type) {
replies.push({
id,
type,
totalItems: reply.totalItems,
});
}
}
return replies.filter((reply) => reply.id && reply.type); // Remove incomplete entries
}

async function fetchOutbox(actor: Actor) {
const outbox = await actor.getOutbox();
console.log("🚀 ~ fetchOutbox ~ outbox:", outbox);
if (!outbox) return null;

const activities: Activity[] = [];
for await (const activity of iterateCollection(outbox)) {
if (activity instanceof Activity) {
activities.push(activity);
}
}

console.log("🚀 ~ fetchOutbox ~ activities:", activities);
return activities;
}

function safeToString(value: unknown): string | undefined {
return value?.toString();
}

function cleanObject(obj: Record<string, any>): Record<string, any> {
const cleaned: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
if (value !== null && value !== undefined) {
cleaned[key] = value;
}
}
return cleaned;
}

async function generateOutbox(actor: Actor, baseUrl: string | URL) {
const activities = await fetchOutbox(actor);
if (!activities) return null;

const outbox = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
id: new URL("/outbox.json", baseUrl).toString(),
type: "OrderedCollection",
totalItems: activities.length,
orderedItems: await Promise.all(
activities.map(async (activity) => {
console.log("🚀 ~ Processing activity:", activity);

const object = await activity.getObject();
console.log("🚀 ~ Retrieved object:", object);

if (!object) {
console.log("🚀 ~ Object is null, skipping activity");
return null;
}

const replies = await object.getReplies();
console.log("🚀 ~ activities.map ~ replies:", replies)
const likes = await object.getLikes();
console.log("🚀 ~ activities.map ~ likes:", likes)
const shares = await object.getShares();
console.log("🚀 ~ activities.map ~ shares:", shares

)
const to = object.toIds;
const cc = object.ccIds;

const tags = await getTagsAsArray(object);

const fullObject = cleanObject({
id: safeToString(object.id),
type: safeToString(object.id),
content: object.content,
published: safeToString(object.published),
url: safeToString(object.url),
to: to.length > 0 ? to : undefined,
cc: cc.length > 0 ? cc : undefined,
tags: tags.length > 0 ? tags : undefined,
replies: {
id: replies?.id,
totalITems: replies?.totalItems,
items: replies?.getItems
},
likes: {
id: likes?.id,
totalItems: likes?.totalItems,
items: likes?.getItems
},
shares: {
id: shares?.id,
totalItems: shares?.totalItems,
items: shares?.getItems
},
});

return cleanObject({
id: safeToString(activity.id),
type: "OrderedCollection",
actor: safeToString(activity.actorId),
published: safeToString(activity.published),
to: activity.toIds,
cc: activity.ccIds,
object: fullObject,
});
}),
).then((items) => items.filter(Boolean)), // Remove null entries
};

return outbox;
}

export { generateOutbox };
61 changes: 24 additions & 37 deletions src/types/account.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,45 +32,32 @@ interface ActorProfile {

// Define an interface for a Post
interface Post {
id: string | SQL<unknown> | ActorIdType;
id: string;
iri: string;
created_at: string;
in_reply_to_id: null | string;
type: SQL<unknown> | "Article" | "Note" | "Question" | undefined;
type: string;
accountId: string;
applicationId: string | null; // Allow null
replyTargetId?: string | null;
sharingId?: string | null;
quoteTargetId?: `${string}-${string}-${string}-${string}-${string}` | null;
visibility: string;
summary?: string | null;
contentHtml?: string | null;
content?: string | null;
pollId?: string | null;
language?: string | null;
tags: Record<string, string>;
emojis: Record<string, string>;
sensitive: boolean;
spoiler_text: string;
visibility:
| SQL<unknown>
| "public"
| "unlisted"
| "private"
| "direct"
| undefined;
language: string;
uri: string;
url: null | string;
replies_count: number;
reblogs_count: number;
favourites_count: number;
favourited: boolean;
reblogged: boolean;
muted: boolean;
bookmarked: boolean;
pinned: boolean;
content: string;
reblog: null | Post;
quote_id: null | string;
quote: null | Post;
application: null | string;
account: ActorProfile;
media_attachments: Array<{ url: string }>;
mentions: Array<{ username: string; url: string }>;
tags: Array<{ name: string }>;
card: null | { url: string; title: string; description: string };
emojis: Array<{ shortcode: string; url: string }>;
emoji_reactions: Array<{ emoji: string; count: number }>;
poll: null | { options: Array<{ title: string; votes_count: number }> };
filtered: null | Array<{ filter: string }>;
url?: string | null;
previewCard?: any | null;
repliesCount: number;
sharesCount: number;
likesCount: number;
idempotenceKey?: string;

Choose a reason for hiding this comment

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

What is this field for?

published?: Date | null;
updated: Date;
media?: Array<{ id: string; url: string; contentType: string }> | null;
}

// Define an interface for FollowersData
Expand Down
Loading