morse command
This commit is contained in:
+7
-2
@@ -55,11 +55,16 @@ int64_t hammy_job_age_ms(const hammy_job_t* job, struct discord* client);
|
||||
// Does not destroy the job.
|
||||
void hammy_job_run(hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
|
||||
|
||||
// Edits the (already deferred) interaction response with a plain text body.
|
||||
// Edits the (already deferred) interaction response with a titled embed,
|
||||
// falling back to a plain text body only if the embed cannot be built.
|
||||
// Used by hammy_job_run() and by the pool's error paths.
|
||||
//
|
||||
// Only valid on the deferred path: it edits a response, so something must have
|
||||
// answered the interaction already. Instant handlers want hammy_job_respond().
|
||||
void hammy_job_reply(const hammy_job_t* job, struct discord* client, const char* title, const char* content, bool isError);
|
||||
|
||||
// Sends a fresh interaction response with a plain text body.
|
||||
// Sends a fresh interaction response with a titled embed, falling back to a
|
||||
// plain text body only if the embed cannot be built.
|
||||
// Only valid on the instant path, where nothing has been sent yet.
|
||||
void hammy_job_respond(const hammy_job_t* job, struct discord* client, const char* title, const char* content, bool isError);
|
||||
|
||||
|
||||
@@ -15,11 +15,15 @@
|
||||
|
||||
#define HAMMY_CALLSIGN_MAX 32
|
||||
#define HAMMY_ENTITY_NAME_MAX 64
|
||||
// Longest code in the morse table is '$' ('...-..-'), seven characters.
|
||||
#define HAMMY_MORSE_MAX 16
|
||||
|
||||
struct hammy_refdb_t {
|
||||
sqlite3* handle;
|
||||
sqlite3_stmt* stExact;
|
||||
sqlite3_stmt* stPrefix;
|
||||
sqlite3_stmt* stMorse;
|
||||
char morseCode[HAMMY_MORSE_MAX]; // Scratch for the last hammy_refdb_get_morse() hit
|
||||
char version[64];
|
||||
};
|
||||
|
||||
@@ -53,6 +57,13 @@ bool hammy_refdb_close(hammy_refdb_t** db);
|
||||
// Returns false if nothing matched.
|
||||
bool hammy_refdb_dxcc(hammy_refdb_t* db, const char* callsign, hammy_dxcc_t* out);
|
||||
|
||||
// Looks up a character in the Morse table. Case-insensitive; non-ASCII bytes
|
||||
// never match. Returns false if not found, leaving *out untouched.
|
||||
//
|
||||
// On a hit *out points at storage owned by the refdb and is only valid until
|
||||
// the NEXT call on the same handle - copy it if it has to outlive that.
|
||||
bool hammy_refdb_get_morse(hammy_refdb_t* db, char c, const char** out);
|
||||
|
||||
// Bundle version string from ref_meta, or NULL. Owned by the refdb.
|
||||
const char* hammy_refdb_version(hammy_refdb_t* db);
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#include <concord/discord.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <hammy/command.h>
|
||||
#include <hammy/job.h>
|
||||
#include <hammy/refdb.h>
|
||||
|
||||
#include <dlibc/vector.h>
|
||||
|
||||
// The reply goes out as an embed description, which Discord caps at 4096
|
||||
// characters, so the two halves get separate budgets that still leave room for
|
||||
// the labels between them. Capping at all is what keeps an unbounded,
|
||||
// user-controlled VLA off the stack: a command option runs to 6000 characters,
|
||||
// and every one of them can expand to eight.
|
||||
#define HAMMY_MORSE_ECHO_MAX 512
|
||||
#define HAMMY_MORSE_CODE_MAX 3072
|
||||
#define HAMMY_MORSE_TRUNCATED " ... (truncated)"
|
||||
|
||||
// Appends one token, space-separated from whatever is already in the buffer and
|
||||
// optionally preceded by a word-gap slash. All or nothing: returns false and
|
||||
// leaves the buffer untouched when the whole thing would not fit, so the caller
|
||||
// can stop without a half-written character on the end.
|
||||
//
|
||||
// len counts bytes excluding the terminator, and the buffer stays terminated
|
||||
// throughout - which is what the strcat-onto-uninitialised-stack version this
|
||||
// replaces got wrong: strcat needs a terminated destination to start from, and
|
||||
// an uninitialised one made the reply random bytes that Discord rejected as
|
||||
// invalid JSON.
|
||||
static bool morse_append(char* buf, size_t cap, size_t* len, bool gap, const char* token) {
|
||||
size_t n = strlen(token);
|
||||
size_t need = (*len > 0 ? 1 : 0) + (gap ? 2 : 0) + n;
|
||||
|
||||
if (*len + need + 1 > cap) { return false; }
|
||||
|
||||
if (*len > 0) { buf[(*len)++] = ' '; }
|
||||
|
||||
if (gap) {
|
||||
buf[(*len)++] = '/';
|
||||
buf[(*len)++] = ' ';
|
||||
}
|
||||
|
||||
memcpy(buf + *len, token, n);
|
||||
*len += n;
|
||||
buf[*len] = '\0';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool morse_is_gap(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
|
||||
}
|
||||
|
||||
// How many bytes of s can be kept without exceeding max OR splitting a UTF-8
|
||||
// sequence. Half a sequence would make the JSON body invalid and cost the whole
|
||||
// reply, so the cut walks back to the nearest lead byte.
|
||||
//
|
||||
// Only the echoed input needs this - the generated code is all ASCII.
|
||||
static size_t morse_utf8_trim(const char* s, size_t max) {
|
||||
size_t n = strlen(s);
|
||||
if (n <= max) { return n; }
|
||||
|
||||
// Continuation bytes are 10xxxxxx. Dropping back past them lands either on
|
||||
// ASCII or on the lead byte of the sequence being cut, and that lead byte
|
||||
// is then excluded too, since the kept range is [0, n).
|
||||
n = max;
|
||||
while (n > 0 && ((unsigned char)s[n] & 0xC0) == 0x80) { n--; }
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
// Instant command: runs on the gateway thread, sends a fresh response.
|
||||
void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, const hammy_refdb_t* refdb) {
|
||||
// Get the text argument from the job
|
||||
const char* text = hammy_job_get_arg(job, "text");
|
||||
if (!text) {
|
||||
hammy_job_respond(job, client, "Error", "No text provided for Morse code conversion.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Echoed back trimmed rather than whole: sizing a buffer from the option
|
||||
// would put an unbounded, user-controlled allocation on the stack.
|
||||
char echo[HAMMY_MORSE_ECHO_MAX + sizeof(HAMMY_MORSE_TRUNCATED)];
|
||||
size_t echoLen = morse_utf8_trim(text, HAMMY_MORSE_ECHO_MAX);
|
||||
|
||||
memcpy(echo, text, echoLen);
|
||||
echo[echoLen] = '\0';
|
||||
|
||||
if (text[echoLen] != '\0') {
|
||||
memcpy(echo + echoLen, HAMMY_MORSE_TRUNCATED, sizeof(HAMMY_MORSE_TRUNCATED));
|
||||
}
|
||||
|
||||
char morse[HAMMY_MORSE_CODE_MAX + sizeof(HAMMY_MORSE_TRUNCATED)];
|
||||
morse[0] = '\0';
|
||||
|
||||
// Room held back so the truncation note always fits.
|
||||
const size_t cap = sizeof(morse) - (sizeof(HAMMY_MORSE_TRUNCATED) - 1);
|
||||
|
||||
size_t len = 0;
|
||||
bool pendingGap = false;
|
||||
bool truncated = false;
|
||||
|
||||
for (const char* p = text; *p; p++) {
|
||||
// The morse table has no row for whitespace, and rendering it as an
|
||||
// unknown character would lose the word boundaries. Held rather than
|
||||
// emitted on the spot so leading, trailing and repeated spaces do not
|
||||
// stack up slashes.
|
||||
if (morse_is_gap(*p)) {
|
||||
pendingGap = (len > 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const char* code = NULL;
|
||||
if (!hammy_refdb_get_morse(refdb, *p, &code)) {
|
||||
code = "?"; // Handle unknown characters
|
||||
}
|
||||
|
||||
if (!morse_append(morse, cap, &len, pendingGap, code)) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
pendingGap = false;
|
||||
}
|
||||
|
||||
// All whitespace, or an empty string: Discord refuses an empty body, so
|
||||
// there is nothing to send back.
|
||||
if (len == 0) {
|
||||
hammy_job_respond(job, client, "Error", "Nothing to convert.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
memcpy(morse + len, HAMMY_MORSE_TRUNCATED, sizeof(HAMMY_MORSE_TRUNCATED));
|
||||
}
|
||||
|
||||
// Reply with the converted Morse code, alongside the text it came from.
|
||||
char body[sizeof(echo) + sizeof(morse) + 32];
|
||||
snprintf(body, sizeof(body), "Original text: `%s`\nMorse: `%s`", echo, morse);
|
||||
|
||||
hammy_job_respond(job, client, "Morse Code Conversion", body, false);
|
||||
}
|
||||
+14
-2
@@ -7,6 +7,18 @@
|
||||
// so adding a command touches exactly two places: its own .c file and this
|
||||
// table.
|
||||
void hammy_cmd_ping(const hammy_job_t* job, struct discord* client, const hammy_refdb_t* refdb);
|
||||
void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, const hammy_refdb_t* refdb);
|
||||
|
||||
// TODO: Temporary - move to a proper options system; e.g. a vector of heap-allocated options or something. For now, just point at static storage.
|
||||
static struct discord_application_command_option morse_opts[] = {
|
||||
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = "text",
|
||||
.description = "Text to convert to Morse code", .required = true },
|
||||
};
|
||||
|
||||
static struct discord_application_command_options morse_opts_struct = {
|
||||
.size = 1,
|
||||
.array = morse_opts
|
||||
};
|
||||
|
||||
// The command table. Pure data - no allocation, no lifetime, no destructor.
|
||||
// Copying an entry into the bot's vector is a plain struct copy, and the vector
|
||||
@@ -14,8 +26,8 @@ void hammy_cmd_ping(const hammy_job_t* job, struct discord* client, const hammy_
|
||||
//
|
||||
// Field order: name, description, options, handler, instant.
|
||||
static const hammy_command_t hammy_builtin_commands[] = {
|
||||
{ "ping", "Check whether Hammy is alive and how fast it is responding",
|
||||
NULL, &hammy_cmd_ping, true },
|
||||
{ "ping", "Check whether Hammy is alive and how fast it is responding", NULL, &hammy_cmd_ping, true },
|
||||
{ "morse", "Text to and from Morse code", &morse_opts_struct, &hammy_cmd_morse, true },
|
||||
|
||||
// Tier 0 commands go here as they land. All pure computation, so instant:
|
||||
// { "grid", "Maidenhead locator conversions", &grid_opts, &hammy_cmd_grid, true },
|
||||
|
||||
+29
-29
@@ -22,6 +22,17 @@ static char* hammy_strdup(const char* src) {
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Logs a failed send, and only a failed send. Concord returns CCORD_PENDING for
|
||||
// an asynchronous request (which is every request made with a NULL ret, i.e.
|
||||
// all of ours) - the request has been queued, not rejected, so treating it as
|
||||
// an error warned on every single reply that actually worked.
|
||||
static void hammy_job_check_send(const hammy_job_t* job, CCORDcode code) {
|
||||
if (code == CCORD_OK || code == CCORD_PENDING) { return; }
|
||||
|
||||
log_warn("[job] Failed to send interaction response for interaction %" PRIu64 ": %s (%d)",
|
||||
job->id, ccord_strerror(code), code);
|
||||
}
|
||||
|
||||
// Pulls the invoking user out of the event. Guild interactions carry it under
|
||||
// member->user, DM interactions under user directly.
|
||||
static u64snowflake hammy_job_extract_user(const struct discord_interaction* event) {
|
||||
@@ -129,31 +140,26 @@ int64_t hammy_job_age_ms(const hammy_job_t* job, struct discord* client) {
|
||||
void hammy_job_reply(const hammy_job_t* job, struct discord* client, const char* title, const char* content, bool isError) {
|
||||
if (!job || !client || !content || !title) { return; }
|
||||
|
||||
if (hammy_embeds_customembed(client, NULL, NULL, title, content, NULL, 0, isError ? 0xFF0000 : 0x00FF00)) {
|
||||
struct discord_interaction_response params = {
|
||||
.type = DISCORD_INTERACTION_CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
.data = &(struct discord_interaction_callback_data){
|
||||
.content = (char*)content
|
||||
}
|
||||
};
|
||||
// Editing, not creating: every caller is on the deferred path, where the
|
||||
// gateway thread already sent DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE. A second
|
||||
// create on the same interaction is refused with "Interaction has already
|
||||
// been acknowledged" (40060). Instant handlers want hammy_job_respond().
|
||||
struct discord_edit_original_interaction_response params = { 0 };
|
||||
|
||||
CCORDcode code = discord_create_interaction_response(client, job->id, job->token, ¶ms, NULL);
|
||||
if (code != CCORD_OK) {
|
||||
log_warn("[job] Failed to send interaction response for interaction %" PRIu64 ": %d", job->id, code);
|
||||
}
|
||||
// Both must outlive the call, so neither can be a compound literal inside
|
||||
// the branch below.
|
||||
struct discord_embed embed[1];
|
||||
struct discord_embeds embeds = { .size = 1, .array = embed };
|
||||
|
||||
if (hammy_embeds_customembed(client, embed, NULL, title, content, NULL, 0, isError ? 0xFF0000 : 0x00FF00)) {
|
||||
params.embeds = &embeds;
|
||||
} else {
|
||||
struct discord_interaction_response params = {
|
||||
.type = DISCORD_INTERACTION_CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
.data = &(struct discord_interaction_callback_data){
|
||||
.content = (char*)content
|
||||
// Only reachable on a bad argument, but a plain-text body still beats
|
||||
// sending nothing at all.
|
||||
params.content = (char*)content;
|
||||
}
|
||||
};
|
||||
|
||||
CCORDcode code = discord_create_interaction_response(client, job->id, job->token, ¶ms, NULL);
|
||||
if (code != CCORD_OK) {
|
||||
log_warn("[job] Failed to send interaction response for interaction %" PRIu64 ": %d", job->id, code);
|
||||
}
|
||||
}
|
||||
hammy_job_check_send(job, discord_edit_original_interaction_response(client, job->appId, job->token, ¶ms, NULL));
|
||||
}
|
||||
|
||||
void hammy_job_respond(const hammy_job_t* job, struct discord* client, const char* title, const char* content, bool isError) {
|
||||
@@ -171,10 +177,7 @@ void hammy_job_respond(const hammy_job_t* job, struct discord* client, const cha
|
||||
}
|
||||
};
|
||||
|
||||
CCORDcode code = discord_create_interaction_response(client, job->id, job->token, ¶ms, NULL);
|
||||
if (code != CCORD_OK) {
|
||||
log_warn("[job] Failed to send interaction response for interaction %" PRIu64 ": %d", job->id, code);
|
||||
}
|
||||
hammy_job_check_send(job, discord_create_interaction_response(client, job->id, job->token, ¶ms, NULL));
|
||||
} else {
|
||||
struct discord_interaction_response params = {
|
||||
.type = DISCORD_INTERACTION_CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
@@ -183,10 +186,7 @@ void hammy_job_respond(const hammy_job_t* job, struct discord* client, const cha
|
||||
}
|
||||
};
|
||||
|
||||
CCORDcode code = discord_create_interaction_response(client, job->id, job->token, ¶ms, NULL);
|
||||
if (code != CCORD_OK) {
|
||||
log_warn("[job] Failed to send interaction response for interaction %" PRIu64 ": %d", job->id, code);
|
||||
}
|
||||
hammy_job_check_send(job, discord_create_interaction_response(client, job->id, job->token, ¶ms, NULL));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ static const char* SQL_PREFIX =
|
||||
static const char* SQL_VERSION =
|
||||
"SELECT value FROM ref_meta WHERE key = 'bundle_version'";
|
||||
|
||||
static const char* SQL_MORSE =
|
||||
"SELECT code FROM morse WHERE character = UPPER(?1) LIMIT 1";
|
||||
|
||||
// Authorizer: the connection may read, and nothing else.
|
||||
//
|
||||
// SQLITE_OPEN_READONLY protects the MAIN database file. It does not stop
|
||||
@@ -100,6 +103,7 @@ hammy_refdb_t* hammy_refdb_open(const char* path) {
|
||||
|
||||
if (!prepare(db->handle, SQL_EXACT, &db->stExact)) { goto fail; }
|
||||
if (!prepare(db->handle, SQL_PREFIX, &db->stPrefix)) { goto fail; }
|
||||
if (!prepare(db->handle, SQL_MORSE, &db->stMorse)) { goto fail; }
|
||||
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (prepare(db->handle, SQL_VERSION, &st)) {
|
||||
@@ -129,6 +133,7 @@ bool hammy_refdb_close(hammy_refdb_t** db) {
|
||||
// (sqlite3_close() returns SQLITE_BUSY and the handle leaks)
|
||||
if (d->stExact) { sqlite3_finalize(d->stExact); }
|
||||
if (d->stPrefix) { sqlite3_finalize(d->stPrefix); }
|
||||
if (d->stMorse) { sqlite3_finalize(d->stMorse); }
|
||||
|
||||
if (d->handle) { sqlite3_close(d->handle); }
|
||||
|
||||
@@ -260,3 +265,38 @@ bool hammy_refdb_dxcc(hammy_refdb_t* db, const char* callsign, hammy_dxcc_t* out
|
||||
return search_prefixes(db, call, out);
|
||||
}
|
||||
|
||||
bool hammy_refdb_get_morse(hammy_refdb_t* db, char c, const char** out) {
|
||||
if (!db || !out) { return false; }
|
||||
|
||||
// The table is ASCII (ITU-R M.1677-1 has no non-Latin extensions), and a
|
||||
// single byte out of a multi-byte UTF-8 sequence is not valid text to bind,
|
||||
// so anything with the high bit set is a miss without touching SQLite.
|
||||
if ((unsigned char)c & 0x80u) { return false; }
|
||||
|
||||
// Run the query with the character as a string. SQLite's UPPER() handles case-insensitivity.
|
||||
// SQLITE_STATIC is safe because the statement is reset before this returns,
|
||||
// so key never outlives the binding that points at it.
|
||||
char key[2] = { c, '\0' };
|
||||
|
||||
sqlite3_reset(db->stMorse);
|
||||
sqlite3_clear_bindings(db->stMorse);
|
||||
sqlite3_bind_text(db->stMorse, 1, key, 1, SQLITE_STATIC);
|
||||
|
||||
bool hit = false;
|
||||
|
||||
if (sqlite3_step(db->stMorse) == SQLITE_ROW) {
|
||||
const unsigned char* code = sqlite3_column_text(db->stMorse, 0);
|
||||
|
||||
if (code) {
|
||||
// Copied rather than handed back directly: the column pointer dies
|
||||
// at the reset below, and the caller has no way to know that.
|
||||
snprintf(db->morseCode, sizeof(db->morseCode), "%s", (const char*)code);
|
||||
*out = db->morseCode;
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
|
||||
sqlite3_reset(db->stMorse);
|
||||
|
||||
return hit;
|
||||
}
|
||||
|
||||
Vendored
+2
-1122
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user