diff --git a/db/models.py b/db/models.py index c5e0351..2bbba87 100644 --- a/db/models.py +++ b/db/models.py @@ -36,6 +36,9 @@ class User(BaseModel): mastodon_attn_list = TextField(null=True) mastodon_post_public = BooleanField(null=True, default=False) + verify_mastodon_favorite = BooleanField(null=False, default=False) + verify_delay = IntegerField(null=True) + def mastodon_account(self): if self.mastodon_server is None or self.mastodon_username is None: return @@ -148,6 +151,7 @@ class OrderStatus(BaseModel): confirmed_at = DateTimeField(null=True) created_at = DateTimeField() due_at = DateTimeField(null=True) + verify_at = DateTimeField(null=True) mastodon_id = TextField() text = TextField() diff --git a/db/queries.py b/db/queries.py index 6cff581..4d75065 100644 --- a/db/queries.py +++ b/db/queries.py @@ -51,6 +51,9 @@ def user_preferences_set(id, mastodon_post_public, mastodon_attn_list): ) return q.execute() +def user_has_doms(id): + return DomSubUsers.select().where(DomSubUsers.sub_id == id).count() > 0 + def mastodon_server_get(name): return MastodonServer.get(name=name) @@ -91,6 +94,9 @@ def domsubusers_delete(sub, dom): def domsubusers_list(dom): return DomSubUsers.select().where(DomSubUsers.dom == dom) +def domsubusers_doms(sub): + return [dsu.dom for dsu in DomSubUsers.select(DomSubUsers.dom).where(DomSubUsers.sub_id == sub.id)] + def repeat_get(orders_pool_id): try: return Repeat.get(orders_pool_id = orders_pool_id) @@ -132,7 +138,7 @@ def skip_day_contains(user, date): q = SkipDay.select().where((SkipDay.user == user) & (SkipDay.date == date)) return len(q) > 0 -def order_status_put(orders_pool, user, mastodon_id, created_at, due_at, text, punishment_for=None): +def order_status_put(orders_pool, user, mastodon_id, created_at, due_at, text, punishment_for=None, verify_at=None): return OrderStatus.create( orders_pool_id=orders_pool.id, user_id=user.id, @@ -140,7 +146,8 @@ def order_status_put(orders_pool, user, mastodon_id, created_at, due_at, text, p created_at=created_at, due_at=due_at, text=text, - punishment_for=punishment_for + punishment_for=punishment_for, + verify_at=verify_at ) def order_status_by_id(order_status_id): diff --git a/mastodon.py b/mastodon.py index 1a9bcd6..6167d76 100644 --- a/mastodon.py +++ b/mastodon.py @@ -4,6 +4,7 @@ from settings import MASTODON_INSTANCE, MASTODON_ACCESS_TOKEN, MASTODON_VISIBILI API_STATUSES = '/api/v1/statuses' API_STATUS_CONTEXT = '/api/v1/statuses/%(id)s/context' +API_STATUS_FAVORITES = '/api/v1/statuses/%(id)s/favourited_by' logger = logging.getLogger(__name__) @@ -64,3 +65,11 @@ class Mastodon: 'id': id } ) + + async def statusFavorites(self, id): + return await self.get( + self.instance, + API_STATUS_FAVORITES % { + 'id' : id + } + ) diff --git a/migrations/025_user_add_verify_mastodon_favorite.py b/migrations/025_user_add_verify_mastodon_favorite.py new file mode 100644 index 0000000..453d907 --- /dev/null +++ b/migrations/025_user_add_verify_mastodon_favorite.py @@ -0,0 +1,50 @@ +"""Peewee migrations -- 025_user_add_verify_mastodon_favorite.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + migrator.add_fields( + 'user', + + verify_mastodon_favorite=pw.BooleanField(default=False), + verify_delay=pw.IntegerField(null=True)) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + migrator.remove_fields('user', 'verify_mastodon_favorite', 'verify_delay') diff --git a/migrations/026_order_status_verify_at.py b/migrations/026_order_status_verify_at.py new file mode 100644 index 0000000..9964ade --- /dev/null +++ b/migrations/026_order_status_verify_at.py @@ -0,0 +1,49 @@ +"""Peewee migrations -- 026_order_status_verify_at.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + migrator.add_fields( + 'order_status', + + verify_at=pw.DateTimeField(null=True)) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + migrator.remove_fields('order_status', 'verify_at') diff --git a/orders.py b/orders.py index 2581082..4cde582 100644 --- a/orders.py +++ b/orders.py @@ -5,7 +5,7 @@ import asyncio from db.constants import TIMELINE_ORDER_CONFIRMED, TIMELINE_ORDER_NOT_PUNISHED, TIMELINE_ORDER_PUNISHED from util import make_session from generate import generate_order, generate_punishment -from db.queries import order_status_by_id, order_status_put, order_status_confirm, timeline_event_put +from db.queries import domsubusers_doms, order_status_by_id, order_status_put, order_status_confirm, timeline_event_put from mastodon import Mastodon from telegram.telegram import Telegram from settings import ENV @@ -13,14 +13,17 @@ from util import timezone logger = logging.getLogger(__name__) -async def order_mastodon_post(session, orders_pool, orders_str, repeats, due_at): +async def order_mastodon_post(session, orders_pool, orders_str, repeats, due_at, verify_at=None): user = orders_pool.user post = "Here are today's orders for @%s -\n\n" % user.mastodon_account() post += orders_str + "\n\n" if repeats > 1: post += f"These are the same orders from the last {repeats} days\n\n" - post += "Proof of compliance is due by " + due_at.strftime("%I:%M %p") + "\n\n" + post += "Proof of compliance is due by " + due_at.strftime("%I:%M %p") + "\n" + if verify_at is not None: + post += "Verification due by " + verify_at.strftime("%I:%M %p") + "\n" + post += "\n" if ENV == 'dev': post += "⚠️ DEV" @@ -30,12 +33,15 @@ async def order_mastodon_post(session, orders_pool, orders_str, repeats, due_at) m = Mastodon(session) return await m.statusPost(post) -async def order_telegram_post(session, orders_pool, orders_str, repeats, due_at, m_url): +async def order_telegram_post(session, orders_pool, orders_str, repeats, due_at, m_url, verify_at=None): post = "Here are your orders -\n\n" post += orders_str + "\n\n" if repeats > 1: post += f"These are the same orders from the last {repeats} days\n\n" - post += "Proof of compliance is due by " + due_at.strftime("%I:%M %p") + "\n\n" + post += "Proof of compliance is due by " + due_at.strftime("%I:%M %p") + "\n" + if verify_at is not None: + post += "Verification due by " + verify_at.strftime("%I:%M %p") + "\n" + post += "\n" post += m_url if ENV == 'dev': post += "\n⚠️ DEV" @@ -61,54 +67,64 @@ async def order_telegram_post_none(session, orders_pool): async def order_issue(orders_pool): async with make_session() as session: - if orders_pool.user.mastodon_username is None: - logger.info('Cannot issue order without mastodon username') - await order_telegram_post_need_mastodon(session, orders_pool) - return { "reason": "Cannot issue order without mastodon username" } + user = orders_pool.user - orders_info = generate_order(orders_pool) + if user.mastodon_username is None: + logger.info('Cannot issue order without mastodon username') + await order_telegram_post_need_mastodon(session, orders_pool) + return { "reason": "Cannot issue order without mastodon username" } - if 'orders' not in orders_info: - logger.info(f"{orders_pool} - {orders_info['reason']}") - await order_telegram_post_none(session, orders_pool) - return { "reason": orders_info['reason'] } + orders_info = generate_order(orders_pool) - orders_str = "\n".join(orders_info['orders']) + if 'orders' not in orders_info: + logger.info(f"{orders_pool} - {orders_info['reason']}") + await order_telegram_post_none(session, orders_pool) + return { "reason": orders_info['reason'] } + + orders_str = "\n".join(orders_info['orders']) - created_at = datetime.datetime.now(tz=timezone()) - if orders_pool.confirm_delay is not None: - due_at = created_at + datetime.timedelta(hours=orders_pool.confirm_delay) + created_at = datetime.datetime.now(tz=timezone()) + due_at = None + verify_at = None + if orders_pool.confirm_delay is not None: + due_at = created_at + datetime.timedelta(hours=orders_pool.confirm_delay) - repeats_count = orders_info.get('count', 0) + if user.verify_mastodon_favorite: + verify_at = due_at + datetime.timedelta(hours=user.verify_delay) - m_status = await order_mastodon_post( - session, - orders_pool, - orders_str, - repeats_count, - due_at - ) + repeats_count = orders_info.get('count', 0) - await order_telegram_post( - session, - orders_pool, - orders_str, - repeats_count, - due_at, - m_status['url'] - ) + m_status = await order_mastodon_post( + session, + orders_pool, + orders_str, + repeats_count, + due_at, + verify_at=verify_at + ) - return { - "order_status" : order_status_put( - orders_pool, - orders_pool.user, - m_status['id'], - created_at, - due_at, - orders_str - ), - "mastodon_status": m_status - } + await order_telegram_post( + session, + orders_pool, + orders_str, + repeats_count, + due_at, + m_status['url'], + verify_at=verify_at + ) + + return { + "order_status" : order_status_put( + orders_pool, + orders_pool.user, + m_status['id'], + created_at, + due_at, + orders_str, + verify_at=verify_at + ), + "mastodon_status": m_status + } async def punishment_mastodon_post(session, orders_pool, punishment_str, reply_id=None): user = orders_pool.user @@ -174,11 +190,30 @@ async def punishment_issue(session, order_status): "mastodon_status": punishment_status } +async def status_has_favorites(m, status_id, user): + favorites = await m.statusFavorites(status_id) + user_mastodon_account = user.mastodon_account() + + accts = [f['acct'] for f in favorites] + + for dom in domsubusers_doms(user): + dom_account = dom.mastodon_account() + if dom_account is not None and dom_account in accts: + return True + + for attn_user in user.mastodon_attn_list.split(" "): + if (attn_user[1:] != user_mastodon_account and + attn_user[1:] in accts): + return True + + return False + order_check_lock = asyncio.Lock() async def order_check(order_status_id): async with order_check_lock: async with make_session() as session: order_status = order_status_by_id(order_status_id) + user = order_status.user if order_status.punishment.count() > 0: logger.info(f'Punishment already issued for {order_status.id}') @@ -188,12 +223,34 @@ async def order_check(order_status_id): context = await m.statusContext(order_status.mastodon_id) confirmed_at = None + had_replies = False + had_reply_on_time = False + had_media_attachment = False + had_favorites = False for d in context['descendants']: if ( d['in_reply_to_id'] == order_status.mastodon_id and - d['account']['acct'] == order_status.user.mastodon_account() and - len(d['media_attachments']) > 0 + d['account']['acct'] == order_status.user.mastodon_account() ): + had_replies = True + + if (datetime.datetime.fromisoformat(d['created_at']) < + datetime.datetime.fromisoformat(order_status.due_at)): + had_reply_on_time = True + else: + continue + + if len(d['media_attachments']) > 0: + had_media_attachment = True + else: + continue + + if user.verify_mastodon_favorite: + if await status_has_favorites(m, d['id'], order_status.user): + had_favorites = True + else: + continue + confirmed_at = d['created_at'] order_status_confirm(order_status.id, confirmed_at) logger.info('Confirmed order %s' % (order_status.id)) @@ -214,15 +271,27 @@ async def order_check(order_status_id): due_at = datetime.datetime.fromisoformat(order_status.due_at) if(due_at < datetime.datetime.now(datetime.UTC)): + reason = None + if had_replies is False: + reason = "No replies were found" + elif had_reply_on_time is False: + reason = "Reply was after due date" + elif had_media_attachment is False: + reason = "No replies had a media attachment" + elif had_favorites is False: + reason = "No replies had a favorite from a dom or tagged account" + logger.info('Time to issue a punishment for %s' % order_status.id) issue_result = await punishment_issue(session, order_status) if 'order_status' in issue_result: punishment_status = issue_result['order_status'] + log_text = (reason + "\n") if reason is not None else '' + log_text += punishment_status.text.split("\n")[0] timeline_event_put( TIMELINE_ORDER_PUNISHED, - punishment_status.text.split("\n")[0], + log_text, punishment_status.user, punishment_status.pool, punishment_status, diff --git a/scheduling.py b/scheduling.py index 1bad1ee..e48034b 100644 --- a/scheduling.py +++ b/scheduling.py @@ -28,8 +28,9 @@ class OrderScheduler(): self.schedule_pool(orders_pool) for order_status in order_status_outstanding(): + check_time = order_status.verify_at if order_status.verify_at is not None else order_status.due_at self.scheduler.once( - datetime.datetime.fromisoformat(order_status.due_at) + GRACE_PERIOD, + check_time + GRACE_PERIOD, self.scheduled_check, args=(order_status.id,) ) @@ -91,12 +92,14 @@ class OrderScheduler(): if 'order_status' in issue_result: order_status = issue_result['order_status'] - # Schedule check - self.scheduler.once( - order_status.due_at + GRACE_PERIOD, - self.scheduled_check, - args=(order_status.id,) - ) + if order_status.due_at is not None or order_status.verify_at is not None: + # Schedule check + check_time = order_status.verify_at if order_status.verify_at is not None else order_status.due_at + self.scheduler.once( + check_time + GRACE_PERIOD, + self.scheduled_check, + args=(order_status.id,) + ) timeline_event_put( TIMELINE_ORDER_ISSUED, order_status.text.split("\n")[0], diff --git a/web/api.py b/web/api.py index 95e1103..92e368c 100644 --- a/web/api.py +++ b/web/api.py @@ -6,7 +6,7 @@ from flask import Blueprint, jsonify, abort, request from flask_login import login_required, current_user from db.constants import TIMELINE_ORDERS_POOL_CREATED, TIMELINE_ORDERS_POOL_DELETED, TIMELINE_ORDERS_POOL_UPDATED from db.models import database, OrdersPool, Order, OrderAddOn, MastodonServer -from db.queries import timeline_event_put, timeline_event_recent, user_get, domsubusers_list, orders_pool_list, orders_pool, mastodon_server_get, mastodon_server_put, user_mastodon_server_set, user_preferences_set +from db.queries import timeline_event_put, timeline_event_recent, user_get, domsubusers_list, orders_pool_list, orders_pool, mastodon_server_get, mastodon_server_put, user_has_doms, user_mastodon_server_set, user_preferences_set from settings import MASTODON_OAUTH_CLIENT_NAME, MASTODON_OAUTH_REDIRECT_URI, MASTODON_OAUTH_SCOPES, MASTODON_OAUTH_CLIENT_WEBSITE from util import time_sqlite @@ -18,15 +18,24 @@ api = Blueprint('api', __name__) @login_required def me(): user = current_user.db_user + has_doms = user_has_doms(user.id) - return jsonify({ + result = { "username": user.telegram_username, "telegram_photo_url": user.telegram_photo_url, "mastodon_server": user.mastodon_server.name if user.mastodon_server else None, "mastodon_username": user.mastodon_username, "mastodon_attn_list": user.mastodon_attn_list, - "mastodon_post_public": user.mastodon_post_public - }) + "mastodon_post_public": user.mastodon_post_public, + "has_doms": has_doms + } + + + if not has_doms: + result["verify_mastodon_favorite"] = user.verify_mastodon_favorite + result["verify_delay"] = user.verify_delay + + return jsonify(result) @api.route("/profile", methods=["POST",]) @login_required @@ -149,15 +158,30 @@ def authorized_sub(func): return func(*args, **kwargs) return wrapper -@api.route('/subs/') +@api.route('/subs/', methods=["GET", "POST"]) @login_required @authorized_sub def sub(username, sub): - return jsonify({ - "username": sub.telegram_username, - "mastodon_server": sub.mastodon_server.name if sub.mastodon_server is not None else None, - "mastodon_username": sub.mastodon_username - }) + if user_has_doms(sub) and sub.id == current_user.db_user.id: + abort(403) + return + + if request.method == "POST": + sub.verify_mastodon_favorite = bool(request.json['verify_mastodon_favorite']) + if request.json['verify_delay'] is not None: + sub.verify_delay = int(request.json["verify_delay"]) + + sub.save() + + return ('', 204) + else: + return jsonify({ + "username": sub.telegram_username, + "mastodon_server": sub.mastodon_server.name if sub.mastodon_server is not None else None, + "mastodon_username": sub.mastodon_username, + "verify_mastodon_favorite": sub.verify_mastodon_favorite, + "verify_delay": sub.verify_delay + }) @api.route('/orders/') @login_required diff --git a/web/app.py b/web/app.py index 223d4a1..5a4907b 100644 --- a/web/app.py +++ b/web/app.py @@ -164,7 +164,8 @@ def login(): db_user.save() login_user(FlaskUser(db_user)) - except: + except Exception as e: + print(e) flash("Login failed. Please try again.") return redirect('/') else: diff --git a/web/vite/src/Profile.tsx b/web/vite/src/Profile.tsx index a0845f2..90fa37c 100644 --- a/web/vite/src/Profile.tsx +++ b/web/vite/src/Profile.tsx @@ -17,6 +17,7 @@ import { Link, useLoaderData } from "react-router"; import { useForm } from "@mantine/form"; import { notifications } from "@mantine/notifications"; import { fetchHeaders } from "./fetch"; +import { ProfileVerification } from "./ProfileVerification"; const RE_MASTODON_ACCOUNTS = /^(@(\w+)(@([\w\.]+))? )*(@\w+)(@[\w\.]+)?$/; @@ -63,10 +64,13 @@ export const profileLoader = async () => export const Profile: React.FC = () => { const { username, telegram_photo_url, mastodon_server, mastodon_username } = useUserContext(); - const { mastodon_attn_list, mastodon_post_public } = useLoaderData<{ - mastodon_attn_list?: string; - mastodon_post_public?: boolean; - }>(); + const { + has_doms, + mastodon_attn_list, + mastodon_post_public, + verify_mastodon_favorite, + verify_delay, + } = useLoaderData(); const [opened, { open, close }] = useDisclosure(false); const mastodon_account = React.useMemo( @@ -155,6 +159,13 @@ export const Profile: React.FC = () => { + {!has_doms ? ( + + ) : null} ); }; diff --git a/web/vite/src/ProfileVerification.tsx b/web/vite/src/ProfileVerification.tsx new file mode 100644 index 0000000..efdbf65 --- /dev/null +++ b/web/vite/src/ProfileVerification.tsx @@ -0,0 +1,103 @@ +import { + Button, + Checkbox, + NumberInput, + Paper, + Title, + Text, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import { fetchHeaders } from "./fetch"; +import React from "react"; + +type ProfileVerificationProps = Pick< + UserProfile, + "verify_mastodon_favorite" | "verify_delay" +> & { username: string }; + +type OrderVerificationForm = Pick< + UserProfile, + "verify_mastodon_favorite" | "verify_delay" +>; + +export const ProfileVerification: React.FC = ({ + username, + verify_mastodon_favorite, + verify_delay, +}) => { + const [loading, setLoading] = React.useState(false); + const form = useForm({ + mode: "uncontrolled", + initialValues: { + verify_mastodon_favorite, + verify_delay, + }, + validate: { + verify_delay: (value: number, values: OrderVerificationForm) => { + console.log("oh boy", values.verify_mastodon_favorite, value); + return !values.verify_mastodon_favorite || value + ? null + : "You must set a verification delay"; + }, + }, + }); + + const handleSubmit = React.useCallback( + form.onSubmit((values) => { + setLoading(true); + fetch(`/api/subs/${username}`, { + method: "POST", + headers: fetchHeaders(), + body: JSON.stringify(values), + }) + .then((response) => { + if (response.ok) { + notifications.show({ + title: "Success", + message: "Your preferences have been saved", + color: "green", + }); + } else { + notifications.show({ + title: "Error", + message: "There was a problem saving your preferences", + color: "red", + }); + } + }) + .finally(() => { + setLoading(false); + }); + }), + [], + ); + + return ( + +
+ + Order Verification + + + hours} + /> + + +
+ ); +}; diff --git a/web/vite/src/SubOrderSets.tsx b/web/vite/src/SubOrderSets.tsx index df246a4..64812b4 100644 --- a/web/vite/src/SubOrderSets.tsx +++ b/web/vite/src/SubOrderSets.tsx @@ -1,6 +1,8 @@ import React from "react"; import { Params, useLoaderData, useParams, Link } from "react-router"; import { OrderSetProps, OrderSets } from "./OrderSets"; +import { ProfileVerification } from "./ProfileVerification"; +import { Title } from "@mantine/core"; export const subOrderSetsLoader = async ({ params: { username }, @@ -12,11 +14,32 @@ export const SubOrderSets: React.FC = () => { const { username: sub_username } = useParams(); const orderSets = useLoaderData(); + const [profile, setProfile] = React.useState(null); + React.useEffect(() => { + fetch(`/api/subs/${sub_username}`) + .then((response) => response.json()) + .then(setProfile); + }, [sub_username]); + return ( - Return to dashboard} - /> + <> + Return to dashboard} + /> + {profile ? ( + <> + + Sub Profile + + + + ) : null} + ); }; diff --git a/web/vite/src/TimelineList.tsx b/web/vite/src/TimelineList.tsx index 4219278..50c32f3 100644 --- a/web/vite/src/TimelineList.tsx +++ b/web/vite/src/TimelineList.tsx @@ -78,7 +78,14 @@ export const TimelineList: React.FC<{ extra, }) => ( - {text} + + {text.split("\n").map((str) => ( + <> + {str} +
+ + ))} +
{extra?.mastodon_status_url ? ( diff --git a/web/vite/src/index.d.ts b/web/vite/src/index.d.ts index 006eecf..26f1c21 100644 --- a/web/vite/src/index.d.ts +++ b/web/vite/src/index.d.ts @@ -1,3 +1,15 @@ +type UserProfile = { + username: string; + telegram_photo_url?: string; + mastodon_server?: string; + mastodon_username?: string; + mastodon_attn_list?: string; + mastodon_post_public: boolean; + has_doms: boolean; + verify_mastodon_favorite?: boolean; + verify_delay?: number; +} + type OrderSetOrderAddOn = { id: number; name: string;