<?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;
}
}