Compare commits

...

7 Commits

Author SHA1 Message Date
5d7b7bb23d test(ai): align parse_response tests with chat completions API
All checks were successful
CI Code / Check coding style (pull_request) Successful in 29s
CI Code / Check spelling (pull_request) Successful in 15s
CI Code / Code Coverage (pull_request) Successful in 3m37s
CI Code / Linux (debian) (pull_request) Successful in 7m36s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m52s
CI Code / Linux (arch) (pull_request) Successful in 11m26s
CI Code / Check spelling (push) Successful in 15s
CI Code / Check coding style (push) Successful in 28s
CI Code / Code Coverage (push) Successful in 3m30s
CI Code / Linux (debian) (push) Successful in 5m19s
CI Code / Linux (ubuntu) (push) Successful in 5m25s
CI Code / Linux (arch) (push) Successful in 7m11s
The chat-completions refactor removed the legacy Perplexity "text"
extraction but left two tests asserting the old behavior, breaking
make check on every CI flavor:

- test_ai_parse_response_perplexity_text expected the legacy /v1/agent
  nested format to parse; it now yields NULL — renamed to
  test_ai_parse_response_legacy_text_format_unsupported.
- test_ai_parse_response_text_preferred_over_content expected "text"
  to win; "content" is authoritative now — renamed to
  test_ai_parse_response_content_preferred_over_text.

Add test_ai_set_provider_setting_reserved_key covering the new
reserved-key rejection.
2026-07-04 14:07:22 +03:00
fa6857f4c8 fix(ai): harden custom settings payload against invalid JSON and races
Custom setting values were JSON-escaped but emitted unquoted, so any
non-numeric value (e.g. "high") produced an invalid JSON payload and
failed the whole request; quoting the value manually could not work
either, since the escaper turns quotes into \". Emit RFC 8259 scalars
(number/true/false/null) bare and everything else as a quoted JSON
string.

Reserved payload keys (model, messages, stream) would duplicate the
fixed fields with parser-dependent precedence; reject them in
ai_set_provider_setting (surfaced as an error by /ai set custom) and
skip them at payload-build time for settings loaded from hand-edited
prefs.

provider->settings was mutated on the main thread while the request
thread iterates it when building the payload; guard both sides with a
new per-provider settings_lock.

Also refresh stale docs: ai_parse_response no longer mentions the
dropped Perplexity "text" path, the payload docstring says "messages"
instead of "input", and the /ai help example uses a realistic scalar
setting.
2026-07-04 14:07:22 +03:00
9913344bbf refactor(ai): align AI client with OpenAI chat completions API
Some checks failed
CI Code / Check spelling (push) Successful in 16s
CI Code / Check coding style (push) Successful in 29s
CI Code / Linux (debian) (push) Failing after 3m38s
CI Code / Code Coverage (push) Failing after 5m19s
CI Code / Linux (ubuntu) (push) Failing after 6m15s
CI Code / Linux (arch) (push) Failing after 9m31s
Update request endpoint to /v1/chat/completions and switch payload key
from input to messages. Remove store flag and legacy Perplexity response
parsing to standardize on OpenAI's content extraction.
2026-07-04 09:58:17 +00:00
91631aa91a feat(ai): add suport for /ai set custom parameters
Modify _build_json_payload_from_list to accept an AIProvider parameter
and dynamically merge its custom settings into the JSON payload. The
settings are serialized as additional key-value pairs alongside the
standard model and input fields, enabling per-provider configuration
options without hardcoding them.
2026-07-04 09:47:28 +00:00
622054fc6d fix(pgp): prevent plaintext fallback in PGP/OX encryption
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Code Coverage (pull_request) Successful in 3m41s
CI Code / Linux (debian) (pull_request) Successful in 5m15s
CI Code / Linux (ubuntu) (pull_request) Successful in 5m21s
CI Code / Linux (arch) (pull_request) Successful in 13m55s
CI Code / Check spelling (push) Successful in 1m24s
CI Code / Check coding style (push) Successful in 1m38s
CI Code / Linux (arch) (push) Successful in 7m14s
CI Code / Linux (debian) (push) Successful in 8m27s
CI Code / Linux (ubuntu) (push) Successful in 8m29s
CI Code / Code Coverage (push) Successful in 7m50s
Block all three code paths in message_send_chat_pgp that previously
fell back to sending unencrypted messages: encryption failure, missing
PGP key, and missing LibGPGME. Each path now shows a specific error
in the chat window and returns NULL so the caller skips logging and
display.

Also replace the generic cons_show for OX signcrypt failure with a
win_println on the current window, ensuring the user sees the error
even when the chat window is not focused.

Improve p_gpg_encrypt error reporting by adding a gchar** err
out-parameter. Each failure path now sets a descriptive message
(e.g. missing recipient key, missing sender key, GPGME context
failure, encryption failure) so the caller can display the exact
reason to the user.

Fix a memory leak: gpgme_key_unref(receiver_key) was missing from
the sender_key failure path.
2026-06-29 14:08:40 +00:00
ca92d29179 fix(ui): measure dead pad space from cursor instead of absolute height
All checks were successful
CI Code / Check spelling (push) Successful in 20s
CI Code / Check coding style (push) Successful in 38s
CI Code / Code Coverage (push) Successful in 4m2s
CI Code / Linux (ubuntu) (push) Successful in 5m22s
CI Code / Linux (debian) (push) Successful in 7m21s
CI Code / Linux (arch) (push) Successful in 7m39s
Remove the /autoping warning subcommand and PREF_AUTOPING_WARNING
preference entirely. The warning was shown at connect time when
autoping was disabled but the server supported XEP-0199 ping.

The pad threshold logic previously used an absolute height
(PAD_THRESHOLD=12000) which was above the buffer cap and never
fired during normal scrolling. Replace with a dead-space measurement
(cursor position minus live buffer lines) that only triggers a redraw
when dead space exceeds PAD_DEAD_SPACE_LIMIT (2000 lines).

Author: jabber.developer2 <jabber.developer2@jabber.space>
2026-06-24 16:22:52 +00:00
72aa603147 build: untrack generated src/gitversion.h.in
All checks were successful
CI Code / Check coding style (push) Successful in 1m33s
CI Code / Check spelling (push) Successful in 1m39s
CI Code / Linux (debian) (push) Successful in 5m20s
CI Code / Code Coverage (push) Successful in 4m30s
CI Code / Linux (arch) (push) Successful in 7m4s
CI Code / Linux (ubuntu) (push) Successful in 7m9s
The file is gitignored and regenerated by the build recipe from
.git/HEAD/.git/index; it was committed by mistake during the upstream
sync. Untrack it so builds no longer dirty the working tree. The
version string behaviour is unchanged.
2026-06-23 12:33:49 +00:00
12 changed files with 189 additions and 69 deletions

View File

@@ -186,6 +186,22 @@ ai_json_escape(const gchar* str)
return g_string_free(result, FALSE);
}
/* Keys emitted as fixed payload fields; custom settings must not duplicate them */
static gboolean
_is_reserved_payload_key(const gchar* key)
{
return g_strcmp0(key, "model") == 0 || g_strcmp0(key, "messages") == 0 || g_strcmp0(key, "stream") == 0;
}
/* RFC 8259 scalars (number/true/false/null) may be emitted bare in a JSON payload */
static gboolean
_is_json_bare_token(const gchar* value)
{
if (g_strcmp0(value, "true") == 0 || g_strcmp0(value, "false") == 0 || g_strcmp0(value, "null") == 0)
return TRUE;
return g_regex_match_simple("^-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?$", value, 0, 0);
}
/**
* Convert a 4-hex-digit sequence at @p to a 16-bit code point.
* Returns -1 on invalid hex.
@@ -392,6 +408,7 @@ ai_provider_new(const gchar* name, const gchar* api_url)
provider->models = NULL;
provider->models_fresh = FALSE;
provider->ref_count = 1;
pthread_mutex_init(&provider->settings_lock, NULL);
return provider;
}
@@ -419,6 +436,7 @@ ai_provider_unref(AIProvider* provider)
g_free(provider->project_id);
g_free(provider->default_model);
g_hash_table_destroy(provider->settings);
pthread_mutex_destroy(&provider->settings_lock);
GList* curr = provider->models;
while (curr) {
@@ -729,31 +747,44 @@ ai_get_provider_default_model(const gchar* provider_name)
return provider->default_model;
}
void
gboolean
ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value)
{
if (!provider_name || !setting)
return;
return FALSE;
if (_is_reserved_payload_key(setting)) {
log_warning("Setting '%s' for provider '%s' rejected: reserved payload key", setting, provider_name);
return FALSE;
}
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
log_warning("Provider '%s' not found for setting '%s'", provider_name, setting);
return;
return FALSE;
}
/* settings is iterated by the request thread; mutate under settings_lock */
pthread_mutex_lock(&provider->settings_lock);
if (!provider->settings) {
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
}
if (value) {
g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value));
} else {
g_hash_table_remove(provider->settings, setting);
}
pthread_mutex_unlock(&provider->settings_lock);
if (value) {
prefs_ai_set_setting(provider_name, setting, value);
log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value);
} else {
g_hash_table_remove(provider->settings, setting);
prefs_ai_remove_setting(provider_name, setting);
log_info("Setting '%s' removed for provider '%s'", setting, provider_name);
}
return TRUE;
}
gchar*
@@ -766,8 +797,10 @@ ai_get_provider_setting(const gchar* provider_name, const gchar* setting)
if (!provider || !provider->settings)
return NULL;
gchar* value = g_hash_table_lookup(provider->settings, setting);
return g_strdup(value);
pthread_mutex_lock(&provider->settings_lock);
gchar* value = g_strdup(g_hash_table_lookup(provider->settings, setting));
pthread_mutex_unlock(&provider->settings_lock);
return value;
}
/* ========================================================================
@@ -1435,13 +1468,19 @@ ai_session_switch(AISession* session, const gchar* provider_name,
* This is the thread-safe variant used by _ai_request_thread() which holds
* the session lock while calling it.
*
* Custom provider settings are merged into the payload as additional JSON
* key-value pairs alongside "model" and "messages": scalar values
* (number/true/false/null) are emitted bare, anything else as a JSON string;
* reserved keys (model/messages/stream) are skipped.
*
* @param provider The AI provider (for custom settings)
* @param model The model identifier
* @param history GList of AIMessage* (must be held under session lock)
* @param prompt The new user prompt to append
* @return Newly allocated JSON string (caller must free)
*/
static gchar*
_build_json_payload_from_list(const gchar* model, GList* history, const gchar* prompt)
_build_json_payload_from_list(AIProvider* provider, const gchar* model, GList* history, const gchar* prompt)
{
GString* messages_json = g_string_new("");
GList* curr = history;
@@ -1468,11 +1507,50 @@ _build_json_payload_from_list(const gchar* model, GList* history, const gchar* p
g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt);
auto_gchar gchar* escaped_model = ai_json_escape(model);
gchar* json_payload = g_strdup_printf(
"{\"model\":\"%s\",\"input\":[%s],\"stream\":false,\"store\":false}",
escaped_model, messages_json->str);
/* Build custom settings JSON fragment; settings is mutated on the main
* thread (/ai set custom), so iterate under settings_lock */
GString* custom_json = g_string_new("");
if (provider) {
pthread_mutex_lock(&provider->settings_lock);
if (provider->settings) {
GHashTableIter iter;
gpointer key, value;
g_hash_table_iter_init(&iter, provider->settings);
while (g_hash_table_iter_next(&iter, &key, &value)) {
if (_is_reserved_payload_key((gchar*)key)) {
continue; /* fixed payload fields stay authoritative */
}
auto_gchar gchar* escaped_key = ai_json_escape((gchar*)key);
if (custom_json->len > 0) {
g_string_append_c(custom_json, ',');
}
if (_is_json_bare_token((gchar*)value)) {
g_string_append_printf(custom_json, "\"%s\": %s", escaped_key, (gchar*)value);
} else {
/* non-scalar values must be quoted, else the payload is invalid JSON */
auto_gchar gchar* escaped_val = ai_json_escape((gchar*)value);
g_string_append_printf(custom_json, "\"%s\": \"%s\"", escaped_key, escaped_val);
}
}
}
pthread_mutex_unlock(&provider->settings_lock);
}
/* Assemble final payload: model, messages, custom settings, then fixed keys */
gchar* json_payload;
if (custom_json->len > 0) {
json_payload = g_strdup_printf(
"{\"model\":\"%s\",\"messages\":[%s],%s,\"stream\":false}",
escaped_model, messages_json->str, custom_json->str);
} else {
json_payload = g_strdup_printf(
"{\"model\":\"%s\",\"messages\":[%s],\"stream\":false}",
escaped_model, messages_json->str);
}
g_string_free(messages_json, TRUE);
g_string_free(custom_json, TRUE);
return json_payload;
}
@@ -1482,12 +1560,7 @@ ai_parse_response(const gchar* response_json)
if (!response_json || strlen(response_json) == 0)
return NULL;
/* Perplexity /v1/agent: {"output":[{"content":[{"text":"...","type":"output_text"}]}]}
* Legacy OpenAI: {"choices":[{"message":{"content":"..."}}]} */
gchar* text = _extract_json_string(response_json, "text");
if (text)
return text;
/* OpenAI /v1/chat/completions: {"choices":[{"message":{"content":"..."}}]} */
return _extract_json_string(response_json, "content");
}
@@ -1566,7 +1639,7 @@ _ai_request_thread(gpointer data)
/* Build JSON payload from history + prompt (under lock), then add user message to history */
log_debug("[AI-THREAD] Building JSON payload...");
pthread_mutex_lock(&session->lock);
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_model, session->history, prompt);
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_provider, local_model, session->history, prompt);
/* Add user message to history now (it's in JSON but not yet in history) */
AIMessage* user_msg = g_new0(AIMessage, 1);
@@ -1587,7 +1660,7 @@ _ai_request_thread(gpointer data)
/* Build request URL */
const gchar* api_url = local_provider->api_url;
auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/responses", api_url, g_str_has_suffix(api_url, "/") ? "" : "/");
auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/chat/completions", api_url, g_str_has_suffix(api_url, "/") ? "" : "/");
log_debug("[AI-THREAD] API URL: %s", api_url);
log_debug("[AI-THREAD] API Request URL: %s", request_url);
log_debug("[AI-THREAD] Model: %s", local_model);

View File

@@ -17,14 +17,15 @@ typedef struct ai_message_t
*/
typedef struct ai_provider_t
{
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
gchar* api_url; /* API endpoint URL */
gchar* project_id; /* Optional project ID (for some providers) */
gchar* default_model; /* Default model for this provider */
GHashTable* settings; /* Extensible per-provider settings (e.g., tools=enabled, search=disabled) */
GList* models; /* Cached models (gchar*) */
gboolean models_fresh; /* Whether models cache is current */
gint32 ref_count; /* Reference count (atomic) */
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
gchar* api_url; /* API endpoint URL */
gchar* project_id; /* Optional project ID (for some providers) */
gchar* default_model; /* Default model for this provider */
pthread_mutex_t settings_lock; /* Protects settings (iterated by the request thread) */
GHashTable* settings; /* Extensible per-provider settings (e.g., temperature=0.7) */
GList* models; /* Cached models (gchar*) */
gboolean models_fresh; /* Whether models cache is current */
gint32 ref_count; /* Reference count (atomic) */
} AIProvider;
/**
@@ -142,11 +143,13 @@ const gchar* ai_get_provider_default_model(const gchar* provider_name);
/**
* Set a custom setting for a provider.
* Reserved payload keys (model, messages, stream) are rejected.
* @param provider_name The provider name
* @param setting The setting key (e.g., "tools", "search")
* @param value The setting value
* @param setting The setting key (e.g., "temperature")
* @param value The setting value, or NULL to remove
* @return TRUE on success, FALSE on unknown provider or reserved key
*/
void ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
gboolean ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
/**
* Get a custom setting for a provider.
@@ -184,9 +187,9 @@ gboolean ai_models_are_fresh(const gchar* provider_name);
void ai_parse_models_from_json(AIProvider* provider, const gchar* json);
/**
* Extract assistant content from an LLM response body. Tries Perplexity
* /v1/responses "text" first, falls back to OpenAI "content". Decodes
* the \" and \n escape sequences only.
* Extract assistant content from an OpenAI chat-completions response body
* ({"choices":[{"message":{"content":"..."}}]}). Decodes the \" and \n
* escape sequences only.
* @param response_json The JSON body from the provider
* @return Newly allocated content string, or NULL if not found (caller frees)
*/

View File

@@ -2833,7 +2833,7 @@ static const struct cmd_t command_defs[] = {
{ "set provider <name> <url>", "Add or update a provider with custom API endpoint" },
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
{ "set default-model <provider> <model>", "Set default model for a provider" },
{ "set custom <provider> <setting> <value>", "Set provider-level setting (e.g., tools, search)" },
{ "set custom <provider> <setting> <value>", "Set provider-level setting (e.g., temperature, max_tokens)" },
{ "remove provider <name>", "Remove a custom provider" },
{ "providers", "List configured providers with full details" },
{ "start <provider> [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
@@ -2846,7 +2846,7 @@ static const struct cmd_t command_defs[] = {
"/ai set token perplexity pplx-xxx",
"/ai set provider custom https://my-api.com/v1/chat/completions",
"/ai set default-model perplexity sonar",
"/ai set custom perplexity tools enabled",
"/ai set custom perplexity temperature 0.7",
"/ai remove provider custom",
"/ai start",
"/ai start perplexity",

View File

@@ -10787,8 +10787,11 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
cons_bad_cmd_usage(command);
return TRUE;
}
ai_set_provider_setting(args[2], args[3], args[4]);
cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]);
if (ai_set_provider_setting(args[2], args[3], args[4])) {
cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]);
} else {
cons_show_error("Cannot set '%s' for provider '%s': unknown provider or reserved key (model, messages, stream).", args[3], args[2]);
}
cons_show("");
return TRUE;
}

View File

@@ -1,6 +0,0 @@
#ifndef PROF_GIT_BRANCH
#define PROF_GIT_BRANCH @PROF_GIT_BRANCH@
#endif
#ifndef PROF_GIT_REVISION
#define PROF_GIT_REVISION @PROF_GIT_REVISION@
#endif

View File

@@ -519,13 +519,15 @@ p_gpg_sign(const gchar* const str, const gchar* const fp)
}
gchar*
p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp)
p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp, gchar** err)
{
ProfPGPPubKeyId* pubkeyid = g_hash_table_lookup(pubkeys, barejid);
if (!pubkeyid) {
*err = g_strdup("No PGP key found for recipient");
return NULL;
}
if (!pubkeyid->id) {
*err = g_strdup("No key ID found for recipient");
return NULL;
}
@@ -538,6 +540,7 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_ctx_t ctx;
gpgme_error_t error = gpgme_new(&ctx);
if (error) {
*err = g_strdup_printf("Failed to create GPGME context: %s %s", gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to create gpgme context. %s %s", gpgme_strsource(error), gpgme_strerror(error));
return NULL;
}
@@ -545,6 +548,7 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_key_t receiver_key;
error = gpgme_get_key(ctx, pubkeyid->id, &receiver_key, 0);
if (error || receiver_key == NULL) {
*err = g_strdup_printf("Failed to get receiver key '%s': %s %s", pubkeyid->id, gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to get receiver_key. %s %s", gpgme_strsource(error), gpgme_strerror(error));
gpgme_release(ctx);
return NULL;
@@ -554,8 +558,10 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_key_t sender_key = NULL;
error = gpgme_get_key(ctx, fp, &sender_key, 0);
if (error || sender_key == NULL) {
*err = g_strdup_printf("Failed to get sender key '%s': %s %s", fp, gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to get sender_key. %s %s", gpgme_strsource(error), gpgme_strerror(error));
gpgme_release(ctx);
gpgme_key_unref(receiver_key);
return NULL;
}
keys[1] = sender_key;
@@ -574,7 +580,8 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_key_unref(sender_key);
if (error) {
log_error("GPG: Failed to encrypt message. %s %s", gpgme_strsource(error), gpgme_strerror(error));
*err = g_strdup_printf("Encryption failed: (%s) %s", gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to encrypt message. (%s) %s", gpgme_strsource(error), gpgme_strerror(error));
return NULL;
}

View File

@@ -42,7 +42,7 @@ gboolean p_gpg_available(const gchar* const barejid);
const gchar* p_gpg_libver(void);
gchar* p_gpg_sign(const gchar* const str, const gchar* const fp);
void p_gpg_verify(const gchar* const barejid, const gchar* const sign);
gchar* p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp);
gchar* p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp, gchar** err);
gchar* p_gpg_decrypt(const gchar* const cipher);
void p_gpg_free_decrypted(gchar* decrypted);
gchar* p_gpg_autocomplete_key(const gchar* const search_str, gboolean previous, void* context);

View File

@@ -43,7 +43,7 @@
#include "ai/ai_client.h"
static const int PAD_MIN_HEIGHT = 100;
static const int PAD_THRESHOLD = 12000; // above buffer cap (~9000): reclaims dead pad space, never fires while scrolling
static const int PAD_DEAD_SPACE_LIMIT = 2000; // reclaim once the pad holds this much dead space (cursor past live buffer); size-independent, so it never fires while scrolling
static gboolean _in_redraw = FALSE;
static void
@@ -56,9 +56,10 @@ _win_ensure_pad_capacity(ProfWin* window, WINDOW* win, int lines_needed)
int cur_height = getmaxy(win);
int cur_width = getmaxx(win);
if (lines_needed >= cur_height - 1) {
// If we are getting too large, trigger a redraw to clean up old lines
// but only if we are not already in a redraw process.
if (window && cur_height >= PAD_THRESHOLD && !_in_redraw) {
// redraw to reclaim dead pad space (cursor far past live buffer, e.g. a long
// append-only session); dead space never accrues while scrolling, only here
int dead = window ? getcury(win) - window->layout->buffer->lines : 0;
if (window && dead > PAD_DEAD_SPACE_LIMIT && !_in_redraw) {
win_redraw(window);
} else {
// resize to required lines + some buffer for next messages

View File

@@ -467,12 +467,15 @@ message_send_chat_pgp(const char* const barejid, const char* const msg, gboolean
auto_char char* jid = chat_session_get_jid(barejid);
char* id = connection_create_stanza_id();
ProfWin* current = wins_get_current();
xmpp_stanza_t* message = NULL;
#ifdef HAVE_LIBGPGME
ProfAccount* account = accounts_get_account(session_get_account_name());
if (account->pgp_keyid) {
auto_jid Jid* jidp = jid_create(jid);
auto_gchar gchar* encrypted = p_gpg_encrypt(jidp->barejid, msg, account->pgp_keyid);
auto_gchar gchar* err = NULL;
auto_gchar gchar* encrypted = p_gpg_encrypt(jidp->barejid, msg, account->pgp_keyid, &err);
if (encrypted) {
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, "This message is encrypted (XEP-0027).");
@@ -486,18 +489,31 @@ message_send_chat_pgp(const char* const barejid, const char* const msg, gboolean
xmpp_stanza_add_child(message, x);
xmpp_stanza_release(x);
} else {
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, msg);
if (current) {
win_println(current, THEME_ERROR, "-", "Unable to encrypt message for %s: %s.", jid, err);
}
log_error("Message not encrypted for %s: %s.", jid, err);
account_free(account);
free(id);
return NULL;
}
} else {
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, msg);
if (current) {
win_println(current, THEME_ERROR, "-", "No PGP key configured for this account.");
}
log_error("No PGP key configured, message not sent.");
account_free(account);
free(id);
return NULL;
}
account_free(account);
#else
// ?
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, msg);
if (current) {
win_println(current, THEME_ERROR, "-", "LibGPGME not available, message not sent.");
}
log_error("LibGPGME not available, message not sent.");
free(id);
return NULL;
#endif
if (state) {
@@ -546,7 +562,10 @@ message_send_chat_ox(const char* const barejid, const char* const msg, gboolean
xmpp_stanza_to_text(signcrypt, &c, &s);
char* signcrypt_e = p_ox_gpg_signcrypt(account->jid, barejid, c);
if (signcrypt_e == NULL) {
cons_show("Unable to send OX message. Check log file and profanity-ox-setup man page for details.");
ProfWin* current = wins_get_current();
if (current) {
win_println(current, THEME_ERROR, "-", "Unable to send OX message. Check log file and profanity-ox-setup man page for details.");
}
log_error("Message not signcrypted.");
return NULL;
}

View File

@@ -614,6 +614,24 @@ test_ai_set_provider_setting(void** state)
assert_null(ai_get_provider_setting("p_set_settings", "nonexistent_setting"));
}
void
test_ai_set_provider_setting_reserved_key(void** state)
{
ai_add_provider("p_reserved_keys", "https://example.test/rk/");
/* Reserved payload keys are rejected (would duplicate fixed fields) */
assert_false(ai_set_provider_setting("p_reserved_keys", "model", "x"));
assert_false(ai_set_provider_setting("p_reserved_keys", "messages", "[]"));
assert_false(ai_set_provider_setting("p_reserved_keys", "stream", "true"));
assert_null(ai_get_provider_setting("p_reserved_keys", "model"));
/* Ordinary keys still work */
assert_true(ai_set_provider_setting("p_reserved_keys", "temperature", "0.7"));
auto_gchar gchar* temp = ai_get_provider_setting("p_reserved_keys", "temperature");
assert_non_null(temp);
assert_string_equal("0.7", temp);
}
void
test_ai_get_provider_setting(void** state)
{
@@ -919,22 +937,22 @@ test_ai_parse_response_openai_content(void** state)
}
void
test_ai_parse_response_perplexity_text(void** state)
test_ai_parse_response_legacy_text_format_unsupported(void** state)
{
/* Legacy Perplexity /v1/agent format: "content" is an array, not a string.
* Dropped with the chat-completions alignment, so parsing yields NULL. */
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"Search result\",\"type\":\"output_text\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("Search result", out);
assert_null(ai_parse_response(json));
}
void
test_ai_parse_response_text_preferred_over_content(void** state)
test_ai_parse_response_content_preferred_over_text(void** state)
{
/* When both formats are present, "text" wins (Perplexity path tried first). */
/* Chat-completions "content" is extracted; a stray "text" field is ignored. */
const gchar* json = "{\"text\":\"from text field\",\"content\":\"from content field\"}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("from text field", out);
assert_string_equal("from content field", out);
}
void

View File

@@ -43,6 +43,7 @@ void test_ai_providers_find_case_insensitive(void** state);
void test_ai_set_provider_default_model(void** state);
void test_ai_get_provider_default_model(void** state);
void test_ai_set_provider_setting(void** state);
void test_ai_set_provider_setting_reserved_key(void** state);
void test_ai_get_provider_setting(void** state);
/* Model caching tests */
void test_ai_models_are_fresh_initial(void** state);
@@ -64,8 +65,8 @@ int ai_client_teardown_with_prefs(void** state);
/* Chat response parser tests (ai_parse_response) */
void test_ai_parse_response_openai_content(void** state);
void test_ai_parse_response_perplexity_text(void** state);
void test_ai_parse_response_text_preferred_over_content(void** state);
void test_ai_parse_response_legacy_text_format_unsupported(void** state);
void test_ai_parse_response_content_preferred_over_text(void** state);
void test_ai_parse_response_escaped_quote(void** state);
void test_ai_parse_response_newline_escape(void** state);
void test_ai_parse_response_empty_content(void** state);

View File

@@ -752,6 +752,7 @@ main(int argc, char* argv[])
cmocka_unit_test_setup_teardown(test_ai_set_provider_default_model, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_get_provider_default_model, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_set_provider_setting, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_set_provider_setting_reserved_key, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_get_provider_setting, ai_client_setup, ai_client_teardown),
/* Model caching tests */
cmocka_unit_test_setup_teardown(test_ai_models_are_fresh_initial, ai_client_setup, ai_client_teardown),
@@ -774,8 +775,8 @@ main(int argc, char* argv[])
cmocka_unit_test(test_ai_json_escape_backslash_quote),
/* Chat response parser */
cmocka_unit_test(test_ai_parse_response_openai_content),
cmocka_unit_test(test_ai_parse_response_perplexity_text),
cmocka_unit_test(test_ai_parse_response_text_preferred_over_content),
cmocka_unit_test(test_ai_parse_response_legacy_text_format_unsupported),
cmocka_unit_test(test_ai_parse_response_content_preferred_over_text),
cmocka_unit_test(test_ai_parse_response_escaped_quote),
cmocka_unit_test(test_ai_parse_response_newline_escape),
cmocka_unit_test(test_ai_parse_response_empty_content),