├── embedg-site ├── static │ ├── .nojekyll │ ├── img │ │ ├── logo.png │ │ ├── example.jpg │ │ ├── favicon.ico │ │ ├── logo-256.png │ │ ├── logo-512.png │ │ ├── logo-1024.png │ │ ├── feature-save-messages.png │ │ └── logo.svg │ └── robots.txt ├── package.go ├── go.mod ├── src │ ├── css │ │ ├── tailwind.css │ │ └── global.css │ ├── pages │ │ └── index.tsx │ └── components │ │ ├── HomeHero.tsx │ │ ├── HomeFooter.tsx │ │ └── HomeHeader.tsx ├── babel.config.js ├── docs │ ├── features │ │ ├── components-v2.png │ │ ├── _category_.json │ │ ├── ai-assistant-feature.png │ │ ├── components-v2-enable.png │ │ ├── white-label-feature.png │ │ ├── save-messages-feature.png │ │ ├── custom-branding-feature.png │ │ ├── custom-branding-preview.png │ │ ├── custom-commands-feature.png │ │ ├── interactive-components-feature.png │ │ ├── ai-assistant.md │ │ ├── custom-branding.md │ │ ├── custom-commands.md │ │ ├── white-label.md │ │ ├── interactive-components.md │ │ ├── save-messages.md │ │ └── components-v2.md │ ├── guides │ │ ├── _category_.json │ │ ├── variables-button.png │ │ ├── variables-response.png │ │ ├── actions-assign-roles.png │ │ ├── actions-saved-response.png │ │ ├── actions-text-response.png │ │ ├── custom-bots-configure.png │ │ ├── custom-bots-devportal.png │ │ ├── custom-commands-first.png │ │ ├── interactive-components.png │ │ ├── scheduled-messages-once.png │ │ ├── discohook-message-backups.png │ │ ├── scheduled-messages-periodic.png │ │ ├── interactive-components-select.png │ │ ├── interactive-components-buttons.png │ │ └── scheduled-messages.md │ ├── intro.md │ └── premium.md ├── noembed.go ├── blog │ ├── 2024-01-19-executing-webhooks │ │ ├── message.png │ │ ├── webhooks.png │ │ ├── editor-send.png │ │ ├── editor-message.png │ │ ├── webhook-info.png │ │ └── docusaurus-plushie-banner.jpeg │ ├── 2024-01-20-discohook-alternative │ │ ├── editor.png │ │ ├── embed.jpg │ │ ├── buttons.png │ │ ├── component.png │ │ ├── interface.png │ │ └── no-webhook.png │ ├── 2024-01-16-webhooks │ │ └── index.md │ ├── authors.yml │ ├── 2023-02-23-discord-club │ │ └── index.md │ └── 2024-01-18-embed-json │ │ └── index.md ├── embed.go ├── tsconfig.json ├── .gitignore ├── tailwind.config.js ├── sidebars.js ├── README.md └── package.json ├── .gitignore ├── embedg-app ├── package.go ├── go.mod ├── public │ ├── logo-256.png │ ├── logo-512.png │ ├── logo-1024.png │ ├── whitney-font │ │ ├── Bold.woff │ │ ├── Book.woff │ │ ├── Medium.woff │ │ └── Semibold.woff │ └── logo.svg ├── postcss.config.js ├── src │ ├── util │ │ ├── index.ts │ │ ├── time.ts │ │ ├── url.ts │ │ ├── discord.ts │ │ ├── premium.ts │ │ ├── autoAnimate.tsx │ │ └── toasts.tsx │ ├── index.css │ ├── api │ │ ├── base.ts │ │ └── client.ts │ ├── vite-env.d.ts │ ├── components │ │ ├── RequestLoadingIndicator.tsx │ │ ├── LoginLink.tsx │ │ ├── LogoutLink.tsx │ │ ├── Twemoji.tsx │ │ ├── ToolsBackButton.tsx │ │ ├── CheckBox.tsx │ │ ├── EditorComponentSectionAccessory.tsx │ │ ├── EditorMessageContentField.tsx │ │ ├── ValidationError.tsx │ │ ├── DateTimePicker.tsx │ │ ├── ValidationErrorIndicator.tsx │ │ ├── ConfirmOnExit.tsx │ │ ├── ClickOutsideHandler.tsx │ │ ├── DateTimePicker.css │ │ ├── CronExpressionBuilder.tsx │ │ ├── ActivityLoadingScreen.tsx │ │ ├── EditorMessagePreview.tsx │ │ ├── EditorWebhookFields.tsx │ │ ├── EditorErrorBoundary.tsx │ │ ├── ConfirmModal.tsx │ │ ├── EditorComponentFile.tsx │ │ ├── EditorIconButton.tsx │ │ ├── ToolsColoredText.module.css │ │ ├── SendMenu.tsx │ │ ├── CronExpressionBuilder.css │ │ ├── EditorModal.tsx │ │ ├── EditorEmbedImages.tsx │ │ ├── EditorMenuBar.tsx │ │ ├── EditorAttachment.tsx │ │ ├── EditorComponentSeparator.tsx │ │ ├── EditorComponentEntry.tsx │ │ ├── EditorComponentTextDisplay.tsx │ │ ├── Modal.tsx │ │ ├── ImageUploadButton.tsx │ │ ├── Collapsable.tsx │ │ └── EditorComponentActionRowButton.tsx │ ├── views │ │ ├── error.tsx │ │ ├── settings.tsx │ │ ├── editor │ │ │ └── clear.tsx │ │ └── tools │ │ │ ├── webhookInfo.tsx │ │ │ ├── embedLinks.tsx │ │ │ └── coloredText.tsx │ ├── state │ │ ├── activity.ts │ │ ├── upsell.ts │ │ ├── settings.ts │ │ ├── validationError.ts │ │ ├── sendSettings.ts │ │ ├── collapsed.ts │ │ └── attachments.ts │ ├── main.tsx │ ├── discord │ │ ├── cdn.ts │ │ └── util.ts │ └── assets │ │ └── logo.svg ├── noembed.go ├── embed.go ├── tsconfig.node.json ├── .gitignore ├── tsconfig.json ├── vite.config.ts ├── tailwind.config.js ├── index.html └── package.json ├── embedg-server ├── .gitignore ├── db │ ├── postgres │ │ ├── migrations │ │ │ ├── 001_create_users_table.down.sql │ │ │ ├── 011_create_images_table.down.sql │ │ │ ├── 002_create_session_table.down.sql │ │ │ ├── 007_create_custom_bots_table.down.sql │ │ │ ├── 015_create_embed_links_table.down.sql │ │ │ ├── 003_create_saved_messages_table.down.sql │ │ │ ├── 005_create_entitlements_table.down.sql │ │ │ ├── 016_create_kv_entries_table.down.sql │ │ │ ├── 006_create_shared_messages_table.down.sql │ │ │ ├── 008_create_custom_commands_table.down.sql │ │ │ ├── 004_create_message_action_sets_table.down.sql │ │ │ ├── 013_create_scheduled_messages_table.down.sql │ │ │ ├── 014_add_scheduled_messages_tz.down.sql │ │ │ ├── 018_add_scheduled_messages_thread_name.down.sql │ │ │ ├── 018_add_scheduled_messages_thread_name.up.sql │ │ │ ├── 017_add_entitlements_consumed.down.sql │ │ │ ├── 014_add_scheduled_messages_tz.up.sql │ │ │ ├── 017_add_entitlements_consumed.up.sql │ │ │ ├── 004_create_message_action_sets_table.up.sql │ │ │ ├── 006_create_shared_messages_table.up.sql │ │ │ ├── 001_create_users_table.up.sql │ │ │ ├── 009_add_derived_permissions_column.down.sql │ │ │ ├── 012_add_custom_bots_gateway.down.sql │ │ │ ├── 010_create_db_indexes.up.sql │ │ │ ├── 005_create_entitlements_table.up.sql │ │ │ ├── 002_create_session_table.up.sql │ │ │ ├── 012_add_custom_bots_gateway.up.sql │ │ │ ├── 016_create_kv_entries_table.up.sql │ │ │ ├── 011_create_images_table.up.sql │ │ │ ├── 003_create_saved_messages_table.up.sql │ │ │ ├── 009_add_derived_permissions_column.up.sql │ │ │ ├── 008_create_custom_commands_table.up.sql │ │ │ ├── 007_create_custom_bots_table.up.sql │ │ │ ├── 015_create_embed_links_table.up.sql │ │ │ └── 013_create_scheduled_messages_table.up.sql │ │ ├── queries │ │ │ ├── images.sql │ │ │ ├── users.sql │ │ │ ├── shared_messages.sql │ │ │ ├── sessions.sql │ │ │ ├── message_action_sets.sql │ │ │ ├── embed_links.sql │ │ │ ├── custom_commands.sql │ │ │ ├── saved_messages.sql │ │ │ ├── entitlements.sql │ │ │ ├── kv_entries.sql │ │ │ ├── custom_bots.sql │ │ │ └── scheduled_messages.sql │ │ ├── pgmodel │ │ │ ├── db.go │ │ │ ├── users.sql.go │ │ │ ├── shared_messages.sql.go │ │ │ └── images.sql.go │ │ └── store.go │ └── s3 │ │ ├── db_backups.go │ │ └── store.go ├── bot │ ├── logo-512.png │ ├── rest │ │ └── client.go │ ├── stateway │ │ ├── listener.go │ │ └── client.go │ └── sharding │ │ └── monitor.go ├── store │ ├── error.go │ ├── entitlement.go │ ├── plan.go │ └── kv.go ├── util │ ├── id.go │ ├── guilded.go │ ├── vaultbin.go │ └── util.go ├── sqlc.yaml ├── api │ ├── wire │ │ ├── auth.go │ │ ├── user.go │ │ ├── assistant.go │ │ ├── images.go │ │ ├── health.go │ │ ├── shared_message.go │ │ ├── base.go │ │ └── embeds_links.go │ ├── stores.go │ ├── helpers │ │ ├── error.go │ │ └── helpers.go │ ├── session │ │ └── middleware.go │ ├── api.go │ ├── access │ │ └── check.go │ ├── handlers │ │ └── users │ │ │ └── handler.go │ ├── managers.go │ └── premium │ │ └── roles.go ├── entry │ ├── admin │ │ ├── cmd.go │ │ └── impersonate.go │ ├── database │ │ └── logger.go │ └── server │ │ ├── stores.go │ │ └── cmd.go ├── .goreleaser.yml ├── model │ └── kv.go ├── buildinfo │ └── buildinfo.go ├── actions │ ├── template │ │ └── writer.go │ └── variables │ │ ├── channel.go │ │ └── guild.go ├── docker-compose.yaml ├── config │ ├── config.go │ └── defaults.go ├── scheduled_messages │ └── cron.go ├── main.go └── telemetry │ └── logging.go ├── tutorial.png ├── go.work ├── tygo.yaml ├── .github └── workflows │ ├── docker-push.yaml │ └── release.yaml ├── LICENSE ├── Dockerfile └── docker-compose.yaml /embedg-site/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /embedg-site/static/img/logo.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.zip 3 | -------------------------------------------------------------------------------- /embedg-app/package.go: -------------------------------------------------------------------------------- 1 | package embedgapp 2 | -------------------------------------------------------------------------------- /embedg-site/package.go: -------------------------------------------------------------------------------- 1 | package embedgsite 2 | -------------------------------------------------------------------------------- /embedg-server/.gitignore: -------------------------------------------------------------------------------- 1 | config.yaml 2 | embedg-server -------------------------------------------------------------------------------- /embedg-site/static/robots.txt: -------------------------------------------------------------------------------- 1 | user-agent: * 2 | allow: / 3 | -------------------------------------------------------------------------------- /tutorial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/tutorial.png -------------------------------------------------------------------------------- /embedg-app/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/merlinfuchs/embed-generator/embedg-app 2 | 3 | go 1.18 4 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/001_create_users_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/011_create_images_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS images; -------------------------------------------------------------------------------- /embedg-site/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/merlinfuchs/embed-generator/embedg-site 2 | 3 | go 1.18 4 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/002_create_session_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS sessions; -------------------------------------------------------------------------------- /embedg-site/src/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.25.0 2 | 3 | use ./embedg-server 4 | 5 | use ./embedg-app 6 | 7 | use ./embedg-site 8 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/007_create_custom_bots_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS custom_bots; -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/015_create_embed_links_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS embed_links; -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/003_create_saved_messages_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS saved_messages; -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/005_create_entitlements_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS entitlements; -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/016_create_kv_entries_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS kv_entries; 2 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/006_create_shared_messages_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS shared_messages; -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/008_create_custom_commands_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS custom_commands; -------------------------------------------------------------------------------- /embedg-app/public/logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-app/public/logo-256.png -------------------------------------------------------------------------------- /embedg-app/public/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-app/public/logo-512.png -------------------------------------------------------------------------------- /embedg-server/bot/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-server/bot/logo-512.png -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/004_create_message_action_sets_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS message_action_sets; -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/013_create_scheduled_messages_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS scheduled_messages; -------------------------------------------------------------------------------- /embedg-app/public/logo-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-app/public/logo-1024.png -------------------------------------------------------------------------------- /embedg-site/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /embedg-app/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /embedg-app/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export function getUniqueId(): number { 2 | return Math.floor(Math.random() * 1000000000); 3 | } 4 | -------------------------------------------------------------------------------- /embedg-site/static/img/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/static/img/example.jpg -------------------------------------------------------------------------------- /embedg-site/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/static/img/favicon.ico -------------------------------------------------------------------------------- /embedg-site/static/img/logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/static/img/logo-256.png -------------------------------------------------------------------------------- /embedg-site/static/img/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/static/img/logo-512.png -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/014_add_scheduled_messages_tz.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE scheduled_messages DROP COLUMN cron_timezone; 2 | -------------------------------------------------------------------------------- /embedg-site/static/img/logo-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/static/img/logo-1024.png -------------------------------------------------------------------------------- /embedg-app/public/whitney-font/Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-app/public/whitney-font/Bold.woff -------------------------------------------------------------------------------- /embedg-app/public/whitney-font/Book.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-app/public/whitney-font/Book.woff -------------------------------------------------------------------------------- /embedg-app/public/whitney-font/Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-app/public/whitney-font/Medium.woff -------------------------------------------------------------------------------- /embedg-app/src/util/time.ts: -------------------------------------------------------------------------------- 1 | export function getCurrentTimezone(): string { 2 | return Intl.DateTimeFormat().resolvedOptions().timeZone; 3 | } 4 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/018_add_scheduled_messages_thread_name.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE scheduled_messages DROP COLUMN thread_name; 2 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/018_add_scheduled_messages_thread_name.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE scheduled_messages ADD COLUMN thread_name TEXT; 2 | -------------------------------------------------------------------------------- /embedg-app/noembed.go: -------------------------------------------------------------------------------- 1 | //go:build !embedapp 2 | // +build !embedapp 3 | 4 | package embedgapp 5 | 6 | import "embed" 7 | 8 | var DistFS embed.FS 9 | -------------------------------------------------------------------------------- /embedg-app/public/whitney-font/Semibold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-app/public/whitney-font/Semibold.woff -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/017_add_entitlements_consumed.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE entitlements DROP COLUMN consumed, DROP COLUMN consumed_guild_id; -------------------------------------------------------------------------------- /embedg-site/docs/features/components-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/features/components-v2.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "position": 3, 3 | "label": "Guides", 4 | "collapsible": false, 5 | "collapsed": false 6 | } 7 | -------------------------------------------------------------------------------- /embedg-site/docs/guides/variables-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/variables-button.png -------------------------------------------------------------------------------- /embedg-site/docs/features/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "position": 4, 3 | "label": "Features", 4 | "collapsible": false, 5 | "collapsed": false 6 | } 7 | -------------------------------------------------------------------------------- /embedg-site/docs/guides/variables-response.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/variables-response.png -------------------------------------------------------------------------------- /embedg-site/noembed.go: -------------------------------------------------------------------------------- 1 | //go:build !embedsite 2 | // +build !embedsite 3 | 4 | package embedgsite 5 | 6 | import "embed" 7 | 8 | var DistFS embed.FS 9 | -------------------------------------------------------------------------------- /embedg-site/docs/features/ai-assistant-feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/features/ai-assistant-feature.png -------------------------------------------------------------------------------- /embedg-site/docs/features/components-v2-enable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/features/components-v2-enable.png -------------------------------------------------------------------------------- /embedg-site/docs/features/white-label-feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/features/white-label-feature.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/actions-assign-roles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/actions-assign-roles.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/actions-saved-response.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/actions-saved-response.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/actions-text-response.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/actions-text-response.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/custom-bots-configure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/custom-bots-configure.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/custom-bots-devportal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/custom-bots-devportal.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/custom-commands-first.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/custom-commands-first.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/interactive-components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/interactive-components.png -------------------------------------------------------------------------------- /embedg-site/static/img/feature-save-messages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/static/img/feature-save-messages.png -------------------------------------------------------------------------------- /embedg-site/docs/features/save-messages-feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/features/save-messages-feature.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/scheduled-messages-once.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/scheduled-messages-once.png -------------------------------------------------------------------------------- /embedg-site/docs/features/custom-branding-feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/features/custom-branding-feature.png -------------------------------------------------------------------------------- /embedg-site/docs/features/custom-branding-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/features/custom-branding-preview.png -------------------------------------------------------------------------------- /embedg-site/docs/features/custom-commands-feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/features/custom-commands-feature.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/discohook-message-backups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/discohook-message-backups.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/scheduled-messages-periodic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/scheduled-messages-periodic.png -------------------------------------------------------------------------------- /embedg-app/embed.go: -------------------------------------------------------------------------------- 1 | //go:build embedapp 2 | // +build embedapp 3 | 4 | package embedgapp 5 | 6 | import "embed" 7 | 8 | //go:embed dist/* 9 | var DistFS embed.FS 10 | -------------------------------------------------------------------------------- /embedg-server/store/error.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "errors" 4 | 5 | var ErrNotFound = errors.New("not found") 6 | var ErrAlreadyExists = errors.New("already exists") 7 | -------------------------------------------------------------------------------- /embedg-site/docs/guides/interactive-components-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/interactive-components-select.png -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-19-executing-webhooks/message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-19-executing-webhooks/message.png -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-19-executing-webhooks/webhooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-19-executing-webhooks/webhooks.png -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-20-discohook-alternative/editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-20-discohook-alternative/editor.png -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-20-discohook-alternative/embed.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-20-discohook-alternative/embed.jpg -------------------------------------------------------------------------------- /embedg-site/docs/features/interactive-components-feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/features/interactive-components-feature.png -------------------------------------------------------------------------------- /embedg-site/docs/guides/interactive-components-buttons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/docs/guides/interactive-components-buttons.png -------------------------------------------------------------------------------- /embedg-site/embed.go: -------------------------------------------------------------------------------- 1 | //go:build embedsite 2 | // +build embedsite 3 | 4 | package embedgsite 5 | 6 | import "embed" 7 | 8 | //go:embed dist/* 9 | var DistFS embed.FS 10 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/014_add_scheduled_messages_tz.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE scheduled_messages ADD COLUMN cron_timezone TEXT; -- The timezone to use for the cron expression 2 | -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-19-executing-webhooks/editor-send.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-19-executing-webhooks/editor-send.png -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-20-discohook-alternative/buttons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-20-discohook-alternative/buttons.png -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/017_add_entitlements_consumed.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE entitlements ADD COLUMN consumed BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN consumed_guild_id TEXT; 2 | -------------------------------------------------------------------------------- /embedg-server/store/entitlement.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "context" 4 | 5 | type EntitlementStore interface { 6 | GetEntitledUserIDs(ctx context.Context) ([]string, error) 7 | } 8 | -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-16-webhooks/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | slug: webhooks 3 | title: What are Webhooks? 4 | authors: [merlin] 5 | tags: [webhooks, discord, json, embed] 6 | draft: true 7 | --- 8 | -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-19-executing-webhooks/editor-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-19-executing-webhooks/editor-message.png -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-19-executing-webhooks/webhook-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-19-executing-webhooks/webhook-info.png -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-20-discohook-alternative/component.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-20-discohook-alternative/component.png -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-20-discohook-alternative/interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-20-discohook-alternative/interface.png -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-20-discohook-alternative/no-webhook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-20-discohook-alternative/no-webhook.png -------------------------------------------------------------------------------- /embedg-app/src/util/url.ts: -------------------------------------------------------------------------------- 1 | export const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, ""); 2 | 3 | export function getRelativeUrl(path: string): string { 4 | return `${baseUrl}${path}`; 5 | } 6 | -------------------------------------------------------------------------------- /embedg-site/blog/authors.yml: -------------------------------------------------------------------------------- 1 | merlin: 2 | name: Merlin 3 | title: Developer of Embed Generator 4 | url: https://github.com/merlinfuchs 5 | image_url: https://avatars.githubusercontent.com/u/33966852 6 | -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-19-executing-webhooks/docusaurus-plushie-banner.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinfuchs/embed-generator/HEAD/embedg-site/blog/2024-01-19-executing-webhooks/docusaurus-plushie-banner.jpeg -------------------------------------------------------------------------------- /embedg-site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /embedg-server/util/id.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import gonanoid "github.com/matoous/go-nanoid" 4 | 5 | func UniqueID() string { 6 | id, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyzAPCDEFGHIJKLMNOPQRSTUVWXYZ1234567890", 8) 7 | return id 8 | } 9 | -------------------------------------------------------------------------------- /embedg-app/src/util/discord.ts: -------------------------------------------------------------------------------- 1 | export function colorIntToHex(color: number) { 2 | return "#" + color.toString(16).padStart(6, "0"); 3 | } 4 | 5 | export function colorHexToInt(color: string) { 6 | return parseInt(color.replace("#", ""), 16); 7 | } 8 | -------------------------------------------------------------------------------- /embedg-app/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/004_create_message_action_sets_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS message_action_sets ( 2 | id TEXT PRIMARY KEY, 3 | message_id TEXT NOT NULL, 4 | set_id TEXT NOT NULL, 5 | actions JSONB NOT NULL 6 | ); 7 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/006_create_shared_messages_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS shared_messages ( 2 | id TEXT PRIMARY KEY, 3 | created_at TIMESTAMP NOT NULL, 4 | expires_at TIMESTAMP NOT NULL, 5 | data JSONB NOT NULL 6 | ); 7 | -------------------------------------------------------------------------------- /embedg-app/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .no-scrollbar::-webkit-scrollbar { 6 | display: none; 7 | } 8 | 9 | .no-scrollbar { 10 | -ms-overflow-style: none; 11 | scrollbar-width: none; 12 | } 13 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/001_create_users_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | id TEXT PRIMARY KEY, 3 | name TEXT NOT NULL, 4 | discriminator TEXT NOT NULL, 5 | avatar TEXT, 6 | is_tester BOOLEAN NOT NULL DEFAULT FALSE 7 | ); 8 | -------------------------------------------------------------------------------- /embedg-server/sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | sql: 3 | - engine: "postgresql" 4 | schema: 5 | - "db/postgres/migrations" 6 | queries: "db/postgres/queries" 7 | gen: 8 | go: 9 | package: "pgmodel" 10 | out: "db/postgres/pgmodel" 11 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/009_add_derived_permissions_column.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE message_action_sets DROP COLUMN derived_permissions, DROP COLUMN last_used_at, DROP COLUMN ephemeral; 2 | 3 | ALTER TABLE custom_commands DROP COLUMN derived_permissions, DROP COLUMN last_used_at; 4 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/012_add_custom_bots_gateway.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE custom_bots DROP COLUMN token_invalid, DROP COLUMN gateway_status, DROP COLUMN gateway_activity_type, DROP COLUMN gateway_activity_name, DROP COLUMN gateway_activity_state, DROP COLUMN gateway_activity_url; 2 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/images.sql: -------------------------------------------------------------------------------- 1 | -- name: InsertImage :one 2 | INSERT INTO images (id, guild_id, user_id, file_hash, file_name, file_content_type, file_size, s3_key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; 3 | 4 | -- name: GetImage :one 5 | SELECT * FROM images WHERE id = $1; -------------------------------------------------------------------------------- /embedg-site/blog/2023-02-23-discord-club/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | slug: discord-club 3 | title: The Migration from Discord.club 4 | authors: [merlin] 5 | tags: [discord, discord.club, embed, webhook] 6 | draft: true 7 | --- 8 | 9 | Why Embed Generator switched from the discord.club Domain to message.style. 10 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/010_create_db_indexes.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX ON saved_messages (guild_id); 2 | CREATE INDEX ON saved_messages (creator_id); 3 | 4 | CREATE INDEX ON custom_commands (guild_id); 5 | 6 | CREATE INDEX ON message_action_sets (message_id); 7 | CREATE INDEX ON message_action_sets (set_id); 8 | -------------------------------------------------------------------------------- /embedg-site/docs/features/ai-assistant.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 7 3 | --- 4 | 5 | # ⭐ AI Assistant 6 | 7 | With Embed Generator Premium you get your own AI assistant that helps you create beautiful messages and unleash your creativity. 8 | 9 | ![AI Assistant Feature Preview](./ai-assistant-feature.png) 10 | -------------------------------------------------------------------------------- /embedg-app/src/api/base.ts: -------------------------------------------------------------------------------- 1 | export type APIResponse = 2 | | { 3 | success: true; 4 | data: T; 5 | } 6 | | { 7 | success: false; 8 | error: APIError; 9 | }; 10 | 11 | export type APIError = { 12 | code: string; 13 | message: string; 14 | data?: Record; 15 | }; 16 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/005_create_entitlements_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS entitlements ( 2 | id TEXT PRIMARY KEY, 3 | user_id TEXT, 4 | guild_id TEXT, 5 | updated_at TIMESTAMP NOT NULL, 6 | deleted BOOLEAN NOT NULL, 7 | sku_id TEXT NOT NULL, 8 | starts_at TIMESTAMP, 9 | ends_at TIMESTAMP 10 | ); -------------------------------------------------------------------------------- /embedg-site/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /dist 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /embedg-app/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_DISCORD_ACTIVITY: string; 5 | readonly VITE_DISCORD_CLIENT_ID: string; 6 | readonly VITE_PUBLIC_HOST: string; 7 | readonly BASE_URL: string; 8 | } 9 | 10 | interface ImportMeta { 11 | readonly env: ImportMetaEnv; 12 | } 13 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/002_create_session_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS sessions ( 2 | token_hash TEXT PRIMARY KEY, 3 | user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, 4 | guild_ids TEXT[] NOT NULL, 5 | access_token TEXT NOT NULL, 6 | created_at TIMESTAMP NOT NULL, 7 | expires_at TIMESTAMP NOT NULL 8 | ); 9 | -------------------------------------------------------------------------------- /embedg-server/api/wire/auth.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | type AuthExchangeRequestWire struct { 4 | Code string `json:"code"` 5 | } 6 | 7 | type AuthExchangeResponseDataWire struct { 8 | AccessToken string `json:"access_token"` 9 | SessionToken string `json:"session_token"` 10 | } 11 | 12 | type AuthExchangeResponseWire APIResponse[AuthExchangeResponseDataWire] 13 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/012_add_custom_bots_gateway.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE custom_bots ADD COLUMN token_invalid BOOLEAN NOT NULL DEFAULT false, ADD COLUMN gateway_status TEXT NOT NULL DEFAULT 'online', ADD COLUMN gateway_activity_type SMALLINT, ADD COLUMN gateway_activity_name TEXT, ADD COLUMN gateway_activity_state TEXT, ADD COLUMN gateway_activity_url TEXT; 2 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/016_create_kv_entries_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS kv_entries ( 2 | key TEXT NOT NULL, 3 | guild_id TEXT NOT NULL, 4 | value TEXT NOT NULL, 5 | 6 | expires_at TIMESTAMP, 7 | created_at TIMESTAMP NOT NULL, 8 | updated_at TIMESTAMP NOT NULL, 9 | 10 | PRIMARY KEY (key, guild_id) 11 | ); 12 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/users.sql: -------------------------------------------------------------------------------- 1 | -- name: UpsertUser :one 2 | INSERT INTO users (id, name, discriminator, avatar) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET name = $2, discriminator = $3, avatar = $4 RETURNING *; 3 | 4 | -- name: GetUser :one 5 | SELECT * FROM users WHERE id = $1; 6 | 7 | -- name: DeleteUser :exec 8 | DELETE FROM users WHERE id = $1; -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/011_create_images_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS images ( 2 | id TEXT PRIMARY KEY, 3 | user_id TEXT NOT NULL, 4 | guild_id TEXT, 5 | file_hash TEXT NOT NULL, 6 | file_name TEXT NOT NULL, 7 | file_size INTEGER NOT NULL, 8 | file_content_type TEXT NOT NULL, 9 | s3_key TEXT NOT NULL 10 | ); 11 | -------------------------------------------------------------------------------- /embedg-server/entry/admin/cmd.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func Setup() *cobra.Command { 8 | adminRootCMD := &cobra.Command{ 9 | Use: "admin", 10 | Short: "Admin commands used for debugging and administration", 11 | } 12 | 13 | adminRootCMD.AddCommand(impersonateCMD()) 14 | 15 | return adminRootCMD 16 | } 17 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/003_create_saved_messages_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS saved_messages ( 2 | id TEXT PRIMARY KEY, 3 | creator_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, 4 | guild_id TEXT, 5 | updated_at TIMESTAMP NOT NULL, 6 | name TEXT NOT NULL, 7 | description TEXT, 8 | data JSONB NOT NULL 9 | ); 10 | -------------------------------------------------------------------------------- /embedg-site/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | slug: / 4 | --- 5 | 6 | # Tutorial 7 | 8 | 9 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/shared_messages.sql: -------------------------------------------------------------------------------- 1 | -- name: InsertSharedMessage :one 2 | INSERT INTO shared_messages (id, created_at, expires_at, data) VALUES ($1, $2, $3, $4) RETURNING *; 3 | 4 | -- name: GetSharedMessage :one 5 | SELECT * FROM shared_messages WHERE id = $1; 6 | 7 | -- name: DeleteExpiredSharedMessages :exec 8 | DELETE FROM shared_messages WHERE expires_at < $1; 9 | -------------------------------------------------------------------------------- /embedg-server/api/stores.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/merlinfuchs/embed-generator/embedg-server/bot" 5 | "github.com/merlinfuchs/embed-generator/embedg-server/db/postgres" 6 | "github.com/merlinfuchs/embed-generator/embedg-server/db/s3" 7 | ) 8 | 9 | type Stores struct { 10 | PG *postgres.PostgresStore 11 | Blob *s3.BlobStore 12 | Bot *bot.Bot 13 | } 14 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/009_add_derived_permissions_column.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE message_action_sets ADD COLUMN derived_permissions JSONB, ADD COLUMN last_used_at TIMESTAMP NOT NULL DEFAULT NOW(), ADD COLUMN ephemeral BOOLEAN NOT NULL DEFAULT FALSE; 2 | 3 | ALTER TABLE custom_commands ADD COLUMN derived_permissions JSONB, ADD COLUMN last_used_at TIMESTAMP NOT NULL DEFAULT NOW(); 4 | -------------------------------------------------------------------------------- /embedg-app/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .vite 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /embedg-app/src/components/RequestLoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { useIsFetching } from "react-query"; 2 | 3 | export default function RequestLoadingIndicator() { 4 | const isFetching = useIsFetching(); 5 | 6 | if (isFetching === 0) { 7 | return null; 8 | } 9 | 10 | return ( 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /embedg-server/.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: embedg-server 3 | tags: 4 | - embedapp 5 | - embedsite 6 | ldflags: 7 | - -s -w -X github.com/merlinfuchs/embed-generator/embedg-server/buildinfo.version={{.Version}} -X github.com/merlinfuchs/embed-generator/embedg-server/buildinfo.commit={{.Commit}} -X github.com/merlinfuchs/embed-generator/embedg-server/buildinfo.commitDate={{.CommitDate}} 8 | -------------------------------------------------------------------------------- /embedg-site/docs/features/custom-branding.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Custom Branding 6 | 7 | Embed Generator allows your to customize the username and avatar for your messages. This way the messages look exactly how you want and represent your brand! 8 | 9 | ![Custom Branding Feature Preview](./custom-branding-feature.png) 10 | 11 | ![Custom Branding Preview](./custom-branding-preview.png) 12 | -------------------------------------------------------------------------------- /embedg-site/docs/features/custom-commands.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | # ⭐ Custom Commands 6 | 7 | With Embed Generator Premium you can add your own commands to the bot. These can include custom logic, hand out roles or respond with your custom messages. 8 | 9 | ![Custom Commands Feature Preview](./custom-commands-feature.png) 10 | 11 | Read the full guide [here](../guides/custom-commands)! 12 | -------------------------------------------------------------------------------- /embedg-site/docs/features/white-label.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # ⭐ White Label 6 | 7 | With Embed Generator Premium you can add your own bot to Embed Generator. This way you can change the username and avatar for response messages to your interactive components. 8 | 9 | ![White Label Feature Preview](./white-label-feature.png) 10 | 11 | Read the full guide [here](../guides/custom-bots)! 12 | -------------------------------------------------------------------------------- /embedg-app/src/views/error.tsx: -------------------------------------------------------------------------------- 1 | import { useRouteError } from "react-router-dom"; 2 | 3 | export default function ErrorView() { 4 | const error = useRouteError() as any; 5 | 6 | return ( 7 |
8 |

Oops!

9 |

Sorry, an unexpected error has occurred.

10 |

11 | {error.statusText || error.message} 12 |

13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /embedg-app/src/components/LoginLink.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useLocation } from "react-router-dom"; 3 | 4 | export default function LoginLink(props: any) { 5 | const location = useLocation(); 6 | 7 | const href = useMemo( 8 | () => `/api/auth/login?redirect=${encodeURIComponent(location.pathname)}`, 9 | [location.pathname] 10 | ); 11 | 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /embedg-server/api/wire/user.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import ( 4 | "gopkg.in/guregu/null.v4" 5 | ) 6 | 7 | type UserWire struct { 8 | ID string `json:"id"` 9 | Name string `json:"name"` 10 | Discriminator string `json:"discriminator"` 11 | Avatar null.String `json:"avatar"` 12 | IsTester bool `json:"is_tester"` 13 | } 14 | 15 | type UserResponseWire APIResponse[UserWire] 16 | -------------------------------------------------------------------------------- /embedg-site/blog/2024-01-18-embed-json/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | slug: embed-json 3 | title: Generate Discord Embed JSON 4 | authors: [merlin] 5 | tags: [discord, embed, webhook, json, code] 6 | draft: true 7 | --- 8 | 9 | Using Embed Generator to generate the JSON code for a Discord embed message. The JSON code can be used to send the embed message using a Discord bot powered by discord.py, discord.js or any other programming language. 10 | -------------------------------------------------------------------------------- /embedg-app/src/components/LogoutLink.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useLocation } from "react-router-dom"; 3 | 4 | export default function LogoutLink(props: any) { 5 | const location = useLocation(); 6 | 7 | const href = useMemo( 8 | () => `/api/auth/logout?redirect=${encodeURIComponent(location.pathname)}`, 9 | [location.pathname] 10 | ); 11 | 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /embedg-app/src/components/Twemoji.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import Inner from "react-twemoji"; 3 | 4 | interface Props { 5 | children: ReactNode; 6 | options: any; 7 | } 8 | 9 | export default function Twemoji({ children, options }: Props) { 10 | const opt = options || {}; 11 | opt["base"] = "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/"; 12 | 13 | return {children}; 14 | } 15 | -------------------------------------------------------------------------------- /embedg-server/api/wire/assistant.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import "gopkg.in/guregu/null.v4" 4 | 5 | type AssistantGenerateMessageRequestWire struct { 6 | BaseData null.String `json:"base_data"` 7 | Prompt string `json:"prompt"` 8 | } 9 | 10 | type AssistantGenerateMessageResponseDataWire struct { 11 | Data string `json:"data"` 12 | } 13 | 14 | type AssistantGenerateMessageResponseWire APIResponse[AssistantGenerateMessageResponseDataWire] 15 | -------------------------------------------------------------------------------- /embedg-site/docs/features/interactive-components.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Interactive Components 6 | 7 | With Embed Generator you can add interactive components to your messages. With buttons and select menus you can create menus and hand out roles to your server members. 8 | 9 | ![Save Messages Feature Preview](./interactive-components-feature.png) 10 | 11 | Read the full guide [here](../guides/interactive-components)! 12 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/sessions.sql: -------------------------------------------------------------------------------- 1 | -- name: InsertSession :one 2 | INSERT INTO sessions (token_hash, user_id, guild_ids, access_token, created_at, expires_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; 3 | 4 | -- name: GetSession :one 5 | SELECT * FROM sessions WHERE token_hash = $1; 6 | 7 | -- name: DeleteSession :exec 8 | DELETE FROM sessions WHERE token_hash = $1; 9 | 10 | -- name: GetSessionsForUser :many 11 | SELECT * FROM sessions WHERE user_id = $1; -------------------------------------------------------------------------------- /embedg-server/store/plan.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/merlinfuchs/embed-generator/embedg-server/model" 7 | ) 8 | 9 | type PlanStore interface { 10 | GetPlanByID(id string) *model.Plan 11 | GetPlanBySKUID(skuID string) *model.Plan 12 | GetPlanFeaturesForGuild(ctx context.Context, guildID string) (model.PlanFeatures, error) 13 | GetPlanFeaturesForUser(ctx context.Context, userID string) (model.PlanFeatures, error) 14 | } 15 | -------------------------------------------------------------------------------- /embedg-app/src/views/settings.tsx: -------------------------------------------------------------------------------- 1 | import SettingsGeneral from "../components/SettingsGeneral"; 2 | import SettingsCustomBot from "../components/SettingsCustomBot"; 3 | 4 | export default function SettingsView() { 5 | return ( 6 |
7 |
8 | 9 | 10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/008_create_custom_commands_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS custom_commands ( 2 | id TEXT PRIMARY KEY, 3 | guild_id TEXT NOT NULL, 4 | name TEXT NOT NULL, 5 | description TEXT NOT NULL, 6 | enabled BOOLEAN NOT NULL DEFAULT true, 7 | parameters JSONB NOT NULL, 8 | actions JSONB NOT NULL, 9 | created_at TIMESTAMP NOT NULL, 10 | updated_at TIMESTAMP NOT NULL, 11 | deployed_at TIMESTAMP 12 | ); 13 | -------------------------------------------------------------------------------- /embedg-server/bot/rest/client.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | 8 | "github.com/merlinfuchs/discordgo" 9 | ) 10 | 11 | var ErrNotFound = errors.New("not found") 12 | 13 | type RestClient interface { 14 | Request(ctx context.Context, method string, url string, body io.Reader, options ...discordgo.RequestOption) ([]byte, error) 15 | 16 | GuildMember(ctx context.Context, guildID string, userID string) (*discordgo.Member, error) 17 | } 18 | -------------------------------------------------------------------------------- /embedg-server/model/kv.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "gopkg.in/guregu/null.v4" 7 | ) 8 | 9 | type KVEntry struct { 10 | Key string 11 | GuildID string 12 | Value string 13 | ExpiresAt null.Time 14 | CreatedAt time.Time 15 | UpdatedAt time.Time 16 | } 17 | 18 | type KVEntryIncreaseParams struct { 19 | Key string 20 | GuildID string 21 | Delta int 22 | ExpiresAt null.Time 23 | CreatedAt time.Time 24 | UpdatedAt time.Time 25 | } 26 | -------------------------------------------------------------------------------- /embedg-server/entry/database/logger.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | ) 6 | 7 | type migrationZeroLogger struct { 8 | zerologger zerolog.Logger 9 | verbose bool 10 | } 11 | 12 | // Printf is like fmt.Printf 13 | func (ml migrationZeroLogger) Printf(format string, v ...interface{}) { 14 | ml.zerologger.Info().Msgf(format, v...) 15 | } 16 | 17 | // Printf is like fmt.Printf 18 | func (ml migrationZeroLogger) Verbose() bool { 19 | return ml.verbose 20 | } 21 | -------------------------------------------------------------------------------- /embedg-server/api/wire/images.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import "gopkg.in/guregu/null.v4" 4 | 5 | type ImageWire struct { 6 | ID string `json:"id"` 7 | UserID string `json:"user_id"` 8 | GuildID null.String `json:"guild_id"` 9 | FileName string `json:"file_name"` 10 | FileSize int32 `json:"file_size"` 11 | CDNURL string `json:"cdn_url"` 12 | } 13 | 14 | type UploadImageResponseWire APIResponse[ImageWire] 15 | 16 | type GetImageResponseWire APIResponse[ImageWire] 17 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/007_create_custom_bots_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS custom_bots ( 2 | id TEXT PRIMARY KEY, 3 | guild_id TEXT NOT NULL UNIQUE, 4 | application_id TEXT NOT NULL, 5 | token TEXT NOT NULL, 6 | public_key TEXT NOT NULL, 7 | user_id TEXT NOT NULL, 8 | user_name TEXT NOT NULL, 9 | user_discriminator TEXT NOT NULL, 10 | user_avatar TEXT, 11 | handled_first_interaction BOOLEAN NOT NULL DEFAULT false, 12 | created_at TIMESTAMP NOT NULL 13 | ); 14 | -------------------------------------------------------------------------------- /embedg-site/docs/features/save-messages.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Save Messages 6 | 7 | Embed Generator allows you to store your created messages in the cloud and even share them with your other server administrators. 8 | 9 | You can access saved messages from any device by logging in with your Discord account. You can decide to save your messages to your personal Discord account or save them to one of your Discord servers so all the admins can access them. 10 | 11 | ![Save Messages Feature Preview](./save-messages-feature.png) 12 | -------------------------------------------------------------------------------- /embedg-app/src/components/ToolsBackButton.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeftIcon } from "@heroicons/react/24/outline"; 2 | import { Link } from "react-router-dom"; 3 | 4 | export default function ToolsBackButton() { 5 | return ( 6 |
7 | 11 | 12 |
Other Tools
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /embedg-app/src/state/activity.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | export interface ActivityStateStore { 4 | loading: boolean; 5 | error: string | null; 6 | setError(error: string | null): void; 7 | setLoading(loading: boolean): void; 8 | } 9 | 10 | export const useActivityStateStore = create()( 11 | (set, get) => ({ 12 | loading: false, 13 | error: null, 14 | setError: (error) => { 15 | set({ error }); 16 | }, 17 | setLoading: (loading) => { 18 | set({ loading }); 19 | }, 20 | }) 21 | ); 22 | -------------------------------------------------------------------------------- /embedg-site/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import HomeHero from "../components/HomeHero"; 3 | import HomeHeader from "../components/HomeHeader"; 4 | import HomeFeatures from "../components/HomeFeatures"; 5 | import HomeFooter from "../components/HomeFooter"; 6 | 7 | import "../css/tailwind.css"; 8 | 9 | export default function Home(): JSX.Element { 10 | return ( 11 |
12 | 13 | 14 | 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/message_action_sets.sql: -------------------------------------------------------------------------------- 1 | -- name: InsertMessageActionSet :one 2 | INSERT INTO message_action_sets (id, message_id, set_id, actions, derived_permissions, ephemeral) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; 3 | 4 | -- name: GetMessageActionSet :one 5 | SELECT * FROM message_action_sets WHERE message_id = $1 AND set_id = $2; 6 | 7 | -- name: GetMessageActionSets :many 8 | SELECT * FROM message_action_sets WHERE message_id = $1; 9 | 10 | -- name: DeleteMessageActionSetsForMessage :exec 11 | DELETE FROM message_action_sets WHERE message_id = $1; 12 | -------------------------------------------------------------------------------- /embedg-server/entry/server/stores.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/merlinfuchs/embed-generator/embedg-server/db/postgres" 5 | "github.com/merlinfuchs/embed-generator/embedg-server/db/s3" 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | type stores struct { 10 | pg *postgres.PostgresStore 11 | blob *s3.BlobStore 12 | } 13 | 14 | func createStores() *stores { 15 | pg := postgres.NewPostgresStore() 16 | 17 | blob, err := s3.New() 18 | if err != nil { 19 | log.Fatal().Err(err).Msg("Failed to initialize blob store") 20 | } 21 | 22 | return &stores{ 23 | pg: pg, 24 | blob: blob, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /embedg-app/src/components/CheckBox.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from "@heroicons/react/20/solid"; 2 | import clsx from "clsx"; 3 | 4 | interface Props { 5 | checked: boolean; 6 | onChange: (checked: boolean) => void; 7 | height?: 9 | 10; 8 | } 9 | 10 | export default function CheckBox({ checked, onChange, height }: Props) { 11 | return ( 12 |
onChange(!checked)} 19 | > 20 | {checked && } 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /embedg-app/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | import { QueryClientProvider } from "react-query"; 6 | import queryClient from "./api/client"; 7 | import { BrowserRouter } from "react-router-dom"; 8 | import { baseUrl } from "./util/url"; 9 | 10 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /embedg-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /embedg-server/buildinfo/buildinfo.go: -------------------------------------------------------------------------------- 1 | package buildinfo 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | // Field injected by goreleaser 9 | var ( 10 | version = "" 11 | commitDate = "date unknown" 12 | commit = "" 13 | ) 14 | 15 | func Version() string { 16 | return version 17 | } 18 | 19 | func CommitDate() string { 20 | return commitDate 21 | } 22 | 23 | func Commit() string { 24 | return commit 25 | } 26 | 27 | func Target() string { 28 | return runtime.GOOS 29 | } 30 | 31 | func FullVersion() string { 32 | return fmt.Sprintf("%s %s/%s %s (%s) %s", 33 | version, runtime.GOOS, runtime.GOARCH, runtime.Version(), commitDate, commit) 34 | } 35 | -------------------------------------------------------------------------------- /tygo.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - path: "github.com/merlinfuchs/embed-generator/embedg-server/api/wire" 3 | output_path: "embedg-app/src/api/wire.ts" 4 | type_mappings: 5 | null.String: "null | string" 6 | null.Bool: "null | boolean" 7 | null.Float: "null | number" 8 | null.Int: "null | number" 9 | uuid.UUID: "string /* uuid */" 10 | uuid.NullUUID: "null | string /* uuid */" 11 | time.Time: "string /* RFC3339 */" 12 | null.Time: "null | string /* RFC3339 */" 13 | json.RawMessage: "Record | null" 14 | exclude_files: 15 | - "base.go" 16 | frontmatter: | 17 | import {APIResponse} from "./base" 18 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/015_create_embed_links_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS embed_links ( 2 | id TEXT PRIMARY KEY, 3 | 4 | url TEXT NOT NULL, 5 | theme_color TEXT, 6 | 7 | -- Additional OpenGraph metadata 8 | og_title TEXT, 9 | og_site_name TEXT, 10 | og_description TEXT, 11 | og_image TEXT, 12 | 13 | -- Additional oEmbed metadata 14 | oe_type TEXT, 15 | oe_author_name TEXT, 16 | oe_author_url TEXT, 17 | oe_provider_name TEXT, 18 | oe_provider_url TEXT, 19 | 20 | -- Additional Twitter metadata 21 | tw_card TEXT, 22 | 23 | expires_at TIMESTAMP, 24 | created_at TIMESTAMP NOT NULL 25 | ); 26 | -------------------------------------------------------------------------------- /embedg-server/api/wire/health.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import "time" 4 | 5 | type ShardListWire struct { 6 | ShardCount int `json:"shard_count"` 7 | Shards []ShardWire `json:"shards"` 8 | } 9 | 10 | type ShardWire struct { 11 | ID int `json:"id"` 12 | HasSession bool `json:"has_session"` 13 | LastHeartbeatAck time.Time `json:"last_heartbeat_ack"` 14 | LastHeartbeatSent time.Time `json:"last_heartbeat_sent"` 15 | ShouldReconnectOnError bool `json:"should_reconnect_on_error"` 16 | ShouldRetryOnRateLimit bool `json:"should_retry_on_rate_limit"` 17 | Suspicious bool `json:"suspicious"` 18 | } 19 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorComponentSectionAccessory.tsx: -------------------------------------------------------------------------------- 1 | import EditorComponentChildButton from "./EditorComponentActionRowButton"; 2 | 3 | export default function EditorComponentSectionAccessory({ 4 | rootIndex, 5 | rootId, 6 | }: { 7 | rootIndex: number; 8 | rootId: number; 9 | }) { 10 | return ( 11 |
12 |
13 |
Accessory
14 |
15 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorMessageContentField.tsx: -------------------------------------------------------------------------------- 1 | import { useCurrentMessageStore } from "../state/message"; 2 | import EditorInput from "./EditorInput"; 3 | 4 | export default function EditorMessageContentField() { 5 | const content = useCurrentMessageStore((state) => state.content); 6 | const setContent = useCurrentMessageStore((state) => state.setContent); 7 | 8 | return ( 9 |
10 | setContent(v)} 15 | maxLength={2000} 16 | validationPath={`content`} 17 | controls={true} 18 | /> 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /embedg-server/store/kv.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/merlinfuchs/embed-generator/embedg-server/model" 7 | ) 8 | 9 | type KVEntryStore interface { 10 | GetKVEntry(ctx context.Context, guildID string, key string) (model.KVEntry, error) 11 | SetKVEntry(ctx context.Context, entry model.KVEntry) error 12 | IncreaseKVEntry(ctx context.Context, params model.KVEntryIncreaseParams) (model.KVEntry, error) 13 | DeleteKVEntry(ctx context.Context, guildID string, key string) (model.KVEntry, error) 14 | SearchKVEntries(ctx context.Context, guildID string, pattern string) ([]model.KVEntry, error) 15 | CountKVEntries(ctx context.Context, guildID string) (int, error) 16 | } 17 | -------------------------------------------------------------------------------- /embedg-app/src/components/ValidationError.tsx: -------------------------------------------------------------------------------- 1 | import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; 2 | import { useValidationErrorStore } from "../state/validationError"; 3 | 4 | interface Props { 5 | path: string; 6 | } 7 | 8 | export default function ValidationError({ path }: Props) { 9 | const issue = useValidationErrorStore( 10 | (state) => state.getIssueByPath(path)?.message 11 | ); 12 | 13 | if (issue) { 14 | return ( 15 |
16 | 17 |
{issue}
18 |
19 | ); 20 | } else { 21 | return null; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /embedg-app/src/components/DateTimePicker.tsx: -------------------------------------------------------------------------------- 1 | import ReactDateTimePicker from "react-datetime-picker"; 2 | 3 | import "react-datetime-picker/dist/DateTimePicker.css"; 4 | import "react-calendar/dist/Calendar.css"; 5 | import "react-clock/dist/Clock.css"; 6 | import "./DateTimePicker.css"; 7 | 8 | interface Props { 9 | value: string | undefined; 10 | onChange: (v: string | undefined) => void; 11 | clearable: boolean; 12 | } 13 | 14 | export default function DateTimePicker({ value, onChange, clearable }: Props) { 15 | return ( 16 | onChange(v?.toISOString())} 18 | value={value} 19 | clearIcon={clearable ? undefined : null} 20 | /> 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/embed_links.sql: -------------------------------------------------------------------------------- 1 | -- name: InsertEmbedLink :one 2 | INSERT INTO embed_links ( 3 | id, 4 | url, 5 | theme_color, 6 | og_title, 7 | og_site_name, 8 | og_description, 9 | og_image, 10 | oe_type, 11 | oe_author_name, 12 | oe_author_url, 13 | oe_provider_name, 14 | oe_provider_url, 15 | tw_card, 16 | expires_at, 17 | created_at 18 | ) VALUES ( 19 | $1, 20 | $2, 21 | $3, 22 | $4, 23 | $5, 24 | $6, 25 | $7, 26 | $8, 27 | $9, 28 | $10, 29 | $11, 30 | $12, 31 | $13, 32 | $14, 33 | $15 34 | ) RETURNING *; 35 | 36 | -- name: GetEmbedLink :one 37 | SELECT * FROM embed_links WHERE id = $1; -------------------------------------------------------------------------------- /embedg-app/src/components/ValidationErrorIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; 2 | import { useValidationErrorStore } from "../state/validationError"; 3 | 4 | interface Props { 5 | pathPrefix: string | string[]; 6 | } 7 | 8 | export default function ValidationErrorIndicator({ pathPrefix }: Props) { 9 | const error = useValidationErrorStore((state) => 10 | typeof pathPrefix === "string" 11 | ? state.checkIssueByPathPrefix(pathPrefix) 12 | : pathPrefix.some((prefix) => state.checkIssueByPathPrefix(prefix)) 13 | ); 14 | 15 | if (error) { 16 | return ; 17 | } else { 18 | return null; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /embedg-server/api/wire/shared_message.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | type SharedMessageWire struct { 9 | ID string `json:"id"` 10 | CreatedAt time.Time `json:"created_at"` 11 | ExpiresAt time.Time `json:"expires_at"` 12 | Data json.RawMessage `json:"data"` 13 | URL string `json:"url"` 14 | } 15 | 16 | type SharedMessageCreateRequestWire struct { 17 | Data json.RawMessage `json:"data"` 18 | } 19 | 20 | func (req SharedMessageCreateRequestWire) Validate() error { 21 | return nil 22 | } 23 | 24 | type SharedMessageCreateResponseWire APIResponse[SharedMessageWire] 25 | 26 | type SharedMessageGetResponseWire APIResponse[SharedMessageWire] 27 | -------------------------------------------------------------------------------- /embedg-app/src/views/editor/clear.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "react-router-dom"; 2 | import ConfirmModal from "../../components/ConfirmModal"; 3 | import { defaultMessage, useCurrentMessageStore } from "../../state/message"; 4 | 5 | export default function ClearView() { 6 | const navigate = useNavigate(); 7 | 8 | function clear() { 9 | useCurrentMessageStore.setState(defaultMessage); 10 | navigate("/editor"); 11 | } 12 | 13 | return ( 14 | navigate("/editor")} 18 | onConfirm={clear} 19 | /> 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /embedg-app/src/components/ConfirmOnExit.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useSettingsStore } from "../state/settings"; 3 | 4 | export default function ConfirmOnExit() { 5 | const confirmOnExit = useSettingsStore((s) => s.confirmOnExit); 6 | 7 | useEffect(() => { 8 | if (!confirmOnExit) { 9 | return; 10 | } 11 | 12 | function onBeforeUnload(e: BeforeUnloadEvent) { 13 | e.preventDefault(); 14 | return (e.returnValue = "Are you sure you want to leave?"); 15 | } 16 | 17 | window.addEventListener("beforeunload", onBeforeUnload); 18 | 19 | return () => { 20 | window.removeEventListener("beforeunload", onBeforeUnload); 21 | }; 22 | }, [confirmOnExit]); 23 | 24 | return null; 25 | } 26 | -------------------------------------------------------------------------------- /embedg-server/db/s3/db_backups.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/minio/minio-go/v7" 9 | ) 10 | 11 | const dbBackupBucket = "embedg-db-backups" 12 | 13 | func (c *BlobStore) StoreDBBackup( 14 | ctx context.Context, 15 | database string, 16 | key string, 17 | size int64, 18 | reader io.Reader, 19 | ) error { 20 | objectName := fmt.Sprintf("%s/%s.tar.gz", database, key) 21 | 22 | _, err := c.client.PutObject(ctx, dbBackupBucket, objectName, reader, size, minio.PutObjectOptions{ 23 | ContentType: "application/tar+gzip", 24 | ServerSideEncryption: c.encryption, 25 | }) 26 | if err != nil { 27 | return fmt.Errorf("failed to store db backup: %w", err) 28 | } 29 | 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/pgmodel/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.27.0 4 | 5 | package pgmodel 6 | 7 | import ( 8 | "context" 9 | "database/sql" 10 | ) 11 | 12 | type DBTX interface { 13 | ExecContext(context.Context, string, ...interface{}) (sql.Result, error) 14 | PrepareContext(context.Context, string) (*sql.Stmt, error) 15 | QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) 16 | QueryRowContext(context.Context, string, ...interface{}) *sql.Row 17 | } 18 | 19 | func New(db DBTX) *Queries { 20 | return &Queries{db: db} 21 | } 22 | 23 | type Queries struct { 24 | db DBTX 25 | } 26 | 27 | func (q *Queries) WithTx(tx *sql.Tx) *Queries { 28 | return &Queries{ 29 | db: tx, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /embedg-server/actions/template/writer.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import "io" 4 | 5 | type limitedWriter struct { 6 | W io.Writer 7 | N int64 8 | } 9 | 10 | func (l *limitedWriter) Write(p []byte) (n int, err error) { 11 | if l.N <= 0 { 12 | return 0, io.ErrShortWrite 13 | } 14 | if int64(len(p)) > l.N { 15 | p = p[0:l.N] 16 | err = io.ErrShortWrite 17 | } 18 | n, er := l.W.Write(p) 19 | if er != nil { 20 | err = er 21 | } 22 | l.N -= int64(n) 23 | return n, err 24 | } 25 | 26 | // LimitWriter works like io.LimitReader. It writes at most n bytes 27 | // to the underlying Writer. It returns io.ErrShortWrite if more than n 28 | // bytes are attempted to be written. 29 | func LimitWriter(w io.Writer, n int64) io.Writer { 30 | return &limitedWriter{W: w, N: n} 31 | } 32 | -------------------------------------------------------------------------------- /embedg-app/src/util/premium.ts: -------------------------------------------------------------------------------- 1 | import { 2 | usePremiumGuildFeaturesQuery, 3 | usePremiumUserFeaturesQuery, 4 | } from "../api/queries"; 5 | import { useSendSettingsStore } from "../state/sendSettings"; 6 | 7 | export function usePremiumGuildFeatures(guildId?: string | null) { 8 | const selectedGuildID = useSendSettingsStore().guildId; 9 | if (guildId === undefined) { 10 | guildId = selectedGuildID; 11 | } 12 | 13 | const { data } = usePremiumGuildFeaturesQuery(guildId); 14 | 15 | if (!data?.success) { 16 | return null; 17 | } 18 | 19 | return data.data; 20 | } 21 | 22 | export function usePremiumUserFeatures() { 23 | const { data } = usePremiumUserFeaturesQuery(); 24 | 25 | if (!data?.success) { 26 | return null; 27 | } 28 | 29 | return data.data; 30 | } 31 | -------------------------------------------------------------------------------- /embedg-site/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,jsx,ts,tsx}", "./docusaurus.config.js"], 4 | darkMode: "class", 5 | theme: { 6 | extend: { 7 | colors: { 8 | blurple: "#5865F2", 9 | "blurple-dark": "#4650c7", 10 | green: "#57F287", 11 | yellow: "#FEE75C", 12 | fuchsia: "#EB459E", 13 | red: "#ED4245", 14 | "dark-1": "#18191c", 15 | "dark-2": "#1f2225", 16 | "dark-3": "#2e3136", 17 | "dark-4": "#36393e", 18 | "dark-5": "#3e4247", 19 | "dark-6": "#45494f", 20 | "dark-7": "#71757d", 21 | }, 22 | }, 23 | }, 24 | plugins: [], 25 | corePlugins: { 26 | preflight: false, 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /embedg-app/src/util/autoAnimate.tsx: -------------------------------------------------------------------------------- 1 | import autoAnimate, { AutoAnimateOptions } from "@formkit/auto-animate"; 2 | import { ReactNode, useEffect, useState } from "react"; 3 | 4 | export function useAutoAnimate(options: Partial = {}) { 5 | const [element, setElement] = useState(null); 6 | useEffect(() => { 7 | if (element instanceof HTMLElement) autoAnimate(element, options); 8 | }, [element, options]); 9 | return [setElement]; 10 | } 11 | 12 | interface Props { 13 | children: ReactNode; 14 | className?: string; 15 | } 16 | 17 | export function AutoAnimate({ children, className }: Props) { 18 | const [setElement] = useAutoAnimate(); 19 | return ( 20 |
21 | {children} 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /embedg-server/api/wire/base.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type APIResponse[Data any] struct { 8 | Success bool `json:"success"` 9 | Data Data `json:"data"` 10 | Error *Error `json:"error,omitempty"` 11 | } 12 | 13 | type Error struct { 14 | Status int `json:"-"` 15 | Code string `json:"code"` 16 | Message string `json:"message"` 17 | Data interface{} `json:"data,omitempty"` 18 | } 19 | 20 | func (e *Error) Error() string { 21 | return e.Message 22 | } 23 | 24 | func (e Error) MarshalJSON() ([]byte, error) { 25 | type Alias Error 26 | 27 | wrapped := struct { 28 | Success bool `json:"success"` 29 | Error Alias `json:"error,omitempty"` 30 | }{ 31 | Success: false, 32 | Error: Alias(e), 33 | } 34 | 35 | return json.Marshal(wrapped) 36 | } 37 | -------------------------------------------------------------------------------- /embedg-server/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | restart: always 7 | ports: 8 | - "${PG_HOST_PORT:-5432}:5432" 9 | volumes: 10 | - embedg-local-postgres:/var/lib/postgresql/data 11 | environment: 12 | POSTGRES_USER: postgres 13 | POSTGRES_DB: embedg 14 | PGUSER: postgres 15 | PGDATA: /var/lib/postgresql/data/pgdata 16 | POSTGRES_HOST_AUTH_METHOD: trust 17 | 18 | minio: 19 | image: quay.io/minio/minio 20 | command: server --console-address ":9001" /data 21 | ports: 22 | - "9000:9000" 23 | - "9001:9001" 24 | environment: 25 | MINIO_ROOT_USER: embedg 26 | MINIO_ROOT_PASSWORD: 1234567890 27 | volumes: 28 | - embedg-local-minio:/data 29 | 30 | volumes: 31 | embedg-local-postgres: 32 | embedg-local-minio: 33 | -------------------------------------------------------------------------------- /embedg-app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | import { resolve } from "path"; 4 | 5 | export default ({ mode }) => { 6 | const env = loadEnv(mode, process.cwd(), ""); 7 | 8 | return defineConfig({ 9 | plugins: [react()], 10 | base: env.VITE_DISCORD_ACTIVITY === "true" ? undefined : "/app", 11 | server: { 12 | proxy: { 13 | "/api": { 14 | target: "http://127.0.0.1:8080", 15 | }, 16 | "/e": { 17 | target: "http://127.0.0.1:8080", 18 | }, 19 | }, 20 | base: "/app/", 21 | }, 22 | build: { 23 | rollupOptions: { 24 | input: { 25 | main: resolve(__dirname, "index.html"), 26 | // nested: resolve(__dirname, "nested/index.html"), 27 | }, 28 | }, 29 | }, 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /embedg-app/src/components/ClickOutsideHandler.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useRef } from "react"; 2 | 3 | interface Props { 4 | onClickOutside: () => void; 5 | children: ReactNode; 6 | className?: string; 7 | } 8 | 9 | export default function ClickOutsideHandler({ 10 | onClickOutside, 11 | children, 12 | className, 13 | }: Props) { 14 | const ref = useRef(null); 15 | 16 | useEffect(() => { 17 | function handleClick(e: MouseEvent) { 18 | if (ref.current && !ref.current.contains(e.target as Node)) { 19 | onClickOutside(); 20 | } 21 | } 22 | document.addEventListener("mousedown", handleClick); 23 | return () => document.removeEventListener("mousedown", handleClick); 24 | }, [onClickOutside]); 25 | 26 | return ( 27 |
28 | {children} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /embedg-server/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | var CfgFile string 11 | 12 | func InitConfig() { 13 | if CfgFile != "" { 14 | // Use config file from the flag. 15 | fmt.Println("Using config file from flag:", CfgFile) 16 | viper.SetConfigFile(CfgFile) 17 | } else { 18 | viper.AddConfigPath("./") 19 | viper.SetConfigName("config") 20 | } 21 | 22 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "__")) 23 | viper.SetEnvPrefix("embedg") 24 | viper.AutomaticEnv() // read in environment variables that match 25 | 26 | // If a config file is found, read it in. 27 | err := viper.ReadInConfig() 28 | if err != nil { 29 | fmt.Println("WARN could not find config file", err) 30 | } else { 31 | fmt.Println("Using config file:", viper.ConfigFileUsed()) 32 | } 33 | 34 | setupDefaults() 35 | } 36 | -------------------------------------------------------------------------------- /embedg-app/src/state/upsell.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | 4 | const upsellAfterSeconds = 5 * 60; 5 | 6 | export interface UpsellStateStore { 7 | pageFirstOpenedAt: number; 8 | upsellClosed: boolean; 9 | 10 | setUpsellClosed: (upsellClosed: boolean) => void; 11 | shouldUpsell: () => boolean; 12 | } 13 | 14 | export const useUpsellStateStore = create()( 15 | persist( 16 | (set, get) => ({ 17 | pageFirstOpenedAt: Date.now(), 18 | upsellClosed: false, 19 | 20 | setUpsellClosed: (upsellClosed: boolean) => set({ upsellClosed }), 21 | shouldUpsell: () => { 22 | if (get().upsellClosed) { 23 | return false; 24 | } 25 | 26 | return Date.now() - get().pageFirstOpenedAt > upsellAfterSeconds * 1000; 27 | }, 28 | }), 29 | { name: "upselling", version: 0 } 30 | ) 31 | ); 32 | -------------------------------------------------------------------------------- /embedg-server/entry/server/cmd.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/merlinfuchs/embed-generator/embedg-server/api" 5 | "github.com/merlinfuchs/embed-generator/embedg-server/bot" 6 | "github.com/rs/zerolog/log" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func Setup() *cobra.Command { 12 | serverRootCmd := &cobra.Command{ 13 | Use: "server", 14 | Short: "Start the server", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | startServer() 17 | }, 18 | } 19 | 20 | return serverRootCmd 21 | } 22 | 23 | func startServer() { 24 | stores := createStores() 25 | 26 | bot, err := bot.New(viper.GetString("discord.token"), stores.pg) 27 | if err != nil { 28 | log.Fatal().Err(err).Msg("Failed to initialize bot") 29 | } 30 | 31 | go bot.Start() 32 | 33 | api.Serve(&api.Stores{ 34 | PG: stores.pg, 35 | Blob: stores.blob, 36 | Bot: bot, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /embedg-site/docs/features/components-v2.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Components V2 6 | 7 | Embed Generator has experimental support for Discord's new Components V2. They replace regular embeds and can't be mixed with them. 8 | 9 | With Components V2 you have more control over the look and feel of your messages. You can now add separators, multiple columns, images, and more. 10 | 11 | **⭐ Some components are currently only available with Embed Generator Premium.** 12 | 13 | ![Components V2 Example](./components-v2.png) 14 | 15 | ## Enable Components V2 16 | 17 | To enable Components V2, click on the "Components V2" button in the editor menu bar. This will remove all existing data from the editor. 18 | 19 | ![Enable Components V2](./components-v2-enable.png) 20 | 21 | You can also disable Components V2 by clicking on the "Components V2" button again. This will again remove all existing data from the editor. 22 | -------------------------------------------------------------------------------- /embedg-site/docs/guides/scheduled-messages.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Scheduled Messages 6 | 7 | Scheduled Messages let you scheduled messages to be sent at a later point. This way you can define an exact point in time where your message will be sent. 8 | 9 | ## Send Once 10 | 11 | Usually you want your message to be sent once at a specific time. Just select the saved message, the target channel, and a time and give your scheduled message a name. 12 | 13 | Make sure there are no errors in the selected message, otherwise the message will not be sent. 14 | 15 | ![Scheduled Messages Once](./scheduled-messages-once.png) 16 | 17 | ## Send Periodically 18 | 19 | You can also select messages to be sent periodically, so every hour, day or week. 20 | 21 | This feature is only available to [Embed Generator Premium](../premium) subscribers. 22 | 23 | ![Scheduled Messages Periodic](./scheduled-messages-periodic.png) 24 | -------------------------------------------------------------------------------- /embedg-site/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | module.exports = sidebars; 34 | -------------------------------------------------------------------------------- /embedg-site/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /embedg-app/src/discord/cdn.ts: -------------------------------------------------------------------------------- 1 | export function userAvatarUrl( 2 | user: { id: string; discriminator: string; avatar: string | null }, 3 | size: number = 128 4 | ) { 5 | if (user.avatar) { 6 | return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=${size}`; 7 | } else { 8 | let defaultAvatar: number | BigInt = parseInt(user.discriminator) % 5; 9 | if (!user.discriminator || user.discriminator === "0") { 10 | defaultAvatar = (BigInt(user.id) >> 22n) % 6n; 11 | } 12 | 13 | return `https://cdn.discordapp.com/embed/avatars/${defaultAvatar}.png?size=${size}`; 14 | } 15 | } 16 | 17 | export function guildIconUrl( 18 | guild: { id: string; icon: string | null }, 19 | size: number = 128 20 | ) { 21 | if (guild.icon) { 22 | return `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=${size}`; 23 | } else { 24 | return `https://cdn.discordapp.com/embed/avatars/0.png?size=${size}`; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/migrations/013_create_scheduled_messages_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS scheduled_messages ( 2 | id TEXT PRIMARY KEY, 3 | creator_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, 4 | guild_id TEXT NOT NULL, 5 | channel_id TEXT NOT NULL, 6 | message_id TEXT, 7 | saved_message_id TEXT NOT NULL, 8 | name TEXT NOT NULL, 9 | description TEXT, 10 | cron_expression TEXT, -- This may be null if the message is scheduled to only be sent once 11 | only_once BOOLEAN NOT NULL DEFAULT false, -- Whether the message should be sent only once or repeatedly 12 | start_at TIMESTAMP NOT NULL, -- The first time the message was / will be sent 13 | end_at TIMESTAMP, -- The last time the message was / will be sent 14 | next_at TIMESTAMP NOT NULL, -- The next or only time the message should be sent 15 | enabled BOOLEAN NOT NULL DEFAULT true, 16 | created_at TIMESTAMP NOT NULL, 17 | updated_at TIMESTAMP NOT NULL 18 | ); 19 | -------------------------------------------------------------------------------- /embedg-app/src/views/tools/webhookInfo.tsx: -------------------------------------------------------------------------------- 1 | import ToolsBackButton from "../../components/ToolsBackButton"; 2 | import ToolsWebhookInfo from "../../components/ToolsWebhookInfo"; 3 | 4 | export default function WebhookInfoToolInfo() { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 |

12 | Webhook Info 13 |

14 |

15 | Discord webhooks are a great way to send messages to Discord 16 | channels without a bot. This tool lets you easily inspect and get 17 | information about a webhook. 18 |

19 |
20 | 21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /embedg-site/docs/premium.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Embed Generator Premium 6 | 7 | You want to get the most out of Embed Generator? Then [Embed Generator Premium](https://message.style/premium) is the right thing for you! 8 | 9 | You pay $4.99 a month to **support the development Embed Generator** and get the following perks for your server: 10 | 11 | - **Up to 100 saved messages**: Save up to 100 messages instead of only 25 12 | - **Up to 5 component actions**: Add up to 5 actions to each interactive component instead of only 2 13 | - **Custom Bot**: Add a custom bot to Embed Generator to change the username and avatar in interaction response messages 14 | - **Custom Commands**: Add custom commands to your bot which your server members can use 15 | - **Periodically Scheduled Messages**: Schedule messages to be send multiple times (e.g. every hour, day, or week) 16 | - **AI Assistant**: Get an AI assistant that will help you create good looking messages 17 | 18 | -> [Get it now!](https://message.style/premium) 19 | -------------------------------------------------------------------------------- /embedg-app/src/views/tools/embedLinks.tsx: -------------------------------------------------------------------------------- 1 | import ToolsBackButton from "../../components/ToolsBackButton"; 2 | import ToolsEmbedLinks from "../../components/ToolsEmbedLinks"; 3 | 4 | export default function EmbedLinksToolView() { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 |

12 | Embed Links 13 |

14 |

15 | Embed links are a way to share rich embeds with others without 16 | needing to send the actual embed. They only support a subset of 17 | the features of the actual embeds but can be send anywhere. 18 |

19 |
20 | 21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /embedg-server/config/defaults.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/spf13/viper" 4 | 5 | func setupDefaults() { 6 | v := viper.GetViper() 7 | 8 | v.SetDefault("discord.activity_name", "message.style") 9 | 10 | // Postgres defaults 11 | v.SetDefault("postgres.host", "localhost") 12 | v.SetDefault("postgres.port", 5432) 13 | v.SetDefault("postgres.dbname", "embedg") 14 | v.SetDefault("postgres.user", "postgres") 15 | v.SetDefault("postgres.password", "") 16 | 17 | // S3 defaults 18 | v.SetDefault("s3.endpoint", "localhost:9000") 19 | v.SetDefault("s3.access_key_id", "embedg") 20 | v.SetDefault("s3.secret_access_key", "1234567890") 21 | 22 | v.SetDefault("app.public_url", "http://localhost:5173/app") 23 | 24 | // v.SetDefault("nats.url", "nats://localhost:4222") 25 | 26 | // API defaults 27 | v.SetDefault("api.host", "localhost") 28 | v.SetDefault("api.port", 8080) 29 | v.SetDefault("api.public_url", "http://localhost:5173/api") 30 | 31 | // CDN defaults 32 | v.SetDefault("cdn.public_url", "http://localhost:8080/cdn") 33 | } 34 | -------------------------------------------------------------------------------- /embedg-app/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | blurple: "#5865F2", 8 | "blurple-dark": "#4650c7", 9 | green: "#57F287", 10 | yellow: "#FEE75C", 11 | fuchsia: "#EB459E", 12 | red: "#ED4245", 13 | "dark-1": "#18191c", 14 | "dark-2": "#1f2225", 15 | "dark-3": "#2e3136", 16 | "dark-4": "#36393e", 17 | "dark-5": "#3e4247", 18 | "dark-6": "#45494f", 19 | "dark-7": "#71757d", 20 | }, 21 | height: { 22 | 128: "32rem", 23 | 160: "40rem", 24 | 192: "48rem", 25 | 256: "64rem", 26 | }, 27 | width: { 28 | 18: "4.5rem", 29 | 128: "32rem", 30 | 160: "40rem", 31 | 192: "48rem", 32 | 256: "64rem", 33 | }, 34 | scale: { 35 | 101: "1.01", 36 | }, 37 | }, 38 | }, 39 | plugins: [], 40 | }; 41 | -------------------------------------------------------------------------------- /embedg-app/src/state/settings.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | 4 | interface Settings { 5 | editHistoryEnabled: boolean; 6 | alwaysCollapseSidebar: boolean; 7 | confirmOnExit: boolean; 8 | } 9 | 10 | interface SettingsStore extends Settings { 11 | setEditHistoryEnabled: (enabled: boolean) => void; 12 | setAlwaysCollapseSidebar: (enabled: boolean) => void; 13 | setConfirmOnExit: (enabled: boolean) => void; 14 | } 15 | 16 | const defaultSettings: Settings = { 17 | editHistoryEnabled: true, 18 | alwaysCollapseSidebar: false, 19 | confirmOnExit: false, 20 | }; 21 | 22 | export const useSettingsStore = create()( 23 | persist( 24 | (set) => ({ 25 | ...defaultSettings, 26 | setEditHistoryEnabled: (enabled) => set({ editHistoryEnabled: enabled }), 27 | setAlwaysCollapseSidebar: (enabled) => 28 | set({ alwaysCollapseSidebar: enabled }), 29 | setConfirmOnExit: (enabled) => set({ confirmOnExit: enabled }), 30 | }), 31 | { name: "settings" } 32 | ) 33 | ); 34 | -------------------------------------------------------------------------------- /embedg-app/src/components/DateTimePicker.css: -------------------------------------------------------------------------------- 1 | .react-datetime-picker { 2 | @apply w-full; 3 | } 4 | 5 | .react-datetime-picker__wrapper { 6 | @apply h-10 bg-dark-2 rounded border-none text-gray-100 px-3 w-full; 7 | } 8 | 9 | .react-datetime-picker__inputGroup { 10 | @apply flex-auto w-full; 11 | } 12 | 13 | .react-datetime-picker__inputGroup__input { 14 | @apply focus:outline-none focus:bg-dark-3; 15 | } 16 | 17 | .react-datetime-picker__button svg { 18 | @apply stroke-gray-100; 19 | } 20 | 21 | .react-calendar { 22 | @apply bg-dark-2 rounded border-none text-gray-100; 23 | } 24 | 25 | .react-calendar button { 26 | @apply focus:bg-dark-3 hover:bg-dark-3 !important; 27 | } 28 | 29 | .react-calendar__navigation__label { 30 | @apply bg-dark-2 !important; 31 | } 32 | 33 | .react-datetime-picker__clock { 34 | @apply bg-dark-2 rounded border-none text-gray-100; 35 | } 36 | 37 | .react-clock__face { 38 | @apply bg-dark-3 !important; 39 | } 40 | 41 | .react-clock__face div { 42 | @apply bg-gray-100 !important; 43 | } 44 | 45 | .react-clock__hand div { 46 | @apply bg-gray-100 !important; 47 | } 48 | -------------------------------------------------------------------------------- /embedg-server/util/guilded.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/merlinfuchs/discordgo" 10 | ) 11 | 12 | func ExecuteGuildedWebhook(webhookID, webhookToken string, params *discordgo.WebhookParams) error { 13 | webhookURL := fmt.Sprintf("https://media.guilded.gg/webhooks/%s/%s", webhookID, webhookToken) 14 | 15 | files := params.Files 16 | params.Files = make([]*discordgo.File, 0) 17 | 18 | contentType, body, err := discordgo.MultipartBodyWithJSON(params, files) 19 | if err != nil { 20 | return fmt.Errorf("failed to construct request body: %w", err) 21 | } 22 | 23 | resp, err := http.Post(webhookURL, contentType, bytes.NewReader(body)) 24 | if err != nil { 25 | return fmt.Errorf("failed to make guilded api request: %w", err) 26 | } 27 | 28 | respBody, err := io.ReadAll(resp.Body) 29 | if err != nil { 30 | return fmt.Errorf("failed to read response body: %w", err) 31 | } 32 | 33 | if resp.StatusCode >= 300 || resp.StatusCode < 200 { 34 | return fmt.Errorf("Guilded error: %s", string(respBody)) 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/docker-push.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Images 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | push_to_registries: 9 | name: Push Docker image to multiple registries 10 | runs-on: ubuntu-latest 11 | permissions: 12 | packages: write 13 | contents: read 14 | steps: 15 | - name: Check out the repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Log in to Docker Hub 19 | uses: docker/login-action@v2 20 | with: 21 | username: ${{ secrets.DOCKER_USERNAME }} 22 | password: ${{ secrets.DOCKER_PASSWORD }} 23 | 24 | - name: Log in to the Container registry 25 | uses: docker/login-action@v2 26 | with: 27 | registry: ghcr.io 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Build and push Docker images 32 | uses: docker/build-push-action@v4 33 | with: 34 | context: . 35 | push: true 36 | tags: merlintor/embed-generator:latest,merlintor/embed-generator:${{ github.ref_name }} 37 | -------------------------------------------------------------------------------- /embedg-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Embed Generator | Discord embeds without the hassle 8 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /embedg-app/src/views/tools/coloredText.tsx: -------------------------------------------------------------------------------- 1 | import ToolsColoredText from "../../components/ToolsColoredText"; 2 | import ToolsBackButton from "../../components/ToolsBackButton"; 3 | 4 | export default function ColoredTextToolView() { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 |

12 | Colored{" "} 13 | Text Generator 14 |

15 |

16 | Discord supports colored text via ANSI color codes in code blocks. 17 | This tool makes it very simple to generate colored text that you 18 | can then use in your Discord message. 19 |

20 |
21 | 22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Merlin Fuchs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /embedg-app/src/components/CronExpressionBuilder.tsx: -------------------------------------------------------------------------------- 1 | import Cron from "react-cron-generator"; 2 | import "./CronExpressionBuilder.css"; 3 | import { useMemo } from "react"; 4 | 5 | interface Props { 6 | value: string | null; 7 | onChange: (v: string | null) => void; 8 | } 9 | 10 | export default function CronExpressionBuilder({ value, onChange }: Props) { 11 | const sevenFieldExpression = useMemo( 12 | () => (value ? "0 " + value + " *" : undefined), 13 | [value] 14 | ); 15 | 16 | const setSevenFieldExpression = (v: string | null) => { 17 | if (!v) { 18 | onChange(null); 19 | return; 20 | } 21 | 22 | const parts = v.split(" "); 23 | onChange(parts.slice(1, 6).join(" ")); 24 | }; 25 | 26 | return ( 27 |
28 |
29 |
30 | Schedule 31 |
32 |
33 | setSevenFieldExpression(v || null)} 35 | value={sevenFieldExpression} 36 | showResultText={true} 37 | showResultCron={false} 38 | /> 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/custom_commands.sql: -------------------------------------------------------------------------------- 1 | -- name: GetCustomCommands :many 2 | SELECT * FROM custom_commands WHERE guild_id = $1; 3 | 4 | -- name: GetCustomCommand :one 5 | SELECT * FROM custom_commands WHERE id = $1 AND guild_id = $2; 6 | 7 | -- name: GetCustomCommandByName :one 8 | SELECT * FROM custom_commands WHERE name = $1 AND guild_id = $2; 9 | 10 | -- name: CountCustomCommands :one 11 | SELECT COUNT(*) FROM custom_commands WHERE guild_id = $1; 12 | 13 | -- name: InsertCustomCommand :one 14 | INSERT INTO custom_commands (id, guild_id, name, description, parameters, actions, derived_permissions, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; 15 | 16 | -- name: UpdateCustomCommand :one 17 | UPDATE custom_commands SET name = $3, description = $4, enabled = $5, actions = $6, parameters = $7, derived_permissions = $8, updated_at = $9 WHERE id = $1 AND guild_id = $2 RETURNING *; 18 | 19 | -- name: DeleteCustomCommand :one 20 | DELETE FROM custom_commands WHERE id = $1 AND guild_id = $2 RETURNING *; 21 | 22 | -- name: SetCustomCommandsDeployedAt :one 23 | UPDATE custom_commands SET deployed_at = $2 WHERE guild_id = $1 RETURNING *; 24 | -------------------------------------------------------------------------------- /embedg-site/src/css/global.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #237feb; 10 | --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme="dark"] { 22 | --ifm-color-primary: #237feb; 23 | --ifm-color-primary-dark: #21af90; 24 | --ifm-color-primary-darker: #1fa588; 25 | --ifm-color-primary-darkest: #1a8870; 26 | --ifm-color-primary-light: #29d5b0; 27 | --ifm-color-primary-lighter: #32d8b4; 28 | --ifm-color-primary-lightest: #4fddbf; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | -------------------------------------------------------------------------------- /embedg-app/src/state/validationError.ts: -------------------------------------------------------------------------------- 1 | import { ZodError, ZodIssue } from "zod"; 2 | import { create } from "zustand"; 3 | 4 | export interface ValidationErrorStore { 5 | error: ZodError | null; 6 | setError(error: ZodError | null): void; 7 | getIssueByPath(path: string): ZodIssue | null; 8 | checkIssueByPathPrefix(path: string): boolean; 9 | } 10 | 11 | export const useValidationErrorStore = create()( 12 | (set, get) => ({ 13 | error: null, 14 | setError: (error) => { 15 | set({ error }); 16 | }, 17 | getIssueByPath: (path) => { 18 | const state = get(); 19 | if (!state.error) return null; 20 | 21 | for (const issue of state.error.issues) { 22 | if (issue.path?.join(".") === path) { 23 | return issue; 24 | } 25 | } 26 | return null; 27 | }, 28 | checkIssueByPathPrefix: (path) => { 29 | const state = get(); 30 | if (!state.error) return false; 31 | 32 | for (const issue of state.error.issues) { 33 | if (issue.path?.join(".").startsWith(path)) { 34 | return true; 35 | } 36 | } 37 | return false; 38 | }, 39 | }) 40 | ); 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release Builds 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | release: 9 | name: Release Server 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Node 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: "18" 20 | - name: Build Site 21 | run: npm install -g yarn && yarn install && yarn build 22 | working-directory: ./embedg-site 23 | - name: Build App 24 | run: npm install -g yarn && yarn install && yarn build 25 | working-directory: ./embedg-app 26 | - name: Set up Go 27 | uses: actions/setup-go@v4 28 | with: 29 | go-version: ">=1.21.0" 30 | check-latest: true 31 | - name: Run GoReleaser 32 | uses: goreleaser/goreleaser-action@v4 33 | with: 34 | workdir: embedg-server 35 | distribution: goreleaser 36 | version: latest 37 | args: release --clean 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /embedg-server/api/helpers/error.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/merlinfuchs/embed-generator/embedg-server/api/wire" 6 | ) 7 | 8 | func NotFound(code string, message string) *wire.Error { 9 | return &wire.Error{ 10 | Status: fiber.StatusNotFound, 11 | Code: code, 12 | Message: message, 13 | } 14 | } 15 | 16 | func Forbidden(code string, message string) *wire.Error { 17 | return &wire.Error{ 18 | Status: fiber.StatusForbidden, 19 | Code: code, 20 | Message: message, 21 | } 22 | } 23 | 24 | func Unauthorized(code string, message string) *wire.Error { 25 | return &wire.Error{ 26 | Status: fiber.StatusUnauthorized, 27 | Code: code, 28 | Message: message, 29 | } 30 | } 31 | 32 | func ValidationError(data interface{}) *wire.Error { 33 | return &wire.Error{ 34 | Status: fiber.StatusBadRequest, 35 | Code: "validation_error", 36 | Message: "Validation for request body failed", 37 | Data: data, 38 | } 39 | } 40 | 41 | func BadRequest(code string, message string) *wire.Error { 42 | return &wire.Error{ 43 | Status: fiber.StatusBadRequest, 44 | Code: code, 45 | Message: message, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /embedg-app/src/components/ActivityLoadingScreen.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "zustand/shallow"; 2 | import { useActivityStateStore } from "../state/activity"; 3 | import { ArrowPathIcon, XMarkIcon } from "@heroicons/react/24/outline"; 4 | 5 | export default function ActivityLoadingScreen() { 6 | const { loading, error } = useActivityStateStore((state) => state, shallow); 7 | 8 | if (loading) { 9 | return ( 10 |
11 | 12 |
13 | ); 14 | } 15 | 16 | if (error) { 17 | return ( 18 |
19 |
20 | 21 |
{error}
22 |
23 | Try to restart or join the activity again. 24 |
25 |
26 |
27 | ); 28 | } 29 | 30 | return null; 31 | } 32 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/saved_messages.sql: -------------------------------------------------------------------------------- 1 | -- name: InsertSavedMessage :one 2 | INSERT INTO saved_messages (id, creator_id, guild_id, updated_at, name, description, data) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *; 3 | 4 | -- name: UpdateSavedMessageForCreator :one 5 | UPDATE saved_messages SET updated_at = $3, name = $4, description = $5, data = $6 WHERE id = $1 AND creator_id = $2 RETURNING *; 6 | 7 | -- name: UpdateSavedMessageForGuild :one 8 | UPDATE saved_messages SET updated_at = $3, name = $4, description = $5, data = $6 WHERE id = $1 AND guild_id = $2 RETURNING *; 9 | 10 | -- name: DeleteSavedMessageForCreator :exec 11 | DELETE FROM saved_messages WHERE id = $1 AND creator_id = $2; 12 | 13 | -- name: DeleteSavedMessageForGuild :exec 14 | DELETE FROM saved_messages WHERE id = $1 AND guild_id = $2; 15 | 16 | -- name: GetSavedMessagesForCreator :many 17 | SELECT * FROM saved_messages WHERE creator_id = $1 AND guild_id IS NULL ORDER BY updated_at DESC; 18 | 19 | -- name: GetSavedMessagesForGuild :many 20 | SELECT * FROM saved_messages WHERE guild_id = $1 ORDER BY updated_at DESC; 21 | 22 | -- name: GetSavedMessageForGuild :one 23 | SELECT * FROM saved_messages WHERE guild_id = $1 AND id = $2; -------------------------------------------------------------------------------- /embedg-app/src/components/EditorMessagePreview.tsx: -------------------------------------------------------------------------------- 1 | import { debounce } from "debounce"; 2 | import { lazy, Suspense, useState } from "react"; 3 | import { Message } from "../discord/schema"; 4 | import { useCurrentMessageStore } from "../state/message"; 5 | 6 | const LazyMessagePreview = lazy(() => import("./MessagePreview")); 7 | 8 | export default function EditorMessagePreview() { 9 | const [msg, setMsg] = useState(); 10 | 11 | const debouncedSetMessage = debounce(setMsg, 250); 12 | 13 | // We debounce the message preview to prevent it from updating too often. 14 | useCurrentMessageStore((state) => debouncedSetMessage(state)); 15 | 16 | if (msg) { 17 | if (msg.flags && (msg.flags & (1 << 15)) !== 0) { 18 | return ( 19 |
20 | Message preview is currently not available when using Components V2. 21 | Just send your message to a test channel to see how it looks. 22 |
23 | ); 24 | } else { 25 | return ( 26 | 27 | 28 | 29 | ); 30 | } 31 | } else { 32 | return null; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/store.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/jmoiron/sqlx" 9 | _ "github.com/lib/pq" 10 | "github.com/merlinfuchs/embed-generator/embedg-server/db/postgres/pgmodel" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | func BuildConnectionDSN() string { 15 | dbname := viper.GetString("postgres.dbname") 16 | password := viper.GetString("postgres.password") 17 | 18 | dsn := fmt.Sprintf("host=%s port=%d dbname=%s user=%s sslmode=disable", 19 | viper.GetString("postgres.host"), viper.GetInt("postgres.port"), dbname, viper.GetString("postgres.user")) 20 | 21 | if password != "" { 22 | dsn += " password=" + password 23 | } 24 | return dsn 25 | 26 | } 27 | 28 | type PostgresStore struct { 29 | db *sqlx.DB 30 | Q *pgmodel.Queries 31 | } 32 | 33 | func NewPostgresStore() *PostgresStore { 34 | db, err := sqlx.Open("postgres", BuildConnectionDSN()) 35 | if err != nil { 36 | log.Fatalf("Failed to connect to postgres store: %v", err) 37 | } 38 | 39 | db.SetMaxIdleConns(20) 40 | db.SetMaxOpenConns(80) 41 | db.SetConnMaxLifetime(time.Hour * 1) 42 | 43 | return &PostgresStore{ 44 | db: db, 45 | Q: pgmodel.New(db), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /embedg-server/api/helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | ) 8 | 9 | func WithRequestBody[R any](handler func(c *fiber.Ctx, req R) error) fiber.Handler { 10 | return func(c *fiber.Ctx) error { 11 | var req R 12 | if err := c.BodyParser(&req); err != nil { 13 | return fmt.Errorf("failed to parse request body: %w", err) 14 | } 15 | return handler(c, req) 16 | } 17 | } 18 | 19 | type RequestBodyValidatable interface { 20 | Validate() error 21 | } 22 | 23 | type RequestBodyNormalizeValidate interface { 24 | Validate() error 25 | Normalize() 26 | } 27 | 28 | func WithRequestBodyValidated[R RequestBodyValidatable](handler func(c *fiber.Ctx, req R) error) fiber.Handler { 29 | return func(c *fiber.Ctx) error { 30 | var req R 31 | if err := c.BodyParser(&req); err != nil { 32 | return fmt.Errorf("failed to parse request body: %w", err) 33 | } 34 | if err := ValidateBody(c, req); err != nil { 35 | return err 36 | } 37 | return handler(c, req) 38 | } 39 | } 40 | 41 | func ValidateBody(c *fiber.Ctx, v RequestBodyValidatable) error { 42 | err := v.Validate() 43 | 44 | if err != nil { 45 | return ValidationError(err) 46 | } 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /embedg-server/api/session/middleware.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/merlinfuchs/embed-generator/embedg-server/api/helpers" 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | type SessionMiddleware struct { 10 | manager *SessionManager 11 | } 12 | 13 | func NewSessionMiddleware(manager *SessionManager) *SessionMiddleware { 14 | return &SessionMiddleware{ 15 | manager: manager, 16 | } 17 | } 18 | 19 | func (m *SessionMiddleware) SessionRequired() func(c *fiber.Ctx) error { 20 | return func(c *fiber.Ctx) error { 21 | session, err := m.manager.GetSession(c) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | if session == nil { 27 | return helpers.Unauthorized("invalid_session", "No valid session, perhaps it expired, try logging in again.") 28 | } 29 | 30 | c.Locals("session", session) 31 | 32 | return c.Next() 33 | } 34 | } 35 | 36 | func (m *SessionMiddleware) SessionOptional() func(c *fiber.Ctx) error { 37 | return func(c *fiber.Ctx) error { 38 | session, err := m.manager.GetSession(c) 39 | if err != nil { 40 | log.Error().Err(err).Msg("Failed to validate session") 41 | } 42 | 43 | c.Locals("session", session) 44 | 45 | return c.Next() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /embedg-app/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /embedg-app/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /embedg-site/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:latest as builder 2 | WORKDIR /root/ 3 | COPY . . 4 | 5 | # Install NodeJS (https://github.com/nodesource/distributions#installation-instructions) 6 | RUN apt-get update 7 | RUN apt-get install -y ca-certificates curl gnupg build-essential 8 | RUN mkdir -p /etc/apt/keyrings 9 | RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg 10 | 11 | RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list 12 | 13 | RUN apt-get update 14 | RUN apt-get -y install nodejs 15 | 16 | # Enable Yarn via Corepack (bundled with Node.js) 17 | RUN corepack enable 18 | RUN corepack prepare yarn@1.22.22 --activate 19 | 20 | # Build site 21 | RUN cd embedg-site && yarn install && yarn build && cd .. 22 | 23 | # Build app 24 | RUN cd embedg-app && yarn install && yarn build && cd .. 25 | 26 | # Build backend 27 | RUN cd embedg-server && go build --tags "embedapp embedsite" && cd .. 28 | 29 | FROM debian:stable-slim 30 | WORKDIR /root/ 31 | COPY --from=builder /root/embedg-server/embedg-server . 32 | 33 | RUN apt-get update 34 | RUN apt-get install -y ca-certificates gnupg build-essential 35 | 36 | EXPOSE 8080 37 | CMD ./embedg-server migrate postgres up; ./embedg-server server -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/entitlements.sql: -------------------------------------------------------------------------------- 1 | -- name: GetActiveEntitlementsForGuild :many 2 | SELECT * FROM entitlements 3 | WHERE deleted = false 4 | AND (starts_at IS NULL OR starts_at < NOW()) 5 | AND (ends_at IS NULL OR ends_at > NOW()) 6 | AND (guild_id = $1 OR consumed_guild_id = $1); 7 | 8 | -- name: GetActiveEntitlementsForUser :many 9 | SELECT * FROM entitlements 10 | WHERE deleted = false 11 | AND (starts_at IS NULL OR starts_at < NOW()) 12 | AND (ends_at IS NULL OR ends_at > NOW()) 13 | AND user_id = $1; 14 | 15 | -- name: GetEntitlements :many 16 | SELECT * FROM entitlements; 17 | 18 | -- name: GetEntitlement :one 19 | SELECT * FROM entitlements WHERE id = $1 AND user_id = $2; 20 | 21 | -- name: UpdateEntitlementConsumedGuildID :one 22 | UPDATE entitlements SET consumed = true, consumed_guild_id = $2 WHERE id = $1 RETURNING *; 23 | 24 | -- name: UpsertEntitlement :one 25 | INSERT INTO entitlements ( 26 | id, 27 | user_id, 28 | guild_id, 29 | updated_at, 30 | deleted, 31 | sku_id, 32 | starts_at, 33 | ends_at, 34 | consumed 35 | ) VALUES ( 36 | $1, 37 | $2, 38 | $3, 39 | $4, 40 | $5, 41 | $6, 42 | $7, 43 | $8, 44 | $9 45 | ) 46 | ON CONFLICT (id) 47 | DO UPDATE SET 48 | deleted = $5, 49 | starts_at = $7, 50 | ends_at = $8, 51 | updated_at = $4, 52 | consumed = $9 53 | RETURNING *; 54 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorWebhookFields.tsx: -------------------------------------------------------------------------------- 1 | import { useCurrentMessageStore } from "../state/message"; 2 | import EditorInput from "./EditorInput"; 3 | 4 | export default function EditorWebhookFields() { 5 | const username = useCurrentMessageStore((state) => state.username); 6 | const setUsername = useCurrentMessageStore((state) => state.setUsername); 7 | 8 | const avatarUrl = useCurrentMessageStore((state) => state.avatar_url); 9 | const setAvatarUrl = useCurrentMessageStore((state) => state.setAvatarUrl); 10 | 11 | return ( 12 |
13 |
14 |
15 | setUsername(v || undefined)} 19 | maxLength={80} 20 | validationPath={`username`} 21 | /> 22 |
23 |
24 | setAvatarUrl(v || undefined)} 29 | validationPath={`avatar_url`} 30 | className="flex-auto" 31 | imageUpload={true} 32 | /> 33 |
34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/kv_entries.sql: -------------------------------------------------------------------------------- 1 | -- name: GetKVEntry :one 2 | SELECT * FROM kv_entries WHERE key = $1 AND guild_id = $2; 3 | 4 | -- name: SetKVEntry :exec 5 | INSERT INTO kv_entries ( 6 | key, 7 | guild_id, 8 | value, 9 | expires_at, 10 | created_at, 11 | updated_at 12 | ) VALUES ( 13 | $1, 14 | $2, 15 | $3, 16 | $4, 17 | $5, 18 | $6 19 | ) ON CONFLICT (key, guild_id) 20 | DO UPDATE SET 21 | value = EXCLUDED.value, 22 | expires_at = EXCLUDED.expires_at, 23 | updated_at = EXCLUDED.updated_at; 24 | 25 | -- name: IncreaseKVEntry :one 26 | INSERT INTO kv_entries ( 27 | key, 28 | guild_id, 29 | value, 30 | expires_at, 31 | created_at, 32 | updated_at 33 | ) VALUES ( 34 | $1, 35 | $2, 36 | $3, 37 | $4, 38 | $5, 39 | $6 40 | ) ON CONFLICT (key, guild_id) 41 | DO UPDATE SET 42 | value = kv_entries.value::int + EXCLUDED.value::int, 43 | expires_at = EXCLUDED.expires_at, 44 | updated_at = EXCLUDED.updated_at 45 | RETURNING *; 46 | 47 | -- name: DeleteKVEntry :one 48 | DELETE FROM kv_entries WHERE key = $1 AND guild_id = $2 RETURNING *; 49 | 50 | -- name: SearchKVEntries :many 51 | SELECT * FROM kv_entries WHERE key LIKE $1 AND guild_id = $2; 52 | 53 | -- name: CountKVEntries :one 54 | SELECT COUNT(*) FROM kv_entries WHERE guild_id = $1; -------------------------------------------------------------------------------- /embedg-server/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/gofiber/fiber/v2/middleware/recover" 9 | "github.com/merlinfuchs/embed-generator/embedg-server/api/wire" 10 | "github.com/rs/zerolog/log" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | func Serve(stores *Stores) { 15 | app := fiber.New(fiber.Config{ 16 | ErrorHandler: func(c *fiber.Ctx, err error) error { 17 | var e *wire.Error 18 | if errors.As(err, &e) { 19 | return c.Status(e.Status).JSON(e) 20 | } else { 21 | log.Error().Err(err).Msg("Unhandled error in rest endpoint") 22 | return c.Status(fiber.StatusInternalServerError).JSON(wire.Error{ 23 | Status: fiber.StatusInternalServerError, 24 | Code: "internal_server_error", 25 | Message: err.Error(), 26 | }) 27 | } 28 | }, 29 | BodyLimit: 1024 * 1024 * 32, // 32 MB 30 | }) 31 | 32 | // We don't want the whole app to crash but panics are still very bad 33 | app.Use(recover.New(recover.Config{ 34 | EnableStackTrace: true, 35 | })) 36 | 37 | managers := createManagers(stores, stores.Bot) 38 | 39 | registerRoutes(app, stores, stores.Bot, managers) 40 | 41 | err := app.Listen(fmt.Sprintf("%s:%d", viper.GetString("api.host"), viper.GetInt("api.port"))) 42 | if err != nil { 43 | log.Fatal().Err(err).Msg("Failed to start server") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /embedg-app/src/discord/util.ts: -------------------------------------------------------------------------------- 1 | export const discordWebhookUrlRegex = 2 | /https?:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api(\/v[0-9]+)?\/webhooks\/([0-9]+)\/([a-zA-Z0-9_-]+)/; 3 | 4 | export const messageUrlRegex = 5 | /https?:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/channels\/[0-9]+\/([0-9]+)\/([0-9]+)/; 6 | 7 | export const guildedWebhookUrlRegex = 8 | /https?:\/\/media\.guilded\.gg\/webhooks\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)/; 9 | 10 | interface WebhookInfo { 11 | type: "discord" | "guilded"; 12 | id: string; 13 | token: string; 14 | } 15 | 16 | export function parseWebhookUrl(webhookUrl: string): WebhookInfo | null { 17 | let match = webhookUrl.match(discordWebhookUrlRegex); 18 | if (match) { 19 | return { 20 | type: "discord", 21 | id: match[2], 22 | token: match[3], 23 | }; 24 | } 25 | 26 | match = webhookUrl.match(guildedWebhookUrlRegex); 27 | if (match) { 28 | return { 29 | type: "guilded", 30 | id: match[1], 31 | token: match[2], 32 | }; 33 | } 34 | 35 | return null; 36 | } 37 | 38 | export function isComponentV2Enabled(flags: number): boolean { 39 | return (flags & (1 << 15)) === 1; 40 | } 41 | 42 | export function enableComponentV2(flags: number): number { 43 | return flags | (1 << 15); 44 | } 45 | 46 | export function disableComponentV2(flags: number): number { 47 | return flags & ~(1 << 15); 48 | } 49 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { ErrorBoundary } from "react-error-boundary"; 3 | import { useCurrentMessageStore } from "../state/message"; 4 | 5 | export default function EditorErrorBoundary({ 6 | children, 7 | }: { 8 | children: ReactNode; 9 | }) { 10 | const clearMessage = useCurrentMessageStore((s) => s.clear); 11 | 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | } 18 | 19 | function ErrorFallback({ 20 | error, 21 | resetErrorBoundary, 22 | }: { 23 | error: Error; 24 | resetErrorBoundary: () => void; 25 | }) { 26 | return ( 27 |
28 |
Editor Error
29 |
30 | The editor encountered an error. Please report this to the developers 31 | with the following error message: 32 |
33 |
34 |         {`${error}\n\n${error.stack}`}
35 |       
36 |
37 | 43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /embedg-server/api/access/check.go: -------------------------------------------------------------------------------- 1 | package access 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/merlinfuchs/embed-generator/embedg-server/api/helpers" 6 | "github.com/merlinfuchs/embed-generator/embedg-server/api/session" 7 | ) 8 | 9 | func (m *AccessManager) CheckGuildAccessForRequest(c *fiber.Ctx, guildID string) error { 10 | session := c.Locals("session").(*session.Session) 11 | 12 | access, err := m.GetGuildAccessForUser(session.UserID, guildID) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | if !access.HasChannelWithBotAccess { 18 | return helpers.Forbidden("bot_missing_access", "The bot doesn't have access to this guild") 19 | } 20 | 21 | if !access.HasChannelWithUserAccess { 22 | return helpers.Forbidden("missing_access", "You don't have access to this guild") 23 | } 24 | 25 | return nil 26 | } 27 | 28 | func (m *AccessManager) CheckChannelAccessForRequest(c *fiber.Ctx, channelID string) error { 29 | session := c.Locals("session").(*session.Session) 30 | 31 | access, err := m.GetChannelAccessForUser(session.UserID, channelID) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if !access.BotAccess() { 37 | return helpers.Forbidden("bot_missing_access", "The bot doesn't have access to this channel") 38 | } 39 | 40 | if !access.UserAccess() { 41 | return helpers.Forbidden("missing_access", "You don't have access to this channel") 42 | } 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /embedg-server/actions/variables/channel.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import "github.com/merlinfuchs/discordgo" 4 | 5 | type ChannelVariables struct { 6 | channelID string 7 | channel *discordgo.Channel 8 | state *discordgo.State 9 | } 10 | 11 | func NewChannelVariables(channelID string, state *discordgo.State, channel *discordgo.Channel) *ChannelVariables { 12 | return &ChannelVariables{ 13 | channelID: channelID, 14 | channel: channel, 15 | state: state, 16 | } 17 | } 18 | 19 | func (v *ChannelVariables) ensureChannel() bool { 20 | if v.channel != nil { 21 | return true 22 | } 23 | 24 | channel, err := v.state.Channel(v.channelID) 25 | if err != nil { 26 | if err == discordgo.ErrStateNotFound { 27 | return false 28 | } 29 | return false 30 | } 31 | 32 | v.channel = channel 33 | return true 34 | 35 | } 36 | 37 | func (v *ChannelVariables) Get(keys ...string) *string { 38 | if len(keys) == 0 { 39 | return nil 40 | } 41 | 42 | if keys[0] != "channel" { 43 | return nil 44 | } 45 | 46 | if !v.ensureChannel() { 47 | return nil 48 | } 49 | 50 | if len(keys) == 1 { 51 | m := v.channel.Mention() 52 | return &m 53 | } 54 | 55 | switch keys[1] { 56 | case "id": 57 | return &v.channel.ID 58 | case "name": 59 | return &v.channel.Name 60 | case "topic": 61 | return &v.channel.Topic 62 | case "mention": 63 | m := v.channel.Mention() 64 | return &m 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /embedg-server/bot/stateway/listener.go: -------------------------------------------------------------------------------- 1 | package stateway 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/merlinfuchs/discordgo" 8 | "github.com/merlinfuchs/stateway/stateway-lib/broker" 9 | "github.com/merlinfuchs/stateway/stateway-lib/event" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | type GatewayListener struct { 14 | session *discordgo.Session 15 | gatewayIDs []int 16 | } 17 | 18 | func (l *GatewayListener) BalanceKey() string { 19 | balanceKey := "embedg_" 20 | if len(l.gatewayIDs) > 0 { 21 | for _, gatewayID := range l.gatewayIDs { 22 | balanceKey += fmt.Sprintf("%d", gatewayID) 23 | } 24 | } else { 25 | balanceKey += "all" 26 | } 27 | return balanceKey 28 | } 29 | 30 | func (l *GatewayListener) EventFilter() broker.EventFilter { 31 | return broker.EventFilter{ 32 | GatewayIDs: l.gatewayIDs, 33 | EventTypes: []string{ 34 | "ready", 35 | "resumed", 36 | "guild.>", 37 | "channel.>", 38 | "thread.>", 39 | "entitlement.>", 40 | "webhooks.>", 41 | "interaction.>", 42 | "message.delete", 43 | }, 44 | } 45 | } 46 | 47 | func (l *GatewayListener) HandleEvent(ctx context.Context, event *event.GatewayEvent) error { 48 | err := l.session.OnRawEvent(&discordgo.Event{ 49 | Operation: 0, 50 | Type: event.Type, 51 | RawData: event.Data, 52 | }) 53 | if err != nil { 54 | log.Error().Err(err).Str("type", event.Type).Msg("Failed to handle event") 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /embedg-app/src/components/ConfirmModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "./Modal"; 2 | 3 | interface Props { 4 | title: string; 5 | subTitle: string; 6 | children?: React.ReactNode; 7 | 8 | onClose: () => void; 9 | onConfirm: () => void; 10 | } 11 | 12 | export default function ConfirmModal({ 13 | title, 14 | subTitle, 15 | children, 16 | onClose, 17 | onConfirm, 18 | }: Props) { 19 | return ( 20 | 21 |
22 |
23 |
{title}
24 |
25 | {subTitle} 26 |
27 | 28 | {children && ( 29 |
30 | {children} 31 |
32 | )} 33 |
34 |
35 | 41 | 47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorComponentFile.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "zustand/shallow"; 2 | import { useCurrentMessageStore } from "../state/message"; 3 | import EditorComponentBaseFile from "./EditorComponentBaseFile"; 4 | 5 | interface Props { 6 | rootIndex: number; 7 | rootId: number; 8 | } 9 | 10 | export default function EditorComponentFile({ rootIndex, rootId }: Props) { 11 | const file = useCurrentMessageStore((state) => state.getFile(rootIndex)); 12 | const [ 13 | updateFile, 14 | duplicateFile, 15 | moveComponentUp, 16 | moveComponentDown, 17 | deleteComponent, 18 | ] = useCurrentMessageStore( 19 | (state) => [ 20 | state.updateComponent, 21 | state.duplicateComponent, 22 | state.moveComponentUp, 23 | state.moveComponentDown, 24 | state.deleteComponent, 25 | ], 26 | shallow 27 | ); 28 | 29 | if (!file) { 30 | return null; 31 | } 32 | 33 | return ( 34 |
35 | updateFile(rootIndex, data)} 40 | duplicate={() => duplicateFile(rootIndex)} 41 | moveUp={() => moveComponentUp(rootIndex)} 42 | moveDown={() => moveComponentDown(rootIndex)} 43 | remove={() => deleteComponent(rootIndex)} 44 | /> 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/custom_bots.sql: -------------------------------------------------------------------------------- 1 | -- name: UpsertCustomBot :one 2 | INSERT INTO custom_bots (id, guild_id, application_id, user_id, user_name, user_discriminator, user_avatar, token, public_key, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 3 | ON CONFLICT (guild_id) DO UPDATE SET id = $1, application_id = $3, user_id = $4, user_name = $5, user_discriminator = $6, user_avatar = $7, token = $8, public_key = $9, created_at = $10, handled_first_interaction = false, token_invalid = false 4 | RETURNING *; 5 | 6 | -- name: UpdateCustomBotPresence :one 7 | UPDATE custom_bots SET gateway_status = $2, gateway_activity_type = $3, gateway_activity_name = $4, gateway_activity_state = $5, gateway_activity_url = $6 WHERE guild_id = $1 RETURNING *; 8 | 9 | -- name: UpdateCustomBotUser :one 10 | UPDATE custom_bots SET user_name = $2, user_discriminator = $3, user_avatar = $4 WHERE guild_id = $1 RETURNING *; 11 | 12 | -- name: UpdateCustomBotTokenInvalid :one 13 | UPDATE custom_bots SET token_invalid = $2 WHERE guild_id = $1 RETURNING *; 14 | 15 | -- name: DeleteCustomBot :one 16 | DELETE FROM custom_bots WHERE guild_id = $1 RETURNING *; 17 | 18 | -- name: GetCustomBot :one 19 | SELECT * FROM custom_bots WHERE id = $1; 20 | 21 | -- name: GetCustomBotByGuildID :one 22 | SELECT * FROM custom_bots WHERE guild_id = $1; 23 | 24 | -- name: SetCustomBotHandledFirstInteraction :exec 25 | UPDATE custom_bots SET handled_first_interaction = true WHERE id = $1; 26 | 27 | -- name: GetCustomBots :many 28 | SELECT * FROM custom_bots; -------------------------------------------------------------------------------- /embedg-server/util/vaultbin.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | type VaultBinPaste struct { 12 | ID string 13 | } 14 | 15 | func (v *VaultBinPaste) URL() string { 16 | return fmt.Sprintf("https://vaultb.in/%s", v.ID) 17 | } 18 | 19 | type vaultBinRequest struct { 20 | Content string `json:"content"` 21 | Language string `json:"language"` 22 | } 23 | 24 | type vaultBinResponse struct { 25 | Data vaultbinResponseData `json:"data"` 26 | } 27 | 28 | type vaultbinResponseData struct { 29 | ID string `json:"id"` 30 | } 31 | 32 | func CreateVaultBinPaste(content string, language string) (*VaultBinPaste, error) { 33 | reqBody, err := json.Marshal(vaultBinRequest{ 34 | Content: content, 35 | Language: language, 36 | }) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | req, err := http.NewRequest("POST", "https://vaultb.in/api/pastes", bytes.NewReader(reqBody)) 42 | req.Header.Set("Content-Type", "application/json") 43 | 44 | resp, err := http.DefaultClient.Do(req) 45 | 46 | if resp.StatusCode != 200 { 47 | return nil, fmt.Errorf("vaultb.in returned status code %d", resp.StatusCode) 48 | } 49 | 50 | respBody, err := io.ReadAll(resp.Body) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | var vaultBinResp vaultBinResponse 56 | err = json.Unmarshal(respBody, &vaultBinResp) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return &VaultBinPaste{ 62 | ID: vaultBinResp.Data.ID, 63 | }, nil 64 | } 65 | -------------------------------------------------------------------------------- /embedg-site/src/components/HomeHero.tsx: -------------------------------------------------------------------------------- 1 | import { SparklesIcon } from "@heroicons/react/24/solid"; 2 | import React from "react"; 3 | 4 | export default function HomeHero(): JSX.Element { 5 | return ( 6 |
7 |
8 |
9 | 10 |
11 |
12 |

13 | The best way to create Discord embeds! 14 |

15 |

16 | Create embed messages for your Discord server with ease and give 17 | them your own branding using webhooks. 18 |

19 | 28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /embedg-server/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "mime" 7 | "strconv" 8 | 9 | "github.com/merlinfuchs/discordgo" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | func Ptr[T any](val T) *T { 14 | return &val 15 | } 16 | 17 | func BotInviteURL() string { 18 | return fmt.Sprintf("https://discord.com/oauth2/authorize?client_id=%s&scope=bot%%20applications.commands&permissions=536945664", viper.GetString("discord.client_id")) 19 | } 20 | 21 | func HashBytes(b []byte) string { 22 | hasher := sha256.New() 23 | hasher.Write(b) 24 | return fmt.Sprintf("%x", hasher.Sum(nil)) 25 | } 26 | 27 | func GetFileExtensionFromMimeType(mimeType string) string { 28 | res, err := mime.ExtensionsByType(mimeType) 29 | if err != nil || len(res) == 0 { 30 | return "" 31 | } 32 | 33 | return res[0] 34 | } 35 | 36 | func IsDiscordRestErrorCode(err error, codes ...int) bool { 37 | if err, ok := err.(*discordgo.RESTError); ok { 38 | if err.Message == nil { 39 | return false 40 | } 41 | 42 | for _, code := range codes { 43 | if err.Message.Code == code { 44 | return true 45 | } 46 | } 47 | } 48 | return false 49 | } 50 | 51 | func DiscordAvatarURL(id string, discriminator string, avatar string) string { 52 | if avatar == "" { 53 | parsedDiscriminator, _ := strconv.Atoi(discriminator) 54 | return fmt.Sprintf("https://cdn.discordapp.com/embed/avatars/%d.png", parsedDiscriminator%5) 55 | } 56 | 57 | return fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", id, avatar) 58 | } 59 | -------------------------------------------------------------------------------- /embedg-site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "embedg-site", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build --out-dir dist", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "^3.8.1", 19 | "@docusaurus/preset-classic": "^3.8.1", 20 | "@heroicons/react": "^2.0.18", 21 | "@mdx-js/react": "^3.0.0", 22 | "autoprefixer": "^10.4.14", 23 | "clsx": "^1.2.1", 24 | "postcss": "^8.4.23", 25 | "prism-react-renderer": "^2.1.0", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "tailwindcss": "^3.3.2" 29 | }, 30 | "devDependencies": { 31 | "@docusaurus/module-type-aliases": "^3.8.1", 32 | "@docusaurus/tsconfig": "3.0.0", 33 | "@docusaurus/types": "3.0.0", 34 | "@types/react": "^18.2.29", 35 | "typescript": "^5.2.2" 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.5%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | }, 49 | "engines": { 50 | "node": ">=18.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | restart: always 7 | volumes: 8 | - embedg-local-postgres:/var/lib/postgresql/data 9 | environment: 10 | POSTGRES_USER: postgres 11 | POSTGRES_DB: embedg 12 | PGUSER: postgres 13 | PGDATA: /var/lib/postgresql/data/pgdata 14 | POSTGRES_HOST_AUTH_METHOD: trust 15 | healthcheck: 16 | test: ["CMD", "pg_isready"] 17 | interval: 3s 18 | timeout: 30s 19 | retries: 3 20 | 21 | minio: 22 | image: quay.io/minio/minio 23 | command: server --console-address ":9001" /data 24 | ports: 25 | - "9000:9000" 26 | - "9001:9001" 27 | environment: 28 | MINIO_ROOT_USER: embedg 29 | MINIO_ROOT_PASSWORD: 1234567890 30 | volumes: 31 | - embedg-local-minio:/data 32 | 33 | embedg: 34 | image: merlintor/embed-generator:latest 35 | restart: always 36 | ports: 37 | - "8080:8080" 38 | environment: 39 | - EMBEDG_API__HOST=0.0.0.0 40 | - EMBEDG_API__INSECURE_COOKIES=true 41 | - EMBEDG_POSTGRES__HOST=postgres 42 | - EMBEDG_POSTGRES__USER=postgres 43 | - EMBEDG_POSTGRES__DB=embedg 44 | - EMBEDG_S3__ENDPOINT=minio:9000 45 | - EMBEDG_API__PUBLIC_URL=http://localhost:8080/api 46 | - EMBEDG_APP__PUBLIC_URL=http://localhost:8080/app 47 | volumes: 48 | - ./config.yaml:/root/config.yaml 49 | depends_on: 50 | postgres: 51 | condition: service_healthy 52 | 53 | volumes: 54 | embedg-local-postgres: 55 | embedg-local-minio: 56 | -------------------------------------------------------------------------------- /embedg-server/api/wire/embeds_links.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import ( 4 | validation "github.com/go-ozzo/ozzo-validation/v4" 5 | "gopkg.in/guregu/null.v4" 6 | ) 7 | 8 | type EmbedLinkCreateRequestWire struct { 9 | Url string `json:"url"` 10 | ThemeColor null.String `json:"theme_color"` 11 | OgTitle null.String `json:"og_title"` 12 | OgSiteName null.String `json:"og_site_name"` 13 | OgDescription null.String `json:"og_description"` 14 | OgImage null.String `json:"og_image"` 15 | OeType null.String `json:"oe_type"` 16 | OeAuthorName null.String `json:"oe_author_name"` 17 | OeAuthorUrl null.String `json:"oe_author_url"` 18 | OeProviderName null.String `json:"oe_provider_name"` 19 | OeProviderUrl null.String `json:"oe_provider_url"` 20 | TwCard null.String `json:"tw_card"` 21 | } 22 | 23 | func (req EmbedLinkCreateRequestWire) Validate() error { 24 | return validation.ValidateStruct(&req, 25 | validation.Field(&req.Url, validation.Required), 26 | ) 27 | } 28 | 29 | type EmbedLinkCreateResponseDataWire struct { 30 | ID string `json:"id"` 31 | URL string `json:"url"` 32 | } 33 | 34 | type EmbedLinkCreateResponseWire APIResponse[EmbedLinkCreateResponseDataWire] 35 | 36 | type EmbedLinkOEmbedResponseWire struct { 37 | Type string `json:"type,omitempty"` 38 | Title string `json:"title,omitempty"` 39 | AuthorName string `json:"author_name,omitempty"` 40 | AuthorUrl string `json:"author_url,omitempty"` 41 | ProviderName string `json:"provider_name,omitempty"` 42 | ProviderUrl string `json:"provider_url,omitempty"` 43 | } 44 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorIconButton.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import Tooltip from "./Tooltip"; 3 | import clsx from "clsx"; 4 | 5 | interface Props { 6 | label: string; 7 | children: React.ReactNode; 8 | href?: string; 9 | onClick?: () => void; 10 | highlight?: boolean; 11 | disabled?: boolean; 12 | className?: string; 13 | } 14 | 15 | export default function EditorIconButton({ 16 | label, 17 | children, 18 | href, 19 | onClick, 20 | highlight, 21 | disabled, 22 | className, 23 | }: Props) { 24 | return ( 25 | 26 | {href ? ( 27 | 38 |
{children}
39 | 40 | ) : ( 41 | 54 | )} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /embedg-server/bot/sharding/monitor.go: -------------------------------------------------------------------------------- 1 | package sharding 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | func (m *ShardManager) monitorShards() { 10 | ticker := time.NewTicker(time.Minute * 30) 11 | defer ticker.Stop() 12 | 13 | for { 14 | select { 15 | case <-ticker.C: 16 | m.checkShards() 17 | case <-m.stopCh: 18 | return 19 | } 20 | } 21 | } 22 | 23 | func (m *ShardManager) checkShards() { 24 | m.RLock() 25 | defer m.RUnlock() 26 | 27 | log.Info().Msg("Checking for suspicious shards") 28 | 29 | for _, shard := range m.Shards { 30 | if shard.Session == nil { 31 | go m.restartShard(shard) 32 | continue 33 | } 34 | 35 | if time.Since(shard.Session.LastHeartbeatAck) > 15*time.Minute { 36 | go m.restartShard(shard) 37 | continue 38 | } 39 | 40 | if time.Since(shard.Session.LastHeartbeatSent) > 10*time.Second && 41 | shard.Session.LastHeartbeatAck.Before(shard.Session.LastHeartbeatSent) { 42 | go m.restartShard(shard) 43 | continue 44 | } 45 | } 46 | } 47 | 48 | func (m *ShardManager) restartShard(shard *Shard) { 49 | log.Info().Int("shard_id", shard.ID).Msg("Restarting suspicious shard") 50 | 51 | if err := shard.Kill(); err != nil { 52 | log.Error().Err(err).Msg("Failed to stop suspicious shard for reconnect") 53 | } 54 | 55 | log.Info().Int("shard_id", shard.ID).Msg("Suspicious shard stopped") 56 | 57 | if err := shard.Start(m.token, m.Intents); err != nil { 58 | log.Error().Err(err).Msg("Failed to start suspicious shard for reconnect") 59 | } 60 | 61 | log.Info().Int("shard_id", shard.ID).Msg("Suspicious shard restarted") 62 | } 63 | -------------------------------------------------------------------------------- /embedg-app/src/components/ToolsColoredText.module.css: -------------------------------------------------------------------------------- 1 | .editor { 2 | white-space: pre-wrap; 3 | font-size: 0.875rem; 4 | line-height: 1.125rem; 5 | text-indent: 0; 6 | font-family: monospace; 7 | color: #b9bbbe; 8 | } 9 | 10 | .ansi1 { 11 | font-weight: 700; 12 | text-decoration: none; 13 | } 14 | .ansi4 { 15 | font-weight: 500; 16 | text-decoration: underline; 17 | } 18 | 19 | .ansi30 { 20 | color: #4f545c; 21 | } 22 | .ansi31 { 23 | color: #dc322f; 24 | } 25 | .ansi32 { 26 | color: #859900; 27 | } 28 | .ansi33 { 29 | color: #b58900; 30 | } 31 | .ansi34 { 32 | color: #268bd2; 33 | } 34 | .ansi35 { 35 | color: #d33682; 36 | } 37 | .ansi36 { 38 | color: #2aa198; 39 | } 40 | .ansi37 { 41 | color: #ffffff; 42 | } 43 | 44 | .ansi30bg { 45 | background-color: #4f545c; 46 | } 47 | .ansi31bg { 48 | background-color: #dc322f; 49 | } 50 | .ansi32bg { 51 | background-color: #859900; 52 | } 53 | .ansi33bg { 54 | background-color: #b58900; 55 | } 56 | .ansi34bg { 57 | background-color: #268bd2; 58 | } 59 | .ansi35bg { 60 | background-color: #d33682; 61 | } 62 | .ansi36bg { 63 | background-color: #2aa198; 64 | } 65 | .ansi37bg { 66 | background-color: #ffffff; 67 | } 68 | 69 | .ansi40 { 70 | background-color: #002b36; 71 | } 72 | .ansi41 { 73 | background-color: #cb4b16; 74 | } 75 | .ansi42 { 76 | background-color: #586e75; 77 | } 78 | .ansi43 { 79 | background-color: #657b83; 80 | } 81 | .ansi44 { 82 | background-color: #839496; 83 | } 84 | .ansi45 { 85 | background-color: #6c71c4; 86 | } 87 | .ansi46 { 88 | background-color: #93a1a1; 89 | } 90 | .ansi47 { 91 | background-color: #fdf6e3; 92 | } 93 | -------------------------------------------------------------------------------- /embedg-app/src/components/SendMenu.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { shallow } from "zustand/shallow"; 3 | import { useUserQuery } from "../api/queries"; 4 | import { useSendSettingsStore } from "../state/sendSettings"; 5 | import LoginSuggest from "./LoginSuggest"; 6 | import SendMenuChannel from "./SendMenuChannel"; 7 | import SendMenuWebhook from "./SendMenuWebhook"; 8 | 9 | export default function SendMenu() { 10 | const [mode, setMode] = useSendSettingsStore( 11 | (state) => [state.mode, state.setMode], 12 | shallow 13 | ); 14 | 15 | const { data: user } = useUserQuery(); 16 | 17 | function toggleMode() { 18 | setMode(mode === "webhook" ? "channel" : "webhook"); 19 | } 20 | 21 | return ( 22 |
23 |
24 | 45 |
46 | {mode === "webhook" ? ( 47 | 48 | ) : !!user ? ( 49 | 50 | ) : ( 51 | 52 | )} 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /embedg-app/src/components/CronExpressionBuilder.css: -------------------------------------------------------------------------------- 1 | .cron_builder { 2 | @apply bg-dark-2 border-none m-0 p-2 rounded w-full !important; 3 | } 4 | 5 | .cron_builder .nav-item .nav-link { 6 | @apply bg-dark-4 text-gray-300 !important; 7 | } 8 | 9 | .cron_builder .nav-item .nav-link:hover { 10 | @apply bg-dark-3 text-gray-300 !important; 11 | } 12 | 13 | .cron_builder .nav-item .active { 14 | @apply bg-dark-3 text-white !important; 15 | } 16 | 17 | .cron_builder .nav-item .active:hover { 18 | @apply bg-dark-3 text-white !important; 19 | } 20 | 21 | .cron_builder .cron_builder_bordering { 22 | @apply bg-dark-3 border-none !important; 23 | } 24 | 25 | .cron_builder .well { 26 | @apply bg-dark-2 border-none text-gray-300 !important; 27 | } 28 | 29 | .cron_builder .well-small { 30 | @apply bg-dark-2 border-none text-gray-300 !important; 31 | } 32 | 33 | .cron_builder .cron_builder_bordering select { 34 | @apply bg-dark-1 px-3 py-2 rounded border-none text-white !important; 35 | } 36 | 37 | .cron_builder .cron_builder_bordering input[type="radio"] { 38 | @apply cursor-pointer !important; 39 | } 40 | 41 | .cron_builder .well input { 42 | @apply bg-dark-1 px-3 py-2 rounded border-none text-white !important; 43 | } 44 | 45 | .cron_builder .well select { 46 | @apply bg-dark-1 px-3 py-2 rounded border-none !important; 47 | } 48 | 49 | .cron_builder .cron_builder_bordering .tab-pane { 50 | @apply text-gray-300 !important; 51 | } 52 | 53 | .cron_builder .cron_builder_bordering .container-fluid { 54 | @apply text-gray-300 !important; 55 | } 56 | 57 | .cron_builder .cron-builder-bg { 58 | @apply bg-blurple rounded my-2 !important; 59 | } 60 | -------------------------------------------------------------------------------- /embedg-server/api/handlers/users/handler.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/merlinfuchs/embed-generator/embedg-server/api/helpers" 8 | "github.com/merlinfuchs/embed-generator/embedg-server/api/session" 9 | "github.com/merlinfuchs/embed-generator/embedg-server/api/wire" 10 | "github.com/merlinfuchs/embed-generator/embedg-server/db/postgres" 11 | "github.com/merlinfuchs/embed-generator/embedg-server/store" 12 | "github.com/rs/zerolog/log" 13 | "gopkg.in/guregu/null.v4" 14 | ) 15 | 16 | type UsersHandler struct { 17 | pg *postgres.PostgresStore 18 | planStore store.PlanStore 19 | } 20 | 21 | func New(pg *postgres.PostgresStore, planStore store.PlanStore) *UsersHandler { 22 | return &UsersHandler{ 23 | pg: pg, 24 | planStore: planStore, 25 | } 26 | } 27 | 28 | func (h *UsersHandler) HandleGetUser(c *fiber.Ctx) error { 29 | session := c.Locals("session").(*session.Session) 30 | userID := c.Params("userID") 31 | 32 | if userID == "@me" { 33 | userID = session.UserID 34 | } 35 | 36 | user, err := h.pg.Q.GetUser(c.Context(), userID) 37 | if err != nil { 38 | if err == sql.ErrNoRows { 39 | return helpers.NotFound("unknown_user", "The user does not exist.") 40 | } 41 | log.Error().Err(err).Msg("Failed to get user") 42 | return err 43 | } 44 | 45 | return c.JSON(wire.UserResponseWire{ 46 | Success: true, 47 | Data: wire.UserWire{ 48 | ID: user.ID, 49 | Name: user.Name, 50 | Discriminator: user.Discriminator, 51 | Avatar: null.NewString(user.Avatar.String, user.Avatar.Valid), 52 | IsTester: user.IsTester, 53 | }, 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /embedg-server/scheduled_messages/cron.go: -------------------------------------------------------------------------------- 1 | package scheduled_messages 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | _ "time/tzdata" 8 | 9 | "github.com/adhocore/gronx" 10 | ) 11 | 12 | func GetNextCronTick(cronExpression string, last time.Time, timezone string) (time.Time, error) { 13 | loc, err := time.LoadLocation(timezone) 14 | if err != nil { 15 | return time.Time{}, fmt.Errorf("Failed to load timezone: %w", err) 16 | } 17 | 18 | offset := getTimezoneOffset(loc) 19 | 20 | // The last time is in UTC, so we need to convert it to the timezone of the cron expression 21 | last = last.Add(time.Duration(offset) * time.Second) 22 | 23 | res, err := gronx.NextTickAfter(cronExpression, last, false) 24 | if err != nil { 25 | return res, err 26 | } 27 | 28 | // The result is in the timezone of the cron expression, so we need to convert it back to UTC 29 | res = res.Add(time.Duration(-offset) * time.Second) 30 | 31 | return res, nil 32 | } 33 | 34 | func GetFirstCronTick(cronExpression string, start time.Time, timezone string) (time.Time, error) { 35 | res, err := gronx.NextTickAfter(cronExpression, start, true) 36 | if err != nil { 37 | return res, err 38 | } 39 | 40 | loc, err := time.LoadLocation(timezone) 41 | if err != nil { 42 | return time.Time{}, fmt.Errorf("Failed to load timezone: %w", err) 43 | } 44 | 45 | offset := getTimezoneOffset(loc) 46 | 47 | // The result is in the timezone of the cron expression, so we need to convert it back to UTC 48 | res = res.Add(time.Duration(-offset) * time.Second) 49 | 50 | return res, nil 51 | } 52 | 53 | func getTimezoneOffset(loc *time.Location) int { 54 | _, offset := time.Now().In(loc).Zone() 55 | return offset 56 | } 57 | -------------------------------------------------------------------------------- /embedg-app/src/state/sendSettings.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | 4 | export interface SendSettingsStore { 5 | mode: "webhook" | "channel"; 6 | 7 | messageId: string | null; 8 | 9 | // webhook 10 | webhookUrl: string | null; 11 | threadId: string | null; 12 | 13 | // channel 14 | guildId: string | null; 15 | channelId: string | null; 16 | threadName: string | null; 17 | 18 | setMode: (mode: "webhook" | "channel") => void; 19 | setMessageId: (messageId: string | null) => void; 20 | setWebhookUrl: (webhookId: string | null) => void; 21 | setThreadId: (threadId: string | null) => void; 22 | setGuildId: (guildId: string | null) => void; 23 | setChannelId: (channelId: string | null) => void; 24 | setThreadName: (threadName: string | null) => void; 25 | } 26 | 27 | export const useSendSettingsStore = create()( 28 | persist( 29 | (set) => ({ 30 | mode: "webhook", 31 | messageId: null, 32 | webhookUrl: null, 33 | threadId: null, 34 | guildId: null, 35 | channelId: null, 36 | threadName: null, 37 | 38 | setMode: (mode: "webhook" | "channel") => set({ mode }), 39 | setMessageId: (messageId: string | null) => set({ messageId }), 40 | setWebhookUrl: (webhookUrl: string | null) => set({ webhookUrl }), 41 | setThreadId: (threadId: string | null) => set({ threadId }), 42 | setGuildId: (guildId: string | null) => set({ guildId }), 43 | setChannelId: (channelId: string | null) => set({ channelId }), 44 | setThreadName: (threadName: string | null) => set({ threadName }), 45 | }), 46 | { name: "send-settings", version: 0 } 47 | ) 48 | ); 49 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/pgmodel/users.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.27.0 4 | // source: users.sql 5 | 6 | package pgmodel 7 | 8 | import ( 9 | "context" 10 | "database/sql" 11 | ) 12 | 13 | const deleteUser = `-- name: DeleteUser :exec 14 | DELETE FROM users WHERE id = $1 15 | ` 16 | 17 | func (q *Queries) DeleteUser(ctx context.Context, id string) error { 18 | _, err := q.db.ExecContext(ctx, deleteUser, id) 19 | return err 20 | } 21 | 22 | const getUser = `-- name: GetUser :one 23 | SELECT id, name, discriminator, avatar, is_tester FROM users WHERE id = $1 24 | ` 25 | 26 | func (q *Queries) GetUser(ctx context.Context, id string) (User, error) { 27 | row := q.db.QueryRowContext(ctx, getUser, id) 28 | var i User 29 | err := row.Scan( 30 | &i.ID, 31 | &i.Name, 32 | &i.Discriminator, 33 | &i.Avatar, 34 | &i.IsTester, 35 | ) 36 | return i, err 37 | } 38 | 39 | const upsertUser = `-- name: UpsertUser :one 40 | INSERT INTO users (id, name, discriminator, avatar) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET name = $2, discriminator = $3, avatar = $4 RETURNING id, name, discriminator, avatar, is_tester 41 | ` 42 | 43 | type UpsertUserParams struct { 44 | ID string 45 | Name string 46 | Discriminator string 47 | Avatar sql.NullString 48 | } 49 | 50 | func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, error) { 51 | row := q.db.QueryRowContext(ctx, upsertUser, 52 | arg.ID, 53 | arg.Name, 54 | arg.Discriminator, 55 | arg.Avatar, 56 | ) 57 | var i User 58 | err := row.Scan( 59 | &i.ID, 60 | &i.Name, 61 | &i.Discriminator, 62 | &i.Avatar, 63 | &i.IsTester, 64 | ) 65 | return i, err 66 | } 67 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorModal.tsx: -------------------------------------------------------------------------------- 1 | import { XMarkIcon } from "@heroicons/react/20/solid"; 2 | import clsx from "clsx"; 3 | import { ReactNode } from "react"; 4 | import { useNavigate } from "react-router-dom"; 5 | 6 | interface Props { 7 | children: ReactNode; 8 | width?: "xs" | "sm" | "md" | "lg" | "xl" | "full"; 9 | height?: "auto" | "full"; 10 | closeButton?: boolean; 11 | } 12 | 13 | export default function ({ 14 | children, 15 | width = "xl", 16 | height = "auto", 17 | closeButton, 18 | }: Props) { 19 | const navigate = useNavigate(); 20 | 21 | return ( 22 |
e.target === e.currentTarget && navigate("/editor")} 25 | > 26 |
43 | {closeButton !== false && ( 44 | navigate("/editor")} 48 | /> 49 | )} 50 | {children} 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /embedg-server/entry/admin/impersonate.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/merlinfuchs/embed-generator/embedg-server/api/session" 8 | "github.com/merlinfuchs/embed-generator/embedg-server/db/postgres" 9 | "github.com/rs/zerolog/log" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func impersonateCMD() *cobra.Command { 14 | impersonateCMD := &cobra.Command{ 15 | Use: "impersonate", 16 | Short: "Impersonate a user by creating a session token that can be injected into the session cookie", 17 | Run: func(cmd *cobra.Command, args []string) { 18 | sessionToken, err := CreateSessionForUser(cmd.Flag("user_id").Value.String()) 19 | if err != nil { 20 | log.Error().Err(err).Msg("Failed to create session token") 21 | return 22 | } 23 | 24 | fmt.Println("Session token:", sessionToken) 25 | }, 26 | } 27 | impersonateCMD.Flags().String("user_id", "", "User ID to impersonate") 28 | 29 | return impersonateCMD 30 | } 31 | 32 | func CreateSessionForUser(userID string) (string, error) { 33 | pg := postgres.NewPostgresStore() 34 | sessionManager := session.New(pg) 35 | 36 | sessions, err := pg.Q.GetSessionsForUser(context.Background(), userID) 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | if len(sessions) == 0 { 42 | return "", fmt.Errorf("User has no sessions that we can derive a new session from") 43 | } 44 | 45 | session := sessions[0] 46 | for _, s := range sessions { 47 | if s.CreatedAt.After(session.CreatedAt) { 48 | session = s 49 | } 50 | } 51 | 52 | sessionToken, err := sessionManager.CreateSession(context.Background(), userID, session.GuildIds, session.AccessToken) 53 | if err != nil { 54 | return "", err 55 | } 56 | 57 | return sessionToken, nil 58 | } 59 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorEmbedImages.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "zustand/shallow"; 2 | import { useCurrentMessageStore } from "../state/message"; 3 | import Collapsable from "./Collapsable"; 4 | import EditorInput from "./EditorInput"; 5 | 6 | interface Props { 7 | embedIndex: number; 8 | embedId: number; 9 | } 10 | 11 | export default function EditorEmbedImages({ embedIndex, embedId }: Props) { 12 | const [imageUrl, setImageUrl] = useCurrentMessageStore( 13 | (state) => [state.embeds[embedIndex]?.image?.url, state.setEmbedImageUrl], 14 | shallow 15 | ); 16 | 17 | const [thumbnailUrl, setThumbnailUrl] = useCurrentMessageStore( 18 | (state) => [ 19 | state.embeds[embedIndex]?.thumbnail?.url, 20 | state.setEmbedThumbnailUrl, 21 | ], 22 | shallow 23 | ); 24 | 25 | return ( 26 | 34 |
35 | setImageUrl(embedIndex, v || undefined)} 40 | validationPath={`embeds.${embedIndex}.image.url`} 41 | imageUpload={true} 42 | /> 43 | setThumbnailUrl(embedIndex, v || undefined)} 48 | validationPath={`embeds.${embedIndex}.thumbnail.url`} 49 | imageUpload={true} 50 | /> 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorMenuBar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | TrashIcon, 3 | CodeBracketSquareIcon, 4 | SparklesIcon, 5 | LinkIcon, 6 | } from "@heroicons/react/20/solid"; 7 | import { usePremiumGuildFeatures } from "../util/premium"; 8 | import EditorUndoButtons from "./EditorUndoButtons"; 9 | import EditorIconButton from "./EditorIconButton"; 10 | import EditorComponentsV2Toggle from "./EditorComponentsV2Toggle"; 11 | 12 | export default function EditorMenuBar() { 13 | const aiAssistantAllowed = usePremiumGuildFeatures()?.ai_assistant; 14 | const componentsV2Allowed = usePremiumGuildFeatures()?.components_v2; 15 | 16 | return ( 17 | <> 18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {aiAssistantAllowed && ( 31 | 36 | 37 | 38 | )} 39 |
40 | 41 |
42 | {componentsV2Allowed && } 43 |
44 |
45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /embedg-app/src/state/collapsed.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface CollapsedStatesStore { 4 | states: { [key: string]: boolean }; 5 | getCollapsed: (key: string, def?: boolean) => boolean; 6 | toggleCollapsed: (key: string, def?: boolean) => void; 7 | clearCollapsed: (key: string) => void; 8 | clearCollapsedWithPrefix: (prefix: string) => void; 9 | } 10 | 11 | export const useCollapsedStatesStore = create()( 12 | (set, get) => ({ 13 | states: {}, 14 | getCollapsed: (key: string, def: boolean = false) => { 15 | const state = get().states[key]; 16 | return state === undefined ? def : state; 17 | }, 18 | toggleCollapsed: (key: string, def: boolean = false) => { 19 | const states = get().states; 20 | const current = states[key] === undefined ? def : states[key]; 21 | states[key] = !current; 22 | set({ states }); 23 | }, 24 | clearCollapsed: (key: string) => { 25 | const states = get().states; 26 | delete states[key]; 27 | set({ states }); 28 | }, 29 | clearCollapsedWithPrefix(prefix: string) { 30 | const states = get().states; 31 | for (const key in states) { 32 | if (key.startsWith(prefix)) { 33 | delete states[key]; 34 | } 35 | } 36 | set({ states }); 37 | }, 38 | }) 39 | ); 40 | 41 | export const useCollapsedState = (key: string, def: boolean = false) => { 42 | const collapsed = useCollapsedStatesStore((state) => 43 | state.getCollapsed(key, def) 44 | ); 45 | const toggleCollapsed = useCollapsedStatesStore( 46 | (state) => state.toggleCollapsed 47 | ); 48 | const wrappedToggleCollapsed = () => toggleCollapsed(key, def); 49 | 50 | return [collapsed, wrappedToggleCollapsed] as const; 51 | }; 52 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorAttachment.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "zustand/shallow"; 2 | import { useCurrentAttachmentsStore } from "../state/attachments"; 3 | import { TrashIcon } from "@heroicons/react/20/solid"; 4 | import { DocumentIcon } from "@heroicons/react/24/outline"; 5 | 6 | const isImageRegex = /^data:image\//; 7 | 8 | interface Props { 9 | index: number; 10 | id: number; 11 | } 12 | 13 | export default function EditorAttachment({ index, id }: Props) { 14 | const [name, dataUrl] = useCurrentAttachmentsStore( 15 | (state) => [ 16 | state.attachments[index].name, 17 | state.attachments[index].data_url, 18 | ], 19 | shallow 20 | ); 21 | 22 | const [removeAttachment] = useCurrentAttachmentsStore( 23 | (state) => [state.removeAttachment], 24 | shallow 25 | ); 26 | 27 | const isImage = isImageRegex.test(dataUrl); 28 | 29 | return ( 30 |
31 |
32 | 38 | removeAttachment(index)} 41 | /> 42 |
43 |
44 | {isImage ? ( 45 | 50 | ) : ( 51 | 52 | )} 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorComponentSeparator.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "zustand/shallow"; 2 | import { useCurrentMessageStore } from "../state/message"; 3 | import EditorComponentBaseSeparator from "./EditorComponentBaseSeparator"; 4 | 5 | interface Props { 6 | rootIndex: number; 7 | rootId: number; 8 | } 9 | 10 | export default function EditorComponentSeparator({ rootIndex, rootId }: Props) { 11 | const componentCount = useCurrentMessageStore( 12 | (state) => state.components.length 13 | ); 14 | 15 | const separator = useCurrentMessageStore( 16 | (state) => state.getSeparator(rootIndex), 17 | shallow 18 | ); 19 | const updateSeparator = useCurrentMessageStore( 20 | (state) => state.updateComponent 21 | ); 22 | 23 | const [moveUp, moveDown, duplicate, remove] = useCurrentMessageStore( 24 | (state) => [ 25 | state.moveComponentUp, 26 | state.moveComponentDown, 27 | state.duplicateComponent, 28 | state.deleteComponent, 29 | ], 30 | shallow 31 | ); 32 | 33 | if (!separator) { 34 | return null; 35 | } 36 | 37 | return ( 38 |
39 | updateSeparator(rootIndex, data)} 44 | duplicate={componentCount < 5 ? () => duplicate(rootIndex) : undefined} 45 | moveUp={rootIndex > 0 ? () => moveUp(rootIndex) : undefined} 46 | moveDown={ 47 | rootIndex < componentCount - 1 ? () => moveDown(rootIndex) : undefined 48 | } 49 | remove={() => remove(rootIndex)} 50 | size="large" 51 | /> 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /embedg-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "embedg-app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@codemirror/lang-json": "^6.0.1", 13 | "@codemirror/lint": "^6.2.0", 14 | "@discord/embedded-app-sdk": "^1.0.2", 15 | "@emoji-mart/react": "^1.1.1", 16 | "@formkit/auto-animate": "^1.0.0-beta.6", 17 | "@heroicons/react": "^2.0.17", 18 | "@types/debounce": "^1.2.1", 19 | "@types/react-twemoji": "^0.4.1", 20 | "@uiw/codemirror-theme-github": "^4.19.11", 21 | "@uiw/react-codemirror": "^4.19.11", 22 | "autoprefixer": "^10.4.14", 23 | "clsx": "^1.2.1", 24 | "cronstrue": "^2.47.0", 25 | "date-fns": "^2.29.3", 26 | "debounce": "^1.2.1", 27 | "emoji-mart": "^5.5.2", 28 | "highlight.js": "^11.7.0", 29 | "immer": "^9.0.21", 30 | "just-debounce-it": "^3.2.0", 31 | "postcss": "^8.4.21", 32 | "react": "^18.2.0", 33 | "react-colorful": "^5.6.1", 34 | "react-cron-generator": "^2.0.10", 35 | "react-datetime-picker": "^5.2.0", 36 | "react-dom": "^18.2.0", 37 | "react-error-boundary": "^5.0.0", 38 | "react-query": "^3.39.3", 39 | "react-router-dom": "^6.10.0", 40 | "react-textarea-autosize": "^8.4.1", 41 | "react-twemoji": "^0.5.0", 42 | "simple-markdown": "^0.7.3", 43 | "tailwindcss": "^3.3.1", 44 | "vaul": "^0.7.0", 45 | "zod": "^3.21.4", 46 | "zundo": "^2.1.0", 47 | "zustand": "^4.3.7" 48 | }, 49 | "devDependencies": { 50 | "@types/react": "^18.0.28", 51 | "@types/react-dom": "^18.0.11", 52 | "@vitejs/plugin-react-swc": "^3.0.0", 53 | "typescript": "^5.8.3", 54 | "vite": "^4.2.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /embedg-app/src/api/client.ts: -------------------------------------------------------------------------------- 1 | import { QueryCache, QueryClient } from "react-query"; 2 | import { useToasts } from "../util/toasts"; 3 | import { APIError } from "./queries"; 4 | 5 | const queryClient = new QueryClient({ 6 | queryCache: new QueryCache({ 7 | onError: (err) => { 8 | if (err instanceof APIError) { 9 | if (err.status !== 401) { 10 | useToasts.getState().create({ 11 | type: "error", 12 | title: `API Error (${err.status})`, 13 | message: err.message, 14 | }); 15 | } 16 | } else { 17 | useToasts.getState().create({ 18 | type: "error", 19 | title: "Unexpect API error", 20 | message: `${err}`, 21 | }); 22 | } 23 | }, 24 | }), 25 | defaultOptions: { 26 | queries: { 27 | refetchOnWindowFocus: false, 28 | retry: (failureCount, err: any) => { 29 | if (failureCount >= 3) { 30 | return false; 31 | } 32 | return err.status >= 500; 33 | }, 34 | staleTime: 1000 * 60 * 3, 35 | }, 36 | }, 37 | }); 38 | 39 | export default queryClient; 40 | 41 | // This is only used in Discord Activities to work around the lack of cookies 42 | // We don't need to persist the token at all because we re-authenticate for every Activity session 43 | let localSessionToken: string; 44 | 45 | export function setLocalSessionToken(token: string) { 46 | localSessionToken = token; 47 | } 48 | 49 | export function fetchApi( 50 | input: RequestInfo, 51 | init?: RequestInit 52 | ): Promise { 53 | const headers = (init?.headers || {}) as Record; 54 | if (localSessionToken) { 55 | headers.Authorization = localSessionToken; 56 | } 57 | 58 | return fetch(input, { 59 | ...init, 60 | headers, 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorComponentEntry.tsx: -------------------------------------------------------------------------------- 1 | import { useCurrentMessageStore } from "../state/message"; 2 | import EditorComponentRootActionRow from "./EditorComponentActionRow"; 3 | import EditorComponentSection from "./EditorComponentSection"; 4 | import EditorComponentSeparator from "./EditorComponentSeparator"; 5 | import EditorComponentTextDisplay from "./EditorComponentTextDisplay"; 6 | import EditorComponentFile from "./EditorComponentFile"; 7 | import EditorComponentGallery from "./EditorComponentGallery"; 8 | import EditorComponentContainer from "./EditorComponentContainer"; 9 | 10 | interface Props { 11 | rootIndex: number; 12 | rootId: number; 13 | } 14 | 15 | export default function EditorComponentEntry({ rootIndex, rootId }: Props) { 16 | const root = useCurrentMessageStore((state) => state.components[rootIndex]); 17 | 18 | if (!root) { 19 | return null; 20 | } 21 | 22 | if (root.type === 1) { 23 | return ( 24 | 25 | ); 26 | } else if (root.type === 9) { 27 | return ; 28 | } else if (root.type === 10) { 29 | return ; 30 | } else if (root.type === 12) { 31 | return ; 32 | } else if (root.type === 13) { 33 | return ; 34 | } else if (root.type === 14) { 35 | return ; 36 | } else if (root.type === 17) { 37 | return ; 38 | } else { 39 | return
Unknown root component type: {root.type}
; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorComponentTextDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "zustand/shallow"; 2 | import { useCurrentMessageStore } from "../state/message"; 3 | import EditorComponentBaseTextDisplay from "./EditorComponentBaseTextDisplay"; 4 | 5 | interface Props { 6 | rootIndex: number; 7 | rootId: number; 8 | } 9 | 10 | export default function EditorComponentTextDisplay({ 11 | rootIndex, 12 | rootId, 13 | }: Props) { 14 | const componentCount = useCurrentMessageStore( 15 | (state) => state.components.length 16 | ); 17 | 18 | const textDisplay = useCurrentMessageStore( 19 | (state) => state.getTextDisplay(rootIndex), 20 | shallow 21 | ); 22 | const updateTextDisplay = useCurrentMessageStore( 23 | (state) => state.updateComponent 24 | ); 25 | 26 | const [moveUp, moveDown, duplicate, remove] = useCurrentMessageStore( 27 | (state) => [ 28 | state.moveComponentUp, 29 | state.moveComponentDown, 30 | state.duplicateComponent, 31 | state.deleteComponent, 32 | ], 33 | shallow 34 | ); 35 | 36 | if (!textDisplay) { 37 | return null; 38 | } 39 | 40 | return ( 41 |
42 | updateTextDisplay(rootIndex, data)} 47 | duplicate={componentCount < 5 ? () => duplicate(rootIndex) : undefined} 48 | moveUp={rootIndex > 0 ? () => moveUp(rootIndex) : undefined} 49 | moveDown={ 50 | rootIndex < componentCount - 1 ? () => moveDown(rootIndex) : undefined 51 | } 52 | remove={() => remove(rootIndex)} 53 | size="large" 54 | /> 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/pgmodel/shared_messages.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.27.0 4 | // source: shared_messages.sql 5 | 6 | package pgmodel 7 | 8 | import ( 9 | "context" 10 | "encoding/json" 11 | "time" 12 | ) 13 | 14 | const deleteExpiredSharedMessages = `-- name: DeleteExpiredSharedMessages :exec 15 | DELETE FROM shared_messages WHERE expires_at < $1 16 | ` 17 | 18 | func (q *Queries) DeleteExpiredSharedMessages(ctx context.Context, expiresAt time.Time) error { 19 | _, err := q.db.ExecContext(ctx, deleteExpiredSharedMessages, expiresAt) 20 | return err 21 | } 22 | 23 | const getSharedMessage = `-- name: GetSharedMessage :one 24 | SELECT id, created_at, expires_at, data FROM shared_messages WHERE id = $1 25 | ` 26 | 27 | func (q *Queries) GetSharedMessage(ctx context.Context, id string) (SharedMessage, error) { 28 | row := q.db.QueryRowContext(ctx, getSharedMessage, id) 29 | var i SharedMessage 30 | err := row.Scan( 31 | &i.ID, 32 | &i.CreatedAt, 33 | &i.ExpiresAt, 34 | &i.Data, 35 | ) 36 | return i, err 37 | } 38 | 39 | const insertSharedMessage = `-- name: InsertSharedMessage :one 40 | INSERT INTO shared_messages (id, created_at, expires_at, data) VALUES ($1, $2, $3, $4) RETURNING id, created_at, expires_at, data 41 | ` 42 | 43 | type InsertSharedMessageParams struct { 44 | ID string 45 | CreatedAt time.Time 46 | ExpiresAt time.Time 47 | Data json.RawMessage 48 | } 49 | 50 | func (q *Queries) InsertSharedMessage(ctx context.Context, arg InsertSharedMessageParams) (SharedMessage, error) { 51 | row := q.db.QueryRowContext(ctx, insertSharedMessage, 52 | arg.ID, 53 | arg.CreatedAt, 54 | arg.ExpiresAt, 55 | arg.Data, 56 | ) 57 | var i SharedMessage 58 | err := row.Scan( 59 | &i.ID, 60 | &i.CreatedAt, 61 | &i.ExpiresAt, 62 | &i.Data, 63 | ) 64 | return i, err 65 | } 66 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/pgmodel/images.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.27.0 4 | // source: images.sql 5 | 6 | package pgmodel 7 | 8 | import ( 9 | "context" 10 | "database/sql" 11 | ) 12 | 13 | const getImage = `-- name: GetImage :one 14 | SELECT id, user_id, guild_id, file_hash, file_name, file_size, file_content_type, s3_key FROM images WHERE id = $1 15 | ` 16 | 17 | func (q *Queries) GetImage(ctx context.Context, id string) (Image, error) { 18 | row := q.db.QueryRowContext(ctx, getImage, id) 19 | var i Image 20 | err := row.Scan( 21 | &i.ID, 22 | &i.UserID, 23 | &i.GuildID, 24 | &i.FileHash, 25 | &i.FileName, 26 | &i.FileSize, 27 | &i.FileContentType, 28 | &i.S3Key, 29 | ) 30 | return i, err 31 | } 32 | 33 | const insertImage = `-- name: InsertImage :one 34 | INSERT INTO images (id, guild_id, user_id, file_hash, file_name, file_content_type, file_size, s3_key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, user_id, guild_id, file_hash, file_name, file_size, file_content_type, s3_key 35 | ` 36 | 37 | type InsertImageParams struct { 38 | ID string 39 | GuildID sql.NullString 40 | UserID string 41 | FileHash string 42 | FileName string 43 | FileContentType string 44 | FileSize int32 45 | S3Key string 46 | } 47 | 48 | func (q *Queries) InsertImage(ctx context.Context, arg InsertImageParams) (Image, error) { 49 | row := q.db.QueryRowContext(ctx, insertImage, 50 | arg.ID, 51 | arg.GuildID, 52 | arg.UserID, 53 | arg.FileHash, 54 | arg.FileName, 55 | arg.FileContentType, 56 | arg.FileSize, 57 | arg.S3Key, 58 | ) 59 | var i Image 60 | err := row.Scan( 61 | &i.ID, 62 | &i.UserID, 63 | &i.GuildID, 64 | &i.FileHash, 65 | &i.FileName, 66 | &i.FileSize, 67 | &i.FileContentType, 68 | &i.S3Key, 69 | ) 70 | return i, err 71 | } 72 | -------------------------------------------------------------------------------- /embedg-app/src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { XMarkIcon } from "@heroicons/react/20/solid"; 2 | import clsx from "clsx"; 3 | import { ReactNode } from "react"; 4 | import { useNavigate } from "react-router-dom"; 5 | 6 | interface Props { 7 | children: ReactNode; 8 | width?: "xs" | "sm" | "md" | "lg" | "xl" | "full"; 9 | height?: "auto" | "full"; 10 | closeButton?: boolean; 11 | allowOverflow?: boolean; 12 | onClose: () => void; 13 | } 14 | 15 | export default function Modal({ 16 | children, 17 | width = "xl", 18 | height = "auto", 19 | closeButton, 20 | allowOverflow, 21 | onClose, 22 | }: Props) { 23 | return ( 24 |
e.target === e.currentTarget && onClose()} 30 | > 31 |
51 | {closeButton !== false && ( 52 | 57 | )} 58 | {children} 59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /embedg-app/src/components/ImageUploadButton.tsx: -------------------------------------------------------------------------------- 1 | import { DocumentArrowUpIcon } from "@heroicons/react/24/outline"; 2 | import { ChangeEvent, useRef } from "react"; 3 | import { useUploadImageMutation } from "../api/mutations"; 4 | import { useToasts } from "../util/toasts"; 5 | import { useSendSettingsStore } from "../state/sendSettings"; 6 | 7 | interface Props { 8 | onChange: (url: string | undefined) => void; 9 | } 10 | 11 | export default function ImageUploadButton({ onChange }: Props) { 12 | const inputRef = useRef(null); 13 | 14 | const selectedGuildId = useSendSettingsStore((state) => state.guildId); 15 | const createToast = useToasts((s) => s.create); 16 | 17 | const uploadMutation = useUploadImageMutation(); 18 | 19 | function onFileUpload(e: ChangeEvent) { 20 | const file = e.target.files?.[0]; 21 | if (!file) return; 22 | 23 | uploadMutation.mutate( 24 | { 25 | guildId: selectedGuildId, 26 | file, 27 | }, 28 | { 29 | onSuccess: (res) => { 30 | if (res.success) { 31 | onChange(res.data.cdn_url); 32 | } else { 33 | createToast({ 34 | title: "Error uploading image", 35 | message: res.error.message || "Unknown error", 36 | type: "error", 37 | }); 38 | } 39 | }, 40 | } 41 | ); 42 | } 43 | 44 | return ( 45 |
46 | 53 | 59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /embedg-server/db/postgres/queries/scheduled_messages.sql: -------------------------------------------------------------------------------- 1 | -- name: GetDueScheduledMessages :many 2 | SELECT * FROM scheduled_messages WHERE next_at <= $1 AND (end_at IS NULL OR end_at >= $1) AND enabled = true; 3 | 4 | -- name: GetScheduledMessages :many 5 | SELECT * FROM scheduled_messages WHERE guild_id = $1 ORDER BY updated_at DESC; 6 | 7 | -- name: GetScheduledMessage :one 8 | SELECT * FROM scheduled_messages WHERE id = $1 AND guild_id = $2; 9 | 10 | -- name: DeleteScheduledMessage :exec 11 | DELETE FROM scheduled_messages WHERE id = $1 AND guild_id = $2; 12 | 13 | -- name: InsertScheduledMessage :one 14 | INSERT INTO scheduled_messages ( 15 | id, 16 | creator_id, 17 | guild_id, 18 | channel_id, 19 | message_id, 20 | thread_name, 21 | saved_message_id, 22 | name, 23 | description, 24 | cron_expression, 25 | cron_timezone, 26 | start_at, 27 | end_at, 28 | next_at, 29 | only_once, 30 | enabled, 31 | created_at, 32 | updated_at 33 | ) VALUES ( 34 | $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 35 | ) RETURNING *; 36 | 37 | -- name: UpdateScheduledMessage :one 38 | UPDATE scheduled_messages SET 39 | channel_id = $3, 40 | message_id = $4, 41 | thread_name = $5, 42 | saved_message_id = $6, 43 | name = $7, 44 | description = $8, 45 | cron_expression = $9, 46 | next_at = $10, 47 | start_at = $11, 48 | end_at = $12, 49 | only_once = $13, 50 | enabled = $14, 51 | updated_at = $15, 52 | cron_timezone = $16 53 | WHERE id = $1 AND guild_id = $2 RETURNING *; 54 | 55 | -- name: UpdateScheduledMessageNextAt :one 56 | UPDATE scheduled_messages SET next_at = $3, updated_at = $4 WHERE id = $1 AND guild_id = $2 RETURNING *; 57 | 58 | -- name: UpdateScheduledMessageEnabled :one 59 | UPDATE scheduled_messages SET enabled = $3, updated_at = $4 WHERE id = $1 AND guild_id = $2 RETURNING *; -------------------------------------------------------------------------------- /embedg-server/bot/stateway/client.go: -------------------------------------------------------------------------------- 1 | package stateway 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/merlinfuchs/embed-generator/embedg-server/bot/sharding" 8 | "github.com/merlinfuchs/stateway/stateway-lib/broker" 9 | "github.com/rs/zerolog/log" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | type Client struct { 14 | Manager *sharding.ShardManager 15 | Broker broker.Broker 16 | 17 | ctx context.Context 18 | cancel context.CancelFunc 19 | } 20 | 21 | func NewClient(url string, manager *sharding.ShardManager) (*Client, error) { 22 | log.Info().Msgf("Creating stateway client with URL: %s", url) 23 | 24 | broker, err := broker.NewNATSBroker(url) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to create NATS broker: %w", err) 27 | } 28 | 29 | log.Info().Msgf("Created Stateway NATS broker") 30 | 31 | return &Client{ 32 | Broker: broker, 33 | Manager: manager, 34 | }, nil 35 | } 36 | 37 | func (c *Client) Start() error { 38 | c.ctx, c.cancel = context.WithCancel(context.Background()) 39 | 40 | c.Manager.Session.SyncEvents = false 41 | 42 | gatewayCount := viper.GetInt("nats.gateway_count") 43 | if gatewayCount == 0 { 44 | log.Info().Msg("Listening to Stateway NATS broker for all gateways") 45 | err := broker.Listen(c.ctx, c.Broker, &GatewayListener{ 46 | session: c.Manager.Session, 47 | }) 48 | if err != nil { 49 | return fmt.Errorf("failed to listen to broker: %w", err) 50 | } 51 | return nil 52 | } else { 53 | for i := 0; i < gatewayCount; i++ { 54 | log.Info().Msgf("Listening to Stateway NATS broker for gateway %d", i) 55 | err := broker.Listen(c.ctx, c.Broker, &GatewayListener{ 56 | session: c.Manager.Session, 57 | gatewayIDs: []int{i}, 58 | }) 59 | if err != nil { 60 | return fmt.Errorf("failed to listen to broker: %w", err) 61 | } 62 | } 63 | return nil 64 | } 65 | } 66 | 67 | func (c *Client) Stop() error { 68 | if c.cancel != nil { 69 | c.cancel() 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /embedg-server/actions/variables/guild.go: -------------------------------------------------------------------------------- 1 | package variables 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/merlinfuchs/discordgo" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | type GuildVariables struct { 11 | guildID string 12 | guild *discordgo.Guild 13 | state *discordgo.State 14 | } 15 | 16 | func NewGuildVariables(guildID string, state *discordgo.State, guild *discordgo.Guild) *GuildVariables { 17 | return &GuildVariables{ 18 | guildID: guildID, 19 | guild: guild, 20 | state: state, 21 | } 22 | } 23 | 24 | func (v *GuildVariables) ensureGuild() bool { 25 | if v.guild != nil { 26 | return true 27 | } 28 | 29 | guild, err := v.state.Guild(v.guildID) 30 | if err != nil { 31 | if err == discordgo.ErrStateNotFound { 32 | return false 33 | } 34 | log.Error().Err(err).Msg("Failed to retrieve guild for variables") 35 | return false 36 | } 37 | 38 | v.guild = guild 39 | return true 40 | } 41 | 42 | func (v *GuildVariables) Get(keys ...string) *string { 43 | if len(keys) == 0 { 44 | return nil 45 | } 46 | 47 | if keys[0] != "guild" && keys[0] != "server" { 48 | return nil 49 | } 50 | 51 | if !v.ensureGuild() { 52 | return nil 53 | } 54 | 55 | if len(keys) == 1 { 56 | return &v.guild.Name 57 | } 58 | 59 | switch keys[1] { 60 | case "id": 61 | return &v.guild.ID 62 | case "name": 63 | return &v.guild.Name 64 | case "description": 65 | return &v.guild.Description 66 | case "icon": 67 | return &v.guild.Icon 68 | case "icon_url": 69 | v := v.guild.IconURL("512") 70 | return &v 71 | case "banner": 72 | return &v.guild.Banner 73 | case "banner_url": 74 | v := v.guild.BannerURL("1024") 75 | return &v 76 | case "member_count": 77 | v := fmt.Sprintf("%d", v.guild.MemberCount) 78 | return &v 79 | case "boost_count": 80 | v := fmt.Sprintf("%d", v.guild.PremiumSubscriptionCount) 81 | return &v 82 | case "boost_level": 83 | v := fmt.Sprintf("%d", v.guild.PremiumTier) 84 | return &v 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /embedg-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/merlinfuchs/embed-generator/embedg-server/buildinfo" 5 | "github.com/merlinfuchs/embed-generator/embedg-server/config" 6 | "github.com/merlinfuchs/embed-generator/embedg-server/entry/admin" 7 | "github.com/merlinfuchs/embed-generator/embedg-server/entry/database" 8 | "github.com/merlinfuchs/embed-generator/embedg-server/entry/server" 9 | "github.com/merlinfuchs/embed-generator/embedg-server/telemetry" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | var rootCmd = &cobra.Command{ 15 | Use: "embedg", 16 | Short: "The backend for Embed Generator.", 17 | Long: `The backend for Embed Generator.`, 18 | PersistentPreRun: bindFlags, 19 | } 20 | 21 | func init() { 22 | rootCmd.PersistentFlags().StringVar(&config.CfgFile, "config", "", "Config file (default is $HOME/.embedg.yaml)") 23 | rootCmd.Version = buildinfo.FullVersion() 24 | 25 | rootCmd.PersistentFlags().BoolP("debug", "D", false, "Debug mode (prints debug messages and call traces)") 26 | 27 | rootCmd.AddCommand(server.Setup()) 28 | rootCmd.AddCommand(database.SetupMigrate()) 29 | rootCmd.AddCommand(database.SetupBackup()) 30 | rootCmd.AddCommand(admin.Setup()) 31 | } 32 | 33 | func bindFlags(cmd *cobra.Command, args []string) { 34 | viper.BindPFlag("debug", cmd.Flags().Lookup("debug")) 35 | viper.BindPFlag("cfg.local", cmd.Flags().Lookup("cfg.local")) 36 | viper.BindPFlag("cfg.local_file", cmd.Flags().Lookup("cfg.local_file")) 37 | viper.BindPFlag("cfg.remote", cmd.Flags().Lookup("cfg.remote")) 38 | viper.BindPFlag("cfg.remote_file", cmd.Flags().Lookup("cfg.remote_file")) 39 | viper.BindPFlag("cfg.watch", cmd.Flags().Lookup("cfg.watch")) 40 | viper.BindPFlag("cfg.watch_interval_sec", cmd.Flags().Lookup("cfg.watch_interval_sec")) 41 | } 42 | 43 | func main() { 44 | config.InitConfig() 45 | telemetry.SetupLogger() 46 | 47 | if err := rootCmd.Execute(); err != nil { 48 | panic(err) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /embedg-server/api/managers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/merlinfuchs/embed-generator/embedg-server/actions/handler" 5 | "github.com/merlinfuchs/embed-generator/embedg-server/actions/parser" 6 | "github.com/merlinfuchs/embed-generator/embedg-server/api/access" 7 | "github.com/merlinfuchs/embed-generator/embedg-server/api/premium" 8 | "github.com/merlinfuchs/embed-generator/embedg-server/api/session" 9 | "github.com/merlinfuchs/embed-generator/embedg-server/bot" 10 | "github.com/merlinfuchs/embed-generator/embedg-server/custom_bots" 11 | "github.com/merlinfuchs/embed-generator/embedg-server/scheduled_messages" 12 | ) 13 | 14 | type managers struct { 15 | session *session.SessionManager 16 | access *access.AccessManager 17 | premium *premium.PremiumManager 18 | customBots *custom_bots.CustomBotManager 19 | scheduledMessages *scheduled_messages.ScheduledMessageManager 20 | 21 | actionParser *parser.ActionParser 22 | actionHandler *handler.ActionHandler 23 | } 24 | 25 | func createManagers(stores *Stores, bot *bot.Bot) *managers { 26 | sessionManager := session.New(stores.PG) 27 | accessManager := access.New(bot.State, bot.Session, bot.Rest) 28 | premiumManager := premium.New(stores.PG, bot) 29 | 30 | actionParser := parser.New(accessManager, stores.PG, bot.State) 31 | actionHandler := handler.New(stores.PG, actionParser, premiumManager) 32 | 33 | customBots := custom_bots.NewCustomBotManager(stores.PG, actionHandler) 34 | scheduledMessages := scheduled_messages.NewScheduledMessageManager(stores.PG, actionParser, bot, premiumManager) 35 | 36 | bot.ActionHandler = actionHandler 37 | bot.ActionParser = actionParser 38 | 39 | return &managers{ 40 | session: sessionManager, 41 | access: accessManager, 42 | premium: premiumManager, 43 | customBots: customBots, 44 | scheduledMessages: scheduledMessages, 45 | actionParser: actionParser, 46 | actionHandler: actionHandler, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /embedg-server/telemetry/logging.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/spf13/viper" 10 | 11 | "github.com/rs/zerolog" 12 | "github.com/rs/zerolog/diode" 13 | "github.com/rs/zerolog/log" 14 | "github.com/rs/zerolog/pkgerrors" 15 | "gopkg.in/natefinch/lumberjack.v2" 16 | ) 17 | 18 | func SetupLogger() { 19 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack 20 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs 21 | 22 | hostname, err := os.Hostname() 23 | if err != nil { 24 | log.Error().Err(err).Msg("Failed to get hostname") 25 | hostname = "" 26 | } 27 | 28 | logContext := log.With(). 29 | Str("host", hostname) 30 | 31 | if viper.GetBool("debug") { 32 | logContext = logContext.Caller() 33 | } 34 | 35 | logWriters := make([]io.Writer, 0) 36 | if viper.GetBool("log.use_json") { 37 | logWriters = append(logWriters, syncWriter()) 38 | } else { 39 | logWriters = append(logWriters, zerolog.ConsoleWriter{Out: os.Stdout}) 40 | } 41 | 42 | if viper.GetString("logging.filename") != "" { 43 | lj := lumberjack.Logger{ 44 | Filename: viper.GetString("logging.filename"), 45 | MaxSize: viper.GetInt("logging.max_size"), 46 | MaxAge: viper.GetInt("logging.max_age"), 47 | MaxBackups: viper.GetInt("logging.max_backups"), 48 | } 49 | logWriters = append(logWriters, &lj) 50 | } 51 | writer := io.MultiWriter(logWriters...) 52 | log.Logger = logContext.Logger().Output(writer) 53 | 54 | if viper.GetBool("debug") { 55 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 56 | } else { 57 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 58 | } 59 | } 60 | 61 | // syncWriter is concurrent safe writer. 62 | func syncWriter() io.Writer { 63 | return diode.NewWriter(os.Stderr, 1000, 0, func(missed int) { 64 | fmt.Printf("Logger Dropped %d messages", missed) 65 | }) 66 | } 67 | 68 | // Returns the logger for the given fiber user context 69 | func L(c *fiber.Ctx) *zerolog.Logger { 70 | return zerolog.Ctx(c.UserContext()) 71 | } 72 | -------------------------------------------------------------------------------- /embedg-site/src/components/HomeFooter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function HomeFooter(): JSX.Element { 4 | return ( 5 |
6 |
7 |
8 |
9 |
Docs
10 | 15 |
16 |
17 |
Community
18 | 34 |
35 | 46 |
47 |
{`Copyright © ${new Date().getFullYear()} Merlin Fuchs & Contributors | Not affiliated with or endorsed by Discord Inc.`}
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /embedg-app/src/state/attachments.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { MessageAttachmentWire } from "../api/wire"; 3 | import { immer } from "zustand/middleware/immer"; 4 | 5 | export interface MessageAttachment extends MessageAttachmentWire { 6 | id: number; 7 | size: number; 8 | } 9 | 10 | export interface AttachmentsStore { 11 | attachments: MessageAttachment[]; 12 | replaceAttachments: (attachments: MessageAttachment[]) => void; 13 | addAttachment: (attachment: MessageAttachment) => void; 14 | clearAttachments: () => void; 15 | removeAttachment: (i: number) => void; 16 | setAttachmentName: (i: number, name: string) => void; 17 | moveAttachmentUp: (i: number) => void; 18 | moveAttachmentDown: (i: number) => void; 19 | } 20 | 21 | export const useCurrentAttachmentsStore = create()( 22 | immer((set) => ({ 23 | attachments: [], 24 | 25 | replaceAttachments: (attachments: MessageAttachment[]) => 26 | set((state) => { 27 | state.attachments = attachments; 28 | }), 29 | addAttachment: (attachment: MessageAttachment) => 30 | set((state) => { 31 | state.attachments.push(attachment); 32 | }), 33 | clearAttachments: () => 34 | set((state) => { 35 | state.attachments = []; 36 | }), 37 | removeAttachment: (i: number) => 38 | set((state) => { 39 | state.attachments.splice(i, 1); 40 | }), 41 | setAttachmentName: (i: number, name: string) => 42 | set((state) => { 43 | state.attachments[i].name = name; 44 | }), 45 | moveAttachmentUp: (i: number) => 46 | set((state) => { 47 | const attachment = state.attachments[i]; 48 | state.attachments.splice(i, 1); 49 | state.attachments.splice(i - 1, 0, attachment); 50 | }), 51 | moveAttachmentDown: (i: number) => 52 | set((state) => { 53 | const attachment = state.attachments[i]; 54 | state.attachments.splice(i, 1); 55 | state.attachments.splice(i + 1, 0, attachment); 56 | }), 57 | })) 58 | ); 59 | -------------------------------------------------------------------------------- /embedg-server/db/s3/store.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import ( 4 | "context" 5 | "encoding/hex" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/minio/minio-go/v7" 10 | "github.com/minio/minio-go/v7/pkg/credentials" 11 | "github.com/minio/minio-go/v7/pkg/encrypt" 12 | "github.com/rs/zerolog/log" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | var requiredBuckets = []string{ 17 | imagesBucketName, 18 | dbBackupBucket, 19 | } 20 | 21 | type BlobStore struct { 22 | client *minio.Client 23 | encryption encrypt.ServerSide 24 | } 25 | 26 | func New() (*BlobStore, error) { 27 | client, err := minio.New(viper.GetString("s3.endpoint"), &minio.Options{ 28 | Creds: credentials.NewStaticV4(viper.GetString("s3.access_key_id"), viper.GetString("s3.secret_access_key"), ""), 29 | Secure: viper.GetBool("s3.secure"), 30 | }) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | for _, bucket := range requiredBuckets { 36 | exists, err := client.BucketExists(context.Background(), bucket) 37 | if err != nil { 38 | if strings.Contains(err.Error(), "connection refused") { 39 | log.Warn().Msgf("Failed to check if bucket %s exists, is S3 correctly configured?", bucket) 40 | continue 41 | } 42 | return nil, fmt.Errorf("Failed to check if bucket %s exists: %w", bucket, err) 43 | } 44 | 45 | if !exists { 46 | err = client.MakeBucket(context.Background(), bucket, minio.MakeBucketOptions{}) 47 | if err != nil { 48 | return nil, fmt.Errorf("Failed to create bucket %s: %w", bucket, err) 49 | } 50 | } 51 | } 52 | 53 | var encryption encrypt.ServerSide 54 | if viper.GetString("s3.ssec_key") != "" { 55 | key, err := hex.DecodeString(viper.GetString("s3.ssec_key")) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to decode S3 encryption key: %w", err) 58 | } 59 | 60 | encryption, err = encrypt.NewSSEC(key) 61 | if err != nil { 62 | return nil, fmt.Errorf("failed to create S3 encryption: %w", err) 63 | } 64 | } 65 | 66 | return &BlobStore{ 67 | client: client, 68 | encryption: encryption, 69 | }, nil 70 | } 71 | -------------------------------------------------------------------------------- /embedg-site/src/components/HomeHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { SparklesIcon } from "@heroicons/react/24/solid"; 3 | 4 | export default function HomeHeader(): JSX.Element { 5 | return ( 6 |
7 |
8 |
9 | 10 |
11 | Embed Generator 12 |
13 |
14 | 43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /embedg-app/src/util/toasts.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { getUniqueId } from "."; 3 | import { 4 | CheckCircleIcon, 5 | ExclamationCircleIcon, 6 | InformationCircleIcon, 7 | } from "@heroicons/react/20/solid"; 8 | 9 | interface Toast { 10 | title: string; 11 | message: string; 12 | type?: "success" | "error" | "info"; 13 | timeout?: number; 14 | } 15 | 16 | interface ToastWithId extends Toast { 17 | id: number; 18 | } 19 | 20 | interface ToastStore { 21 | toasts: ToastWithId[]; 22 | create(toast: Toast): void; 23 | } 24 | 25 | export const useToasts = create()((set) => ({ 26 | toasts: [], 27 | create: (toast) => { 28 | const id = getUniqueId(); 29 | set((state) => ({ 30 | toasts: [ 31 | ...state.toasts, 32 | { 33 | ...toast, 34 | id, 35 | }, 36 | ], 37 | })); 38 | setTimeout(() => { 39 | set((state) => ({ 40 | toasts: state.toasts.filter((t) => t.id !== id), 41 | })); 42 | }, toast.timeout || 5000); 43 | }, 44 | })); 45 | 46 | export function ToastContainer() { 47 | const toasts = useToasts((state) => state.toasts); 48 | return ( 49 |
50 | {toasts.map((toast) => ( 51 |
55 | {toast.type === "success" ? ( 56 | 57 | ) : toast.type === "error" ? ( 58 | 59 | ) : ( 60 | 61 | )} 62 |
63 |
{toast.title}
64 |
{toast.message}
65 |
66 |
67 | ))} 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /embedg-app/src/components/Collapsable.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronRightIcon } from "@heroicons/react/20/solid"; 2 | import clsx from "clsx"; 3 | import { ReactNode } from "react"; 4 | import { useCollapsedState } from "../state/collapsed"; 5 | import { AutoAnimate } from "../util/autoAnimate"; 6 | import ValidationErrorIndicator from "./ValidationErrorIndicator"; 7 | 8 | interface Props { 9 | id: string; 10 | children: ReactNode; 11 | title: string; 12 | extra?: ReactNode; 13 | buttons?: ReactNode; 14 | size?: "medium" | "large"; 15 | validationPathPrefix?: string | string[]; 16 | defaultCollapsed?: boolean; 17 | } 18 | 19 | export default function Collapsable({ 20 | id, 21 | children, 22 | title, 23 | size = "medium", 24 | extra, 25 | buttons, 26 | validationPathPrefix: valiationPathPrefix, 27 | defaultCollapsed, 28 | }: Props) { 29 | const [collapsed, toggleCollapsed] = useCollapsedState(id, defaultCollapsed); 30 | 31 | return ( 32 |
33 |
34 |
toggleCollapsed()} 37 | > 38 | 46 |
47 | {title} 48 |
49 | {valiationPathPrefix && ( 50 |
51 | 52 |
53 | )} 54 | {extra} 55 |
56 |
{buttons}
57 |
58 | 59 | {!collapsed &&
{children}
} 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /embedg-server/api/premium/roles.go: -------------------------------------------------------------------------------- 1 | package premium 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/merlinfuchs/discordgo" 9 | "github.com/merlinfuchs/embed-generator/embedg-server/util" 10 | "github.com/rs/zerolog/log" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | func (m *PremiumManager) lazyPremiumRolesTask() { 15 | for { 16 | time.Sleep(15 * time.Minute) 17 | 18 | if err := m.assignPremiumRoles(); err != nil { 19 | log.Error().Err(err).Msg("Failed to assign premium roles") 20 | } 21 | } 22 | } 23 | 24 | func (m *PremiumManager) assignPremiumRoles() error { 25 | guildID := viper.GetString("premium.guild_id") 26 | roleID := viper.GetString("premium.role_id") 27 | if guildID == "" || roleID == "" { 28 | return nil 29 | } 30 | 31 | userIDs, err := m.GetEntitledUserIDs(context.Background()) 32 | if err != nil { 33 | return fmt.Errorf("Failed to get entitled user IDs: %w", err) 34 | } 35 | 36 | for _, userID := range userIDs { 37 | features, err := m.GetPlanFeaturesForUser(context.Background(), userID) 38 | if err != nil { 39 | log.Error().Err(err).Msg("Failed to get plan features for guild") 40 | continue 41 | } 42 | 43 | member, err := m.bot.Session.GuildMember(guildID, userID) 44 | if err != nil { 45 | if util.IsDiscordRestErrorCode(err, discordgo.ErrCodeUnknownMember) { 46 | continue 47 | } 48 | 49 | log.Error().Err(err).Msg("Failed to get guild member") 50 | continue 51 | } 52 | 53 | hasPremiumRole := false 54 | for _, r := range member.Roles { 55 | if r == roleID { 56 | hasPremiumRole = true 57 | break 58 | } 59 | } 60 | 61 | if features.IsPremium && !hasPremiumRole { 62 | err = m.bot.Session.GuildMemberRoleAdd(guildID, userID, roleID) 63 | if err != nil { 64 | log.Error().Err(err).Msg("Failed to add premium role") 65 | } 66 | } else if !features.IsPremium && hasPremiumRole { 67 | err = m.bot.Session.GuildMemberRoleRemove(guildID, userID, roleID) 68 | if err != nil { 69 | log.Error().Err(err).Msg("Failed to remove premium role") 70 | } 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /embedg-app/src/components/EditorComponentActionRowButton.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from "zustand/shallow"; 2 | import { useCurrentMessageStore } from "../state/message"; 3 | import EditorComponentBaseButton from "./EditorComponentBaseButton"; 4 | 5 | interface Props { 6 | rootIndex: number; 7 | rootId: number; 8 | childIndex: number; 9 | childId: number; 10 | } 11 | 12 | export default function EditorComponentActionRowButton({ 13 | rootIndex, 14 | rootId, 15 | childIndex, 16 | childId, 17 | }: Props) { 18 | const buttonCount = useCurrentMessageStore( 19 | (state) => state.getActionRow(rootIndex)?.components.length || 0 20 | ); 21 | 22 | const button = useCurrentMessageStore( 23 | (state) => state.getActionRowButton(rootIndex, childIndex), 24 | shallow 25 | ); 26 | 27 | const updateButton = useCurrentMessageStore( 28 | (state) => state.updateActionRowComponent, 29 | shallow 30 | ); 31 | 32 | const [moveUp, moveDown, duplicate, remove] = useCurrentMessageStore( 33 | (state) => [ 34 | state.moveActionRowComponentUp, 35 | state.moveActionRowComponentDown, 36 | state.duplicateActionRowComponent, 37 | state.deleteActionRowComponent, 38 | ], 39 | shallow 40 | ); 41 | 42 | if (!button) { 43 | // This is not a button (shouldn't happen) 44 | return
; 45 | } 46 | 47 | return ( 48 | updateButton(rootIndex, childIndex, data)} 54 | duplicate={ 55 | buttonCount < 5 ? () => duplicate(rootIndex, childIndex) : undefined 56 | } 57 | moveUp={childIndex > 0 ? () => moveUp(rootIndex, childIndex) : undefined} 58 | moveDown={ 59 | childIndex < buttonCount - 1 60 | ? () => moveDown(rootIndex, childIndex) 61 | : undefined 62 | } 63 | remove={() => remove(rootIndex, childIndex)} 64 | /> 65 | ); 66 | } 67 | --------------------------------------------------------------------------------