Files
ds-chat/backend/app/services/custom_emoji_service.py
T
ksmithandClaude Sonnet 5 2e84ca42b7 Add custom emoji support (#18)
Site-wide, any user can upload -- usable both as reactions and inline
in message text via :shortcode:, alongside the existing built-in
Unicode picker. A :shortcode: reference is stored/sent as literal
text (same as the built-in shortcode convention) and resolved to an
image at render time, so it degrades to plain text if the emoji is
later deleted.

Backend: new custom_emoji table (shortcode unique, sized to fit
MessageReaction.emoji's existing column alongside its colons), upload/
list/delete endpoints (delete restricted to uploader or site admin).

Frontend: a CustomEmojiProvider context feeds a new "Custom" category
in the emoji picker (inline upload + hover-to-remove), extends the
composer's shortcode autocomplete, and a shared EmojiGlyph resolver
renders custom emoji wherever a value can appear -- message text,
reaction pills, and the picker itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:47:49 -06:00

81 lines
2.3 KiB
Python

import re
import uuid
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import CustomEmoji, User
from app.storage import delete_file
# Deliberately stricter than the built-in Unicode shortcode set's charset
# (see frontend/src/lib/emojiShortcodes.ts, which also allows '+') -- this
# is validating a *new name being chosen*, not matching against an
# existing fixed list, so there's no reason to allow anything a person
# wouldn't naturally type. Max 30 chars matches CustomEmoji.shortcode's
# column width exactly (see that model's comment for why).
SHORTCODE_PATTERN = re.compile(r"^[a-z0-9_-]{2,30}$")
class InvalidShortcodeError(Exception):
pass
class DuplicateShortcodeError(Exception):
pass
class CustomEmojiNotFoundError(Exception):
pass
class NotEmojiOwnerError(Exception):
pass
async def create_custom_emoji(
db: AsyncSession,
uploaded_by: uuid.UUID,
shortcode: str,
storage_filename: str,
content_type: str,
) -> CustomEmoji:
if not SHORTCODE_PATTERN.match(shortcode):
raise InvalidShortcodeError()
emoji = CustomEmoji(
shortcode=shortcode,
storage_filename=storage_filename,
content_type=content_type,
uploaded_by=uploaded_by,
)
db.add(emoji)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise DuplicateShortcodeError() from exc
await db.refresh(emoji)
return emoji
async def list_custom_emoji(db: AsyncSession) -> list[CustomEmoji]:
result = await db.execute(select(CustomEmoji).order_by(CustomEmoji.shortcode))
return list(result.scalars().all())
async def get_custom_emoji_by_shortcode(db: AsyncSession, shortcode: str) -> CustomEmoji | None:
result = await db.execute(select(CustomEmoji).where(CustomEmoji.shortcode == shortcode))
return result.scalar_one_or_none()
async def delete_custom_emoji(db: AsyncSession, emoji_id: uuid.UUID, current_user: User) -> None:
emoji = await db.get(CustomEmoji, emoji_id)
if emoji is None:
raise CustomEmojiNotFoundError()
if emoji.uploaded_by != current_user.id and not current_user.is_site_admin:
raise NotEmojiOwnerError()
delete_file(emoji.storage_filename)
await db.delete(emoji)
await db.commit()