test phonetics

This commit is contained in:
2026-09-01 15:02:30 +02:00
parent 923ddc5bd2
commit cab60bbd57
6 changed files with 1339 additions and 4 deletions
+14
View File
@@ -21,6 +21,9 @@
// Longest code in the morse table is '$' ('...-..-'), seven characters. // Longest code in the morse table is '$' ('...-..-'), seven characters.
#define HAMMY_MORSE_MAX 16 #define HAMMY_MORSE_MAX 16
// Longest Phonetic code. Fuck it, 32 characters
#define HAMMY_PHONETIC_MAX 32
// The current longest string in the qcodes table is 42; 128 is pretty generous. TODO: Change this if the qcodes table ever changes // The current longest string in the qcodes table is 42; 128 is pretty generous. TODO: Change this if the qcodes table ever changes
#define HAMMY_QCODE_MAX 128 #define HAMMY_QCODE_MAX 128
@@ -37,6 +40,7 @@ struct hammy_refdb_t {
sqlite3_stmt* stPrefix; sqlite3_stmt* stPrefix;
sqlite3_stmt* stMorse; sqlite3_stmt* stMorse;
sqlite3_stmt* stQCode; sqlite3_stmt* stQCode;
sqlite3_stmt* stPhonetic;
sqlite3_stmt* stFreqMain; sqlite3_stmt* stFreqMain;
sqlite3_stmt* stFreqIaru; sqlite3_stmt* stFreqIaru;
sqlite3_stmt* stFreqNearest; sqlite3_stmt* stFreqNearest;
@@ -46,6 +50,9 @@ struct hammy_refdb_t {
char morseCode[HAMMY_MORSE_MAX]; // Scratch for the last hammy_refdb_get_morse() hit char morseCode[HAMMY_MORSE_MAX]; // Scratch for the last hammy_refdb_get_morse() hit
char phoneticCode[HAMMY_PHONETIC_MAX];
char phoneticCodePronunciation[HAMMY_PHONETIC_MAX];
char qcodeQuestion[HAMMY_QCODE_MAX]; // Scratch for the last hammy_refdb_get_qcode() hit char qcodeQuestion[HAMMY_QCODE_MAX]; // Scratch for the last hammy_refdb_get_qcode() hit
char qcodeAnswer[HAMMY_QCODE_MAX]; // Scratch for the last hammy_refdb_get_qcode() hit char qcodeAnswer[HAMMY_QCODE_MAX]; // Scratch for the last hammy_refdb_get_qcode() hit
@@ -154,6 +161,13 @@ bool hammy_refdb_dxcc(hammy_refdb_t* db, const char* callsign, hammy_dxcc_t* out
// the NEXT call on the same handle - copy it if it has to outlive that. // 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); bool hammy_refdb_get_morse(hammy_refdb_t* db, char c, const char** out);
// Looks up a character in the Phonetic 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_phonetic(hammy_refdb_t* db, char c, const char** out, const char** outPronunciation);
// Looks up a QSO code in the qcodes table. Case-insensitive; non-ASCII bytes // Looks up a QSO code in the qcodes table. Case-insensitive; non-ASCII bytes
// never match. Returns false if not found, leaving *out untouched. // never match. Returns false if not found, leaving *out untouched.
// //
+1 -1
View File
@@ -73,7 +73,7 @@ void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, hammy_refdb
// Get the text argument from the job // Get the text argument from the job
const char* text = hammy_job_get_arg(job, "text"); const char* text = hammy_job_get_arg(job, "text");
if (!text) { if (!text) {
hammy_job_respond(job, client, "Error", "No text provided for Morse code conversion.", true); hammy_job_respond(job, client, "Text Missing!", "No Text provided for Morse code conversion.", true);
return; return;
} }
+142
View File
@@ -0,0 +1,142 @@
#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>
// 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_PHONETIC_ECHO_MAX 512
#define HAMMY_PHONETIC_CODE_MAX 3072
#define HAMMY_PHONETIC_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 phonetic_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 phonetic_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 phonetic_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_phonetic(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
// Get the text argument from the job
const char* text = hammy_job_get_arg(job, "callsign");
if (!text) {
hammy_job_respond(job, client, "Callsign Missing!", "No Callsign provided for Phonetic 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_PHONETIC_ECHO_MAX + sizeof(HAMMY_PHONETIC_TRUNCATED)];
size_t echoLen = morse_utf8_trim(text, HAMMY_PHONETIC_ECHO_MAX);
memcpy(echo, text, echoLen);
echo[echoLen] = '\0';
if (text[echoLen] != '\0') {
memcpy(echo + echoLen, HAMMY_PHONETIC_TRUNCATED, sizeof(HAMMY_PHONETIC_TRUNCATED));
}
char phonetic[HAMMY_PHONETIC_CODE_MAX + sizeof(HAMMY_PHONETIC_TRUNCATED)];
phonetic[0] = '\0';
// Room held back so the truncation note always fits.
const size_t cap = sizeof(phonetic) - (sizeof(HAMMY_PHONETIC_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;
const char* codePronunciation = NULL;
if (!hammy_refdb_get_phonetic(refdb, *p, &code, &codePronunciation)) {
code = "?"; // Handle unknown characters
}
if (!phonetic_append(phonetic, 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(phonetic + len, HAMMY_PHONETIC_TRUNCATED, sizeof(HAMMY_PHONETIC_TRUNCATED));
}
// Reply with the converted Phonetic code, alongside the text it came from.
char body[sizeof(echo) + sizeof(phonetic) + 32];
snprintf(body, sizeof(body), "Original text: `%s`\nPhonetic: `%s`", echo, phonetic);
hammy_job_respond(job, client, "Phonetic Code Conversion", body, false);
}
+13 -1
View File
@@ -10,6 +10,7 @@ void hammy_cmd_ping(const hammy_job_t* job, struct discord* client, hammy_refdb_
void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
void hammy_cmd_q(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); void hammy_cmd_q(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
void hammy_cmd_phonetic(const hammy_job_t* job, struct discord* client, 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. // 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[] = { static struct discord_application_command_option morse_opts[] = {
@@ -44,6 +45,16 @@ static struct discord_application_command_options q_opts_struct = {
.array = q_opts .array = q_opts
}; };
static struct discord_application_command_option phonetic_opts[] = {
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = "callsign",
.description = "Callsign to convert to Phonetics", .required = true },
};
static struct discord_application_command_options phonetic_opts_struct = {
.size = 1,
.array = phonetic_opts
};
// The command table. Pure data - no allocation, no lifetime, no destructor. // 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 // Copying an entry into the bot's vector is a plain struct copy, and the vector
// may be created with a NULL destructor hook. // may be created with a NULL destructor hook.
@@ -53,7 +64,8 @@ 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 }, { "morse", "Text to and from Morse code", &morse_opts_struct, &hammy_cmd_morse, true },
{ "freq", "Band, segment and who can transmit", &freq_opts_struct, &hammy_cmd_freq, true }, { "freq", "Band, segment and who can transmit", &freq_opts_struct, &hammy_cmd_freq, true },
{ "q", "Convert Q-Code to the corresponding question and answer", &q_opts_struct, &hammy_cmd_q, true } { "q", "Convert Q-Code to the corresponding question and answer", &q_opts_struct, &hammy_cmd_q, true },
{ "phonetic", "Convert Callsign to Phonetics", &phonetic_opts_struct, &hammy_cmd_phonetic, true }
// Tier 0 commands go here as they land. All pure computation, so instant: // Tier 0 commands go here as they land. All pure computation, so instant:
// { "grid", "Maidenhead locator conversions", &grid_opts, &hammy_cmd_grid, true }, // { "grid", "Maidenhead locator conversions", &grid_opts, &hammy_cmd_grid, true },
+47
View File
@@ -90,6 +90,10 @@ static const char SQL_COUNTRY_KNOWN[] =
static const char SQL_QCODE[] = static const char SQL_QCODE[] =
"SELECT question, answer FROM qcodes WHERE code = UPPER(?1) LIMIT 1"; "SELECT question, answer FROM qcodes WHERE code = UPPER(?1) LIMIT 1";
// Resolve character to phonetic
static const char SQL_PHONETIC[] =
"SELECT word, pronunciation FROM phonetics WHERE letter = UPPER(?1) LIMIT 1"
// Authorizer: the connection may read, and nothing else. // Authorizer: the connection may read, and nothing else.
// //
// SQLITE_OPEN_READONLY protects the MAIN database file. It does not stop // SQLITE_OPEN_READONLY protects the MAIN database file. It does not stop
@@ -143,6 +147,7 @@ static const hammy_stmt_def_t HAMMY_STATEMENTS[] = {
HAMMY_STMT(stPrefix, SQL_PREFIX), HAMMY_STMT(stPrefix, SQL_PREFIX),
HAMMY_STMT(stMorse, SQL_MORSE), HAMMY_STMT(stMorse, SQL_MORSE),
HAMMY_STMT(stQCode, SQL_QCODE), HAMMY_STMT(stQCode, SQL_QCODE),
HAMMY_STMT(stPhonetic, SQL_PHONETIC),
HAMMY_STMT(stFreqMain, SQL_FREQ_MAIN), HAMMY_STMT(stFreqMain, SQL_FREQ_MAIN),
HAMMY_STMT(stFreqIaru, SQL_FREQ_IARU), HAMMY_STMT(stFreqIaru, SQL_FREQ_IARU),
HAMMY_STMT(stFreqNearest, SQL_FREQ_NEAREST), HAMMY_STMT(stFreqNearest, SQL_FREQ_NEAREST),
@@ -451,6 +456,48 @@ bool hammy_refdb_get_morse(hammy_refdb_t* db, char c, const char** out) {
return hit; return hit;
} }
bool hammy_refdb_get_phonetic(hammy_refdb_t* db, char c, const char** out, const char** outPronunciation) {
if (!db || !out) { return false; }
sqlite3_stmt* const needed[] = { db->stPhonetic };
if (!stmts_ready("phonetic", needed, 1)) { 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->stPhonetic);
sqlite3_clear_bindings(db->stPhonetic);
sqlite3_bind_text(db->stPhonetic, 1, key, 1, SQLITE_STATIC);
bool hit = false;
if (sqlite3_step(db->stPhonetic) == SQLITE_ROW) {
const unsigned char* code = sqlite3_column_text(db->stPhonetic, 0);
const unsigned char* codePronunciation = sqlite3_column_text(db->stPhonetic, 1);
if (code && codePronunciation) {
// 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->phoneticCode, sizeof(db->phoneticCode), "%s", (const char*)code);
*out = db->phoneticCode;
snprintf(db->phoneticCodePronunciation, sizeof(db->phoneticCodePronunciation), "%s", (const char*)codePronunciation);
*outPronunciation = db->phoneticCodePronunciation;
hit = true;
}
}
sqlite3_reset(db->stPhonetic);
return hit;
}
bool hammy_refdb_get_qcode(hammy_refdb_t* db, const char* code, const char** outQuestion, const char** outAnswer) { bool hammy_refdb_get_qcode(hammy_refdb_t* db, const char* code, const char** outQuestion, const char** outAnswer) {
if (!db || !code || !outQuestion || !outAnswer) { return false; } if (!db || !code || !outQuestion || !outAnswer) { return false; }
+1122 -2
View File
File diff suppressed because it is too large Load Diff