Fixed 267U.pre2

This commit is contained in:
2026-07-26 18:25:37 +09:00
parent 50bfaeafdf
commit 317d00a284
1286 changed files with 80222 additions and 1 deletions
@@ -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
});
});