All checks were successful
CI Code / Check spelling (push) Successful in 18s
CI Code / Check coding style (push) Successful in 29s
CI Code / Code Coverage (push) Successful in 2m44s
CI Code / Linux (debian) (push) Successful in 4m46s
CI Code / Linux (ubuntu) (push) Successful in 4m59s
CI Code / Linux (arch) (push) Successful in 5m56s
Add an AI client module that integrates with OpenAI-compatible API providers (OpenAI, Perplexity, and custom endpoints) to provide AI-assisted chat within CProof. Users can start sessions with /ai start, send prompts, receive responses in a dedicated AI window, switch between providers and models, and manage API keys — all with tab-completion. Providers are configured via /ai set commands with per-provider API keys, endpoints, default models, and custom settings. Two default providers (openai, perplexity) are seeded on first use. Provider state persists in [ai/<name>] sections of the preferences keyfile with automatic migration from the previous flat-key format. The /ai command integrates into the existing command system with 8 subcommands covering provider management, session lifecycle, model fetching, and conversation clearing. Autocomplete uses the standard flat prefix-matching chain for reliable tab-completion at every nesting level. A new ProfAiWin window type is added to the window system. Architecture: Async design: HTTP requests run on a background thread (pthread) to avoid blocking the ncurses UI loop; results are displayed on the main thread via direct function calls Thread safety: AIProvider and AISession use atomic ref-counting and mutex-protected session state; the request thread snapshots all session data before making the HTTP call Window validation: wins_ai_exists() prevents use-after-free when the user closes the AI window during an in-flight HTTP request (~60s) Privacy: store:false is sent with every request to prevent providers from persisting conversations or using them for training Response size limit: 10MB cap with immediate curl abort via CURL_WRITEFUNC_ERROR to prevent OOM JSON parsing uses unified helpers for both chat responses and error envelopes with consistent escape decoding. The response parser tries Perplexity /v1/responses "text" field first, then falls back to OpenAI "content". Error parsing extracts provider error.message from the standard envelope format. Model parsing handles multiple API response formats (OpenAI list, Perplexity, array) including edge cases. Tests include 470+ lines of unit tests covering provider management, session lifecycle, JSON parsing (multiple formats), autocomplete cycling, and error handling, plus functional tests for /ai command dispatch. A stub_ai.c module isolates unit tests from UI dependencies.
316 lines
11 KiB
C
316 lines
11 KiB
C
/*
|
|
* client_events.c
|
|
* vim: expandtab:ts=4:sts=4:sw=4
|
|
*
|
|
* Copyright (C) 2012 - 2019 James Booth <boothj5@gmail.com>
|
|
* Copyright (C) 2019 - 2025 Michael Vetter <jubalh@iodoru.org>
|
|
*
|
|
* This file is part of Profanity.
|
|
*
|
|
* Profanity is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Profanity is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with Profanity. If not, see <https://www.gnu.org/licenses/>.
|
|
*
|
|
* In addition, as a special exception, the copyright holders give permission to
|
|
* link the code of portions of this program with the OpenSSL library under
|
|
* certain conditions as described in each individual source file, and
|
|
* distribute linked combinations including the two.
|
|
*
|
|
* You must obey the GNU General Public License in all respects for all of the
|
|
* code used other than OpenSSL. If you modify file(s) with this exception, you
|
|
* may extend this exception to your version of the file(s), but you are not
|
|
* obligated to do so. If you do not wish to do so, delete this exception
|
|
* statement from your version. If you delete this exception statement from all
|
|
* source files in the program, then also delete it here.
|
|
*
|
|
*/
|
|
|
|
#include "config.h"
|
|
|
|
#include <stdlib.h>
|
|
#include <assert.h>
|
|
#include <glib.h>
|
|
|
|
#include "ai/ai_client.h"
|
|
#include "log.h"
|
|
#include "chatlog.h"
|
|
#include "database.h"
|
|
#include "client_events.h"
|
|
#include "config/preferences.h"
|
|
#include "event/common.h"
|
|
#include "plugins/plugins.h"
|
|
#include "ui/inputwin.h"
|
|
#include "ui/window.h"
|
|
#include "xmpp/chat_session.h"
|
|
#include "xmpp/session.h"
|
|
#include "xmpp/xmpp.h"
|
|
|
|
#ifdef HAVE_LIBOTR
|
|
#include "otr/otr.h"
|
|
#endif
|
|
|
|
#ifdef HAVE_LIBGPGME
|
|
#include "pgp/gpg.h"
|
|
#endif
|
|
|
|
#ifdef HAVE_OMEMO
|
|
#include "omemo/omemo.h"
|
|
#endif
|
|
|
|
jabber_conn_status_t
|
|
cl_ev_connect_jid(const char* const jid, const char* const passwd, const char* const altdomain, const int port, const char* const tls_policy, const char* const auth_policy)
|
|
{
|
|
cons_show("Connecting as %s", jid);
|
|
return session_connect_with_details(jid, passwd, altdomain, port, tls_policy, auth_policy);
|
|
}
|
|
|
|
jabber_conn_status_t
|
|
cl_ev_connect_account(ProfAccount* account)
|
|
{
|
|
if (account->resource) {
|
|
cons_show("Connecting with account %s as %s/%s", account->name, account->jid, account->resource);
|
|
} else if (g_strcmp0(account->name, account->jid) == 0) {
|
|
cons_show("Connecting with account %s", account->name);
|
|
} else {
|
|
cons_show("Connecting with account %s as %s", account->name, account->jid);
|
|
}
|
|
|
|
return session_connect_with_account(account);
|
|
}
|
|
|
|
void
|
|
cl_ev_disconnect(void)
|
|
{
|
|
cons_show("%s logged out successfully.", connection_get_barejid());
|
|
|
|
ui_close_all_wins();
|
|
ev_disconnect_cleanup();
|
|
// on intentional disconnect reset the counter
|
|
ev_reset_connection_counter();
|
|
}
|
|
|
|
void
|
|
cl_ev_reconnect(void)
|
|
{
|
|
jabber_conn_status_t conn_status = connection_get_status();
|
|
if (conn_status == JABBER_CONNECTING) {
|
|
cons_show_error("Reconnection aborted: Connection attempt is already in progress");
|
|
return;
|
|
}
|
|
|
|
cons_show("Reconnecting now.");
|
|
if (conn_status != JABBER_DISCONNECTED && conn_status != JABBER_DISCONNECTING) {
|
|
connection_disconnect();
|
|
ev_disconnect_cleanup();
|
|
// on intentional disconnect reset the counter
|
|
ev_reset_connection_counter();
|
|
}
|
|
session_reconnect_now();
|
|
}
|
|
|
|
void
|
|
cl_ev_presence_send(const resource_presence_t presence_type, const int idle_secs)
|
|
{
|
|
auto_char char* signed_status = NULL;
|
|
|
|
#ifdef HAVE_LIBGPGME
|
|
ProfAccount* account = accounts_get_account(session_get_account_name());
|
|
if (account->pgp_keyid) {
|
|
signed_status = p_gpg_sign(connection_get_presence_msg(), account->pgp_keyid);
|
|
}
|
|
account_free(account);
|
|
#endif
|
|
|
|
presence_send(presence_type, idle_secs, signed_status);
|
|
}
|
|
|
|
void
|
|
cl_ev_send_msg_correct(ProfChatWin* chatwin, const char* const msg, const char* const oob_url, gboolean correct_last_msg)
|
|
{
|
|
chat_state_active(chatwin->state);
|
|
|
|
gboolean request_receipt = prefs_get_boolean(PREF_RECEIPTS_REQUEST);
|
|
|
|
auto_char char* plugin_msg = plugins_pre_chat_message_send(chatwin->barejid, msg);
|
|
const char* const message = plugin_msg ?: msg;
|
|
|
|
char* replace_id = NULL;
|
|
if (correct_last_msg) {
|
|
replace_id = chatwin->last_msg_id;
|
|
}
|
|
|
|
if (chatwin->is_omemo) {
|
|
#ifdef HAVE_OMEMO
|
|
auto_char char* id = omemo_on_message_send((ProfWin*)chatwin, message, request_receipt, FALSE, replace_id);
|
|
if (id != NULL) {
|
|
chat_log_omemo_msg_out(chatwin->barejid, message, NULL);
|
|
log_database_add_outgoing_chat(id, chatwin->barejid, message, replace_id, PROF_MSG_ENC_OMEMO);
|
|
chatwin_outgoing_msg(chatwin, message, id, PROF_MSG_ENC_OMEMO, request_receipt, replace_id);
|
|
}
|
|
#endif
|
|
} else if (chatwin->is_ox) {
|
|
#ifdef HAVE_LIBGPGME
|
|
// XEP-0373: OpenPGP for XMPP
|
|
auto_char char* id = message_send_chat_ox(chatwin->barejid, message, request_receipt, replace_id);
|
|
if (id != NULL) {
|
|
chat_log_pgp_msg_out(chatwin->barejid, message, NULL);
|
|
log_database_add_outgoing_chat(id, chatwin->barejid, message, replace_id, PROF_MSG_ENC_OX);
|
|
chatwin_outgoing_msg(chatwin, message, id, PROF_MSG_ENC_OX, request_receipt, replace_id);
|
|
}
|
|
#endif
|
|
} else if (chatwin->pgp_send) {
|
|
#ifdef HAVE_LIBGPGME
|
|
auto_char char* id = message_send_chat_pgp(chatwin->barejid, message, request_receipt, replace_id);
|
|
if (id != NULL) {
|
|
chat_log_pgp_msg_out(chatwin->barejid, message, NULL);
|
|
log_database_add_outgoing_chat(id, chatwin->barejid, message, replace_id, PROF_MSG_ENC_PGP);
|
|
chatwin_outgoing_msg(chatwin, message, id, PROF_MSG_ENC_PGP, request_receipt, replace_id);
|
|
}
|
|
#endif
|
|
} else {
|
|
gboolean handled = FALSE;
|
|
#ifdef HAVE_LIBOTR
|
|
handled = otr_on_message_send(chatwin, message, request_receipt, replace_id);
|
|
#endif
|
|
if (!handled && allow_unencrypted_message(chatwin, message)) {
|
|
auto_char char* id = message_send_chat(chatwin->barejid, message, oob_url, request_receipt, replace_id);
|
|
chat_log_msg_out(chatwin->barejid, message, NULL);
|
|
log_database_add_outgoing_chat(id, chatwin->barejid, message, replace_id, PROF_MSG_ENC_NONE);
|
|
chatwin_outgoing_msg(chatwin, message, id, PROF_MSG_ENC_NONE, request_receipt, replace_id);
|
|
}
|
|
}
|
|
|
|
plugins_post_chat_message_send(chatwin->barejid, message);
|
|
return;
|
|
}
|
|
|
|
void
|
|
cl_ev_send_msg(ProfChatWin* chatwin, const char* const msg, const char* const oob_url)
|
|
{
|
|
cl_ev_send_msg_correct(chatwin, msg, oob_url, FALSE);
|
|
}
|
|
|
|
void
|
|
cl_ev_send_muc_msg_corrected(ProfMucWin* mucwin, const char* const msg, const char* const oob_url, gboolean correct_last_msg)
|
|
{
|
|
auto_char char* plugin_msg = plugins_pre_room_message_send(mucwin->roomjid, msg);
|
|
const char* const message = plugin_msg ?: msg;
|
|
|
|
char* replace_id = NULL;
|
|
if (correct_last_msg) {
|
|
replace_id = mucwin->last_msg_id;
|
|
}
|
|
|
|
#ifdef HAVE_OMEMO
|
|
if (mucwin->is_omemo) {
|
|
auto_char char* id = omemo_on_message_send((ProfWin*)mucwin, message, FALSE, TRUE, replace_id);
|
|
groupchat_log_omemo_msg_out(mucwin->roomjid, message);
|
|
log_database_add_outgoing_muc(id, mucwin->roomjid, message, replace_id, PROF_MSG_ENC_OMEMO);
|
|
mucwin_outgoing_msg(mucwin, message, id, PROF_MSG_ENC_OMEMO, replace_id);
|
|
} else
|
|
#endif
|
|
{
|
|
auto_char char* id = message_send_groupchat(mucwin->roomjid, message, oob_url, replace_id);
|
|
groupchat_log_msg_out(mucwin->roomjid, message);
|
|
log_database_add_outgoing_muc(id, mucwin->roomjid, message, replace_id, PROF_MSG_ENC_NONE);
|
|
mucwin_outgoing_msg(mucwin, message, id, PROF_MSG_ENC_NONE, replace_id);
|
|
}
|
|
|
|
plugins_post_room_message_send(mucwin->roomjid, message);
|
|
return;
|
|
}
|
|
|
|
void
|
|
cl_ev_send_muc_msg(ProfMucWin* mucwin, const char* const msg, const char* const oob_url)
|
|
{
|
|
cl_ev_send_muc_msg_corrected(mucwin, msg, oob_url, FALSE);
|
|
}
|
|
|
|
void
|
|
cl_ev_send_priv_msg(ProfPrivateWin* privwin, const char* const msg, const char* const oob_url)
|
|
{
|
|
if (privwin->occupant_offline) {
|
|
privwin_message_occupant_offline(privwin);
|
|
} else if (privwin->room_left) {
|
|
privwin_message_left_room(privwin);
|
|
} else {
|
|
auto_char char* plugin_msg = plugins_pre_priv_message_send(privwin->fulljid, msg);
|
|
const char* const message = plugin_msg ?: msg;
|
|
auto_jid Jid* jidp = jid_create(privwin->fulljid);
|
|
|
|
auto_char char* id = message_send_private(privwin->fulljid, message, oob_url);
|
|
chat_log_msg_out(jidp->barejid, message, jidp->resourcepart);
|
|
log_database_add_outgoing_muc_pm(id, privwin->fulljid, message, NULL, PROF_MSG_ENC_NONE);
|
|
privwin_outgoing_msg(privwin, message);
|
|
|
|
plugins_post_priv_message_send(privwin->fulljid, message);
|
|
}
|
|
}
|
|
|
|
gboolean
|
|
allow_unencrypted_message(ProfChatWin* chatwin, const char* const msg)
|
|
{
|
|
if (!prefs_get_boolean(PREF_FORCE_ENCRYPTION)) {
|
|
return TRUE; // Encryption not enforced
|
|
}
|
|
|
|
auto_gchar gchar* force_enc_mode = prefs_get_string(PREF_FORCE_ENCRYPTION_MODE);
|
|
|
|
if (g_strcmp0(force_enc_mode, "block") == 0) {
|
|
win_println((ProfWin*)chatwin, THEME_ERROR, "-", "Message not sent: encryption required. Enable (omemo/pgp/otr) encryption or /force-encryption off.");
|
|
return FALSE;
|
|
}
|
|
|
|
if (g_strcmp0(force_enc_mode, "resend-to-confirm") == 0) {
|
|
// We do not use chatwin->last_message because it should be set only if it's sent.
|
|
guint msg_hash = g_str_hash(msg);
|
|
if (msg_hash == chatwin->last_attempted_msg_hash) {
|
|
chatwin->last_attempted_msg_hash = 0; // reset hash
|
|
return TRUE;
|
|
}
|
|
win_println((ProfWin*)chatwin, THEME_ERROR, "-", "Message not sent: encryption required. Enable (omemo/pgp/otr) encryption or press Enter again to send without encryption. Use /force-encryption off to disable this protection.");
|
|
chatwin->last_attempted_msg_hash = msg_hash;
|
|
inp_set_line(msg); // reset message to prevent the need to retype it
|
|
return FALSE;
|
|
}
|
|
|
|
win_println((ProfWin*)chatwin, THEME_ERROR, "-", "Message not sent: invalid encryption mode (%s). Use '/force-encryption policy resend-to-confirm'.", force_enc_mode);
|
|
return FALSE;
|
|
}
|
|
|
|
void
|
|
cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id)
|
|
{
|
|
if (!aiwin || !message) {
|
|
return;
|
|
}
|
|
|
|
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
|
|
|
if (!aiwin->session) {
|
|
log_error("[AI] AI session not initialized");
|
|
return;
|
|
}
|
|
|
|
/* Reset paged flag before printing user message.
|
|
* If the user scrolled up to view history, paged=1 would suppress
|
|
* the message in _win_printf(). Reset it here so the message displays. */
|
|
aiwin->window.layout->paged = 0;
|
|
aiwin->window.layout->unread_msg = 0;
|
|
|
|
// Display user message in AI window.
|
|
win_print_outgoing(&aiwin->window, ">>", id, NULL, message);
|
|
|
|
// Send to AI provider.
|
|
ai_send_prompt(aiwin->session, message, aiwin);
|
|
}
|