summaryrefslogtreecommitdiff
path: root/src/core
diff options
context:
space:
mode:
Diffstat (limited to 'src/core')
-rw-r--r--src/core/aichat.c357
-rw-r--r--src/core/command.c5
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);
}
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com