On This Page

Send Messages, Embeds and Files

Reply, send and edit messages, build embeds, attach files and send direct messages

Start with a bot that can reply to !ping. Add these helpers to the first bot example and call them from a command or message handler. They use the bot's existing client rather than opening another connection. The last section shows where they plug into runBot

Reply to a Message and Check What Happened

Use reply when the answer belongs to an incoming message. Awaiting an operation in the default API gives a Result: Check isErr() first, then read value

import { describeError } from "@neontechspace/fluxerly"

export async function replyHello(client, message) {
    if (message.author.isBot)
        return

    const result = await client.messages.reply(message, `Hello, ${message.author.username}!`)
    if (result.isErr()) {
        console.error(describeError(result.error))
        return
    }
    return result.value
}
import { describeError, type Client, type Message } from "@neontechspace/fluxerly"

export async function replyHello(client: Client, message: Message) {
    if (message.author.isBot) return

    const result = await client.messages.reply(message, `Hello, ${message.author.username}!`)
    if (result.isErr()) {
        console.error(describeError(result.error))
        return
    }
    return result.value
}

A plain string sends a text-only message. On success, value is the message Fluxer created, including its ID. The describeError helper prints the failure's message, code, suggested fix and cause. Replies do not notify anyone by default, not even the author of the replied-to message

What Does a Failed Send Say?

The error message names the operation and Fluxer's answer, then Fluxer's code and the HTTP status, for example Message send failed: Fluxer reports that the bot lacks a required permission (MISSING_PERMISSIONS, HTTP 403). The error's hint suggests a next step. For a missing permission, details.requiredPermissions lists the permissions Fluxer checks for the operation, such as ["ViewChannel", "SendMessages"] for a send, and the hint names them

Fluxer does not report which one is missing, so the bot may already have some of them. When Fluxer answers with a code this SDK version does not know yet, details.providerCode holds it. When Fluxer's answer does not have the expected shape, details.responseField names the field that failed

A rejection with HTTP 401 or 403 that the application handles also logs a Warn record with the code rest.rejected, because it usually points at a lasting token or permission problem. The SDK holds that Warn for up to one second. A handler's failure record replaces it only if the handler fails with the same rejection while the Warn is held and no onError hook is configured for that failure

Identical repeats collapse into one record per minute by default. The error and log codes page lists every code with its meaning

Skip Join Notices and Other System Messages

Fluxer also delivers notices, such as a member joining or a pinned message, as messages. Compare message.type with a MessageType constant to tell them apart. For example, message.type === MessageType.UserJoin is a join notice, and ordinary messages are MessageType.Default or MessageType.Reply

A messageCreate or messageUpdate event for a community message can carry message.member, the author's community membership as Fluxer held it when it sent the event, with roles, nickname and join time. It is absent on messages from REST reads, direct messages, webhooks and authors who left the community, and the message cache does not keep it, so read it in the handler

With cache.members enabled, members.get then answers for that author without a request. The same member arrives as reaction.member on messageReactionAdd and messageReactionRemove events and as typing.member on typingStart events. A member that Fluxer sends in a form the SDK cannot read is left off, and the event still arrives

A messageCreate event, and no other message event, can also carry message.channelType, the numeric type of the channel, with 1 for a direct message and 3 for a group direct message, message.mentionedHere, which Fluxer sends only as true for a here mention, and message.nicknames, the nicknames of a group direct message keyed by user ID

REST reads and messageUpdate events never carry them, and the message cache does not keep them. A messageDelete event can carry deletion.member, the author's community membership without a username or bot flag, because Fluxer removes the member's user from it. The member cache does not store it

Received messages can carry message.messageReference, which points at a replied-to or forwarded message, or at the source of an announcement copy or follow notice. Its channelId is always present, but id is optional. Check id, then build a MessageReference from both fields for a fetch or reply. A follow notice identifies only the source channel. See announcement channels for publishing messages and handling their copies

Sends and edits accept MessageFlags.SuppressEmbeds and MessageFlags.SuppressNotifications. Message sends also accept MessageFlags.VoiceMessage for a voice note. Message sends include replies, direct messages, the first message of a forum post and webhook sends. Edits and webhook forwards reject that flag, and the SDK rejects any other flag before sending the request. Fluxer sets Crossposted, IsCrosspost and SourceMessageDeleted itself

Mention Someone Deliberately

Create a mention with format.userMention, then allow a notification for that specific user. Role and channel mentions have matching roleMention and channelMention helpers

import { format } from "@neontechspace/fluxerly"

export async function notifyParticipant(client, channelId, userId) {
    return await client.messages.send(channelId, {
        content: `${format.userMention(userId)} The game is starting`,
        allowedMentions: { users: [userId] },
    })
}
import { format, type Client } from "@neontechspace/fluxerly"

export async function notifyParticipant(client: Client, channelId: string, userId: string) {
    return await client.messages.send(channelId, {
        content: `${format.userMention(userId)} The game is starting`,
        allowedMentions: { users: [userId] },
    })
}

Methods that take a user, role or channel ID expect the decimal ID. To read a mention typed by a user, use format.tryParseMention, which returns a Result instead of throwing on invalid text. Command arguments can also accept either an ID or a mention

Send a Text-to-Speech Message

Add tts: true to the content passed to messages.send, messages.reply, directMessages.send or the reply helper of a runBot handler or command. A value other than a boolean fails with reason input before any request. In a community, a bot without the Send TTS Messages permission gets a normal message and no error

The tts field of a message is meaningful only on the message returned by the send and on the messageCreate event. Fluxer does not store it, so fetches, history pages and other later reads always report false. Webhook messages do not accept tts: A webhook send, reply or forward that includes it fails with reason input before any request

Send, Then Edit the Returned Message

Use send for a channel message that does not reply to anything. Keep the returned message to edit it later

export async function postStatus(client, channelId) {
    const sent = await client.messages.send(channelId, "Preparing the report…")
    if (sent.isErr())
        return sent

    return await client.messages.edit(sent.value, "Report ready")
}
import type { Client } from "@neontechspace/fluxerly"

export async function postStatus(client: Client, channelId: string) {
    const sent = await client.messages.send(channelId, "Preparing the report…")
    if (sent.isErr()) return sent

    return await client.messages.edit(sent.value, "Report ready")
}

The helper posts a message, then edits it, and returns the edit's Result or the send's failure

What if the Send or the Edit Fails?

If the edit fails, the first message stays posted. If the connection fails after a send or edit was dispatched, the change may still have reached Fluxer. Check the returned Result before deciding whether to try again, and avoid repeating a write whose outcome is unknown. The reliability guide explains these uncertain writes and when a retry is safe

Build an Embed

An embed is a formatted card with a title, description, color and fields. The embed builder assembles one step by step. Pass the builder in the message's embeds list

import { builders } from "@neontechspace/fluxerly"

export async function postSchedule(client, channelId) {
    const embed = builders.embed()
        .title("Tonight's game")
        .description("Meet in the lobby before the first round")
        .color("#8b7cf8")
        .field("Start", "19:00 UTC", { inline: true })
        .field("Mode", "Co-op", { inline: true })
        .footer({ text: "Snacks provided" })

    return await client.messages.send(channelId, { embeds: [embed] })
}
import { builders, type Client } from "@neontechspace/fluxerly"

export async function postSchedule(client: Client, channelId: string) {
    const embed = builders.embed()
        .title("Tonight's game")
        .description("Meet in the lobby before the first round")
        .color("#8b7cf8")
        .field("Start", "19:00 UTC", { inline: true })
        .field("Mode", "Co-op", { inline: true })
        .footer({ text: "Snacks provided" })

    return await client.messages.send(channelId, { embeds: [embed] })
}

The inline option places fields side by side. The color method accepts a hex string such as "#8b7cf8", a number or an RGB tuple, and timestamp accepts a Date, epoch milliseconds or an ISO 8601 string with a time zone

The send checks the embed against Fluxer's limits. It reads the builder once, so later builder changes do not alter the sent message. Use messages.edit for that. The builder's build() method returns the same embed as a plain object, which could also be written by hand

An embed's image.url or thumbnail.url can use attachment://filename to show a new image or video uploaded in the same request. Avoid two dots in a row and names such as CON or AUX, because Fluxer renames those files and the reference then fails. The name must match exactly one upload, including case, and its extension must map to an image or video MIME type in Fluxer's filename lookup, such as AVIF or MP4. Referencing an existing attachment ID is not supported

In a community channel, sending or editing a message with embeds needs the EmbedLinks permission. New communities grant it to everyone, but where it is removed, Fluxer rejects the whole message with a missing-permissions error instead of sending it without the embeds

Upload a Small Generated File

For text already in memory, encode it to bytes and give the attachment a file name. No temporary file on disk is needed

import { builders } from "@neontechspace/fluxerly"

export async function sendChecklist(client, channelId) {
    const data = new TextEncoder().encode("[ ] Check permissions\n[ ] Test !ping\n")
    const message = builders.message()
        .content("Bot checklist")
        .attachment({ data, filename: "checklist.txt", contentType: "text/plain" })
        .build()

    return await client.messages.send(channelId, message)
}
import { builders, type Client } from "@neontechspace/fluxerly"

export async function sendChecklist(client: Client, channelId: string) {
    const data = new TextEncoder().encode("[ ] Check permissions\n[ ] Test !ping\n")
    const message = builders.message()
        .content("Bot checklist")
        .attachment({ data, filename: "checklist.txt", contentType: "text/plain" })
        .build()

    return await client.messages.send(channelId, message)
}

The builder keeps a reference to the bytes, and the send copies them when it is called. For larger files, pass a file source such as a Blob, or a stream with its exact size, instead of data. The attachment input reference lists these forms, which avoid loading everything into memory

Send a File from Disk

On Node.js, openAsBlob from node:fs opens a file without reading it into memory. Pass the result as file and give the upload a name. A path string is not accepted, so open the file first

import { openAsBlob } from "node:fs"
import { basename } from "node:path"
import { builders } from "@neontechspace/fluxerly"

export async function sendReport(client, channelId, path) {
    const file = await openAsBlob(path)
    const message = builders.message()
        .content("Daily report")
        .attachment({ file, filename: basename(path) })
        .build()

    return await client.messages.send(channelId, message)
}
import { openAsBlob } from "node:fs"
import { basename } from "node:path"
import { builders, type Client } from "@neontechspace/fluxerly"

export async function sendReport(client: Client, channelId: string, path: string) {
    const file = await openAsBlob(path)
    const message = builders.message()
        .content("Daily report")
        .attachment({ file, filename: basename(path) })
        .build()

    return await client.messages.send(channelId, message)
}

The send reads the file while it uploads, so keep the file unchanged until the send finishes

Mark Media as Explicit or Send a Voice Note

Set explicitMedia: true on an attachment to mark it as sensitive, next to spoiler. Fluxer's apps then treat it like media that Fluxer's own check flags. The mark does not make Fluxer refuse the file. Fluxer still checks media itself and refuses explicit media where the community's explicit content filter applies, with apiError.code explicitContentCannotBeSent

On a received attachment, the explicit-media mark is (attachment.flags & (1 << 4)) !== 0. It can be present even when attachment.nsfw is false or absent

A voice note is an uploaded audio file that Fluxer's apps show with a player and a waveform. It has nothing to do with voice channels, which the SDK does not connect to. Send one with MessageFlags.VoiceMessage and exactly one audio attachment that has duration in whole seconds and waveform, the Base64 amplitude bytes the player draws. The SDK does not record or decode audio. Decode the file into the samples of its first channel, the one Fluxer's apps read, and voiceWaveform(samples, sampleRate) computes both values the way Fluxer's apps do, so the bars match a note recorded in Fluxer

import { MessageFlags, voiceWaveform } from "@neontechspace/fluxerly"

// The samples are one channel of the decoded file, at sampleRate samples per second
export async function sendVoiceNote(client, channelId, audio, samples, sampleRate) {
    return await client.messages.send(channelId, {
        attachments: [{ data: audio, filename: "note.ogg", contentType: "audio/ogg", ...voiceWaveform(samples, sampleRate) }],
        flags: MessageFlags.VoiceMessage,
    })
}
import { MessageFlags, voiceWaveform, type Client } from "@neontechspace/fluxerly"

// The samples are one channel of the decoded file, at sampleRate samples per second
export async function sendVoiceNote(client: Client, channelId: string, audio: Uint8Array, samples: Float32Array, sampleRate: number) {
    return await client.messages.send(channelId, {
        attachments: [{ data: audio, filename: "note.ogg", contentType: "audio/ogg", ...voiceWaveform(samples, sampleRate) }],
        flags: MessageFlags.VoiceMessage,
    })
}

With ffmpeg, ffmpeg -i note.ogg -af 'pan=mono|c0=c0' -ar 48000 -f f32le - writes the first channel as 32-bit float samples at 48,000 samples per second. Mixing the channels, as -ac 1 does, gives other bars for a stereo file. Copy the output into a Float32Array, for example with new Float32Array(new Uint8Array(output).buffer), and pass 48000 as the rate. Exact parity with Fluxer's apps needs the same first-channel samples at the same sample rate, which another decoder or resampling can change slightly. Ordinary decoded sample scales work, such as floats from -1 through 1 or 16-bit integers from a WAV file, because the waveform is relative to the loudest part

A voice note cannot have content, embeds or stickers, and the SDK refuses those before sending. Fluxer also checks that the file is audio and that it is no longer than the instance allows, 1,200 seconds by default. messages.send, messages.reply, direct message sends, the first message of a forum or media post and webhook sends and replies accept voice notes. A waveform holds at most 4,096 characters, except in a webhook send or reply. Webhook files always go as multipart, where Fluxer reads a waveform of at most 256 characters, so a longer waveform fails locally on attachments[].waveform. A voiceWaveform result fits for audio up to 19.2 seconds, and voiceWaveform(samples, sampleRate, { maxPoints: 192 }) fits for audio of any length. For audio over 19.2 seconds, each of its bars covers more audio than in a note recorded in Fluxer. Edits and webhook forwards do not accept voice notes. To keep an existing voice note as a voice note, omit flags when editing it. On the bot's own message, and on a webhook message edited through its webhook, supplying flags, including 0, clears its voice-note flag, and an edit cannot set it again. When the bot client edits another author's message, Fluxer requires Manage Messages in a community and changes only SuppressEmbeds, keeping the other flags

Download an Attachment When Needed

Received messages describe their attachments but do not download them. Set a byte limit and handle a failed download before processing any content

import { describeError } from "@neontechspace/fluxerly"

export async function readFirstSmallFile(client, message) {
    const attachment = message.attachments[0]
    if (!attachment)
        return

    const downloaded = await client.attachments.download(attachment, { maxBytes: 64 * 1024 })
    if (downloaded.isErr()) {
        console.error(describeError(downloaded.error))
        return
    }
    return downloaded.value
}
import { describeError, type Client, type Message } from "@neontechspace/fluxerly"

export async function readFirstSmallFile(client: Client, message: Message) {
    const attachment = message.attachments[0]
    if (!attachment) return

    const downloaded = await client.attachments.download(attachment, { maxBytes: 64 * 1024 })
    if (downloaded.isErr()) {
        console.error(describeError(downloaded.error))
        return
    }
    return downloaded.value
}

Check the file type and contents before processing downloaded bytes. The download uses the attachment's media address on the bot's Fluxer instance and does not accept arbitrary URLs from message text

Buffered downloads and attachments.stream preserve a sanitized cause for discovery and transport failures, including a safe code such as ECONNRESET when available. The original transport error, attachment URL and response body are not retained in that cause

Attachment links can expire while the file still exists. The SDK does not refresh links automatically. Before downloading a stored link, call attachments.refreshUrls and use the returned URL. Refreshing does not restore a deleted file

export async function downloadStored(client, attachment) {
    if (attachment.url === undefined)
        return
    const refreshed = await client.attachments.refreshUrls([attachment.url])
    if (refreshed.isErr())
        return refreshed
    const url = refreshed.value[0]?.refreshed ?? attachment.url
    return client.attachments.download({ ...attachment, url }, { maxBytes: 64 * 1024 })
}
import type { Attachment, Client } from "@neontechspace/fluxerly"

export async function downloadStored(client: Client, attachment: Attachment) {
    if (attachment.url === undefined) return
    const refreshed = await client.attachments.refreshUrls([attachment.url])
    if (refreshed.isErr()) return refreshed
    const url = refreshed.value[0]?.refreshed ?? attachment.url
    return client.attachments.download({ ...attachment, url }, { maxBytes: 64 * 1024 })
}

The same steps in the Effect API:

import { Effect } from "effect"
import type { Attachment, Client } from "@neontechspace/fluxerly/effect"

export const downloadStored = (client: Client, attachment: Attachment) =>
    Effect.gen(function* () {
        if (attachment.url === undefined) return
        const [link] = yield* client.attachments.refreshUrls([attachment.url])
        const url = link?.refreshed ?? attachment.url
        return yield* client.attachments.download({ ...attachment, url }, { maxBytes: 64 * 1024 })
    })

Send a Requested Direct Message

Send a private message only after the recipient asks for one, for example through an opt-in command. The directMessages.send method opens the conversation with that user and sends the message

export async function sendRequestedDm(client, recipientId) {
    return await client.directMessages.send(recipientId, "The requested game instructions are ready")
}
import type { Client } from "@neontechspace/fluxerly"

export async function sendRequestedDm(client: Client, recipientId: string) {
    return await client.directMessages.send(recipientId, "The requested game instructions are ready")
}

A send can still fail when the recipient's privacy settings block it. To reuse the conversation, directMessages.open returns its channel, and existing group conversations use the normal message methods with their channel ID. No method creates a group conversation. Fluxer lets a bot create one with client.rest.request and POST /users/@me/channels with a recipients list, for people who share a community with the bot and allow it. Where the instance turns on CAPTCHA, Fluxer also asks for a solved challenge, which a raw request cannot send, so it fails with apiError.code captchaRequired. See private conversations for reads and permitted management actions

Wire It Up

In a runBot bot, each command receives a reply helper already bound to the incoming message. This bot answers !schedule with an embed and stops cleanly on Ctrl+C

import { builders, runBot } from "@neontechspace/fluxerly"

await runBot({
    token: process.env.FLUXER_BOT_TOKEN,
    commands: {
        prefix: "!",
        commands: {
            schedule: {
                description: "Show tonight's game",
                execute: ({ reply }) => {
                    const embed = builders.embed()
                        .title("Tonight's game")
                        .description("Meet in the lobby before the first round")
                        .color("#8b7cf8")
                    return reply({ embeds: [embed] })
                },
            },
        },
    },
})
import { builders, runBot } from "@neontechspace/fluxerly"

await runBot({
    token: process.env.FLUXER_BOT_TOKEN,
    commands: {
        prefix: "!",
        commands: {
            schedule: {
                description: "Show tonight's game",
                execute: ({ reply }) => {
                    const embed = builders.embed()
                        .title("Tonight's game")
                        .description("Meet in the lobby before the first round")
                        .color("#8b7cf8")
                    return reply({ embeds: [embed] })
                },
            },
        },
    },
})

Returning the reply's Result lets the command router report a failed reply with the command name. If the bot itself stops with a failure, such as a rejected token, runBot logs it once and sets process.exitCode to 1, so the file needs no error handling of its own

Helpers that take a client, such as postSchedule above, receive it from the command context, for example with execute: ({ client, message }) => postSchedule(client, message.channelId)

Continue with prefix commands to turn these helpers into named bot actions, or see the Effect workflow examples for the same operations inside an Effect program