Move Experimental Discord plugin into master (#1843)

Since the discord branch is being used in production by many projects (MetaGame, AraCred, RaidGuild, etc), and we also want to start using it for SourceCred, it would be a good idea to merge it into master in a separate experimental-discord plugin so the proper discord plugin can be developed in parallel while allowing the other projects to stay up to date with master.

paired with @decentralion
This commit is contained in:
Hammad Jutt 2020-06-08 01:00:25 -06:00 committed by GitHub
parent 1abed5f0ed
commit a38860a3d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 3749 additions and 26 deletions

View File

@ -1 +1 @@
[{"type":"sourcecred/project","version":"0.5.1"},{"discourseServer":null,"id":"sourcecred-test/example-github","identities":[],"initiatives":null,"repoIds":[{"name":"example-github","owner":"sourcecred-test"}],"timelineCredParams":null}]
[{"type":"sourcecred/project","version":"0.5.2"},{"discord":null,"discourseServer":null,"id":"sourcecred-test/example-github","identities":[],"initiatives":null,"repoIds":[{"name":"example-github","owner":"sourcecred-test"}],"timelineCredParams":null}]

View File

@ -4,6 +4,7 @@ import {type Project} from "../core/project";
import {type Weights as WeightsT} from "../core/weights";
import {type PluginDeclaration} from "../analysis/pluginDeclaration";
import {type TimelineCredParameters} from "../analysis/timeline/params";
import {type DiscordToken} from "../plugins/experimental-discord/params";
import {type GithubToken} from "../plugins/github/token";
import {type CacheProvider} from "../backend/cache";
import {DataDirectory} from "../backend/dataDirectory";
@ -17,6 +18,7 @@ export type LoadOptions = {|
+plugins: $ReadOnlyArray<PluginDeclaration>,
+sourcecredDirectory: string,
+githubToken: ?GithubToken,
+discordToken: ?DiscordToken,
+initiativesDirectory: ?string,
|};
@ -30,6 +32,7 @@ export async function load(
const {
sourcecredDirectory,
githubToken,
discordToken,
project,
params,
weightsOverrides,
@ -39,6 +42,7 @@ export async function load(
const context = new LoadContext({
cache: (data: CacheProvider),
githubToken,
discordToken,
reporter,
initiativesDirectory,
});

View File

@ -6,6 +6,7 @@ import {type WeightedGraph as WeightedGraphT} from "../core/weightedGraph";
import * as WeightedGraph from "../core/weightedGraph";
import {type TimelineCredParameters} from "../analysis/timeline/params";
import {type GithubToken} from "../plugins/github/token";
import {type DiscordToken} from "../plugins/experimental-discord/params";
import {type CacheProvider} from "./cache";
import {TaskReporter} from "../util/taskReporter";
import {TimelineCred} from "../analysis/timeline/timelineCred";
@ -14,8 +15,9 @@ import {type PluginLoaders as PluginLoadersT} from "./pluginLoaders";
import * as ComputeFunction from "./computeFunction";
import * as PluginLoaders from "./pluginLoaders";
import {default as githubLoader} from "../plugins/github/loader";
import {default as identityLoader} from "../plugins/identity/loader";
import {default as discordLoader} from "../plugins/experimental-discord/loader";
import {default as discourseLoader} from "../plugins/discourse/loader";
import {default as identityLoader} from "../plugins/identity/loader";
import {default as initiativesLoader} from "../plugins/initiatives/loader";
import {type PluginDeclarations} from "../analysis/pluginDeclaration";
@ -29,6 +31,7 @@ export type LoadContextOptions = {|
+cache: CacheProvider,
+reporter: TaskReporter,
+githubToken: ?GithubToken,
+discordToken: ?DiscordToken,
+initiativesDirectory: ?string,
|};
@ -84,6 +87,7 @@ export class LoadContext {
+_compute: ComputeFunctionT = TimelineCred.compute;
+_pluginLoaders: PluginLoadersT = {
github: githubLoader,
discord: discordLoader,
discourse: discourseLoader,
identity: identityLoader,
initiatives: initiativesLoader,

View File

@ -70,6 +70,7 @@ const mockProxyMethods = (
describe("src/backend/loadContext", () => {
describe("LoadContext", () => {
const githubToken = validateToken("0".repeat(40));
const discordToken = "fakeBotToken";
const project = createProject({id: "testing-project"});
const params = {alpha: 0.123};
const initiativesDirectory = fakes.initiativesDirectory;
@ -90,6 +91,7 @@ describe("src/backend/loadContext", () => {
const loadContext = new LoadContext({
cache,
githubToken,
discordToken,
reporter,
initiativesDirectory,
});
@ -121,6 +123,7 @@ describe("src/backend/loadContext", () => {
const loadContext = new LoadContext({
cache,
githubToken,
discordToken,
reporter,
initiativesDirectory,
});
@ -134,6 +137,7 @@ describe("src/backend/loadContext", () => {
const expectedEnv = {
initiativesDirectory,
githubToken,
discordToken,
reporter,
cache,
};
@ -183,6 +187,7 @@ describe("src/backend/loadContext", () => {
const loadContext = new LoadContext({
cache,
githubToken,
discordToken,
reporter,
initiativesDirectory,
});
@ -195,6 +200,7 @@ describe("src/backend/loadContext", () => {
const expectedEnv = {
initiativesDirectory,
githubToken,
discordToken,
reporter,
cache,
};
@ -219,6 +225,7 @@ describe("src/backend/loadContext", () => {
const loadContext = new LoadContext({
cache,
githubToken,
discordToken,
reporter,
initiativesDirectory,
});

View File

@ -7,9 +7,11 @@ import * as WeightedGraph from "../core/weightedGraph";
import {type PluginDeclaration} from "../analysis/pluginDeclaration";
import {type CacheProvider} from "./cache";
import {type GithubToken} from "../plugins/github/token";
import {type DiscordToken} from "../plugins/experimental-discord/params";
import {type Loader as GithubLoader} from "../plugins/github/loader";
import {type Loader as IdentityLoader} from "../plugins/identity/loader";
import {type Loader as DiscordLoader} from "../plugins/experimental-discord/loader";
import {type Loader as DiscourseLoader} from "../plugins/discourse/loader";
import {type Loader as IdentityLoader} from "../plugins/identity/loader";
import {type Loader as InitiativesLoader} from "../plugins/initiatives/loader";
import {type LoadedInitiativesDirectory} from "../plugins/initiatives/initiativesDirectory";
import {
@ -25,6 +27,7 @@ import {
*/
export type PluginLoaders = {|
+github: GithubLoader,
+discord: DiscordLoader,
+discourse: DiscourseLoader,
+identity: IdentityLoader,
+initiatives: InitiativesLoader,
@ -52,19 +55,21 @@ opaque type PluginGraphs = {|
type MirrorEnv = {
+initiativesDirectory: ?string,
+githubToken: ?GithubToken,
+discordToken: ?DiscordToken,
+reporter: TaskReporter,
+cache: CacheProvider,
};
type GraphEnv = {
+githubToken: ?GithubToken,
+discordToken: ?DiscordToken,
};
/**
* Gets all relevant PluginDeclarations for a given Project.
*/
export function declarations(
{github, discourse, identity, initiatives}: PluginLoaders,
{github, discourse, discord, identity, initiatives}: PluginLoaders,
project: Project
): $ReadOnlyArray<PluginDeclaration> {
const plugins: PluginDeclaration[] = [];
@ -74,6 +79,9 @@ export function declarations(
if (project.discourseServer != null) {
plugins.push(discourse.declaration());
}
if (project.discord != null) {
plugins.push(discord.declaration());
}
if (project.identities.length) {
plugins.push(identity.declaration());
}
@ -87,8 +95,8 @@ export function declarations(
* Updates all mirrors into cache as requested by the Project.
*/
export async function updateMirror(
{github, discourse, initiatives}: PluginLoaders,
{githubToken, cache, reporter, initiativesDirectory}: MirrorEnv,
{github, discourse, discord, initiatives}: PluginLoaders,
{githubToken, discordToken, cache, reporter, initiativesDirectory}: MirrorEnv,
project: Project
): Promise<CachedProject> {
const tasks: Promise<void>[] = [];
@ -105,6 +113,14 @@ export async function updateMirror(
github.updateMirror(project.repoIds, githubToken, cache, reporter)
);
}
if (project.discord) {
if (!discordToken) {
throw new Error("Tried to load Discord, but no Discord bot token set");
}
tasks.push(
discord.updateMirror(project.discord, discordToken, cache, reporter)
);
}
let loadedInitiativesDirectory: ?LoadedInitiativesDirectory;
if (project.initiatives) {
@ -160,8 +176,8 @@ export async function createReferenceDetector(
* Creates PluginGraphs containing all plugins requested by the Project.
*/
export async function createPluginGraphs(
{github, discourse, initiatives}: PluginLoaders,
{githubToken}: GraphEnv,
{github, discourse, discord, initiatives}: PluginLoaders,
{githubToken, discordToken}: GraphEnv,
{cache, project, loadedInitiativesDirectory}: CachedProject,
referenceDetector: ReferenceDetector
): Promise<PluginGraphs> {
@ -177,6 +193,12 @@ export async function createPluginGraphs(
}
tasks.push(github.createGraph(project.repoIds, githubToken, cache));
}
if (project.discord) {
if (!discordToken) {
throw new Error("Tried to load Discord, but no Discord bot token set");
}
tasks.push(discord.createGraph(project.discord, cache));
}
if (loadedInitiativesDirectory) {
tasks.push(

View File

@ -22,6 +22,7 @@ export function createWG(name: string) {
const mockGraphs = {
github: createWG("github"),
discord: createWG("discord"),
discourse: createWG("discourse"),
initiatives: createWG("initiatives"),
contracted: createWG("identity-contracted"),
@ -29,6 +30,7 @@ const mockGraphs = {
const fakes = {
githubDeclaration: ({fake: "githubDeclaration"}: any),
discordDeclaration: ({fake: "discordDeclaration"}: any),
githubReferences: ({fake: "githubReferences"}: any),
discourseDeclaration: ({fake: "discourseDeclaration"}: any),
discourseReferences: ({fake: "discourseReferences"}: any),
@ -59,6 +61,11 @@ const mockPluginLoaders = () => ({
referenceDetector: jest.fn().mockResolvedValue(fakes.githubReferences),
createGraph: jest.fn().mockResolvedValue(mockGraphs.github),
},
discord: {
declaration: jest.fn().mockReturnValue(fakes.discordDeclaration),
updateMirror: jest.fn(),
createGraph: jest.fn().mockResolvedValue(mockGraphs.discord),
},
discourse: {
declaration: jest.fn().mockReturnValue(fakes.discourseDeclaration),
updateMirror: jest.fn(),
@ -149,6 +156,7 @@ describe("src/backend/pluginLoaders", () => {
const cache = mockCacheProvider();
const reporter = new TestTaskReporter();
const githubToken = null;
const discordToken = null;
const initiativesDirectory = null;
const project = createProject({
id: "has-discourse",
@ -158,7 +166,7 @@ describe("src/backend/pluginLoaders", () => {
// When
await PluginLoaders.updateMirror(
loaders,
{githubToken, cache, reporter, initiativesDirectory},
{githubToken, discordToken, cache, reporter, initiativesDirectory},
project
);
@ -177,6 +185,7 @@ describe("src/backend/pluginLoaders", () => {
const loaders = mockPluginLoaders();
const cache = mockCacheProvider();
const githubToken = null;
const discordToken = null;
const initiativesDirectory = null;
const reporter = new TestTaskReporter();
const project = createProject({
@ -187,7 +196,7 @@ describe("src/backend/pluginLoaders", () => {
// When
const p = PluginLoaders.updateMirror(
loaders,
{githubToken, cache, reporter, initiativesDirectory},
{githubToken, discordToken, cache, reporter, initiativesDirectory},
project
);
@ -203,6 +212,7 @@ describe("src/backend/pluginLoaders", () => {
const cache = mockCacheProvider();
const reporter = new TestTaskReporter();
const githubToken = null;
const discordToken = null;
const initiativesDirectory = __dirname;
const project = createProject({
id: "has-initiatives",
@ -212,7 +222,7 @@ describe("src/backend/pluginLoaders", () => {
// When
await PluginLoaders.updateMirror(
loaders,
{githubToken, cache, reporter, initiativesDirectory},
{githubToken, discordToken, cache, reporter, initiativesDirectory},
project
);
@ -233,6 +243,7 @@ describe("src/backend/pluginLoaders", () => {
const loaders = mockPluginLoaders();
const cache = mockCacheProvider();
const githubToken = null;
const discordToken = null;
const initiativesDirectory = null;
const reporter = new TestTaskReporter();
const project = createProject({
@ -243,7 +254,7 @@ describe("src/backend/pluginLoaders", () => {
// When
const p = PluginLoaders.updateMirror(
loaders,
{githubToken, cache, reporter, initiativesDirectory},
{githubToken, discordToken, cache, reporter, initiativesDirectory},
project
);
@ -258,6 +269,7 @@ describe("src/backend/pluginLoaders", () => {
const loaders = mockPluginLoaders();
const cache = mockCacheProvider();
const githubToken = exampleGithubToken;
const discordToken = null;
const reporter = new TestTaskReporter();
const initiativesDirectory = null;
const project = createProject({
@ -268,7 +280,7 @@ describe("src/backend/pluginLoaders", () => {
// When
await PluginLoaders.updateMirror(
loaders,
{githubToken, cache, reporter, initiativesDirectory},
{githubToken, discordToken, cache, reporter, initiativesDirectory},
project
);
@ -291,6 +303,7 @@ describe("src/backend/pluginLoaders", () => {
const loaders = mockPluginLoaders();
const cache = mockCacheProvider();
const githubToken = null;
const discordToken = null;
const project = createProject({
id: "has-discourse",
discourseServer: {serverUrl: "http://foo.bar"},
@ -300,7 +313,7 @@ describe("src/backend/pluginLoaders", () => {
// When
const pluginGraphs = await PluginLoaders.createPluginGraphs(
loaders,
{githubToken},
{githubToken, discordToken},
cachedProject,
references
);
@ -325,6 +338,7 @@ describe("src/backend/pluginLoaders", () => {
const cache = mockCacheProvider();
const loadedInitiativesDirectory = mockLoadedDirectory();
const githubToken = null;
const discordToken = null;
const project = createProject({
id: "has-initiatives",
initiatives: {remoteUrl: "http://example.com/initiatives"},
@ -334,7 +348,7 @@ describe("src/backend/pluginLoaders", () => {
// When
const pluginGraphs = await PluginLoaders.createPluginGraphs(
loaders,
{githubToken},
{githubToken, discordToken},
cachedProject,
references
);
@ -358,6 +372,7 @@ describe("src/backend/pluginLoaders", () => {
const loaders = mockPluginLoaders();
const cache = mockCacheProvider();
const githubToken = null;
const discordToken = null;
const project = createProject({
id: "has-github",
repoIds: [exampleRepoId],
@ -367,7 +382,7 @@ describe("src/backend/pluginLoaders", () => {
// When
const p = PluginLoaders.createPluginGraphs(
loaders,
{githubToken},
{githubToken, discordToken},
cachedProject,
references
);
@ -384,6 +399,7 @@ describe("src/backend/pluginLoaders", () => {
const loaders = mockPluginLoaders();
const cache = mockCacheProvider();
const githubToken = exampleGithubToken;
const discordToken = null;
const project = createProject({
id: "has-github",
repoIds: [exampleRepoId],
@ -393,7 +409,7 @@ describe("src/backend/pluginLoaders", () => {
// When
const pluginGraphs = await PluginLoaders.createPluginGraphs(
loaders,
{githubToken},
{githubToken, discordToken},
cachedProject,
references
);
@ -419,6 +435,7 @@ describe("src/backend/pluginLoaders", () => {
const loaders = mockPluginLoaders();
const cache = mockCacheProvider();
const githubToken = exampleGithubToken;
const discordToken = null;
const loadedInitiativesDirectory = mockLoadedDirectory();
const project = createProject({
id: "has-github-discourse-initiatives",
@ -431,7 +448,7 @@ describe("src/backend/pluginLoaders", () => {
// When
const references = await PluginLoaders.createReferenceDetector(
loaders,
{githubToken},
{githubToken, discordToken},
cachedProject
);

View File

@ -7,6 +7,7 @@ import deepFreeze from "deep-freeze";
import fs from "fs-extra";
import {type Weights, fromJSON as weightsFromJSON} from "../core/weights";
import {validateToken, type GithubToken} from "../plugins/github/token";
import {type DiscordToken} from "../plugins/experimental-discord/params";
export type PluginName = "git" | "github";
@ -33,6 +34,10 @@ export function githubToken(): ?GithubToken {
return validateToken(envToken);
}
export function discordToken(): ?DiscordToken {
return process.env.SOURCECRED_DISCORD_TOKEN || null;
}
export async function loadWeights(path: string): Promise<Weights> {
if (!(await fs.exists(path))) {
throw new Error("Could not find the weights file");

42
src/cli/discord.js Normal file
View File

@ -0,0 +1,42 @@
// @flow
import * as Common from "./common";
import {type Command} from "./command";
import {LoggingTaskReporter} from "../util/taskReporter";
import {DataDirectory} from "../backend/dataDirectory";
import Loader from "../plugins/experimental-discord/loader";
function die(std, message) {
std.err("fatal: " + message);
std.err("fatal: run 'sourcecred help discord' for help");
return 1;
}
// TODO: hack
const reactionWeights = {"sourcecred:678399364418502669": 4};
const discord: Command = async (args, std) => {
if (args.length !== 1) {
return die(std, "Expected one positional argument (or --help).");
}
const [guildId] = args;
const taskReporter = new LoggingTaskReporter();
const dir = new DataDirectory(Common.sourcecredDirectory());
const token = process.env.SOURCECRED_DISCORD_TOKEN || null;
if (!token) {
throw new Error("Expecting a SOURCECRED_DISCORD_TOKEN");
}
const opts = {
guildId,
reactionWeights,
};
await Loader.updateMirror(opts, token, dir, taskReporter);
const wg = await Loader.createGraph(opts, dir);
console.log(wg.graph, wg.weights);
return 0;
};
export default discord;

View File

@ -102,6 +102,7 @@ const command: Command = async (args, std) => {
plugins,
sourcecredDirectory: Common.sourcecredDirectory(),
githubToken: null,
discordToken: null,
initiativesDirectory: null,
},
taskReporter

View File

@ -149,6 +149,7 @@ const loadCommand: Command = async (args, std) => {
params,
weightsOverrides: weights,
plugins,
discordToken: Common.discordToken(),
sourcecredDirectory: Common.sourcecredDirectory(),
githubToken,
initiativesDirectory,

View File

@ -22,6 +22,7 @@ const load: JestMockFn = (require("../api/load").load: any);
describe("cli/load", () => {
const exampleGithubToken = validateToken("0".repeat(40));
const exampleDiscordToken = "fakeBotToken";
beforeEach(() => {
jest.clearAllMocks();
// Tests should call `newSourcecredDirectory` directly when they
@ -34,6 +35,7 @@ describe("cli/load", () => {
const dirname = tmp.dirSync().name;
process.env.SOURCECRED_DIRECTORY = dirname;
process.env.SOURCECRED_GITHUB_TOKEN = exampleGithubToken;
process.env.SOURCECRED_DISCORD_TOKEN = exampleDiscordToken;
process.env.SOURCECRED_INITIATIVES_DIRECTORY = tmp.dirSync().name;
return dirname;
}
@ -82,6 +84,7 @@ describe("cli/load", () => {
plugins: [githubDeclaration],
sourcecredDirectory: Common.sourcecredDirectory(),
githubToken: exampleGithubToken,
discordToken: exampleDiscordToken,
initiativesDirectory: Common.initiativesDirectory(),
};
expect(await invocation).toEqual({
@ -107,6 +110,7 @@ describe("cli/load", () => {
plugins: [githubDeclaration],
sourcecredDirectory: Common.sourcecredDirectory(),
githubToken: exampleGithubToken,
discordToken: exampleDiscordToken,
initiativesDirectory: Common.initiativesDirectory(),
});
expect(await invocation).toEqual({
@ -145,6 +149,7 @@ describe("cli/load", () => {
plugins: [githubDeclaration],
sourcecredDirectory: Common.sourcecredDirectory(),
githubToken: exampleGithubToken,
discordToken: exampleDiscordToken,
initiativesDirectory: Common.initiativesDirectory(),
};
expect(await invocation).toEqual({

View File

@ -12,6 +12,7 @@ import output from "./output";
import clear from "./clear";
import genProject from "./genProject";
import discourse from "./discourse";
import discord from "./discord";
const sourcecred: Command = async (args, std) => {
if (args.length === 0) {
@ -35,6 +36,8 @@ const sourcecred: Command = async (args, std) => {
return output(args.slice(1), std);
case "gen-project":
return genProject(args.slice(1), std);
case "discord":
return discord(args.slice(1), std);
case "discourse":
return discourse(args.slice(1), std);
default:

View File

@ -7,6 +7,7 @@ import {type ProjectParameters as Initiatives} from "../plugins/initiatives/para
import {type Identity} from "../plugins/identity/identity";
import {type DiscourseServer} from "../plugins/discourse/server";
import type {TimelineCredParameters} from "../analysis/timeline/params";
import {type ProjectOptions as Discord} from "../plugins/experimental-discord/params";
export type ProjectId = string;
@ -26,24 +27,26 @@ export type ProjectId = string;
* the future (e.g. showing the last update time for each of the project's data
* dependencies).
*/
export type Project = ProjectV051;
export type Project = ProjectV052;
export type SupportedProject =
| ProjectV030
| ProjectV031
| ProjectV040
| ProjectV050
| ProjectV051
| ProjectV050;
| ProjectV052;
export type ProjectV051 = {|
export type ProjectV052 = {|
+id: ProjectId,
+initiatives: Initiatives | null,
+repoIds: $ReadOnlyArray<RepoId>,
+discourseServer: DiscourseServer | null,
+discord: Discord | null,
+identities: $ReadOnlyArray<Identity>,
+timelineCredParams: $Shape<TimelineCredParameters> | null,
|};
const COMPAT_INFO = {type: "sourcecred/project", version: "0.5.1"};
const COMPAT_INFO = {type: "sourcecred/project", version: "0.5.2"};
/**
* Creates a new Project instance with default values.
@ -61,6 +64,7 @@ export function createProject(p: $Shape<Project>): Project {
discourseServer: null,
initiatives: null,
timelineCredParams: null,
discord: null,
...p,
};
}
@ -83,11 +87,26 @@ export function encodeProjectId(id: ProjectId): string {
return base64url.encode(id);
}
const upgradeFrom050 = (p: ProjectV050): ProjectV051 => ({
const upgradeFrom051 = (p: ProjectV051): ProjectV052 => ({
...p,
timelineCredParams: {},
discord: null,
});
export type ProjectV051 = {|
+id: ProjectId,
+initiatives: Initiatives | null,
+repoIds: $ReadOnlyArray<RepoId>,
+discourseServer: DiscourseServer | null,
+identities: $ReadOnlyArray<Identity>,
+timelineCredParams: $Shape<TimelineCredParameters> | null,
|};
const upgradeFrom050 = (p: ProjectV050) =>
upgradeFrom051({
...p,
timelineCredParams: {},
});
export type ProjectV050 = {|
+id: ProjectId,
+initiatives: Initiatives | null,
@ -143,4 +162,5 @@ const upgrades = {
"0.3.1": upgradeFrom030,
"0.4.0": upgradeFrom040,
"0.5.0": upgradeFrom050,
"0.5.1": upgradeFrom051,
};

View File

@ -15,7 +15,7 @@ import {
import {makeRepoId} from "../plugins/github/repoId";
import {toCompat} from "../util/compat";
import type {ProjectV050} from "./project";
import type {ProjectV050, ProjectV051} from "./project";
describe("core/project", () => {
const foobar = deepFreeze(makeRepoId("foo", "bar"));
@ -27,6 +27,7 @@ describe("core/project", () => {
initiatives: null,
identities: [],
timelineCredParams: null,
discord: null,
});
const p2: Project = deepFreeze({
id: "@foo",
@ -40,6 +41,7 @@ describe("core/project", () => {
},
],
timelineCredParams: null,
discord: {guildId: "1234", reactionWeights: {}},
});
describe("to/from JSON", () => {
it("round trip is identity", () => {
@ -77,6 +79,7 @@ describe("core/project", () => {
// It should strip the apiUsername field, keeping just serverUrl.
discourseServer: {serverUrl: "https://example.com"},
initiatives: null,
discord: null,
timelineCredParams: {},
}: Project)
);
@ -107,6 +110,7 @@ describe("core/project", () => {
// It should strip the apiUsername field, keeping just serverUrl.
discourseServer: {serverUrl: "https://example.com"},
initiatives: null,
discord: null,
timelineCredParams: {},
}: Project)
);
@ -133,6 +137,7 @@ describe("core/project", () => {
...body,
// It should add a default initiatives field.
initiatives: null,
discord: null,
timelineCredParams: {},
}: Project)
);
@ -160,10 +165,37 @@ describe("core/project", () => {
...body,
// It should add default params field.
timelineCredParams: {},
discord: null,
}: Project)
);
});
});
it("should upgrade from 0.5.1 formatting", () => {
// Given
const body: ProjectV051 = {
id: "example-050",
repoIds: [foobar, foozod],
discourseServer: {serverUrl: "https://example.com"},
identities: [],
initiatives: null,
timelineCredParams: {},
};
const compat = toCompat(
{type: "sourcecred/project", version: "0.5.1"},
body
);
// When
const project = projectFromJSON(compat);
// Then
expect(project).toEqual(
({
...body,
discord: null,
}: Project)
);
});
describe("encodeProjectId", () => {
it("is a base64-url encoded id", () => {
const project = {id: "foo bar", repoIds: []};
@ -202,6 +234,7 @@ describe("core/project", () => {
repoIds: [],
identities: [],
timelineCredParams: null,
discord: null,
});
});
it("treats input shape as overrides", () => {
@ -219,6 +252,12 @@ describe("core/project", () => {
},
],
timelineCredParams: {alpha: 0.2, intervalDecay: 0.5},
discord: {
guildId: "123",
reactionWeights: {
emoji: 1,
},
},
};
// When

View File

@ -39,6 +39,12 @@ describe("core/project_io", () => {
identities: [{username: "foo", aliases: ["github/foo", "discourse/foo"]}],
initiatives: {remoteUrl: "https://example.com/initiatives"},
timelineCredParams: {alpha: 0.2, intervalDecay: 0.5},
discord: {
guildId: "123",
reactionWeights: {
emoji: 1,
},
},
});
it("setupProjectDirectory results in a loadable project", async () => {

View File

@ -2,7 +2,7 @@
set -eu
snapshots_dir=src/plugins/discord/snapshots
snapshots_dir=src/plugins/experimental-discord/snapshots
test_instance_url="https://discordapp.com/api"
if [ ! "$(jq --version)" ]; then

View File

@ -0,0 +1,68 @@
# SourceCred Discord plugin
This plugin loads data from a Discord server.
## Developer notes
Discord developer docs can be found at:
https://discordapp.com/developers/docs
### Setting up a bot
To query the API with a reasonable rate-limit, you should set up a Discord app,
and a Discord bot as part of that app.
```
Discord app
└── Discord bot
```
You can create both on the developer portal here:
https://discordapp.com/developers/applications
Permissions this bot will need:
- `View Channels`
- `Read Message History`
Represented as `66560` integer.
Then, someone with appropriate permissions needs to invite this bot to the server.
There isn't a simple generator for this link on the dev portal, you'll need to format it yourself like this:
`https://discordapp.com/api/oauth2/authorize?client_id={{clientID}}&scope=bot&permissions={{permissionsInt}}`
And open it in the browser, as a logged-in server admin.
Read more: https://discordapp.com/developers/docs/topics/oauth2#bot-authorization-flow
### Authenticating API requests
You'll want to set your bot's token in a bot style Authorization header.
https://discordapp.com/developers/docs/reference#authentication
`Authorization: Bot MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se7kKWs`
### Finding configuration values / parameters
Many of the ID's you will need for configuration are exposed using the Discord client.
By enabling developer mode (under Appearance > Advanced > Developer Mode).
A right-click menu option "Copy ID" will appear.
Not every element will support this "Copy ID". For example custom emoji.
You can find some of those out by using the browser version of Discord and inspecting the DOM.
Otherwise you can also use the API and query for it yourself with tools like Postman or CURL.
With the bot invited and auth set up. Find out the Guild ID using:
`GET https://discordapp.com/api/users/@me/guilds`
Find your custom emoji using:
`GET https://discordapp.com/api/guilds/{{discordGuildId}}/emojis`

View File

@ -0,0 +1,394 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`plugins/experimental-discord/fetcher snapshot testing loads channels 1`] = `
Array [
Object {
"id": "678348980756938813",
"name": "Text Channels",
"type": "GUILD_CATEGORY",
},
Object {
"id": "678348980798881844",
"name": "Voice Channels",
"type": "GUILD_CATEGORY",
},
Object {
"id": "678348980849213472",
"name": "general",
"type": "GUILD_TEXT",
},
Object {
"id": "678348980878573661",
"name": "General",
"type": "GUILD_VOICE",
},
Object {
"id": "678394406507905129",
"name": "pagination",
"type": "GUILD_TEXT",
},
Object {
"id": "678696874869522446",
"name": "notmymessage",
"type": "GUILD_TEXT",
},
]
`;
exports[`plugins/experimental-discord/fetcher snapshot testing loads emojis 1`] = `
Array [
Object {
"id": "678399364418502669",
"name": "sourcecred",
},
]
`;
exports[`plugins/experimental-discord/fetcher snapshot testing loads guilds 1`] = `
Array [
Object {
"id": "453243919774253079",
"name": "sourcecred",
"permissions": 104193601,
},
Object {
"id": "629411177947987986",
"name": "MetaGame",
"permissions": 104324673,
},
Object {
"id": "678348980639498428",
"name": "SourceCred Test Server",
"permissions": 33620992,
},
]
`;
exports[`plugins/experimental-discord/fetcher snapshot testing loads members 1`] = `
Array [
Object {
"nick": null,
"roles": Array [
"678349848684003359",
"678350026946117694",
],
"user": Object {
"bot": false,
"discriminator": "5887",
"id": "143776454050709505",
"username": "Beanow",
},
},
Object {
"nick": null,
"roles": Array [
"678349848684003359",
"678350026946117694",
],
"user": Object {
"bot": false,
"discriminator": "8636",
"id": "420341518948237331",
"username": "decentralion",
},
},
Object {
"nick": null,
"roles": Array [
"678349848684003359",
"678350026946117694",
],
"user": Object {
"bot": false,
"discriminator": "8658",
"id": "432981598858903585",
"username": "wchargin",
},
},
Object {
"nick": null,
"roles": Array [
"678349848684003359",
"678350026946117694",
],
"user": Object {
"bot": false,
"discriminator": "2386",
"id": "439050857921904640",
"username": "Brian Litwin",
},
},
Object {
"nick": null,
"roles": Array [
"678359229433905152",
],
"user": Object {
"bot": true,
"discriminator": "1705",
"id": "678351352770068560",
"username": "CredBot-Beanow",
},
},
]
`;
exports[`plugins/experimental-discord/fetcher snapshot testing loads messages 1`] = `
Array [
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "10",
"id": "678394455849566208",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812242234,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "9",
"id": "678394451462193154",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812241188,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "8",
"id": "678394448497082388",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812240481,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "7",
"id": "678394445351092275",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812239731,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "6",
"id": "678394442301833247",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812239004,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "5",
"id": "678394436757094410",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [
Object {
"id": "678399364418502669",
"name": "sourcecred",
},
],
"timestampMs": 1581812237682,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "4",
"id": "678394433233747978",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812236842,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "3",
"id": "678394431149178940",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812236345,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "2",
"id": "678394428569813013",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812235730,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "1",
"id": "678394426153893948",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [
Object {
"id": null,
"name": "👍",
},
],
"timestampMs": 1581812235154,
},
]
`;
exports[`plugins/experimental-discord/fetcher snapshot testing loads messages 2`] = `
Array [
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "20",
"id": "678394498098659349",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812252307,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "19",
"id": "678394493124476939",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812251121,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "18",
"id": "678394489391415348",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812250231,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "17",
"id": "678394486644146187",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812249576,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "16",
"id": "678394484291141700",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812249015,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "15",
"id": "678394480184918016",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [
Object {
"id": null,
"name": "😆",
},
],
"timestampMs": 1581812248036,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "14",
"id": "678394477106167818",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812247302,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "13",
"id": "678394473428025354",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812246425,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "12",
"id": "678394469048909841",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812245381,
},
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"content": "11",
"id": "678394468373626890",
"mentions": Array [],
"nonUserAuthor": false,
"reactionEmoji": Array [],
"timestampMs": 1581812245220,
},
]
`;
exports[`plugins/experimental-discord/fetcher snapshot testing loads reactions 1`] = `
Array [
Object {
"authorId": "143776454050709505",
"channelId": "678394406507905129",
"emoji": Object {
"id": "678399364418502669",
"name": "sourcecred",
},
"messageId": "678394436757094410",
},
]
`;
exports[`plugins/experimental-discord/fetcher snapshot testing loads roles 1`] = `
Array [
Object {
"id": "678348980639498428",
"name": "@everyone",
},
Object {
"id": "678349848684003359",
"name": "allow-adding-data",
},
Object {
"id": "678350026946117694",
"name": "admins",
},
Object {
"id": "678359229433905152",
"name": "CredBot-Beanow",
},
]
`;

View File

@ -0,0 +1,271 @@
// @flow
import {escape} from "entities";
import {type WeightedGraph as WeightedGraphT} from "../../core/weightedGraph";
import {type Weights, type NodeWeight} from "../../core/weights";
import {
Graph,
NodeAddress,
EdgeAddress,
type Node,
type Edge,
type NodeAddressT,
type EdgeAddressT,
} from "../../core/graph";
import {SqliteMirrorRepository} from "./mirrorRepository";
import {
memberNodeType,
messageNodeType,
reactionNodeType,
authorsMessageEdgeType,
addsReactionEdgeType,
reactsToEdgeType,
mentionsEdgeType,
} from "./declaration";
import * as Model from "./models";
// Display this many characters in description.
const MESSAGE_LENGTH = 30;
function messageUrl(
guild: Model.Snowflake,
channel: Model.Snowflake,
message: Model.Snowflake
) {
return `https://discordapp.com/channels/${guild}/${channel}/${message}`;
}
export function userAddress(userId: Model.Snowflake): NodeAddressT {
return NodeAddress.append(memberNodeType.prefix, "user", userId);
}
function memberAddress(member: Model.GuildMember): NodeAddressT {
return NodeAddress.append(
memberNodeType.prefix,
member.user.bot ? "bot" : "user",
member.user.id
);
}
function messageAddress(message: Model.Message): NodeAddressT {
return NodeAddress.append(
messageNodeType.prefix,
message.channelId,
message.id
);
}
function reactionAddress(reaction: Model.Reaction): NodeAddressT {
// Hacky order, so we can boost categories.
return NodeAddress.append(
reactionNodeType.prefix,
reaction.channelId,
Model.emojiToRef(reaction.emoji),
reaction.authorId,
reaction.messageId
);
}
function memberNode(member: Model.GuildMember): Node {
const description = `${escape(member.user.username)}#${
member.user.discriminator
}`;
return {
address: memberAddress(member),
description,
timestampMs: null,
};
}
function messageNode(
message: Model.Message,
guild: Model.Snowflake,
channelName: string
): Node {
const url = messageUrl(guild, message.channelId, message.id);
const partialMessage = escape(message.content.substring(0, MESSAGE_LENGTH));
const description = `#${channelName} message ["${partialMessage}..."](${url})`;
return {
address: messageAddress(message),
description,
timestampMs: message.timestampMs,
};
}
function authorsMessageEdge(
message: Model.Message,
author: Model.GuildMember
): Edge {
const address: EdgeAddressT = EdgeAddress.append(
authorsMessageEdgeType.prefix,
author.user.bot ? "bot" : "user",
author.user.id,
message.channelId,
message.id
);
return {
address,
timestampMs: message.timestampMs,
src: memberAddress(author),
dst: messageAddress(message),
};
}
function reactionNode(
reaction: Model.Reaction,
timestampMs: number,
guild: Model.Snowflake
): Node {
const msgUrl = messageUrl(guild, reaction.channelId, reaction.messageId);
const reactionStr = reaction.emoji.id
? `:${reaction.emoji.name}:`
: reaction.emoji.name;
const description = `Reacted \`${reactionStr}\` to message [${reaction.messageId}](${msgUrl})`;
return {
address: reactionAddress(reaction),
description,
timestampMs,
};
}
function addsReactionEdge(
reaction: Model.Reaction,
member: Model.GuildMember,
timestampMs: number
): Edge {
const address: EdgeAddressT = EdgeAddress.append(
addsReactionEdgeType.prefix,
member.user.bot ? "bot" : "user",
member.user.id,
Model.emojiToRef(reaction.emoji),
reaction.channelId,
reaction.messageId
);
return {
address,
// TODO: for now using timestamp of the message,
// as reactions don't have timestamps.
timestampMs,
src: memberAddress(member),
dst: reactionAddress(reaction),
};
}
function reactsToEdge(reaction: Model.Reaction, message: Model.Message): Edge {
const address: EdgeAddressT = EdgeAddress.append(
reactsToEdgeType.prefix,
Model.emojiToRef(reaction.emoji),
reaction.authorId,
reaction.channelId,
reaction.messageId
);
return {
address,
// TODO: for now using timestamp of the message,
// as reactions don't have timestamps.
timestampMs: message.timestampMs,
src: reactionAddress(reaction),
dst: messageAddress(message),
};
}
function mentionsEdge(message: Model.Message, member: Model.GuildMember): Edge {
const address: EdgeAddressT = EdgeAddress.append(
mentionsEdgeType.prefix,
message.channelId,
message.authorId,
message.id,
member.user.bot ? "bot" : "user",
member.user.id
);
return {
address,
timestampMs: message.timestampMs,
src: messageAddress(message),
dst: memberAddress(member),
};
}
export type EmojiWeightMap = {[ref: Model.EmojiRef]: NodeWeight};
export function createGraph(
guild: Model.Snowflake,
repo: SqliteMirrorRepository,
declarationWeights: Weights,
emojiWeights: EmojiWeightMap
): WeightedGraphT {
const wg = {
graph: new Graph(),
weights: declarationWeights,
};
const memberMap = new Map(repo.members().map((m) => [m.user.id, m]));
const channels = repo.channels();
for (const channel of channels) {
const messages = repo.messages(channel.id);
for (const message of messages) {
const hasMentions = message.mentions.length > 0;
if (!hasMentions && message.reactionEmoji.length === 0) continue;
if (message.nonUserAuthor) continue;
let hasWeightedEmoji = false;
const reactions = repo.reactions(channel.id, message.id);
for (const reaction of reactions) {
const emojiRef = Model.emojiToRef(reaction.emoji);
const reactionWeight = emojiWeights[emojiRef];
// TODO: Skip all unweighted emoji in prototype.
if (!reactionWeight) continue;
const reactingMember = memberMap.get(reaction.authorId);
if (!reactingMember) {
console.warn(
`Reacting member not loaded ${reaction.authorId} (reacted ${emojiRef}), maybe a Deleted User?\n` +
`${messageUrl(guild, channel.id, message.id)}`
);
continue;
}
hasWeightedEmoji = true;
const node = reactionNode(reaction, message.timestampMs, guild);
wg.weights.nodeWeights.set(node.address, reactionWeight);
wg.graph.addNode(node);
wg.graph.addNode(memberNode(reactingMember));
wg.graph.addEdge(reactsToEdge(reaction, message));
wg.graph.addEdge(
addsReactionEdge(reaction, reactingMember, message.timestampMs)
);
}
for (const userId of message.mentions) {
const mentionedMember = memberMap.get(userId);
if (!mentionedMember) {
console.warn(
`Mentioned member not loaded ${userId}, maybe a Deleted User?\n` +
`${messageUrl(guild, channel.id, message.id)}`
);
continue;
}
wg.graph.addNode(memberNode(mentionedMember));
wg.graph.addEdge(mentionsEdge(message, mentionedMember));
}
// Don't bloat the graph with no-weighted-reaction messages.
if (hasWeightedEmoji || hasMentions) {
const author = memberMap.get(message.authorId);
if (!author) {
console.warn(
`Message author not loaded ${message.authorId}, maybe a Deleted User?\n` +
`${messageUrl(guild, channel.id, message.id)}`
);
continue;
}
wg.graph.addNode(memberNode(author));
wg.graph.addNode(messageNode(message, guild, channel.name));
wg.graph.addEdge(authorsMessageEdge(message, author));
}
}
}
return wg;
}

View File

@ -0,0 +1,79 @@
// @flow
import deepFreeze from "deep-freeze";
import type {PluginDeclaration} from "../../analysis/pluginDeclaration";
import type {NodeType, EdgeType} from "../../analysis/types";
import {NodeAddress, EdgeAddress} from "../../core/graph";
export const nodePrefix = NodeAddress.fromParts(["sourcecred", "discord"]);
export const edgePrefix = EdgeAddress.fromParts(["sourcecred", "discord"]);
export const memberNodeType: NodeType = deepFreeze({
name: "Member",
pluralName: "Members",
prefix: NodeAddress.append(nodePrefix, "MEMBER"),
defaultWeight: 0,
description: "A member of the Discord server",
});
export const messageNodeType: NodeType = deepFreeze({
name: "Message",
pluralName: "Messages",
prefix: NodeAddress.append(nodePrefix, "MESSAGE"),
defaultWeight: 0,
description: "A Discord message, posted in a particular channel",
});
export const reactionNodeType: NodeType = deepFreeze({
name: "Reaction",
pluralName: "Reactions",
prefix: NodeAddress.append(nodePrefix, "REACTION"),
defaultWeight: 1,
description: "A reaction by some user, directed at some message",
});
export const authorsMessageEdgeType: EdgeType = deepFreeze({
forwardName: "authors message",
backwardName: "message is authored by",
prefix: EdgeAddress.append(edgePrefix, "AUTHORS", "MESSAGE"),
defaultWeight: {forwards: 1 / 4, backwards: 1},
description: "Connects an author to a message they've created.",
});
export const addsReactionEdgeType: EdgeType = deepFreeze({
forwardName: "adds reaction",
backwardName: "reaction added by",
prefix: EdgeAddress.append(edgePrefix, "ADDS_REACTION"),
defaultWeight: {forwards: 1, backwards: 1 / 16},
description: "Connects a member to a reaction that they added.",
});
export const reactsToEdgeType: EdgeType = deepFreeze({
forwardName: "reacts to",
backwardName: "is reacted to by",
prefix: EdgeAddress.append(edgePrefix, "REACTS_TO"),
defaultWeight: {forwards: 1, backwards: 1 / 16},
description: "Connects a reaction to a message that it reacts to.",
});
export const mentionsEdgeType: EdgeType = deepFreeze({
forwardName: "mentions",
backwardName: "is mentioned by",
prefix: EdgeAddress.append(edgePrefix, "MENTIONS"),
defaultWeight: {forwards: 1, backwards: 1 / 16},
description: "Connects a message to the member being mentioned.",
});
export const declaration: PluginDeclaration = deepFreeze({
name: "Discord",
nodePrefix,
edgePrefix,
nodeTypes: [memberNodeType, messageNodeType, reactionNodeType],
edgeTypes: [
authorsMessageEdgeType,
addsReactionEdgeType,
reactsToEdgeType,
mentionsEdgeType,
],
userTypes: [memberNodeType],
});

View File

@ -0,0 +1,189 @@
// @flow
import fetch from "isomorphic-fetch";
import * as Model from "./models";
export interface DiscordApi {
guilds(): Promise<$ReadOnlyArray<Model.Guild>>;
emojis(guild: Model.Snowflake): Promise<$ReadOnlyArray<Model.Emoji>>;
channels(guild: Model.Snowflake): Promise<$ReadOnlyArray<Model.Channel>>;
roles(guild: Model.Snowflake): Promise<$ReadOnlyArray<Model.Role>>;
members(guild: Model.Snowflake): Promise<$ReadOnlyArray<Model.GuildMember>>;
messages(
channel: Model.Snowflake,
after: Model.Snowflake,
limit: number
): Promise<$ReadOnlyArray<Model.Message>>;
reactions(
channel: Model.Snowflake,
message: Model.Snowflake,
emoji: Model.Emoji
): Promise<$ReadOnlyArray<Model.Reaction>>;
}
const fetcherDefaults: FetcherOptions = {
apiUrl: "https://discordapp.com/api",
token: null,
fetch,
};
type FetcherOptions = {|
+apiUrl: string,
+fetch: typeof fetch,
+token: ?Model.BotToken,
|};
export class Fetcher implements DiscordApi {
+_options: FetcherOptions;
constructor(opts?: $Shape<FetcherOptions>) {
this._options = {...fetcherDefaults, ...opts};
if (!this._options.token) {
throw new Error("A BotToken is required");
}
}
_fetch(endpoint: string): Promise<Response> {
const {apiUrl, token} = this._options;
if (!token) {
throw new Error("A BotToken is required");
}
const requestOptions = {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bot ${token}`,
},
};
const url = new URL(`${apiUrl}${endpoint}`).href;
return this._options.fetch(url, requestOptions);
}
async _fetchJson(endpoint: string): Promise<any> {
const res = await this._fetch(endpoint);
failIfMissing(res);
failForNotOk(res);
return await res.json();
}
async guilds(): Promise<$ReadOnlyArray<Model.Guild>> {
const guilds = await this._fetchJson("/users/@me/guilds");
return guilds.map((x) => ({
id: x.id,
name: x.name,
permissions: x.permissions,
}));
}
async emojis(guild: Model.Snowflake): Promise<$ReadOnlyArray<Model.Emoji>> {
const emojis = await this._fetchJson(`/guilds/${guild}/emojis`);
return emojis.map((x) => ({
id: x.id,
name: x.name,
}));
}
async channels(
guild: Model.Snowflake
): Promise<$ReadOnlyArray<Model.Channel>> {
const channels = await this._fetchJson(`/guilds/${guild}/channels`);
return channels.map((x) => ({
id: x.id,
name: x.name,
type: Model.channelTypeFromId(x.type),
}));
}
async roles(guild: Model.Snowflake): Promise<$ReadOnlyArray<Model.Role>> {
const roles = await this._fetchJson(`/guilds/${guild}/roles`);
return roles.map((x) => ({
id: x.id,
name: x.name,
}));
}
async members(
guild: Model.Snowflake
): Promise<$ReadOnlyArray<Model.GuildMember>> {
// TODO: hack, should have pagination.
const members = await this._fetchJson(
`/guilds/${guild}/members?limit=1000`
);
if (members.length === 1000) {
throw new Error(
"TODO: getting 1000 members, needs to implement pagination"
);
}
return members.map((x) => ({
user: {
id: x.user.id,
username: x.user.username,
discriminator: x.user.discriminator,
bot: x.user.bot || x.user.system || false,
},
nick: x.nick || null,
roles: x.roles,
}));
}
async messages(
channel: Model.Snowflake,
after: Model.Snowflake,
limit: number
): Promise<$ReadOnlyArray<Model.Message>> {
const messages = await this._fetchJson(
`/channels/${channel}/messages?after=${after}&limit=${limit}`
);
return messages.map((x) => ({
id: x.id,
channelId: channel,
authorId: x.author.id,
timestampMs: Date.parse(x.timestamp),
content: x.content,
reactionEmoji: (x.reactions || []).map((r) => r.emoji),
nonUserAuthor: x.webhook_id != null || false,
mentions: (x.mentions || []).map((user) => user.id),
}));
}
async reactions(
channel: Model.Snowflake,
message: Model.Snowflake,
emoji: Model.Emoji
): Promise<$ReadOnlyArray<Model.Reaction>> {
// TODO: implement pagination.
const after = "0";
const limit = 100;
const emojiRef = Model.emojiToRef(emoji);
const reactingUsers = await this._fetchJson(
`/channels/${channel}/messages/${message}/reactions/${emojiRef}?after=${after}&limit=${limit}`
);
if (reactingUsers.length === 100) {
throw new Error("TODO: implement reactions pagination");
}
return reactingUsers.map((x) => ({
channelId: channel,
messageId: message,
emoji,
authorId: x.id,
}));
}
}
function failIfMissing(response: Response) {
if (response.status === 404) {
throw new Error(`404 Not Found on: ${response.url}; maybe bad serverUrl?`);
}
if (response.status === 403) {
throw new Error(`403 Forbidden: bad API username or key?\n${response.url}`);
}
if (response.status === 410) {
throw new Error(`410 Gone`);
}
}
function failForNotOk(response: Response) {
if (!response.ok) {
throw new Error(`not OK status ${response.status} on ${response.url}`);
}
}

View File

@ -0,0 +1,41 @@
// @flow
import {snapshotFetcher} from "./mockSnapshotFetcher";
describe("plugins/experimental-discord/fetcher", () => {
describe("snapshot testing", () => {
const guildId = "678348980639498428";
const channelId = "678394406507905129";
const messageId = "678394436757094410";
const emoji = {id: "678399364418502669", name: "sourcecred"};
it("loads guilds", async () => {
expect(await snapshotFetcher().guilds()).toMatchSnapshot();
});
it("loads channels", async () => {
expect(await snapshotFetcher().channels(guildId)).toMatchSnapshot();
});
it("loads emojis", async () => {
expect(await snapshotFetcher().emojis(guildId)).toMatchSnapshot();
});
it("loads roles", async () => {
expect(await snapshotFetcher().roles(guildId)).toMatchSnapshot();
});
it("loads members", async () => {
expect(await snapshotFetcher().members(guildId)).toMatchSnapshot();
});
it("loads messages", async () => {
expect(
await snapshotFetcher().messages(channelId, "0", 10)
).toMatchSnapshot();
expect(
await snapshotFetcher().messages(channelId, "678394455849566208", 10)
).toMatchSnapshot();
});
it("loads reactions", async () => {
expect(
await snapshotFetcher().reactions(channelId, messageId, emoji)
).toMatchSnapshot();
});
});
});

View File

@ -0,0 +1,56 @@
// @flow
import {type PluginDeclaration} from "../../analysis/pluginDeclaration";
import {type WeightedGraph} from "../../core/weightedGraph";
import {TaskReporter} from "../../util/taskReporter";
import {type CacheProvider} from "../../backend/cache";
import {SqliteMirrorRepository} from "./mirrorRepository";
import {weightsForDeclaration} from "../../analysis/pluginDeclaration";
import {createGraph as _createGraph} from "./createGraph";
import {type ProjectOptions} from "./params";
import {declaration} from "./declaration";
import * as Model from "./models";
import {Fetcher} from "./fetcher";
import {Mirror} from "./mirror";
export interface Loader {
declaration(): PluginDeclaration;
updateMirror: typeof updateMirror;
createGraph: typeof createGraph;
}
export default ({
declaration: () => declaration,
updateMirror,
createGraph,
}: Loader);
export async function updateMirror(
{guildId}: ProjectOptions,
token: Model.BotToken,
cache: CacheProvider,
reporter: TaskReporter
): Promise<void> {
const repo = await repository(cache, guildId);
const fetcher = new Fetcher({token});
const mirror = new Mirror(repo, fetcher, guildId);
await mirror.update(reporter);
}
export async function createGraph(
{guildId, reactionWeights}: ProjectOptions,
cache: CacheProvider
): Promise<WeightedGraph> {
const repo = await repository(cache, guildId);
const declarationWeights = weightsForDeclaration(declaration);
return await _createGraph(guildId, repo, declarationWeights, reactionWeights);
}
async function repository(
cache: CacheProvider,
guild: Model.Snowflake
): Promise<SqliteMirrorRepository> {
// TODO: should replace base64url with hex, to be case insensitive.
const db = await cache.database(`discord_${guild}`);
return new SqliteMirrorRepository(db, guild);
}

View File

@ -0,0 +1,98 @@
// @flow
import {TaskReporter} from "../../util/taskReporter";
import {type DiscordApi} from "./fetcher";
import {SqliteMirrorRepository} from "./mirrorRepository";
import * as Model from "./models";
// How many messages for each channel to reload.
const RELOAD_DEPTH = 50;
export class Mirror {
+_repo: SqliteMirrorRepository;
+_api: DiscordApi;
+guild: Model.Snowflake;
constructor(
repo: SqliteMirrorRepository,
api: DiscordApi,
guild: Model.Snowflake
) {
this._repo = repo;
this._api = api;
this.guild = guild;
}
async update(reporter: TaskReporter) {
const guild = await this.validateGuildId();
reporter.start(`discord/${guild.name}`);
await this.addMembers();
const channels = await this.addTextChannels();
for (const channel of channels) {
reporter.start(`discord/${guild.name}/#${channel.name}`);
try {
await this.addMessages(channel.id);
} catch (e) {
console.warn(e);
}
reporter.finish(`discord/${guild.name}/#${channel.name}`);
}
reporter.finish(`discord/${guild.name}`);
}
async validateGuildId() {
const guilds = await this._api.guilds();
const guild = guilds.find((g) => g.id === this.guild);
if (!guild) {
throw new Error(
`Couldn't find guild with ID ${this.guild}\nMaybe the bot has no access to it?`
);
}
// TODO: validate bot permissions
return guild;
}
async addMembers() {
const members = await this._api.members(this.guild);
for (const member of members) {
this._repo.addMember(member);
}
return this._repo.members();
}
async addTextChannels() {
const channels = await this._api.channels(this.guild);
for (const channel of channels) {
if (channel.type !== "GUILD_TEXT") continue;
this._repo.addChannel(channel);
}
return this._repo.channels();
}
async addMessages(channel: Model.Snowflake, messageLimit?: number) {
const loadStart = this._repo.nthMessageFromTail(channel, RELOAD_DEPTH);
// console.log(channel, (loadStart || {}).id);
const limit = messageLimit || 100;
let page: $ReadOnlyArray<Model.Message> = [];
let after: Model.Snowflake = loadStart ? loadStart.id : "0";
do {
page = await this._api.messages(channel, after, limit);
for (const message of page) {
after = after < message.id ? message.id : after;
this._repo.addMessage(message);
for (const emoji of message.reactionEmoji) {
const reactions = await this._api.reactions(
channel,
message.id,
emoji
);
for (const reaction of reactions) {
this._repo.addReaction(reaction);
}
}
}
} while (page.length >= limit);
return this._repo.messages(channel);
}
}

View File

@ -0,0 +1,28 @@
// @flow
import Database from "better-sqlite3";
import {snapshotFetcher} from "./mockSnapshotFetcher";
import {SqliteMirrorRepository} from "./mirrorRepository";
import {Mirror} from "./mirror";
describe("plugins/experimental-discord/mirror", () => {
describe("smoke test", () => {
const guildId = "678348980639498428";
// const channelId = "678394406507905129";
it("should print", async () => {
// Given
const repo = new SqliteMirrorRepository(
new Database(":memory:"),
guildId
);
const api = snapshotFetcher();
// When
const mirror = new Mirror(repo, api, guildId);
await mirror.addMembers();
await mirror.addTextChannels();
// await mirror.addMessages(channelId, 10);
});
});
});

View File

@ -0,0 +1,463 @@
// @flow
import type Database from "better-sqlite3";
import stringify from "json-stable-stringify";
import * as Model from "./models";
import dedent from "../../util/dedent";
const VERSION = "discord_mirror_v1";
export class SqliteMirrorRepository {
+_db: Database;
constructor(db: Database, guild: Model.Snowflake) {
if (db == null) throw new Error("db: " + String(db));
this._db = db;
this._transaction(() => {
this._initialize(guild);
});
}
_transaction(queries: () => void) {
const db = this._db;
if (db.inTransaction) {
throw new Error("already in transaction");
}
try {
db.prepare("BEGIN").run();
queries();
if (db.inTransaction) {
db.prepare("COMMIT").run();
}
} finally {
if (db.inTransaction) {
db.prepare("ROLLBACK").run();
}
}
}
_initialize(guild: Model.Snowflake) {
const db = this._db;
// We store the config in a singleton table `meta`, whose unique row
// has primary key `0`. Only the first ever insert will succeed; we
// are locked into the first config.
db.prepare(
dedent`\
CREATE TABLE IF NOT EXISTS meta (
zero INTEGER PRIMARY KEY,
config TEXT NOT NULL
)
`
).run();
const config = stringify({
version: VERSION,
guild,
});
const existingConfig: string | void = db
.prepare("SELECT config FROM meta")
.pluck()
.get();
if (existingConfig === config) {
// Already set up; nothing to do.
return;
} else if (existingConfig !== undefined) {
throw new Error(
"Database already populated with incompatible server or version"
);
}
db.prepare("INSERT INTO meta (zero, config) VALUES (0, ?)").run(config);
const tables = [
dedent`\
CREATE TABLE channels (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL
)
`,
dedent`\
CREATE TABLE members (
user_id TEXT PRIMARY KEY,
username TEXT NOT NULL,
discriminator TEXT NOT NULL,
bot INTEGER NOT NULL,
nick TEXT
)
`,
dedent`\
CREATE TABLE member_roles (
user_id TEXT,
role TEXT NOT NULL,
CONSTRAINT user_role PRIMARY KEY (user_id, role)
)
`,
dedent`\
CREATE TABLE messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
author_id TEXT NOT NULL,
non_user_author INTEGER NOT NULL,
timestamp_ms INTEGER NOT NULL,
content TEXT NOT NULL
)
`,
dedent`\
CREATE TABLE message_reactions (
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL,
author_id TEXT NOT NULL,
emoji TEXT NOT NULL,
CONSTRAINT value_object PRIMARY KEY (channel_id, message_id, author_id, emoji)
)
`,
dedent`\
CREATE TABLE message_mentions (
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
CONSTRAINT value_object PRIMARY KEY (channel_id, message_id, user_id)
)
`,
];
for (const sql of tables) {
db.prepare(sql).run();
}
}
members(): $ReadOnlyArray<Model.GuildMember> {
return this._db
.prepare(
dedent`\
SELECT
user_id,
username,
discriminator,
bot,
nick
FROM members`
)
.all()
.map((x) => ({
user: {
id: x.user_id,
username: x.username,
discriminator: x.discriminator,
bot: x.bot === 1,
},
nick: x.nick,
roles: this.memberRoles(x.user_id),
}));
}
memberRoles(user: Model.Snowflake): $ReadOnlyArray<Model.Snowflake> {
return this._db
.prepare(
dedent`\
SELECT
user_id,
role
FROM member_roles
WHERE user_id = :user_id`
)
.all({user_id: user})
.map((x) => x.role);
}
channels(): $ReadOnlyArray<Model.Channel> {
return this._db
.prepare(
dedent`\
SELECT
id,
type,
name
FROM channels`
)
.all();
}
messages(channel: Model.Snowflake): $ReadOnlyArray<Model.Message> {
return this._db
.prepare(
dedent`\
SELECT
id,
channel_id,
author_id,
non_user_author,
timestamp_ms,
content
FROM messages
WHERE channel_id = :channel_id`
)
.all({channel_id: channel})
.map((m) => ({
id: m.id,
channelId: m.channel_id,
authorId: m.author_id,
nonUserAuthor: m.non_user_author === 1,
timestampMs: m.timestamp_ms,
content: m.content,
reactionEmoji: this.reactionEmoji(m.channel_id, m.id),
mentions: this.mentions(m.channel_id, m.id),
}));
}
nthMessageFromTail(channel: Model.Snowflake, n: number): ?Model.Message {
const count = this._db
.prepare(
dedent`\
SELECT count(*) as count
FROM messages
WHERE channel_id = :channel_id`
)
.get({channel_id: channel}).count;
if (count < n) return null;
const offset = count - n;
const m = this._db
.prepare(
dedent`\
SELECT
id,
channel_id,
author_id,
non_user_author,
timestamp_ms,
content
FROM messages
WHERE channel_id = :channel_id
ORDER BY timestamp_ms
LIMIT 1
OFFSET :offset
`
)
.get({channel_id: channel, offset});
return {
id: m.id,
channelId: m.channel_id,
authorId: m.author_id,
nonUserAuthor: m.non_user_author === 1,
timestampMs: m.timestamp_ms,
content: m.content,
reactionEmoji: this.reactionEmoji(m.channel_id, m.id),
mentions: this.mentions(m.channel_id, m.id),
};
}
mentions(
channel: Model.Snowflake,
message: Model.Snowflake
): $ReadOnlyArray<Model.Snowflake> {
return this._db
.prepare(
dedent`\
SELECT user_id
FROM message_mentions
WHERE channel_id = :channel_id
AND message_id = :message_id`
)
.all({channel_id: channel, message_id: message})
.map((res) => res.user_id);
}
reactionEmoji(
channel: Model.Snowflake,
message: Model.Snowflake
): $ReadOnlyArray<Model.Emoji> {
return this._db
.prepare(
dedent`\
SELECT DISTINCT
emoji
FROM message_reactions
WHERE channel_id = :channel_id
AND message_id = :message_id`
)
.all({channel_id: channel, message_id: message})
.map((e) => Model.refToEmoji(e.emoji));
}
reactions(
channel: Model.Snowflake,
message: Model.Snowflake
): $ReadOnlyArray<Model.Reaction> {
return this._db
.prepare(
dedent`\
SELECT
channel_id,
message_id,
author_id,
emoji
FROM message_reactions
WHERE channel_id = :channel_id
AND message_id = :message_id`
)
.all({channel_id: channel, message_id: message})
.map((r) => ({
channelId: r.channel_id,
messageId: r.message_id,
authorId: r.author_id,
emoji: Model.refToEmoji(r.emoji),
}));
}
addChannel(channel: Model.Channel) {
this._db
.prepare(
dedent`\
INSERT OR IGNORE INTO channels (
id,
type,
name
) VALUES (
:id,
:type,
:name
)
`
)
.run({
id: channel.id,
type: channel.type,
name: channel.name,
});
}
addMessage(message: Model.Message) {
this._db
.prepare(
dedent`\
INSERT OR IGNORE INTO messages (
id,
channel_id,
author_id,
non_user_author,
timestamp_ms,
content
) VALUES (
:id,
:channel_id,
:author_id,
:non_user_author,
:timestamp_ms,
:content
)
`
)
.run({
id: message.id,
channel_id: message.channelId,
author_id: message.authorId,
non_user_author: Number(message.nonUserAuthor),
timestamp_ms: message.timestampMs,
content: message.content,
});
for (const user of message.mentions) {
this.addMention(message, user);
}
}
addReaction(reaction: Model.Reaction) {
this._db
.prepare(
dedent`\
INSERT OR IGNORE INTO message_reactions (
channel_id,
message_id,
author_id,
emoji
) VALUES (
:channel_id,
:message_id,
:author_id,
:emoji
)
`
)
.run({
channel_id: reaction.channelId,
message_id: reaction.messageId,
author_id: reaction.authorId,
emoji: Model.emojiToRef(reaction.emoji),
});
}
addMention(message: Model.Message, user: Model.Snowflake) {
this._db
.prepare(
dedent`\
INSERT OR IGNORE INTO message_mentions (
channel_id,
message_id,
user_id
) VALUES (
:channel_id,
:message_id,
:user_id
)
`
)
.run({
channel_id: message.channelId,
message_id: message.id,
user_id: user,
});
}
addMember(member: Model.GuildMember) {
this._db
.prepare(
dedent`\
INSERT OR IGNORE INTO members (
user_id,
username,
discriminator,
bot,
nick
) VALUES (
:user_id,
:username,
:discriminator,
:bot,
:nick
)
`
)
.run({
user_id: member.user.id,
username: member.user.username,
discriminator: member.user.discriminator,
bot: Number(member.user.bot),
nick: member.nick,
});
for (const role of member.roles) {
this.addMemberRole(member.user.id, role);
}
}
addMemberRole(user: Model.Snowflake, role: Model.Snowflake) {
this._db
.prepare(
dedent`\
INSERT OR IGNORE INTO member_roles (
user_id,
role
) VALUES (
:user_id,
:role
)
`
)
.run({
user_id: user,
role,
});
}
}

View File

@ -0,0 +1,24 @@
// @flow
import base64url from "base64url";
import path from "path";
import fs from "fs-extra";
import {Fetcher} from "./fetcher";
async function snapshotFetch(url: string | Request | URL): Promise<Response> {
const snapshotDir = "src/plugins/experimental-discord/snapshots";
const filename = base64url(url);
const file = path.join(snapshotDir, filename);
if (await fs.exists(file)) {
const contents = await fs.readFile(file);
return new Response(contents, {status: 200});
} else {
throw new Error(`couldn't load snapshot for ${file} (${String(url)})`);
}
}
export const snapshotFetcher = () =>
new Fetcher({
fetch: snapshotFetch,
token: "mock-token",
});

View File

@ -0,0 +1,112 @@
// @flow
// https://discordapp.com/developers/docs/reference#snowflakes
export type Snowflake = string;
export const ZeroSnowflake: Snowflake = "0";
export type Guild = {|
+id: Snowflake,
+name: string,
+permissions: number,
|};
export type BotToken = string;
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
export type ChannelType =
| "GUILD_TEXT"
| "DM"
| "GUILD_VOICE"
| "GROUP_DM"
| "GUILD_CATEGORY"
| "GUILD_NEWS"
| "GUILD_STORE";
export function channelTypeFromId(id: number): ChannelType {
switch (id) {
case 0:
return "GUILD_TEXT";
case 1:
return "DM";
case 2:
return "GUILD_VOICE";
case 3:
return "GROUP_DM";
case 4:
return "GUILD_CATEGORY";
case 5:
return "GUILD_NEWS";
case 6:
return "GUILD_STORE";
default: {
throw new Error(`Unknown channel type ID: ${id}`);
}
}
}
export type Channel = {|
+id: Snowflake,
+type: ChannelType,
+name: string,
|};
export type Role = {|
+id: Snowflake,
+name: string,
|};
export type User = {|
+id: Snowflake,
+username: string,
+discriminator: string,
+bot: boolean,
|};
export type GuildMember = {|
+user: User,
+nick: string | null,
+roles: $ReadOnlyArray<Snowflake>,
|};
export type Emoji = {|
+id: ?Snowflake,
+name: string,
|};
export type EmojiRef = string;
export function emojiToRef({id, name}: Emoji): EmojiRef {
// Built-in emoji, unicode names.
if (!id) return name;
// Custom emoji.
return `${name}:${id}`;
}
export function refToEmoji(ref: EmojiRef): Emoji {
const [name, id] = ref.split(":");
if (!id) return {id: null, name};
return {id, name};
}
export type Message = {|
+id: Snowflake,
+channelId: Snowflake,
+authorId: Snowflake,
// Could be a message from a webhook, meaning the authorId isn't a user.
+nonUserAuthor: boolean,
+timestampMs: number,
+content: string,
// Normally includes reaction counters, but we don't care about counters.
// We could filter based on which types of emoji have been added though.
+reactionEmoji: $ReadOnlyArray<Emoji>,
// Snowflake of user IDs.
+mentions: $ReadOnlyArray<Snowflake>,
|};
export type Reaction = {|
+channelId: Snowflake,
+messageId: Snowflake,
+authorId: Snowflake,
+emoji: Emoji,
|};

View File

@ -0,0 +1,11 @@
// @flow
import * as Model from "./models";
import {type EmojiWeightMap} from "./createGraph";
export type {BotToken as DiscordToken} from "./models";
export type ProjectOptions = {|
+guildId: Model.Snowflake,
+reactionWeights: EmojiWeightMap,
|};

View File

@ -0,0 +1,16 @@
[
{
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
{
"id": "439050857921904640",
"username": "Brian Litwin",
"avatar": "1cc439c3506ede04a77be8105d934df4",
"discriminator": "2386",
"public_flags": 0
}
]

View File

@ -0,0 +1,224 @@
[
{
"id": "678697350385893404",
"type": 0,
"content": "@here could each of you add one message to <#678696874869522446>? Content doesn't matter :]",
"channel_id": "678348980849213472",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": true,
"tts": false,
"timestamp": "2020-02-16T20:20:57.919000+00:00",
"edited_timestamp": null,
"flags": 0,
"reactions": [
{
"emoji": {
"id": "678399364418502669",
"name": "sourcecred"
},
"count": 1,
"me": false
},
{
"emoji": {
"id": null,
"name": "👍"
},
"count": 1,
"me": false
},
{
"emoji": {
"id": null,
"name": "🐙"
},
"count": 1,
"me": false
},
{
"emoji": {
"id": null,
"name": "🌱"
},
"count": 1,
"me": false
},
{
"emoji": {
"id": null,
"name": "🔥"
},
"count": 1,
"me": false
},
{
"emoji": {
"id": null,
"name": "❤️"
},
"count": 1,
"me": false
},
{
"emoji": {
"id": null,
"name": "💯"
},
"count": 1,
"me": false
}
]
},
{
"id": "678401508219420682",
"type": 7,
"content": "",
"channel_id": "678348980849213472",
"author": {
"id": "439050857921904640",
"username": "Brian Litwin",
"avatar": "1cc439c3506ede04a77be8105d934df4",
"discriminator": "2386"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:45:23.650000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678370372390748199",
"type": 0,
"content": "Made <@!420341518948237331> and <@!432981598858903585> admins 😄",
"channel_id": "678348980849213472",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [
{
"id": "432981598858903585",
"username": "wchargin",
"avatar": "012988df3eab485878fd588696097802",
"discriminator": "8658"
},
{
"id": "420341518948237331",
"username": "decentralion",
"avatar": "d54a9b690fd2e47abd4268c55cc25b8f",
"discriminator": "8636"
}
],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-15T22:41:40.290000+00:00",
"edited_timestamp": "2020-02-16T00:06:41.114000+00:00",
"flags": 0,
"reactions": [
{
"emoji": {
"id": null,
"name": "👀"
},
"count": 1,
"me": false
},
{
"emoji": {
"id": null,
"name": "👍"
},
"count": 1,
"me": false
}
]
},
{
"id": "678367818013737031",
"type": 7,
"content": "",
"channel_id": "678348980849213472",
"author": {
"id": "420341518948237331",
"username": "decentralion",
"avatar": "d54a9b690fd2e47abd4268c55cc25b8f",
"discriminator": "8636"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-15T22:31:31.279000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678365403600257034",
"type": 7,
"content": "",
"channel_id": "678348980849213472",
"author": {
"id": "432981598858903585",
"username": "wchargin",
"avatar": "012988df3eab485878fd588696097802",
"discriminator": "8658"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-15T22:21:55.638000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678359229685563412",
"type": 7,
"content": "",
"channel_id": "678348980849213472",
"author": {
"id": "678351352770068560",
"username": "CredBot-Beanow",
"avatar": "04fdfc8af8cca3c0b3155a946dccc435",
"discriminator": "1705",
"bot": true
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-15T21:57:23.662000+00:00",
"edited_timestamp": null,
"flags": 0
}
]

View File

@ -0,0 +1,151 @@
[
{
"id": "678401508219420682",
"type": 7,
"content": "",
"channel_id": "678348980849213472",
"author": {
"id": "439050857921904640",
"username": "Brian Litwin",
"avatar": "1cc439c3506ede04a77be8105d934df4",
"discriminator": "2386",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:45:23.650000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678370372390748199",
"type": 0,
"content": "Made <@!420341518948237331> and <@!432981598858903585> admins 😄",
"channel_id": "678348980849213472",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [
{
"id": "432981598858903585",
"username": "wchargin",
"avatar": "012988df3eab485878fd588696097802",
"discriminator": "8658",
"public_flags": 0
},
{
"id": "420341518948237331",
"username": "decentralion",
"avatar": "a70b70bc0471e6a1efe5124c39c76020",
"discriminator": "8636",
"public_flags": 0
}
],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-15T22:41:40.290000+00:00",
"edited_timestamp": "2020-02-16T00:06:41.114000+00:00",
"flags": 0,
"reactions": [
{
"emoji": {
"id": null,
"name": "👀"
},
"count": 1,
"me": false
},
{
"emoji": {
"id": null,
"name": "👍"
},
"count": 1,
"me": false
}
]
},
{
"id": "678367818013737031",
"type": 7,
"content": "",
"channel_id": "678348980849213472",
"author": {
"id": "420341518948237331",
"username": "decentralion",
"avatar": "a70b70bc0471e6a1efe5124c39c76020",
"discriminator": "8636",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-15T22:31:31.279000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678365403600257034",
"type": 7,
"content": "",
"channel_id": "678348980849213472",
"author": {
"id": "432981598858903585",
"username": "wchargin",
"avatar": "012988df3eab485878fd588696097802",
"discriminator": "8658",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-15T22:21:55.638000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678359229685563412",
"type": 7,
"content": "",
"channel_id": "678348980849213472",
"author": {
"id": "678351352770068560",
"username": "CredBot-Beanow",
"avatar": "6f5b83041061a114c9f0fb26b863622c",
"discriminator": "1705",
"public_flags": 0,
"bot": true
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-15T21:57:23.662000+00:00",
"edited_timestamp": null,
"flags": 0
}
]

View File

@ -0,0 +1,8 @@
[
{
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
}
]

View File

@ -0,0 +1,242 @@
[
{
"id": "678394455849566208",
"type": 0,
"content": "10",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:22.234000+00:00",
"edited_timestamp": "2020-02-16T00:17:38.520000+00:00",
"flags": 0
},
{
"id": "678394451462193154",
"type": 0,
"content": "9",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:21.188000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394448497082388",
"type": 0,
"content": "8",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:20.481000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394445351092275",
"type": 0,
"content": "7",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:19.731000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394442301833247",
"type": 0,
"content": "6",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:19.004000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394436757094410",
"type": 0,
"content": "5",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:17.682000+00:00",
"edited_timestamp": null,
"flags": 0,
"reactions": [
{
"emoji": {
"id": "678399364418502669",
"name": "sourcecred"
},
"count": 1,
"me": false
}
]
},
{
"id": "678394433233747978",
"type": 0,
"content": "4",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:16.842000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394431149178940",
"type": 0,
"content": "3",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:16.345000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394428569813013",
"type": 0,
"content": "2",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:15.730000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394426153893948",
"type": 0,
"content": "1",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:15.154000+00:00",
"edited_timestamp": null,
"flags": 0,
"reactions": [
{
"emoji": {
"id": null,
"name": "👍"
},
"count": 1,
"me": false
}
]
}
]

View File

@ -0,0 +1,137 @@
[
{
"id": "678394436757094410",
"type": 0,
"content": "5",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:17.682000+00:00",
"edited_timestamp": null,
"flags": 0,
"reactions": [
{
"emoji": {
"id": "678399364418502669",
"name": "sourcecred"
},
"count": 1,
"me": false
}
]
},
{
"id": "678394433233747978",
"type": 0,
"content": "4",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:16.842000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394431149178940",
"type": 0,
"content": "3",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:16.345000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394428569813013",
"type": 0,
"content": "2",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:15.730000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394426153893948",
"type": 0,
"content": "1",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:15.154000+00:00",
"edited_timestamp": null,
"flags": 0,
"reactions": [
{
"emoji": {
"id": null,
"name": "👍"
},
"count": 1,
"me": false
}
]
}
]

View File

@ -0,0 +1,127 @@
[
{
"id": "678394480184918016",
"type": 0,
"content": "15",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:28.036000+00:00",
"edited_timestamp": null,
"flags": 0,
"reactions": [
{
"emoji": {
"id": null,
"name": "😆"
},
"count": 1,
"me": false
}
]
},
{
"id": "678394477106167818",
"type": 0,
"content": "14",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:27.302000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394473428025354",
"type": 0,
"content": "13",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:26.425000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394469048909841",
"type": 0,
"content": "12",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:25.381000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394468373626890",
"type": 0,
"content": "11",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:25.220000+00:00",
"edited_timestamp": null,
"flags": 0
}
]

View File

@ -0,0 +1,232 @@
[
{
"id": "678394498098659349",
"type": 0,
"content": "20",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:32.307000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394493124476939",
"type": 0,
"content": "19",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:31.121000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394489391415348",
"type": 0,
"content": "18",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:30.231000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394486644146187",
"type": 0,
"content": "17",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:29.576000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394484291141700",
"type": 0,
"content": "16",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:29.015000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394480184918016",
"type": 0,
"content": "15",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:28.036000+00:00",
"edited_timestamp": null,
"flags": 0,
"reactions": [
{
"emoji": {
"id": null,
"name": "😆"
},
"count": 1,
"me": false
}
]
},
{
"id": "678394477106167818",
"type": 0,
"content": "14",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:27.302000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394473428025354",
"type": 0,
"content": "13",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:26.425000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394469048909841",
"type": 0,
"content": "12",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:25.381000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394468373626890",
"type": 0,
"content": "11",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:25.220000+00:00",
"edited_timestamp": null,
"flags": 0
}
]

View File

@ -0,0 +1,117 @@
[
{
"id": "678394455849566208",
"type": 0,
"content": "10",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:22.234000+00:00",
"edited_timestamp": "2020-02-16T00:17:38.520000+00:00",
"flags": 0
},
{
"id": "678394451462193154",
"type": 0,
"content": "9",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:21.188000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394448497082388",
"type": 0,
"content": "8",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:20.481000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394445351092275",
"type": 0,
"content": "7",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:19.731000+00:00",
"edited_timestamp": null,
"flags": 0
},
{
"id": "678394442301833247",
"type": 0,
"content": "6",
"channel_id": "678394406507905129",
"author": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"attachments": [],
"embeds": [],
"mentions": [],
"mention_roles": [],
"pinned": false,
"mention_everyone": false,
"tts": false,
"timestamp": "2020-02-16T00:17:19.004000+00:00",
"edited_timestamp": null,
"flags": 0
}
]

View File

@ -0,0 +1,87 @@
{
"id": "678348980639498428",
"name": "SourceCred Test Server",
"icon": null,
"description": null,
"splash": null,
"discovery_splash": null,
"features": [],
"emojis": [
{
"name": "sourcecred",
"roles": [],
"id": "678399364418502669",
"require_colons": true,
"managed": false,
"animated": false,
"available": true
}
],
"banner": null,
"owner_id": "143776454050709505",
"application_id": null,
"region": "us-south",
"afk_channel_id": null,
"afk_timeout": 300,
"system_channel_id": "678348980849213472",
"widget_enabled": false,
"widget_channel_id": null,
"verification_level": 0,
"roles": [
{
"id": "678348980639498428",
"name": "@everyone",
"permissions": 33620992,
"position": 0,
"color": 0,
"hoist": false,
"managed": false,
"mentionable": false
},
{
"id": "678349848684003359",
"name": "allow-adding-data",
"permissions": 104324673,
"position": 1,
"color": 2123412,
"hoist": false,
"managed": false,
"mentionable": false
},
{
"id": "678350026946117694",
"name": "admins",
"permissions": 104324681,
"position": 2,
"color": 15105570,
"hoist": false,
"managed": false,
"mentionable": false
},
{
"id": "678359229433905152",
"name": "CredBot-Beanow",
"permissions": 66560,
"position": 1,
"color": 0,
"hoist": false,
"managed": true,
"mentionable": false
}
],
"default_message_notifications": 1,
"mfa_level": 0,
"explicit_content_filter": 0,
"max_presences": null,
"max_members": 250000,
"max_video_channel_users": 25,
"vanity_url_code": null,
"premium_tier": 0,
"premium_subscription_count": 0,
"system_channel_flags": 0,
"preferred_locale": "en-US",
"rules_channel_id": null,
"public_updates_channel_id": null,
"embed_enabled": false,
"embed_channel_id": null
}

View File

@ -0,0 +1,73 @@
[
{
"id": "678348980756938813",
"type": 4,
"name": "Text Channels",
"position": 0,
"parent_id": null,
"guild_id": "678348980639498428",
"permission_overwrites": [],
"nsfw": false
},
{
"id": "678348980798881844",
"type": 4,
"name": "Voice Channels",
"position": 0,
"parent_id": null,
"guild_id": "678348980639498428",
"permission_overwrites": [],
"nsfw": false
},
{
"id": "678348980849213472",
"last_message_id": "678697350385893404",
"type": 0,
"name": "general",
"position": 0,
"parent_id": "678348980756938813",
"topic": null,
"guild_id": "678348980639498428",
"permission_overwrites": [],
"nsfw": false,
"rate_limit_per_user": 0
},
{
"id": "678348980878573661",
"type": 2,
"name": "General",
"position": 0,
"parent_id": "678348980798881844",
"bitrate": 64000,
"user_limit": 0,
"guild_id": "678348980639498428",
"permission_overwrites": [],
"nsfw": false
},
{
"id": "678394406507905129",
"last_message_id": "678394498098659349",
"type": 0,
"name": "pagination",
"position": 1,
"parent_id": "678348980756938813",
"topic": "A known length of messages to test pagination.",
"guild_id": "678348980639498428",
"permission_overwrites": [],
"nsfw": false,
"rate_limit_per_user": 0
},
{
"id": "678696874869522446",
"last_message_id": "678698977100824587",
"type": 0,
"name": "notmymessage",
"position": 2,
"parent_id": "678348980756938813",
"topic": "Reactions to messages which aren't our own.",
"guild_id": "678348980639498428",
"permission_overwrites": [],
"nsfw": false,
"rate_limit_per_user": 0
}
]

View File

@ -0,0 +1,11 @@
[
{
"name": "sourcecred",
"roles": [],
"id": "678399364418502669",
"require_colons": true,
"managed": false,
"animated": false,
"available": true
}
]

View File

@ -0,0 +1,92 @@
[
{
"user": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887",
"public_flags": 0
},
"roles": [
"678349848684003359",
"678350026946117694"
],
"nick": null,
"premium_since": null,
"mute": false,
"deaf": false,
"joined_at": "2020-02-15T21:16:40.215000+00:00"
},
{
"user": {
"id": "420341518948237331",
"username": "decentralion",
"avatar": "a70b70bc0471e6a1efe5124c39c76020",
"discriminator": "8636",
"public_flags": 0
},
"roles": [
"678349848684003359",
"678350026946117694"
],
"nick": null,
"premium_since": null,
"mute": false,
"deaf": false,
"joined_at": "2020-02-15T22:31:31.243000+00:00"
},
{
"user": {
"id": "432981598858903585",
"username": "wchargin",
"avatar": "012988df3eab485878fd588696097802",
"discriminator": "8658",
"public_flags": 0
},
"roles": [
"678349848684003359",
"678350026946117694"
],
"nick": null,
"premium_since": null,
"mute": false,
"deaf": false,
"joined_at": "2020-02-15T22:21:55.613000+00:00"
},
{
"user": {
"id": "439050857921904640",
"username": "Brian Litwin",
"avatar": "1cc439c3506ede04a77be8105d934df4",
"discriminator": "2386",
"public_flags": 0
},
"roles": [
"678349848684003359",
"678350026946117694"
],
"nick": null,
"premium_since": null,
"mute": false,
"deaf": false,
"joined_at": "2020-02-16T00:45:23.478000+00:00"
},
{
"user": {
"id": "678351352770068560",
"username": "CredBot-Beanow",
"avatar": "6f5b83041061a114c9f0fb26b863622c",
"discriminator": "1705",
"public_flags": 0,
"bot": true
},
"roles": [
"678359229433905152"
],
"nick": null,
"premium_since": null,
"mute": false,
"deaf": false,
"joined_at": "2020-02-15T21:57:23.609000+00:00"
}
]

View File

@ -0,0 +1,87 @@
[
{
"user": {
"id": "143776454050709505",
"username": "Beanow",
"avatar": "6496446fe1455f31f9536b132dcc4ac8",
"discriminator": "5887"
},
"roles": [
"678349848684003359",
"678350026946117694"
],
"nick": null,
"premium_since": null,
"mute": false,
"deaf": false,
"joined_at": "2020-02-15T21:16:40.215000+00:00"
},
{
"user": {
"id": "420341518948237331",
"username": "decentralion",
"avatar": "d54a9b690fd2e47abd4268c55cc25b8f",
"discriminator": "8636"
},
"roles": [
"678349848684003359",
"678350026946117694"
],
"nick": null,
"premium_since": null,
"mute": false,
"deaf": false,
"joined_at": "2020-02-15T22:31:31.243000+00:00"
},
{
"user": {
"id": "432981598858903585",
"username": "wchargin",
"avatar": "012988df3eab485878fd588696097802",
"discriminator": "8658"
},
"roles": [
"678349848684003359",
"678350026946117694"
],
"nick": null,
"premium_since": null,
"mute": false,
"deaf": false,
"joined_at": "2020-02-15T22:21:55.613000+00:00"
},
{
"user": {
"id": "439050857921904640",
"username": "Brian Litwin",
"avatar": "1cc439c3506ede04a77be8105d934df4",
"discriminator": "2386"
},
"roles": [
"678349848684003359",
"678350026946117694"
],
"nick": null,
"premium_since": null,
"mute": false,
"deaf": false,
"joined_at": "2020-02-16T00:45:23.478000+00:00"
},
{
"user": {
"id": "678351352770068560",
"username": "CredBot-Beanow",
"avatar": "04fdfc8af8cca3c0b3155a946dccc435",
"discriminator": "1705",
"bot": true
},
"roles": [
"678359229433905152"
],
"nick": null,
"premium_since": null,
"mute": false,
"deaf": false,
"joined_at": "2020-02-15T21:57:23.609000+00:00"
}
]

View File

@ -0,0 +1,42 @@
[
{
"id": "678348980639498428",
"name": "@everyone",
"permissions": 33620992,
"position": 0,
"color": 0,
"hoist": false,
"managed": false,
"mentionable": false
},
{
"id": "678349848684003359",
"name": "allow-adding-data",
"permissions": 104324673,
"position": 1,
"color": 2123412,
"hoist": false,
"managed": false,
"mentionable": false
},
{
"id": "678350026946117694",
"name": "admins",
"permissions": 104324681,
"position": 2,
"color": 15105570,
"hoist": false,
"managed": false,
"mentionable": false
},
{
"id": "678359229433905152",
"name": "CredBot-Beanow",
"permissions": 66560,
"position": 1,
"color": 0,
"hoist": false,
"managed": true,
"mentionable": false
}
]

View File

@ -0,0 +1,26 @@
[
{
"id": "453243919774253079",
"name": "sourcecred",
"icon": "934f3f7c5d6df1d014c55c1760090d7d",
"owner": false,
"permissions": 104193601,
"features": []
},
{
"id": "629411177947987986",
"name": "MetaGame",
"icon": "e3602d4e34004a5053e4d43d81cf8291",
"owner": false,
"permissions": 104324673,
"features": []
},
{
"id": "678348980639498428",
"name": "SourceCred Test Server",
"icon": null,
"owner": false,
"permissions": 33620992,
"features": []
}
]

View File

@ -0,0 +1,40 @@
#!/bin/bash
set -eu
snapshots_dir=src/plugins/experimental-discord/snapshots
test_instance_url="https://discordapp.com/api"
if [ ! "$(jq --version)" ]; then
printf >&2 'This script depends on jq. Please install it.\n'
exit 1
fi
if [ -z "${SOURCECRED_DISCORD_BOT_TOKEN:-}" ]; then
printf >&2 "Please set the SOURCECRED_DISCORD_TOKEN environment variable.\n"
exit 1
fi
toplevel="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
cd "${toplevel}"
fetch() {
url="${test_instance_url}$1"
filename="$(printf '%s' "${url}" | base64 -w 0 | tr -d '=' | tr '/+' '_-')"
path="${snapshots_dir}/${filename}"
curl -sfL "$url" \
-H "Accept: application/json" \
-H "Authorization: Bot ${SOURCECRED_DISCORD_BOT_TOKEN}" \
| jq '.' > "${path}"
}
rm -r "${snapshots_dir}"
mkdir "${snapshots_dir}"
fetch "/guilds/678348980639498428"
fetch "/guilds/678348980639498428/channels"
fetch "/guilds/678348980639498428/members?after=0&limit=5"
fetch "/channels/678348980849213472/messages?after=0&limit=5"
fetch "/channels/678394406507905129/messages?after=0&limit=5"
fetch "/channels/678394406507905129/messages?after=678394436757094410&limit=5"
fetch "/channels/678394406507905129/messages?after=678394455849566208&limit=5"
fetch "/channels/678348980849213472/messages/678697350385893404/reactions/sourcecred:678399364418502669?after=0&limit=5"