/ src / controllers / ApiController.php
<?php
/**
 *  SeekQuarry/Yioop --
 *  Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 *  Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 *  LICENSE:
 *
 *  This program 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.
 *
 *  This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 *  END LICENSE
 *
 *  @author Eswara Rajesh Pinapala epinapala@live.com
 *  @license https://www.gnu.org/licenses/ GPL3
 *  @link https://www.seekquarry.com/
 *  @copyright 2009 - 2026
 *  @filesource
 */
namespace seekquarry\yioop\controllers;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\mail\UnsubscribeToken;

/**
 * ApiController answers requests for pieces of a page rather than for a
 * whole one, which is what the scripts in a page ask for: a help page
 * from the wiki, or the next batch of search results as a reader scrolls
 * on.
 *
 * @author Eswara Rajesh Pinapala
 */
class ApiController extends Controller implements CrawlConstants
{
    /**
     * $component_activities says which activities of this controller
     * belong to which component. A component is a set of activities held
     * together, a little like a trait, which
     * can be reused.
     *
     * @var array
     */
    public static $component_activities = [
        "wiki" => ["wiki"]];
    /**
     * These are the activities supported by this controller
     * @var array
     */
    public $activities = ["summarize",
        "suggest", "unsubscribe"];
    /**
     * processRequest carries out a request about a group that came from
     * outside the control panel. This either could be because the admin panel
     * is "collapsed" or because the request concerns a wiki page.
     *
     * @return mixed configureRequest result when no profile is
     *      configured; under HTTP this typically exits via
     *      displayView or redirectLocation without returning
     */
    public function processRequest()
    {
        $data = [];
        if (!C\PROFILE) {
            return $this->configureRequest();
        }
        if (isset($_SESSION['USER_ID'])) {
            if ($this->getCSRFTime(C\p('CSRF_TOKEN')) == 0 &&
                $_SERVER['REQUEST_METHOD'] == "GET") {
                $_REQUEST[C\p('CSRF_TOKEN')] = $this->generateCSRFToken(
                    $_SESSION['USER_ID']);
                $this->redirectLocation(C\SHORT_BASE_URL . "?" .
                    http_build_query($_REQUEST));
                exit();
            }
            $user_id = $_SESSION['USER_ID'];
            $data['ADMIN'] = 1;
        } else {
            $user_id = L\remoteAddress();
        }
        $data['SCRIPT'] = "";
        $token_okay = $this->checkCSRFToken(C\p('CSRF_TOKEN'), $user_id);
        $data = array_merge($data, $this->processSession());
        if (isset($data["VIEW"])) {
            $view = $data["VIEW"];
        } else {
            $view = 'api';
        }
        $_SESSION['REMOTE_ADDR'] = L\remoteAddress();
        $this->displayView($view, $data);
    }
    /**
     * processSession makes the call to whichever activity the request
     * asked for, once the reader is known and the request has been
     * checked.
     * processSession is called from @see processRequest, which does some
     * cleaning of fields if the CSRFToken is not valid. It is more likely
     * that that api_controller may be involved in such requests as it can
     * be invoked either when a user is logged in or not and for users with and
     * without accounts. processSession makes sure the $_REQUEST'd activity is
     * valid (or falls back to groupFeeds) then calls it. If someone uses
     * the Settings link to change the language or default number of feed
     * elements to view, this method sets up the $data variable so that
     * the back/cancel button on that page works correctly.
     *
     * @return array $data field variables produced by the dispatched
     *      activity (one of "summarize" or the wiki fallback),
     *      augmented with PAGE_TITLE and ACTIVITY_METHOD
     */
    public function processSession()
    {
        if (isset($_REQUEST['a']) &&
                in_array($_REQUEST['a'], $this->activities)) {
            $activity = $this->clean($_REQUEST['a'], "string");
        } else {
            $activity = "wiki";
        }
        $data = $this->call($activity);
        $data['PAGE_TITLE'] = $data['PAGE_TITLE'] ??
            $this->clean($_REQUEST['page_name'] ?? "", "string");
        $data['ACTIVITY_METHOD'] = $activity;
        if (!is_array($data)) {
            $data = [];
        }
        return $data;
    }
    /**
     * getMessages gives back the posts of one thread, as the reader who
     * asked may see them.
     *
     * @param string $thread_id The id of the thread to get messages for.
     * @return array $messages containing the messages for the thread
     */
    private function getMessages($thread_id)
    {
        $group_model = $this->model("group");
        $feed_model = $this->model("feed");
        $user_id = isset($_SESSION['USER_ID']) ?
            $_SESSION['USER_ID'] : C\PUBLIC_USER_ID;
        $search_array = [ ["parent_id", "=", $thread_id, ""] ];
        $limit = 0;
        $results_per_page = C\MAX_SUMMARIZE_MESSAGES;
        /* -2 is just_thread case */
        $for_group = -2;
        $sort = "ksort";
        $messages = $feed_model->getGroupItems($limit, $results_per_page,
            $search_array, $user_id, $for_group);
        return $messages;
    }
    /**
     * Handles a request to summarize a thread using the LLM.
     *
     * Expected parameters:
     * - thread_id: The id of the thread to summarize.
     *
     * @return array $data containing summary results and status
     */
    public function summarize()
    {
        $data = [];
        $data['ELEMENT'] = 'summarize';
        if (empty($_REQUEST['thread_id'])) {
            $data['ERRORS'] = ["Missing parameter - 'thread_id' required"];
            return $data;
        }
        $thread_id = $this->clean($_REQUEST['thread_id'], "string");
        $user_locale = L\getLocaleTag();
        $messages = $this->getMessages($thread_id);
        if (empty($messages)) {
            $data['ERRORS'] = ["No messages found for thread id $thread_id"];
            return $data;
        }
        $text = "";
        foreach ($messages as $msg) {
            $pretty_date = date("r", $msg['PUBDATE']);
            $username = $msg['USER_NAME'];
            $content = $msg['DESCRIPTION'];
            $text .= "On {$pretty_date}, {$username} wrote: \"{$content}\"\n\n";
        }
        $target_language = $this->getLanguageFromLocale($user_locale);
        $input_text = "\n<in>" . $text . "</in>\n";
        $summarize_prompt = sprintf(
            "Summarize the following thread concisely in %s, " .
            "capturing the key points:%s IMPORTANT: You MUST output " .
            "ONLY the summary between <out></out> tags. Do not include " .
            "any other text, commentary, or explanations outside these " .
            "tags.",
            $target_language, $input_text);
        $api_url = C\LLM_API_URL;
        $llm_model = C\LLM_MODEL;
        $request_body = [
            "model" => $llm_model,
            "messages" => [
                ["role" => "system", "content" => "You are a helpful assistant "
                . "that summarizes threads concisely. You MUST always format "
                . "your response with the summary inside <out></out> "
                . "tags only. "
                . "Never include any text outside these tags."],
                ["role" => "user", "content" => $summarize_prompt]
            ],
            "temperature" => 0.1,
            "max_tokens" => -1,
            "stream" => false
        ];
        $result = $this->sendLLMRequest($api_url, $request_body);
        if (!$result) {
            $data['ERRORS'] = ["Failed to connect to summarization service"];
        } else {
            $decoded = json_decode($result, true);
            if (isset($decoded['choices'][0]['message']['content'])) {
                $content = $decoded['choices'][0]['message']['content'];
                preg_match('/<out>([\s\S]*?)<\/out>/', $content, $matches);
                if (isset($matches[1])) {
                    $data['SUMMARY'] = trim($matches[1]);
                    $data['STATUS'] = 'success';
                } else {
                    $data['ERRORS'] =
                        ["Summary format not recognized - missing " .
                        "<out> tags"];
                }
            } else {
                $data['ERRORS'] = ["Invalid response from LLM"];
            }
        }
        return $data;
    }
    /**
     * getLanguageFromLocale gives the name of a language as a person
     * writes it, from the tag Yioop keeps a locale under. A large
     * language model is asked in those words rather than in tags.
     *
     * @param string $locale_tag The Yioop locale tag (e.g., 'en_US', 'es')
     * @return string The language name for LLM instruction
     */
    private function getLanguageFromLocale($locale_tag)
    {
        $language_map = [
            'ar' => 'Arabic',
            'bn' => 'Bengali',
            'de' => 'German',
            'el_GR' => 'Greek',
            'en_US' => 'English',
            'es' => 'Spanish',
            'fa' => 'Persian',
            'fr_FR' => 'French',
            'he' => 'Hebrew',
            'hi' => 'Hindi',
            'id' => 'Indonesian',
            'it' => 'Italian',
            'ja' => 'Japanese',
            'kn' => 'Kannada',
            'ko' => 'Korean',
            'nl' => 'Dutch',
            'pl' => 'Polish',
            'pt' => 'Portuguese',
            'ru' => 'Russian',
            'te' => 'Telugu',
            'th' => 'Thai',
            'tl' => 'Filipino',
            'tr' => 'Turkish',
            'vi_VN' => 'Vietnamese',
            'zh_CN' => 'Chinese'
        ];

        return isset($language_map[$locale_tag]) ?
            $language_map[$locale_tag] : 'English';
    }
    /**
     * sendLLMRequest asks a large language model service a question and
     * gives back what it answered.
     *
     * @param string $url The API endpoint URL
     * @param array $data The request data to send
     * @return string|bool The response body or false on failure
     */
    private function sendLLMRequest($url, $data)
    {
        $post_data = json_encode($data);
        $headers = ['Content-Type: application/json'];
        $response = L\FetchUrl::getPage($url, $post_data, true, null,
            C\SINGLE_PAGE_TIMEOUT, $headers);
        if ($response === false) {
            error_log("LLM API request failed");
            return false;
        }
        return $response;
    }
    /**
     * suggest gives the names a screen offers while somebody is typing
     * into a field that asks for a person, a group or a role. The
     * messages screen offers people to write to and the manage users
     * screen offers groups and roles to add, and both ask here. Only
     * names beginning with what was typed are given back, in
     * alphabetical order, so what a person is offered always extends
     * what they have already typed. Nothing at all is given back until
     * enough letters have been typed, since two letters match most of
     * a large site.
     *
     * @return array view data holding the names found, which the api
     *     view writes out for the screen that asked
     */
    public function suggest()
    {
        $data = [];
        $data['VIEW'] = "api";
        $data['MODE'] = 'api';
        $data['SUGGESTIONS'] = [];
        $kind = $this->clean($_REQUEST['kind'] ?? "", "string");
        $typed = trim($this->clean($_REQUEST['filter'] ?? "", "string"));
        if (mb_strlen($typed) < C\MIN_SUGGEST_LENGTH) {
            return $data;
        }
        $user_id = $_SESSION['USER_ID'] ?? C\PUBLIC_USER_ID;
        if ($user_id == C\PUBLIC_USER_ID) {
            return $data;
        }
        $how_many = C\MAX_SUGGEST_RESULTS;
        if ($kind == "contact") {
            $data['SUGGESTIONS'] = $this->contactSuggestions($user_id,
                $typed, $how_many);
        } else if ($kind == "group") {
            $found = $this->model("group")->groupsStartingWith($typed,
                $how_many);
            foreach ($found as $one) {
                $data['SUGGESTIONS'][] = ["id" => $one['GROUP_ID'],
                    "name" => $one['GROUP_NAME'], "known" => true];
            }
        } else if ($kind == "role") {
            $found = $this->model("role")->rolesStartingWith($typed,
                $how_many);
            foreach ($found as $one) {
                $data['SUGGESTIONS'][] = ["id" => $one['ROLE_ID'],
                    "name" => $one['NAME'], "known" => true];
            }
        }
        return $data;
    }
    /**
     * contactSuggestions gives the people a person may write to whose
     * name begins with what they have typed. Somebody they already write
     * to comes first and is marked as known, so the screen can open that
     * conversation; anybody else on the site follows and is marked
     * otherwise, so the screen knows to ask them first. Both are in
     * alphabetical order within their own part of the answer.
     *
     * @param int $user_id who is doing the typing
     * @param string $typed the letters typed so far, already trimmed
     * @param int $how_many the most names to give back in all
     * @return array rows holding an id, a name, and whether this is
     *     somebody the person already writes to
     */
    private function contactSuggestions($user_id, $typed, $how_many)
    {
        $group_model = $this->model("group");
        $user_model = $this->model("user");
        $own_group = $group_model->getPersonalGroupId($user_id);
        $contact_ids = array_diff($group_model->getGroupUserIds(
            $own_group), [$user_id]);
        $suggestions = [];
        $found = $user_model->usersStartingWith($typed, $how_many,
            [$user_id]);
        /* Those already written to are listed first, since choosing one
           of them opens a conversation rather than asking to start one. */
        foreach ([true, false] as $wanted_known) {
            foreach ($found as $one) {
                $known = in_array($one['USER_ID'], $contact_ids);
                if ($known !== $wanted_known ||
                    count($suggestions) >= $how_many) {
                    continue;
                }
                $suggestions[] = ["id" => $one['USER_ID'],
                    "name" => $one['USER_NAME'], "known" => $known];
            }
        }
        return $suggestions;
    }
    /**
     * unsubscribe turns off a group's mail for whoever a link names. A
     * mail this site sends carries an unsubscribe address holding a
     * token that names the user and the group. Without the one-click
     * marker the address shows a short confirmation page with a button,
     * so that automated link scanners cannot unsubscribe someone merely
     * by following the link; when the request carries the one-click
     * marker, sent by the button the page shows or by a mail client's
     * own request, the group's mail is turned off for that user. The
     * page itself is drawn by the unsubscribe view, not here.
     *
     * @return array fields the unsubscribe view draws: the state of the
     *      request (invalid link, confirm, or done), the group name, and
     *      the token to repeat on the confirm button
     */
    public function unsubscribe()
    {
        $data = [];
        $data['VIEW'] = "unsubscribe";
        $token = $this->clean($_REQUEST['token'] ?? "", "string");
        $data['UNSUBSCRIBE_TOKEN'] = $token;
        $one_click = isset($_REQUEST['List-Unsubscribe']) &&
            $_REQUEST['List-Unsubscribe'] === "One-Click";
        $parsed = UnsubscribeToken::parse($token);
        $email = ($parsed === false) ?
            UnsubscribeToken::parseEmail($token) : false;
        if ($parsed === false && $email === false) {
            $data['UNSUBSCRIBE_STATE'] = "invalid";
            return $data;
        }
        if ($parsed !== false) {
            $data['UNSUBSCRIBE_SCOPE'] = "group";
            $data['GROUP_NAME'] = $this->clean((string)$this->model("group")
                ->getGroupName($parsed['group_id']), "string");
        } else {
            $data['UNSUBSCRIBE_SCOPE'] = "all";
            $data['UNSUBSCRIBE_EMAIL'] = $this->clean($email, "string");
        }
        if (!$one_click) {
            $data['UNSUBSCRIBE_STATE'] = "confirm";
            return $data;
        }
        if ($parsed !== false) {
            $this->model("group")->setMailSubscription(
                $parsed['user_id'], $parsed['group_id'], 0);
        } else {
            $this->model("mailSuppression")->suppress($email);
        }
        $data['UNSUBSCRIBE_STATE'] = "done";
        return $data;
    }
}
X