feat: bark support (#563)
This commit is contained in:
@@ -2,7 +2,7 @@ export enum EventBusEvents {
|
||||
EmailInit = 'email.init',
|
||||
PushSearch = 'search.push',
|
||||
TokenExpired = 'token.expired',
|
||||
|
||||
CleanAggregateCache = 'cache.aggregate',
|
||||
SystemException = 'system.exception',
|
||||
ConfigChanged = 'config.changed',
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export const generateDefaultConfig: () => IConfig = () => ({
|
||||
recordIpLocation: true,
|
||||
spamKeywords: [],
|
||||
},
|
||||
barkOptions: {
|
||||
enable: false,
|
||||
key: '',
|
||||
serverUrl: 'https://api.day.app',
|
||||
enableComment: true,
|
||||
},
|
||||
friendLinkOptions: { allowApply: true },
|
||||
backupOptions: {
|
||||
enable: isInDemoMode ? false : true,
|
||||
|
||||
@@ -288,3 +288,28 @@ export class TextOptionsDto {
|
||||
@JSONSchemaToggleField('开启文本宏替换')
|
||||
macros: boolean
|
||||
}
|
||||
|
||||
@JSONSchema({ title: 'Bark 通知设定' })
|
||||
export class BarkOptionsDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@JSONSchemaToggleField('开启 Bark 通知')
|
||||
enable: boolean
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@JSONSchemaPlainField('设备 Key')
|
||||
key: string
|
||||
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
@JSONSchemaPlainField('服务器 URL', {
|
||||
description: '如果不填写, 则使用默认的服务器, https://day.app/',
|
||||
})
|
||||
serverUrl: string
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@JSONSchemaToggleField('开启评论通知')
|
||||
enableComment?: boolean
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AlgoliaSearchOptionsDto,
|
||||
BackupOptionsDto,
|
||||
BaiduSearchOptionsDto,
|
||||
BarkOptionsDto,
|
||||
CommentOptionsDto,
|
||||
FriendLinkOptionsDto,
|
||||
MailOptionsDto,
|
||||
@@ -45,6 +46,10 @@ export abstract class IConfig {
|
||||
@ValidateNested()
|
||||
commentOptions: Required<CommentOptionsDto>
|
||||
|
||||
@Type(() => BarkOptionsDto)
|
||||
@ValidateNested()
|
||||
barkOptions: Required<BarkOptionsDto>
|
||||
|
||||
@Type(() => FriendLinkOptionsDto)
|
||||
@ValidateNested()
|
||||
friendLinkOptions: Required<FriendLinkOptionsDto>
|
||||
|
||||
@@ -11,15 +11,16 @@ import {
|
||||
Logger,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common'
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter'
|
||||
import { DocumentType, ReturnModelType } from '@typegoose/typegoose'
|
||||
import { BeAnObject } from '@typegoose/typegoose/lib/types'
|
||||
|
||||
import { EventScope } from '~/constants/business-event.constant'
|
||||
import { RedisKeys } from '~/constants/cache.constant'
|
||||
import { EventBusEvents } from '~/constants/event-bus.constant'
|
||||
import { CacheService } from '~/processors/cache/cache.service'
|
||||
import { EventManagerService } from '~/processors/helper/helper.event.service'
|
||||
import { InjectModel } from '~/transformers/model.transformer'
|
||||
import { banInDemo, sleep } from '~/utils'
|
||||
import { sleep } from '~/utils'
|
||||
import { getRedisKey } from '~/utils/redis.util'
|
||||
|
||||
import * as optionDtos from '../configs/configs.dto'
|
||||
@@ -53,7 +54,7 @@ export class ConfigsService {
|
||||
private readonly userService: UserService,
|
||||
private readonly redis: CacheService,
|
||||
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly eventManager: EventManagerService,
|
||||
) {
|
||||
this.configInit().then(() => {
|
||||
this.logger.log('Config 已经加载完毕!')
|
||||
@@ -165,6 +166,13 @@ export class ConfigsService {
|
||||
const mergedFullConfig = Object.assign({}, config, { [key]: newData })
|
||||
|
||||
await this.setConfig(mergedFullConfig)
|
||||
this.eventManager.emit(
|
||||
EventBusEvents.ConfigChanged,
|
||||
{ ...newData },
|
||||
{
|
||||
scope: EventScope.TO_SYSTEM,
|
||||
},
|
||||
)
|
||||
|
||||
return newData
|
||||
}
|
||||
@@ -178,7 +186,6 @@ export class ConfigsService {
|
||||
key: T,
|
||||
value: Partial<IConfig[T]>,
|
||||
) {
|
||||
banInDemo()
|
||||
value = camelcaseKeys(value, { deep: true }) as any
|
||||
|
||||
switch (key) {
|
||||
@@ -189,7 +196,9 @@ export class ConfigsService {
|
||||
)
|
||||
if (option.enable) {
|
||||
if (cluster.isPrimary) {
|
||||
this.eventEmitter.emit(EventBusEvents.EmailInit)
|
||||
this.eventManager.emit(EventBusEvents.EmailInit, null, {
|
||||
scope: EventScope.TO_SYSTEM,
|
||||
})
|
||||
} else {
|
||||
this.redis.publish(EventBusEvents.EmailInit, '')
|
||||
}
|
||||
@@ -204,7 +213,9 @@ export class ConfigsService {
|
||||
this.validWithDto(AlgoliaSearchOptionsDto, value),
|
||||
)
|
||||
if (option.enable) {
|
||||
this.eventEmitter.emit(EventBusEvents.PushSearch)
|
||||
this.eventManager.emit(EventBusEvents.PushSearch, null, {
|
||||
scope: EventScope.TO_SYSTEM,
|
||||
})
|
||||
}
|
||||
return option
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from '@nestjs/websockets'
|
||||
|
||||
import { LOG_DIR } from '~/constants/path.constant'
|
||||
import { getTodayLogFilePath } from '~/global/consola.global'
|
||||
import { CacheService } from '~/processors/cache/cache.service'
|
||||
|
||||
import { BusinessEvents } from '../../../constants/business-event.constant'
|
||||
@@ -47,6 +46,7 @@ export class AdminEventsGateway
|
||||
|
||||
this.subscribeSocketToHandlerMap.set(client, handler)
|
||||
if (prevLog) {
|
||||
const { getTodayLogFilePath } = await import('~/global/consola.global')
|
||||
const stream = fs
|
||||
.createReadStream(resolve(LOG_DIR, getTodayLogFilePath()), {
|
||||
encoding: 'utf-8',
|
||||
|
||||
73
src/processors/helper/helper.bark.service.ts
Normal file
73
src/processors/helper/helper.bark.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { OnEvent } from '@nestjs/event-emitter'
|
||||
|
||||
import { BusinessEvents } from '~/constants/business-event.constant'
|
||||
import { CommentModel } from '~/modules/comment/comment.model'
|
||||
import { ConfigsService } from '~/modules/configs/configs.service'
|
||||
|
||||
import { HttpService } from './helper.http.service'
|
||||
|
||||
export type BarkPushOptions = {
|
||||
title: string
|
||||
body: string
|
||||
category?: string
|
||||
/**
|
||||
* An url to the icon, available only on iOS 15 or later
|
||||
*/
|
||||
icon?: string
|
||||
group?: string
|
||||
url?: string
|
||||
/**
|
||||
* Value from here <https://github.com/Finb/Bark/tree/master/Sounds>
|
||||
*/
|
||||
sound?: string
|
||||
level?: 'active' | 'timeSensitive' | 'passive'
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BarkPushService {
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly config: ConfigsService,
|
||||
) {}
|
||||
|
||||
// push comment
|
||||
@OnEvent(BusinessEvents.COMMENT_CREATE)
|
||||
async pushCommentEvent(comment: CommentModel) {
|
||||
const { enable } = await this.config.get('barkOptions')
|
||||
if (!enable) {
|
||||
return
|
||||
}
|
||||
const master = await this.config.getMaster()
|
||||
if (comment.author == master.name && comment.author == master.username) {
|
||||
return
|
||||
}
|
||||
const { adminUrl } = await this.config.get('url')
|
||||
|
||||
await this.push({
|
||||
title: '收到一条新评论',
|
||||
body: `${comment.author} 评论了你的文章: ${comment.text}`,
|
||||
icon: comment.avatar,
|
||||
url: `${adminUrl}#/comments`,
|
||||
})
|
||||
}
|
||||
|
||||
async push(options: BarkPushOptions) {
|
||||
const { key, serverUrl = 'https://day.app' } = await this.config.get(
|
||||
'barkOptions',
|
||||
)
|
||||
const { title: siteTitle } = await this.config.get('seo')
|
||||
if (!key) {
|
||||
throw new Error('Bark key is not configured')
|
||||
}
|
||||
const { title, ...rest } = options
|
||||
const response = await this.httpService.axiosRef.post(`${serverUrl}/push`, {
|
||||
device_key: key,
|
||||
title: `[${siteTitle}] ${title}`,
|
||||
category: siteTitle,
|
||||
group: siteTitle,
|
||||
...rest,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export class EventManagerService {
|
||||
): Promise<void>
|
||||
emit(
|
||||
event: EventBusEvents,
|
||||
data: any,
|
||||
data?: any,
|
||||
options?: EventManagerOptions,
|
||||
): Promise<void>
|
||||
async emit(event: string, data: any = null, _options?: EventManagerOptions) {
|
||||
@@ -103,6 +103,7 @@ export class EventManagerService {
|
||||
if (instance instanceof EventEmitter2) {
|
||||
const isObjectLike = typeof data === 'object' && data !== null
|
||||
const payload = isObjectLike ? data : { data }
|
||||
instance.emit(event, payload)
|
||||
return instance.emit(this.#key, {
|
||||
event,
|
||||
payload,
|
||||
|
||||
@@ -27,7 +27,7 @@ export class HttpService {
|
||||
private logger: Logger
|
||||
constructor(private readonly cacheService: CacheService) {
|
||||
this.logger = new Logger(HttpService.name)
|
||||
this.http = this.bindDebugVerboseInterceptor(
|
||||
this.http = this.bindInterceptors(
|
||||
axios.create({
|
||||
...AXIOS_CONFIG,
|
||||
headers: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PostModule } from '~/modules/post/post.module'
|
||||
import { SearchModule } from '~/modules/search/search.module'
|
||||
|
||||
import { AssetService } from './helper.asset.service'
|
||||
import { BarkPushService } from './helper.bark.service'
|
||||
import { CountingService } from './helper.counting.service'
|
||||
import { CronService } from './helper.cron.service'
|
||||
import { EmailService } from './helper.email.service'
|
||||
@@ -22,16 +23,17 @@ import { TaskQueueService } from './helper.tq.service'
|
||||
import { UploadService } from './helper.upload.service'
|
||||
|
||||
const providers: Provider<any>[] = [
|
||||
HttpService,
|
||||
EmailService,
|
||||
ImageService,
|
||||
CronService,
|
||||
CountingService,
|
||||
UploadService,
|
||||
AssetService,
|
||||
TaskQueueService,
|
||||
BarkPushService,
|
||||
CountingService,
|
||||
CronService,
|
||||
EmailService,
|
||||
EventManagerService,
|
||||
HttpService,
|
||||
ImageService,
|
||||
TaskQueueService,
|
||||
TextMacroService,
|
||||
UploadService,
|
||||
]
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { dbHelper } from 'test/helper/db-mock.helper'
|
||||
import { MockCacheService, redisHelper } from 'test/helper/redis-mock.helper'
|
||||
|
||||
import { BadRequestException } from '@nestjs/common'
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter'
|
||||
import { Test } from '@nestjs/testing'
|
||||
import { getModelForClass } from '@typegoose/typegoose'
|
||||
|
||||
@@ -11,6 +10,7 @@ import { OptionModel } from '~/modules/configs/configs.model'
|
||||
import { ConfigsService } from '~/modules/configs/configs.service'
|
||||
import { UserService } from '~/modules/user/user.service'
|
||||
import { CacheService } from '~/processors/cache/cache.service'
|
||||
import { EventManagerService } from '~/processors/helper/helper.event.service'
|
||||
import { getModelToken } from '~/transformers/model.transformer'
|
||||
import { getRedisKey } from '~/utils/redis.util'
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('Test ConfigsService', () => {
|
||||
provide: CacheService,
|
||||
useValue: redisService$,
|
||||
},
|
||||
{ provide: EventEmitter2, useValue: { emit: mockEmitFn } },
|
||||
{ provide: EventManagerService, useValue: { emit: mockEmitFn } },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
@@ -99,34 +99,35 @@ describe('Test ConfigsService', () => {
|
||||
})
|
||||
|
||||
it('should emit event if enable email option and update search', async () => {
|
||||
// + 1 call time because of `config.changed` event
|
||||
await service.patchAndValid('mailOptions', { enable: true })
|
||||
expect(mockEmitFn).toBeCalledTimes(1)
|
||||
expect(mockEmitFn).toBeCalledTimes(3)
|
||||
mockEmitFn.mockClear()
|
||||
|
||||
await service.patchAndValid('mailOptions', { pass: '*' })
|
||||
expect(mockEmitFn).toBeCalledTimes(1)
|
||||
expect(mockEmitFn).toBeCalledTimes(2)
|
||||
mockEmitFn.mockClear()
|
||||
|
||||
await service.patchAndValid('mailOptions', { pass: '*', enable: false })
|
||||
expect(mockEmitFn).toBeCalledTimes(0)
|
||||
expect(mockEmitFn).toBeCalledTimes(1)
|
||||
mockEmitFn.mockClear()
|
||||
|
||||
await service.patchAndValid('algoliaSearchOptions', {
|
||||
enable: true,
|
||||
})
|
||||
expect(mockEmitFn).toBeCalledTimes(1)
|
||||
expect(mockEmitFn).toBeCalledTimes(2)
|
||||
mockEmitFn.mockClear()
|
||||
|
||||
await service.patchAndValid('algoliaSearchOptions', {
|
||||
indexName: 'x',
|
||||
})
|
||||
expect(mockEmitFn).toBeCalledTimes(1)
|
||||
expect(mockEmitFn).toBeCalledTimes(2)
|
||||
mockEmitFn.mockClear()
|
||||
|
||||
await service.patchAndValid('algoliaSearchOptions', {
|
||||
enable: false,
|
||||
})
|
||||
expect(mockEmitFn).toBeCalledTimes(0)
|
||||
expect(mockEmitFn).toBeCalledTimes(1)
|
||||
mockEmitFn.mockClear()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user