mirror of
https://github.com/SirHumza/orbisRPC.git
synced 2026-10-06 12:00:39 +02:00
364 lines
15 KiB
C
364 lines
15 KiB
C
/* discord.c - Discord gateway client using a USER SESSION token.
|
|
*
|
|
* Flow: TLS websocket -> HELLO(op10) -> IDENTIFY(op2) -> READY(dispatch) ->
|
|
* presence updates (op3) + heartbeats (op1/op11).
|
|
*
|
|
* Fresh IDENTIFY per connection (no RESUME): the identify budget is 1000/24h,
|
|
* far above our reconnect rate, and skipping RESUME removes session-state
|
|
* bookkeeping entirely. Close frames are parsed so a rejected token
|
|
* (close 4004) is reported as fatal instead of looping forever.
|
|
*/
|
|
#include "discord.h"
|
|
#include "clock.h"
|
|
#include "b64.h"
|
|
#include "log.h"
|
|
#include "jsonlite.h"
|
|
#include <fcntl.h>
|
|
#include <unistd.h>
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <time.h>
|
|
|
|
#define GW_HOST "gateway.discord.gg"
|
|
#define GW_PORT 443
|
|
#define GW_PATH "/?v=10&encoding=json"
|
|
|
|
static void make_key(char *out){
|
|
unsigned char b[16];
|
|
int fd=open("/dev/urandom",O_RDONLY);
|
|
if(fd>=0){ read(fd,b,16); close(fd); }
|
|
else { for(int i=0;i<16;i++) b[i]=(unsigned char)(time(NULL)+i*7); }
|
|
b64_encode(b,16,out);
|
|
}
|
|
|
|
/* Gateway events live INSIDE the JSON text payload ("op":N) — not in the
|
|
* WebSocket frame header (1=text, 8=close, 9=ping). Scan top-level keys
|
|
* with string-awareness so a nested `"op":` inside a string value can't
|
|
* spoof the event type. Works on truncated buffers too (op/s/t sit at the
|
|
* payload prefix, while the megabyte-long "d" blob may be cut off). */
|
|
static const char *top_val(const char *json, size_t len, const char *key){
|
|
size_t kl = strlen(key);
|
|
int depth = 0, instr = 0, esc = 0;
|
|
for(size_t i = 0; i < len; i++){
|
|
char c = json[i];
|
|
if(instr){
|
|
if(esc) esc = 0;
|
|
else if(c == '\\') esc = 1;
|
|
else if(c == '"') instr = 0;
|
|
continue;
|
|
}
|
|
if(c == '"'){
|
|
/* possible key: must be at depth 1 followed by optional ws + ':' */
|
|
size_t j = i + 1, k = 0;
|
|
while(j < len && k < kl && json[j] == key[k]){ j++; k++; }
|
|
if(k == kl && j < len && json[j] == '"'){
|
|
j++;
|
|
while(j < len && (json[j]==' '||json[j]=='\t')) j++;
|
|
if(j < len && json[j] == ':'){
|
|
if(depth == 1) return json + j + 1;
|
|
/* nested same-name key: skip its string opener below */
|
|
i = j; continue;
|
|
}
|
|
}
|
|
instr = 1; continue;
|
|
}
|
|
if(c == '{' || c == '[') depth++;
|
|
else if(c == '}' || c == ']') depth--;
|
|
}
|
|
return NULL;
|
|
}
|
|
static int gw_op(const char *json, size_t len){
|
|
const char *v = top_val(json, len, "op");
|
|
if(!v) return -1;
|
|
while(v < json+len && (*v==' '||*v=='\t')) v++;
|
|
if(v >= json+len || *v<'0' || *v>'9') return -1;
|
|
int op = 0;
|
|
while(v < json+len && *v>='0' && *v<='9'){ op = op*10 + (*v-'0'); v++; }
|
|
return op;
|
|
}
|
|
static void gw_seq(discord_t *d, const char *json, size_t len){
|
|
if(!d) return;
|
|
const char *v = top_val(json, len, "s");
|
|
if(!v) return;
|
|
while(v < json+len && (*v==' '||*v=='\t')) v++;
|
|
if(v >= json+len || *v<'0' || *v>'9') return; /* null or missing: not a seq */
|
|
int s = 0;
|
|
while(v < json+len && *v>='0' && *v<='9'){ s = s*10 + (*v-'0'); v++; }
|
|
d->seq = s;
|
|
}
|
|
static int is_ready(const char *json, size_t len){
|
|
const char *v = top_val(json, len, "t");
|
|
if(!v) return 0;
|
|
while(v < json+len && (*v==' '||*v=='\t')) v++;
|
|
return (size_t)(json+len-v) >= 7 && !memcmp(v, "\"READY\"", 7);
|
|
}
|
|
|
|
/* recv one frame with a deadline; -4 = timed out. Skipped oversized frames
|
|
* (-3) are logged and retried transparently. */
|
|
static int rx_frame(discord_t *d, char *buf, size_t cap, int *op, int *fin, int64_t deadline){
|
|
for(;;){
|
|
int nr=ws_recv_frame(&d->ws,buf,cap,op,fin);
|
|
if(nr==-3){ log_msg("skipped oversized gateway frame"); continue; }
|
|
if(nr!=0) return nr;
|
|
if(orbis_mono_s()>deadline) return -4;
|
|
usleep(50000);
|
|
}
|
|
}
|
|
|
|
static int send_identify(discord_t *d, const char *token){
|
|
jl_val_t *root=jl_new_object();
|
|
if(!root) return -1;
|
|
jl_obj_set(root,"op",jl_new_int(2));
|
|
jl_val_t *dd=jl_new_object();
|
|
if(!dd){ jl_free(root); return -1; }
|
|
jl_obj_set(dd,"token",jl_new_string(token));
|
|
jl_val_t *pp=jl_new_object();
|
|
if(!pp){ jl_free(root); return -1; }
|
|
jl_obj_set(pp,"os",jl_new_string("windows"));
|
|
jl_obj_set(pp,"browser",jl_new_string("Discord Client"));
|
|
jl_obj_set(pp,"device",jl_new_string(""));
|
|
jl_obj_set(dd,"properties",pp);
|
|
jl_val_t *pr=jl_new_object();
|
|
if(!pr){ jl_free(root); return -1; }
|
|
jl_obj_set(pr,"status",jl_new_string("online"));
|
|
jl_obj_set(pr,"activities",jl_new_array());
|
|
jl_obj_set(pr,"afk",jl_new_bool(0));
|
|
jl_obj_set(pr,"since",jl_new_null());
|
|
jl_obj_set(dd,"presence",pr);
|
|
jl_obj_set(root,"d",dd);
|
|
char *s=jl_stringify(root); jl_free(root);
|
|
if(!s) return -1;
|
|
int rc = ws_send_text(&d->ws,s,strlen(s));
|
|
free(s);
|
|
return rc < 0 ? -1 : 0;
|
|
}
|
|
|
|
int discord_connect(discord_t *d, const char *token){
|
|
if(!d || !token || !token[0]) return -1;
|
|
memset(d,0,sizeof(*d));
|
|
strncpy(d->token, token, sizeof d->token-1);
|
|
d->token[sizeof d->token-1] = 0;
|
|
char key[64]=""; make_key(key);
|
|
int rc=ws_connect(&d->ws, GW_HOST, GW_PORT, GW_PATH, key);
|
|
if(rc){ log_msg("ws connect fail %d",rc); return -1; }
|
|
d->connected=1;
|
|
int64_t now=orbis_mono_s();
|
|
d->last_heartbeat=now; d->last_ack=now;
|
|
/* HELLO (text frame carrying {"op":10,...}) */
|
|
char buf[2048]; int op=0,fin=0;
|
|
int nr=rx_frame(d,buf,sizeof buf,&op,&fin,now+15);
|
|
if(nr==-4){ log_msg("no HELLO (timeout)"); ws_close(&d->ws); d->connected=0; return -1; }
|
|
if(nr<=0 || op!=1){
|
|
log_msg("no HELLO (nr=%d op=%d)",nr,op);
|
|
ws_close(&d->ws); d->connected=0;
|
|
return -1;
|
|
}
|
|
jl_val_t *h=jl_parse(buf,nr);
|
|
if(h){
|
|
const jl_val_t *dd=jl_obj_get(h,"d");
|
|
if(dd){
|
|
const jl_val_t *hi=jl_obj_get(dd,"heartbeat_interval");
|
|
if(hi&&hi->type==JL_NUMBER) d->hb_interval_ms=(int64_t)hi->num;
|
|
}
|
|
jl_free(h);
|
|
}
|
|
if(!d->hb_interval_ms) d->hb_interval_ms=45000;
|
|
if(d->hb_interval_ms < 5000){
|
|
log_msg("discord: hb_interval %lldms suspiciously small; clamping to 5000",
|
|
(long long)d->hb_interval_ms);
|
|
d->hb_interval_ms = 5000;
|
|
}
|
|
if(send_identify(d, token) < 0){
|
|
log_msg("discord: identify send failed");
|
|
ws_close(&d->ws); d->connected=0;
|
|
return -1;
|
|
}
|
|
log_msg("discord: identify sent, hb=%llds",(long long)(d->hb_interval_ms/1000));
|
|
/* READY confirms the token was accepted. `op` is the WebSocket frame
|
|
* type; the gateway event is JSON inside — parse it, don't switch on it. */
|
|
int64_t dl=orbis_mono_s()+20;
|
|
for(;;){
|
|
nr=rx_frame(d,buf,sizeof buf,&op,&fin,dl);
|
|
if(nr==-4){ log_msg("no READY after identify (timeout)"); break; }
|
|
if(nr<=0){ log_msg("no READY after identify (nr=%d)",nr); break; }
|
|
if(op==8){
|
|
if(!fin){ log_msg("fragmented close; reconnecting"); ws_close(&d->ws); d->connected=0; return -1; }
|
|
if(nr<2){ log_msg("malformed close (len %d); reconnecting", nr); ws_close(&d->ws); d->connected=0; return -1; }
|
|
unsigned code = (((unsigned char)buf[0]<<8)|((unsigned char)buf[1]));
|
|
log_msg("gateway closed during auth: %u",code);
|
|
ws_close(&d->ws); d->connected=0;
|
|
return code==4004 ? -2 : -1;
|
|
}
|
|
if(op==9){ ws_pong(&d->ws); continue; }
|
|
if(op!=1) continue;
|
|
int go = gw_op(buf, (size_t)nr);
|
|
if(go==11){ d->last_ack=orbis_mono_s(); continue; }
|
|
if(go==0 && is_ready(buf, (size_t)nr)){
|
|
gw_seq(d, buf, (size_t)nr);
|
|
log_msg("discord: gateway ready");
|
|
return 0;
|
|
}
|
|
/* other pre-READY events: ignore */
|
|
}
|
|
ws_close(&d->ws); d->connected=0;
|
|
return -1;
|
|
}
|
|
|
|
int discord_set_presence(discord_t *d, const char *state, const char *name,
|
|
const char *application_id, int64_t started_epoch){
|
|
return discord_set_presence_ex(d, state, name, NULL, application_id, NULL, NULL, started_epoch);
|
|
}
|
|
|
|
/* Lowercase titleId into an asset-key buffer. Returns key length. */
|
|
static size_t asset_key(const char *title_id, char *key, size_t cap){
|
|
size_t ki = 0;
|
|
if(!title_id || !key || cap == 0) return 0;
|
|
for(size_t i = 0; title_id[i] && ki < cap-1; i++){
|
|
char c = title_id[i];
|
|
if(c>='A'&&c<='Z') c += 'a'-'A';
|
|
if((c>='a'&&c<='z')||(c>='0'&&c<='9')||c=='_') key[ki++]=c;
|
|
}
|
|
key[ki] = 0;
|
|
return ki;
|
|
}
|
|
|
|
int discord_set_presence_ex(discord_t *d, const char *state, const char *name,
|
|
const char *title_id, const char *application_id,
|
|
const char *art_base_url, const char *art_url,
|
|
int64_t started_epoch){
|
|
if(!d || !d->connected || !name) return -1;
|
|
jl_val_t *act=jl_new_object();
|
|
if(!act) return -1;
|
|
jl_obj_set(act,"name",jl_new_string(name?name:""));
|
|
jl_obj_set(act,"type",jl_new_int(0)); /* Playing */
|
|
if(state&&state[0]) jl_obj_set(act,"state",jl_new_string(state));
|
|
if(title_id&&title_id[0]) jl_obj_set(act,"details",jl_new_string(title_id));
|
|
if(started_epoch>0){
|
|
jl_val_t *ts=jl_new_object();
|
|
if(!ts){ jl_free(act); return -1; }
|
|
if(started_epoch > 0 && started_epoch < 100000000000LL)
|
|
jl_obj_set(ts,"start",jl_new_int(started_epoch*1000LL)); /* integer ms epoch */
|
|
jl_obj_set(act,"timestamps",ts);
|
|
}
|
|
if(application_id&&application_id[0])
|
|
jl_obj_set(act,"application_id",jl_new_string(application_id));
|
|
else if(title_id&&title_id[0]&&!(art_base_url&&art_base_url[0])&&!(art_url&&art_url[0])){
|
|
/* No artwork will appear without a shared app or art URL pack:
|
|
* say so once instead of failing silently every update. */
|
|
static int art_warned = 0;
|
|
if(!art_warned){ art_warned = 1;
|
|
log_msg("art: no application_id or art_base_url; presence sends without artwork"); }
|
|
}
|
|
/* Artwork: asset KEYS into our own application only (verified on
|
|
* hardware 2026-09-23). External-URL large_image makes Discord drop
|
|
* the ENTIRE activity, so URLs are never sent — upload each title's
|
|
* icon in the developer portal (key = lowercase title id) instead. */
|
|
(void)art_url; (void)art_base_url;
|
|
if(title_id&&title_id[0]&&application_id&&application_id[0]){
|
|
char key[16];
|
|
if(asset_key(title_id, key, sizeof key) >= 4){
|
|
jl_val_t *as=jl_new_object();
|
|
if(as){
|
|
jl_obj_set(as,"large_image",jl_new_string(key));
|
|
jl_obj_set(as,"large_text",jl_new_string(name?name:""));
|
|
jl_obj_set(act,"assets",as);
|
|
}
|
|
}
|
|
}
|
|
jl_val_t *dd=jl_new_object();
|
|
jl_obj_set(dd,"activities",jl_new_array());
|
|
jl_arr_push(jl_obj_get(dd,"activities"), act);
|
|
jl_obj_set(dd,"status",jl_new_string("online"));
|
|
/* NOT idle: Gateway expects null, not 0. Sending 0 asserts
|
|
* "idle since 1970" and can wedge the client timer at 0:00. */
|
|
jl_obj_set(dd,"since",jl_new_null());
|
|
jl_obj_set(dd,"afk",jl_new_bool(0));
|
|
jl_val_t *root=jl_new_object();
|
|
if(!root){ jl_free(dd); return -1; }
|
|
jl_obj_set(root,"op",jl_new_int(3));
|
|
jl_obj_set(root,"d",dd);
|
|
char *s=jl_stringify(root); jl_free(root);
|
|
if(!s) return -1;
|
|
int r=ws_send_text(&d->ws,s,strlen(s)); free(s);
|
|
return (r>=0)?0:-1;
|
|
}
|
|
|
|
int discord_clear_presence(discord_t *d){
|
|
if(!d || !d->connected) return -1;
|
|
jl_val_t *dd=jl_new_object();
|
|
jl_obj_set(dd,"activities",jl_new_array());
|
|
jl_obj_set(dd,"status",jl_new_string("online")); /* stay visible, just idle */
|
|
jl_obj_set(dd,"since",jl_new_null());
|
|
jl_obj_set(dd,"afk",jl_new_bool(0));
|
|
jl_val_t *root=jl_new_object();
|
|
if(!root){ jl_free(dd); return -1; }
|
|
jl_obj_set(root,"op",jl_new_int(3));
|
|
jl_obj_set(root,"d",dd);
|
|
char *s=jl_stringify(root); jl_free(root);
|
|
if(!s) return -1;
|
|
int r=ws_send_text(&d->ws,s,strlen(s)); free(s);
|
|
return (r>=0)?0:-1;
|
|
}
|
|
|
|
int discord_tick(discord_t *d){
|
|
if(!d || !d->connected) return -1;
|
|
int64_t now=orbis_mono_s();
|
|
long hb_s=(long)(d->hb_interval_ms/1000); if(hb_s<5)hb_s=5;
|
|
/* gateway must ack heartbeats; 2 missed intervals means it's gone */
|
|
if(d->sent_hb && now-d->last_ack > hb_s*2+15){
|
|
log_msg("heartbeat timeout (no ack)");
|
|
d->connected=0;
|
|
return -1;
|
|
}
|
|
/* fire slightly EARLY (tick cadence adds up to ~1s of jitter) so we are
|
|
* always inside the gateway's heartbeat window */
|
|
long fire = hb_s>3 ? hb_s-2 : hb_s;
|
|
if(now-d->last_heartbeat >= fire){
|
|
char hb[64];
|
|
if(d->seq>0) snprintf(hb,sizeof hb,"{\"op\":1,\"d\":%d}",d->seq);
|
|
else snprintf(hb,sizeof hb,"{\"op\":1,\"d\":null}");
|
|
ws_send_text(&d->ws,hb,strlen(hb));
|
|
d->last_heartbeat=now; d->sent_hb=1;
|
|
}
|
|
/* drain pending server frames (non-blocking); 2048 covers the dispatch
|
|
* envelope ({"t":..,"s":N,..) with margin — big payloads are consumed
|
|
* inside ws_recv_frame regardless of this cap */
|
|
char buf[2048]; int op=0,fin=0;
|
|
for(int i=0;i<32;i++){
|
|
int nr=ws_recv_frame(&d->ws,buf,sizeof buf,&op,&fin);
|
|
if(nr==-3){ log_msg("skipped oversized frame"); continue; }
|
|
if(nr==0) break;
|
|
if(nr<0){ d->connected=0; return -1; }
|
|
if(op==8){ /* WS CLOSE: payload starts with a 2-byte big-endian code */
|
|
if(!fin){ log_msg("fragmented close; reconnecting"); d->connected=0; return -1; }
|
|
if(nr<2){ log_msg("malformed close (len %d); reconnecting", nr); d->connected=0; return -1; }
|
|
unsigned code = (((unsigned char)buf[0]<<8)|((unsigned char)buf[1]));
|
|
log_msg("gateway closed: code=%u",code);
|
|
d->connected=0;
|
|
return code==4004 ? -2 : -1;
|
|
}
|
|
if(op==9){ ws_pong(&d->ws); continue; } /* WS PING -> PONG */
|
|
if(op!=1 && op!=0) continue;
|
|
switch(gw_op(buf, (size_t)nr)){
|
|
case 0: /* DISPATCH: only the sequence matters to us */
|
|
gw_seq(d, buf, (size_t)nr);
|
|
break;
|
|
case 7: /* RECONNECT requested */
|
|
log_msg("gateway: reconnect requested");
|
|
d->connected=0;
|
|
return -1;
|
|
case 9: /* INVALID_SESSION: session dead, fresh IDENTIFY needed.
|
|
* Distinct code so the caller retries promptly instead of
|
|
* doubling into a long backoff. */
|
|
log_msg("gateway: invalid session");
|
|
d->connected=0;
|
|
return -3;
|
|
case 11:
|
|
d->last_ack=now;
|
|
break;
|
|
default: break;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|