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

feat(subm): 1 BLD request status google sheet #44

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions subm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"lint:eslint": "eslint '**/*.js'",
"lint:types": "tsc -p jsconfig.json",
"start": "node src/subm.js",
"upsert": "node src/request1bld.js",
"deploy": "gcloud app deploy",
"test": "echo \"Error: no test specified\" && exit 1"
},
Expand Down
89 changes: 81 additions & 8 deletions subm/src/discordGuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,18 @@ function getContent(host, path, headers, { get }) {
});
}

const { keys } = Object;
const query = opts =>
keys(opts).length > 0 ? `?${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 +83,63 @@ 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 => {
console.log('getJSON', { 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({
/** @param { Snowflake } channelID */
channels: channelID => {
return freeze({
Copy link
Member

@dtribble dtribble Apr 13, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these freezes would ideally be hardens. I don't know how helpful they are since they are not recursive.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a certain level of PTSD from trying to use HardenedJS in CLI tools. Perhaps it's less painful now than when I ran into problems a while ago. I can give it a try.

/**
* @param {Record<string,unknown>} opts
* @returns {Promise<MessageObject[]>}
*/
getMessages: (opts = {}) =>
getJSON(`${api}/channels/${channelID}/messages${query(opts)}`),
/** @param { Snowflake } messageID */
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 +169,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 All @@ -136,6 +184,27 @@ function avatar(user) {
return `${avatarBase}/${user.id}/${user.avatar}.png`;
}

/**
* @param {(opts: any) => Promise<T[]>} fn
* @param {number} [limit]
* @template {{ id: string }} T
*/
async function paged(fn, limit = 100) {
/** @type {T[][]} */
const pages = [];
/** @type { string | undefined } */
let before;
do {
console.error('getting page', pages.length, before);
// eslint-disable-next-line no-await-in-loop
const page = await fn(before ? { limit, before } : { limit });
if (!page.length) break;
before = page.slice(-1)[0].id;
pages.push(page);
} while (before);
return pages.flat();
}

/**
* @param {ReturnType<ReturnType<DiscordAPI>['guilds']>} guild
*/
Expand Down Expand Up @@ -163,11 +232,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 integrationTest(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 @@ -179,12 +249,15 @@ async function main(env, { stdout, get }) {

/* global require, process */
if (require.main === module) {
main(process.env, {
integrationTest(process.env, {
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, paged };
220 changes: 220 additions & 0 deletions subm/src/request1bld.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/* eslint-disable no-await-in-loop */
// See https://github.com/Agoric/validator-profiles/wiki/Request-1-BLD

const { DiscordAPI, getContent, paged } = require('./discordGuild.js');
const { upsert } = require('./sheetAccess');
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 paged(channel.getMessages);
const hasAddr = messages.filter(msg => msg.content.match(/agoric1/));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need tighter regex match

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need? really? I think no regex is needed at all; this is just an optimization to avoid processing most messages. The 2 reviewers are the real gating factor.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true as the extract is lower - i figured it would be possible to copy the same regex up here to tighten down the search

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 && detail.roles && detail.roles.includes(role)) {
endorsers.push(detail);
}
}
if (endorsers.length >= quorum) {
const [_, address] = msg.content.match(/(agoric1\S+)/);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need tighter regex match + bech32 validation

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not clear to me why this is needed either. For a correct request, this is sufficient to pick out the address.

This tool isn't designed to validate requests. It's designed to find requests that have been validated by reviewers.

I guess I can read "need" as "please add ...".

Copy link

@arirubinstein arirubinstein Apr 13, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're operating on the value - so this would allow an agoric1"/><whatever"breakgoogleparsingwhoknows}, which we could just not have any exposure to with validation. Since there's manual review currently you could rely on that, but if it were me I would prefer exact control over the shape and content of thing for input validation's sake

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

operating on the value is good justification. thanks.

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 = [];
const eachRequest = authorizedRequests(channel, guild, roleID, config.quorum);
for await (const { message, address, endorsers } of eachRequest) {
const hash = byRecipient.has(address)
? byRecipient.get(address).hash
: undefined;
result.push({
message,
address,
endorsers,
hash,
});
}
return result;
}

const label = user => `${user.username}#${user.discriminator}`;

/**
* @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 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}`,
);
}
}

/**
* @param {string[]} args
* @param {Record<string, string | undefined>} env
* @param {Object} io
* @param {typeof import('google-spreadsheet').GoogleSpreadsheet} io.GoogleSpreadsheet
* @param {typeof import('https').get} io.get
* @param {typeof setTimeout} io.setTimeout
*/
const main2 = async (args, env, { get, setTimeout, GoogleSpreadsheet }) => {
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 creds = {
client_email: env.GOOGLE_SERVICES_EMAIL,
private_key: env.GCS_PRIVATE_KEY,
};
// Initialize the sheet - doc ID is the long id in the sheets URL
const doc = new GoogleSpreadsheet(env.SHEET_1BLD_ID);
// Initialize Auth - see https://theoephraim.github.io/node-google-spreadsheet/#/getting-started/authentication
await doc.useServiceAccountAuth(creds);
await doc.loadInfo(); // loads document properties and worksheets
console.log(doc.title);
const sheet = doc.sheetsByIndex[0];

const memberLabel = mem => mem.nick || label(mem.user);
const eachStatus = await requestStatus(channel, guild, env.REVIEWER_ROLE_ID, {
get,
});
for (const { message: msg, address, endorsers, hash } of eachStatus) {
const record = {
To: address,
Link: `https://discord.com/channels/${env.DISCORD_GUILD_ID}/${env.CHANNEL_ID}/${msg.id}`,
At: msg.timestamp.slice(0, '1999-01-01T12:59'.length).replace('T', ' '),
By: label(msg.author),
Reviewers: endorsers.map(memberLabel).join(','),
Tx: hash && `https://agoric.bigdipper.live/transactions/${hash}`,
Request: msg.content,
};
await upsert(sheet, address, record); // do not interleave upserts!
}
};

/* global require, process, module */
if (require.main === module) {
main2(
process.argv.slice(2),
{ ...process.env },
{
// eslint-disable-next-line global-require
get: require('https').get,
setTimeout,
// eslint-disable-next-line global-require
GoogleSpreadsheet: require('google-spreadsheet').GoogleSpreadsheet, // please excuse CJS
},
).catch(err => console.error(err));
}

module.exports = { requestStatus };
Loading