zynk/cmd_wp.h

429 lines
15 KiB
C

#ifndef CMD_WP_H
#define CMD_WP_H
#include "zynk.h"
/*
* wp_hex_digit - Convert a hexadecimal character to its numeric value.
*
* Parameters:
* c - A character ('0'-'9', 'a'-'f', or 'A'-'F').
*
* Returns: The numeric value (0-15), or -1 if not a valid hex digit.
*/
static int wp_hex_digit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
/*
* wp_codepoint_to_utf8 - Encode a Unicode code point as UTF-8 bytes.
*
* Handles code points from U+0000 through U+10FFFF, producing 1-4 UTF-8
* bytes. Used to decode \uXXXX JSON Unicode escapes in Wikipedia API
* responses into printable UTF-8 text.
*
* Parameters:
* cp - The Unicode code point.
* out - Output buffer for the UTF-8 bytes (at least 4 bytes needed).
*
* Returns: The number of UTF-8 bytes written (1-4).
*/
static size_t wp_codepoint_to_utf8(unsigned int cp, char *out) {
if (cp < 0x80) { out[0] = (char)cp; return 1; }
if (cp < 0x800) { out[0] = (char)(0xC0 | (cp >> 6)); out[1] = (char)(0x80 | (cp & 0x3F)); return 2; }
if (cp < 0x10000) { out[0] = (char)(0xE0 | (cp >> 12)); out[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); out[2] = (char)(0x80 | (cp & 0x3F)); return 3; }
out[0] = (char)(0xF0 | (cp >> 18)); out[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
out[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); out[3] = (char)(0x80 | (cp & 0x3F)); return 4;
}
/*
* wp_url_encode - Percent-encode a string for safe use in a URL query parameter.
*
* Encodes all characters except unreserved characters (alphanumerics, '-', '_',
* '.', '~') using RFC 3986 percent-encoding. Spaces are encoded as '+' for
* application/x-www-form-urlencoded compatibility. Used to encode search
* queries and article titles before embedding them in Wikipedia API URLs.
*
* Parameters:
* src - The input string to encode.
* dst - Output buffer for the encoded string.
* dstsz - Size of the output buffer.
*
* Returns: void (output is written to dst, NUL-terminated).
*/
static void wp_url_encode(const char *src, char *dst, size_t dstsz) {
static const char hex[] = "0123456789ABCDEF";
size_t di = 0;
for (; *src && di + 4 < dstsz; src++) {
unsigned char c = (unsigned char)*src;
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
dst[di++] = c;
} else if (c == ' ') {
dst[di++] = '+';
} else {
if (di + 3 >= dstsz) break;
dst[di++] = '%';
dst[di++] = hex[c >> 4];
dst[di++] = hex[c & 0x0F];
}
}
dst[di] = '\0';
}
/*
* wp_url_encode_title - Percent-encode a Wikipedia article title for use in a URL path.
*
* Like wp_url_encode() but encodes spaces as underscores instead of plus signs,
* matching Wikipedia's URL convention for article titles (e.g. "CLever Audio
* Plug-in" becomes "CLever_Audio_Plug-in" instead of "CLever+Audio+Plug-in").
*
* Parameters:
* src - The article title to encode.
* dst - Output buffer for the encoded string.
* dstsz - Size of the output buffer.
*
* Returns: void (output is written to dst, NUL-terminated).
*/
static void wp_url_encode_title(const char *src, char *dst, size_t dstsz) {
static const char hex[] = "0123456789ABCDEF";
size_t di = 0;
for (; *src && di + 4 < dstsz; src++) {
unsigned char c = (unsigned char)*src;
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
dst[di++] = c;
} else if (c == ' ') {
dst[di++] = '_';
} else {
if (di + 3 >= dstsz) break;
dst[di++] = '%';
dst[di++] = hex[c >> 4];
dst[di++] = hex[c & 0x0F];
}
}
dst[di] = '\0';
}
/*
* wp_decode_json_string - Decode JSON string escape sequences in-place.
*
* Scans a string for JSON backslash escapes and replaces each with the
* corresponding character: \\n and \\t become spaces, \\\" becomes a
* double quote, \\\\ becomes a backslash, \\/ becomes a forward slash,
* and \\uXXXX sequences are decoded to UTF-8 bytes. Processes the input
* from src to dst, which may be the same buffer (safe for in-place
* modification because decoded output is never longer than the escape).
*
* Parameters:
* src - The JSON-encoded input string.
* dst - Output buffer for the decoded string (may alias src).
* dstsz - Size of the output buffer.
*
* Returns: void (output is written to dst, NUL-terminated).
*/
static void wp_decode_json_string(const char *src, char *dst, size_t dstsz) {
size_t di = 0;
while (*src && di + 1 < dstsz) {
if (*src == '\\' && *(src+1)) {
src++;
if (*src == 'n') { dst[di++] = ' '; src++; }
else if (*src == 't') { dst[di++] = ' '; src++; }
else if (*src == '"') { dst[di++] = '"'; src++; }
else if (*src == '\\') { dst[di++] = '\\'; src++; }
else if (*src == '/' ) { dst[di++] = '/'; src++; }
else if (*src == 'u' && strlen(src) >= 5) {
int h1 = wp_hex_digit(src[1]), h2 = wp_hex_digit(src[2]),
h3 = wp_hex_digit(src[3]), h4 = wp_hex_digit(src[4]);
if (h1 >= 0 && h2 >= 0 && h3 >= 0 && h4 >= 0) {
unsigned int cp = ((unsigned int)h1 << 12) | ((unsigned int)h2 << 8) |
((unsigned int)h3 << 4) | (unsigned int)h4;
char utf8[5];
size_t n = wp_codepoint_to_utf8(cp, utf8);
if (di + n < dstsz) { memcpy(dst + di, utf8, n); di += n; }
src += 5;
continue;
} else {
dst[di++] = *src++;
}
} else {
dst[di++] = *src++;
}
} else {
dst[di++] = *src++;
}
}
dst[di] = '\0';
}
/*
* wp_json_get_string - Extract the string value of a JSON key.
*
* Finds the first occurrence of the given key in a JSON string, skips
* past the colon, and reads the double-quoted string value into the
* output buffer. Handles escaped double quotes (\\") inside the value.
* Does not perform full JSON parsing — relies on the flat structure
* of the Wikipedia MediaWiki API responses.
*
* Parameters:
* json - The JSON string to search.
* key - The key to look for (e.g., "\"title\":").
* out - Output buffer for the extracted value.
* outsz - Size of the output buffer.
*
* Returns: Pointer to out on success, NULL if the key or value is not found.
*/
static char *wp_json_get_string(const char *json, const char *key, char *out, size_t outsz) {
const char *p = strstr(json, key);
if (!p) return NULL;
p += strlen(key);
while (*p == ' ' || *p == '\t') p++;
if (*p == ':') { p++; while (*p == ' ' || *p == '\t') p++; }
if (*p != '"') return NULL;
p++;
size_t i = 0;
while (*p && *p != '"' && i + 1 < outsz) {
if (*p == '\\' && *(p+1) == '"') {
out[i++] = '"';
p += 2;
} else {
out[i++] = *p++;
}
}
out[i] = '\0';
return (i > 0) ? out : NULL;
}
/*
* wp_strip_html - Remove HTML tags from a string in-place.
*
* Scans for '<' and '>' characters to identify tags, skipping everything
* between them. Only content outside tags is kept. Used to strip HTML
* markup from Wikipedia search result snippets before displaying them
* to the IRC channel.
*
* Parameters:
* s - The string to strip (modified in-place, NUL-terminated).
*
* Returns: void.
*/
static void wp_strip_html(char *s) {
char *r = s, *w = s;
int in_tag = 0;
while (*r) {
if (*r == '<') { in_tag = 1; r++; continue; }
if (*r == '>') { in_tag = 0; r++; continue; }
if (!in_tag) *w++ = *r;
r++;
}
*w = '\0';
}
/*
* wp_collapse_spaces - Normalize whitespace in a string in-place.
*
* Collapses runs of spaces, tabs, newlines, and carriage returns into a
* single space. Trims leading and trailing whitespace. Produces clean,
* readable text from the output of wp_strip_html, where tag removal
* may leave behind extra whitespace.
*
* Parameters:
* s - The string to normalize (modified in-place, NUL-terminated).
*
* Returns: void.
*/
static void wp_collapse_spaces(char *s) {
char *r = s, *w = s;
int in_space = 1;
while (*r) {
if (*r == ' ' || *r == '\t' || *r == '\n' || *r == '\r') {
if (!in_space) *w++ = ' ';
in_space = 1;
} else {
*w++ = *r;
in_space = 0;
}
r++;
}
*w = 0;
while (w > s && *(w-1) == ' ') *--w = 0;
}
/*
* wp_json_next_array_item - Advance a pointer to the next '{' in a JSON array.
*
* Starting from *cur, finds the next '{' which marks the beginning of the
* next object in a JSON array. Updates *cur to point past that brace so
* subsequent calls continue scanning. Used to iterate over the "search"
* array in the Wikipedia API response.
*
* Parameters:
* cur - Pointer to the current position in the JSON string (in/out).
*
* Returns: Pointer to the '{' of the next array item, or NULL if none found.
*/
static const char *wp_json_next_array_item(const char **cur) {
const char *p = strchr(*cur, '{');
if (p) *cur = p + 1;
return p;
}
/*
* cmd_wp - Handle the "!wp <search terms>" command.
*
* Searches Wikipedia using the MediaWiki API. Accepts multiple keywords.
* For a single result: fetches the article intro (first ~500 chars) and
* sends the title, intro text, and a link. For multiple results: sends
* a compact list of titles with short snippets separated by pipes.
*
* Uses two Wikipedia API endpoints:
* 1. Search API: action=query&list=search (find matching articles)
* 2. Extracts API: action=query&prop=extracts&exintro=true (get intro)
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_wp(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!wp", 3) != 0) return 0;
if (msg[3] != ' ' && msg[3] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target, current_userhost) < 0) return 1;
const char *rest = msg[3] == ' ' ? msg + 4 : "";
while (*rest == ' ') rest++;
if (!*rest) {
irc_reply(s, reply_target, src_nick, "Usage: !wp <search terms>");
return 1;
}
char query_encoded[512];
wp_url_encode(rest, query_encoded, sizeof query_encoded);
char search_url[1024];
snprintf(search_url, sizeof search_url,
"https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=%s&srlimit=5&format=json",
query_encoded);
char *json = fetch_url(search_url);
if (!json) {
irc_reply(s, reply_target, src_nick, "Wikipedia: could not fetch search results");
return 1;
}
const char *results_key = strstr(json, "\"search\":[");
if (!results_key) {
irc_reply(s, reply_target, src_nick, "Wikipedia: no articles found");
free(json);
return 1;
}
int result_count = 0;
{
const char *scan = results_key;
while ((scan = strchr(scan, '{')) != NULL) { result_count++; scan++; }
}
if (result_count == 0) {
irc_reply(s, reply_target, src_nick, "Wikipedia: no articles found");
free(json);
return 1;
}
if (result_count == 1) {
char title[256] = "";
char snippet_raw[512] = "";
const char *brace = strchr(results_key, '{');
if (brace) {
wp_json_get_string(brace, "\"title\":", title, sizeof title);
wp_json_get_string(brace, "\"snippet\":", snippet_raw, sizeof snippet_raw);
}
wp_decode_json_string(snippet_raw, snippet_raw, sizeof snippet_raw);
wp_strip_html(snippet_raw);
wp_collapse_spaces(snippet_raw);
if (!title[0]) {
irc_reply(s, reply_target, src_nick, "Wikipedia: could not parse result");
free(json);
return 1;
}
char enc_title[512];
wp_url_encode_title(title, enc_title, sizeof enc_title);
char extract_url[1024];
snprintf(extract_url, sizeof extract_url,
"https://en.wikipedia.org/w/api.php?action=query&titles=%s&prop=extracts&explaintext=true&exchars=800&format=json",
enc_title);
free(json);
char *ej = fetch_url(extract_url);
char extract[1024] = "";
if (ej) {
const char *ep = strstr(ej, "\"extract\":");
if (ep) {
char raw[1024] = "";
wp_json_get_string(ep, "\"extract\":", raw, sizeof raw);
wp_decode_json_string(raw, extract, sizeof extract);
}
free(ej);
}
char resp[1536];
if (extract[0]) {
size_t elen = strlen(extract);
int truncated = (elen >= 790);
snprintf(resp, sizeof resp, "%s — %s%s", title, extract, truncated ? "..." : "");
} else if (snippet_raw[0]) {
snprintf(resp, sizeof resp, "%s — %s", title, snippet_raw);
} else {
snprintf(resp, sizeof resp, "%s — https://en.wikipedia.org/wiki/%s", title, enc_title);
}
irc_reply(s, reply_target, src_nick, resp);
char link[640];
snprintf(link, sizeof link, "Read full article: https://en.wikipedia.org/wiki/%s", enc_title);
irc_reply(s, reply_target, src_nick, link);
} else {
char resp[512];
snprintf(resp, sizeof resp, "Wikipedia results for \"%s\":", rest);
irc_reply(s, reply_target, src_nick, resp);
const char *cur = results_key;
int idx = 0;
int shown = 0;
while (idx < result_count && shown < 5) {
const char *brace = wp_json_next_array_item(&cur);
if (!brace) break;
idx++;
char title[256] = "";
char snippet_raw[256] = "";
wp_json_get_string(brace, "\"title\":", title, sizeof title);
wp_json_get_string(brace, "\"snippet\":", snippet_raw, sizeof snippet_raw);
wp_decode_json_string(snippet_raw, snippet_raw, sizeof snippet_raw);
wp_strip_html(snippet_raw);
wp_collapse_spaces(snippet_raw);
if (!title[0]) continue;
char enc_title[512];
wp_url_encode_title(title, enc_title, sizeof enc_title);
char line[1024];
if (snippet_raw[0]) {
char snippet_short[256];
snprintf(snippet_short, sizeof snippet_short, "%s", snippet_raw);
size_t slen = strlen(snippet_short);
if (slen > 80) {
char *sp = snippet_short + 77;
while (sp > snippet_short && *sp != ' ') sp--;
if (sp <= snippet_short) sp = snippet_short + 77;
*sp = '\0';
snprintf(line, sizeof line, "%s — %s...", title, snippet_short);
} else {
snprintf(line, sizeof line, "%s — %s", title, snippet_short);
}
} else {
snprintf(line, sizeof line, "%s", title);
}
irc_reply(s, reply_target, src_nick, line);
char link[640];
snprintf(link, sizeof link, "Read full article: https://en.wikipedia.org/wiki/%s", enc_title);
irc_reply(s, reply_target, src_nick, link);
shown++;
}
if (idx < result_count) {
irc_reply(s, reply_target, src_nick, "...");
}
}
log_stamp(); fprintf(stderr, CLR_MAGENTA "WP %s from %s:" CLR_RESET " %s\n", reply_target, src_nick, rest);
return 1;
}
#endif