Save chat history in database.

This commit is contained in:
Ben Rog-Wilhelm 2023-08-27 09:27:34 -05:00
parent a3fbb9671c
commit 35530d927a
7 changed files with 123 additions and 53 deletions

1
chat/global.d.ts vendored
View file

@ -5,7 +5,6 @@ declare const process: {
declare interface IChatMessage { declare interface IChatMessage {
id: string; id: string;
username: string; username: string;
user_id?: string;
avatar: string; avatar: string;
text: string; text: string;
text_html: string; text_html: string;

View file

@ -39,7 +39,6 @@ export function ChatMessage({
}: ChatMessageProps) { }: ChatMessageProps) {
const { const {
id, id,
user_id,
avatar, avatar,
username, username,
text, text,
@ -77,11 +76,11 @@ export function ChatMessage({
const handleDeleteMessage = useCallback(() => { const handleDeleteMessage = useCallback(() => {
if (confirmedDelete) { if (confirmedDelete) {
deleteMessage(text); deleteMessage(id);
} else { } else {
setConfirmedDelete(true); setConfirmedDelete(true);
} }
}, [text, confirmedDelete]); }, [id, confirmedDelete]);
const handleQuoteMessageAction = useCallback(() => { const handleQuoteMessageAction = useCallback(() => {
quoteMessage(message); quoteMessage(message);

View file

@ -32,7 +32,7 @@ interface ChatProviderContext {
updateDraft: React.Dispatch<React.SetStateAction<string>>; updateDraft: React.Dispatch<React.SetStateAction<string>>;
sendMessage(): void; sendMessage(): void;
quoteMessage(message: null | IChatMessage): void; quoteMessage(message: null | IChatMessage): void;
deleteMessage(withText: string): void; deleteMessage(withId: string): void;
} }
const ChatContext = createContext<ChatProviderContext>({ const ChatContext = createContext<ChatProviderContext>({
@ -119,16 +119,16 @@ export function ChatProvider({ children }: PropsWithChildren) {
setDraft(""); setDraft("");
}, [draft, quote]); }, [draft, quote]);
const requestDeleteMessage = useCallback((withText: string) => { const requestDeleteMessage = useCallback((withId: string) => {
socket.current?.emit(ChatHandlers.DELETE, withText); socket.current?.emit(ChatHandlers.DELETE, withId);
}, []); }, []);
const deleteMessage = useCallback((withText: string) => { const deleteMessage = useCallback((withId: string) => {
setMessages((prev) => setMessages((prev) =>
prev.filter((prevMessage) => prevMessage.text !== withText) prev.filter((prevMessage) => prevMessage.id !== withId)
); );
if (quote?.text === withText) { if (quote?.id === withId) {
setQuote(null); setQuote(null);
} }
}, []); }, []);

View file

@ -56,6 +56,7 @@ from urllib.parse import urlencode, urlparse, parse_qs
from .alts import Alt from .alts import Alt
from .award import AwardRelationship from .award import AwardRelationship
from .badges import BadgeDef, Badge from .badges import BadgeDef, Badge
from .chat_message import ChatMessage
from .clients import OauthApp, ClientAuth from .clients import OauthApp, ClientAuth
from .comment import Comment from .comment import Comment
from .domains import BannedDomain from .domains import BannedDomain

View file

@ -0,0 +1,36 @@
from files.classes.base import CreatedDateTimeBase
from files.helpers.lazy import lazy
from sqlalchemy import *
from sqlalchemy.orm import declared_attr, relationship
class ChatMessage(CreatedDateTimeBase):
__tablename__ = "chat_message"
id = Column(Integer, primary_key=True)
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
quote_id = Column(Integer, ForeignKey("chat_message.id"), nullable=True)
text = Column(String, nullable=False)
text_html = Column(String, nullable=False)
author = relationship("User", primaryjoin="User.id==ChatMessage.author_id")
@declared_attr
def created_datetimez_index(self):
return Index('created_datetimez_index', self.created_datetimez)
Index('quote_index', quote_id)
@lazy
def json_speak(self):
data = {
'id': str(self.id),
'quotes': None if self.quote_id is None else str(self.quote_id),
'avatar': self.author.profile_url,
'username': self.author.username,
'text': self.text,
'text_html': self.text_html,
'time': int(self.created_datetimez.timestamp()),
}
return data

View file

@ -62,25 +62,35 @@ CHAT_SCROLLBACK_ITEMS: Final[int] = 500
typing: list[str] = [] typing: list[str] = []
online: list[str] = [] # right now we maintain this but don't actually use it anywhere online: list[str] = [] # right now we maintain this but don't actually use it anywhere
muted: dict[str, int] = cache.get(f'{SITE}_muted') or {}
messages: list[dict[str, Any]] = cache.get(f'{SITE}_chat') or []
total: int = cache.get(f'{SITE}_total') or 0
socket_ids_to_user_ids = {} socket_ids_to_user_ids = {}
user_ids_to_socket_ids = {} user_ids_to_socket_ids = {}
def send_system_reply(text): def send_system_reply(text):
data = { data = {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"quotes": [],
"avatar": g.db.query(User).filter(User.id == NOTIFICATIONS_ID).one().profile_url, "avatar": g.db.query(User).filter(User.id == NOTIFICATIONS_ID).one().profile_url,
"user_id": NOTIFICATIONS_ID, "user_id": NOTIFICATIONS_ID,
"username": "System", "username": "System",
"text": text, "text": text,
"text_html": sanitize(text), "text_html": sanitize(text),
"time": int(time.time()), 'time': int(self.created_datetimez.timestamp()),
} }
emit('speak', data) emit('speak', data)
def get_chat_messages():
# Query for the last visible chat messages
result = (g.db.query(ChatMessage)
.join(User, User.id == ChatMessage.author_id) # Join with the User table to fetch related user data
.order_by(ChatMessage.created_datetimez.desc())
.limit(CHAT_SCROLLBACK_ITEMS)
.all())
# Convert the list of ChatMessage objects into a list of dictionaries
# Also, most recent at the bottom, not the top.
messages = [item.json_speak() for item in result[::-1]]
return messages
def get_chat_userlist(): def get_chat_userlist():
# Query for the User.username column for users with chat_authorized == True # Query for the User.username column for users with chat_authorized == True
result = g.db.query(User.username).filter(User.chat_authorized == True).all() result = g.db.query(User.username).filter(User.chat_authorized == True).all()
@ -94,7 +104,7 @@ def get_chat_userlist():
@is_not_permabanned @is_not_permabanned
@chat_is_allowed() @chat_is_allowed()
def chat(v): def chat(v):
return render_template("chat.html", v=v, messages=messages) return render_template("chat.html", v=v, messages=get_chat_messages())
@socketio.on('speak') @socketio.on('speak')
@ -104,13 +114,6 @@ def speak(data, v):
limiter.check() limiter.check()
if v.is_banned: return '', 403 if v.is_banned: return '', 403
vname = v.username.lower()
if vname in muted and not v.admin_level >= PERMS['CHAT_BYPASS_MUTE']:
if time.time() < muted[vname]: return '', 403
else: del muted[vname]
global messages, total
text = sanitize_raw( text = sanitize_raw(
data['message'], data['message'],
allow_newlines=True, allow_newlines=True,
@ -132,27 +135,16 @@ def speak(data, v):
text_html = sanitize(text) text_html = sanitize(text)
quotes = data['quotes'] quotes = data['quotes']
data = {
"id": str(uuid.uuid4()),
"quotes": quotes,
"avatar": v.profile_url,
"user_id": v.id,
"username": v.username,
"text": text,
"text_html": text_html,
"time": int(time.time()),
}
if v.shadowbanned: chat_message = ChatMessage()
emit('speak', data) chat_message.author_id = v.id
else: chat_message.quote_id = quotes
emit('speak', data, broadcast=True) chat_message.text = text
messages.append(data) chat_message.text_html = text_html
messages = messages[-CHAT_SCROLLBACK_ITEMS:] g.db.add(chat_message)
g.db.commit()
total += 1 emit('speak', chat_message.json_speak(), broadcast=True)
chat_save()
@socketio.on('connect') @socketio.on('connect')
@ -166,7 +158,7 @@ def connect(v):
user_ids_to_socket_ids[v.id] = request.sid user_ids_to_socket_ids[v.id] = request.sid
emit('online', get_chat_userlist()) emit('online', get_chat_userlist())
emit('catchup', messages) emit('catchup', get_chat_messages())
emit('typing', typing) emit('typing', typing)
@ -198,17 +190,19 @@ def typing_indicator(data, v):
@socketio.on('delete') @socketio.on('delete')
@chat_is_allowed(PERMS['CHAT_MODERATION']) @chat_is_allowed(PERMS['CHAT_MODERATION'])
def delete(text, v): def delete(id, v):
for message in messages: chat_message = g.db.query(ChatMessage).filter(ChatMessage.id == id).one_or_none()
if message['text'] == text: if chat_message:
messages.remove(message) # Zero out all the quote_id references to this message
messages_quoting_this = g.db.query(ChatMessage).filter(ChatMessage.quote_id == id).all()
for message in messages_quoting_this:
message.quote_id = None
emit('delete', text, broadcast=True) # Now, delete the chat_message
g.db.delete(chat_message)
g.db.commit()
def chat_save(): emit('delete', id, broadcast=True)
cache.set(f'{SITE}_chat', messages)
cache.set(f'{SITE}_total', total)
cache.set(f'{SITE}_muted', muted)
@register_command('add', PERMS['CHAT_FULL_CONTROL']) @register_command('add', PERMS['CHAT_FULL_CONTROL'])
def add(user): def add(user):
@ -228,6 +222,7 @@ def add(user):
else: else:
send_system_reply(f"Could not find user {user}.") send_system_reply(f"Could not find user {user}.")
@register_command('remove', PERMS['CHAT_FULL_CONTROL']) @register_command('remove', PERMS['CHAT_FULL_CONTROL'])
def remove(user): def remove(user):
print("Removing user", user) print("Removing user", user)

View file

@ -0,0 +1,40 @@
"""add chat_message table
Revision ID: 850c47d647ba
Revises: c41b790058ad
Create Date: 2023-08-27 13:34:34.601539+00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '850c47d647ba'
down_revision = 'c41b790058ad'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('chat_message',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.Column('quote_id', sa.Integer(), nullable=True),
sa.Column('text', sa.String(), nullable=False),
sa.Column('text_html', sa.String(), nullable=False),
sa.Column('created_datetimez', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['quote_id'], ['chat_message.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('created_datetimez_index', 'chat_message', ['created_datetimez'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('created_datetimez_index', table_name='chat_message')
op.drop_table('chat_message')
# ### end Alembic commands ###