luisfun / discord-hono

This module enables you to easily build a Discord Bot that runs on Cloudflare workers
https://discord-hono.luis.fun
MIT License
37 stars 1 forks source link

Embeds #5

Closed Ognisty321 closed 3 months ago

Ognisty321 commented 3 months ago

I'm curious about your plans for embed support in Discord Hono. Are you considering adding built-in support for embeds? If not, then what do you think about this EmbedBuilder class in the attachement I don't want to bother with a pull request as I don't use github, so I'm sharing it here for your consideration. What are your thoughts on this? Also I haven't thoroughly tested or checked this code - it's just an idea I'm putting out there embed.txt

luisfun commented 3 months ago

Hi! In v0.7.2 I did not provide an embed builder because embed is a pure object. I added it in v0.7.3 to try it out. I also created a simple Docs.

Ognisty321 commented 3 months ago

Thanks for adding embed support in v0.7.3! The new docs are really helpful.

I picked Discord Hono for my project because it's fast and works with Cloudflare Workers. I like that it's serverless.

I've used Discord.js before, and I'm wondering how much of what it can do, Discord Hono can do too. Do you think Discord Hono will eventually do everything Discord.js can? Also, are you thinking about using similar names for things as Discord.js does? That might make it easier for people to switch over.

I'm trying to figure out how to make a weather command in Discord Hono. In my Discord.js bot, I had a command that did these things:

  1. Used slash commands to get a city name from the user.
  2. Made API calls to get current conditions, forecasts, and alerts.
  3. Created an embed with the weather info and a button to show alerts.
  4. When the button was clicked, it showed weather alerts in a new embed.

How could I create something similar using the current version of Discord Hono? Are there any limitations I should be aware of when trying to implement this kind of interactive command with multiple API calls and dynamic updates?

luisfun commented 3 months ago

Thank you for choosing Discord Hono!

Regarding Discord.js, I have never used Discord.js, so I am including speculation, but I made this comparison. https://discord-hono.luis.fun/overview/features/#server-based-bot-vs-serverless-bot Also, as you can see from this comparison, I cannot reproduce all the features of Discord.js (server-based bot).

As for keeping the usability the same as Discord.js, I am not currently considering adapting the way the Discord Hono code is written to Discord.js. However, it may be very useful to provide a guide for switching from Discord.js to Discord Hono. (I would need to be somewhat familiar with Discord.js, though.)

I have created a sample code for the Weather Bot you are trying to create. Please take a look at it for reference.

// index.ts
import { Button, Components, DiscordHono, Embed } from 'discord-hono'

type Env = {
  Variables: {
    city?: string
  }
}

const deleteButton = new Button('message-delete', 'Delete', 'Secondary').emoji({ name: '🗑️' })

const app = new DiscordHono<Env>()
  .command('weather', c => {
    const city = c.var.city || 'default city'
    const fetchWeatherInfo = `Weather: ${city}`
    const embed = new Embed().title('Weather Info').description(fetchWeatherInfo)
    const components = new Components().row(
      new Button('alert', 'Weather Alerts').custom_id(city).emoji({ name: '⚠️' }),
      deleteButton,
    )
    return c.res({ embeds: [embed], components })
  })
  .component('alert', c => {
    const city = c.interaction.data?.custom_id || 'default city'
    const fetchAlertInfo = `Alerts: ${city}`
    const embed = new Embed().title('Alerts Info').description(fetchAlertInfo)
    const components = new Components().row(deleteButton)
    return c.resUpdate({ embeds: [embed], components })
  })
  .component('message-delete', c => c.resDeferUpdate(c.followupDelete))

export default app
// register.ts
import { Command, Option, register } from 'discord-hono'

const commands = [
  new Command('weather', 'weather info').options(new Option('city', 'city name').required()),
]

One limitation to note is that the 'alert' and custom_id city strings in the sample code are limited to a combined total of 99 characters. Also, unlike Discord.js server-based bots, the previous state cannot be saved. (I think you can use cloudflare kv or cloudflare d1 ingeniously as a substitute.) Therefore, the previous state is saved in custom_id, etc., and it is referred to when operating buttons, etc.

Ognisty321 commented 3 months ago

Thanks for the detailed reply and that sample code as it's very helpful.

I totally forgot to mention earlier that in my Discord.js bot, I was using ephemeral messages to control if the users want to show it for everyone or not. And I had a feature where users could click buttons under the embed to flip through pages - because showing weather for each hour over 24 hours would be way too much for one embed.

Your sample code is great - gives me a solid starting point. I can see how to handle the basic weather command and show alerts with buttons. Pretty clever using custom_id for the city info, considering how serverless functions work.

I'm kinda curious about a few things:

  1. For longer content like hourly forecasts, how could I implement page switching using buttons in Discord Hono? Is there a way to update the embed content when a user clicks a "next page" or "previous page" button?

  2. I read in your repo that Discord Hono is heavily influenced by Hono, and I noticed that they mention Bun and Deno in their docs. Have you considered switching to Bun as the primary runtime? I've heard it's supposed to be 377% faster than regular Node. Could this speed boost benefit Discord Hono users?

Thanks again for explaining everything and for the sample code. It's really helping me figure out how to move my project over to Discord Hono.

luisfun commented 3 months ago

Glad my reply could help you.

I made a sample code that implements ephemeral messages and page switching. Please take a look at it for reference.

// index.ts
import { Button, Components, DiscordHono, Embed } from 'discord-hono'

type Env = {
  Variables: {
    city?: string
    public?: boolean
  }
}

const deleteButton = new Button('message-delete', 'Delete', 'Secondary').emoji({ name: '🗑️' })

const weatherInfo = (city: string, nowPage: number) => {
  const maxPage = 3
  const fetchWeatherInfo = `Weather: ${city} \nPage: ${nowPage}`
  const fetchAlertInfo = `Alerts: ${city}`
  const noAlert = fetchAlertInfo ? true : false
  const previousPageId = JSON.stringify([city, nowPage - 1])
  const nextPageId = JSON.stringify([city, nowPage + 1])
  // Might need to check character limit check.
  const embed = new Embed().title('Weather Info').description(fetchWeatherInfo)
  const components = new Components()
    .row(
      new Button('weather', 'Previous')
        .custom_id(previousPageId)
        .emoji({ name: '⬅️' })
        .disabled(nowPage <= 1),
      new Button('weather', 'Next')
        .custom_id(nextPageId)
        .emoji({ name: '➡️' })
        .disabled(maxPage <= nowPage),
    )
    .row(
      new Button('alert', 'Alerts', noAlert ? 'Secondary' : 'Primary')
        .custom_id(city)
        .emoji({ name: '⚠️' })
        .disabled(noAlert),
      deleteButton,
    )
  return { embeds: [embed], components }
}

const app = new DiscordHono<Env>()
  .command('weather', c => {
    const isPublic = c.var.public || false
    return c.ephemeral(!isPublic).res(weatherInfo(c.var.city || 'default city', 1))
  })
  .component('weather', c => {
    const [city, nowPage]: [string, number] = JSON.parse(c.interaction.data?.custom_id || '')
    return c.resUpdate(weatherInfo(city, nowPage))
  })
  .component('alert', ...)
  .component('message-delete', ...)

export default app
// register.ts
const commands = [
  new Command('weather', 'weather info').options(
    new Option('city', 'city name').required(),
    new BooleanOption('public', 'public reply (default: false)'),
  ),
]

Regarding Bun As a premise, I have not used Bun yet. I have not thought about using Bun as a development environment. I believe that it is possible to use Bun as the user's environment. If there is an error specific to the Bun environment, I would like to respond to it. (Though I may be late in responding.) When you run npm run register, I think you will benefit from the increased processing speed. However, the actual index.ts running on cloudflare should be irrelevant.

P.S. I found some improvements in Discord Hono while writing the sample code. Thanks.

luisfun commented 3 months ago

Updated to version 0.7.4. The code has been made a little clearer.

// const city = c.interaction.data?.custom_id
const city = c.var.custom_id

Also partially merged the builders. As a breaking change, I expanded the builder's array type argument.

If you are interested, try updating it.

Ognisty321 commented 3 months ago

Thanks. Now it was easier to create the command after looking at your examples :D. Here is the command that I was working on. Maybe it will be useful to someone in the future note: a lot of things are in Polish

Spoiler ```typescript import { CommandContext, ComponentContext, Embed, Components, Button } from 'discord-hono'; import axios from 'axios'; type Env = { Bindings: { ACCUWEATHER_API_KEY: string; }; Variables: { miasto?: string; publiczny?: boolean; }; } interface Location { Key: string; LocalizedName: string; } interface CurrentConditions { WeatherText: string; WeatherIcon: number; IsDayTime: boolean; Temperature: { Metric: { Value: number, Unit: string } }; RealFeelTemperature: { Metric: { Value: number, Unit: string } }; RelativeHumidity: number; Wind: { Direction: { Degrees: number, Localized: string }, Speed: { Metric: { Value: number, Unit: string } } }; UVIndex: number; UVIndexText: string; Visibility: { Metric: { Value: number, Unit: string } }; CloudCover: number; Pressure: { Metric: { Value: number, Unit: string } }; PrecipitationSummary: { Past24Hours: { Metric: { Value: number, Unit: string } } }; TemperatureSummary: { Past24HourRange: { Minimum: { Metric: { Value: number, Unit: string } }, Maximum: { Metric: { Value: number, Unit: string } } } }; } interface WeatherAlert { Description: { Localized: string }; Source?: string; Area: Array<{ Name?: string; StartTime?: string; EndTime?: string; }>; } interface HourlyForecast { DateTime: string; WeatherIcon: number; Temperature: { Value: number }; IconPhrase: string; } function polishPlural(singularNominativ: string, pluralNominativ: string, pluralGenitive: string, value: number): string { if (value === 1) { return singularNominativ; } else if (value % 10 >= 2 && value % 10 <= 4 && (value % 100 < 10 || value % 100 >= 20)) { return pluralNominativ; } else { return pluralGenitive; } } function getWeatherEmoji(iconCode: number): string { const iconMap: { [key: number]: string } = { 1: "☀️", 2: "☀️", 3: "⛅", 4: "⛅", 5: "🌥️", 6: "🌥️", 7: "☁️", 8: "☁️", 11: "🌫️", 12: "🌧️", 13: "🌦️", 14: "🌦️", 15: "⛈️", 16: "⛈️", 17: "⛈️", 18: "🌧️", 19: "❄️", 20: "🌨️", 21: "🌨️", 22: "❄️", 23: "❄️", 24: "❄️", 25: "🌧️", 26: "🌧️", 29: "🌧️❄️", 30: "🌡️", 31: "🥶", 32: "💨", }; return iconMap[iconCode] || "☁️"; } async function getLocationKey(cityName: string, c: CommandContext): Promise { try { const response = await axios.get("https://api.accuweather.com/locations/v1/autocomplete", { params: { apikey: c.env.ACCUWEATHER_API_KEY, q: cityName, language: "pl-pl", includeAliases: "false" }, headers: { "User-Agent": "AccuWeather AccuKotlinSDK Web Request Client/1.0 Google-HTTP-Java-Client/1.43.3 (gzip)" } }); if (Array.isArray(response.data) && response.data.length > 0) { return response.data[0]; } } catch (error) { console.error("Error fetching location key:", error); } return null; } async function getCurrentConditions(locationKey: string, c: CommandContext): Promise { try { const response = await axios.get(`https://api.accuweather.com/currentconditions/v1/${locationKey}.json`, { params: { apikey: c.env.ACCUWEATHER_API_KEY, language: "pl-pl", details: "true" }, headers: { "User-Agent": "AccuWeather AccuKotlinSDK Web Request Client/1.0 Google-HTTP-Java-Client/1.43.3 (gzip)" } }); if (Array.isArray(response.data) && response.data.length > 0) { return response.data[0]; } } catch (error) { console.error("Error fetching current conditions:", error); } return null; } async function getWeatherAlerts(locationKey: string, c: CommandContext): Promise { try { const response = await axios.get(`https://api.accuweather.com/alerts/v1/${locationKey}.json`, { params: { apikey: c.env.ACCUWEATHER_API_KEY, language: "pl-pl", details: "true" }, headers: { "User-Agent": "AccuWeather AccuKotlinSDK Web Request Client/1.0 Google-HTTP-Java-Client/1.43.3 (gzip)" } }); if (Array.isArray(response.data)) { return response.data as WeatherAlert[]; } } catch (error) { console.error("Error fetching weather alerts:", error); } return null; } async function getHourlyForecast(locationKey: string, hours: number, c: CommandContext): Promise { try { const response = await axios.get(`https://api.accuweather.com/forecasts/v1/hourly/24hour/${locationKey}.json`, { params: { apikey: c.env.ACCUWEATHER_API_KEY, language: "pl-pl", details: "true", metric: "true" }, headers: { "User-Agent": "AccuWeather AccuKotlinSDK Web Request Client/1.0 Google-HTTP-Java-Client/1.43.3 (gzip)" } }); if (Array.isArray(response.data)) { return response.data.slice(0, hours) as HourlyForecast[]; } } catch (error) { console.error("Error fetching hourly forecast:", error); } return null; } function formatTime(timeStr: string): string { if (!timeStr) return "N/A"; try { const date = new Date(timeStr); return date.toLocaleString("pl-PL", { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } catch { return "Nieznany format czasu"; } } function getAlertEmoji(alertDescription: string): string { if (alertDescription.includes("Burza")) return "⛈️"; if (alertDescription.includes("Wiatr")) return "🌬️"; if (alertDescription.includes("Śnieg")) return "❄️"; if (alertDescription.includes("Deszcz")) return "🌧️"; if (alertDescription.includes("Upał")) return "🌡️"; if (alertDescription.includes("Mróz")) return "🥶"; return "⚠️"; } function getTemperatureColor(temperature: number): number { if (temperature < 0) return 0x9999FF; if (temperature < 10) return 0x99CCFF; if (temperature < 20) return 0x99FFCC; if (temperature < 30) return 0xFFFF99; return 0xFF9999; } function createWeatherEmbed(location: Location, currentConditions: CurrentConditions, alerts: WeatherAlert[] | null) { const weatherEmoji = getWeatherEmoji(currentConditions.WeatherIcon); const temperatureColor = getTemperatureColor(currentConditions.Temperature.Metric.Value); const embed = new Embed() .title(`${weatherEmoji} Pogoda dla ${location.LocalizedName}`) .description(`${currentConditions.WeatherText} (${currentConditions.IsDayTime ? 'Dzień' : 'Noc'})`) .fields( { name: "🌡️ Temperatura", value: `${currentConditions.Temperature.Metric.Value}°C`, inline: true }, { name: "🥵 Odczuwalna", value: `${currentConditions.RealFeelTemperature.Metric.Value}°C`, inline: true }, { name: "💧 Wilgotność", value: `${currentConditions.RelativeHumidity}%`, inline: true }, { name: "💨 Wiatr", value: `${currentConditions.Wind.Speed.Metric.Value} ${currentConditions.Wind.Speed.Metric.Unit} ${currentConditions.Wind.Direction.Localized}`, inline: true }, { name: "☀️ UV Index", value: `${currentConditions.UVIndex} (${currentConditions.UVIndexText})`, inline: true }, { name: "👁️ Widoczność", value: `${currentConditions.Visibility.Metric.Value} ${currentConditions.Visibility.Metric.Unit}`, inline: true }, { name: "☁️ Zachmurzenie", value: `${currentConditions.CloudCover}%`, inline: true }, { name: "🧭 Ciśnienie", value: `${currentConditions.Pressure.Metric.Value} ${currentConditions.Pressure.Metric.Unit}`, inline: true }, { name: "🌧️ Opady (24h)", value: `${currentConditions.PrecipitationSummary.Past24Hours.Metric.Value} ${currentConditions.PrecipitationSummary.Past24Hours.Metric.Unit}`, inline: true }, { name: "🌡️ Min. temp. (24h)", value: `${currentConditions.TemperatureSummary.Past24HourRange.Minimum.Metric.Value}°C`, inline: true }, { name: "🌡️ Maks. temp. (24h)", value: `${currentConditions.TemperatureSummary.Past24HourRange.Maximum.Metric.Value}°C`, inline: true } ) .color(temperatureColor) .timestamp(new Date().toISOString()) .thumbnail({ url: `https://developer.accuweather.com/sites/default/files/${currentConditions.WeatherIcon.toString().padStart(2, '0')}-s.png` }); const alertCount = alerts ? alerts.length : 0; const alertText = polishPlural("ostrzeżenie pogodowe", "ostrzeżenia pogodowe", "ostrzeżeń pogodowych", alertCount); embed.footer({ text: `${alertCount} ${alertText}` }); return embed; } function createWeatherComponents(city: string, alerts: WeatherAlert[] | null) { const hasAlerts = alerts && alerts.length > 0; return new Components() .row( new Button('show_alerts', 'Pokaż ostrzeżenia', hasAlerts ? 'Danger' : 'Secondary') .custom_id(city) .emoji({ name: '🚨' }) .disabled(!hasAlerts), new Button('show_hourly', 'Prognoza godzinowa', 'Primary') .custom_id(JSON.stringify([city, 1])) .emoji({ name: '🕒' }) ); } export const pogoda = async (c: CommandContext) => { const cityName = c.var.miasto as string; const isPublic = c.var.publiczny as boolean ?? false; try { const location = await getLocationKey(cityName, c); if (!location) { return c.res("Nie udało się znaleźć podanego miasta."); } const [currentConditions, alerts] = await Promise.all([ getCurrentConditions(location.Key, c), getWeatherAlerts(location.Key, c) ]); if (!currentConditions) { return c.res("Nie udało się pobrać danych pogodowych."); } const weatherEmbed = createWeatherEmbed(location, currentConditions, alerts); const components = createWeatherComponents(location.LocalizedName, alerts); return c.ephemeral(!isPublic).res({ embeds: [weatherEmbed], components }); } catch (error) { console.error("Error in pogoda command:", error); return c.res("Wystąpił błąd podczas pobierania danych pogodowych. Spróbuj ponownie później."); } }; export const showAlerts = async (c: ComponentContext) => { const cityName = c.var.custom_id as string; const location = await getLocationKey(cityName, c as unknown as CommandContext); if (!location) { return c.res("Nie udało się znaleźć informacji o mieście."); } const alerts = await getWeatherAlerts(location.Key, c as unknown as CommandContext); if (alerts && alerts.length > 0) { const alertEmbeds = alerts.map((alert, index) => { const emoji = getAlertEmoji(alert.Description.Localized); const startTime = formatTime(alert.Area[0]?.StartTime || ''); const endTime = formatTime(alert.Area[0]?.EndTime || ''); let timeRange; if (startTime && endTime) { timeRange = `${startTime} - ${endTime}`; } else if (startTime) { timeRange = `Od ${startTime}`; } else if (endTime) { timeRange = `Do ${endTime}`; } else { timeRange = 'Brak danych'; } return new Embed() .title(`${emoji} Ostrzeżenie ${index + 1}`) .description(alert.Description.Localized) .color(0xFF0000) .fields( { name: "Źródło", value: alert.Source || 'Brak danych', inline: false }, { name: "Obszar", value: alert.Area[0]?.Name || 'Brak danych', inline: false }, { name: "Przedział czasowy", value: timeRange, inline: false } ) .footer({ text: "Bądź ostrożny i śledź lokalne komunikaty" }); }); return c.ephemeral().res({ content: "🚨 Ostrzeżenia pogodowe:", embeds: alertEmbeds }); } else { const noAlertsEmbed = new Embed() .title("✅ Brak ostrzeżeń pogodowych") .color(0x00FF00) .description("Obecnie nie ma aktywnych ostrzeżeń dla tego obszaru."); return c.res({ embeds: [noAlertsEmbed] }); } }; export const showHourly = async (c: ComponentContext) => { let [cityName, currentPage] = JSON.parse(c.var.custom_id as string); currentPage = parseInt(currentPage); const location = await getLocationKey(cityName, c as unknown as CommandContext); if (!location) { return c.res("Nie udało się znaleźć informacji o mieście."); } const hourlyForecast = await getHourlyForecast(location.Key, 24, c as unknown as CommandContext); if (hourlyForecast) { const chunks = chunk(hourlyForecast, 8); const hourlyEmbed = new Embed() .title(`📊 Prognoza 24-godzinna dla ${location.LocalizedName}`) .color(0x0099FF) .description( chunks[currentPage - 1]?.map(forecast => { const date = new Date(forecast.DateTime); const hour = date.getHours().toString().padStart(2, '0'); const emoji = getWeatherEmoji(forecast.WeatherIcon); return `\`${hour}:00\` | ${emoji} ${forecast.Temperature.Value}°C | ${forecast.IconPhrase}`; }).join("\n") || "Brak danych dla wybranej strony" ) .footer({ text: `Strona ${currentPage} z 3` }); const components = new Components() .row( new Button('prev_hourly', '⬅️ Poprzednie', 'Secondary') .custom_id(JSON.stringify([cityName, currentPage - 1])) .disabled(currentPage <= 1), new Button('next_hourly', 'Następne ➡️', 'Secondary') .custom_id(JSON.stringify([cityName, currentPage + 1])) .disabled(currentPage >= 3) ); return c.ephemeral().resUpdate({ embeds: [hourlyEmbed], components }); } else { return c.res({ content: "Nie udało się pobrać prognozy godzinowej." }); } }; function chunk(array: T[], size: number): T[][] { const chunked_arr = []; for (let i = 0; i < array.length; i += size) { chunked_arr.push(array.slice(i, i + size)); } return chunked_arr; } ```