The OpenD Programming Language

1 /++
2 	Module for interacting with the Discord chat service. I use it to run a discord bot providing some slash commands.
3 
4 
5 	$(LIST
6 		* Real time gateway
7 			See [DiscordGatewayConnection]
8 
9 			You can use [SlashCommandHandler] subclasses registered with a gateway connection to easily add slash commands to your app.
10 		* REST api
11 			See [DiscordRestApi]
12 		* Local RPC server
13 			See [DiscordRpcConnection] (not implemented)
14 		* Voice connections
15 			not implemented
16 		* Login with Discord
17 			OAuth2 is easy enough without the lib, see bingo.d line 340ish-380ish.
18 	)
19 
20 	History:
21 		Started April 20, 2024.
22 +/
23 module arsd.discord;
24 
25 // FIXME: Secure Connect Failed sometimes on trying to reconnect, should prolly just try again after a short period, or ditch the whole thing if reconnectAndResume and try fresh
26 
27 // FIXME: User-Agent: DiscordBot ($url, $versionNumber)
28 
29 import arsd.http2;
30 import arsd.jsvar;
31 
32 import arsd.core;
33 
34 import core.time;
35 
36 static assert(use_arsd_core);
37 
38 /++
39 	Base class to represent some object on Discord, e.g. users, channels, etc., through its subclasses.
40 
41 
42 	Among its implementations are:
43 
44 	$(LIST
45 		* [DiscordChannel]
46 		* [DiscordUser]
47 		* [DiscordRole]
48 	)
49 +/
50 abstract class DiscordEntity {
51 	private DiscordRestApi api;
52 	private string id_;
53 
54 	protected this(DiscordRestApi api, string id) {
55 		this.api = api;
56 		this.id_ = id;
57 	}
58 
59 	override string toString() {
60 		return restType ~ "/" ~ id;
61 	}
62 
63 	/++
64 
65 	+/
66 	abstract string restType();
67 
68 	/++
69 
70 	+/
71 	final string id() {
72 		return id_;
73 	}
74 
75 	/++
76 		Gives easy access to its rest api through [arsd.http2.HttpApiClient]'s dynamic dispatch functions.
77 
78 	+/
79 	DiscordRestApi.RestBuilder rest() {
80 		return api.rest[restType()][id()];
81 	}
82 }
83 
84 /++
85 	Represents something mentionable on Discord with `@name` - roles and users.
86 +/
87 abstract class DiscordMentionable : DiscordEntity {
88 	this(DiscordRestApi api, string id) {
89 		super(api, id);
90 	}
91 }
92 
93 /++
94 	https://discord.com/developers/docs/resources/channel
95 +/
96 class DiscordChannel : DiscordEntity {
97 	this(DiscordRestApi api, string id) {
98 		super(api, id);
99 	}
100 
101 	override string restType() {
102 		return "channels";
103 	}
104 
105 	void sendMessage(string message) {
106 		if(message.length == 0)
107 			message = "empty message specified";
108 		var msg = var.emptyObject;
109 		msg.content = message;
110 		rest.messages.POST(msg).result;
111 	}
112 }
113 
114 /++
115 
116 +/
117 class DiscordRole : DiscordMentionable {
118 	this(DiscordRestApi api, DiscordGuild guild, string id) {
119 		this.guild_ = guild;
120 		super(api, id);
121 	}
122 
123 	private DiscordGuild guild_;
124 
125 	/++
126 
127 	+/
128 	DiscordGuild guild() {
129 		return guild_;
130 	}
131 
132 	override string restType() {
133 		return "roles";
134 	}
135 }
136 
137 /++
138 	https://discord.com/developers/docs/resources/user
139 +/
140 class DiscordUser : DiscordMentionable {
141 	this(DiscordRestApi api, string id) {
142 		super(api, id);
143 	}
144 
145 	private var cachedData;
146 
147 	// DiscordGuild selectedGuild;
148 
149 	override string restType() {
150 		return "users";
151 	}
152 
153 	void addRole(DiscordRole role) {
154 		// PUT /guilds/{guild.id}/members/{user.id}/roles/{role.id}
155 
156 		auto thing = api.rest.guilds[role.guild.id].members[this.id].roles[role.id];
157 		writeln(thing.toUri);
158 
159 		auto result = api.rest.guilds[role.guild.id].members[this.id].roles[role.id].PUT().result;
160 	}
161 
162 	void removeRole(DiscordRole role) {
163 		// DELETE /guilds/{guild.id}/members/{user.id}/roles/{role.id}
164 
165 		auto thing = api.rest.guilds[role.guild.id].members[this.id].roles[role.id];
166 		writeln(thing.toUri);
167 
168 		auto result = api.rest.guilds[role.guild.id].members[this.id].roles[role.id].DELETE().result;
169 	}
170 
171 	private DiscordChannel dmChannel_;
172 
173 	DiscordChannel dmChannel() {
174 		if(dmChannel_ is null) {
175 			var obj = var.emptyObject;
176 			obj.recipient_id = this.id;
177 			var result = this.api.rest.users["@me"].channels.POST(obj).result;
178 
179 			dmChannel_ = new DiscordChannel(api, result.id.get!string);//, result);
180 		}
181 		return dmChannel_;
182 	}
183 
184 	void sendMessage(string what) {
185 		dmChannel.sendMessage(what);
186 	}
187 }
188 
189 /++
190 
191 +/
192 class DiscordGuild : DiscordEntity {
193 	this(DiscordRestApi api, string id) {
194 		super(api, id);
195 	}
196 
197 	override string restType() {
198 		return "guilds";
199 	}
200 
201 }
202 
203 
204 enum InteractionType {
205 	PING = 1,
206 	APPLICATION_COMMAND = 2, // the main one
207 	MESSAGE_COMPONENT = 3,
208 	APPLICATION_COMMAND_AUTOCOMPLETE = 4,
209 	MODAL_SUBMIT = 5,
210 }
211 
212 
213 /++
214 	You can create your own slash command handlers by subclassing this and writing methods like
215 
216 	It will register for you when you connect and call your function when things come in.
217 
218 	See_Also:
219 		https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands
220 +/
221 class SlashCommandHandler {
222 	enum ApplicationCommandOptionType {
223 		INVALID = 0, // my addition
224 		SUB_COMMAND = 1,
225 		SUB_COMMAND_GROUP = 2,
226 		STRING = 3,
227 		INTEGER = 4, // double's int part
228 		BOOLEAN = 5,
229 		USER = 6,
230 		CHANNEL = 7,
231 		ROLE = 8,
232 		MENTIONABLE = 9,
233 		NUMBER = 10, // double
234 		ATTACHMENT = 11,
235 	}
236 
237 	/++
238 		This takes the child type into the parent so we can reflect over your added methods.
239 		to initialize the reflection info to send to Discord. If you subclass your subclass,
240 		make sure the grandchild constructor does `super(); registerAll(this);` to add its method
241 		to the list too, but if you only have one level of child, the compiler will auto-generate
242 		a constructor for you that calls this.
243 	+/
244 	protected this(this This)() {
245 		registerAll(cast(This) this);
246 	}
247 
248 	/++
249 
250 	+/
251 	static class InteractionReplyHelper {
252 		private DiscordRestApi api;
253 		private CommandArgs commandArgs;
254 
255 		private this(DiscordRestApi api, CommandArgs commandArgs) {
256 			this.api = api;
257 			this.commandArgs = commandArgs;
258 
259 		}
260 
261 		/++
262 
263 		+/
264 		void reply(string message, bool ephemeral = false) scope {
265 			replyLowLevel(message, ephemeral);
266 		}
267 
268 		/++
269 
270 		+/
271 		void replyWithError(scope const(char)[] message) scope {
272 			if(message.length == 0)
273 				message = "I am error.";
274 			replyLowLevel(message.idup, true);
275 		}
276 
277 		enum MessageFlags : uint {
278 			SUPPRESS_EMBEDS        = (1 << 2), // skip the embedded content
279 			EPHEMERAL              = (1 << 6), // only visible to you
280 			LOADING                = (1 << 7), // the bot is "thinking"
281 			SUPPRESS_NOTIFICATIONS = (1 << 12) // skip push/desktop notifications
282 		}
283 
284 		void replyLowLevel(string message, bool ephemeral) scope {
285 			if(message.length == 0)
286 				message = "empty message";
287 			var reply = var.emptyObject;
288 			reply.type = 4; // chat response in message. 5 can be answered quick and edited later if loading, 6 if quick answer, no loading message
289 			var replyData = var.emptyObject;
290 			replyData.content = message;
291 			replyData.flags = ephemeral ? (1 << 6) : 0;
292 			reply.data = replyData;
293 			try {
294 				var result = api.rest.
295 					interactions[commandArgs.interactionId][commandArgs.interactionToken].callback
296 					.POST(reply).result;
297 				writeln(result.toString);
298 			} catch(Exception e) {
299 				import std.stdio; writeln(commandArgs);
300 				writeln(e.toString());
301 			}
302 		}
303 	}
304 
305 
306 	private bool alreadyRegistered;
307 	private void register(DiscordRestApi api, string appId) {
308 		if(alreadyRegistered)
309 			return;
310 		auto result = api.rest.applications[appId].commands.PUT(jsonArrayForDiscord).result;
311 		alreadyRegistered = true;
312 	}
313 
314 	private static struct CommandArgs {
315 		InteractionType interactionType;
316 		string interactionToken;
317 		string interactionId;
318 		string guildId;
319 		string channelId;
320 
321 		var interactionData;
322 
323 		var member;
324 		var channel;
325 	}
326 
327 	private {
328 
329 		static void validateDiscordSlashCommandName(string name) {
330 			foreach(ch; name) {
331 				if(ch != '_' && !(ch >= 'a' && ch <= 'z'))
332 					throw new InvalidArgumentsException("name", "discord names must be all lower-case with only letters and underscores", LimitedVariant(name));
333 			}
334 		}
335 
336 		static HandlerInfo makeHandler(alias handler, T)(T slashThis) {
337 			HandlerInfo info;
338 
339 			// must be all lower case!
340 			info.name = __traits(identifier, handler);
341 
342 			validateDiscordSlashCommandName(info.name);
343 
344 			var cmd = var.emptyObject();
345 			cmd.name = info.name;
346 			version(D_OpenD)
347 				cmd.description = __traits(docComment, handler);
348 			else
349 				cmd.description = "";
350 
351 			if(cmd.description == "")
352 				cmd.description = "Can't be blank for CHAT_INPUT";
353 
354 			cmd.type = 1; // CHAT_INPUT
355 
356 			var optionsArray = var.emptyArray;
357 
358 			static if(is(typeof(handler) Params == __parameters)) {}
359 
360 			string[] names;
361 
362 			// extract parameters
363 			foreach(idx, param; Params) {
364 				var option = var.emptyObject;
365 				auto name = __traits(identifier, Params[idx .. idx + 1]);
366 				validateDiscordSlashCommandName(name);
367 				names ~= name;
368 				option.name = name;
369 				option.description = "desc";
370 				option.type = cast(int) applicationComandOptionTypeFromDType!(param);
371 				// can also add "choices" which limit it to just specific members
372 				if(option.type) {
373 					optionsArray ~= option;
374 				}
375 			}
376 
377 			cmd.options = optionsArray;
378 
379 			info.jsonObjectForDiscord = cmd;
380 			info.handler = (CommandArgs args, scope InteractionReplyHelper replyHelper, DiscordRestApi api) {
381 				// extract args
382 				// call the function
383 				// send the appropriate reply
384 				static if(is(typeof(handler) Return == return)) {
385 					static if(is(Return == void)) {
386 						__traits(child, slashThis, handler)(fargsFromJson!Params(api, names, args.interactionData, args).tupleof);
387 						sendHandlerReply("OK", replyHelper, true);
388 					} else {
389 						sendHandlerReply(__traits(child, slashThis, handler)(fargsFromJson!Params(api, names, args.interactionData, args).tupleof), replyHelper, false);
390 					}
391 				} else static assert(0);
392 			};
393 
394 			return info;
395 		}
396 
397 		static auto fargsFromJson(Params...)(DiscordRestApi api, string[] names, var obj, CommandArgs args/*, Params defaults*/) {
398 			static struct Holder {
399 				// FIXME: default params no work
400 				Params params;// = defaults;
401 			}
402 
403 			Holder holder;
404 			foreach(idx, ref param; holder.params) {
405 				setParamFromJson(param, names[idx], api, obj, args);
406 			}
407 
408 			return holder;
409 
410 /+
411 
412 ync def something(interaction:discord.Interaction):
413     await interaction.response.send_message("NOTHING",ephemeral=True)
414     # optional (if you want to edit the response later,delete it, or send a followup)
415     await interaction.edit_original_response(content="Something")
416     await interaction.followup.send("This is a message too.",ephemeral=True)
417     await interaction.delete_original_response()
418     # if you have deleted the original response you can't edit it or send a followup after it
419 +/
420 
421 		}
422 
423 
424 // {"t":"INTERACTION_CREATE","s":7,"op":0,"d":{"version":1,"type":2,"token":"aW50ZXJhY3Rpb246MTIzMzIyNzE0OTU0NTE3NzE2OTp1Sjg5RE0wMzJiWER2UDRURk5XSWRaUTJtMExBeklWNEtpVEZocTQ4a0VZQ3NWUm9ta3g2SG1JbTBzUm1yWmlUNzQ3eWxpc0FnM0RzUzZHaWtENnRXUDBsdUhERElKSWlaYlFWMlNsZlZXTlFkU3VVQUVWU01PNU9TNFQ5cmFQSw",
425 
426 // "member":{"user":{"username":"wrathful_vengeance_god_unleashed","public_flags":0,"id":"395786107780071424","global_name":"adr","discriminator":"0","clan":null,"avatar_decoration_data":null,"avatar":"e3c2aacef7920d3a661a19aaab969337"},"unusual_dm_activity_until":null,"roles":[],"premium_since":null,"permissions":"1125899906842623","pending":false,"nick":"adr","mute":false,"joined_at":"2022-08-24T12:37:21.252000+00:00","flags":0,"deaf":false,"communication_disabled_until":null,"avatar":null},
427 
428 // "locale":"en-US","id":"1233227149545177169","guild_locale":"en-US","guild_id":"1011977515109187704",
429 // "guild":{"locale":"en-US","id":"1011977515109187704","features":[]},
430 // "entitlements":[],"entitlement_sku_ids":[],
431 // "data":{"type":1,"name":"hello","id":"1233221536522174535"},"channel_id":"1011977515109187707",
432 // "channel":{"type":0,"topic":null,"rate_limit_per_user":0,"position":0,"permissions":"1125899906842623","parent_id":"1011977515109187705","nsfw":false,"name":"general","last_message_id":"1233227103844171806","id":"1011977515109187707","guild_id":"1011977515109187704","flags":0},
433 // "application_id":"1223724819821105283","app_permissions":"1122573558992465"}}
434 
435 
436 		template applicationComandOptionTypeFromDType(T) {
437 			static if(is(T == SendingUser) || is(T == SendingChannel))
438 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.INVALID; // telling it to skip sending this to discord, it purely internal
439 			else static if(is(T == DiscordRole))
440 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.ROLE;
441 			else static if(is(T == string))
442 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.STRING;
443 			else static if(is(T == bool))
444 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.BOOLEAN;
445 			else static if(is(T : const long))
446 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.INTEGER;
447 			else static if(is(T : const double))
448 				enum applicationComandOptionTypeFromDType = ApplicationCommandOptionType.NUMBER;
449 			else
450 				static assert(0, T.stringof);
451 		}
452 
453 		static var getOptionForName(var obj, string name) {
454 			foreach(option; obj.options)
455 				if(option.name == name)
456 					return option;
457 			return var.init;
458 		}
459 
460 		static void setParamFromJson(T)(ref T param, string name, DiscordRestApi api, var obj, CommandArgs args) {
461 			static if(is(T == SendingUser)) {
462 				param = new SendingUser(api, args.member.user.id.get!string, obj.member.user);
463 			} else static if(is(T == SendingChannel)) {
464 				param = new SendingChannel(api, args.channel.id.get!string, obj.channel);
465 			} else static if(is(T == string)) {
466 				var option = getOptionForName(obj, name);
467 				if(option.type == cast(int) ApplicationCommandOptionType.STRING)
468 					param = option.value.get!(typeof(param));
469 			} else static if(is(T == bool)) {
470 				var option = getOptionForName(obj, name);
471 				if(option.type == cast(int) ApplicationCommandOptionType.BOOLEAN)
472 					param = option.value.get!(typeof(param));
473 			} else static if(is(T : const long)) {
474 				var option = getOptionForName(obj, name);
475 				if(option.type == cast(int) ApplicationCommandOptionType.INTEGER)
476 					param = option.value.get!(typeof(param));
477 			} else static if(is(T : const double)) {
478 				var option = getOptionForName(obj, name);
479 				if(option.type == cast(int) ApplicationCommandOptionType.NUMBER)
480 					param = option.value.get!(typeof(param));
481 			} else static if(is(T == DiscordRole)) {
482 
483 //"data":{"type":1,"resolved":{"roles":{"1223727548295544865":{"unicode_emoji":null,"tags":{"bot_id":"1223724819821105283"},"position":1,"permissions":"3088","name":"OpenD","mentionable":false,"managed":true,"id":"1223727548295544865","icon":null,"hoist":false,"flags":0,"description":null,"color":0}}},"options":[{"value":"1223727548295544865","type":8,"name":"role"}],"name":"add_role","id":"1234130839315677226"},"channel_id":"1011977515109187707","channel":{"type":0,"topic":null,"rate_limit_per_user":0,"position":0,"permissions":"1125899906842623","parent_id":"1011977515109187705","nsfw":false,"name":"general","last_message_id":"1234249771745804399","id":"1011977515109187707","guild_id":"1011977515109187704","flags":0},"application_id":"1223724819821105283","app_permissions":"1122573558992465"}}
484 
485 // resolved gives you some precache info
486 
487 				var option = getOptionForName(obj, name);
488 				if(option.type == cast(int) ApplicationCommandOptionType.ROLE)
489 					param = new DiscordRole(api, new DiscordGuild(api, args.guildId), option.value.get!string);
490 				else
491 					param = null;
492 			} else {
493 				static assert(0, "Bad type " ~ T.stringof);
494 			}
495 		}
496 
497 		static void sendHandlerReply(T)(T ret, scope InteractionReplyHelper replyHelper, bool ephemeral) {
498 			import std.conv; // FIXME
499 			replyHelper.reply(to!string(ret), ephemeral);
500 		}
501 
502 		void registerAll(T)(T t) {
503 			assert(t !is null);
504 			foreach(memberName; __traits(derivedMembers, T))
505 				static if(memberName != "__ctor") { // FIXME
506 					HandlerInfo hi = makeHandler!(__traits(getMember, T, memberName))(t);
507 					registerFromRuntimeInfo(hi);
508 				}
509 		}
510 
511 		void registerFromRuntimeInfo(HandlerInfo info) {
512 			handlers[info.name] = info.handler;
513 			if(jsonArrayForDiscord is var.init)
514 				jsonArrayForDiscord = var.emptyArray;
515 			jsonArrayForDiscord ~= info.jsonObjectForDiscord;
516 		}
517 
518 		alias InternalHandler = void delegate(CommandArgs args, scope InteractionReplyHelper replyHelper, DiscordRestApi api);
519 		struct HandlerInfo {
520 			string name;
521 			InternalHandler handler;
522 			var jsonObjectForDiscord;
523 		}
524 		InternalHandler[string] handlers;
525 		var jsonArrayForDiscord;
526 	}
527 }
528 
529 /++
530 	A SendingUser is a special DiscordUser type that just represents the person who sent the message.
531 
532 	It exists so you can use it in a function parameter list that is auto-mapped to a message handler.
533 +/
534 class SendingUser : DiscordUser {
535 	private this(DiscordRestApi api, string id, var initialCache) {
536 		super(api, id);
537 	}
538 }
539 
540 class SendingChannel : DiscordChannel {
541 	private this(DiscordRestApi api, string id, var initialCache) {
542 		super(api, id);
543 	}
544 }
545 
546 // SendingChannel
547 // SendingMessage
548 
549 /++
550 	Use as a UDA
551 
552 	A file of choices for the given option. The exact interpretation depends on the type but the general rule is one option per line, id or name.
553 
554 	FIXME: watch the file for changes for auto-reload and update on the discord side
555 
556 	FIXME: NOT IMPLEMENTED
557 +/
558 struct ChoicesFromFile {
559 	string filename;
560 }
561 
562 /++
563 	Most the magic is inherited from [arsd.http2.HttpApiClient].
564 +/
565 class DiscordRestApi : HttpApiClient!() {
566 	/++
567 		Creates an API client.
568 
569 		Params:
570 			token = the bot authorization token you got from Discord
571 			yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work.
572 			yourBotVersion = version number (or whatever) for your bot, used as part of the user-agent. Should not be null according to the docs but it doesn't seem to matter in practice.
573 	+/
574 	this(string botToken, string yourBotUrl, string yourBotVersion) {
575 		this.authType = "Bot";
576 		super("https://discord.com/api/v10/", botToken);
577 	}
578 }
579 
580 /++
581 
582 +/
583 class DiscordGatewayConnection {
584 	private WebSocket websocket_;
585 	private long lastSequenceNumberReceived;
586 	private string token;
587 	private DiscordRestApi api_;
588 
589 	/++
590 		An instance to the REST api object associated with your connection.
591 	+/
592 	public final DiscordRestApi api() {
593 		return this.api_;
594 	}
595 
596 	/++
597 
598 	+/
599 	protected final WebSocket websocket() {
600 		return websocket_;
601 	}
602 
603 	// https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-opcodes
604 	enum OpCode {
605 		Dispatch = 0, // recv
606 		Heartbeat = 1, // s/r
607 		Identify = 2, // s
608 		PresenceUpdate = 3, // s
609 		VoiceStateUpdate = 4, // s
610 		Resume = 6, // s
611 		Reconnect = 7, // r
612 		RequestGuildMembers = 8, // s
613 		InvalidSession = 9, // r - you should reconnect and identify/resume
614 		Hello = 10, // r
615 		HeartbeatAck = 11, // r
616 	}
617 
618 	enum DisconnectCodes {
619 		UnknownError = 4000, // t
620 		UnknownOpcode = 4001, // t (user error)
621 		DecodeError = 4002, // t (user error)
622 		NotAuthenticated = 4003, // t (user error)
623 		AuthenticationFailed = 4004, // f (user error)
624 		AlreadyAuthenticated = 4005, // t (user error)
625 		InvalidSeq = 4007, // t
626 		RateLimited = 4008, // t
627 		SessionTimedOut = 4009, // t
628 		InvalidShard = 4010, // f (user error)
629 		ShardingRequired = 4011, // f
630 		InvalidApiVersion = 4012, // f
631 		InvalidIntents = 4013, // f
632 		DisallowedIntents = 4014, // f
633 	}
634 
635 	private string cachedGatewayUrl;
636 
637 	/++
638 		Prepare a gateway connection. After you construct it, you still need to call [connect].
639 
640 		Params:
641 			token = the bot authorization token you got from Discord
642 			yourBotUrl = a URL for your bot, used to identify the user-agent. Discord says it should not be null, but that seems to work.
643 			yourBotVersion = version number (or whatever) for your bot, used as part of the user-agent. Should not be null according to the docs but it doesn't seem to matter in practice.
644 	+/
645 	public this(string token, string yourBotUrl, string yourBotVersion) {
646 		this.token = token;
647 		this.api_ = new DiscordRestApi(token, yourBotUrl, yourBotVersion);
648 	}
649 
650 	/++
651 		Allows you to set up a subclass of [SlashCommandHandler] for handling discord slash commands.
652 	+/
653 	final void slashCommandHandler(SlashCommandHandler t) {
654 		if(slashCommandHandler_ !is null && t !is null)
655 			throw ArsdException!"SlashCommandHandler is already set"();
656 		slashCommandHandler_ = t;
657 		if(t && applicationId.length)
658 			t.register(api, applicationId);
659 	}
660 	private SlashCommandHandler slashCommandHandler_;
661 
662 	/++
663 
664 	+/
665 	protected void handleWebsocketClose(WebSocket.CloseEvent closeEvent) {
666 		import std.stdio; writeln(closeEvent);
667 		if(heartbeatTimer)
668 			heartbeatTimer.cancel();
669 
670 		if(closeEvent.code == 1006 || closeEvent.code == 1001) {
671 			reconnectAndResume();
672 		} else {
673 			// otherwise, unless we were asked by the api user to close, let's try reconnecting
674 			// since discord just does discord things.
675 			connect();
676 		}
677 	}
678 
679 	/++
680 	+/
681 	void close() {
682 		close(1000, null);
683 	}
684 
685 	/// ditto
686 	void close(int reason, string reasonText) {
687 		if(heartbeatTimer)
688 			heartbeatTimer.cancel();
689 
690 		websocket_.onclose = null;
691 		websocket_.ontextmessage = null;
692 		websocket_.onbinarymessage = null;
693 		websocket.close(reason, reasonText);
694 		websocket_ = null;
695 	}
696 
697 	/++
698 	+/
699 	protected void handleWebsocketMessage(in char[] msg) {
700 		var m = var.fromJson(msg.idup);
701 
702 		OpCode op = cast(OpCode) m.op.get!int;
703 		var data = m.d;
704 
705 		switch(op) {
706 			case OpCode.Dispatch:
707 				// these are null if op != 0
708 				string eventName = m.t.get!string;
709 				long seqNumber = m.s.get!long;
710 
711 				if(seqNumber > lastSequenceNumberReceived)
712 					lastSequenceNumberReceived = seqNumber;
713 
714 				eventReceived(eventName, data);
715 			break;
716 			case OpCode.Hello:
717 				// the hello heartbeat_interval is in milliseconds
718 				if(slashCommandHandler_ !is null && applicationId.length)
719 					slashCommandHandler_.register(api, applicationId);
720 
721 				setHeartbeatInterval(data.heartbeat_interval.get!int);
722 			break;
723 			case OpCode.Heartbeat:
724 				sendHeartbeat();
725 			break;
726 			case OpCode.HeartbeatAck:
727 				mostRecentHeartbeatAckRecivedAt = MonoTime.currTime;
728 			break;
729 			case OpCode.Reconnect:
730 				writeln("reconnecting");
731 				this.close(4999, "Reconnect requested");
732 				reconnectAndResume();
733 			break;
734 			case OpCode.InvalidSession:
735 				writeln("starting new session");
736 
737 				close();
738 				connect(); // try starting a brand new session
739 			break;
740 			default:
741 				// ignored
742 		}
743 	}
744 
745 	protected void reconnectAndResume() {
746 		this.websocket_ = new WebSocket(Uri(this.resume_gateway_url));
747 
748 		websocket.onmessage = &handleWebsocketMessage;
749 		websocket.onclose = &handleWebsocketClose;
750 
751 		// FIXME: if the connect fails we should set a timer and try
752 		// again, but if it fails then, quit. at least if it is not a websocket reply
753 		// cuz it could be discord went down or something.
754 		this.websocket_.connect();
755 
756 		var resumeData = var.emptyObject;
757 		resumeData.token = this.token;
758 		resumeData.session_id = this.session_id;
759 		resumeData.seq = lastSequenceNumberReceived;
760 
761 		sendWebsocketCommand(OpCode.Resume, resumeData);
762 
763 		// the close event will cancel the heartbeat and thus we need to restart it
764 		if(requestedHeartbeat)
765 			setHeartbeatInterval(requestedHeartbeat);
766 	}
767 
768 	/++
769 	+/
770 	protected void eventReceived(string eventName, var data) {
771 		// FIXME: any time i get an event i could prolly spin it off into an independent async task
772 		switch(eventName) {
773 			case "INTERACTION_CREATE":
774 				var member = data.member; // {"user":{"username":"wrathful_vengeance_god_unleashed","public_flags":0,"id":"395786107780071424","global_name":"adr","discriminator":"0","clan":null,"avatar_decoration_data":null,"avatar":"e3c2aacef7920d3a661a19aaab969337"},"unusual_dm_activity_until":null,"roles":[],"premium_since":null,"permissions":"1125899906842623","pending":false,"nick":"adr","mute":false,"joined_at":"2022-08-24T12:37:21.252000+00:00","flags":0,"deaf":false,"communication_disabled_until":null,"avatar":null}
775 
776 				SlashCommandHandler.CommandArgs commandArgs;
777 
778 				commandArgs.interactionType = cast(InteractionType) data.type.get!int;
779 				commandArgs.interactionToken = data.token.get!string;
780 				commandArgs.interactionId = data.id.get!string;
781 				commandArgs.guildId = data.guild_id.get!string;
782 				commandArgs.channelId = data.channel_id.get!string;
783 				commandArgs.member = member;
784 				commandArgs.channel = data.channel;
785 
786 				commandArgs.interactionData = data.data;
787 				// data.data : type/name/id. can use this to determine what function to call. prolly will include other info too
788 				// "data":{"type":1,"name":"hello","id":"1233221536522174535"}
789 
790 				// application_id and app_permissions and some others there too but that doesn't seem important
791 
792 				/+
793 					replies:
794 					https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type
795 				+/
796 
797 				scope SlashCommandHandler.InteractionReplyHelper replyHelper = new SlashCommandHandler.InteractionReplyHelper(api, commandArgs);
798 
799 				Exception throwExternally;
800 
801 				try {
802 					if(slashCommandHandler_ is null)
803 						throwExternally = ArsdException!"No slash commands registered"();
804 					else {
805 						auto cmdName = commandArgs.interactionData.name.get!string;
806 						if(auto pHandler = cmdName in slashCommandHandler_.handlers) {
807 							(*pHandler)(commandArgs, replyHelper, api);
808 						} else {
809 							throwExternally = ArsdException!"Unregistered slash command"(cmdName);
810 						}
811 					}
812 				} catch(ArsdExceptionBase e) {
813 					const(char)[] msg = e.message;
814 					if(msg.length == 0)
815 						msg = "I am error.";
816 
817 					e.getAdditionalPrintableInformation((string name, in char[] value) {
818 						msg ~= ("\n");
819 						msg ~= (name);
820 						msg ~= (": ");
821 						msg ~= (value);
822 					});
823 
824 					replyHelper.replyWithError(msg);
825 				} catch(Exception e) {
826 					replyHelper.replyWithError(e.message);
827 				}
828 
829 				if(throwExternally !is null)
830 					throw throwExternally;
831 			break;
832 			case "READY":
833 				this.session_id = data.session_id.get!string;
834 				this.resume_gateway_url = data.resume_gateway_url.get!string;
835 				this.applicationId_ = data.application.id.get!string;
836 
837 				if(slashCommandHandler_ !is null && applicationId.length)
838 					slashCommandHandler_.register(api, applicationId);
839 			break;
840 
841 			default:
842 		}
843 	}
844 
845 	private string session_id;
846 	private string resume_gateway_url;
847 	private string applicationId_;
848 
849 	/++
850 		Returns your application id. Only available after the connection is established.
851 	+/
852 	public string applicationId() {
853 		return applicationId_;
854 	}
855 
856 	private arsd.core.Timer heartbeatTimer;
857 	private int requestedHeartbeat;
858 	private bool requestedHeartbeatSet;
859 	//private int heartbeatsSent;
860 	//private int heartbeatAcksReceived;
861 	private MonoTime mostRecentHeartbeatAckRecivedAt;
862 
863 	protected void sendHeartbeat() {
864 	arsd.core.writeln("sendHeartbeat");
865 		sendWebsocketCommand(OpCode.Heartbeat, var(lastSequenceNumberReceived));
866 	}
867 
868 	private final void sendHeartbeatThunk() {
869 		this.sendHeartbeat(); // also virtualizes which wouldn't happen with &sendHeartbeat
870 		if(requestedHeartbeatSet == false) {
871 			heartbeatTimer.changeTime(requestedHeartbeat, true);
872 			requestedHeartbeatSet = true;
873 		} else {
874 			if(MonoTime.currTime - mostRecentHeartbeatAckRecivedAt > 2 * requestedHeartbeat.msecs) {
875 				// throw ArsdException!"connection has no heartbeat"(); // FIXME: pass the info?
876 				websocket.close(1006, "heartbeat unanswered");
877 				reconnectAndResume();
878 			}
879 		}
880 	}
881 
882 	/++
883 	+/
884 	protected void setHeartbeatInterval(int msecs) {
885 		requestedHeartbeat = msecs;
886 		requestedHeartbeatSet = false;
887 
888 		if(heartbeatTimer is null) {
889 			heartbeatTimer = new arsd.core.Timer;
890 			heartbeatTimer.setPulseCallback(&sendHeartbeatThunk);
891 		}
892 
893 		// the first one is supposed to have random jitter
894 		// so we'll do that one-off (but with a non-zero time
895 		// since my timers don't like being run twice in one loop
896 		// iteration) then that first one will set the repeating time
897 		import std.random;
898 		auto firstBeat = std.random.uniform(10, msecs);
899 		heartbeatTimer.changeTime(firstBeat, false);
900 	}
901 
902 	/++
903 
904 	+/
905 	void sendWebsocketCommand(OpCode op, var d) {
906 		assert(websocket !is null, "call connect before sending commands");
907 
908 		var cmd = var.emptyObject;
909 		cmd.d = d;
910 		cmd.op = cast(int) op;
911 		websocket.send(cmd.toJson());
912 	}
913 
914 	/++
915 
916 	+/
917 	void connect() {
918 		assert(websocket is null, "do not call connect twice");
919 
920 		if(cachedGatewayUrl is null) {
921 			auto obj = api.rest.gateway.bot.GET().result;
922 			cachedGatewayUrl = obj.url.get!string;
923 		}
924 
925 		this.websocket_ = new WebSocket(Uri(cachedGatewayUrl));
926 
927 		websocket.onmessage = &handleWebsocketMessage;
928 		websocket.onclose = &handleWebsocketClose;
929 
930 		websocket.connect();
931 
932 		var d = var.emptyObject;
933 		d.token = token;
934 			// FIXME?
935 		d.properties = [
936 			"os": "linux",
937 			"browser": "arsd.discord",
938 			"device": "arsd.discord",
939 		];
940 
941 		sendWebsocketCommand(OpCode.Identify, d);
942 	}
943 }
944 
945 class DiscordRpcConnection {
946 
947 	// this.websocket_ = new WebSocket(Uri("ws://127.0.0.1:6463/?v=1&client_id=XXXXXXXXXXXXXXXXX&encoding=json"), config);
948 	// websocket.send(`{ "nonce": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "args": { "access_token": "XXXXXXXXXXXXXXXXXXXXX" }, "cmd": "AUTHENTICATE" }`);
949 	// writeln(websocket.waitForNextMessage.textData);
950 
951 	// these would tell me user names and ids when people join/leave but it needs authentication alas
952 
953 	/+
954 	websocket.send(`{ "nonce": "ce9a6de3-31d0-4767-a8e9-4818c5690015", "args": {
955     "guild_id": "SSSSSSSSSSSSSSSS",
956     "channel_id": "CCCCCCCCCCCCCCCCC"
957   },
958   "evt": "VOICE_STATE_CREATE",
959   "cmd": "SUBSCRIBE"
960 }`);
961 	writeln(websocket.waitForNextMessage.textData);
962 
963 	websocket.send(`{ "nonce": "de9a6de3-31d0-4767-a8e9-4818c5690015", "args": {
964     "guild_id": "SSSSSSSSSSSSSSSS",
965     "channel_id": "CCCCCCCCCCCCCCCCC"
966   },
967   "evt": "VOICE_STATE_DELETE",
968   "cmd": "SUBSCRIBE"
969 }`);
970 
971 		websocket.onmessage = delegate(in char[] msg) {
972 			writeln(msg);
973 
974 			import arsd.jsvar;
975 			var m = var.fromJson(msg.idup);
976 			if(m.cmd == "DISPATCH") {
977 				if(m.evt == "SPEAKING_START") {
978 					//setSpeaking(m.data.user_id.get!ulong, true);
979 				} else if(m.evt == "SPEAKING_STOP") {
980 					//setSpeaking(m.data.user_id.get!ulong, false);
981 				}
982 			}
983 		};
984 
985 
986 	+/
987 
988 
989 }