From 7e4fd0e28eb6ec358d9c45d7ddc873e3f9531d4f Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Wed, 7 Feb 2024 11:33:41 +0000
Subject: [PATCH] feat: support ChatMessage type

Co-authored-by: Lhcfl <Lhcfl@outlook.com>
---
 .../src/remote/activitypub/models/note.ts     | 10 ++++-
 .../src/remote/activitypub/renderer/index.ts  |  4 ++
 .../backend/src/remote/activitypub/type.ts    |  2 +
 .../backend/src/services/messages/create.ts   | 42 ++++++++++++++++++-
 4 files changed, 55 insertions(+), 3 deletions(-)

diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts
index d9eedb3058..76f471d730 100644
--- a/packages/backend/src/remote/activitypub/models/note.ts
+++ b/packages/backend/src/remote/activitypub/models/note.ts
@@ -129,6 +129,12 @@ export async function createNote(
 		throw new Error(`unexpected schema of note.id: ${note.id}`);
 	}
 
+	// ChatMessage only have id
+	// TODO: split into a separate validate function
+	if (note.type === "ChatMessage") {
+		note.url = note.id;
+	}
+
 	const url = getOneApHrefNullable(note.url);
 
 	if (url && !url.startsWith("https://")) {
@@ -183,7 +189,9 @@ export async function createNote(
 		}
 	}
 
-	let isTalk = note._misskey_talk && visibility === "specified";
+	let isTalk =
+		(note.type === "ChatMessage" || note._misskey_talk) &&
+		visibility === "specified";
 
 	const apMentions = await extractApMentions(note.tag);
 	const apHashtags = await extractApHashtags(note.tag);
diff --git a/packages/backend/src/remote/activitypub/renderer/index.ts b/packages/backend/src/remote/activitypub/renderer/index.ts
index c60a1f4cdb..734d1198c9 100644
--- a/packages/backend/src/remote/activitypub/renderer/index.ts
+++ b/packages/backend/src/remote/activitypub/renderer/index.ts
@@ -49,6 +49,10 @@ export const renderActivity = (x: any): IActivity | null => {
 					fedibird: "http://fedibird.com/ns#",
 					// vcard
 					vcard: "http://www.w3.org/2006/vcard/ns#",
+					// ChatMessage
+					litepub: "http://litepub.social/ns#",
+					ChatMessage: "litepub:ChatMessage",
+					directMessage: "litepub:directMessage",
 				},
 			],
 		},
diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts
index cf0410767b..92889380ac 100644
--- a/packages/backend/src/remote/activitypub/type.ts
+++ b/packages/backend/src/remote/activitypub/type.ts
@@ -115,6 +115,7 @@ export const validPost = [
 	"Page",
 	"Video",
 	"Event",
+	"ChatMessage", // TODO: move it to vaildMessage
 ];
 
 export const isPost = (object: IObject): object is IPost =>
@@ -130,6 +131,7 @@ export interface IPost extends IObject {
 		| "Image"
 		| "Page"
 		| "Video"
+		| "ChatMessage" // TODO: move it to IChatMessage
 		| "Event";
 	source?: {
 		content: string;
diff --git a/packages/backend/src/services/messages/create.ts b/packages/backend/src/services/messages/create.ts
index 506f299966..0b3f8eded9 100644
--- a/packages/backend/src/services/messages/create.ts
+++ b/packages/backend/src/services/messages/create.ts
@@ -22,6 +22,8 @@ import renderNote from "@/remote/activitypub/renderer/note.js";
 import renderCreate from "@/remote/activitypub/renderer/create.js";
 import { renderActivity } from "@/remote/activitypub/renderer/index.js";
 import { deliver } from "@/queue/index.js";
+import { toPuny } from "@/misc/convert-host.js";
+import { Instances } from "@/models/index.js";
 
 export async function createMessage(
 	user: { id: User["id"]; host: User["host"] },
@@ -121,6 +123,9 @@ export async function createMessage(
 		Users.isLocalUser(user) &&
 		Users.isRemoteUser(recipientUser)
 	) {
+		const instance = await Instances.findOneBy({
+			host: toPuny(recipientUser.host),
+		});
 		const note = {
 			id: message.id,
 			createdAt: message.createdAt,
@@ -138,10 +143,43 @@ export async function createMessage(
 			),
 		} as Note;
 
-		const activity = renderActivity(
-			renderCreate(await renderNote(note, false, true), note),
+		let renderedNote: Record<string, unknown> = await renderNote(
+			note,
+			false,
+			true,
 		);
 
+		// TODO: For pleroma and its fork instances, the actor will have a boolean "capabilities": { acceptsChatMessages: boolean } property. May use that instead of checking instance.softwareName. https://kazv.moe/objects/ca5c0b88-88ce-48a7-bf88-54d45f6ce781
+		// ChatMessage document from Pleroma: https://docs.pleroma.social/backend/development/ap_extensions/#chatmessages
+		// Note: LitePub has been stalled since 2019-06-29 and is incomplete as a specification
+		if (
+			instance?.softwareName &&
+			["akkoma", "pleroma", "lemmy"].includes(
+				instance.softwareName.toLowerCase(),
+			)
+		) {
+			const tmp_note = renderedNote;
+			renderedNote = {
+				type: "ChatMessage",
+				attributedTo: tmp_note.attributedTo,
+				content: tmp_note.content,
+				id: tmp_note.id,
+				published: tmp_note.published,
+				to: tmp_note.to,
+				tag: tmp_note.tag,
+				cc: [],
+			};
+			// A recently fixed bug, empty arrays will be rejected by pleroma
+			if (
+				Array.isArray(tmp_note.attachment) &&
+				tmp_note.attachment.length !== 0
+			) {
+				renderedNote.attachment = tmp_note.attachment;
+			}
+		}
+
+		const activity = renderActivity(renderCreate(renderedNote, note));
+
 		deliver(user, activity, recipientUser.inbox);
 	}
 	return messageObj;