diff options
| author | info@mode42.com <info@mode42.com> | 2026-08-09 14:56:03 +0000 |
|---|---|---|
| committer | info@mode42.com <info@mode42.com> | 2026-08-09 14:56:03 +0000 |
| commit | 12c15060e7266bf53d5879a9e6f7f581de8e808e (patch) | |
| tree | 4b338b00a0fd060cbe890de1fe3fc161600ffc57 /src/core | |
| parent | 7815183927ecb914de430a5d64f9df27a48f49ae (diff) | |
Diffstat (limited to 'src/core')
| -rw-r--r-- | src/core/aichat.c | 357 | ||||
| -rw-r--r-- | src/core/command.c | 5 |
2 files changed, 362 insertions, 0 deletions
diff --git a/src/core/aichat.c b/src/core/aichat.c new file mode 100644 index 0000000..56fe9fa --- /dev/null +++ b/src/core/aichat.c @@ -0,0 +1,357 @@ +#define _POSIX_C_SOURCE 200809L + +#include "hybbx/aichat.h" +#include "hybbx/session.h" +#include "hybbx/service.h" +#include "hybbx/util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <ctype.h> +#include <unistd.h> + +#define HYBBX_AICHAT_MAX_USERS 64 + +typedef struct hybbx_aichat_user_rate { + char username[64]; + time_t window_start; + unsigned request_count; +} hybbx_aichat_user_rate_t; + +static hybbx_aichat_user_rate_t g_user_rates[HYBBX_AICHAT_MAX_USERS]; +static size_t g_user_rate_count = 0; + +void hybbx_aichat_config_init(hybbx_aichat_config_t *cfg, const hybbx_config_t *config) +{ + if (cfg == NULL) { + return; + } + memset(cfg, 0, sizeof(*cfg)); + + if (config == NULL) { + return; + } + + cfg->enabled = hybbx_config_get_bool(config, "aichat", "enabled", 0); + + const char *val; + val = hybbx_config_get(config, "aichat", "provider", "openrouter"); + snprintf(cfg->provider, sizeof(cfg->provider), "%s", val ? val : "openrouter"); + + val = hybbx_config_get(config, "aichat", "api_key", ""); + snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", val ? val : ""); + + val = hybbx_config_get(config, "aichat", "api_url", "https://openrouter.ai/api/v1/chat/completions"); + snprintf(cfg->api_url, sizeof(cfg->api_url), "%s", val ? val : "https://openrouter.ai/api/v1/chat/completions"); + + val = hybbx_config_get(config, "aichat", "model", "meta-llama/llama-3.2-11b-vision-instruct:free"); + snprintf(cfg->model, sizeof(cfg->model), "%s", val ? val : "meta-llama/llama-3.2-11b-vision-instruct:free"); + + cfg->max_tokens = hybbx_config_get_uint(config, "aichat", "max_tokens", 300, 50, 2048); + cfg->rate_limit_per_user_hour = hybbx_config_get_uint(config, "aichat", "rate_limit_per_user_hour", 5, 1, 100); + cfg->timeout_sec = hybbx_config_get_uint(config, "aichat", "timeout_sec", 10, 2, 60); + + val = hybbx_config_get(config, "aichat", "site_url", "https://hybbx.un1t.me"); + snprintf(cfg->site_url, sizeof(cfg->site_url), "%s", val ? val : "https://hybbx.un1t.me"); + + val = hybbx_config_get(config, "aichat", "site_name", "hyBBX Network"); + snprintf(cfg->site_name, sizeof(cfg->site_name), "%s", val ? val : "hyBBX Network"); +} + +static int check_and_inc_rate_limit(const char *username, unsigned max_per_hour) +{ + time_t now = time(NULL); + size_t i; + int found = -1; + + for (i = 0; i < g_user_rate_count; i++) { + if (strcmp(g_user_rates[i].username, username) == 0) { + found = (int)i; + break; + } + } + + if (found < 0) { + if (g_user_rate_count < HYBBX_AICHAT_MAX_USERS) { + found = (int)g_user_rate_count++; + snprintf(g_user_rates[found].username, sizeof(g_user_rates[found].username), "%s", username); + g_user_rates[found].window_start = now; + g_user_rates[found].request_count = 0; + } else { + found = 0; /* Fallback wrap */ + snprintf(g_user_rates[found].username, sizeof(g_user_rates[found].username), "%s", username); + g_user_rates[found].window_start = now; + g_user_rates[found].request_count = 0; + } + } + + /* Reset hourly window if > 3600 seconds elapsed */ + if (now - g_user_rates[found].window_start >= 3600) { + g_user_rates[found].window_start = now; + g_user_rates[found].request_count = 0; + } + + if (g_user_rates[found].request_count >= max_per_hour) { + return 0; /* Exceeded limit */ + } + + g_user_rates[found].request_count++; + return 1; /* OK */ +} + +static void json_escape(const char *in, char *out, size_t out_sz) +{ + size_t j = 0; + if (out_sz == 0) return; + for (; in != NULL && *in != '\0' && j + 2 < out_sz; in++) { + if (*in == '"' || *in == '\\') { + out[j++] = '\\'; + } + if (*in == '\n' || *in == '\r') { + out[j++] = ' '; + continue; + } + out[j++] = *in; + } + out[j] = '\0'; +} + +static int openrouter_parse_content(const char *json, char *out, size_t out_sz) +{ + const char *p; + size_t j = 0; + int esc = 0; + + if (json == NULL || out == NULL || out_sz == 0) return -1; + out[0] = '\0'; + + p = strstr(json, "\"choices\""); + if (p == NULL) p = json; + + p = strstr(p, "\"content\""); + if (p == NULL) { + p = strstr(json, "\"error\""); + if (p != NULL) { + const char *msg = strstr(p, "\"message\""); + if (msg != NULL) { + msg = strchr(msg, ':'); + if (msg != NULL) { + while (*msg != '\0' && (*msg == ':' || *msg == ' ' || *msg == '"')) { + msg++; + } + snprintf(out, out_sz, "[AI Error] %.*s", (int)(out_sz - 16), msg); + /* Remove trailing quote */ + size_t len = strlen(out); + if (len > 0 && out[len - 1] == '"') out[len - 1] = '\0'; + return 0; + } + } + } + snprintf(out, out_sz, "[AI Error] Unable to parse response from AI provider."); + return -1; + } + + p = strchr(p, ':'); + if (p == NULL) return -1; + while (*p != '\0' && *p != '"') p++; + if (*p == '"') p++; + + while (*p != '\0' && j + 1 < out_sz) { + if (esc) { + if (*p == 'n') out[j++] = '\n'; + else if (*p == 'r') out[j++] = ' '; + else if (*p == 't') out[j++] = ' '; + else out[j++] = *p; + esc = 0; + } else if (*p == '\\') { + esc = 1; + } else if (*p == '"') { + break; + } else { + out[j++] = *p; + } + p++; + } + out[j] = '\0'; + return 0; +} + +static void send_wrapped_output(hybbx_session_t *session, const char *text) +{ + char line_buf[82]; + size_t col = 0; + const char *p = text; + + hybbx_session_write_line(session, "--- AI Assistant (OpenRouter) ---"); + + while (*p != '\0') { + if (*p == '\n') { + line_buf[col] = '\0'; + hybbx_session_write_line(session, line_buf); + col = 0; + p++; + continue; + } + + line_buf[col++] = *p++; + if (col >= 76) { + /* Find last space if possible */ + size_t break_pos = col; + while (break_pos > 40 && line_buf[break_pos - 1] != ' ') { + break_pos--; + } + if (break_pos <= 40) { + break_pos = col; + } + char saved = line_buf[break_pos]; + line_buf[break_pos] = '\0'; + hybbx_session_write_line(session, line_buf); + + /* Shift remainder */ + size_t rem = col - break_pos; + if (saved == ' ') { + /* Skip space */ + if (rem > 0) rem--; + memmove(line_buf, line_buf + break_pos + 1, rem); + } else { + memmove(line_buf, line_buf + break_pos, rem); + } + col = rem; + } + } + + if (col > 0) { + line_buf[col] = '\0'; + hybbx_session_write_line(session, line_buf); + } + + hybbx_session_write_line(session, "---------------------------------"); +} + +hybbx_result_t cmd_aichat(hybbx_service_t *service, + hybbx_session_t *session, + const hybbx_parsed_command_t *cmd) +{ + hybbx_aichat_config_t cfg; + char prompt[1024] = {0}; + char escaped_prompt[2048] = {0}; + char json_req[4096] = {0}; + char json_resp[8192] = {0}; + char ai_text[4096] = {0}; + char cmd_buf[8192] = {0}; + size_t i; + + hybbx_config_t config_obj; + const char *cfg_path = hybbx_service_config_path(service); + if (cfg_path != NULL && cfg_path[0] != '\0') { + if (hybbx_config_load(&config_obj, cfg_path) == HYBBX_OK) { + hybbx_aichat_config_init(&cfg, &config_obj); + hybbx_config_free(&config_obj); + } else { + hybbx_aichat_config_init(&cfg, NULL); + } + } else { + hybbx_aichat_config_init(&cfg, NULL); + } + + if (!cfg.enabled) { + hybbx_session_write_line(session, + "[AI] /aichat is disabled on this station. (Enable with [aichat] enabled=yes)"); + return HYBBX_OK; + } + + if (cmd->argc == 0) { + hybbx_session_write_line(session, "Usage: /aichat <your prompt or question>"); + hybbx_session_write_line(session, "Aliases: /ai, /ask"); + hybbx_session_write_line(session, + "Notice: Prompts in /aichat are processed via OpenRouter AI. Do not send private keys/secrets."); + return HYBBX_OK; + } + + /* Reconstruct prompt string */ + for (i = 0; i < cmd->argc; i++) { + if (i > 0) strncat(prompt, " ", sizeof(prompt) - strlen(prompt) - 1); + strncat(prompt, cmd->argv[i], sizeof(prompt) - strlen(prompt) - 1); + } + + const char *user = hybbx_session_username(session); + if (user == NULL || user[0] == '\0') { + user = "Guest"; + } + + if (!check_and_inc_rate_limit(user, cfg.rate_limit_per_user_hour)) { + snprintf(ai_text, sizeof(ai_text), + "[AI] Hourly limit reached (%u/%u per hour for %s). Please try again later.", + cfg.rate_limit_per_user_hour, cfg.rate_limit_per_user_hour, user); + hybbx_session_write_line(session, ai_text); + return HYBBX_OK; + } + + if (cfg.api_key[0] == '\0') { + hybbx_session_write_line(session, + "[AI Error] API key missing in [aichat] section of ./local/hybbx.ini"); + return HYBBX_OK; + } + + hybbx_session_write_line(session, "[AI] Querying OpenRouter assistant..."); + + json_escape(prompt, escaped_prompt, sizeof(escaped_prompt)); + + snprintf(json_req, sizeof(json_req), + "{\"model\":\"%s\",\"max_tokens\":%u,\"temperature\":0.7," + "\"messages\":[" + "{\"role\":\"system\",\"content\":\"You are an AI assistant on a text-based Mailbox (BBX) station. Reply strictly in plain ASCII text. Keep responses concise, under 75 columns wide, without markdown headers or complex symbols.\"}," + "{\"role\":\"user\",\"content\":\"%s\"}" + "]}", + cfg.model, cfg.max_tokens, escaped_prompt); + + /* Construct curl command line safely via temporary request file */ + char tmp_req_path[256]; + snprintf(tmp_req_path, sizeof(tmp_req_path), "/tmp/hybbx_aichat_%d_%lu.json", + (int)getpid(), (unsigned long)time(NULL)); + + FILE *fp_req = fopen(tmp_req_path, "w"); + if (fp_req == NULL) { + hybbx_session_write_line(session, "[AI Error] System unable to write temp request file."); + return HYBBX_OK; + } + fputs(json_req, fp_req); + fclose(fp_req); + + snprintf(cmd_buf, sizeof(cmd_buf), + "curl -s -m %u -X POST \"%s\" " + "-H \"Authorization: Bearer %s\" " + "-H \"HTTP-Referer: %s\" " + "-H \"X-Title: %s\" " + "-H \"Content-Type: application/json\" " + "-d @\"%s\"", + cfg.timeout_sec, cfg.api_url, cfg.api_key, + cfg.site_url, cfg.site_name, tmp_req_path); + + FILE *pipe = popen(cmd_buf, "r"); + if (pipe == NULL) { + unlink(tmp_req_path); + hybbx_session_write_line(session, "[AI Error] System unable to launch curl command."); + return HYBBX_OK; + } + + size_t bytes_read = fread(json_resp, 1, sizeof(json_resp) - 1, pipe); + json_resp[bytes_read] = '\0'; + pclose(pipe); + unlink(tmp_req_path); + + if (bytes_read == 0) { + hybbx_session_write_line(session, "[AI Error] No response received from OpenRouter API."); + return HYBBX_OK; + } + + if (openrouter_parse_content(json_resp, ai_text, sizeof(ai_text)) != 0) { + hybbx_session_write_line(session, "[AI Error] Failed to parse AI response content."); + return HYBBX_OK; + } + + send_wrapped_output(session, ai_text); + return HYBBX_OK; +} diff --git a/src/core/command.c b/src/core/command.c index 44485c7..f33ad04 100644 --- a/src/core/command.c +++ b/src/core/command.c @@ -18,6 +18,7 @@ #include "hybbx/password.h" #include "hybbx/traffic.h" #include "hybbx/monitor.h" +#include "hybbx/aichat.h" #include "hybbx/util.h" #include <stdio.h> @@ -2448,6 +2449,10 @@ hybbx_result_t hybbx_command_dispatch(hybbx_service_t *service, return cmd_broadcast(service, session, cmd); } + if (str_ieq(cmd->verb, "aichat") || str_ieq(cmd->verb, "ai") || str_ieq(cmd->verb, "ask")) { + return cmd_aichat(service, session, cmd); + } + if (str_ieq(cmd->verb, "shutdown")) { return cmd_shutdown(service, session); } |
