enigma-bbs/core/activitypub/note.js

237 lines
8.9 KiB
JavaScript

const Message = require('../message');
const ActivityPubObject = require('./object');
const { Errors } = require('../enig_error');
const { getISOTimestampString } = require('../database');
const User = require('../user');
const { messageBodyToHtml, htmlToMessageBody } = require('./util');
const { isAnsi } = require('../string_util');
const Log = require('../logger').log;
// deps
const { v5: UUIDv5 } = require('uuid');
const Actor = require('./actor');
const moment = require('moment');
const Collection = require('./collection');
const async = require('async');
const { isString, isObject, truncate } = require('lodash');
const APMessageIdNamespace = '307bc7b3-3735-4573-9a20-e3f9eaac29c5';
const APDefaultSummary = '[ActivityPub]';
module.exports = class Note extends ActivityPubObject {
constructor(obj) {
super(obj);
}
isValid() {
if (!super.isValid()) {
return false;
}
// :TODO: validate required properties
return true;
}
static fromPublicNoteId(noteId, cb) {
Collection.embeddedObjById('outbox', false, noteId, (err, obj) => {
if (err) {
return cb(err);
}
return cb(null, new Note(obj.object));
});
}
// A local Message bound for ActivityPub
static fromLocalOutgoingMessage(message, webServer, cb) {
const localUserId = message.getLocalFromUserId();
if (!localUserId) {
return cb(Errors.UnexpectedState('Invalid user ID for local user!'));
}
if (Message.AddressFlavor.ActivityPub !== message.getAddressFlavor()) {
return cb(
Errors.Invalid('Cannot build note for non-ActivityPub addressed message')
);
}
const remoteActorAccount = message.getRemoteToUser();
if (!remoteActorAccount) {
return cb(
Errors.UnexpectedState('Message does not contain a remote address')
);
}
async.waterfall(
[
callback => {
return User.getUser(localUserId, callback);
},
(fromUser, callback) => {
Actor.fromLocalUser(fromUser, webServer, (err, fromActor) => {
return callback(err, fromUser, fromActor);
});
},
(fromUser, fromActor, callback) => {
Actor.fromId(remoteActorAccount, (err, remoteActor) => {
return callback(err, fromUser, fromActor, remoteActor);
});
},
(fromUser, fromActor, remoteActor, callback) => {
if (!message.replyToMsgId) {
return callback(null, null, fromUser, fromActor, remoteActor);
}
Message.getMetaValuesByMessageId(
message.replyToMsgId,
Message.WellKnownMetaCategories.ActivityPub,
Message.ActivityPubPropertyNames.NoteId,
(err, replyToNoteId) => {
// (ignore error)
return callback(
null,
replyToNoteId,
fromUser,
fromActor,
remoteActor
);
}
);
},
(replyToNoteId, fromUser, fromActor, remoteActor, callback) => {
const to = [
message.isPrivate()
? remoteActor.id
: Collection.PublicCollectionId,
];
const sourceMediaType = isAnsi(message.message)
? 'text/x-ansi' // ye ol' https://lists.freedesktop.org/archives/xdg/2006-March/006214.html
: 'text/plain';
// https://docs.joinmastodon.org/spec/activitypub/#properties-used
const obj = {
id: ActivityPubObject.makeObjectId(webServer, 'note'),
type: 'Note',
published: getISOTimestampString(message.modTimestamp),
to,
attributedTo: fromActor.id,
content: messageBodyToHtml(message.message.trim()),
source: {
content: message.message,
mediaType: sourceMediaType,
},
};
if (replyToNoteId) {
obj.inReplyTo = replyToNoteId;
}
// ignore the subject if it's our default summary value for replies
if (message.subject !== `RE: ${APDefaultSummary}`) {
obj.summary = message.subject;
}
const note = new Note(obj);
return callback(null, { note, fromUser, remoteActor });
},
],
(err, noteInfo) => {
return cb(err, noteInfo);
}
);
}
toMessage(options, cb) {
if (!isObject(options.toUser) || !isString(options.areaTag)) {
return cb(Errors.MissingParam('Missing one or more required options!'));
}
// stable ID based on Note ID
const message = new Message({
uuid: UUIDv5(this.id, APMessageIdNamespace),
});
// Fetch the remote actor info to get their user info
Actor.fromId(this.attributedTo, (err, attributedToActor, fromActorSubject) => {
if (err) {
return cb(err);
}
message.fromUserName = fromActorSubject || this.attributedTo;
//
// Note's can be addressed to 1:N users, but a Message is a 1:1
// relationship. This method requires the mapping up front via options
//
message.toUserName = options.toUser.username;
message.meta.System[Message.SystemMetaNames.LocalToUserID] =
options.toUser.userId;
message.areaTag = options.areaTag || Message.WellKnownAreaTags.Private;
// :TODO: it would be better to do some basic HTML to ANSI or pipe codes perhaps
message.message = htmlToMessageBody(this.content);
// If the summary is not present, build one using the message itself;
// finally, default to a static subject so there is *something* if
// all else fails.
if (this.summary) {
message.subject = this.summary;
} else {
let subject = message.message.replace(`@${message.toUserName} `, '');
subject = truncate(subject, { length: 32, omission: '...' });
subject = subject || APDefaultSummary;
message.subject = subject;
}
try {
message.modTimestamp = moment(this.published);
} catch (e) {
Log.warn(
{ published: this.published, error: e.message },
'Failed to parse Note published timestamp'
);
message.modTimestamp = moment();
}
// :TODO: replyToMsgId from 'inReplyTo'
message.setRemoteFromUser(this.attributedTo);
message.setExternalFlavor(Message.AddressFlavor.ActivityPub);
message.meta.ActivityPub = message.meta.ActivityPub || {};
message.meta.ActivityPub[Message.ActivityPubPropertyNames.ActivityId] =
options.activityId || 0;
message.meta.ActivityPub[Message.ActivityPubPropertyNames.NoteId] = this.id;
if (this.inReplyTo) {
message.meta.ActivityPub[Message.ActivityPubPropertyNames.InReplyTo] =
this.inReplyTo;
const filter = {
resultType: 'id',
metaTuples: [
{
category: Message.WellKnownMetaCategories.ActivityPub,
name: Message.ActivityPubPropertyNames.InReplyTo,
value: this.inReplyTo,
},
],
limit: 1,
};
Message.findMessages(filter, (err, messageId) => {
if (messageId) {
// we get an array, but limited 1; use the first
messageId = messageId[0];
message.replyToMsgId = messageId;
}
return cb(null, message);
});
} else {
return cb(null, message);
}
});
}
};