Render attachments and embeds

This commit is contained in:
rxdn 2024-09-15 15:43:39 +01:00
parent 89a5ea3b15
commit 3a7e140314
4 changed files with 368 additions and 87 deletions

View File

@ -2,18 +2,18 @@ package api
import (
"context"
"errors"
"fmt"
"github.com/TicketsBot/GoPanel/botcontext"
"github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc/cache"
dbclient "github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/database"
"github.com/gin-gonic/gin"
cache2 "github.com/rxdn/gdl/cache"
"github.com/rxdn/gdl/objects/channel"
"github.com/rxdn/gdl/objects/channel/embed"
"github.com/rxdn/gdl/objects/user"
"github.com/rxdn/gdl/rest"
"regexp"
"strconv"
"strings"
"time"
)
var MentionRegex, _ = regexp.Compile("<@(\\d+)>")
@ -41,7 +41,7 @@ func GetTicket(ctx *gin.Context) {
}
// Get the ticket struct
ticket, err := database.Client.Tickets.Get(ctx, ticketId, guildId)
ticket, err := dbclient.Client.Tickets.Get(ctx, ticketId, guildId)
if err != nil {
ctx.JSON(500, gin.H{
"success": true,
@ -77,50 +77,56 @@ func GetTicket(ctx *gin.Context) {
return
}
messagesFormatted := make([]map[string]interface{}, 0)
if ticket.ChannelId != nil {
// Get messages
messages, err := rest.GetChannelMessages(context.Background(), botContext.Token, botContext.RateLimiter, *ticket.ChannelId, rest.GetChannelMessagesData{Limit: 100})
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
if ticket.ChannelId == nil {
ctx.JSON(404, gin.H{
"success": false,
"error": "Channel ID is nil",
})
return
}
// Format messages, exclude unneeded data
for _, message := range utils.Reverse(messages) {
content := message.Content
// Format mentions properly
match := MentionRegex.FindAllStringSubmatch(content, -1)
for _, mention := range match {
if len(mention) >= 2 {
mentionedId, err := strconv.ParseUint(mention[1], 10, 64)
if err != nil {
continue
}
user, err := cache.Instance.GetUser(context.Background(), mentionedId)
if err == nil {
content = strings.ReplaceAll(content, fmt.Sprintf("<@%d>", mentionedId), fmt.Sprintf("@%s", user.Username))
} else if errors.Is(err, cache2.ErrNotFound) {
content = strings.ReplaceAll(content, fmt.Sprintf("<@%d>", mentionedId), "@Unknown User")
} else {
ctx.JSON(500, utils.ErrorJson(err))
return
}
}
}
messagesFormatted = append(messagesFormatted, map[string]interface{}{
"author": message.Author,
"content": content,
})
}
messages, err := fetchMessages(botContext, ticket)
if err != nil {
ctx.JSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
ctx.JSON(200, gin.H{
"success": true,
"ticket": ticket,
"messages": messagesFormatted,
"messages": messages,
})
}
type StrippedMessage struct {
Author user.User `json:"author"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
Attachments []channel.Attachment `json:"attachments"`
Embeds []embed.Embed `json:"embeds"`
}
func fetchMessages(botContext *botcontext.BotContext, ticket database.Ticket) ([]StrippedMessage, error) {
// Get messages
messages, err := rest.GetChannelMessages(context.Background(), botContext.Token, botContext.RateLimiter, *ticket.ChannelId, rest.GetChannelMessagesData{Limit: 100})
if err != nil {
return nil, err
}
// Format messages, exclude unneeded data
stripped := make([]StrippedMessage, len(messages))
for i, message := range utils.Reverse(messages) {
stripped[i] = StrippedMessage{
Author: message.Author,
Content: message.Content,
Timestamp: message.Timestamp,
Attachments: message.Attachments,
Embeds: message.Embeds,
}
}
return stripped, nil
}

View File

@ -24,14 +24,86 @@
</div>
{/if}
<div class="discord-container">
<section class="discord-container">
<div class="channel-header">
<span id="channel-name">#ticket-{ticketId}</span>
<span class="channel-name">#ticket-{ticketId}</span>
</div>
<div id="message-container" bind:this={container}>
<div class="message-container" bind:this={container}>
{#each messages as message}
<div class="message">
<b>{message.author.username}:</b> {message.content}
<img class="avatar" src={getAvatarUrl(message.author.id, message.author.avatar)}
on:error={(e) => handleAvatarLoadError(e, message.author.id)} alt="Avatar"/>
<div>
<div>
<span class="username">{message.author.global_name || message.author.username}</span>
<span class="timestamp">
{new Date() - new Date(message.timestamp) < 86400000 ? getRelativeTime(new Date(message.timestamp)) : new Date(message.timestamp).toLocaleString()}
</span>
</div>
<div class="content">
{#if message.content?.length > 0}
<span class="plaintext">{message.content}</span>
{/if}
{#if message.embeds?.length > 0}
<div class="embed-wrapper">
{#each message.embeds as embed}
<div class="embed">
<div class="colour" style="background-color: #{embed.color.toString(16)}"></div>
<div class="main">
{#if embed.title}
<b>{embed.title}</b>
{/if}
{#if embed.description}
<span>{embed.description}</span>
{/if}
{#if embed.fields && embed.fields.length > 0}
<div class="fields">
{#each embed.fields as field}
<div class="field" class:inline={field.inline}>
<span class="name">{field.name}</span>
<span class="value">{field.value}</span>
</div>
{/each}
</div>
{/if}
{#if embed.image && embed.image.proxy_url}
<img src={embed.image.proxy_url} alt="Embed Image"/>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#if message.attachments?.length > 0}
<div class="attachment-wrapper">
{#each message.attachments.filter(a => isImage(a.filename)) as attachment}
{@const proxyUrl = attachment.proxy_url.replaceAll("\u0026", "&")}
<img src={proxyUrl} alt="{attachment.filename}"/>
{/each}
{#each message.attachments.filter(a => !isImage(a.filename)) as attachment}
{@const directUrl = attachment.url.replaceAll("\u0026", "&")}
{@const proxyUrl = attachment.proxy_url.replaceAll("\u0026", "&")}
<div class="other">
<div class="metadata">
<span class="name">{attachment.filename}</span>
<span class="size">{formatFileSize(attachment.size)}</span>
</div>
<a href="{isCdnUrl(directUrl) ? directUrl : proxyUrl}" target="_blank"
download="{attachment.filename}">
<i class="fa-solid fa-download"></i>
</a>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
@ -47,14 +119,16 @@
{/if}
</form>
</div>
</div>
</section>
<script>
import {createEventDispatcher} from "svelte";
import {createEventDispatcher, onMount} from "svelte";
import {fade} from "svelte/transition";
import Button from "./Button.svelte";
import Card from "./Card.svelte";
import Dropdown from "./form/Dropdown.svelte";
import {getAvatarUrl, getDefaultIcon} from "../js/icons";
import {getRelativeTime} from "../js/util";
export let ticketId;
export let isPremium = false;
@ -92,6 +166,49 @@
selectedTag = undefined;
}
let failed = [];
function handleAvatarLoadError(e, userId) {
if (!failed.includes(userId)) {
failed.push(userId);
e.target.src = getDefaultIcon(userId);
}
}
function isImage(fileName) {
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'gifv', 'webp'];
return imageExtensions.includes(fileName.split('.').pop().toLowerCase());
}
function formatFileSize(size) {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(0)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(0)} MB`;
else return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function isCdnUrl(url) {
const parsed = new URL(url);
return parsed.hostname === 'cdn.discordapp.com';
}
onMount(() => {
messages = messages.map(message => {
// Sort attachments; image first
message.attachments = message.attachments.sort((a, b) => {
if (isImage(a.filename) && !isImage(b.filename)) {
return -1;
} else if (!isImage(a.filename) && isImage(b.filename)) {
return 1;
} else {
return 0;
}
});
return message;
})
});
</script>
<style>
@ -107,7 +224,8 @@
max-height: 100vh;
margin: 0;
padding: 0;
font-family: 'Catamaran', sans-serif !important;
/*font-family: 'Catamaran', sans-serif !important;*/
font-family: 'Poppins', sans-serif !important;
}
.channel-header {
@ -123,28 +241,178 @@
text-align: center;
}
#channel-name {
.channel-name {
color: white;
font-weight: bold;
padding-left: 20px;
}
#message-container {
.message-container {
display: flex;
flex-direction: column;
flex: 1;
gap: 10px;
position: relative;
overflow-y: scroll;
overflow-wrap: break-word;
padding-left: 10px;
}
.message {
color: white !important;
padding-left: 20px;
display: flex;
flex-direction: row;
gap: 10px;
}
#message-container:last-child {
.message:first-child {
margin-top: 5px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
}
.message > div {
display: flex;
flex-direction: column;
line-height: 16px;
}
.username {
color: white;
font-weight: bold;
font-size: 16px;
}
.timestamp {
font-size: 11px;
opacity: 0.6;
}
.plaintext {
font-size: 14px;
}
.embed-wrapper {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 5px;
}
.embed {
display: flex;
flex-direction: row;
gap: 5px;
width: 100%;
min-width: 300px;
border-radius: 5px;
background-color: #272727;
}
.embed > .colour {
width: 4px;
border-radius: 5px 0 0 5px;
}
.embed > .main {
display: flex;
flex-direction: column;
padding: 10px 10px 10px 5px;
width: 100%;
}
.fields {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 5px;
margin-top: 10px;
}
.field {
display: flex;
flex-direction: column;
}
.field.inline {
flex: 0 0 calc(33.3333% - 5px);
}
.field:not(.inline) {
flex-basis: 100%;
}
.field > .name {
font-size: 14px;
font-weight: bold;
}
.field > .value {
font-size: 14px;
}
.embed > .main > img {
width: 100%;
max-width: 300px;
margin-top: 5px;
border-radius: 3px;
}
.attachment-wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 5px;
row-gap: 5px;
margin-top: 5px;
}
.attachment-wrapper > img {
box-sizing: border-box;
width: 40%;
min-width: 300px;
border-radius: 5px;
}
.attachment-wrapper > .other {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 5px 10px;
border-radius: 5px;
background-color: #272727;
}
.attachment-wrapper > .other > .metadata {
display: flex;
flex-direction: column;
gap: 2px;
}
.attachment-wrapper > .other > .metadata > .name {
font-size: 14px;
font-weight: bold;
}
.attachment-wrapper > .other > .metadata > .size {
font-size: 12px;
opacity: 0.6;
}
.attachment-wrapper > .other i {
font-size: 24px;
color: white;
opacity: 0.8;
cursor: pointer;
}
.message-container:last-child {
margin-bottom: 5px;
}

View File

@ -6,18 +6,30 @@ export function isAnimated(icon) {
}
}
export function getIconUrl(id, icon) {
export function getIconUrl(id, icon, size = 256) {
if (!icon || icon === "") {
return getDefaultIcon(id);
}
if (isAnimated(icon)) {
return `https:\/\/cdn.discordapp.com/icons/${id}/${icon}.gif?size=256`
return `https:\/\/cdn.discordapp.com/icons/${id}/${icon}.gif?size=${size}`;
} else {
return `https:\/\/cdn.discordapp.com/icons/${id}/${icon}.webp?size=256`
return `https:\/\/cdn.discordapp.com/icons/${id}/${icon}.webp?size=${size}`;
}
}
export function getAvatarUrl(id, avatar, size = 256) {
if (!avatar || avatar === "") {
return getDefaultIcon(id);
}
if (isAnimated(avatar)) {
return `https:\/\/cdn.discordapp.com/avatars/${id}/${avatar}.gif?size=${size}`;
} else {
return `https:\/\/cdn.discordapp.com/avatars/${id}/${avatar}.webp?size=${size}`;
}
}
export function getDefaultIcon(id) {
return `https://cdn.discordapp.com/embed/avatars/${Number((BigInt(id) >> BigInt(22)) % BigInt(6))}.png`
}
}

View File

@ -1,31 +1,26 @@
<div class="parent">
<div class="content">
<Card footer={false}>
<span slot="title">Ticket #{ticketId}</span>
<div slot="body" class="body-wrapper">
<div class="section">
<h2 class="section-title">Close Ticket</h2>
<div class="content">
<Card footer={false}>
<span slot="title">Ticket #{ticketId}</span>
<div slot="body" class="body-wrapper">
<div class="section">
<h2 class="section-title">Close Ticket</h2>
<form on:submit|preventDefault={closeTicket}>
<div class="row" style="max-height: 63px; align-items: flex-end"> <!-- hacky -->
<div class="col-2" style="margin-bottom: 0 !important;">
<Input label="Close Reason" placeholder="No reason specified" col1={true} bind:value={closeReason}/>
</div>
<div class="col-3">
<div style="margin-left: 30px; margin-bottom: 0.5em">
<Button danger={true} noShadow icon="fas fa-lock">Close Ticket</Button>
<div class="row" style="gap: 20px">
<Input label="Close Reason" col2 placeholder="No reason specified" bind:value={closeReason}/>
<div style="display: flex; align-items: flex-end; padding-bottom: 8px">
<Button danger={true} noShadow icon="fas fa-lock" col3 on:click={closeTicket}>Close Ticket
</Button>
</div>
</div>
</div>
<div class="section">
<h2 class="section-title">View Ticket</h2>
<DiscordMessages {ticketId} {isPremium} {tags} {messages} bind:container on:send={sendMessage}/>
</div>
</div>
</div>
</form>
</div>
<div class="section">
<h2 class="section-title">View Ticket</h2>
<DiscordMessages {ticketId} {isPremium} {tags} {messages} bind:container on:send={sendMessage} />
</div>
</div>
</Card>
</div>
</Card>
</div>
</div>
<script>
@ -34,7 +29,7 @@
import Button from "../components/Button.svelte";
import axios from "axios";
import {API_URL} from "../js/constants";
import {setDefaultHeaders, getToken} from '../includes/Auth.svelte'
import {getToken, setDefaultHeaders} from '../includes/Auth.svelte'
import Input from "../components/form/Input.svelte";
import {navigateTo} from "svelte-router-spa";
import DiscordMessages from "../components/DiscordMessages.svelte";