Merge branch 'develop' into iceshrimp_mastodon

This commit is contained in:
naskya 2024-06-07 08:07:24 +09:00
commit 16f26bc6d7
No known key found for this signature in database
GPG key ID: 712D413B3A9FED5C
23 changed files with 607 additions and 582 deletions

50
Cargo.lock generated
View file

@ -4,9 +4,9 @@ version = 3
[[package]]
name = "addr2line"
version = "0.22.0"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678"
checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
dependencies = [
"gimli",
]
@ -239,9 +239,9 @@ dependencies = [
[[package]]
name = "backtrace"
version = "0.3.72"
version = "0.3.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17c6a35df3749d2e8bb1b7b21a976d82b15548788d2735b9d82f329268f71a11"
checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d"
dependencies = [
"addr2line",
"cc",
@ -928,6 +928,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "finl_unicode"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6"
[[package]]
name = "flate2"
version = "1.0.30"
@ -1117,9 +1123,9 @@ dependencies = [
[[package]]
name = "gimli"
version = "0.29.0"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
[[package]]
name = "group"
@ -1539,9 +1545,9 @@ dependencies = [
[[package]]
name = "libz-sys"
version = "1.1.18"
version = "1.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c15da26e5af7e25c90b37a2d75cdbf940cf4a55316de9d84c679c9b8bfabf82e"
checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9"
dependencies = [
"cc",
"libc",
@ -1885,9 +1891,9 @@ dependencies = [
[[package]]
name = "object"
version = "0.35.0"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8ec7ab813848ba4522158d5517a6093db1ded27575b070f4177b8d12b41db5e"
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
dependencies = [
"memchr",
]
@ -2023,9 +2029,9 @@ checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae"
[[package]]
name = "parking_lot"
version = "0.12.3"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb"
dependencies = [
"lock_api",
"parking_lot_core",
@ -3218,13 +3224,13 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "stringprep"
version = "0.1.5"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6"
dependencies = [
"finl_unicode",
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
@ -3585,12 +3591,6 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4259d9d4425d9f0661581b804cb85fe66a4c631cadd8f490d1c13a35d5d9291"
[[package]]
name = "unicode-segmentation"
version = "1.11.0"
@ -3982,9 +3982,9 @@ checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
[[package]]
name = "winnow"
version = "0.6.9"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86c949fede1d13936a99f14fafd3e76fd642b556dd2ce96287fbe2e0151bfac6"
checksum = "c3c52e9c97a68071b23e836c9380edae937f17b9c4667bd021973efc689f618d"
dependencies = [
"memchr",
]
@ -4017,9 +4017,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.1"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d"
[[package]]
name = "zune-core"

View file

@ -1,6 +1,10 @@
BEGIN;
DELETE FROM "migrations" WHERE name IN (
'SwSubscriptionAccessToken1709395223611',
'AddMastodonSubscriptionType1715181461692',
'ClientCredentials1713108561474',
'RefactorScheduledPosts1716804636187',
'RemoveEnumTypenameSuffix1716462794927',
'CreateScheduledNote1714728200194',
'AddBackTimezone1715351290096',
@ -30,10 +34,7 @@ DELETE FROM "migrations" WHERE name IN (
'EmojiModerator1692825433698',
'RemoveNsfwDetection1705848938166',
'FirefishUrlMove1707850084123',
'SwSubscriptionAccessToken1709395223611'
'UserProfileMentions1711075007936',
'ClientCredentials1713108561474',
'AddMastodonSubscriptionType1715181461692'
'UserProfileMentions1711075007936'
);
-- addMastodonSubscriptionType
@ -50,6 +51,38 @@ ALTER TABLE "user_profile" DROP COLUMN "mentions";
-- client-credential-support
ALTER TABLE "access_token" ALTER COLUMN "userId" SET NOT NULL;
-- refactor-scheduled-post
CREATE TABLE "scheduled_note" (
"id" character varying(32) NOT NULL PRIMARY KEY,
"noteId" character varying(32) NOT NULL,
"userId" character varying(32) NOT NULL,
"scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL
);
COMMENT ON COLUMN "scheduled_note"."noteId" IS 'The ID of the temporarily created note that corresponds to the schedule.';
CREATE EXTENSION pgcrypto;
CREATE FUNCTION generate_scheduled_note_id(size int) RETURNS text AS $$ DECLARE
characters text := 'abcdefghijklmnopqrstuvwxyz0123456789';
bytes bytea := gen_random_bytes(size);
l int := length(characters);
i int := 0;
output text := '';
BEGIN
WHILE i < size LOOP
output := output || substr(characters, get_byte(bytes, i) % l + 1, 1);
i := i + 1;
END LOOP;
RETURN output;
END;
$$ LANGUAGE plpgsql VOLATILE;
INSERT INTO "scheduled_note" ("id", "noteId", "userId", "scheduledAt") (SELECT generate_scheduled_note_id(16), "id", "userId", "scheduledAt" FROM "note" WHERE "note"."scheduledAt" IS NOT NULL);
DROP EXTENSION pgcrypto;
DROP FUNCTION "generate_scheduled_note_id";
CREATE INDEX "IDX_noteId_ScheduledNote" ON "scheduled_note" ("noteId");
CREATE INDEX "IDX_userId_ScheduledNote" ON "scheduled_note" ("userId");
ALTER TABLE "scheduled_note" ADD FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
ALTER TABLE "scheduled_note" ADD FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
ALTER TABLE "note" DROP COLUMN "scheduledAt";
-- remove-enum-typename-suffix
ALTER TYPE "antenna_src" RENAME TO "antenna_src_enum";
ALTER TYPE "drive_file_usage_hint" RENAME TO "drive_file_usage_hint_enum";

View file

@ -47,7 +47,7 @@
"@biomejs/cli-linux-arm64": "1.8.0",
"@biomejs/cli-linux-x64": "1.8.0",
"@types/node": "20.14.2",
"execa": "9.1.0",
"execa": "9.2.0",
"pnpm": "9.2.0",
"typescript": "5.4.5"
}

View file

@ -927,6 +927,7 @@ export interface Note {
threadId: string | null
updatedAt: DateTimeWithTimeZone | null
lang: string | null
scheduledAt: DateTimeWithTimeZone | null
}
export interface NoteEdit {
id: string
@ -1086,12 +1087,6 @@ export interface ReplyMuting {
muteeId: string
muterId: string
}
export interface ScheduledNote {
id: string
noteId: string
userId: string
scheduledAt: DateTimeWithTimeZone
}
export enum AntennaSrc {
All = 'all',
Group = 'group',

View file

@ -53,7 +53,6 @@ pub mod registry_item;
pub mod relay;
pub mod renote_muting;
pub mod reply_muting;
pub mod scheduled_note;
pub mod sea_orm_active_enums;
pub mod signin;
pub mod sw_subscription;

View file

@ -68,6 +68,8 @@ pub struct Model {
#[sea_orm(column_name = "updatedAt")]
pub updated_at: Option<DateTimeWithTimeZone>,
pub lang: Option<String>,
#[sea_orm(column_name = "scheduledAt")]
pub scheduled_at: Option<DateTimeWithTimeZone>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@ -124,8 +126,6 @@ pub enum Relation {
PromoNote,
#[sea_orm(has_many = "super::promo_read::Entity")]
PromoRead,
#[sea_orm(has_many = "super::scheduled_note::Entity")]
ScheduledNote,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
@ -228,12 +228,6 @@ impl Related<super::promo_read::Entity> for Entity {
}
}
impl Related<super::scheduled_note::Entity> for Entity {
fn to() -> RelationDef {
Relation::ScheduledNote.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()

View file

@ -51,7 +51,6 @@ pub use super::registry_item::Entity as RegistryItem;
pub use super::relay::Entity as Relay;
pub use super::renote_muting::Entity as RenoteMuting;
pub use super::reply_muting::Entity as ReplyMuting;
pub use super::scheduled_note::Entity as ScheduledNote;
pub use super::signin::Entity as Signin;
pub use super::sw_subscription::Entity as SwSubscription;
pub use super::used_username::Entity as UsedUsername;

View file

@ -1,55 +0,0 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[sea_orm(table_name = "scheduled_note")]
#[cfg_attr(
feature = "napi",
napi_derive::napi(object, js_name = "ScheduledNote", use_nullable = true)
)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
#[sea_orm(column_name = "noteId")]
pub note_id: String,
#[sea_orm(column_name = "userId")]
pub user_id: String,
#[sea_orm(column_name = "scheduledAt")]
pub scheduled_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::note::Entity",
from = "Column::NoteId",
to = "super::note::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Note,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::note::Entity> for Entity {
fn to() -> RelationDef {
Relation::Note.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -153,8 +153,6 @@ pub enum Relation {
PromoRead,
#[sea_orm(has_many = "super::registry_item::Entity")]
RegistryItem,
#[sea_orm(has_many = "super::scheduled_note::Entity")]
ScheduledNote,
#[sea_orm(has_many = "super::signin::Entity")]
Signin,
#[sea_orm(has_many = "super::sw_subscription::Entity")]
@ -347,12 +345,6 @@ impl Related<super::registry_item::Entity> for Entity {
}
}
impl Related<super::scheduled_note::Entity> for Entity {
fn to() -> RelationDef {
Relation::ScheduledNote.def()
}
}
impl Related<super::signin::Entity> for Entity {
fn to() -> RelationDef {
Relation::Signin.def()

View file

@ -22,9 +22,9 @@
"@swc/core-android-arm64": "1.3.11"
},
"dependencies": {
"@bull-board/api": "5.20.0",
"@bull-board/koa": "5.20.0",
"@bull-board/ui": "5.20.0",
"@bull-board/api": "5.20.1",
"@bull-board/koa": "5.20.1",
"@bull-board/ui": "5.20.1",
"@discordapp/twemoji": "15.0.3",
"@koa/cors": "5.0.0",
"@koa/multer": "3.0.2",
@ -38,7 +38,7 @@
"archiver": "7.0.1",
"async-lock": "1.4.0",
"async-mutex": "0.5.0",
"aws-sdk": "2.1635.0",
"aws-sdk": "2.1636.0",
"axios": "1.7.2",
"backend-rs": "workspace:*",
"blurhash": "2.0.5",
@ -59,7 +59,7 @@
"firefish-js": "workspace:*",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.0",
"got": "14.4.0",
"got": "14.4.1",
"gunzip-maybe": "1.4.2",
"hpagent": "1.2.0",
"ioredis": "5.4.1",

View file

@ -74,7 +74,6 @@ import { Webhook } from "@/models/entities/webhook.js";
import { UserIp } from "@/models/entities/user-ip.js";
import { NoteEdit } from "@/models/entities/note-edit.js";
import { NoteFile } from "@/models/entities/note-file.js";
import { ScheduledNote } from "@/models/entities/scheduled-note.js";
import { entities as charts } from "@/services/chart/entities.js";
import { dbLogger } from "./logger.js";
@ -183,7 +182,6 @@ export const entities = [
UserPending,
Webhook,
UserIp,
ScheduledNote,
...charts,
];

View file

@ -0,0 +1,81 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class RefactorScheduledPosts1716804636187 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "note" ADD COLUMN "scheduledAt" timestamp with time zone`,
);
await queryRunner.query(
`CREATE TEMP TABLE "tmp_scheduled_note" (LIKE "note")`,
);
await queryRunner.query(
`INSERT INTO "tmp_scheduled_note" (SELECT * FROM "note" WHERE "note"."id" IN (SELECT "noteId" FROM "scheduled_note"))`,
);
await queryRunner.query(
`UPDATE "tmp_scheduled_note" SET "scheduledAt" = "scheduled_note"."scheduledAt" FROM "scheduled_note" WHERE "tmp_scheduled_note"."id" = "scheduled_note"."noteId"`,
);
await queryRunner.query(
`DELETE FROM "note" WHERE "note"."id" IN (SELECT "noteId" FROM "scheduled_note")`,
);
await queryRunner.query(
`INSERT INTO "note" SELECT * FROM "tmp_scheduled_note"`,
);
await queryRunner.query(`DROP TABLE "scheduled_note"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "scheduled_note" (
"id" character varying(32) NOT NULL PRIMARY KEY,
"noteId" character varying(32) NOT NULL,
"userId" character varying(32) NOT NULL,
"scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL
)`,
);
await queryRunner.query(
`COMMENT ON COLUMN "scheduled_note"."noteId" IS 'The ID of the temporarily created note that corresponds to the schedule.'`,
);
// temp function to populate "scheduled_note"."id" with random values (as it's unused)
await queryRunner.query("CREATE EXTENSION pgcrypto");
await queryRunner.query(`
CREATE FUNCTION generate_scheduled_note_id(size int) RETURNS text AS $$ DECLARE
characters text := 'abcdefghijklmnopqrstuvwxyz0123456789';
bytes bytea := gen_random_bytes(size);
l int := length(characters);
i int := 0;
output text := '';
BEGIN
WHILE i < size LOOP
output := output || substr(characters, get_byte(bytes, i) % l + 1, 1);
i := i + 1;
END LOOP;
RETURN output;
END;
$$ LANGUAGE plpgsql VOLATILE;
`);
await queryRunner.query(
`INSERT INTO "scheduled_note" ("id", "noteId", "userId", "scheduledAt") (SELECT generate_scheduled_note_id(16), "id", "userId", "scheduledAt" FROM "note" WHERE "note"."scheduledAt" IS NOT NULL)`,
);
await queryRunner.query("DROP EXTENSION pgcrypto");
await queryRunner.query(`DROP FUNCTION "generate_scheduled_note_id"`);
await queryRunner.query(
`CREATE INDEX "IDX_noteId_ScheduledNote" ON "scheduled_note" ("noteId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_userId_ScheduledNote" ON "scheduled_note" ("userId")`,
);
await queryRunner.query(`
ALTER TABLE "scheduled_note"
ADD FOREIGN KEY ("noteId") REFERENCES "note"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION
`);
await queryRunner.query(`
ALTER TABLE "scheduled_note"
ADD FOREIGN KEY ("userId") REFERENCES "user"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION
`);
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "scheduledAt"`);
}
}

View file

@ -2,7 +2,7 @@ import fetch from "node-fetch";
import { Converter } from "opencc-js";
import { getAgentByUrl } from "@/misc/fetch.js";
import { fetchMeta } from "backend-rs";
import type { PostLanguage } from "@/misc/langmap";
import type { PostLanguage } from "firefish-js";
import * as deepl from "deepl-node";
// DeepL translate and LibreTranslate don't provide

View file

@ -31,6 +31,11 @@ export class Note {
})
public createdAt: Date;
@Column("timestamp with time zone", {
nullable: true,
})
public scheduledAt: Date | null;
@Index()
@Column({
...id(),

View file

@ -1,48 +0,0 @@
import {
Entity,
JoinColumn,
Column,
ManyToOne,
OneToOne,
PrimaryColumn,
Index,
type Relation,
} from "typeorm";
import { Note } from "./note.js";
import { id } from "../id.js";
import { User } from "./user.js";
@Entity()
export class ScheduledNote {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
comment:
"The ID of the temporarily created note that corresponds to the schedule.",
})
public noteId: Note["id"];
@Index()
@Column(id())
public userId: User["id"];
@Column("timestamp with time zone")
public scheduledAt: Date;
//#region Relations
@OneToOne(() => Note, {
onDelete: "CASCADE",
})
@JoinColumn()
public note: Relation<Note>;
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
@JoinColumn()
public user: Relation<User>;
//#endregion
}

View file

@ -67,7 +67,6 @@ import { UserIp } from "./entities/user-ip.js";
import { NoteFileRepository } from "./repositories/note-file.js";
import { NoteEditRepository } from "./repositories/note-edit.js";
import { UserProfileRepository } from "./repositories/user-profile.js";
import { ScheduledNote } from "./entities/scheduled-note.js";
export const Announcements = db.getRepository(Announcement);
export const AnnouncementReads = db.getRepository(AnnouncementRead);
@ -136,4 +135,3 @@ export const RegistryItems = db.getRepository(RegistryItem);
export const Webhooks = db.getRepository(Webhook);
export const Ads = db.getRepository(Ad);
export const PasswordResetRequests = db.getRepository(PasswordResetRequest);
export const ScheduledNotes = db.getRepository(ScheduledNote);

View file

@ -12,7 +12,6 @@ import {
Channels,
UserProfiles,
Notes,
ScheduledNotes,
} from "../index.js";
import type { Packed } from "@/misc/schema.js";
import { countReactions, decodeReaction, nyaify } from "backend-rs";
@ -224,19 +223,17 @@ export const NoteRepository = db.getRepository(Note).extend({
host,
);
let scheduledAt: string | undefined;
if (note.visibility === "specified" && note.visibleUserIds.length === 0) {
scheduledAt = (
await ScheduledNotes.findOneBy({
noteId: note.id,
})
)?.scheduledAt?.toISOString();
}
const reactionEmoji = await populateEmojis(reactionEmojiNames, host);
const packed: Packed<"Note"> = await awaitAll({
id: note.id,
createdAt: note.createdAt.toISOString(),
// FIXME: note.scheduledAt should be a `Date`
scheduledAt:
note.scheduledAt == null
? undefined
: typeof note.scheduledAt === "string"
? note.scheduledAt
: note.scheduledAt?.toISOString(),
userId: note.userId,
user: Users.pack(note.user ?? note.userId, me, {
detail: false,
@ -266,7 +263,6 @@ export const NoteRepository = db.getRepository(Note).extend({
},
})
: undefined,
scheduledAt,
reactions: countReactions(note.reactions),
reactionEmojis: reactionEmoji,
emojis: noteEmoji,

View file

@ -1,4 +1,4 @@
import { Users, Notes, ScheduledNotes, DriveFiles } from "@/models/index.js";
import { Users, Notes, DriveFiles } from "@/models/index.js";
import type { DbUserScheduledNoteData } from "@/queue/types.js";
import { queueLogger } from "../../logger.js";
import type Bull from "bull";
@ -14,52 +14,82 @@ export async function scheduledNote(
): Promise<void> {
logger.info(`Creating: ${job.data.noteId}`);
const user = await Users.findOneBy({ id: job.data.user.id });
const [user, draftNote] = await Promise.all([
Users.findOneBy({ id: job.data.user.id }),
Notes.findOneBy({ id: job.data.noteId }),
]);
if (user == null) {
logger.warn(`User ${job.data.user.id} does not exist, aborting`);
done();
return;
}
const note = await Notes.findOneBy({ id: job.data.noteId });
if (note == null) {
if (draftNote == null) {
logger.warn(`Note ${job.data.noteId} does not exist, aborting`);
done();
return;
}
const files = await DriveFiles.findBy({ id: In(note.fileIds) });
if (user.isSuspended) {
deleteNote(user, note);
logger.info(
`Cancelled due to user ${job.data.user.id} being suspended, aborting`,
);
await deleteNote(user, draftNote);
done();
return;
}
await ScheduledNotes.delete({
noteId: note.id,
userId: user.id,
});
const [visibleUsers, reply, renote, files] = await Promise.all([
job.data.option.visibleUserIds
? Users.findBy({
id: In(job.data.option.visibleUserIds),
})
: [],
job.data.option.replyId != null
? Notes.findOneBy({ id: job.data.option.replyId })
: undefined,
job.data.option.renoteId != null
? Notes.findOneBy({ id: job.data.option.renoteId })
: undefined,
DriveFiles.findBy({ id: In(draftNote.fileIds) }),
]);
const visibleUsers = job.data.option.visibleUserIds
? await Users.findBy({
id: In(job.data.option.visibleUserIds),
})
: [];
if (job.data.option.replyId != null && reply == null) {
logger.warn(
`Note ${job.data.option.replyId} (reply) does not exist, aborting`,
);
done();
return;
}
if (job.data.option.renoteId != null && renote == null) {
logger.warn(
`Note ${job.data.option.renoteId} (renote) does not exist, aborting`,
);
done();
return;
}
// Create scheduled (actual) note
await createNote(user, {
createdAt: new Date(),
scheduledAt: null,
files,
poll: job.data.option.poll,
text: note.text || undefined,
lang: note.lang,
reply: note.reply,
renote: note.renote,
cw: note.cw,
localOnly: note.localOnly,
text: draftNote.text || undefined,
lang: draftNote.lang,
reply,
renote,
cw: draftNote.cw,
localOnly: draftNote.localOnly,
visibility: job.data.option.visibility,
visibleUsers,
channel: note.channel,
channel: draftNote.channel,
});
await deleteNote(user, note);
// Delete temporal (draft) note
await deleteNote(user, draftNote);
logger.info("Success");

View file

@ -62,6 +62,8 @@ export type DbUserScheduledNoteData = {
option: {
visibility: string;
visibleUserIds?: string[] | null;
replyId?: string;
renoteId?: string;
poll?: IPoll;
};
noteId: Note["id"];

View file

@ -7,7 +7,6 @@ import {
Notes,
Channels,
Blockings,
ScheduledNotes,
} from "@/models/index.js";
import type { DriveFile } from "@/models/entities/drive-file.js";
import type { Note } from "@/models/entities/note.js";
@ -16,7 +15,7 @@ import { config } from "@/config.js";
import { noteVisibilities } from "@/types.js";
import { ApiError } from "@/server/api/error.js";
import define from "@/server/api/define.js";
import { HOUR, genIdAt } from "backend-rs";
import { HOUR } from "backend-rs";
import { getNote } from "@/server/api/common/getters.js";
import { langmap } from "firefish-js";
import { createScheduledNoteJob } from "@/queue/index.js";
@ -95,6 +94,12 @@ export const meta = {
code: "ACCOUNT_LOCKED",
id: "d390d7e1-8a5e-46ed-b625-06271cafd3d3",
},
scheduledTimeIsPast: {
message: "The scheduled time is past.",
code: "SCHEDULED_TIME_IS_PAST",
id: "277f91df-8d8e-4647-b4e3-5885fda8978a",
},
},
} as const;
@ -299,26 +304,26 @@ export default define(meta, paramDef, async (ps, user) => {
}
let delay: number | null = null;
if (ps.scheduledAt) {
if (ps.scheduledAt != null) {
delay = ps.scheduledAt - Date.now();
if (delay < 0) {
delay = null;
throw new ApiError(meta.errors.scheduledTimeIsPast);
}
}
const now = new Date();
// Create a post
const note = await create(
user,
{
createdAt: now,
createdAt: new Date(),
scheduledAt: delay != null ? new Date(ps.scheduledAt!) : null,
files: files,
poll: ps.poll
? {
choices: ps.poll.choices,
multiple: ps.poll.multiple,
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
expiresAt:
ps.poll.expiresAt != null ? new Date(ps.poll.expiresAt) : null,
}
: undefined,
text: ps.text || undefined,
@ -344,13 +349,6 @@ export default define(meta, paramDef, async (ps, user) => {
false,
delay
? async (note) => {
await ScheduledNotes.insert({
id: genIdAt(now),
noteId: note.id,
userId: user.id,
scheduledAt: new Date(ps.scheduledAt as number),
});
createScheduledNoteJob(
{
user: { id: user.id },
@ -367,6 +365,8 @@ export default define(meta, paramDef, async (ps, user) => {
: undefined,
visibility: ps.visibility,
visibleUserIds: ps.visibleUserIds,
replyId: ps.replyId ?? undefined,
renoteId: ps.renoteId ?? undefined,
},
},
delay,

View file

@ -133,15 +133,10 @@ class NotificationManager {
}
}
type MinimumUser = {
id: User["id"];
host: User["host"];
username: User["username"];
uri: User["uri"];
};
type Option = {
type UserLike = Pick<User, "id" | "host" | "username" | "uri">;
type NoteLike = {
createdAt?: Date | null;
scheduledAt?: Date | null;
name?: string | null;
text?: string | null;
lang?: string | null;
@ -152,9 +147,9 @@ type Option = {
localOnly?: boolean | null;
cw?: string | null;
visibility?: string;
visibleUsers?: MinimumUser[] | null;
visibleUsers?: UserLike[] | null;
channel?: Channel | null;
apMentions?: MinimumUser[] | null;
apMentions?: UserLike[] | null;
apHashtags?: string[] | null;
apEmojis?: string[] | null;
uri?: string | null;
@ -163,16 +158,11 @@ type Option = {
};
export default async (
user: {
id: User["id"];
username: User["username"];
host: User["host"];
isSilenced: User["isSilenced"];
createdAt: User["createdAt"];
isBot: User["isBot"];
inbox?: User["inbox"];
},
data: Option,
user: Pick<
User,
"id" | "username" | "host" | "isSilenced" | "createdAt" | "isBot"
> & { inbox?: User["inbox"] },
data: NoteLike,
silent = false,
waitToPublish?: (note: Note) => Promise<void>,
) =>
@ -181,6 +171,9 @@ export default async (
const dontFederateInitially =
data.visibility?.startsWith("hidden") === true;
// Whether this is a scheduled "draft" post (yet to be published)
const isDraft = data.scheduledAt != null;
// 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 (
@ -208,6 +201,7 @@ export default async (
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";
@ -277,13 +271,9 @@ export default async (
data.localOnly = true;
}
if (data.text) {
data.text = data.text.trim();
} else {
data.text = null;
}
data.text = data.text?.trim() ?? null;
if (data.lang) {
if (data.lang != null) {
if (!Object.keys(langmap).includes(data.lang.toLowerCase()))
throw new Error("invalid param");
data.lang = data.lang.toLowerCase();
@ -297,10 +287,10 @@ export default async (
// 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 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)!))
? concat(data.poll.choices.map((choice) => mfm.parse(choice)))
: [];
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
@ -318,16 +308,16 @@ export default async (
.splice(0, 32);
if (
data.reply &&
data.reply != null &&
user.id !== data.reply.userId &&
!mentionedUsers.some((u) => u.id === data.reply!.userId)
!mentionedUsers.some((u) => u.id === data.reply?.userId)
) {
mentionedUsers.push(
await Users.findOneByOrFail({ id: data.reply!.userId }),
await Users.findOneByOrFail({ id: data.reply.userId }),
);
}
if (data.visibility === "specified") {
if (!isDraft && data.visibility === "specified") {
if (data.visibleUsers == null) throw new Error("invalid param");
for (const u of data.visibleUsers) {
@ -338,10 +328,10 @@ export default async (
if (
data.reply &&
!data.visibleUsers.some((x) => x.id === data.reply!.userId)
!data.visibleUsers.some((x) => x.id === data.reply?.userId)
) {
data.visibleUsers.push(
await Users.findOneByOrFail({ id: data.reply!.userId }),
await Users.findOneByOrFail({ id: data.reply?.userId }),
);
}
}
@ -365,314 +355,321 @@ export default async (
});
}
// ハッシュタグ更新
if (data.visibility === "public" || data.visibility === "home") {
updateHashtags(user, tags);
}
if (!isDraft) {
// ハッシュタグ更新
if (data.visibility === "public" || data.visibility === "home") {
updateHashtags(user, tags);
}
// Increment notes count (user)
incNotesCountOfUser(user);
// Increment notes count (user)
incNotesCountOfUser(user);
// Word mutes & antenna
const thisNoteIsMutedBy: string[] = [];
// Word mutes & antenna
const thisNoteIsMutedBy: string[] = [];
await hardMutesCache
.fetch(null, () =>
UserProfiles.find({
where: {
enableWordMute: true,
},
select: ["userId", "mutedWords", "mutedPatterns"],
}),
)
.then(async (us) => {
for (const u of us) {
if (u.userId === user.id) return;
await checkWordMute(note, u.mutedWords, u.mutedPatterns).then(
(shouldMute: boolean) => {
if (shouldMute) {
thisNoteIsMutedBy.push(u.userId);
MutedNotes.insert({
id: genId(),
userId: u.userId,
noteId: note.id,
reason: "word",
});
}
await hardMutesCache
.fetch(null, () =>
UserProfiles.find({
where: {
enableWordMute: true,
},
);
}
});
select: ["userId", "mutedWords", "mutedPatterns"],
}),
)
.then(async (us) => {
for (const u of us) {
if (u.userId === user.id) return;
await checkWordMute(note, u.mutedWords, u.mutedPatterns).then(
(shouldMute: boolean) => {
if (shouldMute) {
thisNoteIsMutedBy.push(u.userId);
MutedNotes.insert({
id: genId(),
userId: u.userId,
noteId: note.id,
reason: "word",
});
}
},
);
}
});
// type errors will be resolved by https://github.com/napi-rs/napi-rs/pull/2054
const _note = toRustObject(note);
if (note.renoteId == null || isQuote(_note)) {
await updateAntennasOnNewNote(_note, user, thisNoteIsMutedBy);
}
// type errors will be resolved by https://github.com/napi-rs/napi-rs/pull/2054
const _note = toRustObject(note);
if (note.renoteId == null || isQuote(_note)) {
await updateAntennasOnNewNote(_note, user, thisNoteIsMutedBy);
}
// Channel
if (note.channelId) {
ChannelFollowings.findBy({ followeeId: note.channelId }).then(
(followings) => {
for (const following of followings) {
insertNoteUnread(following.followerId, note, {
isSpecified: false,
// Channel
if (note.channelId != null) {
ChannelFollowings.findBy({ followeeId: note.channelId }).then(
(followings) => {
for (const following of followings) {
insertNoteUnread(following.followerId, note, {
isSpecified: false,
isMentioned: false,
});
}
},
);
}
if (data.reply) {
saveReply(data.reply, note);
}
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
if (
data.renote &&
!user.isBot &&
(await countSameRenotes(user.id, data.renote.id, note.id)) === 0
) {
incRenoteCount(data.renote);
}
if (data.poll?.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now();
endedPollNotificationQueue.add(
{
noteId: note.id,
},
{
delay,
removeOnComplete: true,
},
);
}
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,
});
}
},
);
}
if (data.reply) {
saveReply(data.reply, note);
}
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
if (
data.renote &&
!user.isBot &&
(await countSameRenotes(user.id, data.renote.id, note.id)) === 0
) {
incRenoteCount(data.renote);
}
if (data.poll?.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now();
endedPollNotificationQueue.add(
{
noteId: note.id,
},
{
delay,
removeOnComplete: true,
},
);
}
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: string;
let noteToPublish: Note;
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;
}
for (const u of mentionedUsers) {
// ローカルユーザーのみ
if (!Users.isLocalUser(u)) continue;
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,
insertNoteUnread(u.id, note, {
isSpecified: false,
isMentioned: true,
});
publishMainStream(data.reply.userId, "reply", packedReply);
}
}
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 != null) {
// 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 != null) {
const type = data.text ? "quote" : "renote";
// 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 === data.reply!.userId && x.on.includes("reply"),
(x) => x.userId === renote.userId && x.on.includes("renote"),
);
for (const webhook of webhooks) {
webhookDeliver(webhook, "reply", {
note: packedReply,
webhookDeliver(webhook, "renote", {
note: packedRenote,
});
}
}
}
}
// If it is renote
if (data.renote) {
const type = data.text ? "quote" : "renote";
Promise.all(nmRelatedPromises).then(() => {
nm.deliver();
});
// Notify
if (data.renote.userHost === null) {
const threadMuted = await NoteThreadMutings.findOneBy({
userId: data.renote.userId,
threadId: data.renote.threadId || data.renote.id,
});
//#region AP deliver
if (Users.isLocalUser(user) && !dontFederateInitially) {
(async () => {
const noteActivity = await renderNoteOrRenoteActivity(data, note);
const dm = new DeliverManager(user, noteActivity);
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,
});
}
// メンションされたリモートユーザーに配送
for (const u of mentionedUsers.filter((u) =>
Users.isRemoteUser(u),
)) {
dm.addDirectRecipe(u as IRemoteUser);
}
// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
if (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?.userHost != null) {
const u = await Users.findOneBy({ id: data.renote.userId });
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
}
// フォロワーに配送
if (["public", "home", "followers"].includes(note.visibility)) {
dm.addFollowersRecipe();
}
if (["public"].includes(note.visibility)) {
deliverToRelays(user, noteActivity);
}
dm.execute();
})();
}
//#endregion
}
Promise.all(nmRelatedPromises).then(() => {
nm.deliver();
});
if (data.channel) {
Channels.increment({ id: data.channel.id }, "notesCount", 1);
Channels.update(data.channel.id, {
lastNotedAt: new Date(),
});
//#region AP deliver
if (Users.isLocalUser(user) && !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 as IRemoteUser);
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);
}
// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
if (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?.userHost != null) {
const u = await Users.findOneBy({ id: data.renote.userId });
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(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(),
});
if (!dontFederateInitially) {
let publishKey: string;
let noteToPublish: Note;
const relays = await getCachedRelays();
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);
// 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();
}
}
});
async function renderNoteOrRenoteActivity(data: Option, note: Note) {
async function renderNoteOrRenoteActivity(data: NoteLike, note: Note) {
if (data.localOnly) return null;
const content =
@ -704,17 +701,17 @@ function incRenoteCount(renote: Note) {
async function insertNote(
user: { id: User["id"]; host: User["host"] },
data: Option,
data: NoteLike,
tags: string[],
emojis: string[],
mentionedUsers: MinimumUser[],
mentionedUsers: UserLike[],
) {
if (data.createdAt === null || data.createdAt === undefined) {
data.createdAt = new Date();
}
const insert = new Note({
data.createdAt ??= new Date();
const note = new Note({
id: genIdAt(data.createdAt),
createdAt: data.createdAt,
scheduledAt: data.scheduledAt ?? null,
fileIds: data.files ? data.files.map((file) => file.id) : [],
replyId: data.reply ? data.reply.id : null,
renoteId: data.renote ? data.renote.id : null,
@ -743,7 +740,7 @@ async function insertNote(
attachedFileTypes: data.files ? data.files.map((file) => file.type) : [],
// 以下非正規化データ
// denormalized fields
replyUserId: data.reply ? data.reply.userId : null,
replyUserHost: data.reply ? data.reply.userHost : null,
renoteUserId: data.renote ? data.renote.userId : null,
@ -751,22 +748,22 @@ async function insertNote(
userHost: user.host,
});
if (data.uri != null) insert.uri = data.uri;
if (data.url != null) insert.url = data.url;
if (data.uri != null) note.uri = data.uri;
if (data.url != null) note.url = data.url;
// 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(
note.mentions = mentionedUsers.map((u) => u.id);
const profiles = await UserProfiles.findBy({ userId: In(note.mentions) });
note.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;
const url = profile?.url ?? null;
return {
uri: u.uri,
url: url == null ? undefined : url,
url: url ?? undefined,
username: u.username,
host: u.host,
} as IMentionedRemoteUsers[0];
@ -776,12 +773,12 @@ async function insertNote(
// 投稿を作成
try {
if (insert.hasPoll) {
if (note.hasPoll) {
// Start transaction
await db.transaction(async (transactionalEntityManager) => {
if (!data.poll) throw new Error("Empty poll data");
await transactionalEntityManager.insert(Note, insert);
await transactionalEntityManager.insert(Note, note);
let expiresAt: Date | null;
if (
@ -794,12 +791,12 @@ async function insertNote(
}
const poll = new Poll({
noteId: insert.id,
noteId: note.id,
choices: data.poll.choices,
expiresAt,
multiple: data.poll.multiple,
votes: new Array(data.poll.choices.length).fill(0),
noteVisibility: insert.visibility,
noteVisibility: note.visibility,
userId: user.id,
userHost: user.host,
});
@ -807,10 +804,10 @@ async function insertNote(
await transactionalEntityManager.insert(Poll, poll);
});
} else {
await Notes.insert(insert);
await Notes.insert(note);
}
return insert;
return note;
} catch (e) {
// duplicate key error
if (isDuplicateKeyValueError(e)) {
@ -857,7 +854,7 @@ async function notifyToWatchersOfReplyee(
}
async function createMentionedEvents(
mentionedUsers: MinimumUser[],
mentionedUsers: UserLike[],
note: Note,
nm: NotificationManager,
) {

View file

@ -42,8 +42,12 @@ export default async function (
) {
const deletedAt = new Date();
// Whether this is a scheduled "draft" post
const isDraft = note.scheduledAt != null;
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
if (
!isDraft &&
note.renoteId &&
(await countSameRenotes(user.id, note.renoteId, note.id)) === 0 &&
deleteFromDb
@ -52,7 +56,7 @@ export default async function (
Notes.decrement({ id: note.renoteId }, "score", 1);
}
if (note.replyId && deleteFromDb) {
if (!isDraft && note.replyId != null && deleteFromDb) {
await Notes.decrement({ id: note.replyId }, "repliesCount", 1);
}
@ -74,7 +78,7 @@ export default async function (
}
//#region ローカルの投稿なら削除アクティビティを配送
if (Users.isLocalUser(user) && !note.localOnly) {
if (!isDraft && Users.isLocalUser(user) && !note.localOnly) {
let renote: Note | null = null;
// if deletd note is renote

View file

@ -31,8 +31,8 @@ importers:
specifier: 20.14.2
version: 20.14.2
execa:
specifier: 9.1.0
version: 9.1.0
specifier: 9.2.0
version: 9.2.0
pnpm:
specifier: 9.2.0
version: 9.2.0
@ -43,14 +43,14 @@ importers:
packages/backend:
dependencies:
'@bull-board/api':
specifier: 5.20.0
version: 5.20.0(@bull-board/ui@5.20.0)
specifier: 5.20.1
version: 5.20.1(@bull-board/ui@5.20.1)
'@bull-board/koa':
specifier: 5.20.0
version: 5.20.0(@types/koa@2.15.0)(lodash@4.17.21)(pug@3.0.3)
specifier: 5.20.1
version: 5.20.1(@types/koa@2.15.0)(lodash@4.17.21)(pug@3.0.3)
'@bull-board/ui':
specifier: 5.20.0
version: 5.20.0
specifier: 5.20.1
version: 5.20.1
'@discordapp/twemoji':
specifier: 15.0.3
version: 15.0.3
@ -91,8 +91,8 @@ importers:
specifier: 0.5.0
version: 0.5.0
aws-sdk:
specifier: 2.1635.0
version: 2.1635.0
specifier: 2.1636.0
version: 2.1636.0
axios:
specifier: 1.7.2
version: 1.7.2
@ -154,8 +154,8 @@ importers:
specifier: 4.0.0
version: 4.0.0
got:
specifier: 14.4.0
version: 14.4.0
specifier: 14.4.1
version: 14.4.1
gunzip-maybe:
specifier: 1.4.2
version: 1.4.2
@ -1043,11 +1043,13 @@ packages:
'@biomejs/cli-darwin-arm64@1.8.0':
resolution: {integrity: sha512-dBAYzfIJ1JmWigKlWourT3sJ3I60LZPjqNwwlsyFjiv5AV7vPeWlHVVIImV2BpINwNjZQhpXnwDfVnGS4vr7AA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@1.8.0':
resolution: {integrity: sha512-ZTTSD0bP0nn9UpRDGQrQNTILcYSj+IkxTYr3CAV64DWBDtQBomlk2oVKWzDaA1LOhpAsTh0giLCbPJaVk2jfMQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@1.8.0':
@ -1059,6 +1061,7 @@ packages:
'@biomejs/cli-linux-arm64@1.8.0':
resolution: {integrity: sha512-cx725jTlJS6dskvJJwwCQaaMRBKE2Qss7ukzmx27Rn/DXRxz6tnnBix4FUGPf1uZfwrERkiJlbWM05JWzpvvXg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@1.8.0':
@ -1070,6 +1073,7 @@ packages:
'@biomejs/cli-linux-x64@1.8.0':
resolution: {integrity: sha512-cmgmhlD4QUxMhL1VdaNqnB81xBHb3R7huVNyYnPYzP+AykZ7XqJbPd1KcWAszNjUk2AHdx0aLKEBwCOWemxb2g==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@1.8.0':
@ -1084,16 +1088,16 @@ packages:
cpu: [x64]
os: [win32]
'@bull-board/api@5.20.0':
resolution: {integrity: sha512-WUSuHdunODSWX8rkZmTnXpJsrUHqLS6+H3IGN0MNh6ylM/plZxGJJ3jW4nTXePWASeLNoDC0gjRcziDjlodPnQ==}
'@bull-board/api@5.20.1':
resolution: {integrity: sha512-45aDhnOzWRrtUUAKHxdClSnLIus5f8BK3ATzb2IwI/BRgOi1lWTe1YG266hVqDdWXsUDxKzf75DAANKfAoEsRA==}
peerDependencies:
'@bull-board/ui': 5.20.0
'@bull-board/ui': 5.20.1
'@bull-board/koa@5.20.0':
resolution: {integrity: sha512-jto1WNIBZscCSJMkGm4G4qrb5KhblyJT/Q/59k5hHg9fdogzW9PfR+UQj7OOjLTMNX3phtPNvL0jG7/3HfmtIA==}
'@bull-board/koa@5.20.1':
resolution: {integrity: sha512-xUEEpMsdzZWHZQMnfk4a9kFhCz4PQkMBnb/t/C2xYwG15nCLapHWqMd5xO59OxnWOZ5XIiLsJwtCuf0l5G+L8A==}
'@bull-board/ui@5.20.0':
resolution: {integrity: sha512-EuijEHzQ9EAaA8WD4B+iXpTWTvCpTU2pskVUOs8CFkp0AYBqIhQDOi1W3f+e3FMqwS81l2/WkVHKkd6QRzbRzA==}
'@bull-board/ui@5.20.1':
resolution: {integrity: sha512-RzNinC4FKHNuxzkIRsCL+n9iO5RxmF5YM7byCuuv1/UeFjtCtsLHFi6TI9ZgJsXETA2Uxq9Mg7ppncojUjrINw==}
'@cbor-extract/cbor-extract-darwin-arm64@2.2.0':
resolution: {integrity: sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==}
@ -2887,8 +2891,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
aws-sdk@2.1635.0:
resolution: {integrity: sha512-zab4y8ftjgcctYao33c1fr8Yx1wMuRlEbZT7hwEXcK8Ta3+LAEXJs1wKjPpium+KUwl6zw7wxhaIdEibiz6InA==}
aws-sdk@2.1636.0:
resolution: {integrity: sha512-0w/jOCYnwewLYjH4UCh3GTBjR/NMdvEKNrd1pnM4FvfJSmjfzCinDvmf5Qc6xeIrqPfrdYOoQh7NJhYeJScCIQ==}
engines: {node: '>= 10.0.0'}
axios@0.24.0:
@ -4264,9 +4268,9 @@ packages:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
execa@9.1.0:
resolution: {integrity: sha512-lSgHc4Elo2m6bUDhc3Hl/VxvUDJdQWI40RZ4KMY9bKRc+hgMOT7II/JjbNDhI8VnMtrCb7U/fhpJIkLORZozWw==}
engines: {node: '>=18'}
execa@9.2.0:
resolution: {integrity: sha512-vpOyYg7UAVKLAWWtRS2gAdgkT7oJbCn0me3gmUmxZih4kd3MF/oo8kNTBTIbkO3yuuF5uB4ZCZfn8BOolITYhg==}
engines: {node: ^18.19.0 || >=20.5.0}
executable@4.1.1:
resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
@ -4606,8 +4610,8 @@ packages:
resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==}
engines: {node: '>=10.19.0'}
got@14.4.0:
resolution: {integrity: sha512-baa2HMfREJ9UQSXOPwWe0DNK+FT8Okcxe9kmTJvaetv2q/MUxq0qFzEnfSbxo+wj45/QioGcH5ZhuT9VBIPJ5Q==}
got@14.4.1:
resolution: {integrity: sha512-IvDJbJBUeexX74xNQuMIVgCRRuNOm5wuK+OC3Dc2pnSoh1AOmgc7JVj7WC+cJ4u0aPcO9KZ2frTXcqK4W/5qTQ==}
engines: {node: '>=20'}
graceful-fs@4.2.11:
@ -8041,15 +8045,15 @@ snapshots:
'@biomejs/cli-win32-x64@1.8.0':
optional: true
'@bull-board/api@5.20.0(@bull-board/ui@5.20.0)':
'@bull-board/api@5.20.1(@bull-board/ui@5.20.1)':
dependencies:
'@bull-board/ui': 5.20.0
'@bull-board/ui': 5.20.1
redis-info: 3.1.0
'@bull-board/koa@5.20.0(@types/koa@2.15.0)(lodash@4.17.21)(pug@3.0.3)':
'@bull-board/koa@5.20.1(@types/koa@2.15.0)(lodash@4.17.21)(pug@3.0.3)':
dependencies:
'@bull-board/api': 5.20.0(@bull-board/ui@5.20.0)
'@bull-board/ui': 5.20.0
'@bull-board/api': 5.20.1(@bull-board/ui@5.20.1)
'@bull-board/ui': 5.20.1
ejs: 3.1.10
koa: 2.15.3
koa-mount: 4.0.0
@ -8112,9 +8116,9 @@ snapshots:
- walrus
- whiskers
'@bull-board/ui@5.20.0':
'@bull-board/ui@5.20.1':
dependencies:
'@bull-board/api': 5.20.0(@bull-board/ui@5.20.0)
'@bull-board/api': 5.20.1(@bull-board/ui@5.20.1)
'@cbor-extract/cbor-extract-darwin-arm64@2.2.0':
optional: true
@ -10043,7 +10047,7 @@ snapshots:
dependencies:
possible-typed-array-names: 1.0.0
aws-sdk@2.1635.0:
aws-sdk@2.1636.0:
dependencies:
buffer: 4.9.2
events: 1.1.1
@ -11585,7 +11589,7 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
execa@9.1.0:
execa@9.2.0:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
cross-spawn: 7.0.3
@ -11976,7 +11980,7 @@ snapshots:
p-cancelable: 2.1.1
responselike: 2.0.1
got@14.4.0:
got@14.4.1:
dependencies:
'@sindresorhus/is': 6.3.1
'@szmarczak/http-timer': 5.0.1
@ -11989,6 +11993,7 @@ snapshots:
lowercase-keys: 3.0.0
p-cancelable: 4.0.1
responselike: 3.0.0
type-fest: 4.19.0
graceful-fs@4.2.11: {}