diff --git a/.github/workflows/vrtl-docker-release.yml b/.github/workflows/vrtl-docker-release.yml new file mode 100644 index 000000000000..438b95de8b0f --- /dev/null +++ b/.github/workflows/vrtl-docker-release.yml @@ -0,0 +1,53 @@ +name: Build and Push VRTL Misskey Docker Image + +on: + push: + tags: + - 'v*-vrtl.*' + +env: + DOCKER_REGISTRY_NAME: ghcr.io + DOCKER_IMAGE_NAME: anatawa12/vrtl-misskey + +jobs: + build: + permissions: + contents: read + packages: write + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to Docker hub + uses: docker/login-action@v1 + with: + registry: ${{ env.DOCKER_REGISTRY_NAME }} + username: ${{ github.repository_owner }} + password: ${{ github.token }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v3 + with: + images: ${{ env.DOCKER_REGISTRY_NAME }}/${{ env.DOCKER_IMAGE_NAME }} + + - name: Build & Push + uses: docker/build-push-action@v2 + env: + DOCKER_BUILDKIT: 1 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: ${{ env.DOCKER_REGISTRY_NAME }}/${{ env.DOCKER_IMAGE_NAME }}:latest + build-args: BUILDKIT_INLINE_CACHE=1 diff --git a/CHANGELOG-VRTL.md b/CHANGELOG-VRTL.md new file mode 100644 index 000000000000..0d8d5e00b868 --- /dev/null +++ b/CHANGELOG-VRTL.md @@ -0,0 +1,27 @@ +# CHANGELOG about VRTL + +VRTLのブランチで行われた変更点をまとめています + + + +--- 2024.10.1-vrtl.1 released at this time --- + +--- 2024.9.0-vrtl.1 released at this time --- + +--- 2024.8.0-vrtl.1 released at this time --- + +- fix(frontend): VRTL VSTLの名前が表示されないところがある問題 (anatawa12#97) - 2024/08/07 +- fic(frontend): VRTL VSTLでリプライのトグルが表示されない問題 (anatawa12#92) - 2024/08/04 + +--- 2024.7.0-vrtl.1 released at this time --- + +--- 2024.5.0-vrtl.2 released at this time --- + +- chore(backend): VRTL参加サーバーの取得に失敗したときのリトライの間隔を短く +- feat: VRTL/VSTLに連合なし投稿を含めるかを選択可能に + - もともとのVRTL/VSTLでは連合なし投稿が常に含まれていましたが、正しくVRTL/VSTLのノートを表現するために含めないようにできるようになりました + - VSTLの場合、連合なし投稿を含めないようにしてもフォローしている人の連合なし投稿は表示されます +- fix(frontend): ウィジェットでVRTL/VSTLが使用できない問題を修正 +- fix(backend): 自分自身に対するリプライがwithReplies = falseなVRTL/VSTLにて含まれていない問題を修正 +- feat(backend): `vmimiRelayTimelineImplemented` と `disableVmimiRelayTimeline` nodeinfo に追加しました + - これによりサードパーティクライアントがVRTLの有無を認知できるようになりました。 diff --git a/README.md b/README.md index 92e8fef6396e..d86dec7b9dc0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,131 @@ +# Anatawa12's fork of Misskey + +This is anatawa12's fork of Misskey. + +This fork is used for several purposes: + +- The repository for [vmimi-relay timeline] extension development. I'm the maintainer of the [vmimi-relay timeline]. +- The repository for writing pull-requests to the vmimi relay users. I'm usually sending pull requests to the vmimi relay users after each updates. +- The repository for writing pull-requests to the upstream repository. I'm one of the contributors of the upstream repository. +- The repository for writing pull-requests to the [misskey.niri.la]. I'm one of the maintainers of the [misskey.niri.la]. + +This branch is the branch for releasing the Vmimi Relay Timeline extension. + +## Vmimi Relay Timeline + +The Vmimi Relay Timeline is additional timelines for Misskey servers who belong to the [Virtual Kemomimi Relay]. +This extension adds two timelines: Vmimi Relay Timeline (VRTL) and Vmimi Social Timeline (VSTL). + +ぶいみみリレータイムラインは[ぶいみみリレー][Virtual Kemomimi Relay]に参加しているサーバー向けのmisskeyの拡張タイムラインです。 +この拡張はふたつのタイムラインを追加します: ぶいみみリレータイムライン (VRTL) とぶいみみソーシャルタイムライン (VSTL) です。 + +### Vmimi Relay Timeline (VRTL) + +The Vmimi Relay Timeline (VRTL) is the timeline that shows the posts from the Virtual Kemomimi Relay users. +This timeline is designed to be similar to the Local Timeline, +but it shows the posts from the Virtual Kemomimi Relay users. + +ぶいみみリレータイムライン (VRTL) はぶいみみリレーに参加しているユーザーの投稿を表示するタイムラインです。 +このタイムラインはローカルタイムラインに似ていますが、ぶいみみリレーに参加しているユーザーの投稿を表示します。 + +### Vmimi Social Timeline (VSTL) + +The Vmimi Social Timeline (VSTL) is the timeline that shows the posts from +the Virtual Kemomimi Relay users and the users you are following. +In other words, this timeline is the combination of the VRTL and the Home Timeline. +This timeline is designed to be similar to the Social Timeline, +but it shows the posts from the Virtual Kemomimi Relay users instead of the Local Timeline. + +ぶいみみソーシャルタイムライン (VSTL) はぶいみみリレーに参加しているユーザーとフォローしているユーザーの投稿を表示するタイムラインです。 +つまり、このタイムラインはVRTLとホームタイムラインの組み合わせです。 +このタイムラインはソーシャルタイムラインに似ていますが、ローカルタイムラインの代わりにぶいみみリレーに参加しているユーザーの投稿を表示します。 + +### How to use the Vmimi Relay Timeline + +First, this extension is designed for servers who joined the [Virtual Kemomimi Relay] +so you have to join the [Virtual Kemomimi Relay] to use this extension. + +After that, you have two ways to use the Vmimi Relay Timeline: +- Use releases of the misskey docker image if you're using official docker image. + The image is designed to be a drop-in replacement of the official image. + The image name is [`ghcr.io/anatawa12/vrtl-misskey:latest`]. +- Merge the extension branch to your fork and build the image by yourself. + You can merge the [`vmimi-relay-timeline/generic`] branch to your fork. + If you ask me, I'll create a pull request to your fork. Feel free to ask me. + +はじめに、この拡張は[ぶいみみリレー][Virtual Kemomimi Relay]に参加しているサーバー向けに設計されているため、この拡張を使用するには[ぶいみみリレー][Virtual Kemomimi Relay]に参加する必要があります。 + +その後、ぶいみみリレータイムラインを使用する方法はふたつあります: +- 公式のdockerイメージを使用している場合は、VRTLのdockerイメージを使用してください。 + のイメージは公式イメージの代わりとして使用できます。 + イメージ名は [`ghcr.io/anatawa12/vrtl-misskey:latest`] です。 +- このブランチをあなたのフォークにマージして、自分でイメージをビルドしてください。 + [`vmimi-relay-timeline/generic`] ブランチをあなたのフォークにマージすることで、この拡張を使用できます。 + 必要であれば、あなたのフォークにプルリクエストを作成します。お気軽にお尋ねください。 + +### Notes for Third-party Client Developers + +Here are the technical notes for third-party misskey client developers. + +サードパーティ Misskey クライアント向けの技術的な情報です + +#### Detecting Vmimi Relay Timeline + +There is `vmimiRelayTimelineImplemented` property on `metadata` object of `nodeinfo`. +If it's true, your client can assume that the VRTL is implemented for the sever. + +`nodeinfo`の`metadata`オブジェクトに`vmimiRelayTimelineImplemented`プロパティがあります。 +この値が true であれば、VRTLがそのサーバーにて実装されてると考えて問題ありません + +#### Endpoints and Channels of Vmimi Relay Timeline + +The fetch note endpoint for VRTL and VSTL are `notes/vmimi-relay-timeline` and `notes/vmimi-relay-hybrid-timeline`, +and the channel name for them are `vmimiRelayTimeline` and `vmimiRelayHybridTimeline`. + +Those endpoints and channels have almost same options as LTL / STL but they have one extra option specific to VRTL/VSTL. +The `withLocalOnly` flag (true by default) indicates if the timeline should include local only (non-federated) notes from server (local) timeline. + +For more details, see `misskey-js`. + +VRTLとVSTLのfetchエンドポイントはそれぞれ `notes/vmimi-relay-timeline`と `notes/vmimi-relay-hybrid-timeline`で、 +チャンネルは `vmimiRelayTimeline` と `vmimiRelayHybridTimeline` です。 + +これらのエンドポイントとチャンネルは LTL/STL とほぼ同じオプションを持っていますが、 VRTL/VSTL に固有オプションが一つあります。 +`withLocalOnly` (デフォルトtrue) はタイムラインにローカルのみ(連合なし)ノートがタイムラインに含まれるかどうかを示します。 + +### Branches related to Vmimi Relay Timeline + +- [`vmimi-relay-timeline/generic`]: + The branch for the Vmimi Relay Timeline extension development. + All changes to the Vmimi Relay Timeline extension will be merged to this branch. + This branch should be based on the latest official release of Misskey. + This branch does not include any changes other than the Vmimi Relay Timeline extension itself. +- [`vmimi-relay-timeline/releases`]: + The branch for releasing the Vmimi Relay Timeline extension. + This branch includes the changes for `package.json` and changes to the repository link. +- `vmimi-relay-timeline/forks/nirila`, `vmimi-relay-timeline/forks/buiso` and other branches: + Those branches are the branches for sending pull requests to each fork-based server. + Those branches are not permanent; they will be deleted after each pull request is merged. + +### How the Vmimi Relay Timeline works + +The Vmimi Relay Timeline is implemented as a server-whitelisted timeline. + +The Vmimi Relay provides the API endpoint to get the list of joined servers. +The Vmimi Relay Timeline extension fetches the list of joined servers from the Vmimi Relay +and filters the received / created notes by the list. +For implementation simplicity, the Vmimi Relay Timeline includes all public notes of the local server including +non-federated notes, but this behavior may change in the future. + +[Virtual Kemomimi Relay]: https://relay.virtualkemomimi.net/ +[misskey.niri.la]: https://github.com/niri-la/misskey.niri.la/ +[vmimi-relay timeline]: #vmimi-relay-timeline +[`vmimi-relay-timeline/generic`]: https://github.com/anatawa12/misskey/tree/vmimi-relay-timeline/generic +[`vmimi-relay-timeline/releases`]: https://github.com/anatawa12/misskey/tree/vmimi-relay-timeline/releases +[`ghcr.io/anatawa12/vrtl-misskey:latest`]: https://github.com/anatawa12/misskey/pkgs/container/vrtl-misskey + +--- +
Misskey logo diff --git a/locales/index.d.ts b/locales/index.d.ts index a0540fd22883..935a28afc9f3 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4862,6 +4862,10 @@ export interface Locale extends ILocale { * TLに現在フォロー中の人全員の返信を含めないようにする */ "hideRepliesToOthersInTimelineAll": string; + /** + * TLに連合なし投稿を含める + */ + "showLocalOnlyInTimeline": string; /** * この操作は元に戻せません。本当にTLに現在フォロー中の人全員の返信を含めるようにしますか? */ @@ -5766,6 +5770,14 @@ export interface Locale extends ILocale { * グローバルタイムラインでは、接続している他のすべてのサーバーからの投稿を見られます。 */ "global": string; + /** + * ぶいみみリレータイムラインでは、バーチャルケモミミリレーサーバーに参加しているサーバーのユーザー全員の投稿を見られます。 + */ + "vmimi-relay": string; + /** + * ぶいみみソーシャルタイムラインには、ホームタイムラインとぶいみみリレータイムラインの投稿が両方表示されます。 + */ + "vmimi-relay-social": string; }; "_serverRules": { /** @@ -6913,6 +6925,10 @@ export interface Locale extends ILocale { * ローカルタイムラインの閲覧 */ "ltlAvailable": string; + /** + * ぶいみみリレータイムラインの閲覧 + */ + "vrtlAvailable": string; /** * パブリック投稿の許可 */ @@ -9104,6 +9120,14 @@ export interface Locale extends ILocale { * グローバル */ "global": string; + /** + * ぶいみみリレー + */ + "vmimi-relay": string; + /** + * ぶいみみソーシャル + */ + "vmimi-relay-social": string; }; "_play": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a578704434ea..45540e35f4d6 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1211,6 +1211,7 @@ showRepliesToOthersInTimeline: "TLに他の人への返信を含める" hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" showRepliesToOthersInTimelineAll: "TLに現在フォロー中の人全員の返信を含めるようにする" hideRepliesToOthersInTimelineAll: "TLに現在フォロー中の人全員の返信を含めないようにする" +showLocalOnlyInTimeline: "TLに連合なし投稿を含める" confirmShowRepliesAll: "この操作は元に戻せません。本当にTLに現在フォロー中の人全員の返信を含めるようにしますか?" confirmHideRepliesAll: "この操作は元に戻せません。本当にTLに現在フォロー中の人全員の返信を含めないようにしますか?" externalServices: "外部サービス" @@ -1456,6 +1457,8 @@ _timelineDescription: local: "ローカルタイムラインでは、このサーバーにいるユーザー全員の投稿を見られます。" social: "ソーシャルタイムラインには、ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" global: "グローバルタイムラインでは、接続している他のすべてのサーバーからの投稿を見られます。" + vmimi-relay: "ぶいみみリレータイムラインでは、バーチャルケモミミリレーサーバーに参加しているサーバーのユーザー全員の投稿を見られます。" + vmimi-relay-social: "ぶいみみソーシャルタイムラインには、ホームタイムラインとぶいみみリレータイムラインの投稿が両方表示されます。" _serverRules: description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" @@ -1787,6 +1790,7 @@ _role: _options: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" + vrtlAvailable: "ぶいみみリレータイムラインの閲覧" canPublicNote: "パブリック投稿の許可" mentionMax: "ノート内の最大メンション数" canInvite: "サーバー招待コードの発行" @@ -2400,6 +2404,8 @@ _timelines: local: "ローカル" social: "ソーシャル" global: "グローバル" + vmimi-relay: "ぶいみみリレー" + vmimi-relay-social: "ぶいみみソーシャル" _play: new: "Playの作成" diff --git a/package.json b/package.json index bddb4f85a282..2f4c9273c9ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2025.1.0", + "version": "2025.1.0-vrtl.1", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/migration/1713168415416-VmimiRelayTimeline.js b/packages/backend/migration/1713168415416-VmimiRelayTimeline.js new file mode 100644 index 000000000000..edc309aee481 --- /dev/null +++ b/packages/backend/migration/1713168415416-VmimiRelayTimeline.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: anatawa12 and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class VmimiRelayTimeline1713168415416 { + name = 'VmimiRelayTimeline1713168415416' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "vmimiRelayTimelineCacheMax" integer NOT NULL DEFAULT '300'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "vmimiRelayTimelineCacheMax"`); + } +} diff --git a/packages/backend/migration/1716641282089-repositoryUrl-vmimi-relay-timeline.js b/packages/backend/migration/1716641282089-repositoryUrl-vmimi-relay-timeline.js new file mode 100644 index 000000000000..77a9699d82a7 --- /dev/null +++ b/packages/backend/migration/1716641282089-repositoryUrl-vmimi-relay-timeline.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RepositoryUrlVmimiRelayTimeline1716641282089 { + name = 'RepositoryUrlVmimiRelayTimeline1716641282089' + + async up(queryRunner) { + await queryRunner.query(`UPDATE "meta" SET "repositoryUrl" = 'https://github.com/anatawa12/misskey/tree/vmimi-relay-timeline-releases?tab=readme-ov-file#vmimi-relay-timeline'`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" SET DEFAULT 'https://github.com/anatawa12/misskey/tree/vmimi-relay-timeline-releases?tab=readme-ov-file#vmimi-relay-timeline'`); + } + + async down(queryRunner) { + // no valid down migration for repositoryUrl value + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" SET DEFAULT 'https://github.com/misskey-dev/misskey'`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 734d135648d5..4c13d7e156d4 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -149,12 +149,14 @@ import { ApMentionService } from './activitypub/models/ApMentionService.js'; import { ApNoteService } from './activitypub/models/ApNoteService.js'; import { ApPersonService } from './activitypub/models/ApPersonService.js'; import { ApQuestionService } from './activitypub/models/ApQuestionService.js'; +import { VmimiRelayTimelineService } from './VmimiRelayTimelineService.js'; import { QueueModule } from './QueueModule.js'; import { QueueService } from './QueueService.js'; import { LoggerService } from './LoggerService.js'; import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) +const $VmimiRelayTimelineService: Provider = { provide: 'VmimiRelayTimelineService', useExisting: VmimiRelayTimelineService }; const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisting: AbuseReportService }; const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService }; @@ -306,6 +308,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting QueueModule, ], providers: [ + VmimiRelayTimelineService, LoggerService, AbuseReportService, AbuseReportNotificationService, @@ -453,6 +456,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting QueueService, //#region 文字列ベースでのinjection用(循環参照対応のため) + $VmimiRelayTimelineService, $LoggerService, $AbuseReportService, $AbuseReportNotificationService, @@ -600,6 +604,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#endregion ], exports: [ + VmimiRelayTimelineService, QueueModule, LoggerService, AbuseReportService, @@ -747,6 +752,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting QueueService, //#region 文字列ベースでのinjection用(循環参照対応のため) + $VmimiRelayTimelineService, $LoggerService, $AbuseReportService, $AbuseReportNotificationService, diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index f6dabfadcd6d..be3c968eeb67 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -38,6 +38,12 @@ export type FanoutTimelineName = // role timelines | `roleTimeline:${string}` // any notes are included + // vmimi relay timelines + | 'vmimiRelayTimeline' // replies are not included + | 'vmimiRelayTimelineWithFiles' // only non-reply notes with files are included + | 'vmimiRelayTimelineWithReplies' // only replies are included + | `vmimiRelayTimelineWithReplyTo:${string}` // Only replies to specific local user are included. Parameter is reply user id. + @Injectable() export class FanoutTimelineService { constructor( diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 8a79908e8263..839b7027a732 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -46,6 +46,7 @@ import { NoteReadService } from '@/core/NoteReadService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { VmimiRelayTimelineService } from '@/core/VmimiRelayTimelineService.js'; import { RoleService } from '@/core/RoleService.js'; import { SearchService } from '@/core/SearchService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; @@ -193,6 +194,7 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, + private vmimiRelayTimelineService: VmimiRelayTimelineService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private idService: IdService, @@ -939,6 +941,12 @@ export class NoteCreateService implements OnApplicationShutdown { this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r); } } + if (note.visibility === 'public' && this.vmimiRelayTimelineService.isRelayedInstance(note.userHost) && !note.localOnly) { + this.fanoutTimelineService.push('vmimiRelayTimelineWithReplies', note.id, this.meta.vmimiRelayTimelineCacheMax, r); + if (note.replyUserHost == null) { + this.fanoutTimelineService.push(`vmimiRelayTimelineWithReplyTo:${note.replyUserId}`, note.id, this.meta.vmimiRelayTimelineCacheMax / 10, r); + } + } } else { this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); if (note.fileIds.length > 0) { @@ -951,6 +959,12 @@ export class NoteCreateService implements OnApplicationShutdown { this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); } } + if (note.visibility === 'public' && this.vmimiRelayTimelineService.isRelayedInstance(note.userHost) && !note.localOnly) { + this.fanoutTimelineService.push('vmimiRelayTimeline', note.id, this.meta.vmimiRelayTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push('vmimiRelayTimelineWithFiles', note.id, this.meta.vmimiRelayTimelineCacheMax / 2, r); + } + } } if (Math.random() < 0.1) { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 5af6b0594253..7bb7f220e3be 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -32,6 +32,7 @@ import { NotificationService } from '@/core/NotificationService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; export type RolePolicies = { + vrtlAvailable: boolean; gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; @@ -66,6 +67,7 @@ export type RolePolicies = { }; export const DEFAULT_POLICIES: RolePolicies = { + vrtlAvailable: true, gtlAvailable: true, ltlAvailable: true, canPublicNote: true, @@ -371,6 +373,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } return { + vrtlAvailable: calc('vrtlAvailable', vs => vs.some(v => v === true)), 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)), diff --git a/packages/backend/src/core/VmimiRelayTimelineService.ts b/packages/backend/src/core/VmimiRelayTimelineService.ts new file mode 100644 index 000000000000..a5f1b43699cc --- /dev/null +++ b/packages/backend/src/core/VmimiRelayTimelineService.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; +import { bindThis } from '@/decorators.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; + +type VmimiInstanceList = { Url: string; }[]; + +// one day +const UpdateInterval = 1000 * 60 * 60 * 24; // 24 hours = 1 day +const MinRetryInterval = 1000 * 60; // one minutes +const MaxRetryInterval = 1000 * 60 * 60 * 6; // 6 hours + +@Injectable() +export class VmimiRelayTimelineService { + instanceHosts: Set; + instanceHostsArray: string[]; + nextUpdate: number; + nextRetryInterval: number; + updatePromise: Promise | null; + private logger: Logger; + + constructor( + private httpRequestService: HttpRequestService, + private loggerService: LoggerService, + ) { + // Initialize with + this.instanceHosts = new Set([]); + this.instanceHostsArray = []; + this.nextUpdate = 0; + this.nextRetryInterval = MinRetryInterval; + this.updatePromise = null; + + this.logger = this.loggerService.getLogger('vmimi'); + + this.checkForUpdateInstanceList(); + } + + @bindThis + checkForUpdateInstanceList() { + if (this.updatePromise == null && this.nextUpdate < Date.now()) { + this.updatePromise = this.updateInstanceList().finally(() => this.updatePromise = null); + } + } + + @bindThis + async updateInstanceList() { + try { + this.logger.info('Updating instance list'); + const instanceList = await this.httpRequestService.getJson('https://relay.virtualkemomimi.net/api/servers'); + this.instanceHostsArray = instanceList.map(i => new URL(i.Url).host); + this.instanceHosts = new Set(this.instanceHostsArray); + this.nextUpdate = Date.now() + UpdateInterval; + this.logger.info(`Got instance list: ${this.instanceHostsArray}`); + this.nextRetryInterval = MinRetryInterval; + } catch (e) { + this.logger.error('Failed to update instance list', e as any); + this.nextUpdate = Date.now() + this.nextRetryInterval; + setTimeout(() => this.checkForUpdateInstanceList(), this.nextRetryInterval + 5); + this.nextRetryInterval = Math.min(this.nextRetryInterval * 2, MaxRetryInterval); + } + } + + @bindThis + isRelayedInstance(host: string | null): boolean { + this.checkForUpdateInstanceList(); + // assuming the current instance is joined to the i relay + if (host == null) return true; + return this.instanceHosts.has(host); + } + + get hostNames (): string[] { + this.checkForUpdateInstanceList(); + return this.instanceHostsArray; + } +} diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index ec0b5360f4a2..ef20e4acf846 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -159,6 +159,7 @@ export class MetaEntityService { features: { localTimeline: instance.policies.ltlAvailable, globalTimeline: instance.policies.gtlAvailable, + vmimiRelayTimeline: instance.policies.vrtlAvailable, registration: !instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, hcaptcha: instance.enableHcaptcha, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index ad5e31ad6ff2..9e07196ed8a8 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -367,7 +367,7 @@ export class MiMeta { @Column('varchar', { length: 1024, - default: 'https://github.com/misskey-dev/misskey', + default: 'https://github.com/anatawa12/misskey/tree/vmimi-relay-timeline-releases?tab=readme-ov-file#vmimi-relay-timeline', nullable: true, }) public repositoryUrl: string | null; @@ -574,6 +574,11 @@ export class MiMeta { }) public preservedUsernames: string[]; + @Column('integer', { + default: 300, + }) + public vmimiRelayTimelineCacheMax: number; + @Column('boolean', { default: true, }) diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index e7ae2ee8e560..e2e114e82f46 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -293,6 +293,10 @@ export const packedMetaDetailedOnlySchema = { type: 'boolean', optional: false, nullable: false, }, + vmimiRelayTimeline: { + type: 'boolean', + optional: false, nullable: false, + }, hcaptcha: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 3537de94c891..b6793357655d 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -168,6 +168,10 @@ export const packedRolePoliciesSchema = { type: 'object', optional: false, nullable: false, properties: { + vrtlAvailable: { + type: 'boolean', + optional: false, nullable: false, + }, gtlAvailable: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 9a641007ee5b..6e5e778e75dc 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -115,6 +115,8 @@ export class NodeinfoServerService { disableRegistration: meta.disableRegistration, disableLocalTimeline: !basePolicies.ltlAvailable, disableGlobalTimeline: !basePolicies.gtlAvailable, + vmimiRelayTimelineImplemented: true, + disableVmimiRelayTimeline: !basePolicies.vrtlAvailable, emailRequiredForSignup: meta.emailRequiredForSignup, enableHcaptcha: meta.enableHcaptcha, enableRecaptcha: meta.enableRecaptcha, diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 3ab0b815f232..eb1aa6f9a46a 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -36,6 +36,8 @@ import { AntennaChannelService } from './api/stream/channels/antenna.js'; import { ChannelChannelService } from './api/stream/channels/channel.js'; import { DriveChannelService } from './api/stream/channels/drive.js'; import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js'; +import { VmimiRelayTimelineChannelService } from './api/stream/channels/vmimi-relay-timeline.js'; +import { VmimiRelayHybridTimelineChannelService } from './api/stream/channels/vmimi-relay-hybrid-timeline.js'; import { HashtagChannelService } from './api/stream/channels/hashtag.js'; import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; @@ -82,6 +84,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ChannelChannelService, DriveChannelService, GlobalTimelineChannelService, + VmimiRelayTimelineChannelService, + VmimiRelayHybridTimelineChannelService, HashtagChannelService, RoleTimelineChannelService, ReversiChannelService, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 28f7cfea04a6..b0aa8545869a 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -324,6 +324,8 @@ export * as 'notes/timeline' from './endpoints/notes/timeline.js'; export * as 'notes/translate' from './endpoints/notes/translate.js'; export * as 'notes/unrenote' from './endpoints/notes/unrenote.js'; export * as 'notes/user-list-timeline' from './endpoints/notes/user-list-timeline.js'; +export * as 'notes/vmimi-relay-hybrid-timeline' from './endpoints/notes/vmimi-relay-hybrid-timeline.js'; +export * as 'notes/vmimi-relay-timeline' from './endpoints/notes/vmimi-relay-timeline.js'; export * as 'notifications/create' from './endpoints/notifications/create.js'; export * as 'notifications/flush' from './endpoints/notifications/flush.js'; export * as 'notifications/mark-all-as-read' from './endpoints/notifications/mark-all-as-read.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 64e3cc33bd2c..2adf11b687cf 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -645,6 +645,7 @@ export default class extends Endpoint { // eslint- bannedEmailDomains: instance.bannedEmailDomains, policies: { ...DEFAULT_POLICIES, ...instance.policies }, manifestJsonOverride: instance.manifestJsonOverride, + vmimiRelayTimelineCacheMax: instance.vmimiRelayTimelineCacheMax, enableFanoutTimeline: instance.enableFanoutTimeline, enableFanoutTimelineDbFallback: instance.enableFanoutTimelineDbFallback, perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 38ef0d1de837..0ee94e76251b 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -145,6 +145,7 @@ export const paramDef = { manifestJsonOverride: { type: 'string' }, enableFanoutTimeline: { type: 'boolean' }, enableFanoutTimelineDbFallback: { type: 'boolean' }, + vmimiRelayTimelineCacheMax: { type: 'integer' }, perLocalUserUserTimelineCacheMax: { type: 'integer' }, perRemoteUserUserTimelineCacheMax: { type: 'integer' }, perUserHomeTimelineCacheMax: { type: 'integer' }, @@ -615,6 +616,10 @@ export default class extends Endpoint { // eslint- set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax; } + if (ps.vmimiRelayTimelineCacheMax !== undefined) { + set.vmimiRelayTimelineCacheMax = ps.vmimiRelayTimelineCacheMax; + } + if (ps.perRemoteUserUserTimelineCacheMax !== undefined) { set.perRemoteUserUserTimelineCacheMax = ps.perRemoteUserUserTimelineCacheMax; } diff --git a/packages/backend/src/server/api/endpoints/notes/vmimi-relay-hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/vmimi-relay-hybrid-timeline.ts new file mode 100644 index 000000000000..f798a05e0c6d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/vmimi-relay-hybrid-timeline.ts @@ -0,0 +1,258 @@ +/* + * SPDX-FileCopyrightText: anatawa12 + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import type { ChannelFollowingsRepository, NotesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { VmimiRelayTimelineService } from '@/core/VmimiRelayTimelineService.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { MiLocalUser } from '@/models/User.js'; +import { MetaService } from '@/core/MetaService.js'; +import { IdService } from '@/core/IdService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + kind: 'read:account', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, + + errors: { + vmimiRelaySocialDisabled: { + message: 'Vmimi Relay Hybrid timeline has been disabled.', + code: 'VMIMI_RELAY_DISABLED', + id: 'e7496627-8086-4294-b488-63323eb80145', + }, + bothWithRepliesAndWithFiles: { + message: 'Specifying both withReplies and withFiles is not supported', + code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', + id: '8222638e-a5a9-495d-ae72-e825793e0a63', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + withFiles: { type: 'boolean', default: false }, + withRenotes: { type: 'boolean', default: true }, + withReplies: { type: 'boolean', default: false }, + withLocalOnly: { type: 'boolean', default: true }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + allowPartial: { type: 'boolean', default: true }, // this timeline is new so true by default + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private roleService: RoleService, + private activeUsersChart: ActiveUsersChart, + private idService: IdService, + private vmimiRelayTimelineService: VmimiRelayTimelineService, + private userFollowingService: UserFollowingService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, + private metaService: MetaService, + ) { + super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + + const policies = await this.roleService.getUserPolicies(me.id); + if (!policies.vrtlAvailable) { + throw new ApiError(meta.errors.vmimiRelaySocialDisabled); + } + + if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); + + const serverSettings = await this.metaService.fetch(); + + if (!serverSettings.enableFanoutTimeline) { + const timeline = await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + withLocalOnly: ps.withLocalOnly, + }, me); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(timeline, me); + } + + let timelineConfig: FanoutTimelineName[]; + + if (ps.withFiles) { + timelineConfig = [ + `homeTimelineWithFiles:${me.id}`, + 'vmimiRelayTimelineWithFiles', + ]; + if (ps.withLocalOnly) timelineConfig = [...timelineConfig, 'localTimelineWithFiles']; + } else if (ps.withReplies) { + timelineConfig = [ + `homeTimeline:${me.id}`, + 'vmimiRelayTimeline', + 'vmimiRelayTimelineWithReplies', + ]; + if (ps.withLocalOnly) timelineConfig = [...timelineConfig, 'localTimeline', 'localTimelineWithReplies']; + } else { + timelineConfig = [ + `homeTimeline:${me.id}`, + 'vmimiRelayTimeline', + `vmimiRelayTimelineWithReplyTo:${me.id}`, + ]; + if (ps.withLocalOnly) timelineConfig = [...timelineConfig, 'localTimeline', `localTimelineWithReplyTo:${me.id}`]; + } + + const redisTimeline = await this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + redisTimelines: timelineConfig, + useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + alwaysIncludeMyNotes: true, + excludePureRenotes: !ps.withRenotes, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ + untilId, + sinceId, + limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + withLocalOnly: ps.withLocalOnly, + }, me), + }); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return redisTimeline; + }); + } + + private async getFromDb(ps: { + untilId: string | null, + sinceId: string | null, + limit: number, + withFiles: boolean, + withReplies: boolean, + withLocalOnly: boolean, + }, me: MiLocalUser) { + const followees = await this.userFollowingService.getFollowees(me.id); + const followingChannels = await this.channelFollowingsRepository.find({ + where: { + followerId: me.id, + }, + }); + const vmimiRelayInstances = this.vmimiRelayTimelineService.hostNames; + + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { + if (followees.length > 0) { + const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + qb.orWhere(new Brackets(qb => { + qb.where('note.visibility = \'public\''); + if (!ps.withLocalOnly) qb.andWhere('note.localOnly = FALSE'); + qb.andWhere(new Brackets(qb => { + qb.where('note.userHost IS NULL'); + if (vmimiRelayInstances.length !== 0) { + qb.orWhere('note.userHost IN (:...vmimiRelayInstances)', { vmimiRelayInstances }); + } + })); + })); + } else { + qb.where('note.userId = :meId', { meId: me.id }); + qb.orWhere(new Brackets(qb => { + qb.where('note.visibility = \'public\''); + if (!ps.withLocalOnly) qb.andWhere('note.localOnly = FALSE'); + qb.andWhere(new Brackets(qb => { + qb.where('note.userHost IS NULL'); + if (vmimiRelayInstances.length !== 0) { + qb.orWhere('note.userHost IN (:...vmimiRelayInstances)', { vmimiRelayInstances }); + } + })); + })); + } + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (followingChannels.length > 0) { + const followingChannelIds = followingChannels.map(x => x.followeeId); + + query.andWhere(new Brackets(qb => { + qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); + qb.orWhere('note.channelId IS NULL'); + })); + } else { + query.andWhere('note.channelId IS NULL'); + } + + if (!ps.withReplies) { + query.andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + return await query.limit(ps.limit).getMany(); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/vmimi-relay-timeline.ts b/packages/backend/src/server/api/endpoints/notes/vmimi-relay-timeline.ts new file mode 100644 index 000000000000..fddf282f7a64 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/vmimi-relay-timeline.ts @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: anatawa12 + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import type { NotesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { VmimiRelayTimelineService } from '@/core/VmimiRelayTimelineService.js'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { MiLocalUser } from '@/models/User.js'; +import { MetaService } from '@/core/MetaService.js'; +import { IdService } from '@/core/IdService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, + + errors: { + vmimiRelayDisabled: { + message: 'Vmimi Relay timeline has been disabled.', + code: 'VMIMI_RELAY_DISABLED', + id: '7f0064c3-59a0-4154-8c37-a8898c128ccc', + }, + bothWithRepliesAndWithFiles: { + message: 'Specifying both withReplies and withFiles is not supported', + code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', + id: 'dd9c8400-1cb5-4eef-8a31-200c5f933793', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + withFiles: { type: 'boolean', default: false }, + withRenotes: { type: 'boolean', default: true }, + withReplies: { type: 'boolean', default: false }, + withLocalOnly: { type: 'boolean', default: true }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + allowPartial: { type: 'boolean', default: true }, // this timeline is new so true by default + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private roleService: RoleService, + private activeUsersChart: ActiveUsersChart, + private idService: IdService, + private vmimiRelayTimelineService: VmimiRelayTimelineService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, + private metaService: MetaService, + ) { + super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + + const policies = await this.roleService.getUserPolicies(me ? me.id : null); + if (!policies.vrtlAvailable) { + throw new ApiError(meta.errors.vmimiRelayDisabled); + } + + if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); + + const serverSettings = await this.metaService.fetch(); + + if (!serverSettings.enableFanoutTimeline) { + const timeline = await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + withReplies: ps.withReplies, + withLocalOnly: ps.withLocalOnly, + }, me); + + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); + } + }); + + return await this.noteEntityService.packMany(timeline, me); + } + + const timeline = await this.fanoutTimelineEndpointService.timeline({ + untilId, + sinceId, + limit: ps.limit, + allowPartial: ps.allowPartial, + me, + useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + redisTimelines: + ps.withFiles ? ['vmimiRelayTimelineWithFiles', ...(ps.withLocalOnly ? ['localTimelineWithFiles'] as const : [])] + : ps.withReplies ? ['vmimiRelayTimeline', 'vmimiRelayTimelineWithReplies', ...(ps.withLocalOnly ? ['localTimeline', 'localTimelineWithReplies'] as const : [])] + : me ? ['vmimiRelayTimeline', `vmimiRelayTimelineWithReplyTo:${me.id}`, ...(ps.withLocalOnly ? ['localTimeline', `localTimelineWithReplyTo:${me.id}`] as const : [])] + : ['vmimiRelayTimeline', ...(ps.withLocalOnly ? ['localTimeline'] as const : [])], + alwaysIncludeMyNotes: true, + excludePureRenotes: !ps.withRenotes, + dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ + untilId, + sinceId, + limit, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + withReplies: ps.withReplies, + withLocalOnly: ps.withLocalOnly, + }, me), + }); + + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); + } + }); + + return timeline; + }); + } + + private async getFromDb(ps: { + sinceId: string | null, + untilId: string | null, + limit: number, + withFiles: boolean, + withRenotes: boolean, + withReplies: boolean, + withLocalOnly: boolean, + }, me: MiLocalUser | null) { + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.visibility = \'public\'') + .andWhere('note.channelId IS NULL') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + const vmimiRelayInstances = this.vmimiRelayTimelineService.hostNames; + query.andWhere(new Brackets(qb => { + qb.where('note.userHost IS NULL'); + if (vmimiRelayInstances.length !== 0) { + qb.orWhere('note.userHost IN (:...vmimiRelayInstances)', { vmimiRelayInstances }); + } + })); + + if (!ps.withLocalOnly) { + query.andWhere('note.localOnly = FALSE'); + } + + if (!ps.withReplies) { + query.andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (!ps.withRenotes) { + query.andWhere(new Brackets(qb => { + qb.where('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.where('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + //#endregion + + return await query.limit(ps.limit).getMany(); + } +} diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 253409259fad..39008a0cd9a5 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -9,6 +9,8 @@ import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; import { LocalTimelineChannelService } from './channels/local-timeline.js'; import { HomeTimelineChannelService } from './channels/home-timeline.js'; import { GlobalTimelineChannelService } from './channels/global-timeline.js'; +import { VmimiRelayTimelineChannelService } from './channels/vmimi-relay-timeline.js'; +import { VmimiRelayHybridTimelineChannelService } from './channels/vmimi-relay-hybrid-timeline.js'; import { MainChannelService } from './channels/main.js'; import { ChannelChannelService } from './channels/channel.js'; import { AdminChannelService } from './channels/admin.js'; @@ -31,6 +33,8 @@ export class ChannelsService { private localTimelineChannelService: LocalTimelineChannelService, private hybridTimelineChannelService: HybridTimelineChannelService, private globalTimelineChannelService: GlobalTimelineChannelService, + private vmimiRelayTimelineChannelService: VmimiRelayTimelineChannelService, + private vmimiRelayHybridTimelineChannelService: VmimiRelayHybridTimelineChannelService, private userListChannelService: UserListChannelService, private hashtagChannelService: HashtagChannelService, private roleTimelineChannelService: RoleTimelineChannelService, @@ -53,6 +57,8 @@ export class ChannelsService { case 'localTimeline': return this.localTimelineChannelService; case 'hybridTimeline': return this.hybridTimelineChannelService; case 'globalTimeline': return this.globalTimelineChannelService; + case 'vmimiRelayTimeline': return this.vmimiRelayTimelineChannelService; + case 'vmimiRelayHybridTimeline': return this.vmimiRelayHybridTimelineChannelService; case 'userList': return this.userListChannelService; case 'hashtag': return this.hashtagChannelService; case 'roleTimeline': return this.roleTimelineChannelService; diff --git a/packages/backend/src/server/api/stream/channels/vmimi-relay-hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/vmimi-relay-hybrid-timeline.ts new file mode 100644 index 000000000000..eb0663f7f7d7 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/vmimi-relay-hybrid-timeline.ts @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; +import { VmimiRelayTimelineService } from '@/core/VmimiRelayTimelineService.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class VmimiRelayHybridTimelineChannel extends Channel { + public readonly chName = 'vmimiRelayHybridTimeline'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:account'; + private withRenotes: boolean; + private withReplies: boolean; + private withFiles: boolean; + private withLocalOnly: boolean; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + private vmimiRelayTimelineService: VmimiRelayTimelineService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: any): Promise { + const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); + if (!policies.vrtlAvailable) return; + + this.withRenotes = params.withRenotes ?? true; + this.withReplies = params.withReplies ?? false; + this.withFiles = params.withFiles ?? false; + this.withLocalOnly = params.withLocalOnly ?? true; + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @bindThis + private async onNote(note: Packed<'Note'>) { + const isMe = this.user!.id === note.userId; + + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; + + // チャンネルの投稿ではなく、自分自身の投稿 または + // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または + // チャンネルの投稿ではなく、全体公開のぶいみみリレーまたはローカルの投稿 または + // フォローしているチャンネルの投稿 の場合だけ + if (!( + (note.channelId == null && isMe) || + (note.channelId == null && Object.hasOwn(this.following, note.userId)) || + (note.channelId == null && (this.vmimiRelayTimelineService.isRelayedInstance(note.user.host) && note.visibility === 'public') && (this.withLocalOnly || !note.localOnly)) || + (note.channelId != null && this.followingChannels.has(note.channelId)) + )) return; + + if (note.visibility === 'followers') { + if (!isMe && !Object.hasOwn(this.following, note.userId)) return; + } else if (note.visibility === 'specified') { + if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; + } + + if (this.isNoteMutedOrBlocked(note)) return; + + if (note.reply) { + const reply = note.reply; + if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + } else { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + } + } + + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; + + if (this.user && note.renoteId && !note.text) { + if (note.renote && Object.keys(note.renote.reactions).length > 0) { + console.log(note.renote.reactionAndUserPairCache); + const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); + note.renote.myReaction = myRenoteReaction; + } + } + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @bindThis + public dispose(): void { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} + +@Injectable() +export class VmimiRelayHybridTimelineChannelService implements MiChannelService { + public readonly shouldShare = VmimiRelayHybridTimelineChannel.shouldShare; + public readonly requireCredential = VmimiRelayHybridTimelineChannel.requireCredential; + public readonly kind = VmimiRelayHybridTimelineChannel.kind; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + private vmimiRelayTimelineService: VmimiRelayTimelineService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): VmimiRelayHybridTimelineChannel { + return new VmimiRelayHybridTimelineChannel( + this.metaService, + this.roleService, + this.noteEntityService, + this.vmimiRelayTimelineService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/vmimi-relay-timeline.ts b/packages/backend/src/server/api/stream/channels/vmimi-relay-timeline.ts new file mode 100644 index 000000000000..b07261419bea --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/vmimi-relay-timeline.ts @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import type { Packed } from '@/misc/json-schema.js'; +import { MetaService } from '@/core/MetaService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; +import { VmimiRelayTimelineService } from '@/core/VmimiRelayTimelineService.js'; +import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class VmimiRelayTimelineChannel extends Channel { + public readonly chName = 'vmimiRelayTimeline'; + public static shouldShare = false; + public static requireCredential = false as const; + private withRenotes: boolean; + private withReplies: boolean; + private withFiles: boolean; + private withLocalOnly: boolean; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + private vmimiRelayTimelineService: VmimiRelayTimelineService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + } + + @bindThis + public async init(params: any) { + const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null); + if (!policies.vrtlAvailable) return; + + this.withRenotes = params.withRenotes ?? true; + this.withReplies = params.withReplies ?? false; + this.withFiles = params.withFiles ?? false; + this.withLocalOnly = params.withLocalOnly ?? true; + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @bindThis + private async onNote(note: Packed<'Note'>) { + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; + + if (!this.vmimiRelayTimelineService.isRelayedInstance(note.user.host ?? null)) return; + if (!this.withLocalOnly && note.localOnly) return; + if (note.visibility !== 'public') return; + if (note.channelId != null) return; + + // 関係ない返信は除外 + if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; + } + + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; + + if (this.isNoteMutedOrBlocked(note)) return; + + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { + if (note.renote && Object.keys(note.renote.reactions).length > 0) { + const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); + note.renote.myReaction = myRenoteReaction; + } + } + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @bindThis + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} + +@Injectable() +export class VmimiRelayTimelineChannelService implements MiChannelService { + public readonly shouldShare = VmimiRelayTimelineChannel.shouldShare; + public readonly requireCredential = VmimiRelayTimelineChannel.requireCredential; + public readonly kind = VmimiRelayTimelineChannel.kind; + + constructor( + private metaService: MetaService, + private roleService: RoleService, + private noteEntityService: NoteEntityService, + private vmimiRelayTimelineService: VmimiRelayTimelineService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): VmimiRelayTimelineChannel { + return new VmimiRelayTimelineChannel( + this.metaService, + this.roleService, + this.noteEntityService, + this.vmimiRelayTimelineService, + id, + connection, + ); + } +} diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 4fe5cbb205a7..e205ee308f29 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -77,6 +77,7 @@ export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; export const ROLE_POLICIES = [ 'gtlAvailable', 'ltlAvailable', + 'vrtlAvailable', 'canPublicNote', 'mentionLimit', 'canInvite', diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index fb8eb4ae37d8..3c644645db58 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -40,11 +40,13 @@ const props = withDefaults(defineProps<{ withReplies?: boolean; withSensitive?: boolean; onlyFiles?: boolean; + withLocalOnly?: boolean; }>(), { withRenotes: true, withReplies: false, withSensitive: true, onlyFiles: false, + withLocalOnly: true, }); const emit = defineEmits<{ @@ -61,6 +63,7 @@ type TimelineQueryType = { withRenotes?: boolean, withReplies?: boolean, withFiles?: boolean, + withLocalOnly?: boolean, visibility?: string, listId?: string, channelId?: string, @@ -125,6 +128,20 @@ function connectChannel() { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, }); + } else if (props.src === 'vmimi-relay') { + connection = stream.useChannel('vmimiRelayTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withReplies: props.withReplies, + withLocalOnly: props.withLocalOnly, + }); + } else if (props.src === 'vmimi-relay-social') { + connection = stream.useChannel('vmimiRelayHybridTimeline', { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withReplies: props.withReplies, + withLocalOnly: props.withLocalOnly, + }); } else if (props.src === 'mentions') { connection = stream.useChannel('main'); connection.on('mention', prepend); @@ -197,6 +214,22 @@ function updatePaginationQuery() { withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, }; + } else if (props.src === 'vmimi-relay') { + endpoint = 'notes/vmimi-relay-timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withReplies: props.withReplies, + withLocalOnly: props.withLocalOnly, + }; + } else if (props.src === 'vmimi-relay-social') { + endpoint = 'notes/vmimi-relay-hybrid-timeline'; + query = { + withRenotes: props.withRenotes, + withFiles: props.onlyFiles ? true : undefined, + withReplies: props.withReplies, + withLocalOnly: props.withLocalOnly, + }; } else if (props.src === 'mentions') { endpoint = 'notes/mentions'; query = null; diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index d05f52334e4a..079e01efa127 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -140,6 +140,26 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + +
+ + + + + + + + + +
+
+ diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index ac7babb250df..2c1ee1c0dff8 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -677,6 +677,30 @@ export type Channels = { }; receives: null; }; + vmimiRelayTimeline: { + params: { + withRenotes?: boolean; + withFiles?: boolean; + withReplies?: boolean; + withLocalOnly?: boolean; + }; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; + vmimiRelayHybridTimeline: { + params: { + withRenotes?: boolean; + withReplies?: boolean; + withFiles?: boolean; + withLocalOnly?: boolean; + }; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; userList: { params: { listId: string; @@ -1710,6 +1734,10 @@ declare namespace entities { NotesUnrenoteRequest, NotesUserListTimelineRequest, NotesUserListTimelineResponse, + NotesVmimiRelayHybridTimelineRequest, + NotesVmimiRelayHybridTimelineResponse, + NotesVmimiRelayTimelineRequest, + NotesVmimiRelayTimelineResponse, NotificationsCreateRequest, PagePushRequest, PagesCreateRequest, @@ -2822,6 +2850,18 @@ type NotesUserListTimelineRequest = operations['notes___user-list-timeline']['re // @public (undocumented) type NotesUserListTimelineResponse = operations['notes___user-list-timeline']['responses']['200']['content']['application/json']; +// @public (undocumented) +type NotesVmimiRelayHybridTimelineRequest = operations['notes___vmimi-relay-hybrid-timeline']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesVmimiRelayHybridTimelineResponse = operations['notes___vmimi-relay-hybrid-timeline']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type NotesVmimiRelayTimelineRequest = operations['notes___vmimi-relay-timeline']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesVmimiRelayTimelineResponse = operations['notes___vmimi-relay-timeline']['responses']['200']['content']['application/json']; + // @public (undocumented) export const noteVisibilities: readonly ["public", "home", "followers", "specified"]; @@ -3436,8 +3476,8 @@ type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['respons // // src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.ts:57:3 - (ae-forgotten-export) The symbol "ReconnectingWebSocket" needs to be exported by the entry point index.d.ts -// src/streaming.types.ts:220:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts -// src/streaming.types.ts:230:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts +// src/streaming.types.ts:244:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts +// src/streaming.types.ts:254:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index 9d48531c481c..28a5a629fcf3 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2025.1.0", + "version": "2025.1.0-vrtl.1", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 6bace3924c27..212b295eca13 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -3505,6 +3505,28 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *No* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index a9903b91391c..8186e4870587 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -475,6 +475,10 @@ import type { NotesUnrenoteRequest, NotesUserListTimelineRequest, NotesUserListTimelineResponse, + NotesVmimiRelayHybridTimelineRequest, + NotesVmimiRelayHybridTimelineResponse, + NotesVmimiRelayTimelineRequest, + NotesVmimiRelayTimelineResponse, NotificationsCreateRequest, PagePushRequest, PagesCreateRequest, @@ -900,6 +904,8 @@ export type Endpoints = { 'notes/translate': { req: NotesTranslateRequest; res: NotesTranslateResponse }; 'notes/unrenote': { req: NotesUnrenoteRequest; res: EmptyResponse }; 'notes/user-list-timeline': { req: NotesUserListTimelineRequest; res: NotesUserListTimelineResponse }; + 'notes/vmimi-relay-hybrid-timeline': { req: NotesVmimiRelayHybridTimelineRequest; res: NotesVmimiRelayHybridTimelineResponse }; + 'notes/vmimi-relay-timeline': { req: NotesVmimiRelayTimelineRequest; res: NotesVmimiRelayTimelineResponse }; 'notifications/create': { req: NotificationsCreateRequest; res: EmptyResponse }; 'notifications/flush': { req: EmptyRequest; res: EmptyResponse }; 'notifications/mark-all-as-read': { req: EmptyRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index b7639abca800..c13d7eb0a65f 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -478,6 +478,10 @@ export type NotesTranslateResponse = operations['notes___translate']['responses' export type NotesUnrenoteRequest = operations['notes___unrenote']['requestBody']['content']['application/json']; export type NotesUserListTimelineRequest = operations['notes___user-list-timeline']['requestBody']['content']['application/json']; export type NotesUserListTimelineResponse = operations['notes___user-list-timeline']['responses']['200']['content']['application/json']; +export type NotesVmimiRelayHybridTimelineRequest = operations['notes___vmimi-relay-hybrid-timeline']['requestBody']['content']['application/json']; +export type NotesVmimiRelayHybridTimelineResponse = operations['notes___vmimi-relay-hybrid-timeline']['responses']['200']['content']['application/json']; +export type NotesVmimiRelayTimelineRequest = operations['notes___vmimi-relay-timeline']['requestBody']['content']['application/json']; +export type NotesVmimiRelayTimelineResponse = operations['notes___vmimi-relay-timeline']['responses']['200']['content']['application/json']; export type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json']; export type PagePushRequest = operations['page-push']['requestBody']['content']['application/json']; export type PagesCreateRequest = operations['pages___create']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index e42a163288ac..302fb6bb3909 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3040,6 +3040,24 @@ export type paths = { */ post: operations['notes___user-list-timeline']; }; + '/notes/vmimi-relay-hybrid-timeline': { + /** + * notes/vmimi-relay-hybrid-timeline + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + post: operations['notes___vmimi-relay-hybrid-timeline']; + }; + '/notes/vmimi-relay-timeline': { + /** + * notes/vmimi-relay-timeline + * @description No description provided. + * + * **Credential required**: *No* + */ + post: operations['notes___vmimi-relay-timeline']; + }; '/notifications/create': { /** * notifications/create @@ -4900,6 +4918,7 @@ export type components = { usersCount: number; }); RolePolicies: { + vrtlAvailable: boolean; gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; @@ -5088,6 +5107,7 @@ export type components = { emailRequiredForSignup: boolean; localTimeline: boolean; globalTimeline: boolean; + vmimiRelayTimeline: boolean; hcaptcha: boolean; turnstile: boolean; recaptcha: boolean; @@ -10669,6 +10689,7 @@ export type operations = { manifestJsonOverride?: string; enableFanoutTimeline?: boolean; enableFanoutTimelineDbFallback?: boolean; + vmimiRelayTimelineCacheMax?: number; perLocalUserUserTimelineCacheMax?: number; perRemoteUserUserTimelineCacheMax?: number; perUserHomeTimelineCacheMax?: number; @@ -24039,6 +24060,146 @@ export type operations = { }; }; }; + /** + * notes/vmimi-relay-hybrid-timeline + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + 'notes___vmimi-relay-hybrid-timeline': { + requestBody: { + content: { + 'application/json': { + /** @default false */ + withFiles?: boolean; + /** @default true */ + withRenotes?: boolean; + /** @default false */ + withReplies?: boolean; + /** @default true */ + withLocalOnly?: boolean; + /** @default 10 */ + limit?: number; + /** @default true */ + allowPartial?: boolean; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + sinceDate?: number; + untilDate?: number; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['Note'][]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * notes/vmimi-relay-timeline + * @description No description provided. + * + * **Credential required**: *No* + */ + 'notes___vmimi-relay-timeline': { + requestBody: { + content: { + 'application/json': { + /** @default false */ + withFiles?: boolean; + /** @default true */ + withRenotes?: boolean; + /** @default false */ + withReplies?: boolean; + /** @default true */ + withLocalOnly?: boolean; + /** @default 10 */ + limit?: number; + /** @default true */ + allowPartial?: boolean; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + sinceDate?: number; + untilDate?: number; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['Note'][]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * notifications/create * @description No description provided. diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts index 26a50f9fa41a..4f5081fc6c4a 100644 --- a/packages/misskey-js/src/streaming.types.ts +++ b/packages/misskey-js/src/streaming.types.ts @@ -111,6 +111,30 @@ export type Channels = { }; receives: null; }; + vmimiRelayTimeline: { + params: { + withRenotes?: boolean; + withFiles?: boolean; + withReplies?: boolean; + withLocalOnly?: boolean; + }; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; + vmimiRelayHybridTimeline: { + params: { + withRenotes?: boolean; + withReplies?: boolean; + withFiles?: boolean; + withLocalOnly?: boolean; + }; + events: { + note: (payload: Note) => void; + }; + receives: null; + }; userList: { params: { listId: string;