Verify Mastodon posts have a favorite

This commit is contained in:
Johnny Gear 2026-03-14 16:17:41 -05:00
parent 0a1f5967af
commit cb6dd98ce1
14 changed files with 450 additions and 78 deletions

View file

@ -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()

View file

@ -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):

View file

@ -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
}
)

View file

@ -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')

View file

@ -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')

View file

@ -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,7 +67,9 @@ 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:
user = orders_pool.user
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" }
@ -76,9 +84,14 @@ async def order_issue(orders_pool):
orders_str = "\n".join(orders_info['orders'])
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)
if user.verify_mastodon_favorite:
verify_at = due_at + datetime.timedelta(hours=user.verify_delay)
repeats_count = orders_info.get('count', 0)
m_status = await order_mastodon_post(
@ -86,7 +99,8 @@ async def order_issue(orders_pool):
orders_pool,
orders_str,
repeats_count,
due_at
due_at,
verify_at=verify_at
)
await order_telegram_post(
@ -95,7 +109,8 @@ async def order_issue(orders_pool):
orders_str,
repeats_count,
due_at,
m_status['url']
m_status['url'],
verify_at=verify_at
)
return {
@ -105,7 +120,8 @@ async def order_issue(orders_pool):
m_status['id'],
created_at,
due_at,
orders_str
orders_str,
verify_at=verify_at
),
"mastodon_status": m_status
}
@ -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,

View file

@ -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,9 +92,11 @@ class OrderScheduler():
if 'order_status' in issue_result:
order_status = issue_result['order_status']
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(
order_status.due_at + GRACE_PERIOD,
check_time + GRACE_PERIOD,
self.scheduled_check,
args=(order_status.id,)
)

View file

@ -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,14 +158,29 @@ def authorized_sub(func):
return func(*args, **kwargs)
return wrapper
@api.route('/subs/<username>')
@api.route('/subs/<username>', methods=["GET", "POST"])
@login_required
@authorized_sub
def sub(username, sub):
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
"mastodon_username": sub.mastodon_username,
"verify_mastodon_favorite": sub.verify_mastodon_favorite,
"verify_delay": sub.verify_delay
})
@api.route('/orders/')

View file

@ -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:

View file

@ -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<UserProfile>();
const [opened, { open, close }] = useDisclosure(false);
const mastodon_account = React.useMemo(
@ -155,6 +159,13 @@ export const Profile: React.FC = () => {
</form>
</Fieldset>
</Paper>
{!has_doms ? (
<ProfileVerification
username={username}
verify_mastodon_favorite={verify_mastodon_favorite}
verify_delay={verify_delay}
/>
) : null}
</>
);
};

View file

@ -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<ProfileVerificationProps> = ({
username,
verify_mastodon_favorite,
verify_delay,
}) => {
const [loading, setLoading] = React.useState(false);
const form = useForm<OrderVerificationForm>({
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 (
<Paper bg="gray.1">
<form onSubmit={handleSubmit}>
<Title order={4} mb="md">
Order Verification
</Title>
<Checkbox
{...form.getInputProps("verify_mastodon_favorite", {
type: "checkbox",
})}
label="Posts must be Favorited by a dom or tagged account"
mb="md"
/>
<NumberInput
{...form.getInputProps("verify_delay")}
label="Verification Delay"
description="Hours to wait after order is due to verify post"
min={1}
max={168}
rightSection={<Text mr="xl">hours</Text>}
/>
<Button type="submit" loading={loading} mt="md">
Save
</Button>
</form>
</Paper>
);
};

View file

@ -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<OrderSetProps["orderSets"]>();
const [profile, setProfile] = React.useState<UserProfile | null>(null);
React.useEffect(() => {
fetch(`/api/subs/${sub_username}`)
.then((response) => response.json())
.then(setProfile);
}, [sub_username]);
return (
<>
<OrderSets
username={sub_username}
orderSets={orderSets}
linkBack={<Link to={`/dashboard/`}>Return to dashboard</Link>}
/>
{profile ? (
<>
<Title order={1} mb="md">
Sub Profile
</Title>
<ProfileVerification
username={sub_username}
verify_mastodon_favorite={profile.verify_mastodon_favorite}
verify_delay={profile.verify_delay}
/>
</>
) : null}
</>
);
};

View file

@ -78,7 +78,14 @@ export const TimelineList: React.FC<{
extra,
}) => (
<Timeline.Item key={id} active {...(TIMELINE_TYPE[type] ?? {})}>
<Text size="sm">{text}</Text>
<Text size="sm">
{text.split("\n").map((str) => (
<>
{str}
<br />
</>
))}
</Text>
<Flex mt={4} gap="xs">
{extra?.mastodon_status_url ? (
<Text size="xs">

View file

@ -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;