#define _POSIX_C_SOURCE 200809L #include "hybbx/aichat.h" #include "hybbx/session.h" #include "hybbx/service.h" #include "hybbx/util.h" #include #include #include #include #include #include #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 "); 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; }