import pick from 'lodash.pick'; import * as v from 'valibot'; import { isDefaultAvatar, isDefaultHeader } from '../utils/accounts'; import { guessFqn } from '../utils/domain'; import { customEmojiSchema } from './custom-emoji'; import { relationshipSchema } from './relationship'; import { roleSchema } from './role'; import { coerceObject, datetimeSchema, filteredArray } from './utils'; const filterBadges = (tags?: string[]) => tags?.filter(tag => tag.startsWith('badge:')).map(tag => v.parse(roleSchema, { id: tag, name: tag.replace(/^badge:/, '') })); const MKLJCZK_ACCOUNTS = ['https://pl.fediverse.pl/users/mkljczk', 'https://gts.mkljczk.pl/users/mkljczk', 'https://gts.mkljczk.pl/@mkljczk']; const paymentOptionSchema = v.variant('type', [ v.object({ /** Payment type */ type: v.literal('link'), /** Link name (only for link type) */ name: v.fallback(v.nullable(v.string()), null), /** Link URL (only for link type) */ href: v.fallback(v.nullable(v.string()), null), /** Unique identifier of a proposal object */ object_id: v.fallback(v.nullable(v.string()), null), }), v.object({ /** Payment type */ type: v.literal('monero-subscription'), /** CAIP-2 chain ID (only for monero-subscription type) */ chain_id: v.fallback(v.nullable(v.string()), null), /** Subscription price (only for monero-subscription type) */ price: v.fallback(v.nullable(v.number()), null), /** Minimum payment amount (only for monero-subscription type) */ amount_min: v.fallback(v.nullable(v.number()), null), /** Unique identifier of a proposal object */ object_id: v.fallback(v.nullable(v.string()), null), }), ]); const preprocessAccount = v.transform((account: any) => { if (!account?.acct) return null; const username = account.username || account.acct.split('@')[0]; const fqn = account.fqn || guessFqn(account); const domain = fqn.split('@')[1] || ''; const isCat = (account.pleroma?.is_cat ?? account.is_cat) || MKLJCZK_ACCOUNTS.includes(account.uri ?? account.url); const speakAsCat = account.pleroma?.speak_as_cat ?? account.speak_as_cat ?? isCat; return { username, fqn, domain, avatar: account.avatar || account.avatar_static, header: account.header || account.header_static, avatar_default: isDefaultAvatar(account.avatar || account.avatar_static || ''), header_default: isDefaultHeader(account.header || account.header_static || ''), local: typeof account.pleroma?.is_local === 'boolean' ? account.pleroma.is_local : account.acct.split('@')[1] === undefined, discoverable: account.discoverable || account.pleroma?.source?.discoverable, verified: account.verified || account.pleroma?.tags?.includes('verified'), ...(account.role?.permissions ? { is_admin: (account.role?.permissions & 0x1) === 0x1, } : {}), ap_id: account.pleroma?.ap_id ?? account.actor_id, ...(pick(account.pleroma || {}, [ 'background_image', 'relationship', 'is_moderator', 'is_admin', 'hide_favorites', 'hide_followers', 'hide_follows', 'hide_followers_count', 'hide_follows_count', 'accepts_chat_messages', 'favicon', 'birthday', 'deactivated', 'avatar_description', 'header_description', 'settings_store', 'chat_token', 'allow_following_move', 'unread_conversation_count', 'unread_notifications_count', 'notification_settings', 'location', ])), ...(pick(account.akkoma || {}, [ 'permit_followback', ])), is_cat: isCat, speak_as_cat: speakAsCat, ...(pick(account.other_settings || {}), ['birthday', 'location']), __meta: pick(account, ['pleroma', 'source']), ...account, display_name: account.display_name?.replace(/^[\s\u180E\u200B-\u200D\u2060\uFEFF]+|[\s\u180E\u200B-\u200D\u2060\uFEFF]+$/g, '').trim() || username, roles: account.roles?.length ? account.roles : filterBadges(account.pleroma?.tags), source: account.source ? { ...(pick(account.pleroma?.source || {}, [ 'show_role', 'no_rich_text', 'discoverable', 'actor_type', 'show_birthday', ])), ...account.source } : undefined, }; }); const fieldSchema = v.object({ name: v.string(), value: v.string(), verified_at: v.fallback(v.nullable(datetimeSchema), null), }); const baseAccountSchema = v.object({ id: v.string(), username: v.fallback(v.string(), ''), acct: v.fallback(v.string(), ''), url: v.pipe(v.string(), v.url()), display_name: v.fallback(v.string(), ''), note: v.fallback(v.pipe(v.string(), v.transform(note => note === '

' ? '' : note)), ''), avatar: v.fallback(v.string(), ''), avatar_static: v.fallback(v.pipe(v.string(), v.url()), ''), header: v.fallback(v.pipe(v.string(), v.url()), ''), header_static: v.fallback(v.pipe(v.string(), v.url()), ''), locked: v.fallback(v.boolean(), false), fields: filteredArray(fieldSchema), emojis: filteredArray(customEmojiSchema), bot: v.fallback(v.boolean(), false), group: v.fallback(v.boolean(), false), discoverable: v.fallback(v.boolean(), false), indexable: v.fallback(v.nullable(v.boolean()), null), noindex: v.fallback(v.nullable(v.boolean()), null), memorial: v.fallback(v.nullable(v.boolean()), null), suspended: v.fallback(v.optional(v.boolean()), undefined), limited: v.fallback(v.optional(v.boolean()), undefined), created_at: v.fallback(datetimeSchema, new Date().toISOString()), last_status_at: v.fallback(v.nullable(v.pipe(v.string(), v.isoDate())), null), statuses_count: v.fallback(v.number(), 0), followers_count: v.fallback(v.number(), 0), following_count: v.fallback(v.number(), 0), roles: filteredArray(roleSchema), hide_collections: v.fallback(v.optional(v.boolean()), undefined), fqn: v.string(), ap_id: v.fallback(v.nullable(v.string()), null), background_image: v.fallback(v.nullable(v.string()), null), relationship: v.fallback(v.optional(relationshipSchema), undefined), is_moderator: v.fallback(v.optional(v.boolean()), undefined), is_admin: v.fallback(v.optional(v.boolean()), undefined), is_suggested: v.fallback(v.optional(v.boolean()), undefined), hide_favorites: v.fallback(v.boolean(), true), hide_followers: v.fallback(v.optional(v.boolean()), undefined), hide_follows: v.fallback(v.optional(v.boolean()), undefined), hide_followers_count: v.fallback(v.optional(v.boolean()), undefined), hide_follows_count: v.fallback(v.optional(v.boolean()), undefined), accepts_chat_messages: v.fallback(v.nullable(v.boolean()), null), favicon: v.fallback(v.optional(v.string()), undefined), birthday: v.fallback(v.optional(v.pipe(v.string(), v.isoDate())), undefined), deactivated: v.fallback(v.optional(v.boolean()), undefined), location: v.fallback(v.optional(v.string()), undefined), local: v.fallback(v.optional(v.boolean()), false), permit_followback: v.fallback(v.optional(v.boolean()), undefined), avatar_description: v.fallback(v.string(), ''), custom_css: v.fallback(v.string(), ''), enable_rss: v.fallback(v.boolean(), false), header_description: v.fallback(v.string(), ''), verified: v.fallback(v.optional(v.boolean()), undefined), domain: v.fallback(v.string(), ''), pronouns: v.fallback(v.array(v.string()), []), /** Mention policy */ mention_policy: v.fallback(v.picklist(['none', 'only_known', 'only_contacts']), 'none'), /** The reported subscribers of this user */ subscribers_count: v.fallback(v.number(), 0), /** Identity proofs */ identity_proofs: filteredArray(v.object({ /** The key of a given field's key-value pair */ name: v.fallback(v.string(), ''), /** The value associated with the name key */ value: v.fallback(v.string(), ''), /** Timestamp of when the server verified the field value */ verified_at: v.fallback(datetimeSchema, new Date().toISOString()), })), /** Payment options */ payment_options: filteredArray(paymentOptionSchema), /** Whether the user is a cat */ is_cat: v.fallback(v.boolean(), false), /** Whether the user's posts should be nyanified */ speak_as_cat: v.fallback(v.boolean(), false), __meta: coerceObject({ pleroma: v.optional(v.any(), undefined), source: v.optional(v.any(), undefined), }), avatar_default: v.fallback(v.boolean(), false), header_default: v.fallback(v.boolean(), false), }); const accountWithMovedAccountSchema = v.object({ ...baseAccountSchema.entries, moved: v.fallback(v.nullable(v.lazy((): typeof baseAccountSchema => accountWithMovedAccountSchema as any)), null), }); /** @see {@link https://docs.joinmastodon.org/entities/Account/} */ const untypedAccountSchema = v.pipe(v.any(), preprocessAccount, accountWithMovedAccountSchema); type WithMoved = { moved: Account | null; }; /** * @category Entity types */ type Account = v.InferOutput & WithMoved; /** * @category Schemas */ const accountSchema: v.BaseSchema> = untypedAccountSchema as any; const untypedCredentialAccountSchema = v.pipe(v.any(), preprocessAccount, v.object({ ...accountWithMovedAccountSchema.entries, source: v.fallback(v.nullable(coerceObject({ attribution_domains: v.fallback(v.optional(v.nullable(v.array(v.string()))), null), note: v.fallback(v.optional(v.string()), ''), fields: v.fallback(v.optional(filteredArray(fieldSchema)), []), privacy: v.fallback(v.optional(v.picklist(['public', 'unlisted', 'private', 'direct'])), 'public'), sensitive: v.fallback(v.optional(v.boolean()), false), language: v.fallback(v.optional(v.nullable(v.string())), null), follow_requests_count: v.fallback(v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))), 0), hide_collections: v.fallback(v.optional(v.boolean()), undefined), discoverable: v.fallback(v.optional(v.boolean()), undefined), indexable: v.fallback(v.nullable(v.boolean()), null), quote_policy: v.fallback(v.nullable(v.picklist(['public', 'followers', 'nobody'])), null), show_role: v.fallback(v.optional(v.nullable(v.boolean())), undefined), no_rich_text: v.fallback(v.optional(v.nullable(v.boolean())), undefined), actor_type: v.fallback(v.optional(v.string()), undefined), show_birthday: v.fallback(v.optional(v.boolean()), undefined), also_known_as_uris: v.fallback(v.optional(v.array(v.string())), undefined), status_content_type: v.fallback(v.optional(v.string()), undefined), web_layout: v.fallback(v.optional(v.picklist(['microblog', 'gallery'])), undefined), web_visibility: v.fallback(v.optional(v.picklist(['public', 'unlisted', 'none'])), undefined), })), null), role: v.fallback(v.nullable(roleSchema), null), settings_store: v.fallback(v.optional(v.record(v.string(), v.any())), undefined), chat_token: v.fallback(v.optional(v.string()), undefined), allow_following_move: v.fallback(v.optional(v.boolean()), undefined), unread_conversation_count: v.fallback(v.optional(v.number()), undefined), unread_notifications_count: v.fallback(v.optional(v.number()), undefined), notification_settings: v.fallback(v.optional(v.object({ block_from_strangers: v.fallback(v.boolean(), false), hide_notification_contents: v.fallback(v.boolean(), false), })), undefined), })); /** * @category Entity types */ type CredentialAccount = v.InferOutput & WithMoved; /** * @category Schemas */ const credentialAccountSchema: v.BaseSchema> = untypedCredentialAccountSchema as any; const untypedBlockedAccountSchema = v.pipe(v.any(), preprocessAccount, v.object({ ...accountWithMovedAccountSchema.entries, block_expires_at: v.fallback(v.nullable(datetimeSchema), null), })); /** * @category Entity types */ type BlockedAccount = v.InferOutput & WithMoved; /** * @category Schemas */ const blockedAccountSchema: v.BaseSchema> = untypedBlockedAccountSchema as any; const untypedMutedAccountSchema = v.pipe(v.any(), preprocessAccount, v.object({ ...accountWithMovedAccountSchema.entries, mute_expires_at: v.fallback(v.nullable(datetimeSchema), null), })); /** * @category Entity types */ type MutedAccount = v.InferOutput & WithMoved; /** * @category Schemas */ const mutedAccountSchema: v.BaseSchema> = untypedMutedAccountSchema as any; export { accountSchema, credentialAccountSchema, blockedAccountSchema, mutedAccountSchema, type Account, type CredentialAccount, type BlockedAccount, type MutedAccount, };