Fixed 267U.pre2
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { In } from "typeorm";
|
||||
import create from "./create.js";
|
||||
import { Users, DriveFiles, Channels, Blockings, UserGroups, UserGroupJoinings } from "../../models/index.js";
|
||||
import { getNote } from "../../server/api/common/getters.js";
|
||||
import { ApiError } from "../../server/api/error.js";
|
||||
const errors = {
|
||||
noSuchRenoteTarget: {
|
||||
message: "No such renote target.",
|
||||
code: "NO_SUCH_RENOTE_TARGET",
|
||||
id: "b5c90186-4ab0-49c8-9bba-a1f76c282ba4"
|
||||
},
|
||||
cannotReRenote: {
|
||||
message: "You can not Renote a pure Renote.",
|
||||
code: "CANNOT_RENOTE_TO_A_PURE_RENOTE",
|
||||
id: "fd4cc33e-2a37-48dd-99cc-9b806eb2031a"
|
||||
},
|
||||
noSuchReplyTarget: {
|
||||
message: "No such reply target.",
|
||||
code: "NO_SUCH_REPLY_TARGET",
|
||||
id: "749ee0f6-d3da-459a-bf02-282e2da4292c"
|
||||
},
|
||||
cannotReplyToPureRenote: {
|
||||
message: "You can not reply to a pure Renote.",
|
||||
code: "CANNOT_REPLY_TO_A_PURE_RENOTE",
|
||||
id: "3ac74a84-8fd5-4bb0-870f-01804f82ce15"
|
||||
},
|
||||
cannotCreateAlreadyExpiredPoll: {
|
||||
message: "Poll is already expired.",
|
||||
code: "CANNOT_CREATE_ALREADY_EXPIRED_POLL",
|
||||
id: "04da457d-b083-4055-9082-955525eda5a5"
|
||||
},
|
||||
noSuchChannel: {
|
||||
message: "No such channel.",
|
||||
code: "NO_SUCH_CHANNEL",
|
||||
id: "b1653923-5453-4edc-b786-7c4f39bb0bbb"
|
||||
},
|
||||
youHaveBeenBlocked: {
|
||||
message: "You have been blocked by this user.",
|
||||
code: "YOU_HAVE_BEEN_BLOCKED",
|
||||
id: "b390d7e1-8a5e-46ed-b625-06271cafd3d3"
|
||||
},
|
||||
noSuchGroup: {
|
||||
message: "No such group.",
|
||||
code: "NO_SUCH_GROUP",
|
||||
id: "b6344dfc-7c1d-4238-8cc0-0f719c4f9d91"
|
||||
}
|
||||
};
|
||||
export async function createNoteFromApiData(user, data, createdAt = new Date()) {
|
||||
let visibleUsers = [];
|
||||
if (data.visibleUserIds) {
|
||||
visibleUsers = await Users.findBy({
|
||||
id: In(data.visibleUserIds)
|
||||
});
|
||||
}
|
||||
let files = [];
|
||||
const fileIds = data.fileIds != null ? data.fileIds : data.mediaIds != null ? data.mediaIds : null;
|
||||
if (fileIds != null) {
|
||||
files = await DriveFiles.createQueryBuilder("file").where("file.userId = :userId AND file.id IN (:...fileIds)", {
|
||||
userId: user.id,
|
||||
fileIds
|
||||
}).orderBy('array_position(ARRAY[:...fileIds], "id"::text)').setParameters({
|
||||
fileIds
|
||||
}).getMany();
|
||||
}
|
||||
let renote = null;
|
||||
if (data.renoteId != null) {
|
||||
renote = await getNote(data.renoteId, user).catch((e)=>{
|
||||
if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") throw new ApiError(errors.noSuchRenoteTarget);
|
||||
throw e;
|
||||
});
|
||||
if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) {
|
||||
throw new ApiError(errors.cannotReRenote);
|
||||
}
|
||||
if (renote.userId !== user.id) {
|
||||
const isBlocked = await Blockings.exist({
|
||||
where: [
|
||||
{
|
||||
blockerId: renote.userId,
|
||||
blockeeId: user.id,
|
||||
groupId: null
|
||||
},
|
||||
...renote.groupId ? [
|
||||
{
|
||||
groupId: renote.groupId,
|
||||
blockeeId: user.id
|
||||
}
|
||||
] : []
|
||||
]
|
||||
});
|
||||
if (isBlocked) {
|
||||
throw new ApiError(errors.youHaveBeenBlocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
let reply = null;
|
||||
if (data.replyId != null) {
|
||||
reply = await getNote(data.replyId, user).catch((e)=>{
|
||||
if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") throw new ApiError(errors.noSuchReplyTarget);
|
||||
throw e;
|
||||
});
|
||||
if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) {
|
||||
throw new ApiError(errors.cannotReplyToPureRenote);
|
||||
}
|
||||
if (reply.userId !== user.id) {
|
||||
const isBlocked = await Blockings.exist({
|
||||
where: [
|
||||
{
|
||||
blockerId: reply.userId,
|
||||
blockeeId: user.id,
|
||||
groupId: null
|
||||
},
|
||||
...reply.groupId ? [
|
||||
{
|
||||
groupId: reply.groupId,
|
||||
blockeeId: user.id
|
||||
}
|
||||
] : []
|
||||
]
|
||||
});
|
||||
if (isBlocked) {
|
||||
throw new ApiError(errors.youHaveBeenBlocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
const poll = data.poll ? {
|
||||
...data.poll
|
||||
} : null;
|
||||
if (poll) {
|
||||
if (typeof poll.expiresAt === "number") {
|
||||
if (poll.expiresAt < createdAt.getTime()) {
|
||||
throw new ApiError(errors.cannotCreateAlreadyExpiredPoll);
|
||||
}
|
||||
} else if (typeof poll.expiredAfter === "number") {
|
||||
poll.expiresAt = createdAt.getTime() + poll.expiredAfter;
|
||||
}
|
||||
}
|
||||
let channel = null;
|
||||
if (data.channelId != null) {
|
||||
channel = await Channels.findOneBy({
|
||||
id: data.channelId
|
||||
});
|
||||
if (channel == null) {
|
||||
throw new ApiError(errors.noSuchChannel);
|
||||
}
|
||||
}
|
||||
let group = null;
|
||||
if (data.groupId != null) {
|
||||
group = await UserGroups.findOneBy({
|
||||
id: data.groupId
|
||||
});
|
||||
if (group == null) {
|
||||
throw new ApiError(errors.noSuchGroup);
|
||||
}
|
||||
if (group.userId !== user.id) {
|
||||
const joining = await UserGroupJoinings.findOneBy({
|
||||
userId: user.id,
|
||||
userGroupId: group.id
|
||||
});
|
||||
if (joining == null) {
|
||||
throw new ApiError(errors.noSuchGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
return await create(user, {
|
||||
createdAt,
|
||||
files,
|
||||
poll: poll ? {
|
||||
choices: poll.choices,
|
||||
multiple: poll.multiple,
|
||||
expiresAt: poll.expiresAt ? new Date(poll.expiresAt) : null
|
||||
} : undefined,
|
||||
text: data.text || undefined,
|
||||
reply,
|
||||
renote,
|
||||
cw: data.cw,
|
||||
localOnly: data.localOnly,
|
||||
visibility: data.visibility ?? "public",
|
||||
visibleUsers,
|
||||
channel,
|
||||
group,
|
||||
apMentions: data.noExtractMentions ? [] : undefined,
|
||||
apHashtags: data.noExtractHashtags ? [] : undefined,
|
||||
apEmojis: data.noExtractEmojis ? [] : undefined
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
import * as mfm from "mfm-js";
|
||||
import { publishMainStream, publishNotesStream, publishNoteStream } from "../stream.js";
|
||||
import DeliverManager, { deliverToUser } from "../../remote/activitypub/deliver-manager.js";
|
||||
import renderNote from "../../remote/activitypub/renderer/note.js";
|
||||
import renderCreate from "../../remote/activitypub/renderer/create.js";
|
||||
import renderAnnounce from "../../remote/activitypub/renderer/announce.js";
|
||||
import { renderActivity } from "../../remote/activitypub/renderer/index.js";
|
||||
import { resolveUser } from "../../remote/resolve-user.js";
|
||||
import config from "../../config/index.js";
|
||||
import { updateHashtags } from "../update-hashtag.js";
|
||||
import { concat } from "../../prelude/array.js";
|
||||
import { insertNoteUnread } from "./unread.js";
|
||||
import { registerOrFetchInstanceDoc } from "../register-or-fetch-instance-doc.js";
|
||||
import { extractMentions } from "../../misc/extract-mentions.js";
|
||||
import { extractCustomEmojiNamesFromText, extractCustomEmojisFromMfm } from "../../misc/extract-custom-emojis-from-mfm.js";
|
||||
import { extractGroupMentionedUsers } from "../../misc/extract-group-mentions.js";
|
||||
import { extractHashtags } from "../../misc/extract-hashtags.js";
|
||||
import { Note } from "../../models/entities/note.js";
|
||||
import { Mutings, Users, DriveFiles, NoteWatchings, Notes, Instances, UserProfiles, Channels, ChannelFollowings, NoteThreadMutings, InteractionStamps } from "../../models/index.js";
|
||||
import { Not, In } from "typeorm";
|
||||
import { genId } from "../../misc/gen-id.js";
|
||||
import { notesChart, perUserNotesChart, activeUsersChart, instanceChart } from "../chart/index.js";
|
||||
import { Poll } from "../../models/entities/poll.js";
|
||||
import { createNotification } from "../create-notification.js";
|
||||
import { isDuplicateKeyValueError } from "../../misc/is-duplicate-key-value-error.js";
|
||||
import { checkHitAntenna } from "../../misc/check-hit-antenna.js";
|
||||
import { addNoteToAntenna } from "../add-note-to-antenna.js";
|
||||
import { countSameRenotes } from "../../misc/count-same-renotes.js";
|
||||
import { deliverToRelays, getCachedRelays } from "../relay.js";
|
||||
import { normalizeForSearch } from "../../misc/normalize-for-search.js";
|
||||
import { getAntennas } from "../../misc/antenna-cache.js";
|
||||
import { endedPollNotificationQueue } from "../../queue/queues.js";
|
||||
import { webhookDeliver } from "../../queue/index.js";
|
||||
import { db } from "../../db/postgre.js";
|
||||
import { getActiveWebhooks } from "../../misc/webhook-cache.js";
|
||||
import { shouldSilenceInstance } from "../../misc/should-block-instance.js";
|
||||
import { redisClient } from "../../db/redis.js";
|
||||
import { Mutex } from "redis-semaphore";
|
||||
import { RecursionLimiter } from "../../models/repositories/user-profile.js";
|
||||
import { NoteConverter } from "../../server/api/mastodon/converters/note.js";
|
||||
import { defaultJobOpts } from "../../queue/queues/index.js";
|
||||
import renderQuoteRequest from "../../remote/activitypub/renderer/quote-request.js";
|
||||
class NotificationManager {
|
||||
notifier;
|
||||
note;
|
||||
queue;
|
||||
constructor(notifier, note){
|
||||
this.notifier = notifier;
|
||||
this.note = note;
|
||||
this.queue = [];
|
||||
}
|
||||
push(notifiee, reason) {
|
||||
// 自分自身へは通知しない
|
||||
if (this.notifier.id === notifiee) return;
|
||||
const exist = this.queue.find((x)=>x.target === notifiee);
|
||||
if (exist) {
|
||||
// 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする
|
||||
if (reason !== "mention") {
|
||||
exist.reason = reason;
|
||||
}
|
||||
} else {
|
||||
this.queue.push({
|
||||
reason: reason,
|
||||
target: notifiee
|
||||
});
|
||||
}
|
||||
}
|
||||
async deliver() {
|
||||
for (const x of this.queue){
|
||||
// ミュート情報を取得
|
||||
const mentioneeMutes = await Mutings.findBy({
|
||||
muterId: x.target
|
||||
});
|
||||
const mentioneesMutedUserIds = mentioneeMutes.map((m)=>m.muteeId);
|
||||
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
|
||||
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
|
||||
createNotification(x.target, x.reason, {
|
||||
notifierId: this.notifier.id,
|
||||
noteId: this.note.id,
|
||||
note: this.note
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default (async (user, data, silent = false, limiter = new RecursionLimiter())=>// rome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME
|
||||
new Promise(async (res, rej)=>{
|
||||
const dontFederateInitially = data.visibility === "hidden";
|
||||
// If you reply outside the channel, match the scope of the target.
|
||||
// TODO (I think it's a process that could be done on the client side, but it's server side for now.)
|
||||
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
|
||||
if (data.reply.channelId) {
|
||||
data.channel = await Channels.findOneBy({
|
||||
id: data.reply.channelId
|
||||
});
|
||||
} else {
|
||||
data.channel = null;
|
||||
}
|
||||
}
|
||||
// When you reply in a channel, match the scope of the target
|
||||
// TODO (I think it's a process that could be done on the client side, but it's server side for now.)
|
||||
if (data.reply && data.channel == null && data.reply.channelId) {
|
||||
data.channel = await Channels.findOneBy({
|
||||
id: data.reply.channelId
|
||||
});
|
||||
}
|
||||
const now = new Date();
|
||||
if (!data.createdAt || isNaN(data.createdAt.getTime()) || data.createdAt > now) data.createdAt = now;
|
||||
if (data.visibility == null) data.visibility = "public";
|
||||
if (data.localOnly == null) data.localOnly = false;
|
||||
if (data.channel != null) data.visibility = "public";
|
||||
if (data.channel != null) data.visibleUsers = [];
|
||||
if (data.channel != null) data.localOnly = true;
|
||||
if (data.visibility === "hidden") data.visibility = "public";
|
||||
// enforce silent clients on server
|
||||
if (user.isSilenced && data.visibility === "public" && data.channel == null) {
|
||||
data.visibility = "home";
|
||||
}
|
||||
// Enforce home visibility if the user is in a silenced instance.
|
||||
if (data.visibility === "public" && Users.isRemoteUser(user) && await shouldSilenceInstance(user.host)) {
|
||||
data.visibility = "home";
|
||||
}
|
||||
// Reject if the target of the renote is a public range other than "Home or Entire".
|
||||
if (data.renote && data.renote.visibility !== "public" && data.renote.visibility !== "home" && data.renote.userId !== user.id) {
|
||||
return rej("Renote target is not public or home");
|
||||
}
|
||||
// If the target of the renote is not public, make it home.
|
||||
if (data.renote && data.renote.visibility !== "public" && data.visibility === "public") {
|
||||
data.visibility = "home";
|
||||
}
|
||||
// If the target of Renote is followers, make it followers.
|
||||
if (data.renote && data.renote.visibility === "followers") {
|
||||
data.visibility = "followers";
|
||||
}
|
||||
// If the reply target is not public, make it home.
|
||||
if (data.reply && data.reply.visibility !== "public" && data.visibility === "public") {
|
||||
data.visibility = "home";
|
||||
}
|
||||
// Renote local only if you Renote local only.
|
||||
if (data.renote?.localOnly && data.channel == null) {
|
||||
data.localOnly = true;
|
||||
}
|
||||
// If you reply to local only, make it local only.
|
||||
if (data.reply?.localOnly && data.channel == null) {
|
||||
data.localOnly = true;
|
||||
}
|
||||
if (data.text) {
|
||||
data.text = data.text.trim();
|
||||
} else {
|
||||
data.text = null;
|
||||
}
|
||||
let tags = data.apHashtags;
|
||||
let emojis = data.apEmojis;
|
||||
let mentionedUsers = data.apMentions;
|
||||
// Parse MFM if needed
|
||||
if (!(tags && emojis && mentionedUsers)) {
|
||||
const tokens = data.text ? mfm.parse(data.text) : [];
|
||||
const cwTokens = data.cw ? mfm.parse(data.cw) : [];
|
||||
const choiceTokens = data.poll?.choices ? concat(data.poll.choices.map((choice)=>mfm.parse(choice))) : [];
|
||||
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
|
||||
tags = data.apHashtags || extractHashtags(combinedTokens);
|
||||
emojis = data.apEmojis || [
|
||||
...new Set([
|
||||
...extractCustomEmojisFromMfm(combinedTokens),
|
||||
...extractCustomEmojiNamesFromText([
|
||||
data.text,
|
||||
data.cw,
|
||||
...data.poll?.choices ?? []
|
||||
])
|
||||
])
|
||||
];
|
||||
mentionedUsers = data.apMentions || await extractMentionedUsers(user, combinedTokens, limiter);
|
||||
if (!data.apMentions) {
|
||||
const groupMentionedUsers = await extractGroupMentionedUsers([
|
||||
data.text,
|
||||
data.cw,
|
||||
...data.poll?.choices ?? []
|
||||
]);
|
||||
for (const u of groupMentionedUsers){
|
||||
if (!mentionedUsers.some((x)=>x.id === u.id)) {
|
||||
mentionedUsers.push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tags = tags.filter((tag)=>Array.from(tag || "").length <= 128).splice(0, 32);
|
||||
if (data.reply && user.id !== data.reply.userId && !mentionedUsers.some((u)=>u.id === data.reply.userId)) {
|
||||
mentionedUsers.push(await Users.findOneByOrFail({
|
||||
id: data.reply.userId
|
||||
}));
|
||||
}
|
||||
if (data.visibility === "specified") {
|
||||
if (data.visibleUsers == null) throw new Error("invalid param");
|
||||
for (const u of data.visibleUsers){
|
||||
if (!mentionedUsers.some((x)=>x.id === u.id)) {
|
||||
mentionedUsers.push(u);
|
||||
}
|
||||
}
|
||||
if (data.reply && !data.visibleUsers.some((x)=>x.id === data.reply.userId)) {
|
||||
data.visibleUsers.push(await Users.findOneByOrFail({
|
||||
id: data.reply.userId
|
||||
}));
|
||||
}
|
||||
}
|
||||
const note = await insertNote(user, data, tags, emojis, mentionedUsers);
|
||||
// We need to increment these before resolving the promise
|
||||
if (data.reply) {
|
||||
await incRepliesCount(data.reply);
|
||||
}
|
||||
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
|
||||
if (data.renote && await countSameRenotes(user.id, data.renote.id, note.id, data.group?.id ?? null) === 0) {
|
||||
await incRenoteCount(data.renote);
|
||||
}
|
||||
res(note);
|
||||
// Prewarm html cache
|
||||
NoteConverter.prewarmCache(note);
|
||||
// 統計を更新
|
||||
notesChart.update(note, true);
|
||||
perUserNotesChart.update(user, note, true);
|
||||
// Register host
|
||||
if (Users.isRemoteUser(user)) {
|
||||
registerOrFetchInstanceDoc(user.host).then((i)=>{
|
||||
Instances.increment({
|
||||
id: i.id
|
||||
}, "notesCount", 1);
|
||||
instanceChart.updateNote(i.host, note, true);
|
||||
});
|
||||
}
|
||||
// ハッシュタグ更新
|
||||
if (data.visibility === "public" || data.visibility === "home") {
|
||||
updateHashtags(user, tags);
|
||||
}
|
||||
// Increment notes count (user)
|
||||
incNotesCountOfUser(user);
|
||||
// Antenna
|
||||
for (const antenna of (await getAntennas())){
|
||||
checkHitAntenna(antenna, note, user).then((hit)=>{
|
||||
if (hit) {
|
||||
addNoteToAntenna(antenna, note, user);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Channel
|
||||
if (note.channelId) {
|
||||
ChannelFollowings.findBy({
|
||||
followeeId: note.channelId
|
||||
}).then((followings)=>{
|
||||
for (const following of followings){
|
||||
insertNoteUnread(following.followerId, note, {
|
||||
isSpecified: false,
|
||||
isMentioned: false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (data.poll?.expiresAt) {
|
||||
const delay = data.poll.expiresAt.getTime() - Date.now();
|
||||
endedPollNotificationQueue.add("default", {
|
||||
noteId: note.id
|
||||
}, {
|
||||
delay,
|
||||
...defaultJobOpts
|
||||
});
|
||||
}
|
||||
if (!silent) {
|
||||
if (Users.isLocalUser(user)) activeUsersChart.write(user);
|
||||
// 未読通知を作成
|
||||
if (data.visibility === "specified") {
|
||||
if (data.visibleUsers == null) throw new Error("invalid param");
|
||||
for (const u of data.visibleUsers){
|
||||
// ローカルユーザーのみ
|
||||
if (!Users.isLocalUser(u)) continue;
|
||||
insertNoteUnread(u.id, note, {
|
||||
isSpecified: true,
|
||||
isMentioned: false
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const u of mentionedUsers){
|
||||
// ローカルユーザーのみ
|
||||
if (!Users.isLocalUser(u)) continue;
|
||||
insertNoteUnread(u.id, note, {
|
||||
isSpecified: false,
|
||||
isMentioned: true
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!dontFederateInitially) {
|
||||
let publishKey;
|
||||
let noteToPublish;
|
||||
const relays = await getCachedRelays();
|
||||
// Some relays (e.g., aode-relay) deliver posts by boosting them as
|
||||
// Announce activities. In that case, user is the relay's actor.
|
||||
const boostedByRelay = !!user.inbox && relays.map((relay)=>relay.inbox).includes(user.inbox);
|
||||
if (boostedByRelay && data.renote && data.renote.userHost) {
|
||||
publishKey = `publishedNote:${data.renote.id}`;
|
||||
noteToPublish = data.renote;
|
||||
} else {
|
||||
publishKey = `publishedNote:${note.id}`;
|
||||
noteToPublish = note;
|
||||
}
|
||||
const lock = new Mutex(redisClient, "publishedNote");
|
||||
await lock.acquire();
|
||||
try {
|
||||
const published = await redisClient.get(publishKey) !== null;
|
||||
if (!published) {
|
||||
await redisClient.set(publishKey, "done", "EX", 30);
|
||||
if (noteToPublish.renoteId) {
|
||||
// Prevents other threads from publishing the boosting post
|
||||
await redisClient.set(`publishedNote:${noteToPublish.renoteId}`, "done", "EX", 30);
|
||||
}
|
||||
publishNotesStream(noteToPublish);
|
||||
}
|
||||
} finally{
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
if (note.replyId != null) {
|
||||
// Only provide the reply note id here as the recipient may not be authorized to see the note.
|
||||
publishNoteStream(note.replyId, "replied", {
|
||||
id: note.id
|
||||
});
|
||||
}
|
||||
const webhooks = await getActiveWebhooks().then((webhooks)=>webhooks.filter((x)=>x.userId === user.id && x.on.includes("note")));
|
||||
for (const webhook of webhooks){
|
||||
webhookDeliver(webhook, "note", {
|
||||
note: await Notes.pack(note, user)
|
||||
});
|
||||
}
|
||||
const nm = new NotificationManager(user, note);
|
||||
const nmRelatedPromises = [];
|
||||
await createMentionedEvents(mentionedUsers, note, nm);
|
||||
// If has in reply to note
|
||||
if (data.reply) {
|
||||
// Fetch watchers
|
||||
nmRelatedPromises.push(notifyToWatchersOfReplyee(data.reply, user, nm));
|
||||
// 通知
|
||||
if (data.reply.userHost === null) {
|
||||
const threadMuted = await NoteThreadMutings.findOneBy({
|
||||
userId: data.reply.userId,
|
||||
threadId: data.reply.threadId || data.reply.id
|
||||
});
|
||||
if (!threadMuted) {
|
||||
nm.push(data.reply.userId, "reply");
|
||||
const packedReply = await Notes.pack(note, {
|
||||
id: data.reply.userId
|
||||
});
|
||||
publishMainStream(data.reply.userId, "reply", packedReply);
|
||||
const webhooks = (await getActiveWebhooks()).filter((x)=>x.userId === data.reply.userId && x.on.includes("reply"));
|
||||
for (const webhook of webhooks){
|
||||
webhookDeliver(webhook, "reply", {
|
||||
note: packedReply
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If it is renote
|
||||
if (data.renote) {
|
||||
const type = isPlain(note) ? "renote" : "quote";
|
||||
// Notify
|
||||
if (data.renote.userHost === null) {
|
||||
const threadMuted = await NoteThreadMutings.findOneBy({
|
||||
userId: data.renote.userId,
|
||||
threadId: data.renote.threadId || data.renote.id
|
||||
});
|
||||
if (!threadMuted) {
|
||||
nm.push(data.renote.userId, type);
|
||||
}
|
||||
}
|
||||
// Fetch watchers
|
||||
nmRelatedPromises.push(notifyToWatchersOfRenotee(data.renote, user, nm, type));
|
||||
// Publish event
|
||||
if (user.id !== data.renote.userId && data.renote.userHost === null) {
|
||||
const packedRenote = await Notes.pack(note, {
|
||||
id: data.renote.userId
|
||||
});
|
||||
publishMainStream(data.renote.userId, "renote", packedRenote);
|
||||
const renote = data.renote;
|
||||
const webhooks = (await getActiveWebhooks()).filter((x)=>x.userId === renote.userId && x.on.includes("renote"));
|
||||
for (const webhook of webhooks){
|
||||
webhookDeliver(webhook, "renote", {
|
||||
note: packedRenote
|
||||
});
|
||||
}
|
||||
}
|
||||
// Stamp renote if target is a local post
|
||||
if (!data.localOnly && data.renote.userHost === null && type === "quote") {
|
||||
console.log("stamping");
|
||||
const stamp = {
|
||||
id: genId(),
|
||||
type: "quote",
|
||||
noteId: note.id,
|
||||
targetNoteId: data.renote.id
|
||||
};
|
||||
await InteractionStamps.insert(stamp);
|
||||
note.quoteAuthorization = `${config.url}/stamp/${stamp.id}`;
|
||||
await Notes.update({
|
||||
id: note.id
|
||||
}, {
|
||||
quoteAuthorization: note.quoteAuthorization
|
||||
});
|
||||
}
|
||||
}
|
||||
Promise.all(nmRelatedPromises).then(()=>{
|
||||
nm.deliver();
|
||||
});
|
||||
//#region AP deliver
|
||||
if (Users.isLocalUser(user) && !data.localOnly && !dontFederateInitially) {
|
||||
(async ()=>{
|
||||
const noteActivity = await renderNoteOrRenoteActivity(data, note);
|
||||
const dm = new DeliverManager(user, noteActivity);
|
||||
// メンションされたリモートユーザーに配送
|
||||
for (const u of mentionedUsers.filter((u)=>Users.isRemoteUser(u))){
|
||||
dm.addDirectRecipe(u);
|
||||
}
|
||||
// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
|
||||
if (data.reply && data.reply.userHost !== null) {
|
||||
const u = await Users.findOneBy({
|
||||
id: data.reply.userId
|
||||
});
|
||||
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
|
||||
}
|
||||
// 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
|
||||
if (data.renote && data.renote.userHost !== null) {
|
||||
const u = await Users.findOneBy({
|
||||
id: data.renote.userId
|
||||
});
|
||||
if (u && Users.isRemoteUser(u)) {
|
||||
dm.addDirectRecipe(u);
|
||||
if (data.renote.canQuote && !isPlain(note)) deliverToUser(user, renderActivity(renderQuoteRequest(note, data.renote)), u);
|
||||
}
|
||||
}
|
||||
// フォロワーに配送
|
||||
if ([
|
||||
"public",
|
||||
"home",
|
||||
"followers"
|
||||
].includes(note.visibility)) {
|
||||
dm.addFollowersRecipe();
|
||||
}
|
||||
if ([
|
||||
"public"
|
||||
].includes(note.visibility)) {
|
||||
deliverToRelays(user, noteActivity);
|
||||
}
|
||||
dm.execute();
|
||||
})();
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
if (data.channel) {
|
||||
Channels.increment({
|
||||
id: data.channel.id
|
||||
}, "notesCount", 1);
|
||||
Channels.update(data.channel.id, {
|
||||
lastNotedAt: new Date()
|
||||
});
|
||||
await Notes.countBy({
|
||||
userId: user.id,
|
||||
channelId: data.channel.id
|
||||
}).then((count)=>{
|
||||
// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
|
||||
// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
|
||||
if (count === 1 && data.channel != null) {
|
||||
Channels.increment({
|
||||
id: data.channel.id
|
||||
}, "usersCount", 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
async function renderNoteOrRenoteActivity(data, note) {
|
||||
if (data.localOnly) return null;
|
||||
const content = data.renote && isPlain(note) ? renderAnnounce(data.renote.uri ?? `${config.url}/notes/${data.renote.id}`, note) : renderCreate(await renderNote(note, false), note);
|
||||
return renderActivity(content);
|
||||
}
|
||||
function isPlain(note) {
|
||||
return note.text == null && note.cw == null && !note.hasPoll && note.fileIds.length === 0;
|
||||
}
|
||||
async function incRenoteCount(renote) {
|
||||
// only await renoteCount increment, as score isn't relevant for returning the correct created note response
|
||||
await Notes.increment({
|
||||
id: renote.id
|
||||
}, "renoteCount", 1);
|
||||
Notes.increment({
|
||||
id: renote.id
|
||||
}, "score", 1);
|
||||
}
|
||||
async function insertNote(user, data, tags, emojis, mentionedUsers) {
|
||||
if (data.createdAt === null || data.createdAt === undefined) {
|
||||
data.createdAt = new Date();
|
||||
}
|
||||
const insert = new Note({
|
||||
id: genId(data.createdAt),
|
||||
createdAt: data.createdAt,
|
||||
fileIds: data.files ? data.files.map((file)=>file.id) : [],
|
||||
replyId: data.reply ? data.reply.id : null,
|
||||
renoteId: data.renote ? data.renote.id : null,
|
||||
channelId: data.channel ? data.channel.id : null,
|
||||
threadId: data.reply ? data.reply.threadId ? data.reply.threadId : data.reply.id : null,
|
||||
name: data.name,
|
||||
text: data.text,
|
||||
hasPoll: data.poll != null,
|
||||
cw: data.cw == null ? null : data.cw,
|
||||
tags: tags.map((tag)=>normalizeForSearch(tag)),
|
||||
emojis,
|
||||
userId: user.id,
|
||||
groupId: data.group?.id ?? null,
|
||||
localOnly: data.localOnly || false,
|
||||
visibility: data.visibility,
|
||||
visibleUserIds: data.visibility === "specified" ? data.visibleUsers ? data.visibleUsers.map((u)=>u.id) : [] : [],
|
||||
attachedFileTypes: data.files ? data.files.map((file)=>file.type) : [],
|
||||
canQuote: data.canQuote,
|
||||
// 以下非正規化データ
|
||||
replyUserId: data.reply ? data.reply.userId : null,
|
||||
replyUserHost: data.reply ? data.reply.userHost : null,
|
||||
renoteUserId: data.renote ? data.renote.userId : null,
|
||||
renoteUserHost: data.renote ? data.renote.userHost : null,
|
||||
userHost: user.host
|
||||
});
|
||||
if (data.uri != null) insert.uri = data.uri;
|
||||
if (data.url != null) insert.url = data.url;
|
||||
if (insert.fileIds.length > 0 && insert.tags.includes("karaokeservice")) {
|
||||
await DriveFiles.update({
|
||||
id: In(insert.fileIds)
|
||||
}, {
|
||||
allowDownload: false
|
||||
});
|
||||
}
|
||||
// Append mentions data
|
||||
if (mentionedUsers.length > 0) {
|
||||
insert.mentions = mentionedUsers.map((u)=>u.id);
|
||||
const profiles = await UserProfiles.findBy({
|
||||
userId: In(insert.mentions)
|
||||
});
|
||||
insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter((u)=>Users.isRemoteUser(u)).map((u)=>{
|
||||
const profile = profiles.find((p)=>p.userId === u.id);
|
||||
const url = profile != null ? profile.url : null;
|
||||
return {
|
||||
uri: u.uri,
|
||||
url: url == null ? undefined : url,
|
||||
username: u.username,
|
||||
host: u.host
|
||||
};
|
||||
}));
|
||||
}
|
||||
// 投稿を作成
|
||||
try {
|
||||
if (insert.hasPoll) {
|
||||
// Prepare objects
|
||||
if (!data.poll) throw new Error("Empty poll data");
|
||||
let expiresAt;
|
||||
if (!data.poll.expiresAt || isNaN(data.poll.expiresAt.getTime())) {
|
||||
expiresAt = null;
|
||||
} else {
|
||||
expiresAt = data.poll.expiresAt;
|
||||
}
|
||||
const poll = new Poll({
|
||||
noteId: insert.id,
|
||||
choices: data.poll.choices,
|
||||
expiresAt,
|
||||
multiple: data.poll.multiple,
|
||||
votes: new Array(data.poll.choices.length).fill(0),
|
||||
noteVisibility: insert.visibility,
|
||||
userId: user.id,
|
||||
userHost: user.host
|
||||
});
|
||||
// Save the objects atomically using a db transaction, note that we should never run any code in a transaction block directly
|
||||
await db.transaction(async (transactionalEntityManager)=>{
|
||||
await transactionalEntityManager.insert(Note, insert);
|
||||
await transactionalEntityManager.insert(Poll, poll);
|
||||
});
|
||||
} else {
|
||||
await Notes.insert(insert);
|
||||
}
|
||||
return insert;
|
||||
} catch (e) {
|
||||
// duplicate key error
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
const err = new Error("Duplicated note");
|
||||
err.name = "duplicated";
|
||||
throw err;
|
||||
}
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
async function notifyToWatchersOfRenotee(renote, user, nm, type) {
|
||||
const watchers = await NoteWatchings.findBy({
|
||||
noteId: renote.id,
|
||||
userId: Not(user.id)
|
||||
});
|
||||
for (const watcher of watchers){
|
||||
nm.push(watcher.userId, type);
|
||||
}
|
||||
}
|
||||
async function notifyToWatchersOfReplyee(reply, user, nm) {
|
||||
const watchers = await NoteWatchings.findBy({
|
||||
noteId: reply.id,
|
||||
userId: Not(user.id)
|
||||
});
|
||||
for (const watcher of watchers){
|
||||
nm.push(watcher.userId, "reply");
|
||||
}
|
||||
}
|
||||
async function createMentionedEvents(mentionedUsers, note, nm) {
|
||||
for (const u of mentionedUsers.filter((u)=>Users.isLocalUser(u))){
|
||||
const threadMuted = await NoteThreadMutings.findOneBy({
|
||||
userId: u.id,
|
||||
threadId: note.threadId || note.id
|
||||
});
|
||||
if (threadMuted) {
|
||||
continue;
|
||||
}
|
||||
// note with "specified" visibility might not be visible to mentioned users
|
||||
try {
|
||||
const detailPackedNote = await Notes.pack(note, u, {
|
||||
detail: true
|
||||
});
|
||||
publishMainStream(u.id, "mention", detailPackedNote);
|
||||
const webhooks = (await getActiveWebhooks()).filter((x)=>x.userId === u.id && x.on.includes("mention"));
|
||||
for (const webhook of webhooks){
|
||||
webhookDeliver(webhook, "mention", {
|
||||
note: detailPackedNote
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") continue;
|
||||
throw err;
|
||||
}
|
||||
// Create notification
|
||||
nm.push(u.id, "mention");
|
||||
}
|
||||
}
|
||||
async function incRepliesCount(reply) {
|
||||
await Notes.increment({
|
||||
id: reply.id
|
||||
}, "repliesCount", 1);
|
||||
}
|
||||
function incNotesCountOfUser(user) {
|
||||
Users.createQueryBuilder().update().set({
|
||||
updatedAt: new Date(),
|
||||
notesCount: ()=>'"notesCount" + 1'
|
||||
}).where("id = :id", {
|
||||
id: user.id
|
||||
}).execute();
|
||||
}
|
||||
export async function extractMentionedUsers(user, tokens, limiter = new RecursionLimiter()) {
|
||||
if (tokens == null) return [];
|
||||
const mentions = extractMentions(tokens);
|
||||
let mentionedUsers = (await Promise.all(mentions.map((m)=>resolveUser(m.username, m.host || user.host, undefined, limiter).catch(()=>null)))).filter((x)=>x != null);
|
||||
// Drop duplicate users
|
||||
mentionedUsers = mentionedUsers.filter((u, i, self)=>i === self.findIndex((u2)=>u.id === u2.id));
|
||||
return mentionedUsers;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Brackets, In } from "typeorm";
|
||||
import { publishNoteStream, publishNoteUpdatesStream } from "../stream.js";
|
||||
import renderDelete from "../../remote/activitypub/renderer/delete.js";
|
||||
import renderAnnounce from "../../remote/activitypub/renderer/announce.js";
|
||||
import renderUndo from "../../remote/activitypub/renderer/undo.js";
|
||||
import { renderActivity } from "../../remote/activitypub/renderer/index.js";
|
||||
import renderTombstone from "../../remote/activitypub/renderer/tombstone.js";
|
||||
import config from "../../config/index.js";
|
||||
import { Notes, Users, Instances } from "../../models/index.js";
|
||||
import { notesChart, perUserNotesChart, instanceChart } from "../chart/index.js";
|
||||
import { deliverToFollowers, deliverToUser } from "../../remote/activitypub/deliver-manager.js";
|
||||
import { countSameRenotes } from "../../misc/count-same-renotes.js";
|
||||
import { registerOrFetchInstanceDoc } from "../register-or-fetch-instance-doc.js";
|
||||
import { deliverToRelays } from "../relay.js";
|
||||
/**
|
||||
* 投稿を削除します。
|
||||
* @param user 投稿者
|
||||
* @param note 投稿
|
||||
*/ export default async function(user, note, quiet = false) {
|
||||
const deletedAt = new Date();
|
||||
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
|
||||
if (note.renoteId && await countSameRenotes(user.id, note.renoteId, note.id, note.groupId) === 0) {
|
||||
await Notes.decrement({
|
||||
id: note.renoteId
|
||||
}, "renoteCount", 1);
|
||||
await Notes.decrement({
|
||||
id: note.renoteId
|
||||
}, "score", 1);
|
||||
}
|
||||
if (note.replyId) {
|
||||
await Notes.decrement({
|
||||
id: note.replyId
|
||||
}, "repliesCount", 1);
|
||||
}
|
||||
if (!quiet) {
|
||||
publishNoteStream(note.id, "deleted", {
|
||||
deletedAt: deletedAt
|
||||
});
|
||||
publishNoteUpdatesStream("deleted", note);
|
||||
//#region ローカルの投稿なら削除アクティビティを配送
|
||||
if (Users.isLocalUser(user) && !note.localOnly) {
|
||||
let renote = null;
|
||||
// if deletd note is renote
|
||||
if (note.renoteId && note.text == null && note.cw == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
|
||||
renote = await Notes.findOneBy({
|
||||
id: note.renoteId
|
||||
});
|
||||
}
|
||||
const content = renderActivity(renote ? renderUndo(renderAnnounce(renote.uri || `${config.url}/notes/${renote.id}`, note), user) : renderDelete(renderTombstone(`${config.url}/notes/${note.id}`), user));
|
||||
deliverToConcerned(user, note, content);
|
||||
}
|
||||
// also deliever delete activity to cascaded notes
|
||||
const cascadingNotes = (await findCascadingNotes(note)).filter((note)=>!note.localOnly); // filter out local-only notes
|
||||
for (const cascadingNote of cascadingNotes){
|
||||
if (!cascadingNote.user) continue;
|
||||
if (!Users.isLocalUser(cascadingNote.user)) continue;
|
||||
const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
|
||||
deliverToConcerned(cascadingNote.user, cascadingNote, content);
|
||||
}
|
||||
//#endregion
|
||||
// 統計を更新
|
||||
notesChart.update(note, false);
|
||||
perUserNotesChart.update(user, note, false);
|
||||
if (Users.isRemoteUser(user)) {
|
||||
registerOrFetchInstanceDoc(user.host).then((i)=>{
|
||||
Instances.decrement({
|
||||
id: i.id
|
||||
}, "notesCount", 1);
|
||||
instanceChart.updateNote(i.host, note, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
await Notes.delete({
|
||||
id: note.id,
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
async function findCascadingNotes(note) {
|
||||
const cascadingNotes = [];
|
||||
const recursive = async (noteId)=>{
|
||||
const query = Notes.createQueryBuilder("note").where("note.replyId = :noteId", {
|
||||
noteId
|
||||
}).orWhere(new Brackets((q)=>{
|
||||
q.where("note.renoteId = :noteId", {
|
||||
noteId
|
||||
}).andWhere("note.text IS NOT NULL");
|
||||
})).leftJoinAndSelect("note.user", "user");
|
||||
const replies = await query.getMany();
|
||||
for (const reply of replies){
|
||||
cascadingNotes.push(reply);
|
||||
await recursive(reply.id);
|
||||
}
|
||||
};
|
||||
await recursive(note.id);
|
||||
return cascadingNotes.filter((note)=>note.userHost === null); // filter out non-local users
|
||||
}
|
||||
async function getMentionedRemoteUsers(note) {
|
||||
const where = [];
|
||||
// mention / reply / dm
|
||||
const uris = JSON.parse(note.mentionedRemoteUsers).map((x)=>x.uri);
|
||||
if (uris.length > 0) {
|
||||
where.push({
|
||||
uri: In(uris)
|
||||
});
|
||||
}
|
||||
// renote / quote
|
||||
if (note.renoteUserId) {
|
||||
where.push({
|
||||
id: note.renoteUserId
|
||||
});
|
||||
}
|
||||
if (where.length === 0) return [];
|
||||
return await Users.find({
|
||||
where
|
||||
});
|
||||
}
|
||||
async function deliverToConcerned(user, note, content) {
|
||||
deliverToFollowers(user, content);
|
||||
deliverToRelays(user, content);
|
||||
const remoteUsers = await getMentionedRemoteUsers(note);
|
||||
for (const remoteUser of remoteUsers){
|
||||
deliverToUser(user, content, remoteUser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import * as mfm from "mfm-js";
|
||||
import { publishNoteStream, publishNoteUpdatesStream } from "../stream.js";
|
||||
import DeliverManager from "../../remote/activitypub/deliver-manager.js";
|
||||
import renderNote from "../../remote/activitypub/renderer/note.js";
|
||||
import { renderActivity } from "../../remote/activitypub/renderer/index.js";
|
||||
import { extractCustomEmojisFromMfm } from "../../misc/extract-custom-emojis-from-mfm.js";
|
||||
import { extractHashtags } from "../../misc/extract-hashtags.js";
|
||||
import { Users, Notes, UserProfiles, Polls, NoteEdits, PollVotes } from "../../models/index.js";
|
||||
import { In } from "typeorm";
|
||||
import { genId } from "../../misc/gen-id.js";
|
||||
import { deliverToRelays } from "../relay.js";
|
||||
import renderUpdate from "../../remote/activitypub/renderer/update.js";
|
||||
import { extractMentionedUsers } from "./create.js";
|
||||
import { normalizeForSearch } from "../../misc/normalize-for-search.js";
|
||||
import { NoteConverter } from "../../server/api/mastodon/converters/note.js";
|
||||
export default async function(user, note, data, suppressEvidence = false) {
|
||||
if (data.text !== undefined && data.text !== null) {
|
||||
data.text = data.text.trim();
|
||||
} else {
|
||||
data.text = null;
|
||||
}
|
||||
const fileIds = data.files?.map((file)=>file.id);
|
||||
const fileTypes = data.files?.map((file)=>file.type);
|
||||
const tokens = mfm.parse(data.text || "").concat(mfm.parse(data.cw || ""));
|
||||
const tags = extractHashtags(tokens).filter((tag)=>Array.from(tag || "").length <= 128).splice(0, 32).map(normalizeForSearch);
|
||||
const emojis = extractCustomEmojisFromMfm(tokens);
|
||||
const mentionUsers = await extractMentionedUsers(user, tokens);
|
||||
const mentionUserIds = mentionUsers.map((user)=>user.id);
|
||||
const remoteUsers = mentionUsers.filter((user)=>user.host != null);
|
||||
const remoteUserIds = remoteUsers.map((user)=>user.id);
|
||||
const remoteProfiles = await UserProfiles.findBy({
|
||||
userId: In(remoteUserIds)
|
||||
});
|
||||
const mentionedRemoteUsers = remoteUsers.map((user)=>{
|
||||
const profile = remoteProfiles.find((profile)=>profile.userId === user.id);
|
||||
return {
|
||||
username: user.username,
|
||||
host: user.host ?? null,
|
||||
uri: user.uri,
|
||||
url: profile ? profile.url : undefined
|
||||
};
|
||||
});
|
||||
let publishing = false;
|
||||
const update = {};
|
||||
if (data.text !== null && data.text !== note.text) {
|
||||
update.text = data.text;
|
||||
}
|
||||
if (data.cw !== note.cw) {
|
||||
update.cw = data.cw ?? null;
|
||||
}
|
||||
if (data.files !== undefined && fileIds.sort().join(",") !== note.fileIds.sort().join(",")) {
|
||||
update.fileIds = fileIds;
|
||||
update.attachedFileTypes = fileTypes;
|
||||
}
|
||||
if (tags.sort().join(",") !== note.tags.sort().join(",")) {
|
||||
update.tags = tags;
|
||||
}
|
||||
if (mentionUserIds.sort().join(",") !== note.mentions.sort().join(",")) {
|
||||
update.mentions = mentionUserIds;
|
||||
update.mentionedRemoteUsers = JSON.stringify(mentionedRemoteUsers);
|
||||
}
|
||||
if (emojis.sort().join(",") !== note.emojis.sort().join(",")) {
|
||||
update.emojis = emojis;
|
||||
}
|
||||
if (data.poll !== undefined && note.hasPoll !== !!data.poll) {
|
||||
update.hasPoll = !!data.poll;
|
||||
}
|
||||
if (data.poll) {
|
||||
const dbPoll = await Polls.findOneBy({
|
||||
noteId: note.id
|
||||
});
|
||||
if (dbPoll == null) {
|
||||
await Polls.insert({
|
||||
noteId: note.id,
|
||||
choices: data.poll?.choices,
|
||||
multiple: data.poll?.multiple,
|
||||
votes: new Array(data.poll?.choices.length).fill(0),
|
||||
expiresAt: data.poll?.expiresAt,
|
||||
noteVisibility: note.visibility === "hidden" ? "home" : note.visibility,
|
||||
userId: user.id,
|
||||
userHost: user.host
|
||||
});
|
||||
publishing = true;
|
||||
} else {
|
||||
const choicesChanged = JSON.stringify(dbPoll.choices) !== JSON.stringify(data.poll.choices);
|
||||
if (dbPoll.multiple !== data.poll.multiple || dbPoll.expiresAt !== data.poll.expiresAt || dbPoll.noteVisibility !== note.visibility || choicesChanged) {
|
||||
await Polls.update({
|
||||
noteId: note.id
|
||||
}, {
|
||||
choices: data.poll?.choices,
|
||||
multiple: data.poll?.multiple,
|
||||
votes: choicesChanged ? new Array(data.poll.choices.length).fill(0) : undefined,
|
||||
expiresAt: data.poll?.expiresAt,
|
||||
noteVisibility: note.visibility === "hidden" ? "home" : note.visibility
|
||||
});
|
||||
// Reset votes
|
||||
if (JSON.stringify(dbPoll.choices) !== JSON.stringify(data.poll.choices)) {
|
||||
await PollVotes.delete({
|
||||
noteId: dbPoll.noteId
|
||||
});
|
||||
}
|
||||
publishing = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.quoteAuthorization !== undefined) {
|
||||
update.quoteAuthorization = data.quoteAuthorization;
|
||||
}
|
||||
if (notEmpty(update)) {
|
||||
if (!suppressEvidence) update.updatedAt = new Date();
|
||||
await Notes.update(note.id, update);
|
||||
if (!suppressEvidence) {
|
||||
// Add previous note contents to NoteEdit history
|
||||
await NoteEdits.insert({
|
||||
id: genId(),
|
||||
noteId: note.id,
|
||||
text: note.text || undefined,
|
||||
cw: note.cw,
|
||||
fileIds: note.fileIds,
|
||||
updatedAt: update.updatedAt ?? undefined
|
||||
});
|
||||
}
|
||||
publishing = true;
|
||||
}
|
||||
note = await Notes.findOneByOrFail({
|
||||
id: note.id
|
||||
});
|
||||
if (publishing) {
|
||||
NoteConverter.prewarmCache(note);
|
||||
// Publish update event for the updated note details
|
||||
if (!suppressEvidence) {
|
||||
publishNoteStream(note.id, "updated", {
|
||||
updatedAt: update.updatedAt
|
||||
});
|
||||
publishNoteUpdatesStream("updated", note);
|
||||
}
|
||||
(async ()=>{
|
||||
if (note.localOnly) return;
|
||||
const noteActivity = await renderNote(note, false);
|
||||
if (!suppressEvidence) noteActivity.updated = note.updatedAt?.toISOString();
|
||||
const updateActivity = renderUpdate(noteActivity, user);
|
||||
updateActivity.to = noteActivity.to;
|
||||
updateActivity.cc = noteActivity.cc;
|
||||
const activity = renderActivity(updateActivity);
|
||||
const dm = new DeliverManager(user, activity);
|
||||
// Delivery to remote mentioned users
|
||||
for (const u of mentionUsers.filter((u)=>Users.isRemoteUser(u))){
|
||||
dm.addDirectRecipe(u);
|
||||
}
|
||||
// Post is a reply and remote user is the contributor of the original post
|
||||
if (note.reply && note.reply.userHost !== null) {
|
||||
const u = await Users.findOneBy({
|
||||
id: note.reply.userId
|
||||
});
|
||||
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
|
||||
}
|
||||
// Post is a renote and remote user is the contributor of the original post
|
||||
if (note.renote && note.renote.userHost !== null) {
|
||||
const u = await Users.findOneBy({
|
||||
id: note.renote.userId
|
||||
});
|
||||
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
|
||||
}
|
||||
// Deliver to followers for non-direct posts.
|
||||
if ([
|
||||
"public",
|
||||
"home",
|
||||
"followers"
|
||||
].includes(note.visibility)) {
|
||||
dm.addFollowersRecipe();
|
||||
}
|
||||
// Deliver to relays for public posts.
|
||||
if ([
|
||||
"public"
|
||||
].includes(note.visibility)) {
|
||||
deliverToRelays(user, activity);
|
||||
}
|
||||
// GO!
|
||||
dm.execute();
|
||||
})();
|
||||
}
|
||||
return note;
|
||||
}
|
||||
function notEmpty(partial) {
|
||||
return Object.keys(partial).length > 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import renderUpdate from "../../../remote/activitypub/renderer/update.js";
|
||||
import { renderActivity } from "../../../remote/activitypub/renderer/index.js";
|
||||
import renderNote from "../../../remote/activitypub/renderer/note.js";
|
||||
import { Users, Notes } from "../../../models/index.js";
|
||||
import { deliverToFollowers } from "../../../remote/activitypub/deliver-manager.js";
|
||||
import { deliverToRelays } from "../../relay.js";
|
||||
export async function deliverQuestionUpdate(noteId) {
|
||||
const note = await Notes.findOneBy({
|
||||
id: noteId
|
||||
});
|
||||
if (note == null) throw new Error("note not found");
|
||||
const user = await Users.findOneBy({
|
||||
id: note.userId
|
||||
});
|
||||
if (user == null) throw new Error("note not found");
|
||||
if (Users.isLocalUser(user)) {
|
||||
const content = renderActivity(renderUpdate(await renderNote(note, false), user));
|
||||
deliverToFollowers(user, content);
|
||||
deliverToRelays(user, content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { publishNoteStream } from "../../stream.js";
|
||||
import { PollVotes, NoteWatchings, Polls, Blockings } from "../../../models/index.js";
|
||||
import { Not } from "typeorm";
|
||||
import { genId } from "../../../misc/gen-id.js";
|
||||
import { createNotification } from "../../create-notification.js";
|
||||
export default async function(user, note, choice) {
|
||||
const poll = await Polls.findOneBy({
|
||||
noteId: note.id
|
||||
});
|
||||
if (poll == null) throw new Error("poll not found");
|
||||
// Check whether is valid choice
|
||||
if (poll.choices[choice] == null) throw new Error("invalid choice param");
|
||||
// Check blocking
|
||||
if (note.userId !== user.id) {
|
||||
const blocked = await Blockings.exist({
|
||||
where: [
|
||||
{
|
||||
blockerId: note.userId,
|
||||
blockeeId: user.id,
|
||||
groupId: null
|
||||
},
|
||||
...note.groupId ? [
|
||||
{
|
||||
groupId: note.groupId,
|
||||
blockeeId: user.id
|
||||
}
|
||||
] : []
|
||||
]
|
||||
});
|
||||
if (blocked) {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
}
|
||||
// if already voted
|
||||
const exist = await PollVotes.findBy({
|
||||
noteId: note.id,
|
||||
userId: user.id
|
||||
});
|
||||
if (poll.multiple) {
|
||||
if (exist.some((x)=>x.choice === choice)) {
|
||||
throw new Error("already voted");
|
||||
}
|
||||
} else if (exist.length !== 0) {
|
||||
throw new Error("already voted");
|
||||
}
|
||||
// Create vote
|
||||
await PollVotes.insert({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
choice: choice
|
||||
});
|
||||
// Increment votes count
|
||||
const index = choice + 1; // In SQL, array index is 1 based
|
||||
await Polls.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`);
|
||||
publishNoteStream(note.id, "pollVoted", {
|
||||
choice: choice,
|
||||
userId: user.id
|
||||
});
|
||||
// Notify
|
||||
createNotification(note.userId, "pollVote", {
|
||||
notifierId: user.id,
|
||||
note: note,
|
||||
noteId: note.id,
|
||||
choice: choice
|
||||
});
|
||||
// Fetch watchers
|
||||
NoteWatchings.findBy({
|
||||
noteId: note.id,
|
||||
userId: Not(user.id)
|
||||
}).then((watchers)=>{
|
||||
for (const watcher of watchers){
|
||||
createNotification(watcher.userId, "pollVote", {
|
||||
notifierId: user.id,
|
||||
note: note,
|
||||
noteId: note.id,
|
||||
choice: choice
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { publishNoteStream } from "../../stream.js";
|
||||
import { renderLike } from "../../../remote/activitypub/renderer/like.js";
|
||||
import DeliverManager from "../../../remote/activitypub/deliver-manager.js";
|
||||
import { renderActivity } from "../../../remote/activitypub/renderer/index.js";
|
||||
import { toDbReaction, decodeReaction } from "../../../misc/reaction-lib.js";
|
||||
import { NoteReactions, Users, NoteWatchings, Notes, Emojis, Blockings } from "../../../models/index.js";
|
||||
import { IsNull, Not } from "typeorm";
|
||||
import { perUserReactionsChart } from "../../chart/index.js";
|
||||
import { genId } from "../../../misc/gen-id.js";
|
||||
import { createNotification } from "../../create-notification.js";
|
||||
import deleteReaction from "./delete.js";
|
||||
import { isDuplicateKeyValueError } from "../../../misc/is-duplicate-key-value-error.js";
|
||||
import { IdentifiableError } from "../../../misc/identifiable-error.js";
|
||||
import { populateEmojiOrUserEmoji } from "../../../misc/populate-emojis.js";
|
||||
export default (async (user, note, reaction)=>{
|
||||
// Check blocking
|
||||
if (note.userId !== user.id) {
|
||||
const blocked = await Blockings.exist({
|
||||
where: [
|
||||
{
|
||||
blockerId: note.userId,
|
||||
blockeeId: user.id,
|
||||
groupId: null
|
||||
},
|
||||
...note.groupId ? [
|
||||
{
|
||||
groupId: note.groupId,
|
||||
blockeeId: user.id
|
||||
}
|
||||
] : []
|
||||
]
|
||||
});
|
||||
if (blocked) {
|
||||
throw new IdentifiableError("e70412a4-7197-4726-8e74-f3e0deb92aa7");
|
||||
}
|
||||
}
|
||||
// check visibility
|
||||
if (!await Notes.isVisibleForMe(note, user.id)) {
|
||||
throw new IdentifiableError("68e9d2d1-48bf-42c2-b90a-b20e09fd3d48", "Note not accessible for you.");
|
||||
}
|
||||
// TODO: cache
|
||||
reaction = await toDbReaction(reaction, user.host);
|
||||
const record = {
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
groupId: user.groupId ?? null,
|
||||
reaction
|
||||
};
|
||||
// Create reaction
|
||||
try {
|
||||
await NoteReactions.insert(record);
|
||||
} catch (e) {
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
const exists = await NoteReactions.findOneByOrFail({
|
||||
noteId: note.id,
|
||||
...user.groupId ? {
|
||||
groupId: user.groupId
|
||||
} : {
|
||||
userId: user.id,
|
||||
groupId: null
|
||||
}
|
||||
});
|
||||
if (exists.reaction !== reaction) {
|
||||
// 別のリアクションがすでにされていたら置き換える
|
||||
await deleteReaction(user, note);
|
||||
await NoteReactions.insert(record);
|
||||
} else {
|
||||
// 同じリアクションがすでにされていたらエラー
|
||||
throw new IdentifiableError("51c42bb4-931a-456b-bff7-e5a8a70dd298", "Reaction already exists");
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// Increment reactions count
|
||||
const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
|
||||
await Notes.createQueryBuilder().update().set({
|
||||
reactions: ()=>sql,
|
||||
score: ()=>'"score" + 1'
|
||||
}).where("id = :id", {
|
||||
id: note.id
|
||||
}).execute();
|
||||
perUserReactionsChart.update(user, note);
|
||||
// カスタム絵文字リアクションだったら絵文字情報も送る
|
||||
const decodedReaction = decodeReaction(reaction);
|
||||
const emoji = await Emojis.findOne({
|
||||
where: {
|
||||
name: decodedReaction.name,
|
||||
host: decodedReaction.host ?? IsNull()
|
||||
},
|
||||
select: [
|
||||
"name",
|
||||
"host",
|
||||
"originalUrl",
|
||||
"publicUrl"
|
||||
]
|
||||
});
|
||||
const populatedEmoji = emoji == null ? await populateEmojiOrUserEmoji(reaction.slice(1, -1), note.userHost) : null;
|
||||
publishNoteStream(note.id, "reacted", {
|
||||
reaction: decodedReaction.reaction,
|
||||
emoji: emoji != null ? {
|
||||
name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`,
|
||||
url: emoji.publicUrl || emoji.originalUrl
|
||||
} : populatedEmoji != null ? {
|
||||
name: populatedEmoji.name,
|
||||
url: populatedEmoji.url
|
||||
} : null,
|
||||
userId: user.id,
|
||||
groupId: user.groupId ?? null
|
||||
});
|
||||
// Create notification if the reaction target is a local user.
|
||||
if (note.userHost === null) {
|
||||
createNotification(note.userId, "reaction", {
|
||||
notifierId: user.id,
|
||||
note: note,
|
||||
noteId: note.id,
|
||||
reaction: reaction
|
||||
});
|
||||
}
|
||||
// Fetch watchers
|
||||
NoteWatchings.findBy({
|
||||
noteId: note.id,
|
||||
userId: Not(user.id)
|
||||
}).then((watchers)=>{
|
||||
for (const watcher of watchers){
|
||||
createNotification(watcher.userId, "reaction", {
|
||||
notifierId: user.id,
|
||||
note: note,
|
||||
noteId: note.id,
|
||||
reaction: reaction
|
||||
});
|
||||
}
|
||||
});
|
||||
//#region deliver
|
||||
if (Users.isLocalUser(user) && !note.localOnly && note.visibility !== "hidden") {
|
||||
const content = renderActivity(await renderLike(record, note));
|
||||
const dm = new DeliverManager(user, content);
|
||||
if (note.userHost !== null) {
|
||||
const reactee = await Users.findOneBy({
|
||||
id: note.userId
|
||||
});
|
||||
dm.addDirectRecipe(reactee);
|
||||
}
|
||||
if ([
|
||||
"public",
|
||||
"home",
|
||||
"followers"
|
||||
].includes(note.visibility)) {
|
||||
dm.addFollowersRecipe();
|
||||
} else if (note.visibility === "specified") {
|
||||
const visibleUsers = await Promise.all(note.visibleUserIds.map((id)=>Users.findOneBy({
|
||||
id
|
||||
})));
|
||||
for (const u of visibleUsers.filter((u)=>u && Users.isRemoteUser(u))){
|
||||
dm.addDirectRecipe(u);
|
||||
}
|
||||
}
|
||||
dm.execute();
|
||||
}
|
||||
//#endregion
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { publishNoteStream } from "../../stream.js";
|
||||
import { renderLike } from "../../../remote/activitypub/renderer/like.js";
|
||||
import renderUndo from "../../../remote/activitypub/renderer/undo.js";
|
||||
import { renderActivity } from "../../../remote/activitypub/renderer/index.js";
|
||||
import DeliverManager from "../../../remote/activitypub/deliver-manager.js";
|
||||
import { IdentifiableError } from "../../../misc/identifiable-error.js";
|
||||
import { NoteReactions, Users, Notes } from "../../../models/index.js";
|
||||
import { decodeReaction } from "../../../misc/reaction-lib.js";
|
||||
export default (async (user, note)=>{
|
||||
const reaction = await NoteReactions.findOneBy({
|
||||
noteId: note.id,
|
||||
...user.groupId ? {
|
||||
groupId: user.groupId
|
||||
} : {
|
||||
userId: user.id,
|
||||
groupId: null
|
||||
}
|
||||
});
|
||||
// if already unreacted
|
||||
if (reaction == null) {
|
||||
throw new IdentifiableError("60527ec9-b4cb-4a88-a6bd-32d3ad26817d", "not reacted");
|
||||
}
|
||||
// Delete reaction
|
||||
const result = await NoteReactions.delete(reaction.id);
|
||||
if (result.affected !== 1) {
|
||||
throw new IdentifiableError("60527ec9-b4cb-4a88-a6bd-32d3ad26817d", "not reacted");
|
||||
}
|
||||
// Decrement reactions count
|
||||
const sql = `jsonb_set("reactions", '{${reaction.reaction}}', (COALESCE("reactions"->>'${reaction.reaction}', '0')::int - 1)::text::jsonb)`;
|
||||
await Notes.createQueryBuilder().update().set({
|
||||
reactions: ()=>sql
|
||||
}).where("id = :id", {
|
||||
id: note.id
|
||||
}).execute();
|
||||
Notes.decrement({
|
||||
id: note.id
|
||||
}, "score", 1);
|
||||
publishNoteStream(note.id, "unreacted", {
|
||||
reaction: decodeReaction(reaction.reaction).reaction,
|
||||
userId: user.id,
|
||||
groupId: user.groupId ?? null
|
||||
});
|
||||
//#region 配信
|
||||
if (Users.isLocalUser(user) && !note.localOnly) {
|
||||
const content = renderActivity(renderUndo(await renderLike(reaction, note), user));
|
||||
const dm = new DeliverManager(user, content);
|
||||
if (note.userHost !== null) {
|
||||
const reactee = await Users.findOneBy({
|
||||
id: note.userId
|
||||
});
|
||||
dm.addDirectRecipe(reactee);
|
||||
}
|
||||
dm.addFollowersRecipe();
|
||||
dm.execute();
|
||||
}
|
||||
//#endregion
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { publishMainStream } from "../stream.js";
|
||||
import { NoteUnreads, Followings, ChannelFollowings } from "../../models/index.js";
|
||||
import { Not, IsNull, In } from "typeorm";
|
||||
import { readNotificationByQuery } from "../../server/api/common/read-notification.js";
|
||||
/**
|
||||
* Mark notes as read
|
||||
*/ export default async function(userId, notes, info) {
|
||||
const following = info?.following ? info.following : new Set((await Followings.find({
|
||||
where: {
|
||||
followerId: userId
|
||||
},
|
||||
select: [
|
||||
"followeeId"
|
||||
]
|
||||
})).map((x)=>x.followeeId));
|
||||
const followingChannels = info?.followingChannels ? info.followingChannels : new Set((await ChannelFollowings.find({
|
||||
where: {
|
||||
followerId: userId
|
||||
},
|
||||
select: [
|
||||
"followeeId"
|
||||
]
|
||||
})).map((x)=>x.followeeId));
|
||||
// const myAntennas = (await getAntennas()).filter((a) => a.userId === userId);
|
||||
const readMentions = [];
|
||||
const readSpecifiedNotes = [];
|
||||
const readChannelNotes = [];
|
||||
// const readAntennaNotes: (Note | Packed<"Note">)[] = [];
|
||||
for (const note of notes){
|
||||
if (note.mentions?.includes(userId)) {
|
||||
readMentions.push(note);
|
||||
} else if (note.visibleUserIds?.includes(userId)) {
|
||||
readSpecifiedNotes.push(note);
|
||||
}
|
||||
if (note.channelId && followingChannels.has(note.channelId)) {
|
||||
readChannelNotes.push(note);
|
||||
}
|
||||
// if (note.user != null) {
|
||||
// // たぶんnullになることは無いはずだけど一応
|
||||
// for (const antenna of myAntennas) {
|
||||
// if (
|
||||
// await checkHitAntenna(
|
||||
// antenna,
|
||||
// note,
|
||||
// note.user,
|
||||
// undefined,
|
||||
// Array.from(following),
|
||||
// )
|
||||
// ) {
|
||||
// readAntennaNotes.push(note);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
if (readMentions.length > 0 || readSpecifiedNotes.length > 0 || readChannelNotes.length > 0) {
|
||||
// Remove the record
|
||||
await NoteUnreads.delete({
|
||||
userId: userId,
|
||||
noteId: In([
|
||||
...readMentions.map((n)=>n.id),
|
||||
...readSpecifiedNotes.map((n)=>n.id),
|
||||
...readChannelNotes.map((n)=>n.id)
|
||||
])
|
||||
});
|
||||
// TODO: ↓まとめてクエリしたい
|
||||
NoteUnreads.countBy({
|
||||
userId: userId,
|
||||
isMentioned: true
|
||||
}).then((mentionsCount)=>{
|
||||
if (mentionsCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
publishMainStream(userId, "readAllUnreadMentions");
|
||||
}
|
||||
});
|
||||
NoteUnreads.countBy({
|
||||
userId: userId,
|
||||
isSpecified: true
|
||||
}).then((specifiedCount)=>{
|
||||
if (specifiedCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
publishMainStream(userId, "readAllUnreadSpecifiedNotes");
|
||||
}
|
||||
});
|
||||
NoteUnreads.countBy({
|
||||
userId: userId,
|
||||
noteChannelId: Not(IsNull())
|
||||
}).then((channelNoteCount)=>{
|
||||
if (channelNoteCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
publishMainStream(userId, "readAllChannels");
|
||||
}
|
||||
});
|
||||
readNotificationByQuery(userId, {
|
||||
noteId: In([
|
||||
...readMentions.map((n)=>n.id),
|
||||
...readSpecifiedNotes.map((n)=>n.id)
|
||||
])
|
||||
});
|
||||
}
|
||||
// if (readAntennaNotes.length > 0) {
|
||||
// await AntennaNotes.update(
|
||||
// {
|
||||
// antennaId: In(myAntennas.map((a) => a.id)),
|
||||
// noteId: In(readAntennaNotes.map((n) => n.id)),
|
||||
// },
|
||||
// {
|
||||
// read: true,
|
||||
// },
|
||||
// );
|
||||
// // TODO: まとめてクエリしたい
|
||||
// for (const antenna of myAntennas) {
|
||||
// const count = await AntennaNotes.countBy({
|
||||
// antennaId: antenna.id,
|
||||
// read: false,
|
||||
// });
|
||||
// if (count === 0) {
|
||||
// publishMainStream(userId, "readAntenna", antenna);
|
||||
// }
|
||||
// }
|
||||
// Users.getHasUnreadAntenna(userId).then((unread) => {
|
||||
// if (!unread) {
|
||||
// publishMainStream(userId, "readAllAntennas");
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { genId } from "../../misc/gen-id.js";
|
||||
import { ScheduledNotes, Users } from "../../models/index.js";
|
||||
import { createNoteFromApiData } from "./create-from-api.js";
|
||||
export async function scheduleNote(user, data, scheduledAt) {
|
||||
return await ScheduledNotes.save({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
scheduledAt,
|
||||
userId: user.id,
|
||||
status: "scheduled",
|
||||
data,
|
||||
noteId: null,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
export async function publishScheduledNote(scheduledNoteId) {
|
||||
const scheduled = await ScheduledNotes.findOneBy({
|
||||
id: scheduledNoteId
|
||||
});
|
||||
if (!scheduled) return "skip: scheduled note not found";
|
||||
if (scheduled.status !== "scheduled") return `skip: status=${scheduled.status}`;
|
||||
if (scheduled.scheduledAt.getTime() > Date.now() + 1000) return "skip: not due yet";
|
||||
await ScheduledNotes.update(scheduled.id, {
|
||||
status: "processing",
|
||||
error: null
|
||||
});
|
||||
try {
|
||||
const user = await Users.findOneByOrFail({
|
||||
id: scheduled.userId
|
||||
});
|
||||
const note = await createNoteFromApiData(user, scheduled.data, scheduled.scheduledAt);
|
||||
await ScheduledNotes.update(scheduled.id, {
|
||||
status: "published",
|
||||
noteId: note.id,
|
||||
error: null
|
||||
});
|
||||
return `published: ${note.id}`;
|
||||
} catch (err) {
|
||||
await ScheduledNotes.update(scheduled.id, {
|
||||
status: "failed",
|
||||
error: err?.message ?? String(err)
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { publishMainStream } from "../stream.js";
|
||||
import { Mutings, NoteThreadMutings, NoteUnreads } from "../../models/index.js";
|
||||
import { genId } from "../../misc/gen-id.js";
|
||||
export async function insertNoteUnread(userId, note, params) {
|
||||
//#region ミュートしているなら無視
|
||||
// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
|
||||
const mute = await Mutings.findBy({
|
||||
muterId: userId
|
||||
});
|
||||
if (mute.map((m)=>m.muteeId).includes(note.userId)) return;
|
||||
//#endregion
|
||||
// スレッドミュート
|
||||
const threadMute = await NoteThreadMutings.findOneBy({
|
||||
userId: userId,
|
||||
threadId: note.threadId || note.id
|
||||
});
|
||||
if (threadMute) return;
|
||||
const unread = {
|
||||
id: genId(),
|
||||
noteId: note.id,
|
||||
userId: userId,
|
||||
isSpecified: params.isSpecified,
|
||||
isMentioned: params.isMentioned,
|
||||
noteChannelId: note.channelId,
|
||||
noteUserId: note.userId
|
||||
};
|
||||
await NoteUnreads.insert(unread);
|
||||
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
|
||||
setTimeout(async ()=>{
|
||||
const exist = await NoteUnreads.exist({
|
||||
where: {
|
||||
id: unread.id
|
||||
}
|
||||
});
|
||||
if (!exist) return;
|
||||
if (params.isMentioned) {
|
||||
publishMainStream(userId, "unreadMention", note.id);
|
||||
}
|
||||
if (params.isSpecified) {
|
||||
publishMainStream(userId, "unreadSpecifiedNote", note.id);
|
||||
}
|
||||
if (note.channelId) {
|
||||
publishMainStream(userId, "unreadChannel", note.id);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NoteWatchings } from "../../models/index.js";
|
||||
export default (async (me, note)=>{
|
||||
await NoteWatchings.delete({
|
||||
noteId: note.id,
|
||||
userId: me
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NoteWatchings } from "../../models/index.js";
|
||||
import { genId } from "../../misc/gen-id.js";
|
||||
export default (async (me, note)=>{
|
||||
// 自分の投稿はwatchできない
|
||||
if (me === note.userId) {
|
||||
return;
|
||||
}
|
||||
await NoteWatchings.insert({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
noteId: note.id,
|
||||
userId: me,
|
||||
noteUserId: note.userId
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user