api: Add event logging via webhook.
This commit is contained in:
@@ -1,4 +1,8 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
import base64
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from data import Registry, RowModel, Table, RegisterEnum
|
from data import Registry, RowModel, Table, RegisterEnum
|
||||||
from data.columns import Integer, String, Timestamp, Column
|
from data.columns import Integer, String, Timestamp, Column
|
||||||
|
|
||||||
@@ -9,6 +13,52 @@ class EventType(Enum):
|
|||||||
CHEER = 'cheer',
|
CHEER = 'cheer',
|
||||||
PLAIN = 'plain',
|
PLAIN = 'plain',
|
||||||
|
|
||||||
|
def info(self):
|
||||||
|
if self is EventType.SUBSCRIBER:
|
||||||
|
info = EventTypeInfo(
|
||||||
|
EventType.SUBSCRIBER,
|
||||||
|
DataModel.subscriber_events,
|
||||||
|
("tier", "subscribed_length", "message"),
|
||||||
|
("tier", "subscribed_length", "message"),
|
||||||
|
('subscriber_tier', 'subscriber_length', 'subscriber_message'),
|
||||||
|
)
|
||||||
|
elif self is EventType.RAID:
|
||||||
|
info = EventTypeInfo(
|
||||||
|
EventType.RAID,
|
||||||
|
DataModel.raid_events,
|
||||||
|
('visitor_count',),
|
||||||
|
('viewer_count',),
|
||||||
|
('raid_visitor_count',),
|
||||||
|
)
|
||||||
|
elif self is EventType.CHEER:
|
||||||
|
info = EventTypeInfo(
|
||||||
|
EventType.CHEER,
|
||||||
|
DataModel.cheer_events,
|
||||||
|
('amount', 'cheer_type', 'message'),
|
||||||
|
('amount', 'cheer_type', 'message'),
|
||||||
|
('cheer_amount', 'cheer_type', 'cheer_message'),
|
||||||
|
)
|
||||||
|
elif self is EventType.PLAIN:
|
||||||
|
info = EventTypeInfo(
|
||||||
|
EventType.PLAIN,
|
||||||
|
DataModel.plain_events,
|
||||||
|
('message',),
|
||||||
|
('message',),
|
||||||
|
('plain_message',),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("Unexpected event type.")
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class EventTypeInfo(NamedTuple):
|
||||||
|
typ: EventType
|
||||||
|
table: Table
|
||||||
|
columns: tuple[str, ...]
|
||||||
|
params: tuple[str, ...]
|
||||||
|
detailcolumns: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
class DataModel(Registry):
|
class DataModel(Registry):
|
||||||
_EventType = RegisterEnum(EventType, 'EventType')
|
_EventType = RegisterEnum(EventType, 'EventType')
|
||||||
@@ -118,6 +168,14 @@ class DataModel(Registry):
|
|||||||
metadata = String()
|
metadata = String()
|
||||||
created_at = Timestamp()
|
created_at = Timestamp()
|
||||||
|
|
||||||
|
def to_bytes(self):
|
||||||
|
"""
|
||||||
|
Helper method to decode the saved document data to a byte string.
|
||||||
|
This may fail if the saved string is not base64 encoded.
|
||||||
|
"""
|
||||||
|
byts = BytesIO(base64.b64decode(self.document_data))
|
||||||
|
return byts
|
||||||
|
|
||||||
class DocumentStamp(RowModel):
|
class DocumentStamp(RowModel):
|
||||||
"""
|
"""
|
||||||
Schema
|
Schema
|
||||||
|
|||||||
@@ -4,14 +4,17 @@
|
|||||||
- `/documents/{document_id}/stamps` which is passed to `/stamps` with `document_id` set.
|
- `/documents/{document_id}/stamps` which is passed to `/stamps` with `document_id` set.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import binascii
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, NamedTuple, Optional, Self, TypedDict, Unpack, reveal_type, List
|
from typing import Any, NamedTuple, Optional, Self, TypedDict, Unpack, reveal_type, List
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
import discord
|
||||||
from data import Condition, condition
|
from data import Condition, condition
|
||||||
from data.queries import JOINTYPE
|
from data.queries import JOINTYPE
|
||||||
from datamodels import DataModel
|
from datamodels import DataModel
|
||||||
|
from utils.lib import MessageArgs, tabulate
|
||||||
|
|
||||||
from .lib import ModelField, datamodelsv
|
from .lib import ModelField, datamodelsv, event_log
|
||||||
from .stamps import Stamp, StampCreateParams, StampEditParams, StampPayload
|
from .stamps import Stamp, StampCreateParams, StampEditParams, StampPayload
|
||||||
|
|
||||||
routes = web.RouteTableDef()
|
routes = web.RouteTableDef()
|
||||||
@@ -153,12 +156,76 @@ class Document:
|
|||||||
stamp = await Stamp.create(app, **stampdata)
|
stamp = await Stamp.create(app, **stampdata)
|
||||||
stamps.append(stamp)
|
stamps.append(stamp)
|
||||||
|
|
||||||
return cls(app, row)
|
self = cls(app, row)
|
||||||
|
# await self.log_create()
|
||||||
|
return self
|
||||||
|
|
||||||
async def get_stamps(self) -> List[Stamp]:
|
async def get_stamps(self) -> List[Stamp]:
|
||||||
stamprows = await self.data.DocumentStamp.table.fetch_rows_where(document_id=self.row.document_id).order_by('stamp_id')
|
stamprows = await self.data.DocumentStamp.table.fetch_rows_where(document_id=self.row.document_id).order_by('stamp_id')
|
||||||
return [Stamp(self.app, row) for row in stamprows]
|
return [Stamp(self.app, row) for row in stamprows]
|
||||||
|
|
||||||
|
async def log_create(self, with_image=True):
|
||||||
|
args = await self.event_log_args(with_image=with_image)
|
||||||
|
args.kwargs['embed'].title = f"Document #{self.row.document_id} Created!"
|
||||||
|
try:
|
||||||
|
await event_log(**args.send_args)
|
||||||
|
except discord.HTTPException:
|
||||||
|
if with_image:
|
||||||
|
# Try again without the image in case that was the issue
|
||||||
|
await self.log_create(with_image=False)
|
||||||
|
|
||||||
|
async def log_edit(self, with_image=True):
|
||||||
|
args = await self.event_log_args(with_image=with_image)
|
||||||
|
args.kwargs['embed'].title = f"Document #{self.row.document_id} Updated!"
|
||||||
|
try:
|
||||||
|
await event_log(**args.send_args)
|
||||||
|
except discord.HTTPException:
|
||||||
|
if with_image:
|
||||||
|
# Try again without the image in case that was the issue
|
||||||
|
await self.log_create(with_image=False)
|
||||||
|
|
||||||
|
async def event_log_args(self, with_image=True) -> MessageArgs:
|
||||||
|
desc = '\n'.join(await self.tabulate())
|
||||||
|
embed = discord.Embed(description=desc, timestamp=self.row.created_at)
|
||||||
|
embed.set_footer(text='Created At')
|
||||||
|
args: dict = {'embed': embed}
|
||||||
|
|
||||||
|
if with_image:
|
||||||
|
try:
|
||||||
|
imagedata = self.row.to_bytes()
|
||||||
|
imagedata.seek(0)
|
||||||
|
embed.set_image(url='attachment://document.png')
|
||||||
|
args['files'] = [discord.File(imagedata, "document.png")]
|
||||||
|
except binascii.Error:
|
||||||
|
# Could not decode base64
|
||||||
|
embed.add_field(name='Image', value="Could not decode document data!")
|
||||||
|
else:
|
||||||
|
embed.add_field(name='Image', value="Failed to send image!")
|
||||||
|
return MessageArgs(**args)
|
||||||
|
|
||||||
|
async def tabulate(self):
|
||||||
|
"""
|
||||||
|
Present the Document as a discord-readable table.
|
||||||
|
"""
|
||||||
|
stamps = await self.get_stamps()
|
||||||
|
typnames = []
|
||||||
|
if stamps:
|
||||||
|
typs = {stamp.row.stamp_type for stamp in stamps}
|
||||||
|
for typ in typs:
|
||||||
|
# Stamp types should be cached so this isn't expensive
|
||||||
|
typrow = await self.data.StampType.fetch(typ)
|
||||||
|
typnames.append(typrow.stamp_type_name)
|
||||||
|
|
||||||
|
table = {
|
||||||
|
'document_id': f"`{self.row.document_id}`",
|
||||||
|
'seal': str(self.row.seal),
|
||||||
|
'metadata': f"`{self.row.metadata}`" if self.row.metadata else "No metadata",
|
||||||
|
'stamps': ', '.join(f"`{name}`" for name in typnames) if typnames else "No stamps",
|
||||||
|
'created_at': discord.utils.format_dt(self.row.created_at, 'F'),
|
||||||
|
}
|
||||||
|
return tabulate(*table.items())
|
||||||
|
|
||||||
|
|
||||||
async def prepare(self) -> DocPayload:
|
async def prepare(self) -> DocPayload:
|
||||||
stamps = await self.get_stamps()
|
stamps = await self.get_stamps()
|
||||||
|
|
||||||
@@ -192,6 +259,7 @@ class Document:
|
|||||||
for stampdata in new_stamps:
|
for stampdata in new_stamps:
|
||||||
stampdata.setdefault('document_id', row.document_id)
|
stampdata.setdefault('document_id', row.document_id)
|
||||||
await Stamp.create(self.app, **stampdata)
|
await Stamp.create(self.app, **stampdata)
|
||||||
|
await self.log_edit()
|
||||||
|
|
||||||
async def delete(self) -> DocPayload:
|
async def delete(self) -> DocPayload:
|
||||||
payload = await self.prepare()
|
payload = await self.prepare()
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import binascii
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List
|
from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
import discord
|
||||||
from data import Condition, condition
|
from data import Condition, condition
|
||||||
from data.conditions import NULL
|
from data.conditions import NULL
|
||||||
from data.queries import JOINTYPE
|
from data.queries import JOINTYPE
|
||||||
from datamodels import DataModel, EventType
|
from datamodels import DataModel, EventType
|
||||||
|
|
||||||
from modules.profiles.data import ProfileData
|
from modules.profiles.data import ProfileData
|
||||||
|
from utils.lib import MessageArgs, tabulate
|
||||||
|
|
||||||
from .lib import ModelField, datamodelsv, dbvar, profiledatav
|
from .lib import ModelField, datamodelsv, dbvar, event_log, profiledatav
|
||||||
from .specimens import Specimen, SpecimenPayload
|
from .specimens import Specimen, SpecimenPayload
|
||||||
|
|
||||||
routes = web.RouteTableDef()
|
routes = web.RouteTableDef()
|
||||||
@@ -196,8 +199,76 @@ class Event:
|
|||||||
|
|
||||||
details = await data.EventDetails.fetch(eventrow.event_id)
|
details = await data.EventDetails.fetch(eventrow.event_id)
|
||||||
assert details is not None
|
assert details is not None
|
||||||
return cls(app, details)
|
self = cls(app, details)
|
||||||
|
await self.log_create()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def log_create(self, with_image=True):
|
||||||
|
args = await self.event_log_args(with_image=with_image)
|
||||||
|
args.kwargs['embed'].title = f"Event #{self.row.event_id} Created!"
|
||||||
|
try:
|
||||||
|
await event_log(**args.send_args)
|
||||||
|
except discord.HTTPException:
|
||||||
|
if with_image:
|
||||||
|
# Try again without the image in case that was the issue
|
||||||
|
await self.log_create(with_image=False)
|
||||||
|
|
||||||
|
async def log_edit(self, with_image=True):
|
||||||
|
args = await self.event_log_args(with_image=with_image)
|
||||||
|
args.kwargs['embed'].title = f"Event #{self.row.event_id} Updated!"
|
||||||
|
try:
|
||||||
|
await event_log(**args.send_args)
|
||||||
|
except discord.HTTPException:
|
||||||
|
if with_image:
|
||||||
|
# Try again without the image in case that was the issue
|
||||||
|
await self.log_create(with_image=False)
|
||||||
|
|
||||||
|
async def event_log_args(self, with_image=True) -> MessageArgs:
|
||||||
|
desc = '\n'.join(await self.tabulate())
|
||||||
|
embed = discord.Embed(description=desc, timestamp=self.row.created_at)
|
||||||
|
embed.set_footer(text='Created At')
|
||||||
|
args: dict = {'embed': embed}
|
||||||
|
|
||||||
|
doc = await self.get_document()
|
||||||
|
if doc is not None:
|
||||||
|
embed.add_field(
|
||||||
|
name="Document",
|
||||||
|
value='\n'.join(await doc.tabulate()),
|
||||||
|
inline=False
|
||||||
|
)
|
||||||
|
if with_image:
|
||||||
|
try:
|
||||||
|
imagedata = doc.row.to_bytes()
|
||||||
|
imagedata.seek(0)
|
||||||
|
embed.set_image(url='attachment://document.png')
|
||||||
|
args['files'] = [discord.File(imagedata, "document.png")]
|
||||||
|
except binascii.Error:
|
||||||
|
# Could not decode base64
|
||||||
|
embed.add_field(name='Image', value="Could not decode document data!")
|
||||||
|
else:
|
||||||
|
embed.add_field(name='Image', value="Failed to send image!")
|
||||||
|
return MessageArgs(**args)
|
||||||
|
|
||||||
|
async def tabulate(self):
|
||||||
|
"""
|
||||||
|
Present the Event as a discord-readable table.
|
||||||
|
"""
|
||||||
|
user = await self.get_user()
|
||||||
|
assert user is not None
|
||||||
|
|
||||||
|
table = {
|
||||||
|
'event_id': f"`{self.row.event_id}`",
|
||||||
|
'event_type': f"`{self.row.event_type}`",
|
||||||
|
'user': f"`{self.row.user_id}` (`{self.row.user_name}`)",
|
||||||
|
'document': f"`{self.row.document_id}`",
|
||||||
|
'occurred_at': discord.utils.format_dt(self.row.occurred_at, 'F'),
|
||||||
|
'created_at': discord.utils.format_dt(self.row.created_at, 'F'),
|
||||||
|
}
|
||||||
|
info = self.row.event_type.info()
|
||||||
|
for col, param in zip(info.detailcolumns, info.params):
|
||||||
|
value = getattr(self.row, col)
|
||||||
|
table[param] = f"`{value}`"
|
||||||
|
return tabulate(*table.items())
|
||||||
|
|
||||||
async def edit(self, **kwargs):
|
async def edit(self, **kwargs):
|
||||||
data = self.data
|
data = self.data
|
||||||
@@ -230,6 +301,7 @@ class Event:
|
|||||||
if typparams:
|
if typparams:
|
||||||
await typtab.update_where(event_id=self.row.event_id).set(**typparams)
|
await typtab.update_where(event_id=self.row.event_id).set(**typparams)
|
||||||
|
|
||||||
|
await self.log_edit()
|
||||||
await self.row.refresh()
|
await self.row.refresh()
|
||||||
|
|
||||||
async def delete(self):
|
async def delete(self):
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from typing import NamedTuple, Any, Optional, Self, Unpack, List, TypedDict
|
from typing import NamedTuple, Any, Optional, Self, Unpack, List, TypedDict
|
||||||
from aiohttp import web
|
from aiohttp import web, ClientSession
|
||||||
|
from discord import Webhook
|
||||||
|
|
||||||
from data.database import Database
|
from data.database import Database
|
||||||
from datamodels import DataModel
|
from datamodels import DataModel
|
||||||
from modules.profiles.data import ProfileData
|
from modules.profiles.data import ProfileData
|
||||||
|
from meta import conf
|
||||||
|
|
||||||
dbvar = web.AppKey("database", Database)
|
dbvar = web.AppKey("database", Database)
|
||||||
datamodelsv = web.AppKey("datamodels", DataModel)
|
datamodelsv = web.AppKey("datamodels", DataModel)
|
||||||
@@ -16,3 +18,12 @@ class ModelField(NamedTuple):
|
|||||||
required: bool
|
required: bool
|
||||||
can_create: bool
|
can_create: bool
|
||||||
can_edit: bool
|
can_edit: bool
|
||||||
|
|
||||||
|
|
||||||
|
async def event_log(*args, **kwargs):
|
||||||
|
# Post the given message to the configured event log, if set
|
||||||
|
event_log_url = conf.api.get('EVENTLOG')
|
||||||
|
if event_log_url:
|
||||||
|
async with ClientSession() as session:
|
||||||
|
webhook = Webhook.from_url(event_log_url, session=session)
|
||||||
|
await webhook.send(**kwargs)
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List
|
from typing import Any, Literal, NamedTuple, Optional, Self, TypedDict, Unpack, overload, reveal_type, List
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
import discord
|
||||||
from data import Condition, condition
|
from data import Condition, condition
|
||||||
from data.conditions import NULL
|
from data.conditions import NULL
|
||||||
from data.queries import JOINTYPE
|
from data.queries import JOINTYPE
|
||||||
from datamodels import DataModel
|
from datamodels import DataModel
|
||||||
|
|
||||||
from modules.profiles.data import ProfileData
|
from modules.profiles.data import ProfileData
|
||||||
|
from utils.lib import MessageArgs, tabulate
|
||||||
|
|
||||||
from .lib import ModelField, datamodelsv, dbvar, profiledatav
|
from .lib import ModelField, datamodelsv, dbvar, event_log, profiledatav
|
||||||
from .specimens import Specimen, SpecimenPayload
|
from .specimens import Specimen, SpecimenPayload
|
||||||
|
|
||||||
routes = web.RouteTableDef()
|
routes = web.RouteTableDef()
|
||||||
@@ -141,6 +143,7 @@ class User:
|
|||||||
raise web.HTTPBadRequest(text="Invalid 'twitch_id' passed to user creation!")
|
raise web.HTTPBadRequest(text="Invalid 'twitch_id' passed to user creation!")
|
||||||
|
|
||||||
# First check if the profile already exists by querying the Dreamer database
|
# First check if the profile already exists by querying the Dreamer database
|
||||||
|
edited = 0 # 0 means not edited, 1 means created, 2 means modified
|
||||||
rows = await data.Dreamer.fetch_where(twitch_id=twitch_id)
|
rows = await data.Dreamer.fetch_where(twitch_id=twitch_id)
|
||||||
if rows:
|
if rows:
|
||||||
logger.debug(f"Updating Dreamer for {twitch_id=} with {kwargs}")
|
logger.debug(f"Updating Dreamer for {twitch_id=} with {kwargs}")
|
||||||
@@ -150,6 +153,7 @@ class User:
|
|||||||
if dreamer.preferences is None and dreamer.name is None:
|
if dreamer.preferences is None and dreamer.name is None:
|
||||||
await data.UserPreferences.fetch_or_create(dreamer.user_id, twitch_name=name, preferences=prefs)
|
await data.UserPreferences.fetch_or_create(dreamer.user_id, twitch_name=name, preferences=prefs)
|
||||||
dreamer = await dreamer.refresh()
|
dreamer = await dreamer.refresh()
|
||||||
|
edited = 2
|
||||||
|
|
||||||
# Now compare the existing data against the provided data and update if needed
|
# Now compare the existing data against the provided data and update if needed
|
||||||
if name != dreamer.name:
|
if name != dreamer.name:
|
||||||
@@ -159,6 +163,7 @@ class User:
|
|||||||
q.set(preferences=prefs)
|
q.set(preferences=prefs)
|
||||||
await q
|
await q
|
||||||
dreamer = await dreamer.refresh()
|
dreamer = await dreamer.refresh()
|
||||||
|
edited = 2
|
||||||
else:
|
else:
|
||||||
# Create from scratch
|
# Create from scratch
|
||||||
logger.info(f"Creating Dreamer for {twitch_id=} with {kwargs}")
|
logger.info(f"Creating Dreamer for {twitch_id=} with {kwargs}")
|
||||||
@@ -176,8 +181,16 @@ class User:
|
|||||||
)
|
)
|
||||||
dreamer = await data.Dreamer.fetch(user_profile.profileid)
|
dreamer = await data.Dreamer.fetch(user_profile.profileid)
|
||||||
assert dreamer is not None
|
assert dreamer is not None
|
||||||
|
edited = 1
|
||||||
|
|
||||||
return cls(app, dreamer)
|
self = cls(app, dreamer)
|
||||||
|
if edited == 1:
|
||||||
|
args = await self.event_log_args(title=f"User #{dreamer.user_id} created!")
|
||||||
|
await event_log(**args.send_args)
|
||||||
|
elif edited == 2:
|
||||||
|
args = await self.event_log_args(title=f"User #{dreamer.user_id} updated!")
|
||||||
|
await event_log(**args.send_args)
|
||||||
|
return self
|
||||||
|
|
||||||
async def edit(self, **kwargs: Unpack[UserEditParams]):
|
async def edit(self, **kwargs: Unpack[UserEditParams]):
|
||||||
data = self.data
|
data = self.data
|
||||||
@@ -193,6 +206,9 @@ class User:
|
|||||||
logger.info(f"Updating dreamer {self.row=} with {kwargs}")
|
logger.info(f"Updating dreamer {self.row=} with {kwargs}")
|
||||||
await prefs.update(**update_args)
|
await prefs.update(**update_args)
|
||||||
|
|
||||||
|
args = await self.event_log_args(title=f"User #{self.row.user_id} updated!")
|
||||||
|
await event_log(**args.send_args)
|
||||||
|
|
||||||
async def delete(self) -> UserDetailsPayload:
|
async def delete(self) -> UserDetailsPayload:
|
||||||
payload = await self.prepare(details=True)
|
payload = await self.prepare(details=True)
|
||||||
# This will cascade to all other data the user has
|
# This will cascade to all other data the user has
|
||||||
@@ -225,6 +241,28 @@ class User:
|
|||||||
async def get_inventory(self):
|
async def get_inventory(self):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def event_log_args(self, **kwargs) -> MessageArgs:
|
||||||
|
desc = '\n'.join(await self.tabulate())
|
||||||
|
embed = discord.Embed(description=desc, timestamp=self.row.created_at, **kwargs)
|
||||||
|
embed.set_footer(text='Created At')
|
||||||
|
|
||||||
|
# TODO: We could add wallet, specimen, and inventory info here too
|
||||||
|
return MessageArgs(embed=embed)
|
||||||
|
|
||||||
|
async def tabulate(self):
|
||||||
|
"""
|
||||||
|
Present the User as a discord-readable table.
|
||||||
|
"""
|
||||||
|
table = {
|
||||||
|
'user_id': f"`{self.row.user_id}`",
|
||||||
|
'twitch_id': f"`{self.row.twitch_id}`" if self.row.twitch_id else 'No Twitch linked',
|
||||||
|
'name': f"`{self.row.name}`",
|
||||||
|
'preferences': f"`{self.row.preferences}`",
|
||||||
|
'created_at': discord.utils.format_dt(self.row.created_at, 'F'),
|
||||||
|
}
|
||||||
|
return tabulate(*table.items())
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
async def prepare(self, details: Literal[True]=True) -> UserDetailsPayload:
|
async def prepare(self, details: Literal[True]=True) -> UserDetailsPayload:
|
||||||
...
|
...
|
||||||
|
|||||||
Reference in New Issue
Block a user