From 6e34e77372bd74c85ebf5a6b4214c818231dbe8b Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 8 Apr 2018 06:55:26 +0900 Subject: [PATCH] Implement announce And bug fixes --- src/models/note.ts | 2 +- src/models/notification.ts | 3 - src/remote/activitypub/act/announce/index.ts | 39 +++ src/remote/activitypub/act/announce/note.ts | 52 ++++ src/remote/activitypub/act/index.ts | 5 + src/remote/activitypub/act/like.ts | 2 +- src/remote/activitypub/renderer/announce.ts | 4 + src/remote/activitypub/renderer/like.ts | 3 +- src/remote/activitypub/renderer/note.ts | 8 +- src/remote/activitypub/resolve-person.ts | 10 +- src/remote/activitypub/resolver.ts | 7 + src/remote/activitypub/type.ts | 15 +- src/server/activitypub/note.ts | 23 +- src/server/activitypub/outbox.ts | 2 +- src/server/api/endpoints/posts/create.ts | 251 ------------------- src/services/note/create.ts | 32 ++- src/services/note/reaction/create.ts | 6 +- 17 files changed, 164 insertions(+), 300 deletions(-) create mode 100644 src/remote/activitypub/act/announce/index.ts create mode 100644 src/remote/activitypub/act/announce/note.ts create mode 100644 src/remote/activitypub/renderer/announce.ts delete mode 100644 src/server/api/endpoints/posts/create.ts diff --git a/src/models/note.ts b/src/models/note.ts index 9b33bb76f..f509fa66c 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -112,7 +112,7 @@ export const pack = async ( _note = deepcopy(note); } - if (!_note) throw 'invalid note arg.'; + if (!_note) throw `invalid note arg ${note}`; const id = _note._id; diff --git a/src/models/notification.ts b/src/models/notification.ts index 17144d7ee..d5ca7135b 100644 --- a/src/models/notification.ts +++ b/src/models/notification.ts @@ -51,9 +51,6 @@ export interface INotification { /** * Pack a notification for API response - * - * @param {any} notification - * @return {Promise} */ export const pack = (notification: any) => new Promise(async (resolve, reject) => { let _notification: any; diff --git a/src/remote/activitypub/act/announce/index.ts b/src/remote/activitypub/act/announce/index.ts new file mode 100644 index 000000000..c3ac06607 --- /dev/null +++ b/src/remote/activitypub/act/announce/index.ts @@ -0,0 +1,39 @@ +import * as debug from 'debug'; + +import Resolver from '../../resolver'; +import { IRemoteUser } from '../../../../models/user'; +import announceNote from './note'; +import { IAnnounce } from '../../type'; + +const log = debug('misskey:activitypub'); + +export default async (actor: IRemoteUser, activity: IAnnounce): Promise => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + const uri = activity.id || activity; + + log(`Announce: ${uri}`); + + const resolver = new Resolver(); + + let object; + + try { + object = await resolver.resolve(activity.object); + } catch (e) { + log(`Resolution failed: ${e}`); + throw e; + } + + switch (object.type) { + case 'Note': + announceNote(resolver, actor, activity, object); + break; + + default: + console.warn(`Unknown announce type: ${object.type}`); + break; + } +}; diff --git a/src/remote/activitypub/act/announce/note.ts b/src/remote/activitypub/act/announce/note.ts new file mode 100644 index 000000000..24d159f18 --- /dev/null +++ b/src/remote/activitypub/act/announce/note.ts @@ -0,0 +1,52 @@ +import * as debug from 'debug'; + +import Resolver from '../../resolver'; +import Note from '../../../../models/note'; +import post from '../../../../services/note/create'; +import { IRemoteUser, isRemoteUser } from '../../../../models/user'; +import { IAnnounce, INote } from '../../type'; +import createNote from '../create/note'; +import resolvePerson from '../../resolve-person'; + +const log = debug('misskey:activitypub'); + +/** + * アナウンスアクティビティを捌きます + */ +export default async function(resolver: Resolver, actor: IRemoteUser, activity: IAnnounce, note: INote): Promise { + const uri = activity.id || activity; + + if (typeof uri !== 'string') { + throw new Error('invalid announce'); + } + + // 既に同じURIを持つものが登録されていないかチェック + const exist = await Note.findOne({ uri }); + if (exist) { + return; + } + + // アナウンス元の投稿の投稿者をフェッチ + const announcee = await resolvePerson(note.attributedTo); + + const renote = isRemoteUser(announcee) + ? await createNote(resolver, announcee, note, true) + : await Note.findOne({ _id: note.id.split('/').pop() }); + + log(`Creating the (Re)Note: ${uri}`); + + //#region Visibility + let visibility = 'public'; + if (!activity.to.includes('https://www.w3.org/ns/activitystreams#Public')) visibility = 'unlisted'; + if (activity.cc.length == 0) visibility = 'private'; + // TODO + if (visibility != 'public') throw new Error('unspported visibility'); + //#endergion + + await post(actor, { + createdAt: new Date(activity.published), + renote, + visibility, + uri + }); +} diff --git a/src/remote/activitypub/act/index.ts b/src/remote/activitypub/act/index.ts index 45d7bd16a..15ea9494a 100644 --- a/src/remote/activitypub/act/index.ts +++ b/src/remote/activitypub/act/index.ts @@ -5,6 +5,7 @@ import performDeleteActivity from './delete'; import follow from './follow'; import undo from './undo'; import like from './like'; +import announce from './announce'; const self = async (actor: IRemoteUser, activity: Object): Promise => { switch (activity.type) { @@ -24,6 +25,10 @@ const self = async (actor: IRemoteUser, activity: Object): Promise => { // noop break; + case 'Announce': + await announce(actor, activity); + break; + case 'Like': await like(actor, activity); break; diff --git a/src/remote/activitypub/act/like.ts b/src/remote/activitypub/act/like.ts index a3243948b..494160858 100644 --- a/src/remote/activitypub/act/like.ts +++ b/src/remote/activitypub/act/like.ts @@ -7,7 +7,7 @@ export default async (actor: IRemoteUser, activity: ILike) => { const id = typeof activity.object == 'string' ? activity.object : activity.object.id; // Transform: - // https://misskey.ex/@syuilo/xxxx to + // https://misskey.ex/notes/xxxx to // xxxx const noteId = id.split('/').pop(); diff --git a/src/remote/activitypub/renderer/announce.ts b/src/remote/activitypub/renderer/announce.ts new file mode 100644 index 000000000..8e4b3d26a --- /dev/null +++ b/src/remote/activitypub/renderer/announce.ts @@ -0,0 +1,4 @@ +export default object => ({ + type: 'Announce', + object +}); diff --git a/src/remote/activitypub/renderer/like.ts b/src/remote/activitypub/renderer/like.ts index fe36c7094..744896cc4 100644 --- a/src/remote/activitypub/renderer/like.ts +++ b/src/remote/activitypub/renderer/like.ts @@ -1,6 +1,7 @@ import config from '../../../config'; +import { ILocalUser } from '../../../models/user'; -export default (user, note) => { +export default (user: ILocalUser, note) => { return { type: 'Like', actor: `${config.url}/@${user.username}`, diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts index 244aecf6a..48799af08 100644 --- a/src/remote/activitypub/renderer/note.ts +++ b/src/remote/activitypub/renderer/note.ts @@ -3,9 +3,9 @@ import renderHashtag from './hashtag'; import config from '../../../config'; import DriveFile from '../../../models/drive-file'; import Note, { INote } from '../../../models/note'; -import User, { IUser } from '../../../models/user'; +import User from '../../../models/user'; -export default async (user: IUser, note: INote) => { +export default async (note: INote) => { const promisedFiles = note.mediaIds ? DriveFile.find({ _id: { $in: note.mediaIds } }) : Promise.resolve([]); @@ -30,6 +30,10 @@ export default async (user: IUser, note: INote) => { inReplyTo = null; } + const user = await User.findOne({ + _id: note.userId + }); + const attributedTo = `${config.url}/@${user.username}`; return { diff --git a/src/remote/activitypub/resolve-person.ts b/src/remote/activitypub/resolve-person.ts index ddb8d6871..7d7989a01 100644 --- a/src/remote/activitypub/resolve-person.ts +++ b/src/remote/activitypub/resolve-person.ts @@ -2,18 +2,18 @@ import { JSDOM } from 'jsdom'; import { toUnicode } from 'punycode'; import parseAcct from '../../acct/parse'; import config from '../../config'; -import User, { validateUsername, isValidName, isValidDescription } from '../../models/user'; +import User, { validateUsername, isValidName, isValidDescription, IUser } from '../../models/user'; import webFinger from '../webfinger'; import Resolver from './resolver'; import uploadFromUrl from '../../services/drive/upload-from-url'; -import { isCollectionOrOrderedCollection } from './type'; +import { isCollectionOrOrderedCollection, IObject } from './type'; -export default async (value, verifier?: string) => { - const id = value.id || value; +export default async (value: string | IObject, verifier?: string): Promise => { + const id = typeof value == 'string' ? value : value.id; const localPrefix = config.url + '/@'; if (id.startsWith(localPrefix)) { - return User.findOne(parseAcct(id.slice(localPrefix))); + return await User.findOne(parseAcct(id.substr(localPrefix.length))); } const resolver = new Resolver(); diff --git a/src/remote/activitypub/resolver.ts b/src/remote/activitypub/resolver.ts index 4a97e2ef6..1466139c4 100644 --- a/src/remote/activitypub/resolver.ts +++ b/src/remote/activitypub/resolver.ts @@ -1,6 +1,7 @@ import * as request from 'request-promise-native'; import * as debug from 'debug'; import { IObject } from './type'; +//import config from '../../config'; const log = debug('misskey:activitypub:resolver'); @@ -47,6 +48,11 @@ export default class Resolver { this.history.add(value); + //#region resolve local objects + // TODO + //if (value.startsWith(`${config.url}/@`)) { + //#endregion + const object = await request({ url: value, headers: { @@ -60,6 +66,7 @@ export default class Resolver { !object['@context'].includes('https://www.w3.org/ns/activitystreams') : object['@context'] !== 'https://www.w3.org/ns/activitystreams' )) { + log(`invalid response: ${JSON.stringify(object, null, 2)}`); throw new Error('invalid response'); } diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts index 450d5906d..233551764 100644 --- a/src/remote/activitypub/type.ts +++ b/src/remote/activitypub/type.ts @@ -5,6 +5,10 @@ export interface IObject { type: string; id?: string; summary?: string; + published?: string; + cc?: string[]; + to?: string[]; + attributedTo: string; } export interface IActivity extends IObject { @@ -26,6 +30,10 @@ export interface IOrderedCollection extends IObject { orderedItems: IObject | string | IObject[] | string[]; } +export interface INote extends IObject { + type: 'Note'; +} + export const isCollection = (object: IObject): object is ICollection => object.type === 'Collection'; @@ -59,6 +67,10 @@ export interface ILike extends IActivity { type: 'Like'; } +export interface IAnnounce extends IActivity { + type: 'Announce'; +} + export type Object = ICollection | IOrderedCollection | @@ -67,4 +79,5 @@ export type Object = IUndo | IFollow | IAccept | - ILike; + ILike | + IAnnounce; diff --git a/src/server/activitypub/note.ts b/src/server/activitypub/note.ts index cea9be52d..1c2e695b8 100644 --- a/src/server/activitypub/note.ts +++ b/src/server/activitypub/note.ts @@ -1,40 +1,25 @@ import * as express from 'express'; import context from '../../remote/activitypub/renderer/context'; import render from '../../remote/activitypub/renderer/note'; -import parseAcct from '../../acct/parse'; import Note from '../../models/note'; -import User from '../../models/user'; const app = express.Router(); -app.get('/@:user/:note', async (req, res, next) => { +app.get('/notes/:note', async (req, res, next) => { const accepted = req.accepts(['html', 'application/activity+json', 'application/ld+json']); if (!(['application/activity+json', 'application/ld+json'] as any[]).includes(accepted)) { return next(); } - const { username, host } = parseAcct(req.params.user); - if (host !== null) { - return res.sendStatus(422); - } - - const user = await User.findOne({ - usernameLower: username.toLowerCase(), - host: null - }); - if (user === null) { - return res.sendStatus(404); - } - const note = await Note.findOne({ - _id: req.params.note, - userId: user._id + _id: req.params.note }); + if (note === null) { return res.sendStatus(404); } - const rendered = await render(user, note); + const rendered = await render(note); rendered['@context'] = context; res.json(rendered); diff --git a/src/server/activitypub/outbox.ts b/src/server/activitypub/outbox.ts index b6f3a3f9d..4557871bc 100644 --- a/src/server/activitypub/outbox.ts +++ b/src/server/activitypub/outbox.ts @@ -16,7 +16,7 @@ app.get('/@:user/outbox', withUser(username => { sort: { _id: -1 } }); - const renderedNotes = await Promise.all(notes.map(note => renderNote(user, note))); + const renderedNotes = await Promise.all(notes.map(note => renderNote(note))); const rendered = renderOrderedCollection(`${config.url}/@${user.username}/inbox`, user.notesCount, renderedNotes); rendered['@context'] = context; diff --git a/src/server/api/endpoints/posts/create.ts b/src/server/api/endpoints/posts/create.ts deleted file mode 100644 index 7e79912b1..000000000 --- a/src/server/api/endpoints/posts/create.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Module dependencies - */ -import $ from 'cafy'; -import deepEqual = require('deep-equal'); -import Note, { INote, isValidText, isValidCw, pack } from '../../../../models/note'; -import { ILocalUser } from '../../../../models/user'; -import Channel, { IChannel } from '../../../../models/channel'; -import DriveFile from '../../../../models/drive-file'; -import create from '../../../../services/note/create'; -import { IApp } from '../../../../models/app'; - -/** - * Create a note - * - * @param {any} params - * @param {any} user - * @param {any} app - * @return {Promise} - */ -module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => { - // Get 'visibility' parameter - const [visibility = 'public', visibilityErr] = $(params.visibility).optional.string().or(['public', 'unlisted', 'private', 'direct']).$; - if (visibilityErr) return rej('invalid visibility'); - - // Get 'text' parameter - const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$; - if (textErr) return rej('invalid text'); - - // Get 'cw' parameter - const [cw, cwErr] = $(params.cw).optional.string().pipe(isValidCw).$; - if (cwErr) return rej('invalid cw'); - - // Get 'viaMobile' parameter - const [viaMobile = false, viaMobileErr] = $(params.viaMobile).optional.boolean().$; - if (viaMobileErr) return rej('invalid viaMobile'); - - // Get 'tags' parameter - const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$; - if (tagsErr) return rej('invalid tags'); - - // Get 'geo' parameter - const [geo, geoErr] = $(params.geo).optional.nullable.strict.object() - .have('coordinates', $().array().length(2) - .item(0, $().number().range(-180, 180)) - .item(1, $().number().range(-90, 90))) - .have('altitude', $().nullable.number()) - .have('accuracy', $().nullable.number()) - .have('altitudeAccuracy', $().nullable.number()) - .have('heading', $().nullable.number().range(0, 360)) - .have('speed', $().nullable.number()) - .$; - if (geoErr) return rej('invalid geo'); - - // Get 'mediaIds' parameter - const [mediaIds, mediaIdsErr] = $(params.mediaIds).optional.array('id').unique().range(1, 4).$; - if (mediaIdsErr) return rej('invalid mediaIds'); - - let files = []; - if (mediaIds !== undefined) { - // Fetch files - // forEach だと途中でエラーなどがあっても return できないので - // 敢えて for を使っています。 - for (const mediaId of mediaIds) { - // Fetch file - // SELECT _id - const entity = await DriveFile.findOne({ - _id: mediaId, - 'metadata.userId': user._id - }); - - if (entity === null) { - return rej('file not found'); - } else { - files.push(entity); - } - } - } else { - files = null; - } - - // Get 'renoteId' parameter - const [renoteId, renoteIdErr] = $(params.renoteId).optional.id().$; - if (renoteIdErr) return rej('invalid renoteId'); - - let renote: INote = null; - let isQuote = false; - if (renoteId !== undefined) { - // Fetch renote to note - renote = await Note.findOne({ - _id: renoteId - }); - - if (renote == null) { - return rej('renoteee is not found'); - } else if (renote.renoteId && !renote.text && !renote.mediaIds) { - return rej('cannot renote to renote'); - } - - // Fetch recently note - const latestNote = await Note.findOne({ - userId: user._id - }, { - sort: { - _id: -1 - } - }); - - isQuote = text != null || files != null; - - // 直近と同じRenote対象かつ引用じゃなかったらエラー - if (latestNote && - latestNote.renoteId && - latestNote.renoteId.equals(renote._id) && - !isQuote) { - return rej('cannot renote same note that already reposted in your latest note'); - } - - // 直近がRenote対象かつ引用じゃなかったらエラー - if (latestNote && - latestNote._id.equals(renote._id) && - !isQuote) { - return rej('cannot renote your latest note'); - } - } - - // Get 'replyId' parameter - const [replyId, replyIdErr] = $(params.replyId).optional.id().$; - if (replyIdErr) return rej('invalid replyId'); - - let reply: INote = null; - if (replyId !== undefined) { - // Fetch reply - reply = await Note.findOne({ - _id: replyId - }); - - if (reply === null) { - return rej('in reply to note is not found'); - } - - // 返信対象が引用でないRenoteだったらエラー - if (reply.renoteId && !reply.text && !reply.mediaIds) { - return rej('cannot reply to renote'); - } - } - - // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).optional.id().$; - if (channelIdErr) return rej('invalid channelId'); - - let channel: IChannel = null; - if (channelId !== undefined) { - // Fetch channel - channel = await Channel.findOne({ - _id: channelId - }); - - if (channel === null) { - return rej('channel not found'); - } - - // 返信対象の投稿がこのチャンネルじゃなかったらダメ - if (reply && !channelId.equals(reply.channelId)) { - return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません'); - } - - // Renote対象の投稿がこのチャンネルじゃなかったらダメ - if (renote && !channelId.equals(renote.channelId)) { - return rej('チャンネル内部からチャンネル外部の投稿をRenoteすることはできません'); - } - - // 引用ではないRenoteはダメ - if (renote && !isQuote) { - return rej('チャンネル内部では引用ではないRenoteをすることはできません'); - } - } else { - // 返信対象の投稿がチャンネルへの投稿だったらダメ - if (reply && reply.channelId != null) { - return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません'); - } - - // Renote対象の投稿がチャンネルへの投稿だったらダメ - if (renote && renote.channelId != null) { - return rej('チャンネル外部からチャンネル内部の投稿をRenoteすることはできません'); - } - } - - // Get 'poll' parameter - const [poll, pollErr] = $(params.poll).optional.strict.object() - .have('choices', $().array('string') - .unique() - .range(2, 10) - .each(c => c.length > 0 && c.length < 50)) - .$; - if (pollErr) return rej('invalid poll'); - - if (poll) { - (poll as any).choices = (poll as any).choices.map((choice, i) => ({ - id: i, // IDを付与 - text: choice.trim(), - votes: 0 - })); - } - - // テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー - if (text === undefined && files === null && renote === null && poll === undefined) { - return rej('text, mediaIds, renoteId or poll is required'); - } - - // 直近の投稿と重複してたらエラー - // TODO: 直近の投稿が一日前くらいなら重複とは見なさない - if (user.latestNote) { - if (deepEqual({ - text: user.latestNote.text, - reply: user.latestNote.replyId ? user.latestNote.replyId.toString() : null, - renote: user.latestNote.renoteId ? user.latestNote.renoteId.toString() : null, - mediaIds: (user.latestNote.mediaIds || []).map(id => id.toString()) - }, { - text: text, - reply: reply ? reply._id.toString() : null, - renote: renote ? renote._id.toString() : null, - mediaIds: (files || []).map(file => file._id.toString()) - })) { - return rej('duplicate'); - } - } - - // 投稿を作成 - const note = await create(user, { - createdAt: new Date(), - media: files, - poll: poll, - text: text, - reply, - renote, - cw: cw, - tags: tags, - app: app, - viaMobile: viaMobile, - visibility, - geo - }); - - const noteObj = await pack(note, user); - - // Reponse - res({ - createdNote: noteObj - }); -}); diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 551d61856..aac207cc1 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -5,6 +5,7 @@ import Following from '../../models/following'; import { deliver } from '../../queue'; import renderNote from '../../remote/activitypub/renderer/note'; import renderCreate from '../../remote/activitypub/renderer/create'; +import renderAnnounce from '../../remote/activitypub/renderer/announce'; import context from '../../remote/activitypub/renderer/context'; import { IDriveFile } from '../../models/drive-file'; import notify from '../../publishers/notify'; @@ -34,6 +35,7 @@ export default async (user: IUser, data: { }, silent = false) => new Promise(async (res, rej) => { if (data.createdAt == null) data.createdAt = new Date(); if (data.visibility == null) data.visibility = 'public'; + if (data.viaMobile == null) data.viaMobile = false; const tags = data.tags || []; @@ -77,9 +79,7 @@ export default async (user: IUser, data: { _user: { host: user.host, hostLower: user.hostLower, - account: isLocalUser(user) ? {} : { - inbox: user.inbox - } + inbox: isRemoteUser(user) ? user.inbox : undefined } }; @@ -128,15 +128,25 @@ export default async (user: IUser, data: { }); if (!silent) { - const content = renderCreate(await renderNote(user, note)); - content['@context'] = context; + const render = async () => { + const content = data.renote && data.text == null + ? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote)) + : renderCreate(await renderNote(note)); + content['@context'] = context; + return content; + }; // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 if (data.reply && isLocalUser(user) && isRemoteUser(data.reply._user)) { - deliver(user, content, data.reply._user.inbox).save(); + deliver(user, await render(), data.reply._user.inbox).save(); } - Promise.all(followers.map(follower => { + // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 + if (data.renote && isLocalUser(user) && isRemoteUser(data.renote._user)) { + deliver(user, await render(), data.renote._user.inbox).save(); + } + + Promise.all(followers.map(async follower => { follower = follower.user[0]; if (isLocalUser(follower)) { @@ -145,7 +155,7 @@ export default async (user: IUser, data: { } else { // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信 if (isLocalUser(user)) { - deliver(user, content, follower.inbox).save(); + deliver(user, await render(), follower.inbox).save(); } } })); @@ -255,15 +265,13 @@ export default async (user: IUser, data: { // Notify const type = data.text ? 'quote' : 'renote'; notify(data.renote.userId, user._id, type, { - note_id: note._id + noteId: note._id }); // Fetch watchers NoteWatching.find({ noteId: data.renote._id, - userId: { $ne: user._id }, - // 削除されたドキュメントは除く - deletedAt: { $exists: false } + userId: { $ne: user._id } }, { fields: { userId: true diff --git a/src/services/note/reaction/create.ts b/src/services/note/reaction/create.ts index ea51b205d..88158034f 100644 --- a/src/services/note/reaction/create.ts +++ b/src/services/note/reaction/create.ts @@ -83,11 +83,11 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise } //#region 配信 - const content = renderLike(user, note); - content['@context'] = context; - // リアクターがローカルユーザーかつリアクション対象がリモートユーザーの投稿なら配送 if (isLocalUser(user) && isRemoteUser(note._user)) { + const content = renderLike(user, note); + content['@context'] = context; + deliver(user, content, note._user.inbox).save(); } //#endregion