Open jdanthdavis opened 1 month ago
Possible syntax
Boss:interval
- e.g., Nex:5
If :interval
suffix is missing, assume :1
Longer example
Hespori:20
Nex
Vorkath:100
Zulrah:1
but if the same boss is listed twice with different intervals, it's unclear which to use
This setting may be too complicated to accurately explain to users
Since you have programming experience, I would recommend setting your interval to 1 and write code for a custom webhook handler to forward the notification if your boss-specific interval is met
This setting may be too complicated to accurately explain to users
Since you have programming experience, I would recommend setting your interval to 1 and write code for a custom webhook handler to forward the notification if your boss-specific interval is met
Thanks for the information. This may be a good opportunity for me to explore plugin development! Do you have any recommended guides/docs for creating custom webhooks?
To clarify, rather than writing runelite plugin code, you can write a HTTP request handler that accepts Dink's POST requests and performs custom logic. If you set your kc interval to 1, every kc would hit your logic, which can selectively forward the notification to discord webhook URLs depending on whether the kc is divisible by arbitrary boss-specific values.
To host your webhook handler for free, you can use Cloudflare Workers or AWS Lambda or a localhost webserver.
To parse Dink requests, I recommend you read these docs: https://github.com/pajlads/DinkPlugin/blob/master/docs/json-examples.md#structure
To clarify, rather than writing runelite plugin code, you can write a HTTP request handler that accepts Dink's POST requests and performs custom logic. If you set your kc interval to 1, every kc would hit your logic, which can selectively forward the notification to discord webhook URLs depending on whether the kc is divisible by arbitrary boss-specific values.
To host your webhook handler for free, you can use Cloudflare Workers or AWS Lambda or a localhost webserver.
To parse Dink requests, I recommend you read these docs: https://github.com/pajlads/DinkPlugin/blob/master/docs/json-examples.md#structure
Thank you for the clarification. I'm making good progress! I was wondering if there was some documentation for boss names? I.e. will Dink send me Inferno
vs Tzkal-Zuk
or Fortis Colosseum
vs Sol heredit
?
Thank you for the clarification. I'm making good progress! I was wondering if there was some documentation for boss names? I.e. will Dink send me
Inferno
vsTzkal-Zuk
orFortis Colosseum
vsSol heredit
?
Glad to hear! For the kill count plugin, it uses the exact name used in the chat message. The main exception to this is (Corrupted) Gauntlet: we report the NPC name instead (i.e., Crystalline Hunllef or Corrupted Hunllef). For your two examples, TzKal-Zuk
and Sol Heredit
are correct
Thank you for the clarification. I'm making good progress! I was wondering if there was some documentation for boss names? I.e. will Dink send me
Inferno
vsTzkal-Zuk
orFortis Colosseum
vsSol heredit
?Glad to hear! For the kill count plugin, it uses the exact name used in the chat message. The main exception to this is (Corrupted) Gauntlet: we report the NPC name instead (i.e., Crystalline Hunllef or Corrupted Hunllef). For your two examples,
TzKal-Zuk
andSol Heredit
are correct
Appreciate the information! I am getting a bit stuck on creating the img url to where when it sends to the webhook it posts the screenshot from Dink. Do you have any tips on how to tackle this? Does the img actually have to be hosted somewhere?
Ignoring the heresy that is my snake case, this is my node.js running express + multer to handle dink requests. I've stripped out the surrounding code, this should hopefully point you in the right direction.
This link has a lot of great documentation: https://expressjs.com/en/resources/middleware/multer.html
//Require express and multer (for multi-part webhook/API body handling)
const express = require("express");
const multer = require("multer");
//Initialize express as webhook_handler and define a port
const webhook_handler = express();
const PORT = 1234;
//Start express on the defined port
webhook_handler.listen(PORT, () => console.log(`🚀 Webhook Listening-Server running on port ${PORT}.`));
webhook_handler.use(express.json());
//Multer diskstorage for image up/down load to local
const storage = multer.diskStorage({
destination: './webhook_files',
filename: function (req, file, cb) {
cb(null, path.parse(file.originalname).name + "-" + Date.now() + path.extname(file.originalname));
}
});
//Multer file filter for extention
const file_filter = function (req, file, cb) {
const allowed_mimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
if (allowed_mimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(null, false);
}
};
//upload called when file receieved on webhook, file size limit and filter
//maximum file size is 15Mb
const upload = multer({
storage: storage,
limits: { fileSize: 15 ** 7 },
fileFilter: file_filter
}).single('file');
webhook_handler
and upload
:
webhook_handler.post(webhook.route_param, upload, (...args) => another_file.execute(bot, sqlcon, upload, ...args));
Once you handle all of this and build some infrastructure around your webhook_handler, when you get up to the handling of KC (I had the same use case) it's very simple:
//Assume request is parsed to this class on API call, which includes the whole incoming request, body, imagepath etc
const content = request.body;
//Set payload to JSON parsed payload_json from body.
const payload = JSON.parse(content.payload_json);
//Create type, player_name, account_type const, from above payload information.
const type = payload.type;
const player_name = payload.playerName;
const account_type = payload.accountType;
//Create extra const, from supplied JSON metadata from 'Dink'
const extra = payload.extra;
//File handling
const image_name = request.file ? request.file.filename : null;
const image_path = request.file ? request.file.path : null;
//Prepare generic event_embed (used for all types).
const event_embed = new Discord.EmbedBuilder()
.setColor("#ad4234")
.setAuthor({ name: author_name, iconURL: payload.embeds[0].author.icon_url, url: payload.embeds[0].author.url })
.setTimestamp()
//THIS STEP IS IMPORTANT, if the user did not submit an image, you want to account for that
//Check if image was submitted -> if image, set the attachment to the image.
if(image_path){
event_embed.setImage(`attachment://${image_name}`);
}
//HANDLE KILL COUNT (BOSSING)
if(payload.type === "KILL_COUNT"){
const boss_kc = extra.count;
const remainder_by_5 = extra.count % 5;
const remainder_by_50 = extra.count % 50;
if(boss_kc < 5){
//OPTIONAL MESSAGE
}else if(boss_kc < 50 && remainder_by_5 === 0){
//OPTIONAL MESSAGE
}else if(remainder_by_50 === 0){
//OPTIONAL MESSAGE
}else{
//Otherwise, no condition above matched, so delete the file if any and return.
if(image_path) //handle deletion of image from server (free up storage space)
return;
}
event_embed
.setDescription(`${source.aquire_type} **[${extra.boss}](${source.wiki_URL}})** for the ${extra.count}${utils_formatting.ordinal(extra.count)} time!`)
.setThumbnail('https://oldschool.runescape.wiki/images/Multicombat.png')
}
you can also simplify this by just reusing the incoming request so you don't need to save the image to file or bother with discord.js
if (payload.type === "KILL_COUNT") {
const boss = extra.boss;
const kc = extra.count;
if (isNoteworthyKc(boss, kc)) {
return await fetch(env["url"], request);
}
}
I would think this approach would work for me with fastify as well but something as simple as -
await fetch(WH_URL, { method: "POST", headers: { 'content-type':'multipart/form-data; boundary=--------------------------807394529256768815026859'}, body: request, })
but I'm having no luck. I'm leaning towards the issue being with the body and how Discord is expecting a multipart/form-data
post to look like.
I've tried with Dink sending the request and with Postman. Here is the log from Postman -
POST / HTTP/1.1
User-Agent: PostmanRuntime/7.39.0
Accept: */*
Postman-Token: 7c33dadb-b80f-42ad-992e-729050ff32de
Host: 127.0.0.1:3000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------740985452248073530185399
Cookie: __dcfduid=896cb03a1d2511ef85d3aa593567cc5c; __sdcfduid=896cb03a1d2511ef85d3aa593567cc5c8e91ab58643ba6ce1b7b7810faf449fea093a0b42c4ffd728b108ee2134e15bb
Content-Length: 501747
----------------------------740985452248073530185399
Content-Disposition: form-data; name="payload_json"
{"type":"KILL_COUNT","playerName":"LSx Swap","accountType":"NORMAL","dinkAccountHash":"","seasonalWorld":false,"extra":{"boss":"Sarachnis","count":691,"gameMessage":"Your Sarachnis kill count is: 691.","time":"PT32S","isPersonalBest":false},"content":"**LSx Swap** has defeated **Sarachnis** with a completion count of **691**","embeds":[]}
----------------------------740985452248073530185399
Content-Disposition: form-data; name="file"; filename="Untitled.png"
<Untitled.png>
----------------------------740985452248073530185399--
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2
Date: Tue, 28 May 2024 19:31:43 GMT
Connection: keep-alive
Keep-Alive: timeout=72
don't think you can pass fastify's Request object as a fetch
body. might be easier to yield http code 307 to trigger client-sided redirect: reply.code(307).redirect('WH_URL')
oh actually code 307 won't work because runelite uses an ancient okhttp version (3.14.9
), and 307/308 POST are not supported until 4.6.0: https://togithub.com/square/okhttp/issues/3111
if you're willing to wait, we can add logic to dink to workaround that okhttp bug
don't think you can pass fastify's Request object as a
fetch
body. might be easier to yield http code 307 to trigger client-sided redirect:reply.code(307).redirect('WH_URL')
I went ahead and started looking down this approach but testing with Postman for the time being. It does work however, if an image is sent with the redirect I get error Error: You cannot pipe to this stream after the outbound request has started.
. No matter the flow of redirects. I.e. -
No img redirect ->Posts -> No img redirect -> Posts -> img redirect -> error img redirect -> Posts -> img redirect -> error img redirect -> Posts -> No img redirect -> error
Pretty clear the image has something to do with it but I can't seem to find any similar situations online.
@jdanthdavis could you share your full js code? this problem is outside the realm of dink (can't replicate using a cloudflare worker), but maybe one of us can spot where things are going wrong
@jdanthdavis sample cloudflare worker handler https://gist.github.com/iProdigy/53654691a76679221a74fd442dea068a
Here is my current Cloudflare Worker (would love feedback if you're not busy) - https://gist.github.com/jdanthdavis/647ab73453373f2381f357c9652b58b7
After reviewing yours I was able to get Postman to work flawlessly! Of course, an attempt with Dink didn't succeed but I'm sure your fix will address that. Here is the Cloudflare log of the Dink post in case it could be helpful for you. At this point I think we just wait til the fix is published. I really appreciate your help @iProdigy and @APKiwi! It was my first time working with webhooks and workers and it was awesome to learn.
{
"outcome": "ok",
"scriptVersion": {
"id": "redacted"
},
"scriptName": "redacted",
"diagnosticsChannelEvents": [],
"exceptions": [],
"logs": [],
"eventTimestamp": 1716968129827,
"event": {
"request": {
"url": "redactedCloudflareUrl",
"method": "POST",
"headers": {
"accept-encoding": "gzip, br",
"cf-connecting-ip": "redacted",
"cf-ipcountry": "US",
"cf-ray": "redacted",
"cf-visitor": "{\"scheme\":\"https\"}",
"connection": "Keep-Alive",
"content-length": "556",
"content-type": "multipart/form-data; boundary=bde7ef43-32e6-4137-babc-40c8e9e2b0f1",
"host": "redactedCloudflareUrl",
"user-agent": "RuneLite/1.10.31.2-b22f15e (Dink/1.x)",
"x-forwarded-proto": "https",
"x-real-ip": "redacted"
},
"cf": {
"clientTcpRtt": 18,
"longitude": "redacted",
"httpProtocol": "HTTP/2",
"tlsCipher": "redacted",
"continent": "NA",
"asn": 209,
"clientAcceptEncoding": "gzip",
"country": "US",
"verifiedBotCategory": "",
"tlsClientAuth": {
"certIssuerDNLegacy": "",
"certIssuerSKI": "",
"certSubjectDNRFC2253": "",
"certSubjectDNLegacy": "",
"certFingerprintSHA256": "",
"certNotBefore": "",
"certSKI": "",
"certSerial": "",
"certIssuerDN": "",
"certVerified": "NONE",
"certNotAfter": "",
"certSubjectDN": "",
"certPresented": "0",
"certRevoked": "0",
"certIssuerSerial": "",
"certIssuerDNRFC2253": "",
"certFingerprintSHA1": ""
},
"tlsExportedAuthenticator": {
"clientFinished": redacted,
"clientHandshake": redacted,
"serverHandshake": redacted,
"serverFinished": redacted
},
"tlsVersion": "TLSv1.3",
"city": redacted,
"timezone": redacted,
"colo": "BNA",
"tlsClientHelloLength": "385",
"edgeRequestKeepAliveStatus": 1,
"postalCode": redacted,
"region": redacted,
"latitude": redacted,
"requestPriority": "weight=16;exclusive=0;group=0;group-weight=0",
"regionCode": redacted,
"asOrganization": redacted,
"metroCode": redacted,
"tlsClientExtensionsSha1": redacted,
"tlsClientRandom": redacted
}
},
"response": {
"status": 200
}
},
"id": 0
}
@jdanthdavis glad to hear!
in terms of code review:
extra.playerName
should be payload.playerName
extra
fields to variables before checking the notification type (but js is a loose language so your code is fine too). a side effect: when someone achieves a quest speedrunning personal best, the notification is forwarded to PB_URL
with your current codebossMap.has(bossName.toUpperCase())
can just be bossInterval !== undefined
THEATRE OF BLOOD HARD MORE
has a typo (MORE
should be MODE
)bossName === 'SOL HEREDIT'
and bossName === 'TZKAL-ZUK'
will always evaluate to false since you don't have bossName.toUpperCase()
isPersonalBest
is usually true for kc === 1
(sometimes not, due to jagex spaghetti), so the notification would be forwarded anywayslastly, since you're on cf workers now, if you don't want to wait for the okhttp patch, you can switch back to return await fetch(isPb ? PB_URL : KC_URL, request);
@iProdigy thanks for the feedback; some really good catches their as well! I went ahead and made the adjustments - https://gist.github.com/jdanthdavis/647ab73453373f2381f357c9652b58b7
It is fully working now as intended. However, I did receive this 500
error stating This ReadableStream is disturbed (has already been read from), and cannot be used as a body.
The solution was to change
const form = await request.formData();
to
const form = await request.clone().formData();
Again, greatly appreciate all the help with this!
@iProdigy Quick question, we ran into an instance where the special occasion logic triggered and we saw some unexpected results. A player got their first KC but it also was their personal best since it's their first completion. However, the player double-deathed with the boss. What's interesting is that we had the message for a personal best but it was sent to our KC URL
.
I've setup a fiddle with manipulated data and it works as intended. Did we see this because Dink would be sending two different payloads for this scenario? I.e it sent the personal best payload first and then it would have sent the kc payload after? I'm just confused on how we received the dink message for a personal best but the return await fetch(!isPb ? KC_URL : PB_URL, request);
went to the PB_URL.
Your logic has: const isPb = extra.isPersonalBest && killCount !== 1;
So isPb
would be false since it's their first kill
Which aligns with what I intended but we received the personal best message (but sent to our KC_URL
) from Dink. We never received a notification for KC. Shouldn't Dink have sent 2 payloads in this scenario?
It's a single kill so dink sends 1 kc notif
The forwarded message is missing the specific kc because the user has modified the PB Notification Message
to not include %COUNT%
I see. We achieved a PB so dink sends the PB message but we modified our message to not include %COUNT%
which by default is included in the Dink message.
So, to achieve the logic/messages I want I would have to actually modify the message from Dink once I've done my logic? Which won't be possible until your PR is merged if I'm not mistaking.
you don't need the 307/308 pr to fix this
The easiest approach is to just have users update the value of PB Notification Message
. For example, folks could just append at a completion count of **%COUNT%**
. If your users are doing ::dinkimport
, you can update the json to make this easier on users
Alternatively, you can use the values in the extra
object to construct the message on your server instead, and POST directly to the discord url. This is more involved and folks typically utilize a discord webhook library to create the embed (but it seems like you're not doing embeds, so prolly easy enough without a lib)
The easiest approach is to just have users update the value of PB Notification Message. For example, folks could just append at a completion count of %COUNT%. If your users are doing ::dinkimport, you can update the json to make this easier on users
100% is the easiest solution for this. This is definitely a super picky scenario (plus learning, ya know?)
Alternatively, you can use the values in the extra object to construct the message on your server instead, and POST directly to the discord url. This is more involved and folks typically utilize a discord webhook library to create the embed (but it seems like you're not doing embeds, so prolly easy enough without a lib)
I am able to deconstruct everything from extra
that I need and build specific messages depending on our logic in utils.js
. However, what I am struggling with is creating the correct structure for the payload to the webhook. From reading the official Discord docs it seems not utilizing embeds is a bit of an odd approach these days so the documentation is subpar for constructing a payload in this manner. I'm finding mixed information between needing content
, message
instead of content
, or even using payload_json
for the body(?).
My next approach will be to review the logs of my worker to see what the request
from Dink looks like. Cloudflare is a bit lame in the fact that you need to pay for their upgraded plan to actual have logs that are within your worker and not just the real time logs that only shows the status, trigger, url, ect and of course they have an open issue for upgrading workers currently 😅
yeah the discord docs may not be so clear:
FormData
to prepare the POST body. The first part should be called payload_json
. The second optional part can be named file
, which would contain the screenshot (when available){"content": "your message here"}
cloudflare's real-time logs do report console.log
calls, so you can just leave that running until a notification comes through (and avoid needing to upgrade to a paid worker)
Happy to say with your help we're making wonderful progress. We're getting successful posts with Dink with the custom messages. Albeit, I'm currently passing a random blob
for the img
since I'm issues grabbing the Dink img
. Would that be from:
const file = await request.file;
I also couldn't find the property names of file in the Dink docs either. request.file.name(?)
Just for reference on how I'm building the blob
from an img
URL:
let blob = await fetch("https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRtGtjx1KW-dZmtSyZP2bOPl0PVxtDdLAMUGg&s").then(r => r.blob());
let formData = new FormData();
formData.append('payload_json', '{"content": "your message here"}');
formData.append("file", blob, "mockPhoto.jpg");
you should use const file = form.get("file")
since you already have const form = await request.clone().formData();
then:
const formData = new FormData();
formData.append("payload_json", JSON.stringify({"content": "your message here"}));
if (file !== null) {
formData.append("file", file)
}
I really appreciate your help! With mock data we're looking good. Getting each custom message sent to it's corresponding URL. If you have the time I'd love some feedback on my approach.
https://gist.github.com/jdanthdavis/647ab73453373f2381f357c9652b58b7
I really appreciate your help! With mock data we're looking good. Getting each custom message sent to it's corresponding URL. If you have the time I'd love some feedback on my approach.
https://gist.github.com/jdanthdavis/647ab73453373f2381f357c9652b58b7
yw! I'd add a null check before appending file
(and make sure it can be reused across multiple requests)
may also want to consider a try/catch in case one request fails (which would cause dink to attempt a repeated notif)
Made those adjustments! We did just have an instance where a PB was obtained in game but for the time we posted PT21M35S
which is just straight from extra.time
. Does Dink handle the formatting here and applies it to the gameMessage
before sending the payload? If that's the case and I'm not overlooking something I guess I'll have to do some cleaning of that string.
In the case I do need to clean the string myself; do you have an example of what extra.time
will look like when the player has the precise timing setting in game enabled? I.e. PT21M35S10MS
?
yeah we send extra.time
as a ISO-8601 duration
with precise timing, it'd look like PT21M35.01S
one trick is to remove PT
prefix and add a space after every letter (e.g., via regex)
Seems to be working flawlessly with mock data! Went with this solution -
let sanitizedTime = time.replace("PT", "").replace("S", "").replaceAll(regex, ":")
for our preferred formatting. Thank you again, it's been a pleasure learning!
Hey @iProdigy, noticed something a bit weird with the payload for HMT PBs. We got a 5 man PB but I got
isPersonalBest: false
. Below are screenshots of the exact in-game message and the payload from that exact raid. Any idea what may be going on here?
EDIT: Looks like I'm getting send my "Theatre of Blood total completion time" when the "Theatre of Blood completion time" was the new PB.
Could you screenshot the game tab where it says new personal best (rather than clan tab)
Could you screenshot the game tab where it says new personal best (rather than clan tab)
Sure!
Dink uses the last time posted in chat, so it's reporting 18:25.80
, which technically wasn't a pb
Checklist
Unreleased
section of the changelog for newly added features that sound like my suggestion.Describe your Suggestion
Allow users to enter exceptions that doesn't follow the
Kill Count Interval
value; similar toIgnored Region IDs
in the Death setting. Whether this setting would just send a notification for every time the boss is killed or if possible allow the user to also define what theKill Count Interval
would be for that exception.Reasoning
This would be useful in situations where a group has their
Kill Count Interval
set to100
to avoid spam notifications for the average boss but being able to setKill Count Interval
to say 5 for Zuk/Colosseum would be a nice to have.