| 1 | import { Injectable, Logger } from '@nestjs/common' |
| 2 | import { AccountType, AppException, ResponseCode, zodToJsonSchemaOptions } from '@yikart/common' |
| 3 | import { z } from 'zod' |
| 4 | import { |
| 5 | CallEngagementFunctionBodySchema, |
| 6 | CreateEngagementCommentBodySchema, |
| 7 | EngagementCommentsQuerySchema, |
| 8 | EngagementExecutionQuerySchema, |
| 9 | getEngagementFunctionDataSchema, |
| 10 | } from '../engagement/engagement.dto' |
| 11 | import { |
| 12 | AnalyticsProvider, |
| 13 | AuthProvider, |
| 14 | BrowseProvider, |
| 15 | CachedPlatformMetadata, |
| 16 | ChannelEngagementFunctionName, |
| 17 | ChannelEngagementTargetType, |
| 18 | ChannelPaginationMode, |
| 19 | ChannelWorkAnalyticsDataSource, |
| 20 | EngagementProvider, |
| 21 | PlatformIntegration, |
| 22 | PlatformStatus, |
| 23 | PlatformWebhookHandler, |
| 24 | PublishOptionSourceProvider, |
| 25 | PublishProvider, |
| 26 | WorkProvider, |
| 27 | } from './platforms.interface' |
| 28 | |
| 29 | interface RegisteredPlatform<TOption = any, TDataOption = any> { |
| 30 | integration: PlatformIntegration<TOption, TDataOption> |
| 31 | metadata: CachedPlatformMetadata |
| 32 | } |
| 33 | |
| 34 | @Injectable() |
| 35 | export class PlatformIntegrationRegistry { |
| 36 | private readonly logger = new Logger(PlatformIntegrationRegistry.name) |
| 37 | private readonly platforms = new Map<AccountType, RegisteredPlatform>() |
| 38 | |
| 39 | register<TOption, TDataOption>(integration: PlatformIntegration<TOption, TDataOption>): void { |
| 40 | const status = integration.status ?? PlatformStatus.Available |
| 41 | if (status === PlatformStatus.Hidden) { |
| 42 | throw new Error('Hidden platforms are not registered') |
| 43 | } |
| 44 | if (status === PlatformStatus.Available && !this.hasCapabilityProvider(integration)) { |
| 45 | throw new Error(`Available platform must register at least one capability provider: ${integration.platform}`) |
| 46 | } |
| 47 | if (this.platforms.has(integration.platform)) { |
| 48 | throw new Error(`Platform already registered: ${integration.platform}`) |
| 49 | } |
| 50 | this.platforms.set(integration.platform, { |
| 51 | integration, |
| 52 | metadata: this.createMetadataCache(integration), |
| 53 | }) |
| 54 | this.logger.log({ platform: integration.platform }, 'Registered platform') |
| 55 | } |
| 56 | |
| 57 | get(platform: AccountType): PlatformIntegration { |
| 58 | const registeredPlatform = this.platforms.get(platform) |
| 59 | if (!registeredPlatform) { |
| 60 | this.logger.warn({ platform }, 'Platform not registered') |
| 61 | throw new AppException(ResponseCode.PlatformNotSupported, { platform }) |
| 62 | } |
| 63 | return registeredPlatform.integration |
| 64 | } |
| 65 | |
| 66 | getAuth(platform: AccountType): AuthProvider { |
| 67 | const integration = this.get(platform) |
| 68 | if (!integration.auth) { |
| 69 | this.logger.warn({ platform }, 'Platform does not support auth') |
| 70 | throw new AppException(ResponseCode.PlatformNotSupported, { platform, capability: 'auth' }) |
| 71 | } |
| 72 | return integration.auth |
| 73 | } |
| 74 | |
| 75 | getPublish(platform: AccountType): PublishProvider { |
| 76 | const integration = this.get(platform) |
| 77 | if (!integration.publish) { |
| 78 | this.logger.warn({ platform }, 'Platform does not support publish') |
| 79 | throw new AppException(ResponseCode.ChannelPublishPlatformNotSupported, { platform }) |
| 80 | } |
| 81 | return integration.publish |
| 82 | } |
| 83 | |
| 84 | getPublishOptions(platform: AccountType): PublishOptionSourceProvider | undefined { |
| 85 | const integration = this.get(platform) |
| 86 | return integration.publishOptions |
| 87 | } |
| 88 | |
| 89 | getAnalytics(platform: AccountType): AnalyticsProvider | undefined { |
| 90 | const integration = this.get(platform) |
| 91 | return integration.analytics |
| 92 | } |
| 93 | |
| 94 | getEngagement(platform: AccountType): EngagementProvider | undefined { |
| 95 | const integration = this.get(platform) |
| 96 | return integration.engagement |
| 97 | } |
| 98 | |
| 99 | getBrowse(platform: AccountType): BrowseProvider | undefined { |
| 100 | const integration = this.get(platform) |
| 101 | return integration.browse |
| 102 | } |
| 103 | |
| 104 | getWork(platform: AccountType): WorkProvider | undefined { |
| 105 | const integration = this.get(platform) |
| 106 | return integration.work |
| 107 | } |
| 108 | |
| 109 | hasWorkAnalyticsDataSource(platform: AccountType, dataSource: ChannelWorkAnalyticsDataSource): boolean { |
| 110 | return this.listWorkAnalyticsDataSources(this.get(platform)).includes(dataSource) |
| 111 | } |
| 112 | |
| 113 | getWebhook(platform: AccountType): PlatformWebhookHandler | undefined { |
| 114 | const integration = this.get(platform) |
| 115 | return integration.webhook |
| 116 | } |
| 117 | |
| 118 | listMetadata(): CachedPlatformMetadata[] { |
| 119 | return Array.from(this.platforms.values()).map(platform => platform.metadata) |
| 120 | } |
| 121 | |
| 122 | listIntegrations(): PlatformIntegration[] { |
| 123 | return Array.from(this.platforms.values()).map(platform => platform.integration) |
| 124 | } |
| 125 | |
| 126 | has(platform: AccountType): boolean { |
| 127 | return this.platforms.has(platform) |
| 128 | } |
| 129 | |
| 130 | private createMetadataCache<TOption, TDataOption>(integration: PlatformIntegration<TOption, TDataOption>): CachedPlatformMetadata { |
| 131 | const publishPolicy = integration.metadata.publishPolicy |
| 132 | const publishSupported = Boolean(integration.publish && publishPolicy) |
| 133 | const status = integration.status ?? PlatformStatus.Available |
| 134 | |
| 135 | return { |
| 136 | ...integration.metadata, |
| 137 | status, |
| 138 | capabilities: status === PlatformStatus.Available |
| 139 | ? { |
| 140 | auth: { |
| 141 | supported: Boolean(integration.auth), |
| 142 | revoke: Boolean(integration.auth?.revoke), |
| 143 | selectableAccounts: Boolean(integration.auth?.listSelectableAccounts), |
| 144 | refreshAccountAccess: Boolean(integration.auth?.refreshAccountAccess), |
| 145 | }, |
| 146 | publish: { |
| 147 | supported: publishSupported, |
| 148 | cancel: Boolean(publishSupported && integration.publish?.cancel), |
| 149 | update: Boolean(publishSupported && publishPolicy?.updateSupported && integration.publish?.update), |
| 150 | verify: Boolean(publishSupported && integration.publish?.verify), |
| 151 | finalize: Boolean(publishSupported && integration.publish?.finalize), |
| 152 | scheduleByPlatform: Boolean(publishSupported && publishPolicy?.scheduleByPlatform), |
| 153 | optionSources: Boolean(integration.publishOptions), |
| 154 | completionStrategy: publishSupported ? publishPolicy?.completionStrategy : undefined, |
| 155 | }, |
| 156 | analytics: this.createAnalyticsCapabilities(integration), |
| 157 | engagement: this.createEngagementCapabilities(integration.engagement), |
| 158 | work: { |
| 159 | listWorks: Boolean(integration.work?.listWorks), |
| 160 | listWorksPagination: integration.work?.listWorksPagination ?? { mode: ChannelPaginationMode.None }, |
| 161 | getLinkInfo: Boolean(integration.work?.getLinkInfo), |
| 162 | getDetail: Boolean(integration.work?.getDetail), |
| 163 | verifyOwnership: Boolean(integration.work?.verifyOwnership), |
| 164 | }, |
| 165 | browse: { |
| 166 | search: Boolean(integration.browse?.search), |
| 167 | getDetail: Boolean(integration.browse?.getDetail), |
| 168 | }, |
| 169 | webhook: { |
| 170 | supported: Boolean(integration.webhook), |
| 171 | }, |
| 172 | } |
| 173 | : { |
| 174 | auth: { supported: false, revoke: false, selectableAccounts: false, refreshAccountAccess: false }, |
| 175 | publish: { supported: false, cancel: false, update: false, verify: false, finalize: false, scheduleByPlatform: false, optionSources: false }, |
| 176 | analytics: { account: false, work: false }, |
| 177 | engagement: this.createEngagementCapabilities(undefined), |
| 178 | work: { listWorks: false, listWorksPagination: { mode: ChannelPaginationMode.None }, getLinkInfo: false, getDetail: false, verifyOwnership: false }, |
| 179 | browse: { search: false, getDetail: false }, |
| 180 | webhook: { supported: false }, |
| 181 | }, |
| 182 | optionSchema: z.toJSONSchema(integration.metadata.optionSchema, { |
| 183 | ...zodToJsonSchemaOptions, |
| 184 | io: 'input', |
| 185 | }) as Record<string, unknown>, |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | private createEngagementCapabilities(provider: EngagementProvider | undefined) { |
| 190 | const querySchema = this.toJsonSchema(EngagementExecutionQuerySchema) |
| 191 | const bodySchema = this.toJsonSchema(CallEngagementFunctionBodySchema) |
| 192 | |
| 193 | return { |
| 194 | comments: { |
| 195 | list: { |
| 196 | supported: Boolean(provider?.listComments), |
| 197 | pagination: provider?.commentPagination ?? { mode: ChannelPaginationMode.None }, |
| 198 | parameters: { |
| 199 | querySchema: this.toJsonSchema(EngagementCommentsQuerySchema), |
| 200 | }, |
| 201 | }, |
| 202 | create: { |
| 203 | supported: Boolean(provider?.createComment), |
| 204 | parameters: { |
| 205 | querySchema, |
| 206 | bodySchema: this.toJsonSchema(CreateEngagementCommentBodySchema), |
| 207 | }, |
| 208 | }, |
| 209 | }, |
| 210 | functions: [ |
| 211 | { |
| 212 | name: ChannelEngagementFunctionName.DeleteComment, |
| 213 | label: { 'en-US': 'Delete comment', 'zh-CN': '删除评论' }, |
| 214 | target: ChannelEngagementTargetType.Comment, |
| 215 | supported: Boolean(provider?.deleteComment), |
| 216 | }, |
| 217 | { |
| 218 | name: ChannelEngagementFunctionName.Like, |
| 219 | label: { 'en-US': 'Like work', 'zh-CN': '点赞作品' }, |
| 220 | target: ChannelEngagementTargetType.Work, |
| 221 | supported: Boolean(provider?.like), |
| 222 | }, |
| 223 | { |
| 224 | name: ChannelEngagementFunctionName.Unlike, |
| 225 | label: { 'en-US': 'Unlike work', 'zh-CN': '取消点赞' }, |
| 226 | target: ChannelEngagementTargetType.Work, |
| 227 | supported: Boolean(provider?.unlike), |
| 228 | }, |
| 229 | { |
| 230 | name: ChannelEngagementFunctionName.Repost, |
| 231 | label: { 'en-US': 'Repost work', 'zh-CN': '转发作品' }, |
| 232 | target: ChannelEngagementTargetType.Work, |
| 233 | supported: Boolean(provider?.repost), |
| 234 | }, |
| 235 | { |
| 236 | name: ChannelEngagementFunctionName.UndoRepost, |
| 237 | label: { 'en-US': 'Undo repost', 'zh-CN': '取消转发' }, |
| 238 | target: ChannelEngagementTargetType.Work, |
| 239 | supported: Boolean(provider?.undoRepost), |
| 240 | }, |
| 241 | { |
| 242 | name: ChannelEngagementFunctionName.Quote, |
| 243 | label: { 'en-US': 'Quote work', 'zh-CN': '引用作品' }, |
| 244 | target: ChannelEngagementTargetType.Work, |
| 245 | supported: Boolean(provider?.quote), |
| 246 | }, |
| 247 | { |
| 248 | name: ChannelEngagementFunctionName.Bookmark, |
| 249 | label: { 'en-US': 'Bookmark work', 'zh-CN': '收藏作品' }, |
| 250 | target: ChannelEngagementTargetType.Work, |
| 251 | supported: Boolean(provider?.bookmark), |
| 252 | }, |
| 253 | { |
| 254 | name: ChannelEngagementFunctionName.RemoveBookmark, |
| 255 | label: { 'en-US': 'Remove bookmark', 'zh-CN': '移除收藏' }, |
| 256 | target: ChannelEngagementTargetType.Work, |
| 257 | supported: Boolean(provider?.removeBookmark), |
| 258 | }, |
| 259 | { |
| 260 | name: ChannelEngagementFunctionName.HideReply, |
| 261 | label: { 'en-US': 'Hide reply', 'zh-CN': '隐藏回复' }, |
| 262 | target: ChannelEngagementTargetType.Comment, |
| 263 | supported: Boolean(provider?.hideReply), |
| 264 | }, |
| 265 | { |
| 266 | name: ChannelEngagementFunctionName.UnhideReply, |
| 267 | label: { 'en-US': 'Unhide reply', 'zh-CN': '取消隐藏回复' }, |
| 268 | target: ChannelEngagementTargetType.Comment, |
| 269 | supported: Boolean(provider?.unhideReply), |
| 270 | }, |
| 271 | { |
| 272 | name: ChannelEngagementFunctionName.Follow, |
| 273 | label: { 'en-US': 'Follow account', 'zh-CN': '关注账号' }, |
| 274 | target: ChannelEngagementTargetType.Account, |
| 275 | supported: Boolean(provider?.follow), |
| 276 | }, |
| 277 | { |
| 278 | name: ChannelEngagementFunctionName.Unfollow, |
| 279 | label: { 'en-US': 'Unfollow account', 'zh-CN': '取消关注账号' }, |
| 280 | target: ChannelEngagementTargetType.Account, |
| 281 | supported: Boolean(provider?.unfollow), |
| 282 | }, |
| 283 | ] |
| 284 | .filter(item => item.supported) |
| 285 | .map(item => ({ |
| 286 | name: item.name, |
| 287 | label: item.label, |
| 288 | target: item.target, |
| 289 | parameters: { |
| 290 | querySchema, |
| 291 | bodySchema, |
| 292 | dataSchema: this.toJsonSchema(getEngagementFunctionDataSchema(item.name)), |
| 293 | }, |
| 294 | })), |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | private createAnalyticsCapabilities<TOption, TDataOption>(integration: PlatformIntegration<TOption, TDataOption>) { |
| 299 | return { |
| 300 | account: Boolean(integration.analytics), |
| 301 | work: this.listWorkAnalyticsDataSources(integration).length > 0, |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | private listWorkAnalyticsDataSources<TOption, TDataOption>(integration: PlatformIntegration<TOption, TDataOption>) { |
| 306 | const sources = new Set(integration.metadata.analytics?.work?.dataSources ?? []) |
| 307 | if (integration.analytics?.fetchWorkAnalytics) { |
| 308 | sources.add(ChannelWorkAnalyticsDataSource.Official) |
| 309 | } |
| 310 | return [...sources] |
| 311 | } |
| 312 | |
| 313 | private toJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> { |
| 314 | return z.toJSONSchema(schema, { |
| 315 | ...zodToJsonSchemaOptions, |
| 316 | io: 'input', |
| 317 | }) as Record<string, unknown> |
| 318 | } |
| 319 | |
| 320 | private hasCapabilityProvider<TOption, TDataOption>(integration: PlatformIntegration<TOption, TDataOption>): boolean { |
| 321 | return Boolean( |
| 322 | integration.auth |
| 323 | || integration.publish |
| 324 | || integration.publishOptions |
| 325 | || integration.analytics |
| 326 | || Boolean(integration.metadata.analytics?.work?.dataSources.length) |
| 327 | || integration.engagement |
| 328 | || integration.browse |
| 329 | || integration.work |
| 330 | || integration.webhook, |
| 331 | ) |
| 332 | } |
| 333 | } |
| 334 |