Closed ire4ever1190 closed 1 year ago
The boilerplate of downcasting + handling already completed futures can easily be fixed by a small macro or template
Also note that this is just to test the design. More than happy to rewrite/close it depending on how the discussion goes
Nice ! A couple of comments:
One big change is that we don't store Future in the wait table so that we have more flexibility in what we want to return to the user.
Interesting, this makes sense for purpose-built procs like waitForDeletion
etc, but what about truly generic wait handlers ? Do we just return the raw JsonNode
? (which is fine btw imo).
We also don't run inside
onDispatch
so that the cache and everything is properly up-to-date
I think we really could, it seems like a easy bug to fix afaik, feel free to correct me but it should be a matter of swapping the order of execution of on_dispatch
and handleEventDispatch
(currently on_dispatch
runs first, handleEventDispatch
is where cache updating happens).
originally had a mapping of object ID's to list of handlers but then I ran into a problem where if we have an object 1234, how do we know if its getting deleted or reacted to?
A more reliable way would be to take author_id
, guild_id
and channel_id
if they are available in the object (Message for example) and store them inside WaitHandler
. Then try to see if the event being dispatched match them. But to take advantage of Table
you should "hash" them together instead of iterating through every waits to see if the event match the fields. Just "hash" them again when there is a dispatch and check if the key is inside, in Nim this is a O(1) operation.
The table could also be
Table[string, Table[string, WaitHandler]]
where the first key is the event, and second is the object ID.
Proposal: Make it array[EventKind, Table[string, WaitHandler]]
where EventKind
is an enum mapping every event kind. array
is the length of EventKind
if that make sense...
string
could be an "hash" like I said earlier.
This could be implemented with raw pointers and casting instead of inheritance, but imo that is very unclean and could cause problems
Well you're right, but what happens if we generalize waiting for an event ? (which is what is going to happen unless we create a bazillion procs for every use-case)
let check = proc(m: Message): bool = m.content == "yes"
let myWait = waitBuilder(m, chec
let answer = discord.waitFor("MESSAGE_CREATE", myWait)
if not await answer.withTimeout(10 * 1000):
discard await discord.api.sendMessage(m.channel_id, "You haven't replied on time!")
# do stuff ...
The example is kinda funky and could be better but I hope you get the point, check
could be any proc signature, maybe with object variants ?
Timeout could also be implemented internally by having the user pass the time duration but this is fine this way too.
Interesting, this makes sense for purpose-built procs like waitForDeletion etc, but what about truly generic wait handlers ? Do we just return the raw JsonNode ? (which is fine btw imo).
Well it is truely generic since any type can be returned (even JsonNode
). The handler can use a Future
for any type it wants
I think we really could, it seems like a easy bug to fix afaik, feel free to correct me but it should be a matter of swapping the order of execution of on_dispatch and handleEventDispatch (currently on_dispatch runs first, handleEventDispatch is where cache updating happens).
Should just be a matter of that yeah. But this requires the object to be parsed (So that we don't need to deal with raw JsonNode
) so doing it in the event dispatch means the parsing doesn't need to be done in multiple places. on_dispatch
is a user event anyways so we would need to place a checkIfAwaiting
call after it to mimic the same effect
A more reliable way would be to take author_id, guild_id and channel_id if they are available in the object (Message for example) and store them inside WaitHandler. Then try to see if the event being dispatched match them. But to take advantage of Table you should "hash" them together instead of iterating through every waits to see if the event match the fields. Just "hash" them again when there is a dispatch and check if the key is inside, in Nim this is a O(1) operation.
Would it not be better to just use the ID of the message? Hashing author, guild, and channel together just seems like more effort when the ID of the object is already unique. We will still need to iterate through waits anyways since for example a single message might be waiting on both a response and a reaction (I made a mistake in the description with my second WaitTable
idea, will fix that up). Also tables in Nim are O(n)
worst case iirc but I might be wrong.
I still do plan to do some sort of ID mapping though since the waits list probably will get largish for big bots, just thinking it might not work well for ON_DISPATCH
handlers
Proposal: Make it array[EventKind, Table[string, WaitHandler]] where EventKind is an enum mapping every event kind. array is the length of EventKind if that make sense... string could be an "hash" like I said earlier.
Yeah was planning that but since EventKind
doesn't exist yet I was planning to make another PR to switch dispatch over to an enum and then use that here :+1:. One issue with having a second table is that it then won't work for ON_DISPATCH
since if the user is wanting to check raw data, what should we use for the second key? (Unless we just use ""
as the key for ON_DISPATCH
)
Well you're right, but what happens if we generalize waiting for an event ? (which is what is going to happen unless we create a bazillion procs for every use-case) The example is kinda funky and could be better but I hope you get the point, check could be any proc signature, maybe with object variants ?
Very possible with the current system. I have this proc locally (Will push in a bit, was just testing different things)
proc waitFor*[T: DimscordObject](client: DiscordClient, event: string,
handler: proc (obj: T): bool): Future[void] =
## Generic waiting proc that allows you to write your own condition to wait for
# Implemented like most others
let fut = newFuture[void]("waitFor")
result = fut
client.addHandler(event) do (obj: DimscordObject) -> bool:
if fut.finished(): return true
if obj of T: # Just for safety reasons, shouldn't really be needed
# Except we automatically handle down casting and handling the future
if handler(T(obj)):
fut.complete()
return true
Then your example can be written like
let check = proc(m: Message): bool = m.content == "yes"
let answer = discord.waitFor("MESSAGE_CREATE", check)
if not await answer.withTimeout(10 * 1000):
discard await discord.api.sendMessage(m.channel_id, "You haven't replied on time!")
The argument still needs to correspond with the event, but this allows someone to wait for any condition to pass. I also made a version called waitForObject
that returns a Future[T]
instead if the user wants to get the object that passed the condition
With those two procs it will then be easy to write a series of helpers so that users aren't constantly rewriting common handlers. e.g. waitForDeletion
is now
proc waitForDeletion*(client: DiscordClient, msg: Message): Future[void] =
## Waits for a message to be deleted
client.waitFor("MESSAGE_DELETE") do (m: Message) -> bool:
m.id == msg.id
Will work on switching dispatch to enum now. Since it will be nice to get type safety instead of using strings for the events
Now based on #111 so that I'm not using strings for events anymore :+1:
Would it not be better to just use the ID of the message?
This wouldn't work in some case, what if you're waiting for a reply but the replier doesn't ping you back and you have nothing in message_reference
as a consequence ? You're also very likely to be checking against author_id
anyways, it's such a common thing to do that hashing would save the trouble of writing if m.author.id == user.id
everytime imo.
... Hashing author, guild, and channel together just seems like more effort when the ID of the object is already unique.
Yes it's more effort but I don't understand what the "Object ID" is, all i've found that sounds like it is that it's the object address in memory, surely not what you want then. Whatever it is, you'll need to have it back when you're going to be checking the waits if you're not iterating, if you are then maybe seq
would be better instead ? It all depends on whether iterating > hashing or not. Besides, the "hashing" in question is not "real"
One issue with having a second table is that it then won't work for ON_DISPATCH since if the user is wanting to check raw data, what should we use for the second key? (Unless we just use "" as the key for ON_DISPATCH)
Hmmm. On second thought, maybe it's going to be a bad idea to let users wait for on_dispatch
. I mean, they will have to be checking if not data["kind"].getStr == "DISCORD_EVENT": discard"
, or else this will blow up and result in a hard to find bug (mainly because the error messages are bad). Besides, you'll be checking for every dispatched event, sounds very inefficient and I don't think other libraries do this.
Also tables in Nim are O(n) worst case iirc but I might be wrong.
Surprisingly I found little data on the performance of the implementation of Tables in Nim other than the general dict/hashtable performance profile :(
Very possible with the current system. I have this proc locally (Will push in a bit, was just testing different things) ...
handler: proc (obj: T): bool
Well, the issue i was thinking about is that most events handlers are not unary (if you exclude Shard
), it's not an easy problem to solve and you will need some form of type erasure to deal with this. This is if you don't want to impose unary checks, it's perfectly fine if you and krips do (i think it's a shame though).
Certainly wont work for everything, I'll need to check what to use has an ID before going ahead on implementing an inner table. e.g.
MessageDelete
can definitely just be Message.id
MessageCreate
could be the Channel.id
You're also very likely to be checking against author_id anyways, it's such a common thing to do that hashing would save the trouble of writing if m.author.id == user.id everytime imo.
So when you mean hashing the strings together you mean joining them and then hashing? Even then I don't see the use in doing that just to remove an if
statement.
Yes it's more effort but I don't understand what the "Object ID" is, all i've found that sounds like it is that it's the object address in memory, surely not what you want then
I mean that most objects already have an ID attached to them (e.g. Guild
, Message
, User
, etc all have an id
field to identify them)
It all depends on whether iterating > hashing or not.
Hashing or no, I'm also going to need to iterate anyways. The hash table won't remove iterating but just partition the handlers so the iterating is minimised at times
sounds very inefficient and I don't think other libraries do this.
:+1:
Well, the issue i was thinking about is that most events handlers are not unary (if you exclude Shard), it's not an easy problem to solve and you will need some form of type erasure to deal with this. This is if you don't want to impose unary checks, it's perfectly fine if you and krips do (i think it's a shame though).
Already works fine, just a matter of passing an object that contains the fields of the event handler e.g. for GuildMemberUpdate
type
GuildMemberUpdateData = object of DimscordObject
guild: Guild
member: Member
old: Option[Member]
This was the one thing that made be consider pointer
since it would mean would could just use a tuple and pass a pointer to that then cast it back. But imo the shenanigans that could arise from that just doesn't feel worth it
Heya @ire4ever1190 , I experimented with your PR a little bit and I think I have some ideas to improve it:
Using waitFor
as it currently is limits the capabilities of the event awaiting system because a user may expect to match an event against different type depending on the event in question (for now i'll try to focus on waitFor
and not on waitForObject
) :
## testbot wants to ban a member and waits for your confirmation !
client.waitFor(MessageReactionAdd) do (m: Message, u: User, e: Emoji) -> bool:
if (m.id == prompt.id) and (u.id == mod.id):
# we just check if the mod actually responded, the bot would check from the returning data what emoji the mod reacted with and proceed accordingly. (i know `waitFor` returns `Future[void]` and we should use `waitForObject` instead for that...)
if (get(e.name) == "✅") or (get(e.name) == "❎"): return true
#[
currently fails with:
Error: type mismatch
Expression: waitFor(client, MessageReactionAdd, proc (m: Message; u: User;
e: Emoji): bool =
if m.id == and u.id == :
if get(e.name) == "✅" or get(e.name) == "❎":
return true
)
[1] client: DiscordClient
[2] MessageReactionAdd: DispatchEvent
[3] proc (m: Message; u: User; e: Emoji): bool =
if m.id == and u.id == :
if get(e.name) == "✅" or get(e.name) == "❎":
return true: proc (m: Message, u: User, e: Emoji): bool{.noSideEffect, gcsafe.}
Expected one of (first mismatch at [position]):
[3] proc waitFor[T: DimscordObject](client: DiscordClient; event: DispatchEvent;
handler: proc (obj: T): bool): Future[void]
]#
Many such cases like this one so i think this is a pretty big limitation, this should be solved by using the auto
generic to infer the type passed to the handler
field but requires some changes here and there on your code:
type WaitHandler* = proc(){.closure.}
proc waitFor*(client: DiscordClient, event: DispatchEvent, handler: auto): Future[void] =
let fut = newFuture[void]("waitFor(" & $event & ")")
result = fut
client.waits[event].add cast[proc(){.closure.}](handler)
...
Because we're casting to nullary typed proc, we should be able to fit in our wait handlers without using pointers, but casting it back is not trivial but we can work around that using macros to recover the type information. This way it should be possible to get the type info from client.events.someEvent
, where someEvent
is the event name injected by the macro sort of like how the event
macro pragma does but instead of the proc name, we just use the DispatchEvent
enum, it should look like this:
macro `**`(args: untyped): untyped =
# used to apply a tuple values as individual proc arguments
...
macro getEventType(client: DiscordClient, eventKind: DispatchEvent): untyped =
# gives out the typedesc of `client.events.'eventKind' without `Shard`
...
proc checkIfAwaiting[T: tuple or object](client: DiscordClient, event: DispatchEvent, data: T) =
var handlers = addr client.waits[event]
let evt_type = client.getEventType(event)
for i in countdown(handlers[].len - 1, 0):
# casting to the type obtained by `getEventType`
if cast[evt_type](handlers[i])(**data): # since `data` can be a tuple, we should use a macro to unpack and spread its value as arguments.
handlers[].del(i)
Im not sure how but we should also remove Shard
from the type information we get since we don't actually use it with waitFor
and would then make us cast to the wrong type...
checkIfAwaiting
would then be used like this when an event is dispatched:
# dispatch.nim
import sugar
proc messageReactionAdd(s: Shard, data: JsonNode) {.async.} =
## some code blablabla
s.client.checkIfAwaiting(MessageReactionAdd, (msg, user, emoji, exists))
asyncCheck s.client.events.message_reaction_add(s, msg, user, emoji, exists)
Doing this should give us enough flexibility to have a really powerful event awaiting system without even using pointers, cast
is unsafe but macros take care of getting the right type back.
One big issue arising from this would be users who would waitFor
using the wrong types. This is solved by having documentation or emitting clear error messages (harder than it sounds). Not sure what WaitForObject
should return, most probably a tuple.
Note that this does not rely on inheritance so DimscordObject
would be seldom used, if ever. This means minimal breaking changes 👍🏾 .
Looks good 👍 will start tinkering with that.
I should be able to check that the proc passed is valid, so think users shouldn't run into any problems
Bit messy but got initial version of that. Had to do some weird casts to stop cgen issues with the closures, will do more testing but seems alright.
Though might see if just a pointer to a tuple of arguments is easier :thinking: though that might cause issues if the event handlers need to become async
Actually thinking more about it I don't think the pointer to tuple will have problems.
Will probably just restrict users to the waitFor
/waitForObject
api though since it will be easy to shoot themselves in the foot with it
I think waitForEvent with voice would also be useful along voice event pragma.
Yeah had a few plans for voice stuff :+1: I'll probably leave all the event pragma stuff for a followup PR though
Does anyone have a list of gateway events and intents required for them? Think it would be handy to add asserts to make sure that the client has the correct events enabled (Plan to put this in the base nvm, looking at the list it seems it will need to be a case by case basis depending on what I need)waitForObject
helper so that it works for everything
Implemented waitForReaction
and while testing I wasn't getting anything until I realised I needed to add the giGuildMessageReactions
intent
nvm, found the list here :+1: https://discord.com/developers/docs/topics/gateway#list-of-intents
Does anyone have a list of gateway events and intents required for them? Think it would be handy to add asserts to make sure that the client has the correct events enabled (
Plan to put this in the basenvm, looking at the list it seems it will need to be a case by case basis depending on what I need)waitForObject
helper so that it works for everythingImplemented
waitForReaction
and while testing I wasn't getting anything until I realised I needed to add thegiGuildMessageReactions
intent
I've had this idea of making a PR to add asserts everywhere intents are needed, too many footguns out there, I'll do it soon
For raw/unknown events, assuming we're using JsonNode
there is an important design choice we need to make: Should we store the event "type" in the closure environment or outside of it ?
pros:
on_dispatch
, this mean we can wait for multiple events from within a single wait which is super powerfulcons:
JsonNode
contains ? proposed syntax:
client.waitFor(Unknown) do (event_kind: string, data: JsonNode) -> bool:
...
pros:
Table
JsonNode
cons:
raw_waits
data structureproposed syntax:
client.waitFor("EVENT_KIND") do (data: JsonNode) -> bool:
...
Think first proposal works better.
User probably won't be using it often anyways. Only times I ever used onDispatch
were to implement waiting for events anyways
I would prefer outside the environment due to one of the reasons.
EVENT_KIND
is would be any event that is being received whether it be MESSAGE_CREATE, etc. This would be useful if the user wants to waitfor any raw event before dimscord handles it. Would also be helpful with unknown events, which we would kill 2 birds with one stone.I'd probably place the call to check the handlers here https://github.com/krisppurg/dimscord/blob/c1dae26ada05d01c71fdece66addca2f254df557/dimscord/gateway.nim#L298 So it would work for both events that dimscord hasn't implemented along with running before dimscord handles the event.
But if the user would want to use MessageCreate
, it would be after dimscord handles them and if the user would want to use "MESSAGE_CREATE"
, it would be before dimscord handles them.
client.waitFor(Unknown) do (event_kind: string, data: JsonNode) -> bool:
if event_kind == "MESSAGE_CREATE":
# ...
Would run before dimscord handles it (When I make Unknown
get ran in that place I mentioned, currently they don't run at all)
client.waitFor(Unknown) do (event_kind: string, data: JsonNode) -> bool: if event_kind == "MESSAGE_CREATE": # ...
Would run before dimscord handles it (When I make
Unknown
get ran in that place I mentioned, currently they don't run at
hm well for that use-case Unknown
should be called Any
, since it's a bit misleading.
Think the name is fine. Its a bit of a stretch, but you can consider it that its "unknown" what event you want to wait for.
Hm, I guess so then.
For what is worth, I don't agree that the event we're waiting for is unknown. The user do (and should) know what event they're going to be waiting for as demonstrated by checking if event_kind == "EVENT_KIND"
, or else you're probably misusing the feature by doing something really weird. It's subjective but it's why I favor "outside the environment" approach because it cognitively make sense to me, why the waitFor(Unknown)
when the event is actually not Unknown
but just "Unsupported" ? Why not make the check unnecessary in the first place then? This is also how discordpy/pycord and maybe some other libraries do it.
Looks like discord.py just ignores the event.
I really don't want to add another table just to support a very niche feature, but the semantics of the second option can be done via
proc waitFor(client: DiscordClient, event: string, handler: proc (data: JsonNode): bool): Future[void] =
client.waitFor(Unknown) do (eventKind: string, data: JsonNode) -> bool:
if event == eventKind:
return handler(data)
Looks like discord.py just ignores the event.
I really don't want to add another table just to support a very niche feature, but the semantics of the second option can be done via
proc waitFor(client: DiscordClient, event: string, handler: proc (data: JsonNode): bool): Future[void] = client.waitFor(Unknown) do (eventKind: string, data: JsonNode) -> bool: if event == eventKind: return handler(data)
That's best of both world imo :)
Working on that
Delay is combination of uni + discovered an issue where it will use a different proc that has same name as the handler
Ok fixed that issue
Just need to clean up and then it should be ready
Might be best to add any more helpers in future PRs if everyone is fine with the design of the system (so this can be merged and tested more)
Generic event awaiting system that stems from the discussions in #106. One big change is that we don't store
Future
in the wait table so that we have more flexibility in what we want to return to the user. We also don't run insideonDispatch
so that the cache and everything is properly up-to-dateSystem is built via
WaitHandler
s which are stored in aWaitTable
which is in the client object. TheWaitTable
is a mapping of event type (e.g."MESSAGE_CREATE"
,"MESSAGE_DELETE
) to a list of handlers which are awaiting that event. I originally had a mapping of object ID's to list of handlers but then I ran into a problem where if we have an object1234
, how do we know if its getting deleted or reacted to? (If there is a way then I can change it back, which would be nicer since it means we aren't running every handler when we only care about a few).The table could also be
Table[string, Table[string, seq[WaitHandler]]]
where the first key is the event, and second is the object ID.I use inheritance so that the handlers can get the actual value and don't need to parse it themselves, we just need to downcast within the object. This could be implemented with raw pointers and casting instead of inheritance, but imo that is very unclean and could cause problems
This could work for
ON_DISPATCH
also by making a type like soExample: User
In this example we wait for a message to be deleted and then message that it has been deleted. When that message we sent is replied to, we just parrot back what the user said
They can also use
waitFor
andwaitForObject
to easily write their ownExample: Developer
This is how the handlers need to be implemented
TODO
Add timeouts. Maybe return: Sticking with just leading the user handle timeouts themselves withOption[T]
instead which isnone
if the await timed out. We could also just show in the docs that the user should use withTimeout in their code.withTimeout
checkIfAwaiting
to all dispatch locations so that the system works for all events