From cbe80fdd265734c30d960cd921663df61fa50a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=82=8F=E3=82=8F=E3=82=8F=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Thu, 16 Jan 2025 22:35:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(note):=20=E4=BA=88=E7=B4=84=E6=8A=95?= =?UTF-8?q?=E7=A8=BF=20(MisskeyIO#890)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en-US.yml | 13 ++ locales/index.d.ts | 52 +++++ locales/ja-JP.yml | 13 ++ locales/ko-KR.yml | 15 +- .../backend/assets/tabler-badges/badges.png | Bin 0 -> 1350 bytes .../assets/tabler-badges/calendar-check.png | Bin 0 -> 1990 bytes .../tabler-badges/calendar-exclamation.png | Bin 0 -> 1933 bytes .../assets/tabler-badges/calendar-time.png | Bin 0 -> 2801 bytes .../migration/1736923279563-ScheduledNote.js | 21 ++ packages/backend/src/core/CoreModule.ts | 6 + .../backend/src/core/NoteCreateService.ts | 113 +++++---- packages/backend/src/core/QueueService.ts | 24 ++ packages/backend/src/core/RoleService.ts | 3 + .../core/activitypub/models/ApNoteService.ts | 2 +- .../entities/NotificationEntityService.ts | 13 +- .../entities/ScheduledNoteEntityService.ts | 76 ++++++ packages/backend/src/di-symbols.ts | 1 + packages/backend/src/misc/json-schema.ts | 2 + packages/backend/src/models/Notification.ts | 16 ++ .../backend/src/models/RepositoryModule.ts | 9 + packages/backend/src/models/ScheduledNote.ts | 54 +++++ packages/backend/src/models/_.ts | 3 + .../src/models/json-schema/note-draft.ts | 179 ++++++++++++++ .../src/models/json-schema/notification.ts | 45 ++++ .../backend/src/models/json-schema/role.ts | 4 + packages/backend/src/postgres.ts | 2 + .../backend/src/queue/QueueProcessorModule.ts | 4 + .../src/queue/QueueProcessorService.ts | 10 +- ...eckMissingScheduledNoteProcessorService.ts | 61 +++++ .../ScheduledNoteProcessorService.ts | 101 ++++++++ packages/backend/src/queue/types.ts | 5 + .../backend/src/server/api/EndpointsModule.ts | 8 + packages/backend/src/server/api/endpoints.ts | 4 + .../src/server/api/endpoints/notes/create.ts | 73 +++++- .../api/endpoints/notes/scheduled/cancel.ts | 57 +++++ .../api/endpoints/notes/scheduled/list.ts | 49 ++++ packages/backend/src/types.ts | 43 ++++ .../src/components/MkDraftsDialog.vue | 174 ++++++++++++-- .../src/components/MkNotification.vue | 52 ++++- .../frontend/src/components/MkPostForm.vue | 67 +++++- packages/frontend/src/const.ts | 4 + packages/frontend/src/os.ts | 18 +- .../frontend/src/pages/admin/roles.editor.vue | 20 ++ packages/frontend/src/pages/admin/roles.vue | 8 + .../frontend/src/pages/antenna-timeline.vue | 2 +- packages/frontend/src/pages/timeline.vue | 2 +- .../frontend/src/types/note-draft-item.ts | 38 --- packages/misskey-js/etc/misskey-js.api.md | 18 +- .../misskey-js/src/autogen/apiClientJSDoc.ts | 22 ++ packages/misskey-js/src/autogen/endpoint.ts | 5 + packages/misskey-js/src/autogen/entities.ts | 3 + packages/misskey-js/src/autogen/models.ts | 1 + packages/misskey-js/src/autogen/types.ts | 218 +++++++++++++++++- packages/misskey-js/src/consts.ts | 2 +- .../sw/src/scripts/create-notification.ts | 57 +++-- packages/sw/src/types.ts | 7 +- 56 files changed, 1633 insertions(+), 166 deletions(-) create mode 100644 packages/backend/assets/tabler-badges/badges.png create mode 100644 packages/backend/assets/tabler-badges/calendar-check.png create mode 100644 packages/backend/assets/tabler-badges/calendar-exclamation.png create mode 100644 packages/backend/assets/tabler-badges/calendar-time.png create mode 100644 packages/backend/migration/1736923279563-ScheduledNote.js create mode 100644 packages/backend/src/core/entities/ScheduledNoteEntityService.ts create mode 100644 packages/backend/src/models/ScheduledNote.ts create mode 100644 packages/backend/src/models/json-schema/note-draft.ts create mode 100644 packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts create mode 100644 packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/scheduled/list.ts delete mode 100644 packages/frontend/src/types/note-draft-item.ts diff --git a/locales/en-US.yml b/locales/en-US.yml index 28652cdbe..3ec46cc61 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1319,6 +1319,12 @@ emailAddressLogin: "Login with email address" usernameLogin: "Login with username" autoloadDrafts: "Automatically load drafts when opening the posting form" drafts: "Drafts" +unsent: "Unsent" +schedule: "Schedule" +scheduled: "Scheduled" +unschedule: "Unschedule" +setScheduledTime: "Set scheduled time" +willBePostedAt: "Note will be posted at {x}" _bubbleGame: howToPlay: "How to play" @@ -1787,6 +1793,7 @@ _role: gtlAvailable: "Can view the global timeline" ltlAvailable: "Can view the local timeline" canPublicNote: "Can send public notes" + canScheduleNote: "Can schedule notes" canInitiateConversation: "Can mention, reply or quote" canCreateContent: "Can create contents" canUpdateContent: "Can edit contents" @@ -2474,6 +2481,9 @@ _notification: roleAssigned: "Role given" emptyPushNotificationMessage: "Push notifications have been updated" achievementEarned: "Achievement unlocked" + noteScheduled: "Note has been scheduled" + scheduledNotePosted: "Scheduled note has been posted" + scheduledNoteError: "Scheduled note has problem with posting" testNotification: "Test notification" checkNotificationBehavior: "Check notification appearance" sendTestNotification: "Send test notification" @@ -2497,6 +2507,9 @@ _notification: followRequestAccepted: "Accepted follow requests" roleAssigned: "Role given" achievementEarned: "Achievement unlocked" + noteScheduled: "Note scheduled" + scheduledNotePosted: "Scheduled note posted" + scheduledNoteError: "Problem with scheduled note" app: "Notifications from linked apps" _actions: followBack: "followed you back" diff --git a/locales/index.d.ts b/locales/index.d.ts index 0143fadee..707e058e8 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5338,6 +5338,30 @@ export interface Locale extends ILocale { * 下書き */ "drafts": string; + /** + * 未送信 + */ + "unsent": string; + /** + * 予約 + */ + "schedule": string; + /** + * 予約済み + */ + "scheduled": string; + /** + * 予約を解除 + */ + "unschedule": string; + /** + * 予約日時を設定 + */ + "setScheduledTime": string; + /** + * {x}に投稿されます + */ + "willBePostedAt": ParameterizedString<"x">; "_bubbleGame": { /** * 遊び方 @@ -6991,6 +7015,10 @@ export interface Locale extends ILocale { * パブリック投稿の許可 */ "canPublicNote": string; + /** + * 予約投稿の許可 + */ + "canScheduleNote": string; /** * メンション、リプライ、引用の許可 */ @@ -9649,6 +9677,18 @@ export interface Locale extends ILocale { * 実績を獲得 */ "achievementEarned": string; + /** + * ノートが予約されました + */ + "noteScheduled": string; + /** + * 予約済みのノートが投稿されました + */ + "scheduledNotePosted": string; + /** + * 予約済みのノートを投稿できませんでした + */ + "scheduledNoteError": string; /** * 通知テスト */ @@ -9738,6 +9778,18 @@ export interface Locale extends ILocale { * 実績の獲得 */ "achievementEarned": string; + /** + * ノートが予約された + */ + "noteScheduled": string; + /** + * 予約済みのノートが投稿された + */ + "scheduledNotePosted": string; + /** + * 予約済みのノートが投稿できなかった + */ + "scheduledNoteError": string; /** * 連携アプリからの通知 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index df14061cd..5612736be 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1328,6 +1328,12 @@ emailAddressLogin: "メールアドレスでログイン" usernameLogin: "ユーザー名でログイン" autoloadDrafts: "投稿フォームを開いたときに下書きを自動で読み込む" drafts: "下書き" +unsent: "未送信" +schedule: "予約" +scheduled: "予約済み" +unschedule: "予約を解除" +setScheduledTime: "予約日時を設定" +willBePostedAt: "{x}に投稿されます" _bubbleGame: howToPlay: "遊び方" @@ -1801,6 +1807,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" + canScheduleNote: "予約投稿の許可" canInitiateConversation: "メンション、リプライ、引用の許可" canCreateContent: "コンテンツの作成" canUpdateContent: "コンテンツの編集" @@ -2538,6 +2545,9 @@ _notification: roleAssigned: "ロールが付与されました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" + noteScheduled: "ノートが予約されました" + scheduledNotePosted: "予約済みのノートが投稿されました" + scheduledNoteError: "予約済みのノートを投稿できませんでした" testNotification: "通知テスト" checkNotificationBehavior: "通知の表示を確かめる" sendTestNotification: "テスト通知を送信する" @@ -2562,6 +2572,9 @@ _notification: followRequestAccepted: "フォローが受理された" roleAssigned: "ロールが付与された" achievementEarned: "実績の獲得" + noteScheduled: "ノートが予約された" + scheduledNotePosted: "予約済みのノートが投稿された" + scheduledNoteError: "予約済みのノートが投稿できなかった" app: "連携アプリからの通知" _actions: diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 929e6ab6e..d90bd9adf 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1316,6 +1316,12 @@ emailAddressLogin: "이메일 주소로 로그인" usernameLogin: "사용자명으로 로그인" autoloadDrafts: "글 작성 시 자동으로 임시 저장된 글 불러오기" drafts: "임시 저장" +unsent: "미전송" +schedule: "예약" +scheduled: "예약됨" +unschedule: "예약 취소" +setScheduledTime: "예약 시간 설정" +willBePostedAt: "{x}에 게시됩니다" _bubbleGame: howToPlay: "설명" @@ -1784,6 +1790,7 @@ _role: gtlAvailable: "글로벌 타임라인 보이기" ltlAvailable: "로컬 타임라인 보이기" canPublicNote: "공개 노트 허용" + canScheduleNote: "노트 예약 허용" mentionMax: "노트에 넣을 수 있는 멘션 수" canCreateContent: "컨텐츠 생성 허용" canUpdateContent: "컨텐츠 수정 허용" @@ -2456,9 +2463,12 @@ _notification: pollEnded: "투표 결과가 발표되었습니다" newNote: "새 게시물" unreadAntennaNote: "안테나 {name}" - roleAssigned: "역할이 부여 되었습니다." + roleAssigned: "역할이 부여 되었습니다" emptyPushNotificationMessage: "푸시 알림이 갱신되었습니다" achievementEarned: "도전 과제를 달성했습니다" + noteScheduled: "노트가 예약되었습니다" + scheduledNotePosted: "예약된 노트가 게시되었습니다" + scheduledNoteError: "예약된 노트를 게시하지 못했습니다" testNotification: "알림 테스트" checkNotificationBehavior: "알림 표시를 체크하기" sendTestNotification: "테스트 알림 보내기" @@ -2482,6 +2492,9 @@ _notification: followRequestAccepted: "팔로우 요청이 승인되었을 때" roleAssigned: "역할이 부여 됨" achievementEarned: "도전 과제 획득" + noteScheduled: "노트가 예약됨" + scheduledNotePosted: "예약된 노트가 게시됨" + scheduledNoteError: "예약된 노트를 게시하지 못함" app: "연동된 앱을 통한 알림" _actions: followBack: "팔로우" diff --git a/packages/backend/assets/tabler-badges/badges.png b/packages/backend/assets/tabler-badges/badges.png new file mode 100644 index 0000000000000000000000000000000000000000..f02880a7177f38ab4156a9ac4daae2c9941ce560 GIT binary patch literal 1350 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wkP;g6X zglC$krxpVTki*6x#K_FR2xNh<6eBB`4g<2Kq3k#yTLY?w3CI>^La5JdX925E1B!!y z2M~kIfzd#xOkihV0g4zH85%H7gs>TbCNM03iW?djFfM?Y`2Rlx1JeSCi3vbG%t&k? zm!Ws|wj~S)qzm@6nM^Js^EZeyZY>=O`eI>mJ{_;MgGnVi2LWd&^jsYYS*UMN!F$APmf%%%e=p?S%b0)e=6>OO|08}}(M~z{->LhisqnJx%ai_Vr+)vjW7*|NKIXf6 zuZ!AASU=wXerl}VmzClY>0dSLJf1R6m}{EeJg@S0%^bC|tt$QTkMEmpuJ-C~)SLD3 zYrqeq8w?VYnS-WHb7Bg#X-?TSk#YSdN7e@&f0z>X6?+wCo}8BVB>y+dgAO+_hUloM zr}x)H>7M*smAcir?j;-Jh8t7vtX94L>e$pgH+hE5CR=&ia<__X+VwHFJcMz==DVf| zM!GLWHlGrkly~mre?cPFGsz$>;1&(JvGWz9sEHze=7V_Q^lDZ|6>RFPnKZ^lL+sU~TK#RVU)h z0?pI={cRs;e|P-0(p;iF{?Z=f`j!7ST{PPedfV_T!{gW7AFKBsw%EJ!jrdvX6umj2 zvixh=r|h*73g$Nu|04N&Pvi^l_Xd6W0Y4m$Tw`{6r+A8Kf8Yz}{E6mv3s)}au5t){ zWo+KotniF)``IdoXnp1s@4417;Zadb)0l7VE^lzs^p=!g8F#AUc$oUqmuB)D_P44O zb)u8HUTplzFu(g(Mn|lf+0Hexx*N^?6jw_x`m+9Lx_9_jh6fAYRvpOT44k_qlt1It z{y4$Kg4wI&9(>}LGGqt3dv^YXj=;Z+#aEeg9F*P8F74T+$tz{}>fIvFUWujAW=_g( z7dlG0Op-bq-E<5ubf~gPZ9L+jJWV3wh!AscMA8C5Zyr37rx_sX*nnCXFLb0bm?X_) z=#2nzIe}azlO#zXR}IQ_;OdpoSjgQg@nHdPuLQ>u&t8cR1vh0jsT6@AP_YOGvY+_< VI{GZ~Yh>1f_@1tQF6*2UngBPC9uNQk literal 0 HcmV?d00001 diff --git a/packages/backend/assets/tabler-badges/calendar-check.png b/packages/backend/assets/tabler-badges/calendar-check.png new file mode 100644 index 0000000000000000000000000000000000000000..1f1e1951ad61c5b3ac86a5d7f0b141e22ef7337f GIT binary patch literal 1990 zcmYjSeK^y5AO8JpUcxlm%8X{-!!wr5+TrAF)_T!OVTGn7@)o^`#ab$MlA@ARY^IdX z$s?79(L9k_u0wIQlo%NrQHjjUcBXo+=lNXM{k`w+ecj*t`uz9Jp#@T4`eyn70Klm0 z{kLhDu?RgK%{g%O7*Rt@4z8yk0sw<$ivWS&ubXPj2m3;)(NwA4g8=8#;RDYlCsn0IeB4<#mU8(8_iJq0o$!RmEz?eK7tAcJzt7%Fp;qS~s$z%%hZneM3tLaJ ztEFCn&^pO!lRZ)l(pmO`^9Z4~>E*{&Z{kc*P9szAjd4^a_d<0EcE~rdJbEpZs;PnDT&mi`4w^1`$(Pu@M zP-}*%k_b=1J?ZZ4!qQaCP=gb)deb+m~5Bh(1CA)rjScf^J$;CpzdKhMZLbL;5| zta|iJBKxF$@kSokp;ou+!(V>pZ1H$xcV?e0eWz!kc`F)7KQ~X^6 zWeYS+vz^V8Ih%q@FC_+*=(;cZH44&5Vr-Dgh1O=AZTo%#UT8Hl(J3QnjqWKHr%ix8 z*`1tY=D9L;AHUk(XPxcR<9H)=2|q3=%qEf@Ov(`XrIpOlOi3FgQ(`F$I)usw5ZX%&#W8p!h5e|i(zo4p19y( zEl4WLI%KLKcxS*3ntRt}KgV4yv^wAW{vmiAU-Ad4h9f);E$)LWcg&;o z>GBW6jD>;$>!)53XxCtfWvb~cRG*$O%=widPV*zMt_)LKU>zmxy_siw3-&dH{2C3h z3@ta{#L)~X>*rIR9xvN;fzz4@Kq&#)o&J+)7d9Ao1KMqEK-1&3To{8w`?rUaZxqDe zm*5>01?lpu!mdK|%$w}x@qou*@;63gEP-V)HuVQ*x9?|UIMa1b>o5uJlJ-pJs%|D~ zu6h8&n-bN{AUQB||IvO2w9Fw?8RC=F;mK0A)Ve!LNtylxmX4EJKSo|(tuqglGzyAg zG|`Z<=1YwIb;>7R`%S z3eN9|dJrU6{>gMcc6ogAi#K6ALTa*LFXhJgNykI!pU)XR9l+VXLFjc@w|i*+XHC>23$0|ob-e146Qz0f zS58{U;)rI~>Jd5iy%E@P*4vokw8qW6B+)pk$G}x}LL#t7hzE{0o=>RQu-a+TCrRS$ zR8|~ll6uHidK=sk>xBRNy{*Gg7OBGa8oYhCgnn$lbr))zg1O9&Gwc>>+QxM$D5`@N zH8BT*G0p8yuv)E0&mAo9!W9}5&F;8WtlTb?c3+)fqu#V^3U^*1#L?=*0%A;pr~jI? z6|C^oB~9$?9DLX4Abpo)`tg1#HMRSf_Cf}>4|+E{G^dWf*0kvJgzyrXvN_#1$+Hi- zluUeCZ9?Q`7($r3JMlaEV~b(PAZ5cR=Cb%KDNoOfj@_{?(qxa`fi&FX0~_xhsNj1r ztUXI-j4x3rrNYcp3d$O~>cWe*FCQAm_k<@|wztRz=Sv43G1Mq0ZsbTFhwEGa9y8`0 zq!{?rcE5}6-$LqGMI;qb)WuchBeTOQrUf`QFU(w_SF5)| k0gvB5yx_L_`&hltOsT!`;l_y#or`}Nl^p0_>C0gM1EQ^7b^rhX literal 0 HcmV?d00001 diff --git a/packages/backend/assets/tabler-badges/calendar-exclamation.png b/packages/backend/assets/tabler-badges/calendar-exclamation.png new file mode 100644 index 0000000000000000000000000000000000000000..7944fac88f3ede3e5995dcb3c1141feda536e5f8 GIT binary patch literal 1933 zcmYk7c~sKr8pnS>7Dd2aM-el%GSeckT&5KFg>(wajhncanoE{op2kFtFuRndWr`+n zW=31wGc__n%e85A^pu8UVw09uN_JHemy6Y$d!KWj_xpLj=Xt;9`R~moAM{p78X*Ay zP$&6#99C-hMk7>|=j-A#4oXo=_MxT#07`eGL4aFks5Bdo1(S}GNLBzsc~Jp$Kqvqz zX`_um_*V^7GWI)%D%tY;3`EJgkpIoc<-oqqA6NPt<)iG4oPSigO1>%pQ#yDX0v^2j zjX`As!2j+O@OY5_EpdGv03iHtiQ&pO(EnrQ6}Vj5^s91XCdtFqKdtN|?OC3lryldE z7OSWGL+Du0E3!lwjBQxiw5&YHR=H?}o7$F%qCCutTFa$cKbR_7b- z%<(`xtyvhHOBCI%u9+^Mo(ZDSL~m}C+^jASs?AvVA{45aZuweK`N5_d&)bYNwC=7Q z9BZ>0pwuwDb6t203PQ3N(5_Lwr!`JqoK{FIj62*|i=18=XFp``e?nW{8EqCj8l<|P z0Y&EUFl}3jzw{>s#~o>?Mb4(=VQSo{iqz_*yVtxz3$xEL>h^3$+w+Bho#o|L3%J56 zSnG|26MGttuV_@Qnk&vp9GQdThg2h&o2}xd7XAie3)V^So}_ku7c#uU>qSfsIuDC8 z!nyw0jKe0mGQb21H;Vo&(zmR8?bN(yBurb)%)oBWD3lOFXoYt2n{XF@3fY&Gk~3JP z`g-SLQ11=>`H!3CZ7*HG4lfU}hta&pYm)28mz8hdypNk-l^Rdl8??xrZPF2*Q^^Nc zJCd<|KQ3PB+;@c?eMIr0+wRL^!JyOD@nclFik7THbu4eaYXh{a>KLnu4rS8*<HK7TiPDa;AlM_ytU zT?j4}z!K->b^ec;0U$noe^U(!{D4|!6TPf~uERy@VZZ%e?!$_jkHf6gZ?y%Zz3>%n zC6>*O8D}gq@2Y9sY(Qz?lprY~l@LKY(>lNbl{_|x-S=Q;lX%k=9+sNv1M8vXhh<7> zWE0jZgl>b-JZCf`DKf7Vv_Iqva8G1y?eED659?QBD6<%o`o1cCw3A+zG;QDvSw17I zT&cd^WQ%q(+zvfGg3QQim`eF5@?9^Sd~U=={6DE%2QAcgfp5I- z@X*8Irz2SJHA0NX>{i4=iyg5D^8M>|C-g6+eA29rA7yu?-HS04n28z1A@UYT^h&0@ zcGt_)NzVn&gBrLr{?uJL_i<=|HR4lx)?#3vQ`AL)8vjvAnkIqaN(3e8U4B3BUxh`Q zu-s8votPAYpZf!p>={d<|I20zHsu5HfoAroa&PE|UB^#DAn8l34@Lc5xa2 z4LZiC2jVbrEV-%~RX?!*Zy{3C+gjX6l;57f9Kc$+jdZFP#GXHuhJLxpCX|sR3A*+} z-YoLI(iGP5GNPgh(NHv^+I&LibJqDOCri5*<>t(p zi~Niu4`DPwe#<6pYG<3QxM`VZJygSx$6E{ z5J9#8`lap&So0c!6I!f0vWkuZyvkZy%TGcxW6kt_x~DB-G+Z0!Mp%gMu2^N|+R^&y z51N|1?bR7sd)BxU_EO%a&JJNeJs=TX2nlT-yyLQX)xm+8DBsh<>^e~Dp#Dg6h&Npz zKn%a|$q=brU0M7!-lf3#%@4Zo9f!8T#YEA8%xB@=<0erVZ?} zPV`4;v*Q2F5MMZJV&Oh$qj&Drfzm&1TEcf5u0cEnCkGU$KrY86zmT)7*paivAjA8j jl^!8_FUH+wIddKCd$sdsE2`UR<4YoW9`vYj3(x*9G&Vb$ literal 0 HcmV?d00001 diff --git a/packages/backend/assets/tabler-badges/calendar-time.png b/packages/backend/assets/tabler-badges/calendar-time.png new file mode 100644 index 0000000000000000000000000000000000000000..0443baee2614989c8571a40a24a5f50a00fc5879 GIT binary patch literal 2801 zcmYjTc{tSFAN`JDm@%W&NDLDOkt{J8WoeR~?E5mb49%1!#Mmd4XhHVf$o|@+C|i~m zNrOUUX&7smL<#Ym-uL}Izu$eHbIffE1GAa(_VOtQzQq9Qyxla$Gcu*=kjh znA2LLcOPr@WrqEnnCtC5s)48 zbislk-+r>3F2@m^+Hs0=u4lu5ZEQG=sv4O2;x{s6vUDS?t*}3+9N*?cRZ@YYrQ108 zjD>SbVC%d$(YHzm!jdbfT88R<#~umwMuy8$Gtg$2#>|&*M$Oo@i!oin!bPh|mGab# z#fIGTShevflc>isWT$NeTD*kbg#NFEjGUnWT?7JdJnvjT6V^V?X za)Ndh!_fz&FZqs^UBkb%t(yu;Tt1N4ATRC3+WG{UJVA+k8Ej{>UpZ&LgU8*k(^+&^ zD}Xua?xU#N`z?Jd`Gt7gs}anhVt%YyMVi*k;RBy;ON|o}U6u-nK;%BT+v!cmf?MC> zOJ>wj#Mq*w&!Fg);t}K9mP&+^B9?2D#06;_zCae~#(6SS7uGf~`S&7Jfpi75HB91m-M z-;AQaOhBEL%$c9zlsAfyBeYV-wGXWo2WzzwFOI1hWTsXrbJ^dZGRO;-yIyfy zJh#zHkM9}tZ#GkR^r#9DNzd7nfQgCfW>gGxCmcUuT1ZKCp6yoK&6a$B=;-U+9HXoS z4m51~Mj}0UYZCJQhLZWZug~N4Z@)vMUUB>i!Y02B=YUQBSU0DF!f|q9cn93&#l#4A zz8#Mae7{rVJ9>38sHaiZ6$(~Dh8-`o*h)D8oa^U)BusOa{bONk2wN#CRE4MT7(cnI z)H?i^HNigo3-iXE`zBp=&PFoA{bzcO#3N{sDdw1#oS6VnS99(7r7>aZu;muQRKVTC znsZyHI2=|jCEyOcTHszZq1tV@Jl)IhiGybdUEO|eH0UV2DDSiTUQR1Ak+W&l!+-Rm2;+tH>yoz_!Pc1sv&`OnG&niv)TqGH z?YR;Qe+BZh(7>Qjui8sCIBF%uOV1nE*fAv?HHKKB&Chg!e(g3jJBgFFt#^A77DVv; zY!sM9P~B87Izq<0^M=?7HQVRw2ys^?cdy;*uU z=(aKn=QmX|?nbXz9?%F8)Q!t@OX)YRWw7pz1*m?F zw-u^#?9LP6WW-4rN$Ch7sKs-hFgYG{kLQNhPDW}?F zo%I^uFh24Uu>kTLvMKAxa(9wcWlO`z9S}QZ}?d!GY1^BYeIg;*{2N1FB?T7_HXhI77QXIYzqn4hpkY z1+#`|OOXgo5r3BxNl%~*v-;_0Lj88z*}gN}iXqYZ=T&6PKf&8EVxq~O-$Oo3IoP6q zk9z=4vna_HO=GQ?jzD9dd%op~<|zoDCn@?7a}U;!uEiIy?lD^}*5)rt0{6iWEKBkf~d%^NY!lBDHYHMoy>H?CrH zh~k#r@Q^#{vHHO)tLFa7C-CYy_oCd6m{Vl#Y#f;k5`%aV!~(Be?Dm7@YK|>wYmJt9 z$DF-s_;Y#ZTtCC6T-y(V1;2mj1T`NWM&`ycL<8%!F!>dEvR;f zu+?=WtQehC{}rk$VpcEkl-^k#9^f4*!EzpsPQnIU_iE|!i-0}5;SvUq-%a>TpgOh> z2e@2jlBC>hEOA+FmW}eesRjH8*Pa7bdy^qtD-J+qRlkL zRZy%`BhDjzgD{bUV}#CmSTA*J>-q|>EZ^BHXWB-Yp>~Y=;R_&a8cgwgPPU%A z0{kRig;2ltD``6~sH0?}+C^>^bt=94{FnuK$L)}NPlkxo@YRWJ{Z6&(YrT8l^R-1!`FDdngy_gjl&8gN8TQ-6t|T7hne-=PPW(i(bbCB0{P zx#$rywjXqkcPWw;;{*2p&LdJG@ds+V5<`$u`J%fkik`hysvLQ!u<@xj>`>BRw9JBZ z?AxjKec5~0GS)n=Y)E9c98Nn_A4!p)3yL$<9Uc_9?&#&4_lTltfNJE0y2X^Ot#Jf0 zcG4t8#xKT{trW|cq)cjxla7|{cy72R5hI@ { + }, data: NoteCreateOption, silent = false): Promise { // チャンネル外にリプライしたら対象のスコープに合わせる // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { @@ -425,18 +411,30 @@ export class NoteCreateService implements OnApplicationShutdown { throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', `Notes including mentions are limited to ${policies.mentionLimit} users.`); } - const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); + if (!data.scheduledAt) { + const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); - setImmediate('post created', { signal: this.#shutdownController.signal }).then( - () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), - () => { /* aborted, ignore this */ }, - ); + setImmediate('post created', { signal: this.#shutdownController.signal }).then( + () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), + () => { /* aborted, ignore this */ }, + ); - return note; + return note; + } else { + if (!policies.canScheduleNote) { + throw new IdentifiableError('7cc42034-f7ab-4f7c-87b4-e00854479080', 'User has no permission to schedule notes.'); + } + + const draft = await this.insertScheduledNote(user, data); + + await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!); + + return draft; + } } @bindThis - private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { + private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: NoteCreateOption, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { const insert = new MiNote({ id: this.idService.gen(data.createdAt?.getTime()), createdAt: data.createdAt!, @@ -534,13 +532,40 @@ export class NoteCreateService implements OnApplicationShutdown { } } + @bindThis + private async insertScheduledNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: NoteCreateOption) { + const insert = new MiScheduledNote({ + id: this.idService.gen(data.createdAt?.getTime()), + createdAt: data.createdAt!, + scheduledAt: data.scheduledAt!, + userId: user.id, + draft: data, + }); + + // 予約投稿を作成 + try { + await this.scheduledNotesRepository.insert(insert); + + return insert; + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + throw new IdentifiableError('5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc', 'There is already a scheduled note with the same time.'); + } + + this.logger.error(`Failed to create scheduled note: ${e}`, { error: e }); + + throw e; + } + } + @bindThis private async postNoteCreated(note: MiNote, user: { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; isBot: MiUser['isBot']; - }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { + }, data: NoteCreateOption, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { const meta = await this.metaService.fetch(); this.notesChart.update(note, true); @@ -792,12 +817,12 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private isRenote(note: Option): note is Option & { renote: MiNote } { + private isRenote(note: NoteCreateOption): note is NoteCreateOption & { renote: MiNote } { return note.renote != null; } @bindThis - private isQuote(note: Option): note is Option & { renote: MiNote } & ( + private isQuote(note: NoteCreateOption): note is NoteCreateOption & { renote: MiNote } & ( { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } ) { // NOTE: SYNC WITH misc/is-renote.ts @@ -873,7 +898,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { + private async renderNoteOrRenoteActivity(data: NoteCreateOption, note: MiNote) { if (data.localOnly) return null; const content = this.isRenote(data) && !this.isQuote(data) diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index a41c9c96e..fb5501c42 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js'; import type { Config } from '@/config.js'; @@ -34,6 +35,11 @@ export class QueueService { @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, ) { + this.ensureRepeatJobs(); + } + + @bindThis + private ensureRepeatJobs() { this.systemQueue.add('tickCharts', { }, { repeat: { pattern: '55 * * * *' }, @@ -69,6 +75,12 @@ export class QueueService { repeat: { pattern: '*/5 * * * *' }, removeOnComplete: true, }); + + this.systemQueue.add('checkMissingScheduledNote', { + }, { + repeat: { pattern: '*/5 * * * *' }, + removeOnComplete: true, + }); } @bindThis @@ -382,6 +394,18 @@ export class QueueService { }); } + @bindThis + public createScheduledNoteJob(draftId: MiScheduledNote['id'], scheduledAt: Date) { + return this.systemQueue.add('scheduledNote', { + draftId, + }, { + jobId: `scheduledNote:${draftId}`, + delay: Math.max(scheduledAt.getTime() - Date.now(), 0) + Math.floor(Math.random() * 500 + 250), + removeOnComplete: true, + removeOnFail: true, + }); + } + @bindThis public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean, withReplies?: boolean }[]) { const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel)); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 1c205dca0..1b8091cc2 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -36,6 +36,7 @@ export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; + canScheduleNote: boolean; canInitiateConversation: boolean; canCreateContent: boolean; canUpdateContent: boolean; @@ -77,6 +78,7 @@ export const DEFAULT_POLICIES: RolePolicies = { gtlAvailable: true, ltlAvailable: true, canPublicNote: true, + canScheduleNote: true, canInitiateConversation: true, canCreateContent: true, canUpdateContent: true, @@ -389,6 +391,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), + canScheduleNote: calc('canScheduleNote', vs => vs.some(v => v === true)), canInitiateConversation: calc('canInitiateConversation', vs => vs.some(v => v === true)), canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)), canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 1e6ff5a5a..9effcfac9 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -352,7 +352,7 @@ export class ApNoteService { poll, uri: note.id, url: url, - }, silent); + }, silent) as MiNote; } catch (err: any) { if (err.name !== 'duplicated') { throw err; diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index a580ce44d..bd8f9a1cf 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -20,14 +20,16 @@ import { RoleEntityService } from './RoleEntityService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; +import type { ScheduledNoteEntityService } from './ScheduledNoteEntityService.js'; -const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]); +const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]); @Injectable() export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; private noteEntityService: NoteEntityService; private roleEntityService: RoleEntityService; + private scheduledNoteEntityService: ScheduledNoteEntityService; constructor( private moduleRef: ModuleRef, @@ -52,6 +54,7 @@ export class NotificationEntityService implements OnModuleInit { this.userEntityService = this.moduleRef.get('UserEntityService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.roleEntityService = this.moduleRef.get('RoleEntityService'); + this.scheduledNoteEntityService = this.moduleRef.get('ScheduledNoteEntityService'); } /** @@ -84,6 +87,11 @@ export class NotificationEntityService implements OnModuleInit { // if the note has been deleted, don't show this notification if (needsNote && !noteIfNeed) return null; + const needsDraft = 'draftId' in notification; + const draftIfNeed = needsDraft ? this.scheduledNoteEntityService.pack(notification.draftId, { id: meId }) : undefined; + // if the draft has been deleted, don't show this notification + if (needsDraft && !draftIfNeed) return null; + const needsUser = 'notifierId' in notification; const userIfNeed = needsUser ? ( hint?.packedUsers != null @@ -116,6 +124,7 @@ export class NotificationEntityService implements OnModuleInit { createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, note: noteIfNeed, + draft: draftIfNeed, reactions, }); } else if (notification.type === 'renote:grouped') { @@ -139,6 +148,7 @@ export class NotificationEntityService implements OnModuleInit { createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, note: noteIfNeed, + draft: draftIfNeed, users, }); } @@ -158,6 +168,7 @@ export class NotificationEntityService implements OnModuleInit { userId: 'notifierId' in notification ? notification.notifierId : undefined, ...(userIfNeed != null ? { user: userIfNeed } : {}), ...(noteIfNeed != null ? { note: noteIfNeed } : {}), + ...(draftIfNeed != null ? { draft: draftIfNeed } : {}), ...(notification.type === 'reaction' ? { reaction: notification.reaction, } : {}), diff --git a/packages/backend/src/core/entities/ScheduledNoteEntityService.ts b/packages/backend/src/core/entities/ScheduledNoteEntityService.ts new file mode 100644 index 000000000..07f1c2bca --- /dev/null +++ b/packages/backend/src/core/entities/ScheduledNoteEntityService.ts @@ -0,0 +1,76 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import type { MiUser } from '@/models/User.js'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; +import { bindThis } from '@/decorators.js'; +import { Packed } from '@/misc/json-schema.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class ScheduledNoteEntityService { + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private driveFileEntityService: DriveFileEntityService, + ) { + } + + @bindThis + public async pack( + src: MiScheduledNote['id'] | MiScheduledNote, + me: { id: MiUser['id'] }, + ) : Promise> { + const item = typeof src === 'object' ? src : await this.scheduledNotesRepository.findOneByOrFail({ id: src, userId: me.id }); + + return { + id: item.id, + updatedAt: item.createdAt.toISOString(), + scheduledAt: item.scheduledAt?.toISOString() ?? null, + reason: item.reason ?? undefined, + channel: item.draft.channel ? { + id: item.draft.channel.id, + name: item.draft.channel.name, + } : undefined, + renote: item.draft.renote ? { + id: item.draft.renote.id, + text: (item.draft.renote.cw ?? item.draft.renote.text)?.substring(0, 100) ?? null, + user: { + id: item.draft.renote.userId, + username: item.draft.renote.user!.username, + host: item.draft.renote.user!.host, + }, + } : undefined, + reply: item.draft.reply ? { + id: item.draft.reply.id, + text: (item.draft.reply.cw ?? item.draft.reply.text)?.substring(0, 100) ?? null, + user: { + id: item.draft.reply.userId, + username: item.draft.reply.user!.username, + host: item.draft.reply.user!.host, + }, + } : undefined, + data: { + text: item.draft.text ?? null, + useCw: !!item.draft.cw, + cw: item.draft.cw ?? null, + visibility: item.draft.visibility as 'public' | 'followers' | 'home' | 'specified', + localOnly: item.draft.localOnly ?? false, + files: item.draft.files ? await this.driveFileEntityService.packMany(item.draft.files, me) : [], + poll: item.draft.poll ? { ...item.draft.poll, expiresAt: item.draft.poll.expiresAt?.getTime() ?? null, expiredAfter: null } : null, + visibleUserIds: item.draft.visibility === 'specified' ? item.draft.visibleUsers?.map(x => x.id) : undefined, + }, + }; + } + + @bindThis + public async packMany( + drafts: (MiScheduledNote['id'] | MiScheduledNote)[], + me: { id: MiUser['id'] }, + ) : Promise[]> { + return (await Promise.allSettled(drafts.map(x => this.pack(x, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult>).value); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index a70cce451..8b7b10b92 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -21,6 +21,7 @@ export const DI = { announcementReadsRepository: Symbol('announcementReadsRepository'), appsRepository: Symbol('appsRepository'), avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), + scheduledNotesRepository: Symbol('scheduledNotesRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 5f169c37d..10b144b16 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -31,6 +31,7 @@ import { packedMutingSchema } from '@/models/json-schema/muting.js'; import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; import { packedNoteSchema } from '@/models/json-schema/note.js'; +import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js'; import { packedNotificationSchema } from '@/models/json-schema/notification.js'; import { packedPageLikeSchema, packedPageBlockSchema, packedPageSchema } from '@/models/json-schema/page.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; @@ -77,6 +78,7 @@ export const refs = { Announcement: packedAnnouncementSchema, App: packedAppSchema, Note: packedNoteSchema, + NoteDraft: packedNoteDraftSchema, NoteReaction: packedNoteReactionSchema, NoteFavorite: packedNoteFavoriteSchema, Notification: packedNotificationSchema, diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index df88b9963..4747b51b5 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -5,6 +5,7 @@ import { MiUser } from './User.js'; import { MiNote } from './Note.js'; +import { MiScheduledNote } from './ScheduledNote.js'; import { MiAccessToken } from './AccessToken.js'; import { MiRole } from './Role.js'; @@ -77,6 +78,21 @@ export type MiNotification = { id: string; createdAt: string; achievement: string; +} | { + type: 'noteScheduled'; + id: string; + createdAt: string; + draftId: MiScheduledNote['id']; +} | { + type: 'scheduledNotePosted'; + id: string; + createdAt: string; + noteId: MiNote['id']; +} | { + type: 'scheduledNoteError'; + id: string; + createdAt: string; + draftId: MiScheduledNote['id']; } | { type: 'app'; id: string; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 83eee55ff..ced1ff812 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -39,6 +39,7 @@ import { MiModerationLog, MiMuting, MiNote, + MiScheduledNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, @@ -117,6 +118,12 @@ const $avatarDecorationsRepository: Provider = { inject: [DI.db], }; +const $scheduledNotesRepository: Provider = { + provide: DI.scheduledNotesRepository, + useFactory: (db: DataSource) => db.getRepository(MiScheduledNote), + inject: [DI.db], +}; + const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite), @@ -517,6 +524,7 @@ const $abuseReportResolversRepository: Provider = { $announcementReadsRepository, $appsRepository, $avatarDecorationsRepository, + $scheduledNotesRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, @@ -590,6 +598,7 @@ const $abuseReportResolversRepository: Provider = { $announcementReadsRepository, $appsRepository, $avatarDecorationsRepository, + $scheduledNotesRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, diff --git a/packages/backend/src/models/ScheduledNote.ts b/packages/backend/src/models/ScheduledNote.ts new file mode 100644 index 000000000..d573e2514 --- /dev/null +++ b/packages/backend/src/models/ScheduledNote.ts @@ -0,0 +1,54 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import type { NoteCreateOption } from '@/types.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('note_scheduled') +@Index(['userId', 'scheduledAt'], { unique: true }) +export class MiScheduledNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Note.', + default: () => 'CURRENT_TIMESTAMP', + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The scheduled date of the Note.', + nullable: true + }) + public scheduledAt: Date | null; + + @Column('varchar', { + length: 256, nullable: true, + }) + public reason: string | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column('jsonb') + public draft: NoteCreateOption; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 800af98b1..7bf282a18 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -34,6 +34,7 @@ import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; @@ -108,6 +109,7 @@ export { MiMuting, MiRenoteMuting, MiNote, + MiScheduledNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, @@ -181,6 +183,7 @@ export type ModerationLogsRepository = Repository; export type MutingsRepository = Repository; export type RenoteMutingsRepository = Repository; export type NotesRepository = Repository; +export type ScheduledNotesRepository = Repository; export type NoteFavoritesRepository = Repository; export type NoteReactionsRepository = Repository; export type NoteThreadMutingsRepository = Repository; diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts new file mode 100644 index 000000000..7b81838fe --- /dev/null +++ b/packages/backend/src/models/json-schema/note-draft.ts @@ -0,0 +1,179 @@ +export const packedNoteDraftSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id' + }, + updatedAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time' + }, + scheduledAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + reason: { + type: 'string', + optional: true, nullable: false + }, + channel: { + type: 'object', + optional: true, nullable: true, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id' + }, + name: { + type: 'string', + optional: false, nullable: false + }, + }, + }, + renote: { + type: 'object', + optional: true, nullable: true, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + user: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + username: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + }, + }, + }, + }, + }, + reply: { + type: 'object', + optional: true, nullable: true, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + user: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id' + }, + username: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + }, + }, + }, + }, + }, + data: { + type: 'object', + optional: false, nullable: false, + properties: { + text: { + type: 'string', + optional: false, nullable: true, + }, + useCw: { + type: 'boolean', + optional: false, nullable: false, + }, + cw: { + type: 'string', + optional: false, nullable: true, + }, + visibility: { + type: 'string', + optional: false, nullable: false, + enum: ['public', 'home', 'followers', 'specified'], + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + files: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'DriveFile', + }, + }, + poll: { + type: 'object', + optional: false, nullable: true, + properties: { + choices: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + multiple: { + type: 'boolean', + optional: false, nullable: false, + }, + expiresAt: { + type: 'integer', + optional: false, nullable: true, + }, + expiredAfter: { + type: 'integer', + optional: false, nullable: true, + minimum: 1 + }, + }, + }, + visibleUserIds: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index b4c444275..e68240897 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -296,6 +296,51 @@ export const packedNotificationSchema = { optional: false, nullable: false, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['noteScheduled'], + }, + draft: { + type: 'object', + ref: 'NoteDraft', + optional: false, nullable: false, + } + } + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['scheduledNotePosted'], + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + } + } + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['scheduledNoteError'], + }, + draft: { + type: 'object', + ref: 'NoteDraft', + optional: false, nullable: false, + } + } }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 166a085f5..6cbea1708 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -180,6 +180,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canScheduleNote: { + type: 'boolean', + optional: false, nullable: false, + }, canInitiateConversation: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 58a40ae3c..b272f7d65 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -44,6 +44,7 @@ import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; @@ -162,6 +163,7 @@ export const entities = [ MiRenoteMuting, MiBlocking, MiNote, + MiScheduledNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index f3588544f..d7f0a56e7 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { CheckMissingScheduledNoteProcessorService } from './processors/CheckMissingScheduledNoteProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; @@ -36,6 +37,7 @@ import { ImportUserListsProcessorService } from './processors/ImportUserListsPro import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; import { ReportAbuseProcessorService } from './processors/ReportAbuseProcessorService.js'; +import { ScheduledNoteProcessorService } from './processors/ScheduledNoteProcessorService.js'; import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; @@ -52,6 +54,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor ResyncChartsProcessorService, CleanChartsProcessorService, CheckExpiredMutingsProcessorService, + CheckMissingScheduledNoteProcessorService, CleanProcessorService, DeleteDriveFilesProcessorService, ExportCustomEmojisProcessorService, @@ -75,6 +78,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor CleanRemoteFilesProcessorService, RelationshipProcessorService, ReportAbuseProcessorService, + ScheduledNoteProcessorService, WebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 1e68f750a..dc1429944 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -35,10 +35,12 @@ import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesP import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; import { ReportAbuseProcessorService } from './processors/ReportAbuseProcessorService.js'; -import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; +import { ScheduledNoteProcessorService } from './processors/ScheduledNoteProcessorService.js'; +import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { CheckMissingScheduledNoteProcessorService } from './processors/CheckMissingScheduledNoteProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; @@ -113,11 +115,13 @@ export class QueueProcessorService implements OnApplicationShutdown { private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService, private relationshipProcessorService: RelationshipProcessorService, private reportAbuseProcessorService: ReportAbuseProcessorService, - private tickChartsProcessorService: TickChartsProcessorService, private resyncChartsProcessorService: ResyncChartsProcessorService, + private scheduledNoteProcessorService: ScheduledNoteProcessorService, + private tickChartsProcessorService: TickChartsProcessorService, private cleanChartsProcessorService: CleanChartsProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, + private checkMissingScheduledNoteProcessorService: CheckMissingScheduledNoteProcessorService, private cleanProcessorService: CleanProcessorService, ) { this.logger = this.queueLoggerService.logger; @@ -141,11 +145,13 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region system this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { switch (job.name) { + case 'scheduledNote': return this.scheduledNoteProcessorService.process(job); case 'tickCharts': return this.tickChartsProcessorService.process(); case 'resyncCharts': return this.resyncChartsProcessorService.process(); case 'cleanCharts': return this.cleanChartsProcessorService.process(); case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); + case 'checkMissingScheduledNote': return this.checkMissingScheduledNoteProcessorService.process(); case 'clean': return this.cleanProcessorService.process(); default: throw new Error(`unrecognized job type ${job.name} for system`); } diff --git a/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts new file mode 100644 index 000000000..8c441a9cc --- /dev/null +++ b/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { acquireDistributedLock } from '@/misc/distributed-lock.js'; +import { QueueService } from '@/core/QueueService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; + +@Injectable() +export class CheckMissingScheduledNoteProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private queueService: QueueService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('note:scheduled'); + } + + @bindThis + public async process(): Promise { + this.logger.info(`checking missing scheduled note tasks`); + + try { + await acquireDistributedLock(this.redisForTimelines, `note:scheduled:check`, 3 * 60 * 1000, 1, 1000); + } catch (e) { + this.logger.warn(`check is already being processed`); + return; + } + + const query = this.scheduledNotesRepository.createQueryBuilder('draft') + .where('draft.scheduledAt < now() - interval \'5 minutes\'').orderBy('draft.createdAt', 'ASC'); + + let lastId = '0'; + while (true) { + const drafts = await query.andWhere('draft.id > :lastId', { lastId }).limit(100).getMany(); + + if (drafts.length === 0) { + break; + } + + for (const draft of drafts.filter(draft => draft.scheduledAt !== null)) { + const jobState = await this.queueService.systemQueue.getJobState(`scheduledNote:${draft.id}`); + if (jobState !== 'unknown') continue; + + this.logger.warn(`found missing scheduled note task: ${draft.id}`); + await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!); + } + + lastId = drafts[drafts.length - 1].id; + } + } +} diff --git a/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts new file mode 100644 index 000000000..3ca28941e --- /dev/null +++ b/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import * as Bull from 'bullmq'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import { MiNote, type ScheduledNotesRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { acquireDistributedLock } from '@/misc/distributed-lock.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type { ScheduledNoteJobData } from '../types.js'; + +@Injectable() +export class ScheduledNoteProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private notificationService: NotificationService, + private noteCreateService: NoteCreateService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('note:scheduled'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.logger.info(`processing ${job.data.draftId}`); + + try { + await acquireDistributedLock(this.redisForTimelines, `note:scheduled:${job.data.draftId}`, 30 * 1000, 1, 100); + } catch (e) { + this.logger.warn(`draft=${job.data.draftId} is already being processed`); + return 'ok'; + } + + const draft = await this.scheduledNotesRepository.findOne({ + where: { id: job.data.draftId, reason: IsNull() }, + relations: ['user'], + }); + + if (draft == null) { + this.logger.warn(`draft not found: ${job.data.draftId}`); + return 'ok'; + } + + if (!draft.user || draft.user.isSuspended) { + this.logger.warn(`user is suspended: ${draft.userId}`); + await this.scheduledNotesRepository.delete({ id: draft.id }); + return 'ok'; + } + + try { + const note = (await this.noteCreateService.create(draft.user, { + ...draft.draft, + createdAt: new Date(), + scheduledAt: null, + })) as MiNote; + + await this.scheduledNotesRepository.delete({ id: draft.id }); + + this.notificationService.createNotification(draft.userId, "scheduledNotePosted", { + noteId: note.id, + }); + + return 'ok'; + } catch (e) { + if (e instanceof IdentifiableError) { + if ([ + 'e11b3a16-f543-4885-8eb1-66cad131dbfd', + '689ee33f-f97c-479a-ac49-1b9f8140af99', + '9f466dab-c856-48cd-9e65-ff90ff750580', + '85ab9bd7-3a41-4530-959d-f07073900109', + 'd450b8a9-48e4-4dab-ae36-f4db763fda7c', + ].includes(e.id)) { + this.logger.warn(`creating note from draft=${draft.id} failed: ${e.message}`); + + await this.scheduledNotesRepository.update({ id: draft.id }, { + scheduledAt: null, + reason: e.message, + }); + + this.notificationService.createNotification(draft.userId, "scheduledNoteError", { + draftId: draft.id, + }); + + return e.message; + } + } + throw e; + } + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 49aedec58..ca2662155 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -6,6 +6,7 @@ import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; import type { MiUser } from '@/models/User.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import type { MiWebhook } from '@/models/Webhook.js'; @@ -116,6 +117,10 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; +export type ScheduledNoteJobData = { + draftId: MiScheduledNote['id']; +}; + export type WebhookDeliverJobData = { type: string; content: unknown; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 924eac7a1..33e93a814 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -287,6 +287,8 @@ import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; +import * as ep___notes_scheduled_cancel from './endpoints/notes/scheduled/cancel.js'; +import * as ep___notes_scheduled_list from './endpoints/notes/scheduled/list.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; @@ -682,6 +684,8 @@ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default }; const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default }; const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; +const $notes_scheduled_cancel: Provider = { provide: 'ep:notes/scheduled/cancel', useClass: ep___notes_scheduled_cancel.default }; +const $notes_scheduled_list: Provider = { provide: 'ep:notes/scheduled/list', useClass: ep___notes_scheduled_list.default }; const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default }; @@ -1081,6 +1085,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_conversation, $notes_create, $notes_delete, + $notes_scheduled_cancel, + $notes_scheduled_list, $notes_favorites_create, $notes_favorites_delete, $notes_featured, @@ -1474,6 +1480,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_conversation, $notes_create, $notes_delete, + $notes_scheduled_cancel, + $notes_scheduled_list, $notes_favorites_create, $notes_favorites_delete, $notes_featured, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 987228be6..a5f51328c 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -287,6 +287,8 @@ import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; +import * as ep___notes_scheduled_cancel from './endpoints/notes/scheduled/cancel.js'; +import * as ep___notes_scheduled_list from './endpoints/notes/scheduled/list.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; @@ -680,6 +682,8 @@ const eps = [ ['notes/conversation', ep___notes_conversation], ['notes/create', ep___notes_create], ['notes/delete', ep___notes_delete], + ['notes/scheduled/cancel', ep___notes_scheduled_cancel], + ['notes/scheduled/list', ep___notes_scheduled_list], ['notes/favorites/create', ep___notes_favorites_create], ['notes/favorites/delete', ep___notes_favorites_delete], ['notes/featured', ep___notes_featured], diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 963f3bbfd..c90c9daf9 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -17,6 +17,7 @@ import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -148,6 +149,31 @@ export const meta = { id: '66819f28-9525-389d-4b0a-4974363fbbbf', }, + cannotScheduleToPast: { + message: 'Cannot schedule to the past.', + code: 'CANNOT_SCHEDULE_TO_PAST', + id: 'e577d185-8179-4a17-b47f-6093985558e6', + }, + + cannotScheduleToFarFuture: { + message: 'Cannot schedule to the far future.', + code: 'CANNOT_SCHEDULE_TO_FAR_FUTURE', + id: 'ea102856-e8da-4ae9-a98a-0326821bd177', + }, + + cannotScheduleSameTime: { + message: 'Cannot schedule multiple notes at the same time.', + code: 'CANNOT_SCHEDULE_SAME_TIME', + id: '187a8fab-fd83-4ae6-a46c-0f6f07784634', + }, + + rolePermissionDenied: { + message: 'You are not assigned to a required role.', + code: 'ROLE_PERMISSION_DENIED', + kind: 'permission', + id: '12f1d5d2-f7ec-4d7c-b608-e873f4b20327', + status: 403, + }, }, } as const; @@ -207,6 +233,7 @@ export const paramDef = { }, required: ['choices'], }, + scheduledAt: { type: 'integer', nullable: true }, noCreatedNote: { type: 'boolean', default: false }, }, // (re)note with text, files and poll are optional @@ -263,6 +290,7 @@ export default class extends Endpoint { // eslint- private loggerService: LoggerService, private noteEntityService: NoteEntityService, private noteCreateService: NoteCreateService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me, _token, _file, _cleanup, ip, headers) => { const logger = this.loggerService.getLogger('api:notes:create'); @@ -318,7 +346,7 @@ export default class extends Endpoint { // eslint- let renote: MiNote | null = null; if (ps.renoteId != null) { // Fetch renote to note - renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); + renote = await this.notesRepository.findOne({ where: { id: ps.renoteId }, relations: ['user'] }); if (renote == null) { logger.error('No such renote target.', { renoteId: ps.renoteId }); @@ -371,7 +399,7 @@ export default class extends Endpoint { // eslint- let reply: MiNote | null = null; if (ps.replyId != null) { // Fetch reply - reply = await this.notesRepository.findOneBy({ id: ps.replyId }); + reply = await this.notesRepository.findOne({ where: { id: ps.replyId }, relations: ['user'] }); if (reply == null) { logger.error('No such reply target.', { replyId: ps.replyId }); @@ -384,11 +412,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.cannotReplyToInvisibleNote); } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') { throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); - } else if ( me.isBot ) { - const replayuser = await this.usersRepository.findOneBy({ id: reply.userId }); - if (replayuser?.isBot) { - throw new ApiError(meta.errors.replyingToAnotherBot); - } + } else if (me.isBot && reply.user!.isBot) { + throw new ApiError(meta.errors.replyingToAnotherBot); } // Check blocking @@ -427,10 +452,28 @@ export default class extends Endpoint { // eslint- } } + let scheduledAt: Date | null = null; + if (ps.scheduledAt) { + const now = new Date(); + scheduledAt = new Date(ps.scheduledAt); + scheduledAt.setMilliseconds(0); + + if (scheduledAt < now) { + logger.error('Cannot schedule to the past.', { scheduledAt }); + throw new ApiError(meta.errors.cannotScheduleToPast); + } + + if (scheduledAt.getTime() - now.getTime() > ms('1year')) { + logger.error('Cannot schedule to the far future.', { scheduledAt }); + throw new ApiError(meta.errors.cannotScheduleToFarFuture); + } + } + // 投稿を作成 try { const note = await this.noteCreateService.create(me, { createdAt: new Date(), + scheduledAt: ps.scheduledAt ? scheduledAt : null, files: files, poll: ps.poll ? { choices: ps.poll.choices, @@ -454,10 +497,18 @@ export default class extends Endpoint { // eslint- // 1分間、リクエストの処理結果を記録 await this.redisForTimelines.set(`note:idempotent:${me.id}:${hash}`, note.id, 'EX', 60); - logger.info('Successfully created a note.', { noteId: note.id }); - if (ps.noCreatedNote) return; + if (!scheduledAt) { + logger.info('Successfully created a note.', { noteId: note.id }); + } else { + this.notificationService.createNotification(me.id, "noteScheduled", { + draftId: note.id, + }); + logger.info('Successfully scheduled a note.', { draftId: note.id }); + } + + if (ps.noCreatedNote || scheduledAt) return; else return { - createdNote: await this.noteEntityService.pack(note, me), + createdNote: await this.noteEntityService.pack(note as MiNote, me), }; } catch (err) { // エラーが発生した場合、まだ処理中として記録されている場合はリクエストの処理結果を削除 @@ -468,6 +519,8 @@ export default class extends Endpoint { // eslint- if (err instanceof IdentifiableError) { if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords); if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') throw new ApiError(meta.errors.containsTooManyMentions); + if (err.id === '7cc42034-f7ab-4f7c-87b4-e00854479080') throw new ApiError(meta.errors.rolePermissionDenied); + if (err.id === '5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc') throw new ApiError(meta.errors.cannotScheduleSameTime); } throw err; diff --git a/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts b/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts new file mode 100644 index 000000000..b3627521e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import { QueueService } from '@/core/QueueService.js'; +import { ApiError } from '@/server/api/error.js'; +import ms from 'ms'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canCreateContent', + + prohibitMoved: true, + + limit: { + duration: ms('1hour'), + max: 300, + }, + + kind: 'write:notes', + + errors: { + noSuchDraft: { + message: 'No such draft', + code: 'NO_SUCH_DRAFT', + id: '91c2ad21-fb45-4f2a-ba4c-ea749b262947', + } + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + draftId: { type: 'string', format: 'misskey:id' }, + }, + required: ['draftId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + const draft = await this.scheduledNotesRepository.findOneBy({ id: ps.draftId, userId: me.id }); + if (!draft) throw new ApiError(meta.errors.noSuchDraft); + + await this.queueService.systemQueue.remove(`scheduledNote:${draft.id}`); + await this.scheduledNotesRepository.delete({ id: draft.id }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts b/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts new file mode 100644 index 000000000..c27a7b9a7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import { ScheduledNoteEntityService } from '@/core/entities/ScheduledNoteEntityService.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canScheduleNote', + + kind: 'write:notes', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'NoteDraft', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private scheduledNoteEntityService: ScheduledNoteEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.scheduledNotesRepository.createQueryBuilder('draft').where('draft.userId = :userId', { userId: me.id }); + const drafts = await query.orderBy('draft.scheduledAt', 'ASC', 'NULLS FIRST').offset(ps.offset).limit(ps.limit).getMany(); + + return await this.scheduledNoteEntityService.packMany(drafts, me); + }); + } +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index e125b074f..945eb27b5 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -3,6 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { IPoll } from '@/models/Poll.js'; +import type { MiChannel } from '@/models/Channel.js'; +import type { MiApp } from '@/models/App.js'; +import type { MiUser } from '@/models/User.js'; +import type { MiNote } from '@/models/Note.js'; + /** * note - 通知オンにしているユーザーが投稿した * follow - フォローされた @@ -16,6 +23,9 @@ * followRequestAccepted - 自分の送ったフォローリクエストが承認された * roleAssigned - ロールが付与された * achievementEarned - 実績を獲得 + * noteScheduled - 予約投稿が予約された + * scheduledNotePosted - 予約投稿が投稿された + * scheduledNoteError - 予約投稿がエラーになった * app - アプリ通知 * test - テスト通知(サーバー側) */ @@ -32,6 +42,9 @@ export const notificationTypes = [ 'followRequestAccepted', 'roleAssigned', 'achievementEarned', + 'noteScheduled', + 'scheduledNotePosted', + 'scheduledNoteError', 'app', 'test', ] as const; @@ -338,6 +351,36 @@ export type ModerationLogPayloads = { } }; +export type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +export type NoteCreateOption = { + createdAt?: Date | null; + scheduledAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: MiNote | null; + renote?: MiNote | null; + files?: MiDriveFile[] | null; + poll?: IPoll | null; + localOnly?: boolean | null; + reactionAcceptance?: MiNote['reactionAcceptance']; + cw?: string | null; + visibility?: string; + visibleUsers?: MinimumUser[] | null; + channel?: MiChannel | null; + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; + app?: MiApp | null; +}; + export type Serialized = { [K in keyof T]: T[K] extends Date diff --git a/packages/frontend/src/components/MkDraftsDialog.vue b/packages/frontend/src/components/MkDraftsDialog.vue index 755bb2cf1..289707cd2 100644 --- a/packages/frontend/src/components/MkDraftsDialog.vue +++ b/packages/frontend/src/components/MkDraftsDialog.vue @@ -10,14 +10,18 @@ -
+ + + + +
{{ i18n.ts.nothing }}
-
+
@@ -35,7 +39,13 @@
- +
+
+ + +
+ +
@@ -56,24 +66,94 @@
-
+ + + + diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 1450a3022..f40dc1b2b 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -25,6 +25,9 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_quote]: notification.type === 'quote', [$style.t_pollEnded]: notification.type === 'pollEnded', [$style.t_achievementEarned]: notification.type === 'achievementEarned', + [$style.t_noteScheduled]: notification.type === 'noteScheduled', + [$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted', + [$style.t_scheduledNoteError]: notification.type === 'scheduledNoteError', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, }]" > @@ -37,6 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only + + +