Compare commits

...
25 Commits
Author SHA1 Message Date
dcrubro 2d43a65f1f update cmakelists 2026-09-05 17:51:04 +02:00
dcrubro af7ed86424 update cmakelists 2026-09-05 17:48:21 +02:00
dcrubro 3dcb73aaec suppress the weird cast-to-int warnings in geo.c when doing (int)floor() 2026-09-04 18:02:14 +02:00
dcrubro 623d121c05 Add /dxcc command for converting a callsign into a DXCC entity - optional grid argument calculates distance from 2026-09-03 16:10:13 +02:00
dcrubro c7d2052fac make percision conversion explicit in utils.c 2026-09-03 15:42:58 +02:00
dcrubro fcf510afa4 Fix warnings on clang (literally 95% implicit conversion to char* from const char*)# 2026-09-02 09:07:16 +02:00
dcrubro 3d6b08d9d5 Fix warning - regarding missing prototypes and sign conversions 2026-09-01 19:20:50 +02:00
dcrubro cca190e2f4 update cmakelists and gitignore - fanalyzer and ignore build dirs 2026-09-01 19:02:42 +02:00
dcrubro 7ca1f80c01 update readme 2026-09-01 18:29:07 +02:00
dcrubro d7b9582cc0 Add /abbr command - converts abbreviations to meanings and contexts from sqlite 2026-09-01 18:20:27 +02:00
dcrubro ee045cd1dd Update /phonetic to be generic with truncation above 12 characters 2026-09-01 17:45:06 +02:00
dcrubro ae5389a7d5 Add /phonetic command for converting Callsign (might expand to generic text) to phonetics + pronunciation 2026-09-01 15:10:48 +02:00
dcrubro cab60bbd57 test phonetics 2026-09-01 15:02:30 +02:00
dcrubro 923ddc5bd2 codeeeeecs 2026-09-01 14:32:22 +02:00
dcrubro 44d13ab071 Add /q command for converting Q-Code (case-insensitive) to Question/Answer combination 2026-08-31 22:04:07 +02:00
dcrubro c056b1b67a add /freq command - allows lookup of data regarding ham bands for region-specific details 2026-08-31 20:15:06 +02:00
dcrubro 7acb2aabd4 sql commands for querying the frequency bands and segments 2026-08-31 13:19:50 +02:00
dcrubro 2aa0ae3d9a morse command 2026-08-31 01:26:33 +02:00
dcrubro 7b5514bf67 Add SQLite handler (refdb) and hook into master/workers/job -> commands 2026-08-31 00:18:31 +02:00
dcrubro 7e4e738c19 SQLite building for data 2026-08-30 21:05:46 +02:00
dcrubro 35591b0701 Add registration (needs leak fix), remove something from ping 2026-08-30 17:11:36 +02:00
dcrubro 9b7ce082c2 add header 2026-08-30 16:16:34 +02:00
dcrubro 177528cf60 update readme 2026-08-29 20:47:24 +02:00
dcrubro faa73838c0 embeds in responses 2026-08-29 20:34:39 +02:00
dcrubro 57ff0d0378 Fix command registration and add command infra 2026-08-29 18:46:38 +02:00
42 changed files with 10552 additions and 111 deletions
+2
View File
@@ -1,6 +1,8 @@
.env
build/
build*/
.DS_Store
config.json
.vscode
.vscode/
sqlite/*.sqlite
+37 -4
View File
@@ -198,7 +198,7 @@ else()
-Wshadow
-Wconversion
-Wsign-conversion
-Wcast-qual
#-Wcast-qual
-Wcast-align
-Wstrict-prototypes
-Wmissing-prototypes
@@ -215,6 +215,7 @@ else()
-Wundef
-Winit-self
-Wmissing-include-dirs
-Wno-discarded-qualifiers # Temp
)
if(CMAKE_C_COMPILER_ID MATCHES "GNU")
@@ -245,7 +246,9 @@ else()
hammy_append_supported_c_flags(HAMMY_ANALYZER_FLAGS
-fanalyzer-verbosity=${ANALYZER_VERBOSITY}
-fanalyzer
)
--param=analyzer-checker=taint
--Wanalyzer-too-complex
)
endif()
elseif(CMAKE_C_COMPILER_ID MATCHES "Clang")
# Clang-only diagnostics. Clang has no in-compiler equivalent of
@@ -412,6 +415,8 @@ else()
OUTPUT "${CONCORD_INSTALL_SCRIPT}"
@ONLY
CONTENT [[
cmake_policy(SET CMP0057 NEW)
file(MAKE_DIRECTORY "@CONCORD_INCLUDE_DIR@/concord" "@CONCORD_PREFIX@/lib")
# Upstream flattens all three header directories into one include/concord. Check
@@ -487,7 +492,20 @@ file(COPY ${_libs} DESTINATION "@CONCORD_PREFIX@/lib")
# the environment rather than the make command line because Concord's
# Makefile appends its own -I flags to it; a command-line assignment would
# override those and break the build.
set(CONCORD_CFLAGS "-O2 -g $<JOIN:${HAMMY_INSTRUMENT_COMPILE}, >")
# ... with one carve-out. chash.h's string hash is djb2-style:
# (hash) = (((hash) << 1) + (hash)) + key[i];
# on a signed accumulator, so it deliberately relies on wraparound. That is
# two counts of UB per character -- the shift of a negative value and the
# overflowing add -- plus a third in the __chash_abs() that follows. With
# -fno-sanitize-recover it aborts the first time a ratelimit key hashes past
# LONG_MAX, which a guild command endpoint manages immediately.
#
# The wraparound is benign and the code is not ours to fix, so drop these
# two checks for Concord's build only. Everything else UBSan looks at stays
# on, and our own translation units keep the full set -- these flags are not
# in HAMMY_SANITIZER_FLAGS, only here.
set(CONCORD_UB_CARVEOUTS "-fno-sanitize=signed-integer-overflow -fno-sanitize=shift")
set(CONCORD_CFLAGS "-O2 -g $<JOIN:${HAMMY_INSTRUMENT_COMPILE}, > $<${HAMMY_IS_SANITIZED}:${CONCORD_UB_CARVEOUTS}>")
# gencodecs/Makefile hardcodes CC/HOSTCC/CPP to "cc" for its host-side code
# generator, with plain '=' assignments that the environment cannot override.
@@ -538,6 +556,15 @@ file(COPY ${_libs} DESTINATION "@CONCORD_PREFIX@/lib")
set(HAMMY_CONCORD_EXTERNAL concord_external)
endif()
# ---------------------------------------------------------
# SQLite3 (hammy's own dependency, unlike the Concord ones above)
#
# CMake's bundled FindSQLite3 module (3.14+) exports the SQLite::SQLite3
# imported target and covers both a system package and a manually-specified
# SQLITE3_INCLUDE_DIR/SQLITE3_LIBRARY, so no vendoring is needed here.
# ---------------------------------------------------------
find_package(SQLite3 REQUIRED)
# ---------------------------------------------------------
# Output directories
# ---------------------------------------------------------
@@ -570,7 +597,12 @@ target_include_directories(hammy SYSTEM PRIVATE
# target's interface includes as -isystem, so upstream's headers never trip our
# warning set and no second copy under third_party/ is needed.
target_link_libraries(hammy PRIVATE concord::concord)
target_link_libraries(hammy PRIVATE concord::concord SQLite::SQLite3)
if (NOT MSVC)
target_link_libraries(hammy PRIVATE m)
endif()
if(HAMMY_CONCORD_EXTERNAL)
add_dependencies(hammy ${HAMMY_CONCORD_EXTERNAL})
endif()
@@ -616,6 +648,7 @@ message(STATUS "hammy: static analyzer [Analyzer] ${HAMMY_ANALYZER_FLAGS}")
message(STATUS "hammy: hardening ${HAMMY_ENABLE_HARDENING}")
message(STATUS "hammy: LTO ${HAMMY_ENABLE_LTO}")
message(STATUS "hammy: concord ${concord_SOURCE_DIR} (v3.0.1)")
message(STATUS "hammy: sqlite3 ${SQLite3_LIBRARIES} (v${SQLite3_VERSION})")
if(CMAKE_BUILD_TYPE STREQUAL "Analyzer" AND NOT HAMMY_ANALYZER_FLAGS)
message(STATUS "hammy: NOTE - Analyzer config has no static analyzer on "
"${CMAKE_C_COMPILER_ID}; use scan-build over this build tree")
+19 -1
View File
@@ -1,9 +1,27 @@
# Hammy
A Discord Bot for Ham/Amateur Radio
Fun fact: The logo of Hammy is a function! It is: f(x) = e^(-x^2)sin(9x)
## Features
Current features:
- /freq &lt;frequency (MHz)&gt; [country (default: US)] - Show the band, segment and who can transmit (for that country).
- /abbr &lt;abbreviation&gt; - Convert Abbreviation to Meaning and Context of Meaning.
- /q &lt;q-code&gt; - Convert Q-Code to Question/Answer.
- /phonetic &lt;text&gt; - Convert Text to Phonetics (useful for callsigns); If text is under 12 characters, also shows pronunciaction.
- /morse &lt;text&gt; - Convert Text to Morse Code.
- /dxcc &lt;callsign&gt; [grid (maidenhead locator)] - Show the DXCC Entity of a Callsign.
- /ping - Ping the Bot.
Currently in-development, many features planned!
## Architecture
Hammy is written in C, using the [Concord Library](https://github.com/Cogmasters/concord). It's multithreaded, with a thread pool system for queuing jobs.
User interactions can be of two types:
- Instant Interactions: Handled directly by the master thread, don't require any communication with the backend, performed on-bot.
- Non-Instant Interactions: Queued into the job pool, where a worker thread picks them up (handoff) for processing in the background and responds later.
## AI Disclosure
AI tools were used responsibly in the creation of this project, mainly for cmakelists off a pre-made template. It has also been used for minor things like functions, reviews, commits, etc.
Contributors are responsible for their commits, regardless of the usage of AI or not.
+28 -5
View File
@@ -7,11 +7,15 @@
#include <hammy/types.h>
#include <hammy/command.h>
#include <hammy/refdb.h>
struct hammy_bot_t {
struct discord* client; // Owning reference to the client - handoff from main.c
bool commands_registered; // A flag if commands have been registered. Avoid re-registering every reconnect.
vector_t* commands; // vector_t of commands. Owning.
hammy_refdb_t* refdb; // Owning reference to sqlite - the master and each worker has their own
bool commandsRegistered; // A flag if commands have been registered. Avoid re-registering every reconnect.
vector_t* commands; // vector_t of commands. Owning the vector, NOT the elements.
hammy_pool_t* pool; // Owning reference to the thread pool.
u64snowflake appId; // Learned from the ready event.
};
// Alloc on heap and create
@@ -20,13 +24,32 @@ hammy_bot_t* hammy_bot_create();
// Sets the on_ready() call function pointer
bool hammy_bot_set_on_ready(hammy_bot_t* bot, void (*func)(struct discord* client, const struct discord_ready* event));
// Copies every entry from hammy_command_builtins() into the vector.
bool hammy_bot_load_builtins(hammy_bot_t* bot);
// Starts the worker pool. Pass 0 for the defaults. Call this from the ready
// callback, NOT before hammy_bot_run(): the workers are built on
// discord_clone(), which only succeeds inside a gateway dispatch.
// Returns true if the pool is already running.
bool hammy_bot_start_pool(hammy_bot_t* bot, size_t nWorkers, size_t queueCap);
// Runs the bot. Return true on succeed, false on failure.
bool hammy_bot_run(hammy_bot_t* bot);
// Adds a command to the vector as a COPY, get rid of the old struct.
bool hammy_bot_add_command(hammy_bot_t* bot, hammy_command_t* command);
// Adds a command to the vector as a COPY. Commands own nothing, so the source
// struct needs no cleanup and may be a compound literal.
bool hammy_bot_add_command(hammy_bot_t* bot, const hammy_command_t* command);
// Registers all commands. Return true if succeeded or already registered; false if failure.
// Looks a command up by name. Returns a pointer into the vector's storage, or
// NULL. Valid until the vector is next modified, which after startup is never.
const hammy_command_t* hammy_bot_find_command(const hammy_bot_t* bot, const char* name);
// Wipes all commands from the Discord registry. Return true on success, false on failure.
bool hammy_bot_deregister_all_commands(hammy_bot_t* bot);
// Registers all commands with Discord. Return true if succeeded or already
// registered; false if failure. Requires bot->application_id to be set, so call
// this from on_ready, not before.
bool hammy_bot_register_commands(hammy_bot_t* bot);
// Destroy bot, NULLs the passing reference
+2 -1
View File
@@ -6,6 +6,7 @@
#include <stdbool.h>
#include <hammy/types.h>
#include <hammy/refdb.h>
// A command handler. Runs either on the gateway thread (instant commands) or on
// a worker thread (deferred ones), so it must not assume either. client is
@@ -16,7 +17,7 @@
// or discord_edit_original_interaction_response().
//
// The handler does NOT own the job and must not destroy it.
typedef void (*hammy_command_fn)(const hammy_job_t* job, struct discord* client);
typedef void (*hammy_command_fn)(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb);
// Plain old data. Every field points at static storage, so this struct owns
// nothing, copies freely, and needs no destructor - the vector can be created
+16
View File
@@ -0,0 +1,16 @@
// commands.h
#ifndef HAMMY_COMMANDS_H
#define HAMMY_COMMANDS_H
#include <concord/discord.h>
#include <hammy/types.h>
void hammy_cmd_ping(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Ping
void hammy_cmd_morse(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Text -> Morse
void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Frequency lookup
void hammy_cmd_q(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Q-Code lookup
void hammy_cmd_abbr(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Abbreviation lookup
void hammy_cmd_phonetic(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Callsign -> Phonetics
void hammy_cmd_dxcc(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb); // Callsign + Grid -> DXCC
#endif
+95
View File
@@ -0,0 +1,95 @@
#ifndef HAMMY_EMBEDS_H
#define HAMMY_EMBEDS_H
#include <concord/discord.h>
// Constructs a generic error embed and writes it into out. Returns true on success and false on failure.
// You must allocate space for exactly one embed in the out array. This function cannot check this so please make sure yourself.
static inline bool hammy_embeds_genericerror(struct discord *client, struct discord_embed *out) {
if (!out || !client) { return false; }
static struct discord_embed_footer footer = { .text = (char*)"Hammy Bot" };
out[0] = (struct discord_embed){
.title = (char*)"An Error Occurred",
.description = (char*)"An error occurred while processing your request.",
.color = 0xFF0000,
.timestamp = discord_timestamp(client),
.footer = &footer,
};
return true;
}
// Constructs a custom error embed and writes it into out. Returns true on success and false on failure.
// You must allocate space for exactly one embed in the out array. This function cannot check this so please make sure yourself.
static inline bool hammy_embeds_customerror(struct discord *client,
struct discord_embed *out,
struct discord_embed_fields *fieldsOut,
const char *title,
const char *description,
struct discord_embed_field *fields,
int fieldCount)
{
if (!out || !client || !title || !description) { return false; }
if (fieldCount < 0 || (fieldCount > 0 && !fields)) { return false; }
if (fieldCount > 0 && !fieldsOut) { return false; }
static struct discord_embed_footer footer = { .text = (char*)"Hammy Bot" };
if (fieldCount > 0) {
*fieldsOut = (struct discord_embed_fields){
.size = fieldCount,
.array = fields,
};
}
out[0] = (struct discord_embed){
.title = (char*)title,
.description = (char*)description,
.color = 0xFF0000,
.timestamp = discord_timestamp(client),
.footer = &footer,
.fields = (fieldCount > 0) ? fieldsOut : NULL,
};
return true;
}
// Constructs a custom generic embed and writes it into out. Returns true on success and false on failure.
// You must allocate space for exactly one embed in the out array. This function cannot check this so please make sure yourself.
static inline bool hammy_embeds_customembed(struct discord *client,
struct discord_embed *out,
struct discord_embed_fields *fieldsOut,
const char *title,
const char *description,
struct discord_embed_field *fields,
int fieldCount,
int color)
{
if (!out || !client || !title || !description) { return false; }
if (fieldCount < 0 || (fieldCount > 0 && !fields)) { return false; }
if (fieldCount > 0 && !fieldsOut) { return false; }
static struct discord_embed_footer footer = { .text = (char*)"Hammy Bot" };
if (fieldCount > 0) {
*fieldsOut = (struct discord_embed_fields){
.size = fieldCount,
.array = fields,
};
}
out[0] = (struct discord_embed){
.title = (char*)title,
.description = (char*)description,
.color = color,
.timestamp = discord_timestamp(client),
.footer = &footer,
.fields = (fieldCount > 0) ? fieldsOut : NULL,
};
return true;
}
#endif
+43
View File
@@ -0,0 +1,43 @@
#ifndef HAMMY_GEO_H
#define HAMMY_GEO_H
#include <stdbool.h>
#include <stddef.h>
// Maidenhead locators and great-circle maths. No dependencies beyond libm, no
// state, no database - safe to call from any thread.
//
// Lives in its own module rather than utils.h because /grid, /beacon and /sat
// will all want it, and mixing coordinate maths in with string helpers makes
// both harder to find.
// 8 characters plus NUL. Longer locators exist in theory; nothing uses them.
#define HAMMY_GRID_MAX 9
// Writes a Maidenhead locator for the given coordinates. precision must be
// 2 (field), 4 (square), 6 (subsquare) or 8 (extended square); 6 is what people
// quote. Returns false on out-of-range coordinates, bad precision, or too small
// a buffer.
bool hammy_grid_from_latlon(double lat, double lon, int precision, char* out, size_t cap);
// Parses a 2, 4, 6 or 8 character locator. Case-insensitive, ignores embedded
// whitespace, rejects anything longer rather than truncating it.
//
// Returns the CENTER of the square, not its south-west corner: using the corner
// biases every distance by up to half a square, which at 4-character precision
// is about 60 km.
bool hammy_grid_to_latlon(const char* grid, double* outLat, double* outLon);
// Haversine distance in km and initial bearing in degrees true. Either output
// pointer may be NULL.
void hammy_great_circle(double lat1, double lon1, double lat2, double lon2,
double* outDistKm, double* outBearingDeg);
// The other way round the planet.
double hammy_long_path_km(double shortPathKm);
double hammy_reciprocal_bearing(double bearingDeg);
// 16-point compass abbreviation ("NNE"). Points at static storage.
const char* hammy_compass_point(double bearingDeg);
#endif
+16 -4
View File
@@ -7,6 +7,7 @@
#include <stdint.h>
#include <hammy/types.h>
#include <hammy/refdb.h>
// A single slash-command option. Flattened out of the interaction event.
// Both strings are owned by the job.
@@ -30,12 +31,14 @@ struct hammy_job_t {
hammy_arg_t* args; // Array of size nArgs. Owning, array and contents.
size_t nArgs;
int64_t queuedAt; // Staleness checks, in ms. From discord_timestamp().
hammy_bot_t* bot; // NOT owning, used to access the command table
};
// Deep-copies everything the job needs out of the interaction event, so the
// event may be freed by Concord the moment the handler returns.
// Returns NULL on allocation failure.
hammy_job_t* hammy_job_create(struct discord* client, const struct discord_interaction* event);
hammy_job_t* hammy_job_create(hammy_bot_t* bot, const struct discord_interaction* event);
// Frees the job and everything it owns. NULLs the passing reference.
// Safe to call with NULL or with a pointer to NULL.
@@ -50,10 +53,19 @@ int64_t hammy_job_age_ms(const hammy_job_t* job, struct discord* client);
// Runs the job to completion and sends the reply. Called from a worker thread,
// so client MUST be that worker's own clone, never the gateway client.
// Does not destroy the job.
void hammy_job_run(hammy_job_t* job, struct discord* client);
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.
void hammy_job_reply(const hammy_job_t* job, struct discord* client, const char* content);
//
// 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 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);
#endif
+5 -1
View File
@@ -43,6 +43,9 @@ struct hammy_pool_t {
// Creates the pool and starts n_workers threads, each with its own
// discord_clone() of client. Pass 0 for either size to take the defaults.
// Returns NULL on failure; no threads are left running in that case.
// MUST be called from inside a gateway dispatch callback (on_ready is the
// earliest one): discord_clone() copies the gateway's current payload and
// fails with CCORD_ERRNO when there isn't one.
hammy_pool_t* hammy_pool_create(struct discord* client, size_t nWorkers, size_t queueCap);
// Enqueues a job. See hammy_push_result_t for who owns the job afterwards.
@@ -56,7 +59,8 @@ hammy_push_result_t hammy_pool_push(hammy_pool_t* pool, hammy_job_t* job);
void hammy_pool_shutdown(hammy_pool_t* pool);
// As above but discards anything still queued (each dropped job gets an
// apology reply if it can be sent quickly).
// apology reply). Call from the gateway thread only - the apologies are
// serialised through the original client, which that thread owns.
void hammy_pool_shutdown_now(hammy_pool_t* pool);
// Frees the pool. Runs hammy_pool_shutdown() first if it has not happened yet.
+224
View File
@@ -0,0 +1,224 @@
#ifndef HAMMY_REFDB_H
#define HAMMY_REFDB_H
#include <stdbool.h>
#include <stddef.h>
#include <sqlite3.h>
#include <stdint.h>
#include <hammy/types.h>
// Read-only handle to the reference bundle. Owned by exactly one thread.
// Theading rules: Do NOT share a hammy_refdb_t between threads. Instead, a worker should
// open it's own in hammy_worker_start(), and the gateway should open one for instant
// commands. The bundle is read-only, so no writers for sync. Seperate sqlite3 connections
// never contend.
#define HAMMY_CALLSIGN_MAX 32
#define HAMMY_ENTITY_NAME_MAX 64
#define HAMMY_COUNTRY_MAX 4
// Longest code in the morse table is '$' ('...-..-'), seven characters.
#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
#define HAMMY_QCODE_MAX 128
// Abbreviations
#define HAMMY_ABBR_LONGEST_STR 96
#define HAMMY_ABBR_LONGEST_CTX 16
// A country has at most a handful of licence classes, each with at most a
// couple of segments covering one exact frequency. 24 is generous.
#define HAMMY_FREQ_PRIVS_MAX 24
#define HAMMY_FREQ_IARU_MAX 8
#define HAMMY_FREQ_COUNTRIES_MAX 64
struct hammy_refdb_t {
sqlite3* handle;
sqlite3_stmt* stExact;
sqlite3_stmt* stPrefix;
sqlite3_stmt* stMorse;
sqlite3_stmt* stQCode;
sqlite3_stmt* stPhonetic;
sqlite3_stmt* stAbbr;
sqlite3_stmt* stFreqMain;
sqlite3_stmt* stFreqIaru;
sqlite3_stmt* stFreqNearest;
sqlite3_stmt* stFreqSegEdge;
sqlite3_stmt* stCountryKnown;
sqlite3_stmt* stCountryList;
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 abbrStr[HAMMY_ABBR_LONGEST_STR];
char abbrCtx[HAMMY_ABBR_LONGEST_CTX];
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 version[64];
};
struct hammy_dxcc_t {
int entityId; // ADIF DXCC entity code
char name[HAMMY_ENTITY_NAME_MAX];
char continent[4];
int cqZone; // Override applied if present
int ituZone;
double latitude; // North-positive
double longitude; // East-positive
double utcOffset; // UTC + utcOffset = local
char matchedPrefix[HAMMY_CALLSIGN_MAX];
bool exact;
};
// ---------------------------------------------------------------------------
// Frequency lookup
// ---------------------------------------------------------------------------
struct hammy_freq_priv_t {
char code[8]; // 'E', 'G', 'T'
char name[48]; // 'Amateur Extra'
int rank;
bool permitted; // false = this class may NOT transmit here
char modes[64]; // 'CW,DATA' - empty when !permitted
int64_t segLowHz;
int64_t segHighHz;
int maxPowerW; // 0 = national default, no specific limit
char notes[160];
};
struct hammy_freq_iaru_t {
int region; // 1, 2 or 3
int64_t lowHz;
int64_t highHz;
char modes[32];
};
struct hammy_freq_t {
int64_t freqHz;
bool inBand;
char band[16]; // '20m'
int64_t bandLowHz;
int64_t bandHighHz;
// The frequency sits exactly on a band or segment boundary. Worth saying
// out loud: a signal of any width centerd there straddles both sides.
bool atBandEdge;
bool atSegmentEdge;
// False when the bundle carries no licence data for this country at all.
// The band and IARU results are still valid; only the privilege table is
// missing. Say so rather than showing an empty table.
bool countryKnown;
char country[HAMMY_COUNTRY_MAX];
hammy_freq_priv_t privs[HAMMY_FREQ_PRIVS_MAX];
size_t nPrivs;
hammy_freq_iaru_t iaru[HAMMY_FREQ_IARU_MAX];
size_t nIaru;
// Only meaningful when !in_band.
char nearestBand[16];
int64_t nearestLowHz;
int64_t nearestHighHz;
int64_t nearestDistanceHz;
};
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
// Opens the bundle read-only and prepares the hot statements.
// Returns NULL and logs on failure.
hammy_refdb_t* hammy_refdb_open(const char* path);
// Finalises statements and closes the connection. NULLs the passing reference.
bool hammy_refdb_close(hammy_refdb_t** db);
// Bundle version string from ref_meta, or NULL. Owned by the refdb.
const char* hammy_refdb_version(hammy_refdb_t* db);
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
// Resolves a callsign to a DXCC entity.
//
// Uses candidate-prefix equality seeks rather than "? GLOB prefix || '*'". The
// GLOB form puts the indexed column on the wrong side of the comparison, so
// SQLite scans all 7000 prefix rows; generating the candidates and seeking by
// equality is roughly 40x faster on the current bundle.
//
// 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);
// Looks up a character in the Phonetic table. Case-insensitive; non-ASCII bytes
// never match. Returns false if not found, leaving *out(s) 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
// never match. Returns false if not found, leaving *out(s) untouched.
//
// On a hit *out points at the storage owned by 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_qcode(hammy_refdb_t* db, const char* code, const char** outQuestion, const char** outAnswer);
// Looks up an abbreviation in the abbreviations table. Case-insensitive; non-ASCII bytes
// never match. Returns false if not found, leaving *out(s) untouched.
//
// On a hit *out points at the storage owned by 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_abbr(hammy_refdb_t* db, const char* code, const char** outStr, const char** outContext);
// What band is freq_hz in, and who may transmit there.
//
// country is an ISO 3166-1 alpha-2 code; NULL or empty means "US". Always
// returns true unless the arguments are bad: "not in a band" and "no data for
// that country" are results, not failures. Check out->in_band and
// out->country_known.
bool hammy_refdb_freq(hammy_refdb_t* db, int64_t freqHz, const char* country,
hammy_freq_t* out);
// Which countries have licence data, for the "no data for XX yet" message.
// Writes up to cap codes and returns how many were written.
size_t hammy_refdb_countries(hammy_refdb_t* db,
char out[][HAMMY_COUNTRY_MAX], size_t cap);
// ---------------------------------------------------------------------------
// Input parsing
// ---------------------------------------------------------------------------
// Parses a user-supplied frequency into integer Hz. Accepts "14.150",
// "14150 kHz", "146.52 MHz", "1.2 GHz", "14150000 Hz". A bare number with no
// unit is read as MHz, which is what people type.
//
// Deliberately avoids floating point: "14.150" is converted digit by digit to
// 14150000 exactly. Going via double gives 14150000.000000002, which compares
// wrong against an integer column at exactly a band edge - the one case where
// being right matters most.
//
// Returns false on unparseable input or a value outside 1 Hz .. 300 GHz.
bool hammy_freq_parse(const char* text, int64_t* outHz);
#endif
+5
View File
@@ -6,5 +6,10 @@ typedef struct hammy_job_t hammy_job_t;
typedef struct hammy_worker_t hammy_worker_t;
typedef struct hammy_command_t hammy_command_t;
typedef struct hammy_bot_t hammy_bot_t;
typedef struct hammy_refdb_t hammy_refdb_t;
typedef struct hammy_dxcc_t hammy_dxcc_t;
typedef struct hammy_freq_priv_t hammy_freq_priv_t;
typedef struct hammy_freq_iaru_t hammy_freq_iaru_t;
typedef struct hammy_freq_t hammy_freq_t;
#endif
+14
View File
@@ -0,0 +1,14 @@
#ifndef HAMMY_UTILS_H
#define HAMMY_UTILS_H
#include <ctype.h>
#include <stdio.h>
#include <stdint.h>
// Converts in-place to uppercase
void hammy_to_uppercase(char* str);
// Converts in-place to lowercase
void hammy_to_lowercase(char* str);
#endif
+5
View File
@@ -5,16 +5,21 @@
#include <hammy/types.h>
#include <pthread.h>
#include <hammy/refdb.h>
struct hammy_worker_t {
pthread_t thread;
struct discord* clientCopy; // Clone of the client for concord threading safety - owning
hammy_pool_t* pool; // Non-owning back-reference
hammy_refdb_t* refdb; // Owning sqlite ref
int id; // Log logging mainly
bool started; // For joining
};
// Clones client, spawns the thread. Returns false on clone or spawn failure,
// leaving the worker safe to pass to hammy_worker_join().
// Inherits hammy_pool_create()'s precondition: the clone only succeeds from
// inside a gateway dispatch callback.
bool hammy_worker_start(hammy_worker_t* worker, hammy_pool_t* pool, struct discord* client, int id);
// Joins the thread if it was started and cleans up the clone.
+196
View File
@@ -0,0 +1,196 @@
-- Hammy reference bundle schema
--
-- Read-only at runtime. Regenerated as a whole file, never migrated in place.
-- Open with SQLITE_OPEN_READONLY.
PRAGMA foreign_keys = ON;
-- ---------------------------------------------------------------------------
-- Bundle metadata
-- ---------------------------------------------------------------------------
CREATE TABLE ref_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Where each dataset came from and when. Shown by /about and used to decide
-- whether a newer bundle is worth downloading.
CREATE TABLE ref_sources (
dataset TEXT PRIMARY KEY,
source_name TEXT NOT NULL,
source_url TEXT,
license TEXT,
retrieved TEXT, -- ISO 8601 date
notes TEXT
);
-- ---------------------------------------------------------------------------
-- Bands and privileges
-- ---------------------------------------------------------------------------
-- Named bands, region-independent. edge_low/high are the widest extent across
-- all regions; per-region reality lives in band_segments.
CREATE TABLE bands (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- '20m', '70cm'
edge_low_hz INTEGER NOT NULL,
edge_high_hz INTEGER NOT NULL,
wavelength_m REAL,
sort_order INTEGER NOT NULL
);
CREATE TABLE license_classes (
id INTEGER PRIMARY KEY,
country TEXT NOT NULL, -- ISO 3166-1 alpha-2
code TEXT NOT NULL, -- 'E', 'G', 'T'
name TEXT NOT NULL,
rank INTEGER NOT NULL, -- higher = more privileges
notes TEXT,
UNIQUE (country, code)
);
-- One row per (country, class, contiguous frequency range, mode group).
-- A class with split phone/CW privileges on a band gets several rows.
CREATE TABLE band_segments (
id INTEGER PRIMARY KEY,
band_id INTEGER NOT NULL REFERENCES bands(id),
country TEXT NOT NULL,
iaru_region INTEGER, -- 1, 2, 3, or NULL if not region-scoped
class_id INTEGER REFERENCES license_classes(id),
low_hz INTEGER NOT NULL,
high_hz INTEGER NOT NULL,
modes TEXT NOT NULL, -- 'CW', 'CW,DATA', 'PHONE,IMAGE'
max_power_w INTEGER, -- NULL = national default
notes TEXT
);
CREATE INDEX idx_band_segments_freq ON band_segments (country, low_hz, high_hz);
CREATE INDEX idx_band_segments_class ON band_segments (class_id);
-- ---------------------------------------------------------------------------
-- DXCC
-- ---------------------------------------------------------------------------
-- id is the ADIF/ARRL DXCC entity code, supplied by cty2sql.py's DXCC_IDS map
-- (cty.dat itself carries no entity numbers). WAE and other non-DXCC entities
-- are excluded by default since they have no code.
--
-- latitude is north-positive and longitude is east-positive; utc_offset is the
-- usual "UTC + this = local". All three are flipped relative to cty.dat's own
-- positive-west conventions by the importer.
CREATE TABLE dxcc_entities (
id INTEGER PRIMARY KEY, -- ADIF DXCC entity code
name TEXT NOT NULL,
primary_prefix TEXT NOT NULL UNIQUE,
continent TEXT, -- 'EU', 'NA', ...
cq_zone INTEGER, -- record default
itu_zone INTEGER, -- record default
latitude REAL,
longitude REAL,
utc_offset REAL,
deleted INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_dxcc_entities_prefix ON dxcc_entities (primary_prefix);
-- Prefixes are matched longest-first:
-- SELECT ... WHERE ? GLOB prefix || '*' ORDER BY length(prefix) DESC LIMIT 1
-- cq_zone/itu_zone here OVERRIDE the entity default when non-NULL. About 75% of
-- rows carry one, so COALESCE them in every query:
-- COALESCE(p.cq_zone, e.cq_zone)
CREATE TABLE dxcc_prefixes (
prefix TEXT NOT NULL,
entity_id INTEGER NOT NULL REFERENCES dxcc_entities(id),
exact INTEGER NOT NULL DEFAULT 0, -- 1 = whole callsign must match
cq_zone INTEGER,
itu_zone INTEGER,
PRIMARY KEY (prefix, entity_id)
);
CREATE INDEX idx_dxcc_prefixes_len ON dxcc_prefixes (length(prefix) DESC);
-- ---------------------------------------------------------------------------
-- Operating reference
-- ---------------------------------------------------------------------------
CREATE TABLE qcodes (
code TEXT PRIMARY KEY,
question TEXT NOT NULL,
answer TEXT NOT NULL,
category TEXT -- 'general', 'aeronautical', 'maritime'
);
CREATE TABLE prosigns (
symbol TEXT PRIMARY KEY, -- 'AR', 'SK', 'BT'
morse TEXT NOT NULL,
meaning TEXT NOT NULL,
usage TEXT
);
CREATE TABLE phonetics (
letter TEXT PRIMARY KEY,
word TEXT NOT NULL,
pronunciation TEXT
);
CREATE TABLE morse (
character TEXT PRIMARY KEY,
code TEXT NOT NULL, -- '.-' with . and -
category TEXT NOT NULL -- 'letter', 'digit', 'punctuation'
);
CREATE TABLE abbreviations (
abbr TEXT PRIMARY KEY,
meaning TEXT NOT NULL,
context TEXT -- 'CW', 'general', 'contest'
);
-- RST readability/strength/tone scale
CREATE TABLE rst_scale (
component TEXT NOT NULL, -- 'R', 'S', 'T'
value INTEGER NOT NULL,
meaning TEXT NOT NULL,
PRIMARY KEY (component, value)
);
-- ---------------------------------------------------------------------------
-- Engineering reference
-- ---------------------------------------------------------------------------
-- Matched loss follows the usual two-term model:
-- loss_dB_per_100ft = k1 * sqrt(f_MHz) + k2 * f_MHz
CREATE TABLE coax_types (
name TEXT PRIMARY KEY,
impedance_ohm REAL NOT NULL,
velocity_factor REAL NOT NULL,
capacitance_pf_per_ft REAL,
outer_diameter_mm REAL,
loss_k1 REAL,
loss_k2 REAL,
max_power_hf_w INTEGER,
notes TEXT
);
-- ---------------------------------------------------------------------------
-- NCDXF/IARU beacon network
-- ---------------------------------------------------------------------------
-- 18 stations, 10 s per slot, 3 min for a full cycle on each of 5 frequencies.
-- Which station is on which band at time t is pure arithmetic from slot_index.
CREATE TABLE ncdxf_beacons (
slot_index INTEGER PRIMARY KEY, -- 0..17, order within the cycle
callsign TEXT NOT NULL,
location TEXT NOT NULL,
grid TEXT,
latitude REAL,
longitude REAL,
dxcc_id INTEGER REFERENCES dxcc_entities(id),
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE ncdxf_frequencies (
band_id INTEGER PRIMARY KEY REFERENCES bands(id),
freq_hz INTEGER NOT NULL,
slot_offset INTEGER NOT NULL -- slots to add for this band
);
+33
View File
@@ -0,0 +1,33 @@
INSERT INTO ref_meta (key, value) VALUES
('schema_version', '1'),
('bundle_version', '2026.08.30-dev'),
('generated_at', '2026-08-30'),
('project', 'Hammy'),
('project_url', 'https://hammybot.org');
INSERT INTO ref_sources (dataset, source_name, source_url, license, retrieved, notes) VALUES
('band_segments', 'FCC Part 97 Subpart D', 'https://www.ecfr.gov/current/title-47/part-97',
'US Government work, public domain', NULL,
'HAND-ENTERED STARTER DATA. Must be verified against the current Part 97 text before shipping. Wrong band edges can put an operator out of band.'),
('band_segments_iaru', 'IARU Region 1/2/3 band plans', 'https://www.iaru.org/', 'See IARU', NULL,
'Only allocation extents are seeded, not the full mode/bandwidth plans. Incomplete.'),
('dxcc_entities', 'ARRL DXCC list / cty.dat', 'https://www.country-files.com/',
'Free for amateur radio use, see country-files.com', NULL,
'DEMO SUBSET ONLY - about 25 entities to exercise the schema. Real bundle must be generated by parsing cty.dat.'),
('qcodes', 'ITU Q-code series', NULL, 'Public domain', NULL,
'Amateur-relevant subset of the QRA-QUZ series.'),
('phonetics', 'ITU/NATO phonetic alphabet', NULL, 'Public domain', NULL, NULL),
('morse', 'ITU-R M.1677-1', NULL, 'Public domain', NULL,
'International Morse. Does not include non-Latin extensions.'),
('coax_types', 'Manufacturer datasheets', NULL, 'Various', NULL,
'Impedance, velocity factor and capacitance are nominal published figures. loss_k1/loss_k2 are deliberately NULL - populate from the actual datasheet for each cable rather than a generic approximation, since loss is what the calculator returns.'),
('ncdxf_beacons', 'NCDXF/IARU International Beacon Project', 'https://www.ncdxf.org/beacon/',
'See NCDXF', NULL,
'Station order is stable; individual beacons go offline for extended periods. The active flag needs refreshing against the NCDXF status page at build time.');
+211
View File
@@ -0,0 +1,211 @@
-- ---------------------------------------------------------------------------
-- Bands
-- ---------------------------------------------------------------------------
INSERT INTO bands (id, name, edge_low_hz, edge_high_hz, wavelength_m, sort_order) VALUES
(1, '2200m', 135700, 137800, 2200, 10),
(2, '630m', 472000, 479000, 630, 20),
(3, '160m', 1800000, 2000000, 160, 30),
(4, '80m', 3500000, 4000000, 80, 40),
(5, '60m', 5330500, 5406400, 60, 50),
(6, '40m', 7000000, 7300000, 40, 60),
(7, '30m', 10100000, 10150000, 30, 70),
(8, '20m', 14000000, 14350000, 20, 80),
(9, '17m', 18068000, 18168000, 17, 90),
(10, '15m', 21000000, 21450000, 15, 100),
(11, '12m', 24890000, 24990000, 12, 110),
(12, '10m', 28000000, 29700000, 10, 120),
(13, '6m', 50000000, 54000000, 6, 130),
(14, '2m', 144000000, 148000000, 2, 140),
(15, '1.25m', 222000000, 225000000, 1.25, 150),
(16, '70cm', 420000000, 450000000, 0.70, 160),
(17, '33cm', 902000000, 928000000, 0.33, 170),
(18, '23cm', 1240000000, 1300000000, 0.23, 180);
-- ---------------------------------------------------------------------------
-- US license classes
-- ---------------------------------------------------------------------------
INSERT INTO license_classes (id, country, code, name, rank, notes) VALUES
(1, 'US', 'E', 'Amateur Extra', 50, NULL),
(2, 'US', 'A', 'Advanced', 40, 'Closed to new issue since 1 April 2000; existing licences remain valid.'),
(3, 'US', 'G', 'General', 30, NULL),
(4, 'US', 'T', 'Technician', 20, NULL),
(5, 'US', 'N', 'Novice', 10, 'Closed to new issue since 1 April 2000; existing licences remain valid.');
-- ---------------------------------------------------------------------------
-- US band segments (47 CFR Part 97)
--
-- VERIFY BEFORE SHIPPING. Hand-entered from the Part 97 privilege tables.
-- Wrong edges or wrong privileges can put an operator outside their licence.
--
-- Two shapes are used here, deliberately:
--
-- 1. Bands where every eligible class gets the SAME range are generated with
-- INSERT..SELECT over license_classes and a rank threshold. The rule is
-- stated once, in the WHERE clause, and the rows follow from it.
--
-- 2. Bands where classes get GENUINELY DIFFERENT ranges (most of HF: Extra
-- has 14.000-14.150 where General has 14.025-14.150) are written out
-- explicitly, one row per class. A rank threshold cannot express that.
--
-- Shape 1 exists because hand-enumerating six bands times five classes is
-- thirty near-identical rows, and the first version of this file missed several
-- of them - Advanced was absent from 2200m, 630m and 60m entirely, and VHF/UHF
-- was seeded for Technician only, so an Extra was told they had no 2m access.
-- audit.py's privilege-nesting check catches that class of mistake now, but not
-- making it is better.
-- ---------------------------------------------------------------------------
-- 2200m and 630m: all classes. Strict EIRP limits, and the operator must notify
-- UTC and await a response before transmitting.
-- VERIFY: whether Novice holds these is the part I am least sure of.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, v.modes, v.max_power_w, v.notes
FROM license_classes lc
JOIN (
SELECT 1 AS band_id, 135700 AS low_hz, 137800 AS high_hz,
'CW,DATA' AS modes, NULL AS max_power_w, '1 W EIRP maximum' AS notes
UNION SELECT 2, 472000, 479000, 'CW,DATA', NULL, '5 W EIRP maximum'
) v
WHERE lc.country = 'US';
-- 160m, 30m, 17m, 12m: General and above (rank >= 30), whole band, same for all.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, v.modes, v.max_power_w, v.notes
FROM license_classes lc
JOIN (
SELECT 3 AS band_id, 1800000 AS low_hz, 2000000 AS high_hz,
'CW,DATA,PHONE,IMAGE' AS modes, NULL AS max_power_w, NULL AS notes
UNION SELECT 7, 10100000, 10150000, 'CW,DATA', 200, 'No phone or image permitted'
UNION SELECT 9, 18068000, 18110000, 'CW,DATA', NULL, NULL
UNION SELECT 9, 18110000, 18168000, 'PHONE,IMAGE', NULL, NULL
UNION SELECT 11, 24890000, 24930000, 'CW,DATA', NULL, NULL
UNION SELECT 11, 24930000, 24990000, 'PHONE,IMAGE', NULL, NULL
) v
WHERE lc.country = 'US' AND lc.rank >= 30;
-- 60m: five discrete channels, General and above. Stored as channel-width
-- ranges; the USB dial frequency is the channel center minus 1.5 kHz.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
SELECT 5, 'US', lc.id, v.low_hz, v.high_hz, 'CW,DATA,PHONE', NULL, v.notes
FROM license_classes lc
JOIN (
SELECT 5330500 AS low_hz, 5333300 AS high_hz, 'Channel 1, 100 W ERP, USB' AS notes
UNION SELECT 5346500, 5349300, 'Channel 2, 100 W ERP, USB'
UNION SELECT 5357000, 5359800, 'Channel 3, 100 W ERP, USB'
UNION SELECT 5371500, 5374300, 'Channel 4, 100 W ERP, USB'
UNION SELECT 5403500, 5406300, 'Channel 5, 100 W ERP, USB'
) v
WHERE lc.country = 'US' AND lc.rank >= 30;
-- VHF/UHF and above: Technician and higher (rank >= 20) hold the full
-- allocation. There is no per-class variation above 50 MHz except for Novice.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, v.modes, v.max_power_w, v.notes
FROM license_classes lc
JOIN (
SELECT 13 AS band_id, 50000000 AS low_hz, 50100000 AS high_hz,
'CW' AS modes, NULL AS max_power_w, 'CW only below 50.1 MHz' AS notes
UNION SELECT 13, 50100000, 54000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
UNION SELECT 14, 144000000, 144100000, 'CW', NULL, 'CW only below 144.1 MHz'
UNION SELECT 14, 144100000, 148000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
UNION SELECT 15, 222000000, 225000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
UNION SELECT 16, 420000000, 450000000, 'CW,DATA,PHONE,IMAGE', NULL,
'Geographic restrictions apply near some radar sites'
UNION SELECT 17, 902000000, 928000000, 'CW,DATA,PHONE,IMAGE', NULL,
'Secondary allocation, shared with Part 15 devices'
UNION SELECT 18, 1240000000, 1300000000, 'CW,DATA,PHONE,IMAGE', NULL, NULL
) v
WHERE lc.country = 'US' AND lc.rank >= 20;
-- Novice above 50 MHz: two reduced-power slices and nothing else.
-- VERIFY: Novice licences have not been issued since 2000, so an error here
-- would go unnoticed for years.
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes)
SELECT v.band_id, 'US', lc.id, v.low_hz, v.high_hz, 'CW,DATA,PHONE,IMAGE', v.max_power_w,
'Novice segment, reduced power'
FROM license_classes lc
JOIN (
SELECT 15 AS band_id, 222000000 AS low_hz, 225000000 AS high_hz, 25 AS max_power_w
UNION SELECT 18, 1270000000, 1295000000, 5
) v
WHERE lc.country = 'US' AND lc.code = 'N';
-- ---------------------------------------------------------------------------
-- Bands where classes get genuinely different ranges. Written out explicitly:
-- a rank threshold cannot express "Extra from 3.500, General from 3.525".
-- ---------------------------------------------------------------------------
-- 80m / 75m
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(4, 'US', 1, 3500000, 3600000, 'CW,DATA', NULL, NULL),
(4, 'US', 1, 3600000, 4000000, 'PHONE,IMAGE', NULL, NULL),
(4, 'US', 2, 3525000, 3600000, 'CW,DATA', NULL, NULL),
(4, 'US', 2, 3700000, 4000000, 'PHONE,IMAGE', NULL, NULL),
(4, 'US', 3, 3525000, 3600000, 'CW,DATA', NULL, NULL),
(4, 'US', 3, 3800000, 4000000, 'PHONE,IMAGE', NULL, NULL),
(4, 'US', 4, 3525000, 3600000, 'CW', 200, 'CW only'),
(4, 'US', 5, 3525000, 3600000, 'CW', 200, 'CW only');
-- 40m
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(6, 'US', 1, 7000000, 7125000, 'CW,DATA', NULL, NULL),
(6, 'US', 1, 7125000, 7300000, 'PHONE,IMAGE', NULL, NULL),
(6, 'US', 2, 7025000, 7125000, 'CW,DATA', NULL, NULL),
(6, 'US', 2, 7125000, 7300000, 'PHONE,IMAGE', NULL, NULL),
(6, 'US', 3, 7025000, 7125000, 'CW,DATA', NULL, NULL),
(6, 'US', 3, 7175000, 7300000, 'PHONE,IMAGE', NULL, NULL),
(6, 'US', 4, 7025000, 7125000, 'CW', 200, 'CW only'),
(6, 'US', 5, 7025000, 7125000, 'CW', 200, 'CW only');
-- 20m
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(8, 'US', 1, 14000000, 14150000, 'CW,DATA', NULL, NULL),
(8, 'US', 1, 14150000, 14350000, 'PHONE,IMAGE', NULL, NULL),
(8, 'US', 2, 14025000, 14150000, 'CW,DATA', NULL, NULL),
(8, 'US', 2, 14175000, 14350000, 'PHONE,IMAGE', NULL, NULL),
(8, 'US', 3, 14025000, 14150000, 'CW,DATA', NULL, NULL),
(8, 'US', 3, 14225000, 14350000, 'PHONE,IMAGE', NULL, NULL);
-- 15m
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(10, 'US', 1, 21000000, 21200000, 'CW,DATA', NULL, NULL),
(10, 'US', 1, 21200000, 21450000, 'PHONE,IMAGE', NULL, NULL),
(10, 'US', 2, 21025000, 21200000, 'CW,DATA', NULL, NULL),
(10, 'US', 2, 21225000, 21450000, 'PHONE,IMAGE', NULL, NULL),
(10, 'US', 3, 21025000, 21200000, 'CW,DATA', NULL, NULL),
(10, 'US', 3, 21275000, 21450000, 'PHONE,IMAGE', NULL, NULL),
(10, 'US', 4, 21025000, 21200000, 'CW', 200, 'CW only'),
(10, 'US', 5, 21025000, 21200000, 'CW', 200, 'CW only');
-- 10m: the only HF band with Technician phone privileges
INSERT INTO band_segments (band_id, country, class_id, low_hz, high_hz, modes, max_power_w, notes) VALUES
(12, 'US', 1, 28000000, 28300000, 'CW,DATA', NULL, NULL),
(12, 'US', 1, 28300000, 29700000, 'PHONE,IMAGE', NULL, NULL),
(12, 'US', 2, 28000000, 28300000, 'CW,DATA', NULL, NULL),
(12, 'US', 2, 28300000, 29700000, 'PHONE,IMAGE', NULL, NULL),
(12, 'US', 3, 28000000, 28300000, 'CW,DATA', NULL, NULL),
(12, 'US', 3, 28300000, 29700000, 'PHONE,IMAGE', NULL, NULL),
(12, 'US', 4, 28000000, 28300000, 'CW,DATA', 200, NULL),
(12, 'US', 4, 28300000, 28500000, 'PHONE', 200, 'Technician phone privileges'),
(12, 'US', 5, 28100000, 28300000, 'CW,DATA', 200, NULL),
(12, 'US', 5, 28300000, 28500000, 'PHONE', 200, NULL);
-- ---------------------------------------------------------------------------
-- IARU allocation extents (class_id NULL = the allocation, not privileges)
-- Partial. Enough to answer "is 7.250 legal here" for Region 1 vs 2.
-- ---------------------------------------------------------------------------
INSERT INTO band_segments (band_id, country, iaru_region, class_id, low_hz, high_hz, modes, notes) VALUES
(3, '', 1, NULL, 1810000, 2000000, 'ALL', 'Varies by country within R1'),
(4, '', 1, NULL, 3500000, 3800000, 'ALL', NULL),
(6, '', 1, NULL, 7000000, 7200000, 'ALL', NULL),
(8, '', 1, NULL, 14000000, 14350000, 'ALL', NULL),
(10, '', 1, NULL, 21000000, 21450000, 'ALL', NULL),
(12, '', 1, NULL, 28000000, 29700000, 'ALL', NULL),
(14, '', 1, NULL, 144000000, 146000000, 'ALL', NULL),
(16, '', 1, NULL, 430000000, 440000000, 'ALL', 'Varies by country within R1'),
(4, '', 3, NULL, 3500000, 3900000, 'ALL', 'Varies by country within R3'),
(6, '', 3, NULL, 7000000, 7200000, 'ALL', NULL),
(14, '', 3, NULL, 144000000, 148000000, 'ALL', NULL);
+171
View File
@@ -0,0 +1,171 @@
-- ---------------------------------------------------------------------------
-- Morse (ITU-R M.1677-1)
-- ---------------------------------------------------------------------------
INSERT INTO morse (character, code, category) VALUES
('A', '.-', 'letter'), ('B', '-...', 'letter'), ('C', '-.-.', 'letter'),
('D', '-..', 'letter'), ('E', '.', 'letter'), ('F', '..-.', 'letter'),
('G', '--.', 'letter'), ('H', '....', 'letter'), ('I', '..', 'letter'),
('J', '.---', 'letter'), ('K', '-.-', 'letter'), ('L', '.-..', 'letter'),
('M', '--', 'letter'), ('N', '-.', 'letter'), ('O', '---', 'letter'),
('P', '.--.', 'letter'), ('Q', '--.-', 'letter'), ('R', '.-.', 'letter'),
('S', '...', 'letter'), ('T', '-', 'letter'), ('U', '..-', 'letter'),
('V', '...-', 'letter'), ('W', '.--', 'letter'), ('X', '-..-', 'letter'),
('Y', '-.--', 'letter'), ('Z', '--..', 'letter'),
('0', '-----', 'digit'), ('1', '.----', 'digit'), ('2', '..---', 'digit'),
('3', '...--', 'digit'), ('4', '....-', 'digit'), ('5', '.....', 'digit'),
('6', '-....', 'digit'), ('7', '--...', 'digit'), ('8', '---..', 'digit'),
('9', '----.', 'digit'),
('.', '.-.-.-', 'punctuation'), (',', '--..--', 'punctuation'),
('?', '..--..', 'punctuation'), ('''', '.----.', 'punctuation'),
('!', '-.-.--', 'punctuation'), ('/', '-..-.', 'punctuation'),
('(', '-.--.', 'punctuation'), (')', '-.--.-', 'punctuation'),
('&', '.-...', 'punctuation'), (':', '---...', 'punctuation'),
(';', '-.-.-.', 'punctuation'), ('=', '-...-', 'punctuation'),
('+', '.-.-.', 'punctuation'), ('-', '-....-', 'punctuation'),
('_', '..--.-', 'punctuation'), ('"', '.-..-.', 'punctuation'),
('$', '...-..-','punctuation'), ('@', '.--.-.', 'punctuation');
-- ---------------------------------------------------------------------------
-- ITU/NATO phonetics
-- ---------------------------------------------------------------------------
INSERT INTO phonetics (letter, word, pronunciation) VALUES
('A', 'Alfa', 'AL-FAH'), ('B', 'Bravo', 'BRAH-VOH'),
('C', 'Charlie', 'CHAR-LEE'), ('D', 'Delta', 'DELL-TAH'),
('E', 'Echo', 'ECK-OH'), ('F', 'Foxtrot', 'FOKS-TROT'),
('G', 'Golf', 'GOLF'), ('H', 'Hotel', 'HOH-TELL'),
('I', 'India', 'IN-DEE-AH'), ('J', 'Juliett', 'JEW-LEE-ETT'),
('K', 'Kilo', 'KEY-LOH'), ('L', 'Lima', 'LEE-MAH'),
('M', 'Mike', 'MIKE'), ('N', 'November', 'NO-VEM-BER'),
('O', 'Oscar', 'OSS-CAH'), ('P', 'Papa', 'PAH-PAH'),
('Q', 'Quebec', 'KEH-BECK'), ('R', 'Romeo', 'ROW-ME-OH'),
('S', 'Sierra', 'SEE-AIR-RAH'), ('T', 'Tango', 'TANG-GO'),
('U', 'Uniform', 'YOU-NEE-FORM'),('V', 'Victor', 'VIK-TAH'),
('W', 'Whiskey', 'WISS-KEY'), ('X', 'X-ray', 'ECKS-RAY'),
('Y', 'Yankee', 'YANG-KEY'), ('Z', 'Zulu', 'ZOO-LOO'),
('0', 'Zero', 'ZEE-RO'), ('1', 'One', 'WUN'),
('2', 'Two', 'TOO'), ('3', 'Three', 'TREE'),
('4', 'Four', 'FOW-ER'), ('5', 'Five', 'FIFE'),
('6', 'Six', 'SIX'), ('7', 'Seven', 'SEV-EN'),
('8', 'Eight', 'AIT'), ('9', 'Nine', 'NIN-ER');
-- ---------------------------------------------------------------------------
-- Q-codes (amateur-relevant subset)
-- ---------------------------------------------------------------------------
INSERT INTO qcodes (code, question, answer, category) VALUES
('QRA', 'What is the name of your station?', 'The name of my station is ...', 'general'),
('QRG', 'Will you tell me my exact frequency?', 'Your exact frequency is ... kHz', 'general'),
('QRK', 'What is the readability of my signals?', 'The readability of your signals is ... (1 to 5)', 'general'),
('QRL', 'Are you busy?', 'I am busy, please do not interfere', 'general'),
('QRM', 'Is my transmission being interfered with?', 'Your transmission is being interfered with', 'general'),
('QRN', 'Are you troubled by static?', 'I am troubled by static', 'general'),
('QRO', 'Shall I increase transmit power?', 'Increase transmit power', 'general'),
('QRP', 'Shall I decrease transmit power?', 'Decrease transmit power', 'general'),
('QRQ', 'Shall I send faster?', 'Send faster (... words per minute)', 'general'),
('QRS', 'Shall I send more slowly?', 'Send more slowly (... words per minute)', 'general'),
('QRT', 'Shall I stop sending?', 'Stop sending', 'general'),
('QRU', 'Have you anything for me?', 'I have nothing for you', 'general'),
('QRV', 'Are you ready?', 'I am ready', 'general'),
('QRX', 'When will you call me again?', 'I will call you again at ...', 'general'),
('QRZ', 'Who is calling me?', 'You are being called by ...', 'general'),
('QSA', 'What is the strength of my signals?', 'The strength of your signals is ... (1 to 5)', 'general'),
('QSB', 'Are my signals fading?', 'Your signals are fading', 'general'),
('QSK', 'Can you hear me between your signals?', 'I can hear you between my signals', 'general'),
('QSL', 'Can you acknowledge receipt?', 'I acknowledge receipt', 'general'),
('QSO', 'Can you communicate with ... directly?', 'I can communicate with ... directly', 'general'),
('QSP', 'Will you relay to ...?', 'I will relay to ...', 'general'),
('QSY', 'Shall I change frequency?', 'Change frequency to ...', 'general'),
('QTC', 'How many messages have you to send?', 'I have ... messages for you', 'general'),
('QTH', 'What is your position?', 'My position is ...', 'general'),
('QTR', 'What is the correct time?', 'The correct time is ...', 'general');
-- ---------------------------------------------------------------------------
-- Prosigns
-- ---------------------------------------------------------------------------
INSERT INTO prosigns (symbol, morse, meaning, usage) VALUES
('AR', '.-.-.', 'End of message', 'Sent at the end of a transmission to a specific station'),
('AS', '.-...', 'Wait / stand by', 'Asking the other station to hold'),
('BK', '-...-.-', 'Break', 'Interrupting to hand over quickly'),
('BT', '-...-', 'Separator', 'Between paragraphs or sections; sent as a long dash'),
('CL', '-.-..-..', 'Closing station', 'Going off the air entirely'),
('CT', '-.-.-', 'Attention / start', 'Marks the beginning of a transmission'),
('HH', '........', 'Error', 'Eight dits, meaning the last word was wrong'),
('KN', '-.--.', 'Go ahead, named station only', 'Invites only the station being worked to reply'),
('SK', '...-.-', 'End of contact', 'Final transmission of a QSO'),
('SN', '...-.', 'Understood', 'Also written VE'),
('SOS', '...---...', 'Distress', 'International distress signal; sent as one symbol');
-- ---------------------------------------------------------------------------
-- CW abbreviations
-- ---------------------------------------------------------------------------
INSERT INTO abbreviations (abbr, meaning, context) VALUES
('73', 'Best regards', 'general'),
('88', 'Love and kisses', 'general'),
('ABT', 'About', 'CW'),
('AGN', 'Again', 'CW'),
('ANT', 'Antenna', 'CW'),
('BURO', 'QSL bureau', 'general'),
('CFM', 'Confirm', 'CW'),
('CQ', 'Calling any station', 'general'),
('CUL', 'See you later', 'CW'),
('DE', 'From (this is)', 'CW'),
('DX', 'Distance / long-distance station', 'general'),
('ES', 'And', 'CW'),
('FB', 'Fine business (excellent)', 'CW'),
('GA', 'Good afternoon / go ahead', 'CW'),
('GE', 'Good evening', 'CW'),
('GM', 'Good morning', 'CW'),
('HI', 'Laughter', 'CW'),
('HW', 'How copy?', 'CW'),
('K', 'Invitation to transmit', 'CW'),
('OM', 'Old man (any operator)', 'CW'),
('PSE', 'Please', 'CW'),
('PWR', 'Power', 'CW'),
('RIG', 'Station equipment', 'general'),
('RPT', 'Repeat', 'CW'),
('RST', 'Readability, strength, tone', 'general'),
('RX', 'Receiver', 'general'),
('SIG', 'Signal', 'CW'),
('SK', 'Silent key (deceased operator)', 'general'),
('SRI', 'Sorry', 'CW'),
('TNX', 'Thanks', 'CW'),
('TU', 'Thank you', 'CW'),
('TX', 'Transmitter', 'general'),
('UR', 'Your / you are', 'CW'),
('VY', 'Very', 'CW'),
('WX', 'Weather', 'CW'),
('XYL', 'Wife', 'CW'),
('YL', 'Young lady (female operator)','CW');
-- ---------------------------------------------------------------------------
-- RST scale
-- ---------------------------------------------------------------------------
INSERT INTO rst_scale (component, value, meaning) VALUES
('R', 1, 'Unreadable'),
('R', 2, 'Barely readable, occasional words distinguishable'),
('R', 3, 'Readable with considerable difficulty'),
('R', 4, 'Readable with practically no difficulty'),
('R', 5, 'Perfectly readable'),
('S', 1, 'Faint, signals barely perceptible'),
('S', 2, 'Very weak'),
('S', 3, 'Weak'),
('S', 4, 'Fair'),
('S', 5, 'Fairly good'),
('S', 6, 'Good'),
('S', 7, 'Moderately strong'),
('S', 8, 'Strong'),
('S', 9, 'Extremely strong'),
('T', 1, 'Extremely rough hissing note'),
('T', 2, 'Very rough AC note, no trace of musicality'),
('T', 3, 'Rough, low-pitched AC note, slightly musical'),
('T', 4, 'Rather rough AC note, moderately musical'),
('T', 5, 'Musically modulated note'),
('T', 6, 'Modulated note, slight trace of whistle'),
('T', 7, 'Near DC note, smooth ripple'),
('T', 8, 'Good DC note, trace of ripple'),
('T', 9, 'Purest DC note');
+1962
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
-- ---------------------------------------------------------------------------
-- Coax
--
-- Impedance, velocity factor and capacitance are nominal published figures.
-- loss_k1/loss_k2 are intentionally NULL: matched loss varies enough between
-- manufacturers of nominally the same cable that a generic approximation would
-- give the calculator false precision. Populate from the datasheet of the cable
-- you actually mean.
-- ---------------------------------------------------------------------------
INSERT INTO coax_types (name, impedance_ohm, velocity_factor, capacitance_pf_per_ft, outer_diameter_mm, loss_k1, loss_k2, notes) VALUES
('RG-58C/U', 50.0, 0.66, 30.8, 4.95, NULL, NULL, 'Thin, lossy, common for short VHF jumpers'),
('RG-8X', 50.0, 0.82, 25.0, 6.10, NULL, NULL, 'Foam dielectric, a middle ground'),
('RG-213/U', 50.0, 0.66, 30.8, 10.30, NULL, NULL, 'Workhorse HF cable'),
('RG-214/U', 50.0, 0.66, 30.8, 10.80, NULL, NULL, 'Double-shielded RG-213 equivalent'),
('LMR-400', 50.0, 0.85, 23.9, 10.29, NULL, NULL, 'Low loss, common for VHF/UHF runs'),
('LMR-600', 50.0, 0.87, 23.4, 14.99, NULL, NULL, 'Lower loss, stiffer'),
('RG-6/U', 75.0, 0.83, 16.2, 6.90, NULL, NULL, '75 ohm, cheap, fine for receive'),
('RG-11/U', 75.0, 0.66, 20.6, 10.30, NULL, NULL, '75 ohm, lower loss than RG-6'),
('RG-174/U', 50.0, 0.66, 30.8, 2.55, NULL, NULL, 'Very thin, very lossy, patch use only'),
('Belden 9913', 50.0, 0.84, 24.6, 10.30, NULL, NULL, 'Air dielectric, needs care against water ingress'),
('Hardline LDF4-50A', 50.0, 0.88, 25.9, 15.90, NULL, NULL, '1/2 inch corrugated hardline');
-- ---------------------------------------------------------------------------
-- NCDXF/IARU beacons
--
-- 18 stations, one 10 s slot each, 180 s for a full cycle per band.
-- Station transmitting on a given band at UTC time t:
-- slot = (floor(t / 10) - slot_offset) mod 18
-- ---------------------------------------------------------------------------
INSERT INTO ncdxf_beacons (slot_index, callsign, location, grid, latitude, longitude, dxcc_id) VALUES
(0, '4U1UN', 'United Nations, New York', 'FN30AS', 40.75, -73.97, NULL),
(1, 'VE8AT', 'Inuvik, NT, Canada', 'CP38GJ', 68.38, -133.72, 1),
(2, 'W6WX', 'Mt Umunhum, CA, USA', 'CM97BD', 37.16, -121.90, 291),
(3, 'KH6RS', 'Maui, Hawaii', 'BL10TS', 20.79, -156.46, NULL),
(4, 'ZL6B', 'Masterton, New Zealand', 'RE78TW', -40.92, 175.61, 170),
(5, 'VK6RBP', 'Rolystone, WA, Australia', 'OF87AV', -32.11, 116.05, 150),
(6, 'JA2IGY', 'Mt Asama, Japan', 'PM84JK', 34.45, 136.79, 339),
(7, 'RR9O', 'Novosibirsk, Russia', 'NO14KX', 54.98, 82.89, NULL),
(8, 'VR2B', 'Hong Kong', 'OL72BG', 22.28, 114.16, NULL),
(9, '4S7B', 'Colombo, Sri Lanka', 'MJ96WV', 6.91, 79.87, NULL),
(10, 'ZS6DN', 'Pretoria, South Africa', 'KG33XI', -25.90, 28.26, 462),
(11, '5Z4B', 'Kariobangi, Kenya', 'KI88HR', -1.24, 36.89, NULL),
(12, '4X6TU', 'Tel Aviv, Israel', 'KM72JB', 32.05, 34.78, NULL),
(13, 'OH2B', 'Lohja, Finland', 'KP20EH', 60.25, 24.40, 224),
(14, 'CS3B', 'Madeira', 'IM12JT', 32.72, -16.99, NULL),
(15, 'LU4AA', 'Buenos Aires, Argentina', 'GF05TJ', -34.62, -58.37, 100),
(16, 'OA4B', 'Lima, Peru', 'FH17MW', -12.07, -77.03, NULL),
(17, 'YV5B', 'Caracas, Venezuela', 'FJ69CC', 10.50, -66.92, NULL);
INSERT INTO ncdxf_frequencies (band_id, freq_hz, slot_offset) VALUES
(8, 14100000, 0),
(9, 18110000, 1),
(10, 21150000, 2),
(11, 24930000, 3),
(12, 28200000, 4);
+3661
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
-- Generated by audit.py --indexes. Runs last in the build.
-- The bundle is read-only, so indexes cost file size and nothing else.
-- candidate-prefix equality lookup; leading PK column, but stated explicitly because everything depends on it
CREATE INDEX IF NOT EXISTS idx_dxcc_prefixes_prefix ON "dxcc_prefixes" ("prefix");
-- joins back to dxcc_entities
CREATE INDEX IF NOT EXISTS idx_dxcc_prefixes_entity_id ON "dxcc_prefixes" ("entity_id");
ANALYZE;
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
adif2sql.py -- fetch the current ADIF release's exported data files and turn the
enumerations into SQL for the Hammy reference bundle.
Usage:
python3 adif2sql.py --list # show what the zip contains
python3 adif2sql.py -o 07-adif.sql # fetch, parse, emit SQL
python3 adif2sql.py --zip local.zip --list # work from an already-downloaded zip
python3 adif2sql.py --version 316 -o out.sql
How the site works
------------------
* https://adif.org.uk/adiflatestrelease.txt returns the release as three
digits, e.g. "317" meaning ADIF 3.1.7.
* Most filenames can be constructed from that: 317/adx317.xsd and so on.
* The resources zip CANNOT: its name embeds a release date that is not derivable
from the version number (ADIF_317_resources_2026_03_22.zip). So this script
scrapes /317/index.htm to find it rather than guessing.
* The hosting provider blocks unrecognised User-Agent strings. Python's default
("Python-urllib/3.x") is one of the blocked ones, so a UA is set explicitly
below. Do not remove it.
Be polite: the ADIF site asks that applications download and keep local copies
rather than fetching on every run. This script is a build-time tool, not a
runtime dependency - run it when you cut a new bundle, commit the output.
"""
import argparse
import csv
import io
import os
import re
import sys
import urllib.error
import urllib.request
from datetime import date, timezone
import zipfile
BASE = "https://adif.org.uk"
LATEST_URL = BASE + "/adiflatestrelease.txt"
# The hosting provider blocks Python's default UA. Any plausible string works;
# this one identifies the tool honestly, which is the polite option.
USER_AGENT = "Hammy-refbundle/1.0 (+https://hammybot.org)"
RETRIEVED = date.today().isoformat()
# The resources zip ships every enumeration in six formats:
# exports/{csv,json,ods,tsv,xlsx,xml}/enumerations_<name>.<ext>
# Only one text format is wanted. ods and xlsx are themselves zip archives and
# json/xml are not delimited, so feeding them to a CSV reader produces garbage.
FORMATS = ("tsv", "csv")
# Members are matched with an anchored pattern rather than a substring test.
# Substring matching collapsed enumerations_mode, enumerations_submode and
# enumerations_propagation_mode into one table.
def member_pattern(fmt):
return re.compile(
rf"(?:^|/)exports/{fmt}/enumerations_(?P<name>[A-Za-z0-9_]+)\.{fmt}$",
re.IGNORECASE)
# Optional prettier names. Anything not listed becomes adif_<enumeration_name>.
TABLE_ALIASES = {
"dxcc_entity_code": "adif_dxcc",
"primary_administrative_subdivision": "adif_subdivisions",
"secondary_administrative_subdivision": "adif_subdivisions_secondary",
"secondary_administrative_subdivision_alt": "adif_subdivisions_secondary_alt",
}
def table_for(enum_name):
key = enum_name.lower()
return TABLE_ALIASES.get(key, "adif_" + key)
def fetch(url, binary=False):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = resp.read()
except urllib.error.HTTPError as exc:
if exc.code == 403:
sys.exit(f"403 from {url} - the User-Agent is being blocked. "
f"Current UA: {USER_AGENT!r}")
raise
return data if binary else data.decode("utf-8", errors="replace")
def latest_version():
return fetch(LATEST_URL).strip()
def find_resources_zip(version):
"""Scrape the version index for the resources zip, whose name carries a date."""
index = fetch(f"{BASE}/{version}/index.htm")
m = re.search(rf"ADIF_{version}_resources[_\d]*\.zip", index, re.IGNORECASE)
if not m:
sys.exit(f"no resources zip linked from {BASE}/{version}/index.htm - "
f"the page layout may have changed, check it by hand")
return f"{BASE}/{version}/{m.group(0)}"
def load_zip(path_or_url):
if os.path.exists(path_or_url):
return zipfile.ZipFile(path_or_url)
return zipfile.ZipFile(io.BytesIO(fetch(path_or_url, binary=True)))
def sniff_rows(zf, name):
"""Read a member as delimited text, guessing the delimiter."""
raw = zf.read(name).decode("utf-8-sig", errors="replace")
# ADIF's exports ship with CRLF. io.StringIO translates newlines by default,
# which leaves the \r attached to the last field of every row and makes csv
# raise "new-line character seen in unquoted field". Normalise first, then
# open with newline="" so csv does its own line splitting.
raw = raw.replace("\r\n", "\n").replace("\r", "\n")
first_line = raw.split("\n", 1)[0]
# Prefer an explicit tab check over Sniffer. These files are tab-separated
# and their description columns contain commas, which Sniffer sometimes
# mistakes for the delimiter.
if "\t" in first_line:
dialect = csv.excel_tab
else:
try:
dialect = csv.Sniffer().sniff(raw[:4096], delimiters="\t,;")
except csv.Error:
dialect = csv.excel_tab
reader = csv.reader(io.StringIO(raw, newline=""), dialect)
try:
rows = [r for r in reader if any(cell.strip() for cell in r)]
except csv.Error as exc:
# Not a delimited file at all - a .adi test QSO file, say. Skip it
# rather than taking the whole run down.
sys.stderr.write(f"skipping {name}: {exc}\n")
return [], []
if not rows:
return [], []
return rows[0], rows[1:]
def ident(name):
"""Turn an arbitrary header cell into a safe SQL column name."""
col = re.sub(r"[^0-9a-zA-Z]+", "_", name.strip().lower()).strip("_")
return col or "col"
def q(value):
if value is None or value == "":
return "NULL"
return "'" + str(value).replace("'", "''") + "'"
# Pure ADIF bookkeeping: identical on every row of every file, and recorded once
# in ref_sources instead. Everything else is kept even when constant, because a
# constant can be meaningful - adif_award.import_only is 'Import-only' on all 29
# rows, which says something real about those awards.
METADATA_COLUMNS = {"enumeration_name", "adif_version", "adif_status"}
def prune_columns(cols, rows, keep_all=False):
"""Drop ADIF bookkeeping and columns that are empty in this file.
Returns (kept_column_names, kept_rows, dropped_report).
"""
if keep_all:
return cols, rows, []
keep, dropped = [], []
for i, c in enumerate(cols):
values = {(r[i].strip() if i < len(r) and r[i] is not None else "") for r in rows}
# Compare on the normalised name: headers arrive as "Enumeration Name",
# "ADIF Version" and so on, and are only identifier-ised later.
if ident(c) in METADATA_COLUMNS:
sample = next(iter(values)) if len(values) == 1 else None
dropped.append((c, f"metadata{'=' + sample if sample else ''}"))
continue
if values <= {""}:
dropped.append((c, "empty"))
continue
keep.append(i)
kept_cols = [cols[i] for i in keep]
kept_rows = [[(r[i] if i < len(r) else "") for i in keep] for r in rows]
return kept_cols, kept_rows, dropped
def emit_table(table, header, rows, out):
cols = []
seen = {}
for h in header:
c = ident(h)
if c in seen:
seen[c] += 1
c = f"{c}_{seen[c]}"
else:
seen[c] = 0
cols.append(c)
out.write(f"\nDROP TABLE IF EXISTS {table};\n")
out.write(f"CREATE TABLE {table} (\n")
out.write(",\n".join(f" {c} TEXT" for c in cols))
out.write("\n);\n\n")
out.write(f"INSERT INTO {table} ({', '.join(cols)}) VALUES\n")
lines = []
for r in rows:
# Pad or trim to the header width; ADIF exports occasionally have
# ragged trailing columns.
r = (list(r) + [""] * len(cols))[:len(cols)]
lines.append(" (" + ", ".join(q(cell.strip()) for cell in r) + ")")
out.write(",\n".join(lines) + ";\n")
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--version", help="3-digit ADIF version (default: whatever is current)")
ap.add_argument("--zip", dest="zip_path", help="use a local zip instead of downloading")
ap.add_argument("--list", action="store_true", help="list zip contents and exit")
ap.add_argument("-o", "--output", help="write SQL here (default: stdout)")
ap.add_argument("--format", choices=FORMATS, default="tsv",
help="which export format to read (default: tsv). The zip also "
"contains json, xml, ods and xlsx copies, which are not "
"delimited text and are ignored")
ap.add_argument("--keep-all-columns", action="store_true",
help="keep ADIF bookkeeping columns (enumeration_name, "
"adif_version, adif_status) and columns that are empty "
"in this release")
ap.add_argument("--only", nargs="+", metavar="ENUM",
help="import just these enumerations by name, e.g. "
"--only mode band dxcc_entity_code")
args = ap.parse_args()
if args.zip_path:
source = args.zip_path
version = args.version or "local"
else:
version = args.version or latest_version()
sys.stderr.write(f"ADIF version {version}\n")
source = find_resources_zip(version)
sys.stderr.write(f"resources: {source}\n")
zf = load_zip(source)
members = [n for n in zf.namelist() if not n.endswith("/")]
if args.list:
for n in sorted(members):
info = zf.getinfo(n)
print(f" {info.file_size:9d} {n}")
print(f"\n{len(members)} files")
return 0
pat = member_pattern(args.format)
only = {o.lower() for o in args.only} if args.only else None
picked = []
for n in members:
m = pat.search(n)
if not m:
continue
enum_name = m.group("name").lower()
if only and enum_name not in only:
continue
picked.append((n, enum_name, table_for(enum_name)))
picked.sort(key=lambda x: x[2])
if not picked:
sys.exit(f"no exports/{args.format}/enumerations_*.{args.format} members found. "
f"Run with --list to see the zip layout.")
# One member per table, or a later file silently clobbers an earlier one.
by_table = {}
for n, enum_name, table in picked:
if table in by_table:
sys.exit(f"{table} claimed by both {by_table[table]} and {n} - "
f"add an entry to TABLE_ALIASES to disambiguate")
by_table[table] = n
out = open(args.output, "w", encoding="utf-8") if args.output else sys.stdout
try:
out.write("-- Generated by adif2sql.py. Do not edit by hand.\n")
out.write(f"-- ADIF version {version}, {args.format} exports, from {source}\n")
out.write("-- Columns are all TEXT: these are enumerations, and ADIF's own\n")
out.write("-- exports carry version-dependent extra columns. Cast at query time.\n")
for name, enum_name, table in picked:
header, rows = sniff_rows(zf, name)
if not header:
sys.stderr.write(f"skipping empty {name}\n")
continue
header, rows, dropped = prune_columns(header, rows, args.keep_all_columns)
note = f" (-{len(dropped)} cols)" if dropped else ""
sys.stderr.write(f"{enum_name:44} -> {table:32} {len(rows):5d} rows{note}\n")
emit_table(table, header, rows, out)
# Provenance, so audit.py stops complaining about undocumented tables.
out.write(
"\nINSERT OR REPLACE INTO ref_sources "
"(dataset, source_name, source_url, license, retrieved, notes) VALUES\n"
f" ({q(table)}, {q('ADIF ' + version + ' ' + enum_name)}, "
f"{q(source)}, 'ADIF specification, openly published', "
f"{q(RETRIEVED)}, "
f"{q('Generated by adif2sql.py. Dropped columns: ' + (', '.join(c for c, _ in dropped) or 'none'))});\n")
finally:
if args.output:
out.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+485
View File
@@ -0,0 +1,485 @@
#!/usr/bin/env python3
"""
audit.py -- consistency checks for the Hammy reference bundle.
Usage:
python3 audit.py hammy-ref.sqlite # run every check
python3 audit.py hammy-ref.sqlite --quiet # only failures
python3 audit.py hammy-ref.sqlite --indexes # emit CREATE INDEX DDL
Exits non-zero if any check fails, so it drops straight into CI.
The checks fall into three groups:
structural SQLite's own integrity and declared foreign keys.
relational Joins that SHOULD hold but are not declared as foreign keys,
mostly because the ADIF tables are generated with TEXT columns.
Orphans here mean two datasets disagree.
domain Amateur-radio specific invariants. Band edges that cross, gaps in
the beacon cycle, prefixes that resolve to nothing. These are the
ones that catch a bad hand-entered row.
"""
import argparse
import sqlite3
import sys
FAILURES = []
WARNINGS = []
def fail(check, detail):
FAILURES.append((check, detail))
def warn(check, detail):
WARNINGS.append((check, detail))
def tables(db):
return [r[0] for r in db.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name")]
def columns(db, table):
return [r[1] for r in db.execute(f"PRAGMA table_info({table})")]
def indexed_columns(db, table):
"""Columns usable as the LEADING column of some index or the primary key.
In a composite key (prefix, entity_id) only 'prefix' is reachable; a query
filtering on entity_id alone still scans. PRAGMA table_info's pk field is
the 1-based position within the key, so pk == 1 is the leading column.
"""
covered = set()
for row in db.execute(f"PRAGMA table_info({table})"):
if row[5] == 1:
covered.add(row[1])
for idx in db.execute(f"PRAGMA index_list({table})"):
info = list(db.execute(f"PRAGMA index_info({idx[1]})"))
if info:
covered.add(info[0][2])
return covered
# ---------------------------------------------------------------------------
# Structural
# ---------------------------------------------------------------------------
def check_structural(db, verbose):
result = db.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
fail("integrity_check", result)
elif verbose:
print(" integrity_check ok")
violations = db.execute("PRAGMA foreign_key_check").fetchall()
if violations:
for v in violations[:10]:
fail("foreign_key_check", f"{v[0]} rowid {v[1]} -> {v[2]}")
elif verbose:
print(" foreign_key_check ok")
# ---------------------------------------------------------------------------
# Relational: undeclared joins that should still hold
# ---------------------------------------------------------------------------
# (child table, child column, parent table, parent column, description)
RELATIONS = [
("dxcc_prefixes", "entity_id", "dxcc_entities", "id",
"every prefix resolves to an entity"),
("band_segments", "band_id", "bands", "id",
"every segment belongs to a band"),
("band_segments", "class_id", "license_classes", "id",
"every segment's licence class exists"),
("ncdxf_beacons", "dxcc_id", "dxcc_entities", "id",
"beacon entities resolve"),
("ncdxf_frequencies", "band_id", "bands", "id",
"beacon frequencies map to a band"),
# ADIF tables are all TEXT, so these need a CAST to join against integers.
("adif_subdivisions", "dxcc_entity_code", "adif_dxcc", "entity_code",
"subdivisions point at a real ADIF entity"),
]
def check_relations(db, verbose):
present = set(tables(db))
for child, ccol, parent, pcol, desc in RELATIONS:
if child not in present or parent not in present:
continue
if ccol not in columns(db, child) or pcol not in columns(db, parent):
warn(f"{child}.{ccol}", f"column missing, skipped ({desc})")
continue
# TRIM both sides: the ADIF exports pad some cells.
q = (f"SELECT COUNT(*) FROM {child} c "
f"WHERE c.{ccol} IS NOT NULL AND TRIM(c.{ccol}) <> '' "
f"AND NOT EXISTS (SELECT 1 FROM {parent} p "
f" WHERE TRIM(p.{pcol}) = TRIM(c.{ccol}))")
orphans = db.execute(q).fetchone()[0]
if orphans:
sample = db.execute(
f"SELECT DISTINCT c.{ccol} FROM {child} c "
f"WHERE NOT EXISTS (SELECT 1 FROM {parent} p "
f"WHERE TRIM(p.{pcol}) = TRIM(c.{ccol})) "
f"AND TRIM(c.{ccol}) <> '' LIMIT 5").fetchall()
vals = ", ".join(repr(s[0]) for s in sample)
fail(f"{child}.{ccol} -> {parent}.{pcol}",
f"{orphans} orphan rows ({desc}); e.g. {vals}")
elif verbose:
print(f" {child}.{ccol} -> {parent}.{pcol}".ljust(66) + "ok")
# ---------------------------------------------------------------------------
# Domain invariants
# ---------------------------------------------------------------------------
def check_domain(db, verbose):
present = set(tables(db))
def q1(sql, *args):
return db.execute(sql, args).fetchone()[0]
if "band_segments" in present:
n = q1("SELECT COUNT(*) FROM band_segments WHERE low_hz >= high_hz")
if n:
fail("band_segments", f"{n} rows where low_hz >= high_hz")
elif verbose:
print(" band_segments edges ordered".ljust(66) + "ok")
# A segment outside its own band's extent is almost always a typo.
rows = db.execute("""
SELECT b.name, s.low_hz, s.high_hz, b.edge_low_hz, b.edge_high_hz
FROM band_segments s JOIN bands b ON b.id = s.band_id
WHERE s.low_hz < b.edge_low_hz OR s.high_hz > b.edge_high_hz""").fetchall()
if rows:
for r in rows[:5]:
fail("band_segments", f"{r[0]} segment {r[1]}-{r[2]} outside band {r[3]}-{r[4]}")
elif verbose:
print(" band_segments within band edges".ljust(66) + "ok")
# Two segments for the same country+region+class+mode should not overlap.
# iaru_region must be part of the key: the Region 1 and Region 3
# allocation rows share country='' and legitimately cover the same
# frequencies.
rows = db.execute("""
SELECT a.country, a.iaru_region, a.class_id, a.modes,
a.low_hz, a.high_hz, b.low_hz, b.high_hz
FROM band_segments a JOIN band_segments b
ON a.id < b.id AND a.country = b.country
AND IFNULL(a.iaru_region,-1) = IFNULL(b.iaru_region,-1)
AND IFNULL(a.class_id,-1) = IFNULL(b.class_id,-1)
AND a.modes = b.modes
AND a.low_hz < b.high_hz AND b.low_hz < a.high_hz""").fetchall()
if rows:
for r in rows[:5]:
warn("band_segments", f"{r[0] or 'IARU R' + str(r[1])} class {r[2]} {r[3]}: "
f"{r[4]}-{r[5]} overlaps {r[6]}-{r[7]}")
elif verbose:
print(" band_segments no overlaps".ljust(66) + "ok")
# Privilege nesting. In most licensing regimes a higher class holds a
# superset of a lower one, so a segment that a junior class has and a
# senior class does not is nearly always a missing row rather than a
# real rule. This is exactly the shape of bug that reads as plausible
# output - "Extra: not permitted on 2m" looks like data, not an error.
#
# A warning, not a failure: the superset assumption is true of the US
# and most countries, but it is an assumption, and a contributed band
# plan could legitimately break it.
rows = db.execute("""
SELECT DISTINCT hi.code, lo.code, b.name, s.low_hz, s.high_hz
FROM band_segments s
JOIN license_classes lo ON lo.id = s.class_id
JOIN license_classes hi ON hi.country = lo.country AND hi.rank > lo.rank
JOIN bands b ON b.id = s.band_id
WHERE s.country = lo.country
AND NOT EXISTS (
SELECT 1 FROM band_segments t
WHERE t.country = s.country
AND t.class_id = hi.id
AND t.low_hz <= s.low_hz
AND s.high_hz <= t.high_hz)
ORDER BY b.name, hi.rank DESC""").fetchall()
if rows:
for r in rows[:8]:
warn("band_segments",
f"{r[0]} lacks {r[2]} {r[3]/1e6:.3f}-{r[4]/1e6:.3f} MHz "
f"that {r[1]} holds - missing row?")
if len(rows) > 8:
warn("band_segments", f"...and {len(rows) - 8} more nesting gaps")
elif verbose:
print(" band_segments privileges nest by rank".ljust(66) + "ok")
if "ncdxf_beacons" in present:
slots = [r[0] for r in db.execute("SELECT slot_index FROM ncdxf_beacons ORDER BY slot_index")]
if slots != list(range(18)):
fail("ncdxf_beacons", f"expected slots 0..17, got {len(slots)}: {slots}")
elif verbose:
print(" ncdxf_beacons slots 0..17 complete".ljust(66) + "ok")
if "dxcc_prefixes" in present:
# Longest-prefix matching breaks if a prefix is empty or has whitespace.
n = q1("SELECT COUNT(*) FROM dxcc_prefixes WHERE prefix IS NULL OR TRIM(prefix) <> prefix OR prefix = ''")
if n:
fail("dxcc_prefixes", f"{n} prefixes empty or with surrounding whitespace")
elif verbose:
print(" dxcc_prefixes clean".ljust(66) + "ok")
# Zone overrides that merely restate the entity default are noise.
n = q1("""SELECT COUNT(*) FROM dxcc_prefixes p JOIN dxcc_entities e ON e.id = p.entity_id
WHERE p.cq_zone = e.cq_zone AND p.itu_zone = e.itu_zone""")
if n:
warn("dxcc_prefixes", f"{n} rows whose zone override equals the entity default")
if "morse" in present:
n = q1("SELECT COUNT(*) FROM (SELECT code FROM morse GROUP BY code HAVING COUNT(*) > 1)")
if n:
fail("morse", f"{n} duplicate codes - decoding would be ambiguous")
elif verbose:
print(" morse codes unique".ljust(66) + "ok")
if "ref_sources" in present:
# Every populated table should say where it came from.
documented = {r[0] for r in db.execute("SELECT dataset FROM ref_sources")}
undocumented = []
for t in tables(db):
if t.startswith(("ref_", "sqlite_")):
continue
if q1(f"SELECT COUNT(*) FROM {t}") == 0:
continue
if t not in documented and not any(d in t or t in d for d in documented):
undocumented.append(t)
if undocumented:
warn("ref_sources", f"no provenance row for: {', '.join(undocumented)}")
elif verbose:
print(" ref_sources covers every table".ljust(66) + "ok")
# ---------------------------------------------------------------------------
# Dead weight
# ---------------------------------------------------------------------------
def check_dead_columns(db, verbose, group=True):
"""Constant or empty columns.
A constant column is not automatically a bug: adif_award.import_only is
'Import-only' on every row because every award in that enumeration is, and
adif_subdivisions_secondary.dxcc_entity_code is '6' because Alaska is the
only entity with secondary subdivisions. Empty columns are always dead
weight. Both are reported, but repeated findings across many tables collapse
into one line so the signal is not buried.
"""
empties = []
constants = []
for t in tables(db):
total = db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
if total < 2:
continue
for c in columns(db, t):
distinct = db.execute(
f'SELECT COUNT(DISTINCT IFNULL("{c}", char(0))) FROM "{t}"').fetchone()[0]
if distinct != 1:
continue
val = db.execute(f'SELECT "{c}" FROM "{t}" LIMIT 1').fetchone()[0]
if val is None or str(val).strip() == "":
empties.append((t, c, total))
else:
constants.append((t, c, val, total))
if group:
# Collapse by column name: the same finding across 20 ADIF tables is one
# fact about the export format, not 20 problems.
by_col = {}
for t, c, total in empties:
by_col.setdefault(c, []).append(t)
for c, ts in sorted(by_col.items()):
if len(ts) > 2:
warn(f"*.{c}", f"entirely empty in {len(ts)} tables - drop it "
f"({', '.join(ts[:3])}, ...)")
else:
for t in ts:
warn(f"{t}.{c}", "entirely empty - drop it")
by_col = {}
for t, c, val, total in constants:
by_col.setdefault((c, str(val)), []).append(t)
for (c, val), ts in sorted(by_col.items()):
if len(ts) > 2:
warn(f"*.{c}", f"constant {val!r} in {len(ts)} tables - redundant "
f"({', '.join(ts[:3])}, ...)")
else:
for t in ts:
warn(f"{t}.{c}", f"constant {val!r} - check it is meaningful")
else:
for t, c, total in empties:
warn(f"{t}.{c}", f"entirely empty across {total} rows - drop it")
for t, c, val, total in constants:
warn(f"{t}.{c}", f"constant {val!r} across {total} rows - redundant")
def check_duplicates(db, verbose):
"""Exact duplicate rows, which in a reference table are always a mistake."""
for t in tables(db):
cols = columns(db, t)
if not cols:
continue
collist = ", ".join(f'"{c}"' for c in cols)
n = db.execute(
f"SELECT COUNT(*) FROM (SELECT {collist}, COUNT(*) AS n "
f'FROM "{t}" GROUP BY {collist} HAVING n > 1)').fetchone()[0]
if n:
warn(t, f"{n} groups of exactly duplicated rows")
elif verbose:
print(f" {t} no duplicate rows".ljust(66) + "ok")
# ---------------------------------------------------------------------------
# Index generation
# ---------------------------------------------------------------------------
# Columns worth indexing, by suffix or exact name.
#
# An index only helps a query that filters with equality or a range on its
# leading column. It does NOT help "? GLOB prefix || '*'", because the indexed
# column is on the wrong side of the comparison - SQLite still scans every row
# and evaluates the GLOB. Worse, an index can make such a query slower by
# tempting the planner into a nested loop.
#
# The fix for callsign lookup is on the query side, not here: generate the
# candidate prefixes from the callsign and do equality seeks, longest first.
# See docs in the repo. That turns a 7000-row scan into a handful of index
# seeks, measured at roughly 40x on the current bundle.
INDEX_HINTS = ("code", "_code", "_id", "name", "prefix", "callsign", "band",
"mode", "abbr", "symbol", "letter", "character", "grid",
"country", "continent", "cq_zone", "itu_zone", "dataset")
def emit_indexes(db, out):
out.write("-- Generated by audit.py --indexes. Runs last in the build.\n")
out.write("-- The bundle is read-only, so indexes cost file size and nothing else.\n\n")
# Indexes the heuristic cannot infer but the bot's hot paths need.
ESSENTIAL = [
("dxcc_prefixes", "prefix",
"candidate-prefix equality lookup; leading PK column, but stated "
"explicitly because everything depends on it"),
("dxcc_prefixes", "entity_id", "joins back to dxcc_entities"),
]
made = 0
present = set(tables(db))
for t, c, why in ESSENTIAL:
if t in present and c in columns(db, t):
out.write(f"-- {why}\n")
out.write(f'CREATE INDEX IF NOT EXISTS idx_{t}_{c} ON "{t}" ("{c}");\n')
made += 1
out.write("\n")
for t in tables(db):
if t.startswith("ref_"):
continue
total = db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
if total < 50: # a scan of 50 rows is free
continue
covered = indexed_columns(db, t)
for c in columns(db, t):
if c in covered or (t, c) in {(a, b) for a, b, _ in ESSENTIAL}:
continue
lc = c.lower()
if not (lc in INDEX_HINTS or any(lc.endswith(h) for h in INDEX_HINTS)):
continue
# A column with almost no distinct values is not worth an index.
distinct = db.execute(f'SELECT COUNT(DISTINCT "{c}") FROM "{t}"').fetchone()[0]
if distinct < 2 or distinct < total / 100:
continue
out.write(f'CREATE INDEX IF NOT EXISTS idx_{t}_{lc} ON "{t}" ("{c}");\n')
made += 1
out.write("\n")
out.write("ANALYZE;\n")
sys.stderr.write(f"{made} indexes\n")
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description="Audit the Hammy reference bundle")
ap.add_argument("db", help="path to the bundle")
ap.add_argument("--quiet", action="store_true", help="only report problems")
ap.add_argument("--ungrouped", action="store_true",
help="report every constant/empty column separately instead of "
"collapsing repeated findings")
ap.add_argument("--indexes", action="store_true",
help="emit CREATE INDEX DDL to stdout instead of auditing")
ap.add_argument("-o", "--output", help="write index DDL here")
args = ap.parse_args()
db = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
db.execute("PRAGMA foreign_keys = ON")
if args.indexes:
out = open(args.output, "w") if args.output else sys.stdout
try:
emit_indexes(db, out)
finally:
if args.output:
out.close()
return 0
verbose = not args.quiet
if verbose:
print("structural")
check_structural(db, verbose)
if verbose:
print("\nrelational")
check_relations(db, verbose)
if verbose:
print("\ndomain")
check_domain(db, verbose)
if verbose:
print("\ndead weight and duplicates")
check_dead_columns(db, verbose, group=not args.ungrouped)
check_duplicates(db, False)
print()
for check, detail in WARNINGS:
print(f"WARN {check}: {detail}")
for check, detail in FAILURES:
print(f"FAIL {check}: {detail}")
print(f"\n{len(FAILURES)} failures, {len(WARNINGS)} warnings")
return 1 if FAILURES else 0
if __name__ == "__main__":
sys.exit(main())
+421
View File
@@ -0,0 +1,421 @@
#!/usr/bin/env python3
"""
cty2sql.py -- convert AD1C cty.dat (ham radio DXCC country file) into SQL
INSERT statements for the dxcc_entities / dxcc_prefixes tables.
Usage:
python3 cty2sql.py cty.dat > dxcc.sql
python3 cty2sql.py cty.dat -o dxcc.sql --schema --transaction
Notes on cty.dat that this script deals with for you:
* cty.dat has NO DXCC entity numbers. The ADIF entity code (1 = Canada,
291 = United States, ...) is supplied by the DXCC_IDS table below, keyed
on the record's primary prefix. Override or extend it with --dxcc-map
(a JSON file of {"primary_prefix": entity_id}).
* cty.dat longitudes are POSITIVE WEST. This script flips them to the
conventional positive-east form by default (Ottawa -> -75.0). Use
--longitude as-is to keep the raw cty.dat sign.
* cty.dat UTC offsets are also positive-west (Canada 5.0, Japan -9.0) and
are emitted unchanged by default, matching the target schema. Use
--utc-offset standard to flip them into real UTC offsets (Japan +9.0).
* Entities whose primary prefix starts with "*" are WAE/CQ-only entities
(Sicily, Shetland Is., European Turkey, ...). They are not DXCC entities
and have no entity code, so they are skipped unless --include-wae is
given (which requires you to supply ids for them via --dxcc-map).
* Prefix modifiers are stripped: (cq) [itu] <lat/lon> {cont} ~offset~.
A prefix written "=CALL" is a full callsign match and is emitted with
exact = 1.
No third-party dependencies. Python 3.8+.
"""
import argparse
import json
import os
import re
import sys
from collections import OrderedDict
# --------------------------------------------------------------------------
# cty.dat primary prefix -> ADIF DXCC entity code (ADIF 3.1.6 enumeration).
# Deleted entities are not included. Regenerate/extend with --dxcc-map.
# --------------------------------------------------------------------------
DXCC_IDS = {
"VE": 1, "YA": 3, "3B6": 4, "OH0": 5, "KL": 6, "ZA": 7, "KH8": 9, "FT/z": 10, "VU4": 11,
"VP2E": 12, "CE9": 13, "EK": 14, "UA9": 15, "ZL9": 16, "YV0": 17, "4J": 18, "KH1": 20,
"EA6": 21, "T8": 22, "3Y/b": 24, "EU": 27, "EA8": 29, "T31": 31, "EA9": 32, "VQ9": 33,
"ZL7": 34, "VK9X": 35, "FO/c": 36, "TI9": 37, "VK9C": 38, "SV9": 40, "FT/w": 41, "KP5": 43,
"SV5": 45, "9M6": 46, "CE0Y": 47, "T32": 48, "3C": 49, "XE": 50, "E3": 51, "ES": 52,
"ET": 53, "UA": 54, "PY0F": 56, "C6": 60, "R1FJ": 61, "8P": 62, "FY": 63, "VP9": 64,
"VP2V": 65, "V3": 66, "ZF": 69, "CM": 70, "HC8": 71, "HI": 72, "YS": 74, "4L": 75,
"TG": 76, "J3": 77, "HH": 78, "FG": 79, "HR": 80, "6Y": 82, "FM": 84, "YN": 86, "HP": 88,
"VP5": 89, "9Y": 90, "P4": 91, "V2": 94, "J7": 95, "VP2M": 96, "J6": 97, "J8": 98,
"FT/g": 99, "LU": 100, "KH2": 103, "CP": 104, "KG4": 105, "GU": 106, "3X": 107, "PY": 108,
"J5": 109, "KH6": 110, "VK0H": 111, "CE": 112, "GD": 114, "HK": 116, "4U1I": 117,
"JX": 118, "HC": 120, "GJ": 122, "KH3": 123, "FT/j": 124, "CE0Z": 125, "UA2": 126,
"8R": 129, "UN": 130, "FT/x": 131, "ZP": 132, "ZL8": 133, "EX": 135, "OA": 136, "HL": 137,
"KH7K": 138, "PZ": 140, "VP8": 141, "VU7": 142, "XW": 143, "CX": 144, "YL": 145, "LY": 146,
"VK9L": 147, "YV": 148, "CU": 149, "VK": 150, "XX9": 152, "VK0M": 153, "C2": 157,
"YJ": 158, "8Q": 159, "A3": 160, "HK0/m": 161, "FK": 162, "P2": 163, "3B8": 165,
"KH0": 166, "OJ0": 167, "V7": 168, "FH": 169, "ZL": 170, "VK9M": 171, "VP6": 172,
"V6": 173, "KH4": 174, "FO": 175, "3D2": 176, "JD/m": 177, "ER": 179, "SV/a": 180,
"C9": 181, "KP1": 182, "H4": 185, "5U": 187, "E6": 188, "VK9N": 189, "5W": 190,
"E5/n": 191, "JD/o": 192, "3C0": 195, "KH5": 197, "3Y/p": 199, "ZS8": 201, "KP4": 202,
"C3": 203, "XF4": 204, "ZD8": 205, "OE": 206, "3B9": 207, "ON": 209, "CY0": 211, "LZ": 212,
"FS": 213, "TK": 214, "5B": 215, "HK0/a": 216, "CE0X": 217, "S9": 219, "OZ": 221,
"OY": 222, "G": 223, "OH": 224, "IS": 225, "F": 227, "DL": 230, "T5": 232, "ZB": 233,
"E5/s": 234, "VP8/g": 235, "SV": 236, "OX": 237, "VP8/o": 238, "HA": 239, "VP8/s": 240,
"VP8/h": 241, "TF": 242, "EI": 245, "1A": 246, "1S": 247, "I": 248, "V4": 249, "ZD7": 250,
"HB0": 251, "CY9": 252, "PY0S": 253, "LX": 254, "CT3": 256, "9H": 257, "JW": 259,
"3A": 260, "EY": 262, "PA": 263, "GI": 265, "LA": 266, "SP": 269, "ZK3": 270, "CT": 272,
"PY0T": 273, "ZD9": 274, "YO": 275, "FT/t": 276, "FP": 277, "T7": 278, "GM": 279,
"EZ": 280, "EA": 281, "T2": 282, "ZC4": 283, "SM": 284, "KP2": 285, "5X": 286, "HB": 287,
"UR": 288, "4U1U": 289, "K": 291, "UK": 292, "3W": 293, "GW": 294, "HV": 295, "YU": 296,
"KH9": 297, "FW": 298, "9M2": 299, "T30": 301, "S0": 302, "VK9W": 303, "A9": 304,
"S2": 305, "A5": 306, "TI": 308, "XZ": 309, "XU": 312, "4S": 315, "BY": 318, "VR": 321,
"VU": 324, "YB": 327, "EP": 330, "YI": 333, "4X": 336, "JA": 339, "JY": 342, "P5": 344,
"V8": 345, "9K": 348, "OD": 354, "JT": 363, "9N": 369, "A4": 370, "AP": 372, "DU": 375,
"A7": 376, "HZ": 378, "S7": 379, "9V": 381, "J2": 382, "YK": 384, "BV": 386, "HS": 387,
"TA": 390, "A6": 391, "7X": 400, "D2": 401, "A2": 402, "9U": 404, "TJ": 406, "TL": 408,
"D4": 409, "TT": 410, "D6": 411, "TN": 412, "9Q": 414, "TY": 416, "TR": 420, "C5": 422,
"9G": 424, "TU": 428, "5Z": 430, "7P": 432, "EL": 434, "5A": 436, "5R": 438, "7Q": 440,
"TZ": 442, "5T": 444, "CN": 446, "5N": 450, "Z2": 452, "FR": 453, "9X": 454, "6W": 456,
"9L": 458, "3D2/r": 460, "ZS": 462, "V5": 464, "ST": 466, "3DA": 468, "5H": 470, "3V": 474,
"SU": 478, "XT": 480, "9J": 482, "5V": 483, "3D2/c": 489, "T33": 490, "7O": 492, "9A": 497,
"S5": 499, "E7": 501, "Z3": 502, "OK": 503, "OM": 504, "BV9P": 505, "BS7": 506, "H40": 507,
"FO/a": 508, "FO/m": 509, "E4": 510, "4W": 511, "FK/c": 512, "VP6/d": 513, "4O": 514,
"KH8/s": 515, "FJ": 516, "PJ2": 517, "PJ7": 518, "PJ5": 519, "PJ4": 520, "Z8": 521,
"Z6": 522
}
# --------------------------------------------------------------------------
# Parsing
# --------------------------------------------------------------------------
# Modifiers that may be attached to a prefix inside a cty.dat prefix list.
# These are CAPTURED rather than discarded: about 75% of the prefix tokens in a
# current cty.dat carry a zone override, and they exist precisely because the
# record default is wrong for that prefix. VK4[55] is Queensland at ITU 55, not
# Australia's default 59; drop the modifier and every Queensland call gets the
# wrong zone.
MOD_CQ = re.compile(r"\(\s*(\d+)\s*\)")
MOD_ITU = re.compile(r"\[\s*(\d+)\s*\]")
MOD_LATLON = re.compile(r"<\s*([-\d.]+)\s*/\s*([-\d.]+)\s*>")
MOD_CONT = re.compile(r"\{([^}]*)\}")
MOD_OFFSET = re.compile(r"~([^~]*)~")
MODIFIER_RE = re.compile(r"""
\(\s*\d+\s*\)
| \[\s*\d+\s*\]
| <[^>]*>
| \{[^}]*\}
| ~[^~]*~
""", re.VERBOSE)
def split_modifiers(token):
"""Return (bare_prefix, overrides dict) for one prefix-list token."""
ov = {"cq_zone": None, "itu_zone": None, "latitude": None,
"longitude": None, "continent": None, "utc_offset": None}
m = MOD_CQ.search(token)
if m:
ov["cq_zone"] = int(m.group(1))
m = MOD_ITU.search(token)
if m:
ov["itu_zone"] = int(m.group(1))
m = MOD_LATLON.search(token)
if m:
ov["latitude"] = float(m.group(1))
ov["longitude"] = float(m.group(2)) # still positive-west here
m = MOD_CONT.search(token)
if m:
ov["continent"] = m.group(1).strip().upper() or None
m = MOD_OFFSET.search(token)
if m:
try:
ov["utc_offset"] = float(m.group(1))
except ValueError:
ov["utc_offset"] = None
return MODIFIER_RE.sub("", token).strip(), ov
class Entity:
__slots__ = ("name", "cq_zone", "itu_zone", "continent", "latitude",
"longitude", "utc_offset", "primary", "wae", "prefixes",
"entity_id", "line_no")
def __init__(self, **kw):
for k in self.__slots__:
setattr(self, k, kw.get(k))
def parse_cty(text):
"""Yield Entity objects from the contents of a cty.dat file."""
text = text.replace("\r\n", "\n").replace("\r", "\n")
# Records are terminated by ';'. Track line numbers for error messages.
pos = 0
line_no = 1
for chunk in text.split(";"):
start_line = line_no + chunk[:len(chunk) - len(chunk.lstrip("\n"))].count("\n")
line_no += chunk.count("\n")
record = chunk.strip()
if not record:
continue
head, _, body = record.partition("\n")
fields = [f.strip() for f in head.split(":")]
if len(fields) < 8:
raise ValueError(
"line %d: expected 8 colon-separated header fields, got %d: %r"
% (start_line, len(fields), head))
primary = fields[7]
wae = primary.startswith("*")
ent = Entity(
name=fields[0],
cq_zone=int(fields[1]),
itu_zone=int(fields[2]),
continent=fields[3].upper(),
latitude=float(fields[4]),
longitude=float(fields[5]),
utc_offset=float(fields[6]),
primary=primary.lstrip("*"),
wae=wae,
prefixes=[],
entity_id=None,
line_no=start_line,
)
seen = set()
for token in body.replace("\n", "").split(","):
token = token.strip()
if not token:
continue
bare, ov = split_modifiers(token)
exact = bare.startswith("=")
bare = bare.lstrip("=").strip().upper()
if not bare or bare in seen:
continue
seen.add(bare)
ent.prefixes.append((bare, 1 if exact else 0, ov))
yield ent
# --------------------------------------------------------------------------
# SQL emission
# --------------------------------------------------------------------------
SCHEMA = """\
CREATE TABLE IF NOT EXISTS dxcc_entities (
id INTEGER PRIMARY KEY,
name VARCHAR(64) NOT NULL,
continent CHAR(2) NOT NULL,
cq_zone INTEGER NOT NULL,
itu_zone INTEGER NOT NULL,
latitude DECIMAL(6,2) NOT NULL,
longitude DECIMAL(7,2) NOT NULL,
utc_offset DECIMAL(4,2) NOT NULL
);
CREATE TABLE IF NOT EXISTS dxcc_prefixes (
prefix VARCHAR(16) NOT NULL,
entity_id INTEGER NOT NULL REFERENCES dxcc_entities (id),
exact INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (prefix, entity_id)
);
CREATE INDEX IF NOT EXISTS idx_dxcc_prefixes_entity ON dxcc_prefixes (entity_id);
"""
def q(value):
"""Quote a string for SQL, doubling embedded single quotes."""
return "'" + value.replace("'", "''") + "'"
def emit_entities(entities, out):
rows = []
for e in entities:
rows.append((
"%d," % e.entity_id,
q(e.name) + ",",
q(e.primary) + ",",
q(e.continent) + ",",
"%d," % e.cq_zone,
"%d," % e.itu_zone,
"%.2f," % e.latitude,
"%.2f," % e.longitude,
"%.1f" % e.utc_offset,
))
widths = [max(len(r[i]) for r in rows) for i in range(9)]
# Numeric columns look better right-aligned, text columns left-aligned.
align = ["<", "<", "<", "<", "<", "<", ">", ">", ">"]
out.write("INSERT INTO dxcc_entities "
"(id, name, primary_prefix, continent, cq_zone, itu_zone, "
"latitude, longitude, utc_offset)"
" VALUES\n")
for n, row in enumerate(rows):
cells = [format(cell, "%s%d" % (align[i], widths[i]))
for i, cell in enumerate(row)]
line = " (" + " ".join(cells).rstrip() + ")"
out.write(line + (",\n" if n < len(rows) - 1 else ";\n"))
def emit_prefixes(entities, out, per_line=3):
out.write("INSERT INTO dxcc_prefixes "
"(prefix, entity_id, exact, cq_zone, itu_zone) VALUES\n")
def n(v):
return "NULL" if v is None else str(v)
tuples_by_entity = []
for e in entities:
if e.prefixes:
tuples_by_entity.append(
["(%s, %d, %d, %s, %s)" % (q(p), e.entity_id, x,
n(ov["cq_zone"]), n(ov["itu_zone"]))
for p, x, ov in e.prefixes])
last = len(tuples_by_entity) - 1
for i, group in enumerate(tuples_by_entity):
for j in range(0, len(group), per_line):
slice_ = group[j:j + per_line]
is_final = (i == last) and (j + per_line >= len(group))
out.write(" " + ", ".join(slice_) + (";\n" if is_final else ",\n"))
# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def main(argv=None):
ap = argparse.ArgumentParser(
description="Convert a cty.dat DXCC country file into SQL INSERT statements.")
ap.add_argument("cty", help="path to cty.dat")
ap.add_argument("-o", "--output", help="write SQL here (default: stdout)")
ap.add_argument("--dxcc-map", metavar="JSON",
help='JSON file of {"primary_prefix": entity_id} merged over '
"the built-in table")
ap.add_argument("--longitude", choices=["east-positive", "as-is"],
default="east-positive",
help="cty.dat stores longitude positive-west; 'east-positive' "
"(default) flips it to the usual convention")
ap.add_argument("--utc-offset", choices=["cty", "standard"], default="standard",
help="'cty' (default) keeps cty.dat's positive-west offsets; "
"'standard' flips them to real UTC offsets")
ap.add_argument("--include-wae", action="store_true",
help="include WAE/CQ-only entities (needs ids via --dxcc-map)")
ap.add_argument("--exclude-exact", action="store_true",
help="skip '=CALLSIGN' full-callsign entries entirely")
ap.add_argument("--schema", action="store_true",
help="emit CREATE TABLE statements first")
ap.add_argument("--truncate", action="store_true",
help="emit DELETE FROM statements before the inserts")
ap.add_argument("--transaction", action="store_true",
help="wrap the output in BEGIN / COMMIT")
ap.add_argument("--per-line", type=int, default=5, metavar="N",
help="prefix tuples per output line (default 5)")
ap.add_argument("--strict", action="store_true",
help="exit non-zero if any entity has no known DXCC id")
args = ap.parse_args(argv)
ids = dict(DXCC_IDS)
if args.dxcc_map:
with open(args.dxcc_map, encoding="utf-8") as fh:
ids.update({k: int(v) for k, v in json.load(fh).items()})
with open(args.cty, encoding="utf-8", errors="replace") as fh:
raw = fh.read()
entities = []
skipped_wae = []
unmapped = []
for ent in parse_cty(raw):
if ent.wae and not args.include_wae:
skipped_wae.append(ent)
continue
ent.entity_id = ids.get(ent.primary)
if ent.entity_id is None:
unmapped.append(ent)
continue
if args.longitude == "east-positive":
ent.longitude = -ent.longitude or 0.0 # avoid "-0.00"
if args.utc_offset == "standard":
ent.utc_offset = -ent.utc_offset or 0.0
if args.exclude_exact:
ent.prefixes = [(p, x, ov) for p, x, ov in ent.prefixes if not x]
entities.append(ent)
entities.sort(key=lambda e: e.entity_id)
# A prefix must resolve to one entity; cty.dat should not collide, but say
# so loudly if a hand-edited file does.
owner = OrderedDict()
for e in entities:
kept = []
for p, x, ov in e.prefixes:
if p in owner:
sys.stderr.write(
"warning: prefix %s claimed by both %s and %s; keeping %s\n"
% (p, owner[p], e.name, owner[p]))
continue
owner[p] = e.name
kept.append((p, x, ov))
e.prefixes = kept
for e in skipped_wae:
sys.stderr.write("note: skipping WAE/CQ-only entity %s (%s)\n"
% (e.name, e.primary))
for e in unmapped:
sys.stderr.write("warning: no DXCC id for %s (primary prefix %s, line %d)\n"
% (e.name, e.primary, e.line_no))
if not entities:
sys.stderr.write("error: no entities to write\n")
return 2
out = open(args.output, "w", encoding="utf-8") if args.output else sys.stdout
try:
src = os.path.basename(args.cty)
out.write("-- " + "-" * 73 + "\n")
out.write("-- DXCC entities and prefixes generated from %s by cty2sql.py.\n" % src)
out.write("-- %d entities, %d prefixes. Longitude is %s; utc_offset uses the %s.\n"
% (len(entities), sum(len(e.prefixes) for e in entities),
"positive east" if args.longitude == "east-positive"
else "positive west (raw cty.dat)",
"cty.dat sign (positive west)" if args.utc_offset == "cty"
else "standard UTC sign"))
out.write("-- " + "-" * 73 + "\n")
if args.schema:
out.write(SCHEMA + "\n")
if args.transaction:
out.write("BEGIN;\n")
if args.truncate:
out.write("DELETE FROM dxcc_prefixes;\nDELETE FROM dxcc_entities;\n")
emit_entities(entities, out)
emit_prefixes(entities, out, per_line=max(1, args.per_line))
if args.transaction:
out.write("COMMIT;\n")
finally:
if args.output:
out.close()
sys.stderr.write("wrote %d entities and %d prefixes\n"
% (len(entities), sum(len(e.prefixes) for e in entities)))
return 1 if (args.strict and unmapped) else 0
if __name__ == "__main__":
sys.exit(main())
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
#
# Build the Hammy reference bundle from the numbered SQL sources.
#
# The plain `for f in *.sql; do sqlite3 db < $f; done` loop keeps going after a
# failure, so one bad file leaves a half-populated database that looks fine
# until something queries the missing rows. -bail plus set -e stops at the first
# error instead.
set -euo pipefail
DB="${1:-hammy-ref.sqlite}"
# -batch ignores ~/.sqliterc, so output formatting does not depend on whatever
# .mode the developer has configured.
SQLITE=(sqlite3 -bail -batch)
# Order matters: 05-dxcc.sql must load before 06-eng-beacons.sql, because
# ncdxf_beacons.dxcc_id has a foreign key into dxcc_entities.
SOURCES=(*.sql)
if [ -e "$DB" ]; then
echo "removing existing $DB"
rm -f "$DB"
fi
for f in "${SOURCES[@]}"; do
printf ' %-24s' "$f"
"${SQLITE[@]}" "$DB" < "$f"
echo "ok"
done
echo
echo "integrity"
fk=$("${SQLITE[@]}" "$DB" 'PRAGMA foreign_key_check;')
if [ -n "$fk" ]; then
echo " FOREIGN KEY VIOLATIONS:"
echo "$fk" | sed 's/^/ /'
exit 1
fi
echo " foreign keys ok"
echo " integrity $("${SQLITE[@]}" "$DB" 'PRAGMA integrity_check;')"
echo
echo "row counts"
# pragma_table_info returns one row per COLUMN, so counting it gives column
# counts. Real row counts need a query per table; generate them and pipe back in.
"${SQLITE[@]}" -noheader "$DB" "
SELECT 'SELECT '' ' || name || ''' , COUNT(*) FROM ' || name || ';'
FROM sqlite_master WHERE type='table' ORDER BY name;
" | "${SQLITE[@]}" -noheader -separator ' ' "$DB" | awk '{printf " %-34s %8s\n", $1, $2}'
echo
echo "compacting"
"${SQLITE[@]}" "$DB" 'VACUUM;'
ls -lh "$DB"
+40
View File
@@ -0,0 +1,40 @@
#include <concord/discord.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <hammy/command.h>
#include <hammy/commands.h>
#include <hammy/job.h>
#include <hammy/refdb.h>
#include <hammy/utils.h>
#define HAMMY_ABBR_ECHO_MAX 16
#define HAMMY_ABBR_CODE_MAX 3072
#define HAMMY_ABBR_TRUNCATED " ... (truncated)"
void hammy_cmd_abbr(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
// Get the qcode argument from the job
const char* abbr = hammy_job_get_arg(job, "abbreviation");
if (!abbr) {
hammy_job_respond(job, client, "Error", "No Abbreviation provided for Abbreviation conversion.", true);
return;
}
char body[HAMMY_ABBR_CODE_MAX + sizeof(HAMMY_ABBR_TRUNCATED)]; // Generally, since the bottom pointers can only point to max 128-character strings (set as preprocesor header)
// So yeah - if we ever change the above for some reason to stupid values... yeah. Technically "unsafe" but yeah.
const char* str = NULL;
const char* context = NULL;
if (!hammy_refdb_get_abbr(refdb, abbr, &str, &context)) {
hammy_job_respond(job, client, "Abbreviation Not Found!", "Failed to find the Abbreviation specified. Please check your query!", true);
return;
}
hammy_to_uppercase((char*)abbr);
snprintf(body, sizeof(body), "Abbreviation: `%s`\nMeaning: `%s`\nUsage Context: `%s`", abbr, (str ? str : "Not Specified"), (context ? context : "Not Specified"));
hammy_job_respond(job, client, "Abbreviation Conversion", body, false);
}
+191
View File
@@ -0,0 +1,191 @@
#include <concord/discord.h>
#include <inttypes.h>
#include <math.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <hammy/command.h>
#include <hammy/commands.h>
#include <hammy/geo.h>
#include <hammy/job.h>
#include <hammy/refdb.h>
#include <hammy/utils.h>
#define HAMMY_DXCC_BODY_MAX 2048
#define HAMMY_DXCC_TITLE_MAX 128
// Formats a signed decimal degree as "38.90 N" / "77.04 W".
static void hammy_dxcc_fmt_coord(double value, bool isLatitude, char* out, size_t cap) {
char hemi = isLatitude ? (value >= 0.0 ? 'N' : 'S')
: (value >= 0.0 ? 'E' : 'W');
snprintf(out, cap, "%.2f %c", fabs(value), hemi);
}
// Formats a UTC offset as "UTC+9", "UTC-5", "UTC+5:30". cty.dat carries half
// hour offsets for a handful of entities, so the fractional case is real.
static void hammy_dxcc_fmt_offset(double offset, char* out, size_t cap) {
int totalMinutes = (int)lround(offset * 60.0);
char sign = totalMinutes < 0 ? '-' : '+';
if (totalMinutes < 0) { totalMinutes = -totalMinutes; }
int hours = totalMinutes / 60;
int minutes = totalMinutes % 60;
if (minutes) {
snprintf(out, cap, "UTC%c%d:%02d", sign, hours, minutes);
} else {
snprintf(out, cap, "UTC%c%d", sign, hours);
}
}
// Wall-clock time at the entity. cty.dat gives STANDARD offsets with no notion
// of summer time, so this can be an hour out for part of the year. Said so in
// the output rather than quietly presenting it as exact.
static void hammy_dxcc_fmt_local_time(double offset, char* out, size_t cap) {
time_t now = time(NULL);
if (now == (time_t)-1) {
snprintf(out, cap, "unknown");
return;
}
time_t shifted = now + (time_t)lround(offset * 3600.0);
struct tm tmBuf;
// gmtime_r rather than gmtime: instant commands run on the gateway thread
// and deferred ones on a worker, and gmtime's static buffer is shared.
if (!gmtime_r(&shifted, &tmBuf)) {
snprintf(out, cap, "unknown");
return;
}
snprintf(out, cap, "%02d:%02d", tmBuf.tm_hour, tmBuf.tm_min);
}
// Instant command: runs on the gateway thread, sends a fresh response.
void hammy_cmd_dxcc(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
if (!refdb) {
hammy_job_respond(job, client, "Error", "Reference data is unavailable. Please try again later.", true);
return;
}
const char* callsign = hammy_job_get_arg(job, "callsign");
if (!callsign || !*callsign) {
hammy_job_respond(job, client, "Error", "No callsign provided for lookup.", true);
return;
}
hammy_dxcc_t entity;
if (!hammy_refdb_dxcc(refdb, callsign, &entity)) {
char body[HAMMY_DXCC_BODY_MAX];
snprintf(body, sizeof(body),
"No DXCC entity matches `%s`.\n\n"
"Prefixes are matched longest-first, so this usually means a typo "
"or a prefix that has never been allocated.", callsign);
hammy_job_respond(job, client, "Entity Not Found!", body, true);
return;
}
char gridStr[HAMMY_GRID_MAX] = "unknown";
bool haveEntityGrid = hammy_grid_from_latlon(entity.latitude, entity.longitude, 6,
gridStr, sizeof(gridStr));
char latStr[24];
char lonStr[24];
hammy_dxcc_fmt_coord(entity.latitude, true, latStr, sizeof(latStr));
hammy_dxcc_fmt_coord(entity.longitude, false, lonStr, sizeof(lonStr));
char offsetStr[24];
char timeStr[16];
hammy_dxcc_fmt_offset(entity.utcOffset, offsetStr, sizeof(offsetStr));
hammy_dxcc_fmt_local_time(entity.utcOffset, timeStr, sizeof(timeStr));
char title[HAMMY_DXCC_TITLE_MAX];
char upperCallsign[HAMMY_CALLSIGN_MAX];
snprintf(upperCallsign, sizeof(upperCallsign), "%s", callsign);
hammy_to_uppercase(upperCallsign);
snprintf(title, sizeof(title), "%s - %s", upperCallsign, entity.name);
char body[HAMMY_DXCC_BODY_MAX];
int len = 0;
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Entity: `%s` (DXCC `%d`)\n"
"Continent: `%s` CQ zone: `%d` ITU zone: `%d`\n",
entity.name, entity.entityId, entity.continent,
entity.cqZone, entity.ituZone);
if (haveEntityGrid) {
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Location: `%s` (%s, %s)\n", gridStr, latStr, lonStr);
} else {
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Location: %s, %s\n", latStr, lonStr);
}
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Local time: `%s` (`%s`, standard time)\n", timeStr, offsetStr);
// Saying WHICH rule matched matters more than it looks. An exact hit means
// cty.dat carries a whole-callsign override, and a short prefix hit on a
// long callsign is a hint that the entity guess may be loose.
if (entity.exact) {
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Matched: exact callsign rule `%s`\n", entity.matchedPrefix);
} else {
len += snprintf(body + len, sizeof(body) - (size_t)len,
"Matched: prefix `%s`\n", entity.matchedPrefix);
}
// Optional second argument: the asker's own locator, which turns this from
// trivia into something you can point an antenna with.
const char* fromGrid = hammy_job_get_arg(job, "grid");
if (fromGrid && *fromGrid) {
double fromLat = 0.0;
double fromLon = 0.0;
if (!hammy_grid_to_latlon(fromGrid, &fromLat, &fromLon)) {
len += snprintf(body + len, sizeof(body) - (size_t)len,
"\nCould not read `%s` as a Maidenhead locator. "
"Try something like `JN76` or `JN76gb`.\n", fromGrid);
} else {
double distKm = 0.0;
double bearing = 0.0;
hammy_great_circle(fromLat, fromLon, entity.latitude, entity.longitude,
&distKm, &bearing);
double longKm = hammy_long_path_km(distKm);
double longBearing = hammy_reciprocal_bearing(bearing);
char fromNorm[HAMMY_GRID_MAX];
if (!hammy_grid_from_latlon(fromLat, fromLon, 6, fromNorm, sizeof(fromNorm))) {
snprintf(fromNorm, sizeof(fromNorm), "%s", fromGrid);
}
len += snprintf(body + len, sizeof(body) - (size_t)len,
"\n**From %s**\n"
"Short path: `%.0f km` (`%.0f mi`) bearing `%.0f` (%s)\n"
"Long path: `%.0f km` bearing `%.0f` (%s)\n",
fromNorm,
distKm, distKm * 0.621371, bearing, hammy_compass_point(bearing),
longKm, longBearing, hammy_compass_point(longBearing));
}
}
// The entity coordinates are a nominal center for the whole entity, not the
// station's own position. For the US that is a point in Washington DC, so a
// bearing to a California station will be well off. Worth admitting.
len += snprintf(body + len, sizeof(body) - (size_t)len,
"\n-# Coordinates are the entity's nominal center, not the "
"station's actual location.");
(void)len;
hammy_job_respond(job, client, title, body, false);
}
+208
View File
@@ -0,0 +1,208 @@
#include <concord/discord.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdarg.h>
#include <hammy/command.h>
#include <hammy/commands.h>
#include <hammy/job.h>
#include <hammy/refdb.h>
#define HAMMY_FREQ_BODY_MAX 3600
// Small append-only string builder. Every write is bounds-checked, so a country
// with thirty licence classes overflows into "truncated" rather than the stack.
typedef struct {
char* buf;
size_t cap;
size_t len;
bool overflow;
} hammy_sb_t;
static void sb_addf(hammy_sb_t* sb, const char* fmt, ...) {
if (sb->overflow || sb->len + 1 >= sb->cap) {
sb->overflow = true;
return;
}
va_list ap;
va_start(ap, fmt);
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wformat-nonliteral"
int n = vsnprintf(sb->buf + sb->len, sb->cap - sb->len, fmt, ap);
#pragma clang diagnostic pop
va_end(ap);
if (n < 0) {
sb->overflow = true;
return;
}
if ((size_t)n >= sb->cap - sb->len) {
sb->len = sb->cap - 1;
sb->overflow = true;
return;
}
sb->len += (size_t)n;
}
// Hz -> MHz with three decimals, which is the resolution every band edge in the
// bundle actually uses. Integer maths so no rounding surprises at an edge.
static void fmt_mhz(int64_t hz, char* out, size_t cap) {
int64_t whole = hz / 1000000;
int64_t frac = (hz % 1000000) / 1000;
snprintf(out, cap, "%" PRId64 ".%03" PRId64, whole, frac);
}
// 'CW,DATA' reads better as 'CW, DATA' in an embed.
static void fmt_modes(const char* modes, char* out, size_t cap) {
size_t j = 0;
for (size_t i = 0; modes[i] && j + 2 < cap; i++) {
out[j++] = modes[i];
if (modes[i] == ',' && j + 1 < cap) { out[j++] = ' '; }
}
out[j] = '\0';
}
// Instant command: runs on the gateway thread, sends a fresh response.
void hammy_cmd_freq(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
if (!refdb) {
hammy_job_respond(job, client, "Error", "Reference data is unavailable. Please try again later.", true);
return;
}
const char* arg = hammy_job_get_arg(job, "frequency");
if (!arg || !*arg) {
hammy_job_respond(job, client, "Error", "No frequency provided for lookup.", true);
return;
}
// Parses "14.150", "14150 kHz", "146.52 MHz". Integer maths throughout: a
// bare (int64_t)cast of a float truncates BEFORE the multiply and turns
// 14.150 into 14.000, which silently reports the wrong segment.
int64_t freqHz = 0;
if (!hammy_freq_parse(arg, &freqHz)) {
hammy_job_respond(job, client, "Error", "I could not read that frequency. Try something like `14.150`, `7025 kHz` or `146.52 MHz`.", true);
return;
}
const char* cc = hammy_job_get_arg(job, "country"); // NULL -> refdb uses US
hammy_freq_t r;
if (!hammy_refdb_freq(refdb, freqHz, cc, &r)) {
hammy_job_respond(job, client, "Error", "Something went wrong looking that up.", true);
return;
}
char freqStr[32];
fmt_mhz(r.freqHz, freqStr, sizeof(freqStr));
char body[HAMMY_FREQ_BODY_MAX];
hammy_sb_t sb = { .buf = body, .cap = sizeof(body), .len = 0, .overflow = false };
body[0] = '\0';
// Not a valid ham band
if (!r.inBand) {
char lo[32], hi[32], dist[32];
fmt_mhz(r.nearestLowHz, lo, sizeof(lo));
fmt_mhz(r.nearestHighHz, hi, sizeof(hi));
fmt_mhz(r.nearestDistanceHz, dist, sizeof(dist));
sb_addf(&sb, "**%s MHz** is not in a valid amateur band.\n\n", freqStr);
sb_addf(&sb, "Nearest is **%s** (%s - %s MHz), %s MHz away.", r.nearestBand, lo, hi, dist);
hammy_job_respond(job, client, "Not an Amateur Band!", body, false);
return;
}
char bandLo[32], bandHi[32];
fmt_mhz(r.bandLowHz, bandLo, sizeof(bandLo));
fmt_mhz(r.bandHighHz, bandHi, sizeof(bandHi));
char title[128];
snprintf(title, sizeof(title), "Band **%s** (%s - %s MHz)\n", r.band, bandLo, bandHi);
// Privileges
if (!r.countryKnown) {
// The common case - only US data is seeded so far. Say what is missing
// and what does exist, rather than showing an empty table.
char codes[HAMMY_FREQ_COUNTRIES_MAX][HAMMY_COUNTRY_MAX];
size_t n = hammy_refdb_countries(refdb, codes, HAMMY_FREQ_COUNTRIES_MAX);
sb_addf(&sb, "\nNo licence data for **%s** in this bundle yet.\n", r.country);
if (n) {
sb_addf(&sb, "Currently available: ");
for (size_t i = 0; i < n; i++) {
sb_addf(&sb, "%s`%s`", i ? ", " : "", codes[i]);
}
sb_addf(&sb, "\n");
}
sb_addf(&sb, "Band plans are contributed by operators - if you know your regulator's allocations, please help fill this in.\n");
} else if (r.nPrivs == 0) {
sb_addf(&sb, "\nNo license classes are recorded for **%s**.\n", r.country);
} else {
sb_addf(&sb, "\n**Privileges in %s**\n", r.country);
for (size_t i = 0; i < r.nPrivs; i++) {
const hammy_freq_priv_t* p = &r.privs[i];
if (!p->permitted) {
sb_addf(&sb, "`%-14s` not permitted here\n", p->name);
continue;
}
char lo[32], hi[32], modes[80];
fmt_mhz(p->segLowHz, lo, sizeof(lo));
fmt_mhz(p->segHighHz, hi, sizeof(hi));
fmt_modes(p->modes, modes, sizeof(modes));
sb_addf(&sb, "`%-14s` %s (%s - %s", p->name, modes, lo, hi);
if (p->maxPowerW > 0) {
sb_addf(&sb, ", max %d W", p->maxPowerW);
}
sb_addf(&sb, ")\n");
if (p->notes[0]) {
sb_addf(&sb, " *%s*\n", p->notes);
}
}
}
// IARU
if (r.nIaru) {
sb_addf(&sb, "\n**IARU Allocation**\n");
for (size_t i = 0; i < r.nIaru; i++) {
char lo[32], hi[32];
fmt_mhz(r.iaru[i].lowHz, lo, sizeof(lo));
fmt_mhz(r.iaru[i].highHz, hi, sizeof(hi));
sb_addf(&sb, "Region %d: %s - %s MHz\n", r.iaru[i].region, lo, hi);
}
}
// Boundary warnings
if (r.atBandEdge) {
sb_addf(&sb, "\n> This is exactly the band edge. A signal of any width centerd here extends outside the band.\n");
} else if (r.atSegmentEdge) {
sb_addf(&sb, "\n> This is exactly a segment boundary, so a signal centerd here straddles both sides.\n");
}
if (sb.overflow) {
snprintf(body + sizeof(body) - 20, 20, "\n... truncated");
}
hammy_job_respond(job, client, title, body, false);
}
+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/commands.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_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 ? 1u : 0u) + (gap ? 2u : 0u) + 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, 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, "Text Missing!", "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);
}
+144
View File
@@ -0,0 +1,144 @@
#include <concord/discord.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <hammy/command.h>
#include <hammy/commands.h>
#include <hammy/job.h>
#include <hammy/refdb.h>
#include <hammy/utils.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 a whole word.
#define HAMMY_PHONETIC_CODE_MAX 1536
#define HAMMY_PHONETIC_PRONUNCIATION_MAX 1536
#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 word 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 ? 1u : 0u) + (gap ? 2u : 0u) + 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';
}
// 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, "text");
if (!text) {
hammy_job_respond(job, client, "Text Missing!", "No Text provided for Phonetic conversion.", true);
return;
}
if (strlen(text) >= 128) {
hammy_job_respond(job, client, "Text Too Long!", "Text provided is too long! Max. 128 characters.", true);
return;
}
bool showPronunciation = strlen(text) < 12;
char phonetic[HAMMY_PHONETIC_CODE_MAX + sizeof(HAMMY_PHONETIC_TRUNCATED)];
char pronunciation[HAMMY_PHONETIC_PRONUNCIATION_MAX + sizeof(HAMMY_PHONETIC_TRUNCATED)];
phonetic[0] = '\0';
pronunciation[0] = '\0';
// Room held back so the truncation note always fits.
const size_t phoneticCap = sizeof(phonetic) - (sizeof(HAMMY_PHONETIC_TRUNCATED) - 1);
const size_t pronunciationCap = sizeof(pronunciation) - (sizeof(HAMMY_PHONETIC_TRUNCATED) - 1);
size_t phoneticLen = 0;
size_t pronunciationLen = 0;
bool pendingGap = false;
bool truncated = false;
for (const char* p = text; *p; p++) {
// The phonetic 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 (phonetic_is_gap(*p)) {
pendingGap = (phoneticLen > 0);
continue;
}
const char* code = NULL;
const char* codePronunciation = NULL;
if (!hammy_refdb_get_phonetic(refdb, *p, &code, &codePronunciation)) {
// Both halves get a placeholder: leaving the pronunciation at NULL
// would hand strlen() a null pointer on the next append.
code = "?"; // Handle unknown characters
codePronunciation = "?";
}
// The two halves are read side by side, so they have to stay on the same
// character. A word that fits in one buffer but not the other is rolled
// back out of the first rather than left to skew the columns.
size_t phoneticMark = phoneticLen;
if (!phonetic_append(phonetic, phoneticCap, &phoneticLen, pendingGap, code) ||
!phonetic_append(pronunciation, pronunciationCap, &pronunciationLen, pendingGap, codePronunciation)) {
phoneticLen = phoneticMark;
phonetic[phoneticLen] = '\0';
truncated = true;
break;
}
pendingGap = false;
}
// All whitespace, or an empty string: Discord refuses an empty body, so
// there is nothing to send back.
if (phoneticLen == 0) {
hammy_job_respond(job, client, "Error", "Nothing to convert.", true);
return;
}
if (truncated) {
memcpy(phonetic + phoneticLen, HAMMY_PHONETIC_TRUNCATED, sizeof(HAMMY_PHONETIC_TRUNCATED));
memcpy(pronunciation + pronunciationLen, HAMMY_PHONETIC_TRUNCATED, sizeof(HAMMY_PHONETIC_TRUNCATED));
}
hammy_to_uppercase((char*)text);
// Reply with the phonetic words and how each of them is spoken.
char body[sizeof(phonetic) + sizeof(pronunciation) + 48];
if (showPronunciation) {
snprintf(body, sizeof(body), "Text: `%s`\nPhonetics: `%s`\nPronunciation: `%s`", text, phonetic, pronunciation);
} else {
snprintf(body, sizeof(body), "Text: `(truncated)`\nPhonetics: `%s`", phonetic);
}
hammy_job_respond(job, client, "Text to Phonetics Conversion", body, false);
}
+33
View File
@@ -0,0 +1,33 @@
#include <concord/discord.h>
#include <inttypes.h>
#include <stdio.h>
#include <hammy/command.h>
#include <hammy/commands.h>
#include <hammy/job.h>
// Discord epoch, for turning a snowflake back into a wall-clock time.
#define HAMMY_DISCORD_EPOCH_MS 1420070400000LL
static int64_t hammy_snowflake_to_ms(u64snowflake id) {
return (int64_t)(id >> 22) + HAMMY_DISCORD_EPOCH_MS;
}
// Instant command: runs on the gateway thread, sends a fresh response.
void hammy_cmd_ping(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
(void)refdb;
// Gateway heartbeat round trip, as measured by Concord.
int gatewayMs = discord_get_ping(client);
// Time from Discord minting the interaction to us handling it. This is the
// number that degrades under load, so it is the one worth showing.
int64_t handledMs = (int64_t)discord_timestamp(client) - hammy_snowflake_to_ms(job->id);
char body[256];
snprintf(body, sizeof(body),
"Gateway: `%d ms`\nHandled in: `%" PRId64 " ms`",
gatewayMs, handledMs);
hammy_job_respond(job, client, "Pong!", body, false);
}
+40
View File
@@ -0,0 +1,40 @@
#include <concord/discord.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <hammy/command.h>
#include <hammy/commands.h>
#include <hammy/job.h>
#include <hammy/refdb.h>
#include <hammy/utils.h>
#define HAMMY_QCODE_ECHO_MAX 16
#define HAMMY_QCODE_CODE_MAX 3072
#define HAMMY_QCODE_TRUNCATED " ... (truncated)"
void hammy_cmd_q(const hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
// Get the qcode argument from the job
const char* qcode = hammy_job_get_arg(job, "q-code");
if (!qcode) {
hammy_job_respond(job, client, "Error", "No text provided for Q-Code conversion.", true);
return;
}
char body[HAMMY_QCODE_CODE_MAX + sizeof(HAMMY_QCODE_TRUNCATED)]; // Generally, since the bottom pointers can only point to max 128-character strings (set as preprocesor header)
// So yeah - if we ever change the above for some reason to stupid values... yeah. Technically "unsafe" but yeah.
const char* questionStr = NULL;
const char* answerStr = NULL;
if (!hammy_refdb_get_qcode(refdb, qcode, &questionStr, &answerStr)) {
hammy_job_respond(job, client, "Q-Code Not Found!", "Failed to find the Q-Code specified. Please check your query!", true);
return;
}
hammy_to_uppercase((char*)qcode);
snprintf(body, sizeof(body), "Q-Code: `%s`\nQuestion: `%s`\nAnswer: `%s`", qcode, (questionStr ? questionStr : "Not Specified"), (answerStr ? answerStr : "Not Specified"));
hammy_job_respond(job, client, "Q-Code Conversion", body, false);
}
+287 -33
View File
@@ -1,14 +1,118 @@
#include <concord/discord.h>
#include <concord/log.h>
#include <dlibc/vector.h>
#include <inttypes.h>
#include <stdlib.h>
#include <string.h>
#include <hammy/bot.h>
#include <hammy/command.h>
#include <hammy/job.h>
#include <hammy/pool.h>
#include <hammy/embeds.h>
static void hammy_bot_attach(struct discord* client, hammy_bot_t* bot) {
discord_set_data(client, bot);
}
static hammy_bot_t* hammy_bot_from_client(struct discord* client) {
return (hammy_bot_t*)discord_get_data(client);
}
static void hammy_bot_on_interaction(struct discord* client, const struct discord_interaction* event) {
if (event->type != DISCORD_INTERACTION_APPLICATION_COMMAND) {
return;
}
hammy_bot_t* bot = hammy_bot_from_client(client);
if (!bot) {
log_warn("[bot] No bot data attached to client. Cannot handle interaction.");
return;
}
const char* name = (event->data && event->data->name) ? event->data->name : NULL;
const hammy_command_t* command = hammy_bot_find_command(bot, name);
if (!command) {
log_warn("[bot] No command found for interaction '%s'.", name ? name : "(null)");
struct discord_interaction_response params = {
.type = DISCORD_INTERACTION_CHANNEL_MESSAGE_WITH_SOURCE,
};
struct discord_embed embed[1];
CCORDcode code;
if (hammy_embeds_customerror(client, embed, NULL, "Unknown Command", "I don't know that command!", NULL, 0)) {
params.data = &(struct discord_interaction_callback_data){
.embeds = &(struct discord_embeds){
.size = 1,
.array = embed
}
};
code = discord_create_interaction_response(client, event->id, event->token, &params, NULL);
} else {
params.data = &(struct discord_interaction_callback_data){
.content = (char*)"I don't know that command!"
};
code = discord_create_interaction_response(client, event->id, event->token, &params, NULL);
}
if (code != CCORD_OK) {
log_warn("[job] Failed to send interaction error response for interaction %" PRIu64 ": %d", event->id, code);
}
return;
}
hammy_job_t* job = hammy_job_create(bot, event);
if (!job) {
log_error("[bot] Failed to create job for interaction '%s'.", name ? name : "(null)");
return;
}
// Instant path: no defer or queue, answered on the spot
if (command->instant) {
command->handler(job, client, bot->refdb);
hammy_job_destroy(&job);
return;
}
// Deferred path: ACK first, 3 second window by discord, then queue to worker pool
struct discord_interaction_response deferred = {
.type = DISCORD_INTERACTION_DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
};
discord_create_interaction_response(client, event->id, event->token, &deferred, NULL);
switch (hammy_pool_push(bot->pool, job)) {
case HAMMY_PUSH_OK:
log_info("[bot] Queued job for interaction '%s'.", name ? name : "(null)");
break;
case HAMMY_PUSH_FULL:
log_warn("[bot] Queue pool full. Cannot queue job for interaction '%s'.", name ? name : "(null)");
hammy_job_reply(job, client, "Bot is Busy", "I am a bit busy right now! Please try again later.", true);
hammy_job_destroy(&job);
break;
case HAMMY_PUSH_SHUTDOWN:
// Also covers the window before on_ready starts the pool, since
// hammy_pool_push() reports a NULL pool the same way.
log_error("[bot] Worker pool unavailable. Cannot queue job for interaction '%s'.", name ? name : "(null)");
hammy_job_reply(job, client, "Bot is Offline", "Hammy is not accepting commands right now. Please try again shortly.", true);
hammy_job_destroy(&job);
break;
}
}
hammy_bot_t* hammy_bot_create() {
hammy_bot_t* bot = (hammy_bot_t*)malloc(sizeof(hammy_bot_t));
hammy_bot_t* bot = (hammy_bot_t*)calloc(1, sizeof(*bot));
if (!bot) {
return NULL;
}
// Set defaults
bot->client = NULL;
bot->commands_registered = false;
bot->commandsRegistered = false;
bot->commands = vector_create(sizeof(hammy_command_t));
// Check if vector allocation errored
@@ -17,58 +121,208 @@ hammy_bot_t* hammy_bot_create() {
return NULL;
}
// Init the discord client - even though this is an owning reference, the main() and others may call functions on it (if we don't stay singlethread anymore then FIX THIS)
bot->client = discord_from_json("config.json");
if (!bot->client) {
vector_destroy(&bot->commands);
free(bot);
return NULL;
// Open a reference to sqlite
bot->refdb = hammy_refdb_open("hammy-ref.sqlite"); // TODO: Probably don't hardcode this?
if (!bot->refdb) {
log_error("[bot] Failed to open ref to sqlite. Commands requiring it will be unavailable!"); // TODO: Consider making this a hard-fail
}
return bot;
}
bool hammy_bot_set_on_ready(hammy_bot_t* bot, void (*func)(struct discord* client, const struct discord_ready* event)) {
if (!bot || !func || !bot->client) { return false; }
bool hammy_bot_add_command(hammy_bot_t* bot, const hammy_command_t* command) {
if (!bot || !bot->commands || !command || !command->name) { return false; }
discord_set_on_ready(bot->client, func);
return true;
}
bool hammy_bot_run(hammy_bot_t* bot) {
if (!bot || !bot->client) { return false; }
discord_run(bot->client);
return true;
}
bool hammy_bot_add_command(hammy_bot_t* bot, hammy_command_t* command) {
if (!bot || !command) { return false; }
// Push into commands vector
// Copy the command into the vector. The command owns nothing, so this is safe.
if (vector_push_back(bot->commands, command) < 0) {
log_error("[bot] Failed to add command '%s' to bot.", command->name);
return false;
}
log_info("[bot] Loaded command '%s'.", command->name);
return true;
}
bool hammy_bot_load_builtins(hammy_bot_t* bot) {
if (!bot) { return false; }
size_t count = 0;
const hammy_command_t* builtins = hammy_command_builtins(&count);
for (size_t i = 0; i < count; i++) {
if (!hammy_bot_add_command(bot, &builtins[i])) {
log_error("[bot] Failed to load builtin command '%s'.", builtins[i].name);
return false;
}
}
log_info("[bot] Loaded %zu builtin commands.", count);
return true;
}
const hammy_command_t* hammy_bot_find_command(const hammy_bot_t* bot, const char* name) {
if (!bot || !bot->commands || !name) { return NULL; }
// Vector guarantees contiguous and gapless storage, so a plain-array search is fine.
const hammy_command_t* commands = (const hammy_command_t*)vector_as_c_array(bot->commands);
return hammy_command_find(commands, vector_size(bot->commands), name);
}
bool hammy_bot_start_pool(hammy_bot_t* bot, size_t nWorkers, size_t queueCap) {
if (!bot || !bot->client) { return false; }
if (bot->pool) {
// A reconnect re-fires READY, so this is routine rather than a problem.
log_debug("[bot] Worker pool already started. No-op.");
return true; // Already started
}
bot->pool = hammy_pool_create(bot->client, nWorkers, queueCap);
if (!bot->pool) {
log_error("[bot] Failed to create worker pool.");
return false;
}
return true;
}
bool hammy_bot_register_commands(hammy_bot_t* bot) {
if (!bot) { return false; }
bool hammy_bot_deregister_all_commands(hammy_bot_t* bot) {
// TODO: This leaks and I don't know why. Figure it out.
if (!bot || !bot->client) { return false; }
// TODO: Figure out registration logic
struct discord_application_commands cmds = {0};
struct discord_ret_application_commands ret = { .sync = &cmds };
if (discord_get_global_application_commands(bot->client, bot->appId, &ret) == CCORD_OK) {
for (int i = 0; i < cmds.size; i++) {
discord_delete_global_application_command(bot->client, bot->appId, cmds.array[i].id, NULL);
}
} else {
log_error("[bot] Failed to deregister global commands!");
return false;
}
const char* devGuild = getenv("HAMMY_DEV_GUILD");
u64snowflake guildId = devGuild ? (u64snowflake)strtoull(devGuild, NULL, 10) : 0;
if (guildId) {
if (discord_get_guild_application_commands(bot->client, bot->appId, guildId, &ret) == CCORD_OK) {
for (int i = 0; i < cmds.size; i++) {
discord_delete_guild_application_command(bot->client, bot->appId, guildId, cmds.array[i].id, NULL);
}
} else {
log_error("[bot] Failed to deregister dev guild commands!");
return false;
}
}
bot->commandsRegistered = false;
return true;
}
bool hammy_bot_register_commands(hammy_bot_t* bot) {
if (!bot || !bot->client) { return false; }
if (bot->commandsRegistered) { return true; } // Already registered
if (!bot->appId) {
log_error("[bot] No application ID set. Cannot register commands. This should be set in the on_ready() callback.");
return false;
}
const char* devGuild = getenv("HAMMY_DEV_GUILD");
u64snowflake guildId = devGuild ? (u64snowflake)strtoull(devGuild, NULL, 10) : 0;
size_t count = vector_size(bot->commands);
for (size_t i = 0; i < count; i++) {
const hammy_command_t* cmd = (const hammy_command_t*)vector_get_const(bot->commands, i);
if (!cmd || !cmd->name || !cmd->description) {
log_warn("[bot] Invalid command at index %zu. Cannot register with Discord. Skipping!", i);
continue; // Skip this command, but try to register the rest
}
CCORDcode code;
if (guildId) {
struct discord_create_guild_application_command params = {
.name = (char*)cmd->name,
.description = (char*)cmd->description,
.options = cmd->options
};
code = discord_create_guild_application_command(bot->client, bot->appId, guildId, &params, NULL);
} else {
struct discord_create_global_application_command params = {
.name = (char*)cmd->name,
.description = (char*)cmd->description,
.options = cmd->options
};
code = discord_create_global_application_command(bot->client, bot->appId, &params, NULL);
}
// Passing NULL for the return handle makes these fire-and-forget, so
// a successful enqueue reports CCORD_PENDING rather than CCORD_OK.
if (code != CCORD_OK && code != CCORD_PENDING) {
log_error("[bot] Failed to register command '%s' with Discord. Error code: %d", cmd->name, code);
return false;
}
}
bot->commandsRegistered = true;
log_info("[bot] Successfully registered %zu commands with Discord %s.", count, guildId ? "to dev guild" : "globally");
return true;
}
bool hammy_bot_set_on_ready(hammy_bot_t* bot, void (*func)(struct discord* client, const struct discord_ready* event)) {
if (!bot || !func || !bot->client) { return false; }
discord_set_on_ready(bot->client, func);
return true;
}
bool hammy_bot_run(hammy_bot_t* bot) {
if (!bot || !bot->client) { return false; }
hammy_bot_attach(bot->client, bot);
discord_set_on_interaction_create(bot->client, &hammy_bot_on_interaction);
return discord_run(bot->client) == CCORD_OK;
}
bool hammy_bot_destroy(hammy_bot_t** bot) {
if (!bot || !(*bot)) { return false; }
if ((*bot)->client) {
discord_cleanup((*bot)->client);
hammy_bot_t* b = *bot;
// Workers hold clones of the client
if (b->pool) {
hammy_pool_destroy(&b->pool);
}
// No element destructor, commands own nothing.
if (b->commands) {
vector_destroy(&b->commands);
}
if (b->client) {
discord_cleanup(b->client);
b->client = NULL;
}
if (b->refdb) {
hammy_refdb_close(&b->refdb);
}
// TODO: Potential other stuff here
vector_destroy(&(*bot)->commands); // commands is NULL form hereon, calls destructors on commands automatically
free(*bot);
*bot = NULL;
return true;
}
+70 -7
View File
@@ -2,15 +2,73 @@
#include <string.h>
#include <hammy/command.h>
#include <hammy/commands.h> // Table
// Handlers live in src/hammy/commands/. Declared here rather than in a header
// 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) {
(void)job;
(void)client;
log_info("[command] ping");
}
// 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 = (char*)"text",
.description = (char*)"Text to convert to Morse code", .required = true },
};
static struct discord_application_command_options morse_opts_struct = {
.size = 1,
.array = morse_opts
};
static struct discord_application_command_option freq_opts[] = {
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"frequency",
.description = (char*)"Frequency in MHz", .required = true },
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"country",
.description = (char*)"Country code to look up (e.g. US) - Defaults to US if none specified.", .required = false },
};
static struct discord_application_command_options freq_opts_struct = {
.size = 2,
.array = freq_opts
};
static struct discord_application_command_option q_opts[] = {
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"q-code",
.description = (char*)"Q-Code to convert to Question/Answer", .required = true },
};
static struct discord_application_command_options q_opts_struct = {
.size = 1,
.array = q_opts
};
static struct discord_application_command_option abbr_opts[] = {
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"abbreviation",
.description = (char*)"Abbreviation to convert to Meaning", .required = true },
};
static struct discord_application_command_options abbr_opts_struct = {
.size = 1,
.array = abbr_opts
};
static struct discord_application_command_option phonetic_opts[] = {
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"text",
.description = (char*)"Text to convert to Phonetics", .required = true },
};
static struct discord_application_command_options phonetic_opts_struct = {
.size = 1,
.array = phonetic_opts
};
static struct discord_application_command_option dxcc_opts[] = {
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"callsign",
.description = (char*)"Callsign or prefix to look up", .required = true },
{ .type = DISCORD_APPLICATION_OPTION_STRING, .name = (char*)"grid",
.description = (char*)"Your Maidenhead locator, for distance and bearing", .required = false },
};
static struct discord_application_command_options dxcc_opts_struct = { .size = 2, .array = dxcc_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
@@ -18,8 +76,13 @@ void hammy_cmd_ping(const hammy_job_t* job, struct discord* client) {
//
// 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 },
{ "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 },
{ "phonetic", "Convert Text to Phonetics", &phonetic_opts_struct, &hammy_cmd_phonetic, true },
{ "dxcc", "Look up the DXCC entity for a callsign", &dxcc_opts_struct, &hammy_cmd_dxcc, true },
{ "abbr", "Convert Abbreviation to Meaning and Context", &abbr_opts_struct, &hammy_cmd_abbr, true }
// Tier 0 commands go here as they land. All pure computation, so instant:
// { "grid", "Maidenhead locator conversions", &grid_opts, &hammy_cmd_grid, true },
+197
View File
@@ -0,0 +1,197 @@
#include <ctype.h>
#include <math.h>
#include <stdbool.h>
#include <stddef.h>
#include <string.h>
#include <hammy/geo.h>
// M_PI is POSIX, not C99, so it is absent under -std=c99 without _GNU_SOURCE.
// Defining it here keeps the build strict and portable.
#define HAMMY_PI 3.14159265358979323846
// IUGG mean Earth radius. Any of the common radii agree to well within the
// error a 6-character grid square already carries.
#define HAMMY_EARTH_RADIUS_KM 6371.0088
#define HAMMY_EARTH_CIRCUM_KM (2.0 * HAMMY_PI * HAMMY_EARTH_RADIUS_KM)
static double deg_to_rad(double deg) { return deg * HAMMY_PI / 180.0; }
static double rad_to_deg(double rad) { return rad * 180.0 / HAMMY_PI; }
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wbad-function-cast" // suppresses the dumb floor() cast to int warning
bool hammy_grid_from_latlon(double lat, double lon, int precision, char* out, size_t cap) {
if (!out || cap < 3) { return false; }
if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) { return false; }
if (precision != 2 && precision != 4 && precision != 6 && precision != 8) { return false; }
if (cap < (size_t)precision + 1) { return false; }
// Shift into the all-positive space the locator system is defined over.
double lonA = lon + 180.0;
double latA = lat + 90.0;
// Clamp the poles and the antimeridian so floor() cannot walk off the end
// of the field letters at exactly +90 / +180.
if (lonA >= 360.0) { lonA = 359.999999; }
if (latA >= 180.0) { latA = 179.999999; }
int i = 0;
int f1 = (int)floor(lonA / 20.0);
int f2 = (int)floor(latA / 10.0);
out[i++] = (char)('A' + f1);
out[i++] = (char)('A' + f2);
double remLon = lonA - f1 * 20.0;
double remLat = latA - f2 * 10.0;
if (precision >= 4) {
int s1 = (int)floor(remLon / 2.0);
int s2 = (int)floor(remLat / 1.0);
out[i++] = (char)('0' + s1);
out[i++] = (char)('0' + s2);
remLon -= s1 * 2.0;
remLat -= s2 * 1.0;
}
if (precision >= 6) {
// A square is 2 deg of longitude by 1 deg of latitude, divided 24 ways.
int ss1 = (int)floor(remLon / (2.0 / 24.0));
int ss2 = (int)floor(remLat / (1.0 / 24.0));
if (ss1 > 23) { ss1 = 23; }
if (ss2 > 23) { ss2 = 23; }
out[i++] = (char)('a' + ss1);
out[i++] = (char)('a' + ss2);
remLon -= ss1 * (2.0 / 24.0);
remLat -= ss2 * (1.0 / 24.0);
}
if (precision >= 8) {
int e1 = (int)floor(remLon / (2.0 / 240.0));
int e2 = (int)floor(remLat / (1.0 / 240.0));
if (e1 > 9) { e1 = 9; }
if (e2 > 9) { e2 = 9; }
out[i++] = (char)('0' + e1);
out[i++] = (char)('0' + e2);
}
out[i] = '\0';
return true;
}
bool hammy_grid_to_latlon(const char* grid, double* outLat, double* outLon) {
if (!grid || !outLat || !outLon) { return false; }
char g[HAMMY_GRID_MAX];
size_t n = 0;
for (size_t i = 0; grid[i]; i++) {
if (isspace((unsigned char)grid[i])) { continue; }
// Reject rather than truncate. Silently clipping "IO91wm12345" to
// "IO91wm12" would hand back a plausible-looking position for input the
// user clearly got wrong.
if (n + 1 >= sizeof(g)) { return false; }
g[n++] = grid[i];
}
g[n] = '\0';
// Locators come in even-length pairs; 2, 4, 6 and 8 are the useful ones.
if (n != 2 && n != 4 && n != 6 && n != 8) { return false; }
int f1 = toupper((unsigned char)g[0]) - 'A';
int f2 = toupper((unsigned char)g[1]) - 'A';
if (f1 < 0 || f1 > 17 || f2 < 0 || f2 > 17) { return false; }
double lon = f1 * 20.0;
double lat = f2 * 10.0;
double lonSize = 20.0;
double latSize = 10.0;
if (n >= 4) {
if (!isdigit((unsigned char)g[2]) || !isdigit((unsigned char)g[3])) { return false; }
lon += (g[2] - '0') * 2.0;
lat += (g[3] - '0') * 1.0;
lonSize = 2.0;
latSize = 1.0;
}
if (n >= 6) {
int s1 = tolower((unsigned char)g[4]) - 'a';
int s2 = tolower((unsigned char)g[5]) - 'a';
if (s1 < 0 || s1 > 23 || s2 < 0 || s2 > 23) { return false; }
lon += s1 * (2.0 / 24.0);
lat += s2 * (1.0 / 24.0);
lonSize = 2.0 / 24.0;
latSize = 1.0 / 24.0;
}
if (n >= 8) {
if (!isdigit((unsigned char)g[6]) || !isdigit((unsigned char)g[7])) { return false; }
lon += (g[6] - '0') * (2.0 / 240.0);
lat += (g[7] - '0') * (1.0 / 240.0);
lonSize = 2.0 / 240.0;
latSize = 1.0 / 240.0;
}
// Report the CENTER of the square, not its south-west corner. Using the
// corner biases every distance by up to half a square, which at 4-character
// precision is about 60 km.
*outLon = lon + lonSize / 2.0 - 180.0;
*outLat = lat + latSize / 2.0 - 90.0;
return true;
}
void hammy_great_circle(double lat1, double lon1, double lat2, double lon2,
double* outDistKm, double* outBearingDeg) {
double p1 = deg_to_rad(lat1);
double p2 = deg_to_rad(lat2);
double dp = p2 - p1;
double dl = deg_to_rad(lon2 - lon1);
if (outDistKm) {
// Haversine. The spherical law of cosines is shorter but loses precision
// for short distances, which is exactly the "how far to the next town"
// case people try first.
double a = sin(dp / 2.0) * sin(dp / 2.0)
+ cos(p1) * cos(p2) * sin(dl / 2.0) * sin(dl / 2.0);
if (a > 1.0) { a = 1.0; }
*outDistKm = 2.0 * HAMMY_EARTH_RADIUS_KM * asin(sqrt(a));
}
if (outBearingDeg) {
double y = sin(dl) * cos(p2);
double x = cos(p1) * sin(p2) - sin(p1) * cos(p2) * cos(dl);
double b = rad_to_deg(atan2(y, x));
*outBearingDeg = fmod(b + 360.0, 360.0);
}
}
double hammy_long_path_km(double shortPathKm) {
return HAMMY_EARTH_CIRCUM_KM - shortPathKm;
}
double hammy_reciprocal_bearing(double bearingDeg) {
return fmod(bearingDeg + 180.0, 360.0);
}
const char* hammy_compass_point(double bearingDeg) {
static const char* POINTS[16] = {
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
};
double b = fmod(bearingDeg + 360.0, 360.0);
int idx = (int)floor((b + 11.25) / 22.5) % 16;
return POINTS[idx];
}
#pragma clang diagnostic pop
+80 -18
View File
@@ -4,7 +4,10 @@
#include <stdlib.h>
#include <string.h>
#include <hammy/bot.h>
#include <hammy/command.h>
#include <hammy/job.h>
#include <hammy/embeds.h>
// strdup() is POSIX, so we'll keep a local and keep the code portable.
static char* hammy_strdup(const char* src) {
@@ -19,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) {
@@ -28,17 +42,19 @@ static u64snowflake hammy_job_extract_user(const struct discord_interaction* eve
return 0;
}
hammy_job_t* hammy_job_create(struct discord* client, const struct discord_interaction* event) {
if (!client || !event) { return NULL; }
hammy_job_t* hammy_job_create(hammy_bot_t* bot, const struct discord_interaction* event) {
if (!bot || !bot->client || !event) { return NULL; }
hammy_job_t* job = (hammy_job_t*)calloc(1, sizeof(*job));
if (!job) { return NULL; }
job->bot = bot;
job->id = event->id;
job->appId = event->application_id;
job->user = hammy_job_extract_user(event);
job->token = hammy_strdup(event->token);
job->queuedAt = (int64_t)discord_timestamp(client);
job->queuedAt = (int64_t)discord_timestamp(bot->client);
job->appId = event->application_id ? event->application_id : bot->appId;
if (!job->token) {
goto fail;
@@ -121,27 +137,73 @@ int64_t hammy_job_age_ms(const hammy_job_t* job, struct discord* client) {
return (int64_t)discord_timestamp(client) - job->queuedAt;
}
void hammy_job_reply(const hammy_job_t* job, struct discord* client, const char* content) {
if (!job || !client || !content) { return; }
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; }
// TODO: Embeds
struct discord_edit_original_interaction_response params = {
.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_edit_original_interaction_response(client, job->appId, job->token, &params, NULL);
if (code != CCORD_OK) {
log_warn("[job] Failed to edit 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 {
// Only reachable on a bad argument, but a plain-text body still beats
// sending nothing at all.
params.content = (char*)content;
}
hammy_job_check_send(job, discord_edit_original_interaction_response(client, job->appId, job->token, &params, NULL));
}
void hammy_job_respond(const hammy_job_t* job, struct discord* client, const char* title, const char* content, bool isError) {
if (!job || !client || !content || !title) { return; }
struct discord_embed embed[1];
if (hammy_embeds_customembed(client, embed, 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){
.embeds = &(struct discord_embeds){
.size = 1,
.array = embed
}
}
};
hammy_job_check_send(job, discord_create_interaction_response(client, job->id, job->token, &params, NULL));
} else {
struct discord_interaction_response params = {
.type = DISCORD_INTERACTION_CHANNEL_MESSAGE_WITH_SOURCE,
.data = &(struct discord_interaction_callback_data){
.content = (char*)content
}
};
hammy_job_check_send(job, discord_create_interaction_response(client, job->id, job->token, &params, NULL));
}
}
void hammy_job_run(hammy_job_t* job, struct discord* client) {
void hammy_job_run(hammy_job_t* job, struct discord* client, hammy_refdb_t* refdb) {
if (!job || !client) { return; }
if (!refdb) {
// TODO: Consider making this a hard-fail
log_warn("[job] Ran command '%s' for interaction %" PRIu64 " without a valid refdb! SQLite functions will be unavailable!", job->command ? job->command : "unknown", job->id);
}
// TODO: look job->command up in the bot's command vector and call its
// handler with (job, client). Placeholder until command.h grows a
// dispatch entry point.
log_info("[job] Running command '%s' for interaction %" PRIu64, job->command ? job->command : "unknown", job->id);
const hammy_command_t* command = hammy_bot_find_command(job->bot, job->command);
if (!command || !command->handler) {
log_warn("[job] No handler found for command '%s'.", job->command ? job->command : "unknown");
hammy_job_reply(job, client, "Unknown Command", "I don't know that command!", true);
return;
}
hammy_job_reply(job, client, "This is a placeholder reply. The command handler is not yet implemented.");
command->handler(job, client, refdb);
}
+26 -2
View File
@@ -2,6 +2,7 @@
#include <concord/log.h>
#include <stdlib.h>
#include <hammy/bot.h>
#include <hammy/job.h>
#include <hammy/pool.h>
#include <hammy/worker.h>
@@ -104,13 +105,22 @@ static void hammy_pool_stop(hammy_pool_t* pool, bool drain) {
pool->shutdown = true;
if (!drain) {
// Detach the queue under the lock, but apologise outside it - a REST
// enqueue has no business happening while the workers are blocked on it.
hammy_job_t** dropped = NULL;
size_t nDropped = 0;
if (!drain && pool->count > 0) {
dropped = (hammy_job_t**)calloc(pool->count, sizeof(*dropped));
while (pool->count > 0) {
hammy_job_t* job = pool->jobs[pool->head];
pool->head = (pool->head + 1) % pool->cap;
pool->count--;
hammy_job_destroy(&job);
// Out of memory on the way out is not worth a leak, so drop it quietly.
if (dropped) { dropped[nDropped++] = job; }
else { hammy_job_destroy(&job); }
}
}
@@ -118,6 +128,20 @@ static void hammy_pool_stop(hammy_pool_t* pool, bool drain) {
pthread_cond_broadcast(&pool->notEmpty);
pthread_mutex_unlock(&pool->lock);
for (size_t i = 0; i < nDropped; i++) {
hammy_job_t* job = dropped[i];
// The job's bot back-reference carries the original client, which is
// the one this thread is allowed to serialise through.
if (job->bot && job->bot->client) {
hammy_job_reply(job, job->bot->client, "Bot is Shutting Down", "Hammy is shutting down, so this command was dropped. Please try again once it is back.", true);
}
hammy_job_destroy(&job);
}
free(dropped);
for (size_t i = 0; i < pool->nWorkers; i++) {
hammy_worker_join(&pool->workers[i]);
}
+850
View File
@@ -0,0 +1,850 @@
#include <concord/log.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include <hammy/refdb.h>
// Whole-callsign rules (cty.dat's "=CALL" entries) must match the entire string.
static const char SQL_EXACT[] =
"SELECT e.id, e.name, e.continent,"
" COALESCE(p.cq_zone, e.cq_zone), COALESCE(p.itu_zone, e.itu_zone),"
" e.latitude, e.longitude, e.utc_offset, p.prefix"
" FROM dxcc_prefixes p JOIN dxcc_entities e ON e.id = p.entity_id"
" WHERE p.prefix = ?1 AND p.exact = 1"
" LIMIT 1";
// One candidate at a time, called longest-first by the caller.
static const char SQL_PREFIX[] =
"SELECT e.id, e.name, e.continent,"
" COALESCE(p.cq_zone, e.cq_zone), COALESCE(p.itu_zone, e.itu_zone),"
" e.latitude, e.longitude, e.utc_offset, p.prefix"
" FROM dxcc_prefixes p JOIN dxcc_entities e ON e.id = p.entity_id"
" WHERE p.prefix = ?1 AND p.exact = 0"
" LIMIT 1";
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";
// Band plus per-class privileges in one pass.
//
// Both LEFT JOINs matter. They are what makes "Technician: not permitted here"
// appear as a row rather than vanishing - a user excluded from a segment needs
// to be told so, not shown a shorter list.
//
// Boundary convention differs between the tables on purpose:
// bands inclusive at both ends. 14.350 is still "20m".
// band_segments half-open [low, high). 14.150 is the START of the phone
// segment, not the end of the CW one. BETWEEN would match
// both and the command would print contradictory modes.
static const char SQL_FREQ_MAIN[] =
"SELECT b.name, b.edge_low_hz, b.edge_high_hz,"
" lc.code, lc.name, lc.rank,"
" s.modes, s.low_hz, s.high_hz, s.max_power_w, s.notes"
" FROM bands b"
" LEFT JOIN license_classes lc"
" ON lc.country = ?2"
" LEFT JOIN band_segments s"
" ON s.band_id = b.id"
" AND s.class_id = lc.id"
" AND s.country = ?2"
" AND s.low_hz <= ?1"
" AND ?1 < s.high_hz"
" WHERE b.edge_low_hz <= ?1 AND ?1 <= b.edge_high_hz"
" ORDER BY lc.rank DESC, s.low_hz";
// Regional allocation, independent of national licensing. country = '' and
// class_id IS NULL mark these rows: they say what the band IS in a region, not
// who may use it.
static const char SQL_FREQ_IARU[] =
"SELECT s.iaru_region, s.low_hz, s.high_hz, s.modes"
" FROM band_segments s"
" WHERE s.country = '' AND s.class_id IS NULL"
" AND s.low_hz <= ?1 AND ?1 < s.high_hz"
" ORDER BY s.iaru_region";
// min() with two arguments is SQLite's scalar min, not the aggregate.
static const char SQL_FREQ_NEAREST[] =
"SELECT name, edge_low_hz, edge_high_hz,"
" min(abs(edge_low_hz - ?1), abs(edge_high_hz - ?1))"
" FROM bands ORDER BY 4 LIMIT 1";
// Is the frequency exactly on a segment boundary for this country?
static const char SQL_FREQ_SEG_EDGE[] =
"SELECT 1 FROM band_segments"
" WHERE country = ?2 AND (low_hz = ?1 OR high_hz = ?1) LIMIT 1";
static const char SQL_COUNTRY_LIST[] =
"SELECT DISTINCT country FROM license_classes"
" WHERE country <> '' ORDER BY country";
// Does the bundle carry licence data for this country at all? Only US is seeded
// so far, so "no data yet" is the common answer and needs saying properly.
static const char SQL_COUNTRY_KNOWN[] =
"SELECT 1 FROM license_classes WHERE country = ?1 LIMIT 1";
// Resolve Q-Code to question/answer
static const char SQL_QCODE[] =
"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";
// Resolve abbr to meaning
static const char SQL_ABBR[] =
"SELECT meaning, context FROM abbreviations WHERE abbr = UPPER(?1) LIMIT 1";
// Authorizer: the connection may read, and nothing else.
//
// SQLITE_OPEN_READONLY protects the MAIN database file. It does not stop
// "ATTACH DATABASE 'somewhere.db' AS x" - an attached database is a separate
// file with its own flags, so a read-only connection is not automatically a
// read-only process. SQLITE_LIMIT_ATTACHED below closes that, and this
// authorizer closes it again, because defence in depth costs nothing here.
static int hammy_refdb_authorizer(void* unused, int action,
const char* a1, const char* a2,
const char* dbname, const char* trigger) {
(void)unused; (void)a1; (void)a2; (void)dbname; (void)trigger;
switch (action) {
case SQLITE_SELECT:
case SQLITE_READ:
case SQLITE_FUNCTION: // COALESCE, length(), upper() and friends
return SQLITE_OK;
default:
// INSERT, UPDATE, DELETE, ATTACH, DETACH, CREATE/DROP anything,
// PRAGMA, transactions - all refused at prepare time.
return SQLITE_DENY;
}
}
// Every prepared statement is registered in HAMMY_STATEMENTS below, exactly
// once. open() and close() both walk that table, so adding a statement means
// adding one row - not a struct field plus a prepare call plus a finalize call,
// three places that can silently drift apart.
//
// That drift is what produced the stCountryKnown crash: the field and the SQL
// existed, the prepare didn't, and sqlite3_clear_bindings() dereferenced a NULL
// left over from calloc(). Now it's impossible to reach a slot except through
// the table.
// NOTE: the SQL_* constants above are declared `static const char X[]`, not
// `static const char* X`. A pointer variable is not a constant expression, so
// using one in this table's static initializer is a compile error
// ("initializer element is not constant"). An array's address is a link-time
// constant and works. Don't "tidy" them back into pointers.
typedef struct {
const char* name; // for log messages
size_t offset; // into struct hammy_refdb_t
const char* sql;
} hammy_stmt_def_t;
#define HAMMY_STMT(field, sqlConst) \
{ #field, offsetof(struct hammy_refdb_t, field), sqlConst }
static const hammy_stmt_def_t HAMMY_STATEMENTS[] = {
HAMMY_STMT(stExact, SQL_EXACT),
HAMMY_STMT(stPrefix, SQL_PREFIX),
HAMMY_STMT(stMorse, SQL_MORSE),
HAMMY_STMT(stQCode, SQL_QCODE),
HAMMY_STMT(stPhonetic, SQL_PHONETIC),
HAMMY_STMT(stAbbr, SQL_ABBR),
HAMMY_STMT(stFreqMain, SQL_FREQ_MAIN),
HAMMY_STMT(stFreqIaru, SQL_FREQ_IARU),
HAMMY_STMT(stFreqNearest, SQL_FREQ_NEAREST),
HAMMY_STMT(stFreqSegEdge, SQL_FREQ_SEG_EDGE),
HAMMY_STMT(stCountryKnown, SQL_COUNTRY_KNOWN),
HAMMY_STMT(stCountryList, SQL_COUNTRY_LIST),
};
#define HAMMY_STMT_COUNT (sizeof(HAMMY_STATEMENTS) / sizeof(HAMMY_STATEMENTS[0]))
static sqlite3_stmt** stmt_slot(hammy_refdb_t* db, const hammy_stmt_def_t* def) {
return (sqlite3_stmt**)((char*)db + def->offset);
}
// sqlite3_reset(NULL) is harmless, but sqlite3_clear_bindings(NULL) dereferences
// straight away. Query entry points check their statements before touching them,
// so a half-built refdb returns false instead of taking the process down.
static bool stmts_ready(const char* where, sqlite3_stmt* const* stmts, size_t count) {
for (size_t i = 0; i < count; i++) {
if (!stmts[i]) {
log_error("[refdb] %s called with unprepared statements", where);
return false;
}
}
return true;
}
static bool prepare(sqlite3* h, const char* sql, sqlite3_stmt** out) {
if (sqlite3_prepare_v2(h, sql, -1, out, NULL) != SQLITE_OK) {
log_error("[refdb] prepare failed: %s", sqlite3_errmsg(h));
log_error("[refdb] sql: %s", sql);
// prepare_v2 doesn't reliably clear this on every error path, and a
// garbage pointer would sail past the NULL checks below.
*out = NULL;
return false;
}
return true;
}
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wjump-misses-init" // suppresses the "jumped initialization of varaible" warning, has no effect here
hammy_refdb_t* hammy_refdb_open(const char* path) {
if (!path) { return NULL; }
hammy_refdb_t* db = (hammy_refdb_t*)calloc(1, sizeof(*db));
if (!db) { return NULL; }
// immutable=1 promises SQLite the fill won't change while it's open. Skips locks and change-counter checks
// Faster and stricter than plain read-only
char uri[1024];
snprintf(uri, sizeof(uri), "file:%s?mode=ro&immutable=1", path);
// NOMUTEX - exactly one thread uses this handle, avoid serialization overhead. Safe only because of the one-per-thread rule.
int flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_URI;
if (sqlite3_open_v2(uri, &db->handle, flags, NULL) != SQLITE_OK) {
log_error("[refdb] cannot open %s: %s", path, db->handle ? sqlite3_errmsg(db->handle) : "out of memory");
goto fail;
}
// No ATTACH; closes a way a read-only connection could still open writable files.
sqlite3_limit(db->handle, SQLITE_LIMIT_ATTACHED, 0);
// Bound the damage of a pathological query. Turns a runaway into an error instead of a stall. Keeps plenty of headroom for actual lookups.
sqlite3_limit(db->handle, SQLITE_LIMIT_SQL_LENGTH, 8192);
sqlite3_limit(db->handle, SQLITE_LIMIT_EXPR_DEPTH, 100);
sqlite3_limit(db->handle, SQLITE_LIMIT_LIKE_PATTERN_LENGTH, 256);
sqlite3_limit(db->handle, SQLITE_LIMIT_VARIABLE_NUMBER, 32);
// Refuse schema-corrupting tricks (e.g. PRAGMA writable_schema) and stop the schema itself from invoking unvetted functions.
sqlite3_db_config(db->handle, SQLITE_DBCONFIG_DEFENSIVE, 1, NULL);
sqlite3_db_config(db->handle, SQLITE_DBCONFIG_TRUSTED_SCHEMA, 0, NULL);
// The bundle is small enough to just map memory-whole. Avoids a read() per page. Set before authorizer since it denies PRAGMA.
sqlite3_exec(db->handle, "PRAGMA mmap_size = 67108864;", NULL, NULL, NULL);
// TODO: PLEASE chmod 444 OR SOMETHING AND DON'T RUN AS THE SQLITE DB FILE OWNER - THAT'S THE ONLY REAL PROTECTION
sqlite3_set_authorizer(db->handle, &hammy_refdb_authorizer, NULL);
for (size_t i = 0; i < HAMMY_STMT_COUNT; i++) {
const hammy_stmt_def_t* def = &HAMMY_STATEMENTS[i];
if (!prepare(db->handle, def->sql, stmt_slot(db, def))) {
log_error("[refdb] statement '%s' failed to prepare", def->name);
goto fail;
}
}
// Belt and braces. A registry that drifts from the struct would otherwise
// leave a NULL slot that crashes inside SQLite on first use, far from the
// cause. Fail here instead, naming the statement.
for (size_t i = 0; i < HAMMY_STMT_COUNT; i++) {
if (!(*stmt_slot(db, &HAMMY_STATEMENTS[i]))) {
log_error("[refdb] statement '%s' is NULL after prepare", HAMMY_STATEMENTS[i].name);
goto fail;
}
}
sqlite3_stmt* st = NULL;
if (prepare(db->handle, SQL_VERSION, &st)) {
if (sqlite3_step(st) == SQLITE_ROW) {
const unsigned char* v = sqlite3_column_text(st, 0);
if (v) {
snprintf(db->version, sizeof(db->version), "%s", (const char*)v);
}
}
sqlite3_finalize(st);
}
return db;
fail:
hammy_refdb_close(&db);
return NULL;
}
#pragma GCC diagnostic pop
bool hammy_refdb_close(hammy_refdb_t** db) {
if (!db || !(*db)) { return false; }
hammy_refdb_t* d = *db;
// Statements must be finalized before the connection closes, or we're in deep shit
// (sqlite3_close() returns SQLITE_BUSY and the handle leaks)
for (size_t i = 0; i < HAMMY_STMT_COUNT; i++) {
sqlite3_stmt** slot = stmt_slot(d, &HAMMY_STATEMENTS[i]);
if (*slot) {
sqlite3_finalize(*slot);
*slot = NULL;
}
}
if (d->handle) { sqlite3_close(d->handle); }
free(d);
*db = NULL;
return true;
}
const char* hammy_refdb_version(hammy_refdb_t* db) {
return (db && db->version[0]) ? db->version : NULL;
}
// Uppercase, strip whitespace, keep only characters that appear in callsigns (voodoo).
static void normalise(const char* in, char* out, size_t cap) {
size_t j = 0;
for (size_t i = 0; in[i] && j + 1 < cap; i++) {
unsigned char c = (unsigned char)in[i];
if (c >= 'a' && c <= 'z') { c = (unsigned char)(c - 'a' + 'A'); }
if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '/') {
out[j++] = (char)c;
}
}
out[j] = '\0';
}
static void fill(sqlite3_stmt* st, hammy_dxcc_t* out, bool exact) {
out->entityId = sqlite3_column_int(st, 0);
const unsigned char* name = sqlite3_column_text(st, 1);
const unsigned char* cont = sqlite3_column_text(st, 2);
const unsigned char* pfx = sqlite3_column_text(st, 8);
snprintf(out->name, sizeof(out->name), "%s", name ? (const char*)name : "");
snprintf(out->continent, sizeof(out->continent), "%s", cont ? (const char*)cont : "");
snprintf(out->matchedPrefix, sizeof(out->matchedPrefix), "%s", pfx ? (const char*)pfx : "");
out->cqZone = sqlite3_column_int(st, 3);
out->ituZone = sqlite3_column_int(st, 4);
out->latitude = sqlite3_column_double(st, 5);
out->longitude = sqlite3_column_double(st, 6);
out->utcOffset = sqlite3_column_double(st, 7);
out->exact = exact;
}
// Runs one candidate against a prepared statement. Returns true on a hit.
static bool try_one(sqlite3_stmt* st, const char* candidate, size_t len, hammy_dxcc_t* out, bool exact) {
sqlite3_reset(st);
sqlite3_clear_bindings(st);
sqlite3_bind_text(st, 1, candidate, (int)len, SQLITE_STATIC);
bool hit = (sqlite3_step(st) == SQLITE_ROW);
if (hit) { fill(st, out, exact); }
sqlite3_reset(st);
return hit;
}
// Longest-first prefix search over one string.
static bool search_prefixes(hammy_refdb_t* db, const char* call, hammy_dxcc_t* out) {
size_t len = strlen(call);
for (size_t n = len; n > 0; n--) {
if (try_one(db->stPrefix, call, n, out, false)) { return true; }
}
return false;
}
bool hammy_refdb_dxcc(hammy_refdb_t* db, const char* callsign, hammy_dxcc_t* out) {
if (!db || !callsign || !out) { return false; }
sqlite3_stmt* const needed[] = { db->stExact, db->stPrefix };
if (!stmts_ready("dxcc", needed, 2)) { return false; }
char call[HAMMY_CALLSIGN_MAX];
normalise(callsign, call, sizeof(call));
if (!call[0]) { return false; }
memset(out, 0, sizeof(*out));
// 1. Whole-callsign rules win outright. cty.dat carries a couple of thousand
// of these for stations that do not follow their entity's prefix pattern.
if (try_one(db->stExact, call, strlen(call), out, true))
return true;
// 2. Portable designators. "DL/W1AW" is Germany, "W1AW/4" is still the US.
// The rule of thumb is that the SHORTER side of the slash is the location
// indicator, and a purely numeric tail is a call-area change rather than
// an entity change.
//
// This is a heuristic, not a specification - real callsign parsing has
// genuine ambiguities and every logging program handles them slightly
// differently. Good enough for a lookup command; revisit before using it
// to award DXCC credit. TODO
const char* slash = strchr(call, '/');
if (slash) {
char left[HAMMY_CALLSIGN_MAX] = {0};
char right[HAMMY_CALLSIGN_MAX] = {0};
size_t llen = (size_t)(slash - call);
snprintf(left, sizeof(left), "%.*s", (int)llen, call);
snprintf(right, sizeof(right), "%s", slash + 1);
bool right_numeric = true;
for (const char* p = right; *p; p++) {
if (*p < '0' || *p > '9') {
right_numeric = false;
break;
}
}
// A numeric tail keeps the home entity: try the left side only.
if (right_numeric && right[0]) { return search_prefixes(db, left, out); }
// Otherwise the shorter side is the location indicator.
const char* location = (strlen(right) && strlen(right) < llen) ? right : left;
const char* fallback = (location == right) ? left : right;
if (search_prefixes(db, location, out)) { return true; }
return search_prefixes(db, fallback, out);
}
// 3. Plain callsign: longest prefix wins.
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; }
sqlite3_stmt* const needed[] = { db->stMorse };
if (!stmts_ready("morse", 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->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;
}
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) {
if (sqlite3_column_count(db->stPhonetic) < 2) {
// Error
sqlite3_reset(db->stPhonetic);
return false;
}
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) {
if (!db || !code || !outQuestion || !outAnswer) { return false; }
sqlite3_stmt* const needed[] = { db->stQCode };
if (!stmts_ready("qcodes", needed, 1)) { return false; }
// Read the get_morse comment, I ain't writing this again (entire string edition)
for (const char* p = code; *p != '\0'; p++) {
if ((unsigned char)*p & 0x80u) { return false; }
}
// Query the string
sqlite3_reset(db->stQCode);
sqlite3_clear_bindings(db->stQCode);
sqlite3_bind_text(db->stQCode, 1, code, -1, SQLITE_TRANSIENT); // -1 tells SQLite to figure out the length itself (strlen() call - requires NULL term)
bool hit = false;
if (sqlite3_step(db->stQCode) == SQLITE_ROW) {
if (sqlite3_column_count(db->stQCode) < 2) { // Error
sqlite3_reset(db->stQCode);
return false;
}
const unsigned char* questionStr = sqlite3_column_text(db->stQCode, 0);
const unsigned char* answerStr = sqlite3_column_text(db->stQCode, 1);
if (questionStr) {
snprintf(db->qcodeQuestion, sizeof(db->qcodeQuestion), "%s", (const char*)questionStr);
*outQuestion = db->qcodeQuestion;
hit = true;
}
if (answerStr) {
snprintf(db->qcodeAnswer, sizeof(db->qcodeAnswer), "%s", (const char*)answerStr);
*outAnswer = db->qcodeAnswer;
hit = true;
}
}
sqlite3_reset(db->stQCode);
return hit;
}
bool hammy_refdb_get_abbr(hammy_refdb_t* db, const char* code, const char** outStr, const char** outContext) {
if (!db || !code || !outStr || !outContext) { return false; }
sqlite3_stmt* const needed[] = { db->stAbbr };
if (!stmts_ready("qcodes", needed, 1)) { return false; }
// Read the get_morse comment, I ain't writing this again (entire string edition)
for (const char* p = code; *p != '\0'; p++) {
if ((unsigned char)*p & 0x80u) { return false; }
}
// Query the string
sqlite3_reset(db->stAbbr);
sqlite3_clear_bindings(db->stAbbr);
sqlite3_bind_text(db->stAbbr, 1, code, -1, SQLITE_TRANSIENT); // -1 tells SQLite to figure out the length itself (strlen() call - requires NULL term)
bool hit = false;
if (sqlite3_step(db->stAbbr) == SQLITE_ROW) {
if (sqlite3_column_count(db->stAbbr) < 2) { // Error
sqlite3_reset(db->stAbbr);
return false;
}
const unsigned char* meaningStr = sqlite3_column_text(db->stAbbr, 0);
const unsigned char* ctxStr = sqlite3_column_text(db->stAbbr, 1);
if (meaningStr) {
snprintf(db->abbrStr, sizeof(db->abbrStr), "%s", (const char*)meaningStr);
*outStr = db->abbrStr;
hit = true;
}
if (ctxStr) {
snprintf(db->abbrCtx, sizeof(db->abbrCtx), "%s", (const char*)ctxStr);
*outContext = db->abbrCtx;
hit = true;
}
}
sqlite3_reset(db->stAbbr);
return hit;
}
// ---------------------------------------------------------------------------
// Frequency parsing
// ---------------------------------------------------------------------------
// Copies a TEXT column into a fixed buffer, tolerating NULL.
static void copy_text(sqlite3_stmt* st, int col, char* dst, size_t cap) {
const unsigned char* v = sqlite3_column_text(st, col);
snprintf(dst, cap, "%s", v ? (const char*)v : "");
}
bool hammy_freq_parse(const char* text, int64_t* outHz) {
if (!text || !outHz) { return false; }
while (*text == ' ' || *text == '\t') { text++; }
// Integer part.
int64_t whole = 0;
bool anyDigit = false;
while (*text >= '0' && *text <= '9') {
if (whole > INT64_MAX / 10 - 9) { return false; } // absurd input
whole = whole * 10 + (*text - '0');
anyDigit = true;
text++;
}
// Fractional part, accumulated as digits rather than a double. Six decimal
// places of MHz is exactly 1 Hz, so anything beyond that is discarded.
int64_t frac = 0;
int fracDigits = 0;
if (*text == '.' || *text == ',') {
text++;
while (*text >= '0' && *text <= '9') {
if (fracDigits < 9) {
frac = frac * 10 + (*text - '0');
fracDigits++;
}
anyDigit = true;
text++;
}
}
if (!anyDigit) { return false; }
while (*text == ' ' || *text == '\t') { text++; }
// Unit. A bare number means MHz, which is what people type.
int64_t mult = 1000000; // Hz per unit
if (*text) {
char unit[8] = {0};
size_t i = 0;
while (text[i] && i + 1 < sizeof(unit) && text[i] != ' ') {
char c = text[i];
unit[i] = (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
i++;
}
if (!strcmp(unit, "hz")) { mult = 1; }
else if (!strcmp(unit, "khz") || !strcmp(unit, "k")) { mult = 1000; }
else if (!strcmp(unit, "mhz") || !strcmp(unit, "m")) { mult = 1000000; }
else if (!strcmp(unit, "ghz") || !strcmp(unit, "g")) { mult = 1000000000; }
else { return false; }
}
// Scale the fraction to the unit without ever touching a double.
int64_t scale = 1;
for (int i = 0; i < fracDigits; i++) {
if (scale > INT64_MAX / 10) { return false; }
scale *= 10;
}
if (whole > INT64_MAX / mult) { return false; }
int64_t hz = whole * mult + (frac * mult) / scale;
if (hz <= 0 || hz > 300000000000LL) { return false; } // 1 Hz .. 300 GHz
*outHz = hz;
return true;
}
// ---------------------------------------------------------------------------
// Frequency lookup
// ---------------------------------------------------------------------------
static void normalise_country(const char* in, char* out, size_t cap) {
size_t j = 0;
if (!in || !*in) {
snprintf(out, cap, "US"); // default
return;
}
for (size_t i = 0; in[i] && j + 1 < cap; i++) {
char c = in[i];
if (c >= 'a' && c <= 'z') { c = (char)(c - 'a' + 'A'); }
if (c >= 'A' && c <= 'Z') { out[j++] = c; }
}
out[j] = '\0';
if (!out[0]) { snprintf(out, cap, "US"); }
}
static bool step_bool(sqlite3_stmt* st) {
bool got = (sqlite3_step(st) == SQLITE_ROW);
sqlite3_reset(st);
return got;
}
bool hammy_refdb_freq(hammy_refdb_t* db, int64_t freqHz, const char* country,
hammy_freq_t* out) {
if (!db || !out || freqHz <= 0) { return false; }
sqlite3_stmt* const needed[] = {
db->stFreqMain, db->stFreqIaru, db->stFreqNearest,
db->stFreqSegEdge, db->stCountryKnown
};
if (!stmts_ready("freq", needed, 5)) { return false; }
memset(out, 0, sizeof(*out));
out->freqHz = freqHz;
normalise_country(country, out->country, sizeof(out->country));
// Does the bundle know this country at all? Only US privileges are seeded so
// far, so this is the common path and the message matters.
sqlite3_reset(db->stCountryKnown);
sqlite3_clear_bindings(db->stCountryKnown);
sqlite3_bind_text(db->stCountryKnown, 1, out->country, -1, SQLITE_TRANSIENT);
out->countryKnown = step_bool(db->stCountryKnown);
// Band and privileges.
sqlite3_stmt* st = db->stFreqMain;
sqlite3_reset(st);
sqlite3_clear_bindings(st);
sqlite3_bind_int64(st, 1, freqHz);
sqlite3_bind_text(st, 2, out->country, -1, SQLITE_TRANSIENT);
while (sqlite3_step(st) == SQLITE_ROW) {
if (!out->inBand) {
out->inBand = true;
copy_text(st, 0, out->band, sizeof(out->band));
out->bandLowHz = sqlite3_column_int64(st, 1);
out->bandHighHz = sqlite3_column_int64(st, 2);
out->atBandEdge = (freqHz == out->bandLowHz ||
freqHz == out->bandHighHz);
}
// A NULL class means the country has no licence classes at all; the row
// still carried the band, which is why it is read above first.
if (sqlite3_column_type(st, 3) == SQLITE_NULL) { continue; }
if (out->nPrivs >= HAMMY_FREQ_PRIVS_MAX) { continue; }
hammy_freq_priv_t* p = &out->privs[out->nPrivs++];
copy_text(st, 3, p->code, sizeof(p->code));
copy_text(st, 4, p->name, sizeof(p->name));
p->rank = sqlite3_column_int(st, 5);
p->permitted = (sqlite3_column_type(st, 6) != SQLITE_NULL);
if (p->permitted) {
copy_text(st, 6, p->modes, sizeof(p->modes));
p->segLowHz = sqlite3_column_int64(st, 7);
p->segHighHz = sqlite3_column_int64(st, 8);
p->maxPowerW = sqlite3_column_int(st, 9); // 0 when NULL
copy_text(st, 10, p->notes, sizeof(p->notes));
}
}
sqlite3_reset(st);
// Not in any band: report the nearest one so the user can see how far off
// they are. Usually a typo - 15.000 instead of 14.150.
if (!out->inBand) {
st = db->stFreqNearest;
sqlite3_reset(st);
sqlite3_clear_bindings(st);
sqlite3_bind_int64(st, 1, freqHz);
if (sqlite3_step(st) == SQLITE_ROW) {
copy_text(st, 0, out->nearestBand, sizeof(out->nearestBand));
out->nearestLowHz = sqlite3_column_int64(st, 1);
out->nearestHighHz = sqlite3_column_int64(st, 2);
out->nearestDistanceHz = sqlite3_column_int64(st, 3);
}
sqlite3_reset(st);
return true;
}
// IARU regional allocations. Independent of country, so worth showing even
// when the privilege table is empty.
st = db->stFreqIaru;
sqlite3_reset(st);
sqlite3_clear_bindings(st);
sqlite3_bind_int64(st, 1, freqHz);
while (sqlite3_step(st) == SQLITE_ROW && out->nIaru < HAMMY_FREQ_IARU_MAX) {
hammy_freq_iaru_t* r = &out->iaru[out->nIaru++];
r->region = sqlite3_column_int(st, 0);
r->lowHz = sqlite3_column_int64(st, 1);
r->highHz = sqlite3_column_int64(st, 2);
copy_text(st, 3, r->modes, sizeof(r->modes));
}
sqlite3_reset(st);
// Sitting exactly on a segment boundary is worth flagging: a signal of any
// width centerd there straddles both sides.
st = db->stFreqSegEdge;
sqlite3_reset(st);
sqlite3_clear_bindings(st);
sqlite3_bind_int64(st, 1, freqHz);
sqlite3_bind_text(st, 2, out->country, -1, SQLITE_TRANSIENT);
out->atSegmentEdge = step_bool(st);
return true;
}
size_t hammy_refdb_countries(hammy_refdb_t* db,
char out[][HAMMY_COUNTRY_MAX], size_t cap) {
if (!db || !out || cap == 0 || !db->stCountryList) { return 0; }
size_t n = 0;
sqlite3_reset(db->stCountryList);
while (n < cap && sqlite3_step(db->stCountryList) == SQLITE_ROW) {
copy_text(db->stCountryList, 0, out[n++], HAMMY_COUNTRY_MAX);
}
sqlite3_reset(db->stCountryList);
return n;
}
+13
View File
@@ -0,0 +1,13 @@
#include <hammy/utils.h>
void hammy_to_uppercase(char* str) {
for (size_t i = 0; str[i] != '\0'; i++) {
str[i] = (char)toupper((unsigned char)str[i]);
}
}
void hammy_to_lowercase(char* str) {
for (size_t i = 0; str[i] != '\0'; i++) {
str[i] = (char)tolower((unsigned char)str[i]);
}
}
+35 -7
View File
@@ -1,4 +1,5 @@
#include <concord/discord.h>
#include <concord/discord-internal.h>
#include <concord/log.h>
#include <stdlib.h>
@@ -6,6 +7,20 @@
#include <hammy/pool.h>
#include <hammy/worker.h>
// Workaround for a concord 3.0.1 bug. _discord_clone_gateway_cleanup()
// (discord-client.c:833) frees payload.json.table and then frees payload.data
// as well, but payload.data is a jsmnf_pair pointing INTO that table, not a
// separate allocation. Every discord_cleanup() of a clone is therefore an
// invalid free. Clearing the field first skips that free; the table it points
// into is still released, so nothing leaks.
static void hammy_worker_cleanup_clone(struct discord* clone) {
if (!clone) { return; }
clone->gw.payload.data = NULL;
discord_cleanup(clone);
}
static void* hammy_worker_main(void* arg) {
hammy_worker_t* worker = (hammy_worker_t*)arg;
hammy_pool_t* pool = worker->pool;
@@ -33,14 +48,14 @@ static void* hammy_worker_main(void* arg) {
pthread_mutex_unlock(&pool->lock);
// From here, the worker owns the job and is responsible for destroying it.
log_info("[worker %d] Processing job %llu from user %llu", worker->id, job->id, job->user);
log_info("[worker %d] Processing job %lu from user %lu", worker->id, job->id, job->user);
int64_t age = hammy_job_age_ms(job, worker->clientCopy);
if (age > HAMMY_JOB_MAX_AGE_MS) {
log_warn("[worker %d] Dropping stale job '%s' (age %lld ms)", worker->id, job->command ? job->command : "unknown", (long long)age);
hammy_job_reply(job, worker->clientCopy, "Sorry, your command took too long to process and was dropped. Please try again.");
hammy_job_reply(job, worker->clientCopy, "Command Timeout", "Sorry, your command took too long to process and was dropped. Please try again.", true);
} else {
hammy_job_run(job, worker->clientCopy);
hammy_job_run(job, worker->clientCopy, worker->refdb);
}
hammy_job_destroy(&job);
@@ -63,17 +78,26 @@ bool hammy_worker_start(hammy_worker_t* worker, hammy_pool_t* pool, struct disco
worker->clientCopy = NULL;
worker->started = false;
// According to the concord spec, each thread must have its own discord client, so we clone it here.
// However, concord's buffers, URLs, headers, etc. are NOT shared-safe. They're per-client.
// Each thread needs its own client. The queues, pollers and timers are all
// shared through pointers, but client->registry - the reflect-c registry
// that serialises request bodies on the calling thread - is not.
// discord_clone() copies the gateway's *current* payload, so this only
// works while a dispatch is in flight. See hammy_pool_create().
worker->clientCopy = discord_clone(client);
if (!worker->clientCopy) {
log_error("[worker %d] Failed to clone client", id);
return false;
}
// Open a reference to sqlite
worker->refdb = hammy_refdb_open("hammy-ref.sqlite"); // TODO: Probably don't hardcode this?
if (!worker->refdb) {
log_error("[worker %d] Failed to open ref to sqlite. Commands requiring it will be unavailable!", id); // TODO: Consider making this a hard-fail
}
if (pthread_create(&worker->thread, NULL, &hammy_worker_main, worker) != 0) {
log_error("[worker %d] Failed to create thread", id);
discord_cleanup(worker->clientCopy);
hammy_worker_cleanup_clone(worker->clientCopy);
worker->clientCopy = NULL;
return false;
@@ -93,7 +117,11 @@ void hammy_worker_join(hammy_worker_t* worker) {
}
if (worker->clientCopy) {
discord_cleanup(worker->clientCopy);
hammy_worker_cleanup_clone(worker->clientCopy);
worker->clientCopy = NULL;
}
if (worker->refdb) {
hammy_refdb_close(&worker->refdb);
}
}
+69 -21
View File
@@ -1,44 +1,92 @@
#include <string.h>
#include <signal.h>
#include <concord/discord.h>
#include <concord/log.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <hammy/bot.h>
#include <hammy/pool.h>
static void on_signal(int sig) {
#define HAMMY_CONFIG_PATH "config.json"
static hammy_bot_t* g_bot = NULL;
static void hammy_on_sigint(int sig) {
(void)sig;
discord_shutdown_all();
// Only sets a flag inside Concord and makes discord_run() return. The real
// teardown happens back in main().
if (g_bot && g_bot->client) discord_shutdown(g_bot->client);
}
void on_ready(struct discord* client, const struct discord_ready* event) {
(void)client;
log_info("[master] Logged in as %s", event->user->username);
static void hammy_on_ready(struct discord* client, const struct discord_ready* event) {
hammy_bot_t* bot = (hammy_bot_t*)discord_get_data(client);
if (!bot) return;
log_info("[hammy] logged in as %s", event->user->username);
// The application id is only available now, and both registration and
// response editing need it.
bot->appId = event->application->id;
//hammy_bot_deregister_all_commands(bot);
// discord_clone() deep-copies the gateway's *current* payload, so it only
// succeeds from inside a dispatch callback. READY is the first one we get.
if (!hammy_bot_start_pool(bot, 0, 0)) { // 0, 0 = defaults
log_fatal("[hammy] Could not start the worker pool. Shutting down.");
discord_shutdown(client);
return;
}
hammy_bot_register_commands(bot);
}
int main(void) {
struct sigaction sa = { 0 };
sa.sa_handler = &on_signal;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* deliberately no SA_RESTART */
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
ccord_global_init();
hammy_bot_t* bot = hammy_bot_create();
if (!bot) {
log_error("[master] Hammy Bot creation returned NULL! Bailing!");
fprintf(stderr, "could not create bot\n");
ccord_global_cleanup();
return 1;
return EXIT_FAILURE;
}
hammy_bot_set_on_ready(bot, &on_ready);
hammy_bot_run(bot); // This will block and exit afterwards
// Token and logging settings both come out of config.json.
bot->client = discord_config_init(HAMMY_CONFIG_PATH);
if (!bot->client) {
fprintf(stderr, "could not init discord client from %s\n", HAMMY_CONFIG_PATH);
hammy_bot_destroy(&bot);
ccord_global_cleanup();
return EXIT_FAILURE;
}
bool ok = false; // Declared before the gotos so they don't jump over it.
g_bot = bot;
signal(SIGINT, &hammy_on_sigint);
signal(SIGTERM, &hammy_on_sigint);
// Order matters: commands loaded before the ready callback can register
// them. The pool starts from on_ready, not here - see the comment there.
if (!hammy_bot_load_builtins(bot)) goto fail;
if (!hammy_bot_set_on_ready(bot, &hammy_on_ready)) goto fail;
// Blocks until shutdown. A NULL pool afterwards means on_ready bailed out
// before it could start one.
ok = hammy_bot_run(bot) && bot->pool != NULL;
hammy_bot_destroy(&bot);
ccord_global_cleanup();
return 0;
return ok ? EXIT_SUCCESS : EXIT_FAILURE;
fail:
hammy_bot_destroy(&bot);
ccord_global_cleanup();
return EXIT_FAILURE;
}