Add discord client

This commit is contained in:
Li 2024-03-15 19:08:47 +13:00
parent 324a14a4e4
commit ba683ad1f9
14 changed files with 516 additions and 136 deletions

View File

@ -0,0 +1,136 @@
using Newtonsoft.Json;
using PluralRichPresence.Api;
using System.Dynamic;
using System.Net.WebSockets;
using System.Text;
namespace PluralRichPresence.Api
{
public class AWebSocket : IDisposable
{
private string websocketServerUri;
SemaphoreSlim? semaphore = new SemaphoreSlim(1, 1);
ClientWebSocket? wss = null;
public event EventHandler<DataReceivedEventArgs>? DataReceived;
public event EventHandler<TextReceivedEventArgs>? TextReceived;
public event EventHandler? Disconnected;
private void onTextReceived(string receivedString)
{
if (TextReceived is not null)
{
TextReceived(this, new TextReceivedEventArgs(receivedString));
}
}
private void onDataReceived(byte[] receivedData)
{
if (DataReceived is not null)
{
DataReceived(this, new DataReceivedEventArgs(receivedData));
}
}
private void onDisconnect()
{
if (Disconnected is not null)
{
if (semaphore is null || !semaphore.Wait(0)) return;
Disconnected(this, new EventArgs());
}
}
public AWebSocket(string serverUri)
{
this.websocketServerUri = serverUri;
}
private async Task<string> receiveMessageText()
{
byte[] byteArray = await receiveMessageBytes();
return Encoding.UTF8.GetString(byteArray);
}
private async Task<byte[]> receiveMessageBytes()
{
List<byte> totalPayload = new List<byte>();
byte[] buffer = new byte[0x8000];
while (wss is not null && wss.State == WebSocketState.Open)
{
try
{
WebSocketReceiveResult? result = await wss.ReceiveAsync(buffer, CancellationToken.None);
for (int i = 0; i < result.Count; i++)
totalPayload.Add(buffer[i]);
if (result.EndOfMessage)
return totalPayload.ToArray();
}
catch(Exception) { onDisconnect(); break; };
}
return totalPayload.ToArray();
}
private async Task receiveTask()
{
while (wss is not null && wss.State == WebSocketState.Open) {
try {
string message = await receiveMessageText();
Logger.Debug("< " + message);
onTextReceived(message);
}
catch (Exception) { Logger.Debug("failed"); break; };
}
onDisconnect();
}
public async Task Connect()
{
wss = new ClientWebSocket();
await wss.ConnectAsync(new Uri(this.websocketServerUri), CancellationToken.None);
_ = Task.Run(() => receiveTask());
try {
if(semaphore is not null)
semaphore.Release();
}
catch (SemaphoreFullException) { };
}
public async Task SendText(string text)
{
Logger.Debug("> "+text);
try {
if (wss is not null)
await wss.SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text, true, CancellationToken.None);
}
catch (Exception) { onDisconnect(); }
}
public async Task Close()
{
if (wss is not null)
await wss.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
}
public void Dispose()
{
if(wss is not null)
{
this.Close().Wait();
wss.Dispose();
wss = null;
}
if (semaphore is not null)
{
semaphore.Dispose();
semaphore = null;
}
}
}
}

View File

@ -1,4 +1,4 @@
namespace PluralRichPresence.SimplyPlural
namespace PluralRichPresence.Api
{
public class ApiType
{

View File

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PluralRichPresence.Api
{
public class DataReceivedEventArgs : EventArgs
{
public byte[] Data;
public DataReceivedEventArgs(byte[] data)
{
this.Data = data;
}
}
}

View File

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PluralRichPresence.Api
{
public class TextReceivedEventArgs : EventArgs
{
public string Text;
public TextReceivedEventArgs(string text)
{
this.Text = text;
}
}
}

View File

@ -0,0 +1,210 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PluralRichPresence.Api;
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Net.NetworkInformation;
using System.Text;
using System.Threading.Tasks;
namespace PluralRichPresence.Discord
{
public class DiscordClient : ApiType, IDiscordActivitySetter
{
const string DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
private const string DEFAULT_APPLICATION_ID = "1163661006719963158";
private static HttpClient client = new HttpClient();
private SystemMember? lastFronter = null;
private int? seq = null;
private Timer? keepAliveTimer = null;
private AWebSocket? wSock = null;
private ManualResetEvent waitForConnect = new ManualResetEvent(false);
private int heartBeatInterval = 0;
private void wSockTextReceived(object? sender, TextReceivedEventArgs e)
{
try
{
if (e.Text == "") return;
dynamic? jsonData = JsonConvert.DeserializeObject(e.Text);
if (jsonData is null) return;
seq = jsonData.s;
switch ((int)jsonData.op)
{
case 0:
if (jsonData.t == "READY") waitForConnect.Set();
break;
case 1:
_ = sendKeepAlive();
break;
case 9:
_ = reconnect();
break;
case 10:
sendKeepAlive().Wait();
heartBeatInterval = jsonData.d.heartbeat_interval;
keepAliveTimer = new Timer((TimerCallback) => { _ = sendKeepAlive(); }, null, heartBeatInterval, 0);
break;
}
}
catch (Exception) { };
}
private void wSockDisconnected(object? sender, EventArgs e)
{
_ = reconnect();
}
private async Task<string> getExternalAsset(string url)
{
try
{
dynamic extAssReq = new JObject();
extAssReq.urls = new JArray(url);
StringContent apiReq = new StringContent(JsonConvert.SerializeObject(extAssReq));
apiReq.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");
HttpResponseMessage resp = await client.PostAsync("https://discord.com/api/v9/applications/" + Config.GetEntry("DISCORD_APPLICATION_ID", DEFAULT_APPLICATION_ID) + "/external-assets", apiReq);
resp.EnsureSuccessStatusCode();
string responseString = await resp.Content.ReadAsStringAsync();
dynamic? jsonResponse = JsonConvert.DeserializeObject(responseString);
if(jsonResponse is not null)
return jsonResponse[0].external_asset_path;
return "1163671691000557591";
}
catch (Exception) { return "1163671691000557591"; };
}
private async Task connect()
{
wSock = new AWebSocket(Config.GetEntry("DISCORD_GATEWAY_WEBSOCKET_URL", DEFAULT_DISCORD_GATEWAY_URL));
await wSock.Connect();
wSock.TextReceived += wSockTextReceived;
wSock.Disconnected += wSockDisconnected;
}
private async Task reconnect()
{
waitForConnect.Reset();
while (true)
{
try
{
try
{
if (wSock is not null) wSock.Dispose();
wSock = null;
}
catch (Exception) { };
await connect();
await sendLogin();
if (lastFronter is not null)
SetFronter(lastFronter);
}
catch (Exception) { Logger.Debug("failed to connect."); continue; }
break;
}
}
public DiscordClient(string token) : base(token)
{
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) discord/1.0.9036 Chrome/108.0.5359.215 Electron/22.3.26 Safari/537.36");
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", "https://discord.com/");
client.DefaultRequestHeaders.TryAddWithoutValidation("Referer", "https://discord.com/");
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", this.token);
_ = reconnect();
}
private async Task sendLogin()
{
dynamic gatewayOp = new JObject();
gatewayOp.op = 2;
gatewayOp.d = new JObject();
gatewayOp.d.token = this.token;
gatewayOp.d.capabilities = 16381;
gatewayOp.d.properties = new JObject();
gatewayOp.d.properties.os = "Windows";
gatewayOp.d.properties.browser = "Discord Client";
gatewayOp.d.properties.release_channel = "stable";
gatewayOp.d.properties.client_version = "1.0.9035";
gatewayOp.d.properties.os_version = "10.0.19045";
gatewayOp.d.properties.os_arch = "x64";
gatewayOp.d.properties.app_arch = "ia32";
gatewayOp.d.properties.system_locale = "en-G";
gatewayOp.d.properties.browser_user_agent = "ia32";
gatewayOp.d.properties.browser_version = "22.3.26";
gatewayOp.d.properties.client_build_number = 274388;
gatewayOp.d.properties.native_build_number = 44780;
gatewayOp.d.properties.client_event_source = null;
if (wSock is not null)
await wSock.SendText(JsonConvert.SerializeObject(gatewayOp));
}
private async Task sendKeepAlive()
{
dynamic gatewayOp = new JObject();
gatewayOp.op = 1;
gatewayOp.d = seq;
Logger.Debug("SENDING KEEP ALIVE MESSAGE");
if (wSock is not null)
await wSock.SendText(JsonConvert.SerializeObject(gatewayOp));
if(keepAliveTimer is not null)
keepAliveTimer.Change(this.heartBeatInterval, 0);
}
public void SetFronter(SystemMember sysMember)
{
waitForConnect.WaitOne();
dynamic gatewayOp = new JObject();
gatewayOp.op = 3;
gatewayOp.d = new JObject();
gatewayOp.d.status = "online";
gatewayOp.d.since = 0;
dynamic fronterActivity = new JObject();
fronterActivity.state = sysMember.Pronouns;
fronterActivity.details = sysMember.Name;
if(sysMember.TimeStamp is not null)
{
fronterActivity.timestamps = new JObject();
fronterActivity.timestamps.start = sysMember.TimeStamp;
}
fronterActivity.assets = new JObject();
fronterActivity.assets.large_image = (sysMember.ProfilePhotoUrl is not null) ? "mp:"+getExternalAsset(sysMember.ProfilePhotoUrl).Result : "1163671691000557591";
fronterActivity.assets.large_text = sysMember.Name + " - " + sysMember.Pronouns;
if (sysMember.ProfilePhotoUrl is not null)
fronterActivity.assets.small_image = "1163671691000557591";
fronterActivity.name = "Currently Fronting: " + sysMember.Name;
fronterActivity.application_id = Config.GetEntry("DISCORD_APPLICATION_ID", DEFAULT_APPLICATION_ID);
fronterActivity.type = 0;
gatewayOp.d.activities = new JArray(fronterActivity);
gatewayOp.d.afk = false;
gatewayOp.d.broadcast = null;
if(wSock is not null)
_ = wSock.SendText(JsonConvert.SerializeObject(gatewayOp));
}
}
}

View File

@ -0,0 +1,39 @@
using DiscordRPC;
using DiscordRPC.Logging;
namespace PluralRichPresence.Discord
{
public class DiscordRpc : IDiscordActivitySetter
{
private const string DEFAULT_APPLICATION_ID = "1163661006719963158";
private const string DEFAULT_ICON = "plural";
private DiscordRpcClient client;
public DiscordRpc()
{
client = new DiscordRpcClient(Config.GetEntry("DISCORD_APPLICATION_ID", DEFAULT_APPLICATION_ID));
client.Logger = new ConsoleLogger() { Level = LogLevel.None };
client.OnReady += (sender, e) => { };
client.OnPresenceUpdate += (sender, e) => { };
client.Initialize();
}
public void SetFronter(SystemMember sysMember)
{
client.SetPresence(new RichPresence()
{
Details = sysMember.Name,
State = sysMember.Pronouns,
Assets = new Assets()
{
LargeImageKey = (sysMember.ProfilePhotoUrl is not null) ? sysMember.ProfilePhotoUrl : Config.GetEntry("PLURAL_ICON_NAME", DEFAULT_ICON),
LargeImageText = sysMember.Name + " - " + sysMember.Pronouns,
SmallImageKey = Config.GetEntry("PLURAL_ICON_NAME", DEFAULT_ICON)
},
Timestamps = new Timestamps()
{
StartUnixMilliseconds = sysMember.TimeStamp
}
});
}
}
}

View File

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PluralRichPresence.Discord
{
public interface IDiscordActivitySetter
{
public void SetFronter(SystemMember sysMember);
}
}

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PluralRichPresence.Discord
{
public class SystemMember
{
public string Name;
public string Pronouns;
public string? ProfilePhotoUrl;
public ulong? TimeStamp;
public SystemMember(string name, string pronouns, string? profilePhotoUrl, ulong? timeStamp)
{
this.Name = name;
this.Pronouns = pronouns;
this.ProfilePhotoUrl = profilePhotoUrl;
this.TimeStamp = timeStamp;
}
}
}

View File

@ -1,43 +0,0 @@
using DiscordRPC;
using DiscordRPC.Logging;
namespace PluralRichPresence
{
public class DiscordRPC
{
private DiscordRpcClient client;
private const string DEFAULT_APPLICATION_ID = "1163661006719963158";
public DiscordRPC()
{
client = new DiscordRpcClient(Config.GetEntry("DISCORD_APPLICATION_ID", DEFAULT_APPLICATION_ID));
client.Logger = new ConsoleLogger() { Level = LogLevel.None };
client.OnReady += (sender, e) => {};
client.OnPresenceUpdate += (sender, e) => {};
client.Initialize();
}
public void SetFronter(string user, string pronouns, string profile, ulong? timeStamp)
{
client.SetPresence(new RichPresence()
{
Details = user,
State = pronouns,
Assets = new Assets()
{
LargeImageKey = profile,
LargeImageText = user + " - " + pronouns,
SmallImageKey = "plural"
},
Timestamps = new Timestamps()
{
StartUnixMilliseconds = timeStamp
}
});
}
}
}

View File

@ -1,5 +1,6 @@
using PluralRichPresence.Avatar;
using PluralRichPresence.SimplyPlural;
using PluralRichPresence.Discord;
using System.Drawing;
using System.Drawing.Imaging;
using System.Linq;
@ -10,7 +11,7 @@ namespace PluralRichPresence
internal class Program
{
private static ManualResetEvent waitHandle = new ManualResetEvent(false);
private static DiscordRPC discordRpc = new DiscordRPC();
private static IDiscordActivitySetter? discordSetter = null;
private static System? system;
private const string KEY_SIMPLY_PLURAL_TOKEN = "SIMPLY_PLURAL_TOKEN";
@ -72,21 +73,27 @@ namespace PluralRichPresence
if (frontingMembers.Length > 0)
{
Console.Write("\r" + FmtFronterNames(frontingMembers) + " is fronting!");
discordRpc.SetFronter(FmtFronterNames(frontingMembers),
FmtFronterPronouns(frontingMembers),
FmtAvatar(frontingMembers),
frontingMembers.OrderByDescending(o => o.FrontStartTime).First().FrontStartTime);
if (discordSetter is null) return;
discordSetter.SetFronter(new SystemMember(FmtFronterNames(frontingMembers),
FmtFronterPronouns(frontingMembers),
FmtAvatar(frontingMembers),
frontingMembers.OrderByDescending(o => o.FrontStartTime).First().FrontStartTime));
}
else
{
Console.Write("\r No one is fronting!");
discordRpc.SetFronter("No one is fronting.", "This doesn't make much sense?", "plural", null);
if (discordSetter is null) return;
discordSetter.SetFronter(new SystemMember("No one is fronting.", "This doesn't make much sense?", null, null));
}
}
public static async Task Main(string[] args)
{
if(!Config.EntryExists(KEY_SIMPLY_PLURAL_TOKEN))
discordSetter = new DiscordClient(Config.GetEntry("DISCORD_TOKEN"));
//discordSetter = new DiscordRpc();
if (!Config.EntryExists(KEY_SIMPLY_PLURAL_TOKEN))
{
Console.Write("Enter Simply Plural API Key: ");
string? token = Console.ReadLine();

View File

@ -6,14 +6,13 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net8.0\publish\win-x86\</PublishDir>
<PublishDir>bin\Release\net8.0\publish\linux-x64\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@ -4,7 +4,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<History>True|2023-12-28T10:24:43.7413097Z;True|2023-12-28T23:24:32.3866287+13:00;True|2023-12-28T23:22:28.7251892+13:00;True|2023-12-28T21:45:45.2231538+13:00;True|2023-12-28T21:00:25.2444119+13:00;True|2023-12-28T20:56:12.0172711+13:00;True|2023-12-28T20:54:22.1893325+13:00;True|2023-12-28T20:53:03.6353258+13:00;True|2023-11-19T17:13:26.6652642+13:00;True|2023-11-05T19:18:40.8205304+13:00;True|2023-11-05T19:17:01.7660736+13:00;True|2023-11-05T19:11:32.6382930+13:00;True|2023-11-05T19:11:09.6820258+13:00;False|2023-11-05T19:10:42.2427675+13:00;True|2023-10-18T14:13:41.8203499+13:00;True|2023-10-18T14:05:36.8381808+13:00;True|2023-10-17T21:30:38.9682339+13:00;</History>
<History>True|2024-03-15T06:01:46.3092686Z;True|2023-12-28T23:24:43.7413097+13:00;True|2023-12-28T23:24:32.3866287+13:00;True|2023-12-28T23:22:28.7251892+13:00;True|2023-12-28T21:45:45.2231538+13:00;True|2023-12-28T21:00:25.2444119+13:00;True|2023-12-28T20:56:12.0172711+13:00;True|2023-12-28T20:54:22.1893325+13:00;True|2023-12-28T20:53:03.6353258+13:00;True|2023-11-19T17:13:26.6652642+13:00;True|2023-11-05T19:18:40.8205304+13:00;True|2023-11-05T19:17:01.7660736+13:00;True|2023-11-05T19:11:32.6382930+13:00;True|2023-11-05T19:11:09.6820258+13:00;False|2023-11-05T19:10:42.2427675+13:00;True|2023-10-18T14:13:41.8203499+13:00;True|2023-10-18T14:05:36.8381808+13:00;True|2023-10-17T21:30:38.9682339+13:00;</History>
<LastFailureDetails />
</PropertyGroup>
</Project>

View File

@ -3,6 +3,7 @@ using Newtonsoft.Json.Linq;
using System.Net.Http.Headers;
using System;
using System.Text;
using PluralRichPresence.Api;
namespace PluralRichPresence.SimplyPlural
{

View File

@ -1,17 +1,16 @@
using Newtonsoft.Json;
using PluralRichPresence.Api;
using System.Dynamic;
using System.Net.WebSockets;
using System.Text;
namespace PluralRichPresence.SimplyPlural
{
public class Socket : ApiType
{
private AWebSocket? wSock;
public const int KEEPALIVE_INTERVAL = 10 * 1000;
public const string DEFAULT_WEBSOCKET_SERVER_URI = "wss://api.apparyllis.com/v1/socket";
public event EventHandler? FronterChanged;
SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
ClientWebSocket? wss = null;
Timer? keepAliveTimer = null;
@ -22,34 +21,6 @@ namespace PluralRichPresence.SimplyPlural
FronterChanged(this, new EventArgs());
}
}
private async Task<string> receiveMessageText()
{
byte[] byteArray = await receiveMessageBytes();
return Encoding.UTF8.GetString(byteArray);
}
private async Task<byte[]> receiveMessageBytes()
{
List<byte> totalPayload = new List<byte>();
byte[] buffer = new byte[0x8000];
while (wss is not null && wss.State == WebSocketState.Open)
{
try
{
WebSocketReceiveResult? result = await wss.ReceiveAsync(buffer, CancellationToken.None);
for (int i = 0; i < result.Count; i++)
totalPayload.Add(buffer[i]);
if (result.EndOfMessage)
return totalPayload.ToArray();
}
catch(Exception) { await reconnect(); break; };
}
return totalPayload.ToArray();
}
private void doUpdate(dynamic jsonData)
{
string target = jsonData.target;
@ -61,40 +32,8 @@ namespace PluralRichPresence.SimplyPlural
}
}
private async Task receiveTask()
{
while (wss is not null && wss.State == WebSocketState.Open)
{
try
{
string message = await receiveMessageText();
Logger.Debug("< " + message);
if (message == "pong") continue;
try
{
dynamic? jsonData = JsonConvert.DeserializeObject(message);
if (jsonData is null) continue;
string? type = jsonData.msg;
switch (type)
{
case "update":
doUpdate(jsonData);
break;
case null:
default:
break;
}
}
catch (Exception) { };
}
catch (Exception) { Logger.Debug("failed"); break; };
}
}
private async Task reconnect()
{
if (!await semaphore.WaitAsync(0)) return;
while (true)
{
@ -102,45 +41,64 @@ namespace PluralRichPresence.SimplyPlural
{
try
{
if (wss is not null) await wss.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
wss = null;
if (wSock is not null) wSock.Dispose();
wSock = null;
}
catch (Exception) { };
await connect();
await sendLogin();
_ = Task.Run(() => receiveTask());
this.keepAliveTimer = new Timer((TimerCallback) => { _ = sendKeepAlive(); }, null, KEEPALIVE_INTERVAL, 0);
}
catch (Exception) { Logger.Debug("failed to connect."); continue; }
break;
}
try
{
semaphore.Release();
}
catch (SemaphoreFullException) { };
}
private async Task connect()
{
wss = new ClientWebSocket();
await wss.ConnectAsync(new Uri(Config.GetEntry("SIMPLY_PLURAL_WEBSOCKET_URI", DEFAULT_WEBSOCKET_SERVER_URI)), CancellationToken.None);
wSock = new AWebSocket(Config.GetEntry("SIMPLY_PLURAL_WEBSOCKET_URI", DEFAULT_WEBSOCKET_SERVER_URI));
await wSock.Connect();
wSock.TextReceived += wSockTextReceived;
wSock.Disconnected += wSockDisconnected;
}
private async Task sendText(string text)
private void wSockTextReceived(object? sender, TextReceivedEventArgs e)
{
Logger.Debug("> "+text);
string message = e.Text;
if (message == "pong") return;
try
{
await wss.SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text, true, CancellationToken.None);
dynamic? jsonData = JsonConvert.DeserializeObject(message);
if (jsonData is null) return;
string? type = jsonData.msg;
switch (type)
{
case "update":
doUpdate(jsonData);
break;
case null:
default:
break;
}
}
catch (Exception) { await reconnect(); }
catch (Exception) { };
}
private void wSockDisconnected(object? sender, EventArgs e)
{
_ = reconnect();
}
private async Task sendKeepAlive()
{
await sendText("ping");
if (wSock is not null) {
await wSock.SendText("ping");
}
if (keepAliveTimer is not null) keepAliveTimer.Change(KEEPALIVE_INTERVAL, 0);
}
@ -150,7 +108,8 @@ namespace PluralRichPresence.SimplyPlural
data.op = "authenticate";
data.token = token;
string authenticateJson = JsonConvert.SerializeObject(data);
await sendText(authenticateJson);
if(wSock is not null)
await wSock.SendText(authenticateJson);
}
public Socket(string token) : base(token)