Skip to content
This repository has been archived by the owner on Nov 14, 2023. It is now read-only.

feat(subm): request1bld status page #43

Closed
wants to merge 12 commits into from
Closed
5 changes: 4 additions & 1 deletion subm/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ includes:
# # Discord
# DISCORD_API_TOKEN: sekretsekret
# DISCORD_GUILD_ID: 585...
# # DISCORD_USER_ID: 358096357862408195
# # validator-1-bld
# CHANNEL_ID: 946...
# # mod1bld
# REVIEWER_ROLE_ID: 946...
# DISCORD_CLIENT_ID: 874...
# DISCORD_CLIENT_SECRET: sekretsekret

Expand Down
61 changes: 54 additions & 7 deletions subm/src/discordGuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,16 @@ function getContent(host, path, headers, { get }) {
});
}

const query = opts => (opts ? `?${new URLSearchParams(opts).toString()}` : '');

/**
* Discord API (a small slice of it, anyway)
*
* @param {string} token
* @param {{ get: typeof import('https').get }} io
* @param {{
* get: typeof import('https').get,
* setTimeout: typeof setTimeout,
* }} io
*
* // https://discordapp.com/developers/docs/resources/user
* @typedef {{
Expand Down Expand Up @@ -76,21 +81,60 @@ function getContent(host, path, headers, { get }) {
* }} UserObject
* @typedef { string } Snowflake 64 bit numeral
* @typedef { string } TimeStamp ISO8601 format
*
* https://discord.com/developers/docs/resources/channel#message-object
* @typedef {{
* id: Snowflake,
* author: UserObject,
* content: string,
* timestamp: TimeStamp
* }} MessageObject
*/
function DiscordAPI(token, { get }) {
function DiscordAPI(token, { get, setTimeout }) {
// cribbed from rchain-dbr/o2r/gateway/server/main.js
const host = 'discordapp.com';
const api = '/api/v6';
const headers = { Authorization: `Bot ${token}` };

/**
* @param {string} path
* @returns {Promise<any>}
*/
const getJSON = async path => {
const body = await getContent(host, path, headers, { get });
const data = JSON.parse(body);
// console.log('Discord done:', Object.keys(data));
if ('retry_after' in data) {
await new Promise(r => setTimeout(r, data.retry_after));
return getJSON(path);
}
return data;
};

return freeze({
channels: channelID => {
return freeze({
/**
* @param {Record<string,unknown>} opts
* @returns {Promise<MessageObject[]>}
*/
getMessages: opts =>
getJSON(`${api}/channels/${channelID}/messages${query(opts)}`),
messages: messageID =>
freeze({
/**
* @param {string} emoji
* @returns {Promise<UserObject[]>}
*/
reactions: emoji =>
getJSON(
`${api}/channels/${channelID}/messages/${messageID}/reactions/${encodeURIComponent(
emoji,
)}`,
),
}),
});
},
/**
* @param { string } userID
* @returns { Promise<UserObject> }
Expand Down Expand Up @@ -120,8 +164,7 @@ function DiscordAPI(token, { get }) {
const opts = after
? { limit: `${limit}`, after }
: { limit: `${limit}` };
const query = new URLSearchParams(opts).toString();
return getJSON(`${api}/guilds/${guildID}/members?${query}`);
return getJSON(`${api}/guilds/${guildID}/members${query(opts)}`);
},
});
},
Expand Down Expand Up @@ -163,11 +206,12 @@ async function pagedMembers(guild) {
* @param {{
* get: typeof import('https').get,
* stdout: typeof import('process').stdout
* setTimeout: typeof setTimeout,
* }} io
*/
async function main(env, { stdout, get }) {
async function main(env, { stdout, get, setTimeout }) {
const config = makeConfig(env);
const discordAPI = DiscordAPI(config`DISCORD_API_TOKEN`, { get });
const discordAPI = DiscordAPI(config`DISCORD_API_TOKEN`, { get, setTimeout });
const guild = discordAPI.guilds(config`DISCORD_GUILD_ID`);

const roles = await guild.roles();
Expand All @@ -183,8 +227,11 @@ if (require.main === module) {
stdout: process.stdout,
// eslint-disable-next-line global-require
get: require('https').get,
// @ts-ignore
// eslint-disable-next-line no-undef
setTimeout,
}).catch(err => console.error(err));
}

/* global module */
module.exports = { DiscordAPI, avatar };
module.exports = { DiscordAPI, avatar, getContent };
168 changes: 168 additions & 0 deletions subm/src/request1bld.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/* eslint-disable no-await-in-loop */
// See https://github.com/Agoric/validator-profiles/wiki/Request-1-BLD

const { DiscordAPI, getContent } = require('./discordGuild');
const { searchBySender, transfers } = require('./tendermintRPC');

const config = {
host: 'rpc-agoric.nodes.guru',
address: 'agoric15qxmfufeyj4zm9zwnsczp72elxsjsvd0vm4q8h',
quorum: 2,
};

const fail = () => {
throw Error();
};

/**
* @param {ReturnType<ReturnType<typeof DiscordAPI>['channels']>} channel
* @param {ReturnType<ReturnType<typeof DiscordAPI>['guilds']>} guild
* @param {Snowflake} role
* @param {number} quorum
* @yields {{ message: Message, endorsers: User[] }}
* @typedef {import('./discordGuild').Snowflake} Snowflake
*/
async function* authorizedRequests(channel, guild, role, quorum) {
/** @type {Map<Snowflake, import('./discordGuild').GuildMember>} */
const memberDetail = new Map();
/** @param {Snowflake} id */
const getMemberDetail = async id => {
if (memberDetail.has(id)) {
return memberDetail.get(id) || fail();
}
const detail = await guild.members(id);
// console.log(detail);
memberDetail.set(id, detail);
return detail;
};

const messages = await channel.getMessages({ limit: 100 });
const hasAddr = messages.filter(msg => msg.content.match(/agoric1/));
if (!hasAddr) return;
const hasChecks = hasAddr.filter(msg => {
const [checks] = (msg.reactions || []).filter(r => r.emoji.name === '✅');
return (checks || {}).count >= quorum;
});
if (!hasChecks) return;

for (const msg of hasChecks) {
const endorsements = await channel.messages(msg.id).reactions('✅');
const endorsers = [];
for (const endorsement of endorsements) {
const detail = await getMemberDetail(endorsement.id);
if (detail.roles.includes(role)) {
endorsers.push(detail);
}
}
if (endorsers.length >= quorum) {
const [_, address] = msg.content.match(/(agoric1\S+)/);
if (typeof address !== 'string') throw TypeError(address);
yield { message: msg, address, endorsers };
}
}
}

/**
* @param {ReturnType<ReturnType<typeof DiscordAPI>['channels']>} channel
* @param {ReturnType<ReturnType<typeof DiscordAPI>['guilds']>} guild
* @param {string} roleID
* @param {{
* get: typeof import('https').get,
* }} io
*/
async function requestStatus(channel, guild, roleID, { get }) {
const txs = await getContent(
config.host,
searchBySender(config.address),
{},
{ get },
).then(txt => JSON.parse(txt).result.txs);

const txfrs = transfers(txs);
// console.log(txfrs);
const byRecipient = new Map(txfrs.map(txfr => [txfr.recipient, txfr]));
// console.log(byRecipient.keys());

const result = [];
for await (const {
message: { id, timestamp, author },
address,
endorsers,
} of authorizedRequests(channel, guild, roleID, config.quorum)) {
const hash = byRecipient.has(address)
? byRecipient.get(address).hash
: undefined;
result.push({
message: { id, timestamp, author },
address,
endorsers,
hash,
});
}
return result;
}

/**
* @param {Record<string, string | undefined>} env
* @param {{
* get: typeof import('https').get,
* setTimeout: typeof setTimeout,
* }} io
*/
async function main(env, { get, setTimeout }) {
const discordAPI = DiscordAPI(env.DISCORD_API_TOKEN, { get, setTimeout });
const guild = discordAPI.guilds(env.DISCORD_GUILD_ID);

// to get mod-1-bld role id:
// console.log(await guild.roles());

const channel = discordAPI.channels(env.CHANNEL_ID);

const txs = await getContent(
config.host,
searchBySender(config.address),
{},
{ get },
).then(txt => JSON.parse(txt).result.txs);

const txfrs = transfers(txs);
// console.log(txfrs);
const byRecipient = new Map(txfrs.map(txfr => [txfr.recipient, txfr]));
// console.log(byRecipient.keys());

const header = [
'timestamp',
'msgID',
'requestor',
'address',
'endorsers',
'hash',
];
console.log(header.join(','));
for await (const { message: msg, address, endorsers } of authorizedRequests(
channel,
guild,
env.REVIEWER_ROLE_ID,
2,
)) {
const label = user => `${user.username}#${user.discriminator}`;
const ok = endorsers.map(u => label(u.user)).join(' ');
const hash = byRecipient.has(address) ? byRecipient.get(address).hash : '';
console.log(
`${msg.timestamp},${msg.id},${label(
msg.author,
)},${address},${ok},${hash}`,
);
}
}

/* global require, process, module */
if (require.main === module) {
main(process.env, {
// eslint-disable-next-line global-require
get: require('https').get,
setTimeout,
}).catch(err => console.error(err));
}

module.exports = { requestStatus };
Loading