satoriapi.c

Sun, 10 Aug 2025 23:53:22 +0800

author
Gong Zhile <gongzl@stu.hebust.edu.cn>
date
Sun, 10 Aug 2025 23:53:22 +0800
changeset 3
33a7b189a2c6
parent 1
98bcf06036b8
permissions
-rw-r--r--

Various improvement, Support configuration from UI

/*
 * Purple Satori Plugin - Satori Protocol Plugin for Purple3
 * Copyright (C) 2025 Gong Zhile
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2 of the License, or (at your option) any later version.
 *
 * This library 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
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, see <https://www.gnu.org/licenses/>.
 */

#include <glib.h>
#include <gio/gio.h>
#include <glib-object.h>
#include <json-glib/json-glib.h>
#include <libsoup/soup-message.h>
#include <libsoup/soup-session.h>

#include "purplesatoriconnection.h"
#include "purplesatoriplugin.h"
#include "purplesatoriprotocolconversation.h"
#include "satorimessage.h"
#include "satoritypes.h"
#include "satoriapi.h"

/******************************************************************************
 * Purple Integration Helpers
 *****************************************************************************/

PurpleConversationType
satoir_channel_type_to_conversation_type(SatoriChannelType t)
{
	switch (t) {
	case SATORI_CHANNEL_DIRECT:
		return PURPLE_CONVERSATION_TYPE_DM;
	case SATORI_CHANNEL_TEXT:
		return PURPLE_CONVERSATION_TYPE_CHANNEL;
	case SATORI_CHANNEL_VOICE:
	case SATORI_CHANNEL_CATEGORY:
		return 0;	/* Unsupported */
	default:
		return 0;
	}
}

void
purple_satori_add_person_from_user(PurpleSatoriConnection *con, SatoriUser *user)
{
	PurpleAccount *acc = \
		purple_connection_get_account(PURPLE_CONNECTION(con));
	PurpleContactManager	*manager  = purple_contact_manager_get_default();

	PurpleContact		*contact  = NULL;
	PurpleContactInfo	*info	  = NULL;
	PurplePresence		*presence = NULL;
	PurplePerson		*person	  = NULL;

	gboolean new_contact = FALSE, new_person = FALSE;

	contact = purple_contact_manager_find_with_id(manager, acc, user->id);
	if (!PURPLE_IS_CONTACT(contact)) {
		contact = purple_contact_new(acc, user->id);
		new_contact = TRUE;
	}

	/* Initialize PurpleContactInfo */
	info = PURPLE_CONTACT_INFO(contact);
	purple_contact_info_set_display_name(info, user->nick ?
					     user->nick : user->name);

	/* Initialize PurplePerson */
	person = purple_contact_info_get_person(info);
	if (!PURPLE_IS_PERSON(person)) {
		person = g_object_new(PURPLE_TYPE_PERSON,
				      "id", user->id, NULL);
		new_person = TRUE;
	}

	if (new_person) {
		purple_person_add_contact_info(person, info);
		purple_contact_info_set_person(info, person);
		g_clear_object(&person);
	}

	/* Initialize PurplePresence */
	presence = purple_contact_info_get_presence(info);
	purple_presence_set_primitive(presence,
				      PURPLE_PRESENCE_PRIMITIVE_AVAILABLE);

	if (new_contact) {
		purple_contact_manager_add(manager, contact);
		g_clear_object(&contact);
	}
}

PurpleConversation *
purple_satori_add_conversation_from_chan(PurpleSatoriConnection *con,
					 SatoriChannel *chan)
{
	PurpleAccount *acc =						\
		purple_connection_get_account(PURPLE_CONNECTION(con));
	PurpleConversationManager *manager =	\
		purple_conversation_manager_get_default();
	PurpleConversation *conversation = NULL;

	conversation = purple_conversation_manager_find_with_id(manager, acc,
								chan->id);

	if (!PURPLE_IS_CONVERSATION(conversation)) {
		PurpleConversationType type = \
			satoir_channel_type_to_conversation_type(chan->type);
		if (!type) return NULL;

		conversation = g_object_new(
			PURPLE_TYPE_CONVERSATION,
			"account", acc,
			"id", chan->id,
			"title", chan->name ? chan->name : "Unnamed Channel",
			"type", type,
			"online", TRUE,
			NULL);

		purple_conversation_manager_add(manager, conversation);
		g_object_unref(conversation);
	}

	purple_conversation_set_online(conversation, TRUE);
	return conversation;
}

PurpleConversationMember *
purple_satori_add_conversation_member_from_user(PurpleSatoriConnection *con,
						PurpleConversation *conversation,
						SatoriUser *user)
{
	PurpleAccount *acc = \
		purple_connection_get_account(PURPLE_CONNECTION(con));
	PurpleContactManager *manager = \
		purple_contact_manager_get_default();
	PurpleContact *contact = NULL;

	gboolean found = TRUE;
	contact = purple_contact_manager_find_with_id(manager, acc, user->id);

	if (!PURPLE_IS_CONTACT(contact)) {
		contact = purple_contact_new(acc, user->id);
		found = FALSE;
	}

	if (user->nick)
		purple_contact_info_set_display_name(
			PURPLE_CONTACT_INFO(contact), user->nick);
	else if (user->name)
		purple_contact_info_set_display_name(
			PURPLE_CONTACT_INFO(contact), user->name);

	PurplePresence *presence = purple_contact_info_get_presence(
		PURPLE_CONTACT_INFO(contact));
	purple_presence_set_primitive(presence,
				      PURPLE_PRESENCE_PRIMITIVE_AVAILABLE);

	if (!found)
		purple_contact_manager_add(manager, contact);

	PurpleConversationMembers *existing_members = \
		purple_conversation_get_members(conversation);
	PurpleConversationMembers *new_members = NULL;
	PurpleConversationMember *member = NULL;

	member = purple_conversation_members_find_member(
		existing_members, PURPLE_CONTACT_INFO(contact));

	if (!PURPLE_IS_CONVERSATION_MEMBER(member)) {
		new_members = purple_conversation_members_new();
		member = purple_conversation_members_add_member(
			new_members, PURPLE_CONTACT_INFO(contact), FALSE, NULL);

		purple_conversation_members_extend(existing_members, new_members);
	}

	g_clear_object(&contact);
	return member;
}

/******************************************************************************
 * Buddy/Friend List Synchronization
 *****************************************************************************/

static void
satori_on_buddy_contacts_resp(SoupSession *session,
			      GAsyncResult *res,
			      PurpleSatoriConnection *con)
{
	GError *error = NULL;
	GBytes *resp = soup_session_send_and_read_finish(session, res, &error);

	if (error) {
		purple_debug_error("satori",
				   "refresh_buddy_contacts failed: %s",
				   error->message);
		if (resp)
			g_bytes_unref(resp);
		g_error_free(error);
		return;
	}

	gsize sz;
	const gchar *ptr = g_bytes_get_data(resp, &sz), *next = NULL;

	JsonParser *parser = json_parser_new();
	if (!json_parser_load_from_data(parser, ptr, sz, NULL)) {
		purple_debug_warning("satori", "bad json received from ws");
		goto finish;
	}

	JsonObject *root = json_node_get_object(json_parser_get_root(parser));
	JsonArray *data = json_object_get_array_member(root, "data");
	next = json_object_get_string_member_with_default(
		root, "next", NULL);

	if (!data) goto finish;

	for (guint i = 0; i < json_array_get_length(data); i++) {
		JsonObject *user_obj = json_array_get_object_element(data, i);

		SatoriUser user = { 0 };
		satori_user_from_json(user_obj, &user);

		purple_satori_add_person_from_user(con, &user);
	}

finish:
	if (next)
		satori_refresh_buddy_contacts(con, next);

        g_bytes_unref(resp);
	g_object_unref(parser);
}

void
satori_refresh_buddy_contacts(PurpleSatoriConnection *con, const gchar *next)
{
	GBytes *data = NULL;

	{
		JB_BEGIN_OBJ(b);
		if (next)
			JBA(b, "next", next);
		JB_END_OBJ(data, b);
	}

	SoupMessage *msg = SATORI_ENDPOINT(con, "/friend.list");
	soup_message_set_request_body_from_bytes(msg, "application/json", data);

	purple_satori_connection_send_and_read_async(
		con, msg, 0, NULL,
		(GAsyncReadyCallback) satori_on_buddy_contacts_resp,
		PURPLE_SATORI_CONNECTION(con));

	g_object_unref(msg);
	g_bytes_unref(data);
}

/******************************************************************************
 * Group/Room List (Recursive) Synchronization
 *****************************************************************************/

static void satori_refresh_guild_channels(PurpleSatoriConnection *,
					  const gchar *,
					  const gchar *);

static void
satori_on_guild_channels_resp(SoupSession *session,
			      GAsyncResult *res,
			      PurpleSatoriConnection *con)
{
	GError *error = NULL;
	GBytes *resp = soup_session_send_and_read_finish(session, res, &error);

	if (error) {
		purple_debug_error("satori",
				   "refresh_conversations failed: %s",
				   error->message);
		if (resp)
			g_bytes_unref(resp);
		g_error_free(error);
		return;
	}

	gsize sz;
	const gchar *ptr = g_bytes_get_data(resp, &sz);

	JsonParser *parser = json_parser_new();
	if (!json_parser_load_from_data(parser, ptr, sz, NULL)) {
		purple_debug_warning("satori", "bad json received from ws");
		goto finish;
	}

	JsonObject *root = json_node_get_object(json_parser_get_root(parser));
	JsonArray *data = json_object_get_array_member(root, "data");

	if (!data) goto finish;

	for (guint i = 0; i < json_array_get_length(data); i++) {
		JsonObject *chan_obj = json_array_get_object_element(data, i);

		SatoriChannel chan = { 0 };
		satori_channel_from_json(chan_obj, &chan);

		purple_satori_add_conversation_from_chan(con, &chan);
	}

finish:
	/* if (next) */
	/* 	satori_refresh_guild_channels(con, next); */

        g_bytes_unref(resp);
	g_object_unref(parser);	
}

static void
satori_refresh_guild_channels(PurpleSatoriConnection *con,
			      const gchar *guild_id,
			      const gchar *next)
{
	GBytes *data = NULL;

	{
		JB_BEGIN_OBJ(b);
		JBA(b, "guild_id", guild_id);
		if (next)
			JBA(b, "next", next);
		JB_END_OBJ(data, b);
	}

	SoupMessage *msg = SATORI_ENDPOINT(con, "/channel.list");
	soup_message_set_request_body_from_bytes(msg, "application/json", data);

	purple_satori_connection_send_and_read_async(
		con, msg, 0, NULL,
		(GAsyncReadyCallback) satori_on_guild_channels_resp,
		PURPLE_SATORI_CONNECTION(con));

	g_object_unref(msg);
	g_bytes_unref(data);
}

static void
satori_on_guild_list_resp(SoupSession *session,
			  GAsyncResult *res,
			  PurpleSatoriConnection *con)
{
	GError *error = NULL;
	GBytes *resp = soup_session_send_and_read_finish(session, res, &error);

	if (error) {
		purple_debug_error("satori",
				   "refresh_conversations failed: %s",
				   error->message);
		if (resp)
			g_bytes_unref(resp);
		g_error_free(error);
		return;
	}

	gsize sz;
	const gchar *ptr = g_bytes_get_data(resp, &sz), *next = NULL;

	JsonParser *parser = json_parser_new();
	if (!json_parser_load_from_data(parser, ptr, sz, NULL)) {
		purple_debug_warning("satori", "bad json received from ws");
		goto finish;
	}

	JsonObject *root = json_node_get_object(json_parser_get_root(parser));
	JsonArray *data = json_object_get_array_member(root, "data");
	next = json_object_get_string_member_with_default(
		root, "next", NULL);

	if (!data) goto finish;

	for (guint i = 0; i < json_array_get_length(data); i++) {
		JsonObject *guild_obj = json_array_get_object_element(data, i);
		satori_refresh_guild_channels(
			con,
			json_object_get_string_member(guild_obj, "id"),
			NULL);
	}

finish:
	if (next)
		satori_refresh_conversations(con, next);

        g_bytes_unref(resp);
	g_object_unref(parser);
}

void
satori_refresh_conversations(PurpleSatoriConnection *con, const gchar *next)
{
	GBytes *data = NULL;

	{
		JB_BEGIN_OBJ(b);
		if (next)
			JBA(b, "next", next);
		JB_END_OBJ(data, b);
	}

	SoupMessage *msg = SATORI_ENDPOINT(con, "/guild.list");
	soup_message_set_request_body_from_bytes(msg, "application/json", data);

	purple_satori_connection_send_and_read_async(
		con, msg, 0, NULL,
		(GAsyncReadyCallback) satori_on_guild_list_resp,
		PURPLE_SATORI_CONNECTION(con));

	g_object_unref(msg);
	g_bytes_unref(data);
}

/******************************************************************************
 * DM Creation
 *****************************************************************************/

typedef struct {
	PurpleSatoriConnection *con;
	SatoriUser user;
	GTask *task;
} SatoriOnDmChannelData;

static void
satori_on_dm_channel(SoupSession *session,
		     GAsyncResult *res,
		     SatoriOnDmChannelData *dptr)
{
	GError *error = NULL;
	GBytes *resp = soup_session_send_and_read_finish(session, res, &error);

	if (error) {
		purple_debug_error("satori",
				   "create_dm_channel failed: %s",
				   error->message);
		if (resp)
			g_bytes_unref(resp);

		g_task_return_error(dptr->task, error);
		goto cleanup;
	}

	gsize sz;
	const gchar *ptr = g_bytes_get_data(resp, &sz);

	JsonParser *parser = json_parser_new();
	if (!json_parser_load_from_data(parser, ptr, sz, NULL)) {
		purple_debug_warning("satori", "bad json received from api");
		g_task_return_new_error_literal(dptr->task, PURPLE_SATORI_DOMAIN, 0,
						"bad json received from api");
		goto cleanup;
	}

	JsonObject *root = json_node_get_object(json_parser_get_root(parser));

	SatoriChannel chan;
	satori_channel_from_json(root, &chan);

	PurpleConversation *conversation = \
		purple_satori_add_conversation_from_chan(dptr->con, &chan);

	if (!chan.name)
		purple_conversation_set_title(conversation, dptr->user.name);

	purple_satori_add_conversation_member_from_user(dptr->con, conversation,
							&dptr->user);

	g_task_return_pointer(dptr->task, conversation, g_object_unref);
	g_object_unref(parser);
cleanup:
	g_clear_object(&dptr->task);
	g_free((gchar *) dptr->user.name);
	g_free((gchar *) dptr->user.id);
	g_free(dptr);
}

void
satori_create_dm_channel(PurpleSatoriConnection *con,
			 SatoriUser *user,
			 GTask *task)
{
	GBytes *data = NULL;

	{
		JB_BEGIN_OBJ(b);
		JBA(b, "user_id", user->id);
		JB_END_OBJ(data, b);
	}

	SoupMessage *msg = SATORI_ENDPOINT(con, "/user.channel.create");
	soup_message_set_request_body_from_bytes(msg, "application/json", data);

	SatoriOnDmChannelData *dptr = g_new0(SatoriOnDmChannelData, 1);
	dptr->user.name = dptr->user.nick = g_strdup(user->name);
	dptr->user.id = g_strdup(user->id);
	dptr->task = task;
	dptr->con = con;

	purple_satori_connection_send_and_read_async(
		con, msg, 0, NULL,
		(GAsyncReadyCallback) satori_on_dm_channel,
	        dptr);

	g_object_unref(msg);
	g_bytes_unref(data);
}

/******************************************************************************
 * Message Routines
 *****************************************************************************/

typedef struct {
	PurpleConversation *conversation;
	PurpleMessage *message;
	GTask *task;
} SatoriSendMessageData;

static void
satori_on_message_sent(SoupSession *session,
		       GAsyncResult *res,
		       SatoriSendMessageData *dptr)
{
	GError *error = NULL;
	GBytes *resp = soup_session_send_and_read_finish(session, res, &error);

	if (error) {
		purple_debug_error("satori",
				   "create_dm_channel failed: %s",
				   error->message);
		if (resp)
			g_bytes_unref(resp);

		g_task_return_error(dptr->task, error);
		goto cleanup;
	}

	gsize sz;
	const gchar *ptr = g_bytes_get_data(resp, &sz);

	JsonParser *parser = json_parser_new();
	if (!json_parser_load_from_data(parser, ptr, sz, NULL)) {
		purple_debug_warning("satori", "bad json received from api");
		g_task_return_new_error_literal(dptr->task, PURPLE_SATORI_DOMAIN, 0,
						"bad json received from api");
		goto cleanup;
	}

	/* Initialize ID */

	JsonArray *root = json_node_get_array(json_parser_get_root(parser));
	JsonObject *msg_obj = json_array_get_object_element(root, 0);
	const gchar *id = json_object_get_string_member_with_default(
		msg_obj, "id", NULL);

	if (!id) {
		g_task_return_new_error_literal(dptr->task, PURPLE_SATORI_DOMAIN, 0,
						"message not found");
		goto cleanup;
	}

	purple_message_set_id(dptr->message, id);
	purple_message_set_delivered(dptr->message, TRUE);

	/* Initialize created_time */

	time_t created_at = json_object_get_int_member_with_default(
		msg_obj, "created_at", 0) / 1000; /* timestamp in mS */

	if (created_at) {
		GDateTime *ts = g_date_time_new_from_unix_local(created_at);
		purple_message_set_delivered_at(dptr->message, ts);
		g_date_time_unref(ts);
	}

	g_task_return_boolean(dptr->task, TRUE);
	g_object_unref(parser);
cleanup:
	g_clear_object(&dptr->task);
	g_free(dptr);
}

void
satori_send_message(PurpleSatoriConnection *con,
		    PurpleConversation *conversation,
		    PurpleMessage *message,
		    const gchar *content,
		    GTask *task)
{
	GBytes *data = NULL;

	{
		JB_BEGIN_OBJ(b);
		JBA(b, "channel_id", purple_conversation_get_id(conversation));
		JBA(b, "content", content);
		JB_END_OBJ(data, b);
	}

	SoupMessage *msg = SATORI_ENDPOINT(con, "/message.create");
	soup_message_set_request_body_from_bytes(msg, "application/json", data);

	SatoriSendMessageData *dptr = g_new0(SatoriSendMessageData, 1);
	dptr->conversation = conversation;
	dptr->message = message;
	dptr->task = task;

	purple_satori_connection_send_and_read_async(
		con, msg, 0, NULL,
		(GAsyncReadyCallback) satori_on_message_sent,
		dptr);

	g_object_unref(msg);
	g_bytes_unref(data);
}

mercurial