diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 3f8e5734ce..17b835480f 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -219,3 +219,7 @@ signToActivityPubGet: true # Upload or download file size limits (bytes) #maxFileSize: 262144000 + +defaultTag: + tag: null + append: true diff --git a/.config/example.yml b/.config/example.yml index 60a6a0aa71..9a6673064d 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -321,3 +321,7 @@ signToActivityPubGet: true # PID File of master process #pidFile: /tmp/misskey.pid + +defaultTag: + tag: null + append: true diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 42f1033b9d..6ad6f8c848 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -60,6 +60,10 @@ type Source = { }; sentryForBackend?: { options: Partial; enableNodeProfiling: boolean; }; sentryForFrontend?: { options: Partial }; + defaultTag: { + tag: string; + append: boolean; + }; publishTarballInsteadOfProvideRepositoryUrl?: boolean; @@ -132,6 +136,10 @@ export type Config = { index: string; scope?: 'local' | 'global' | string[]; } | undefined; + defaultTag: { + tag: string; + append: boolean; + }; proxy: string | undefined; proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; @@ -293,6 +301,7 @@ export function loadConfig(): Config { perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500, deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), pidFile: config.pidFile, + defaultTag: config.defaultTag, }; } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 3647fa7231..e95b54132b 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -330,6 +330,16 @@ export class NoteCreateService implements OnApplicationShutdown { data.localOnly = true; } + // デフォルトハッシュタグを本文末尾に書き足す + if (this.config.defaultTag?.append && ['public', 'home'].includes(data.visibility)) { + if (this.config.defaultTag?.tag != null) { + const tag = `#${this.config.defaultTag?.tag}`; + if (String(data.text).match(tag)) { + data.text = `${data.text}\n\n${tag}`; + } + } + } + if (data.text) { if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); @@ -944,6 +954,18 @@ export class NoteCreateService implements OnApplicationShutdown { } } + // デフォルトハッシュタグを含む投稿は、リモートであってもローカルタイムラインに含める + if (this.config.defaultTag?.tag != null) { + const noteTags = note.tags ? note.tags.map((t: string) => t.toLowerCase()) : []; + if (note.visibility === 'public' && noteTags.includes(normalizeForSearch(this.config.defaultTag?.tag))) { + this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); + this.fanoutTimelineService.push('localTimeline', note.id, 1000, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); + } + } + } + // 自分自身以外への返信 if (isReply(note)) { this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index aed9065bf9..0582a84493 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -19,6 +19,8 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { loadConfig } from '@/config.js'; export const meta = { tags: ['notes'], @@ -207,10 +209,16 @@ export default class extends Endpoint { // eslint- if (followees.length > 0) { const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); } else { qb.where('note.userId = :meId', { meId: me.id }); + } + + const config = loadConfig(); + const defaultTag: string | null = config.defaultTag?.tag; + if (defaultTag == null) { qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + } else { + qb.orWhere(`(note.visibility = 'public') AND (:t <@ note.tags`, { t: normalizeForSearch(defaultTag) }); } })) .innerJoinAndSelect('note.user', 'user') diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 0b48f2c78b..71e1fa6dad 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -16,6 +16,8 @@ import { QueryService } from '@/core/QueryService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { loadConfig } from '@/config.js'; export const meta = { tags: ['notes'], @@ -146,9 +148,19 @@ export default class extends Endpoint { // eslint- withFiles: boolean, withReplies: boolean, }, me: MiLocalUser | null) { + const config = loadConfig(); + const defaultTag: string | null = config.defaultTag?.tag; const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL) AND (note.channelId IS NULL)') + .andWhere(new Brackets(qb => { + qb.andWhere('note.visibility = \'public\''); + qb.andWhere('note.channelId IS NULL'); + if (defaultTag == null) { + qb.andWhere('note.userHost IS NULL'); + } else { + qb.andWhere(`:t <@ note.tags`, { t: normalizeForSearch(defaultTag) }); + } + })) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 75bd13221f..ee43cb9e44 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -12,6 +12,8 @@ import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { loadConfig } from '@/config.js'; class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; @@ -21,6 +23,7 @@ class HybridTimelineChannel extends Channel { private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; + private defaultTag: string | null; constructor( private metaService: MetaService, @@ -42,6 +45,8 @@ class HybridTimelineChannel extends Channel { this.withRenotes = !!(params.withRenotes ?? true); this.withReplies = !!(params.withReplies ?? false); this.withFiles = !!(params.withFiles ?? false); + const config = loadConfig(); + this.defaultTag = config.defaultTag?.tag; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -49,6 +54,11 @@ class HybridTimelineChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { + let matched = false; + if (this.defaultTag != null) { + const noteTags = note.tags ? note.tags.map((t: string) => t.toLowerCase()) : []; + matched = noteTags.includes(normalizeForSearch(this.defaultTag)); + } const isMe = this.user!.id === note.userId; if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; @@ -60,7 +70,7 @@ class HybridTimelineChannel extends Channel { if (!( (note.channelId == null && isMe) || (note.channelId == null && Object.hasOwn(this.following, note.userId)) || - (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || + (note.channelId == null && ((note.user.host == null || matched) && note.visibility === 'public')) || (note.channelId != null && this.followingChannels.has(note.channelId)) )) return; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 491029f5de..daf5827029 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -12,6 +12,8 @@ import { RoleService } from '@/core/RoleService.js'; import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { loadConfig } from '@/config.js'; class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; @@ -20,6 +22,7 @@ class LocalTimelineChannel extends Channel { private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; + private defaultTag: string | null; constructor( private metaService: MetaService, @@ -41,6 +44,8 @@ class LocalTimelineChannel extends Channel { this.withRenotes = !!(params.withRenotes ?? true); this.withReplies = !!(params.withReplies ?? false); this.withFiles = !!(params.withFiles ?? false); + const config = loadConfig(); + this.defaultTag = config.defaultTag?.tag; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -50,7 +55,12 @@ class LocalTimelineChannel extends Channel { private async onNote(note: Packed<'Note'>) { if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - if (note.user.host !== null) return; + if (this.defaultTag == null) { + if (note.user.host !== null) return; + } else { + const noteTags = note.tags ? note.tags.map((t: string) => t.toLowerCase()) : []; + if (!noteTags.includes(normalizeForSearch(this.defaultTag))) return; + } if (note.visibility !== 'public') return; if (note.channelId != null) return;