From f56fc15c721824af0073af0c0752e7f827a7a998 Mon Sep 17 00:00:00 2001 From: Superd22 Date: Thu, 10 Jan 2019 13:54:18 +0100 Subject: [PATCH] feat: add inital support for orgs - List membership (public or "admin_mode") - List applicants (admin_mode only) --- package-lock.json | 13 ++ package.json | 1 + spec/org.spec.ts | 19 ++ src/RSI/index.ts | 1 + .../interfaces/RSIApiResponse.interface.ts | 12 +- src/RSI/interfaces/omit.type.ts | 1 + src/RSI/orgs/entities/organisation.entity.ts | 177 ++++++++++++++++++ src/RSI/orgs/index.ts | 7 + .../api/get-org-members-params.interface.ts | 79 ++++++++ .../api/get-org-members.interface.ts | 23 +++ .../rsi/get-applicants-params.interface.ts | 4 + .../rsi/get-applicants.interface.ts | 9 + src/RSI/orgs/services/orgs.service.ts | 30 +++ src/RSI/services/rsi.service.ts | 49 +++-- 14 files changed, 404 insertions(+), 21 deletions(-) create mode 100644 spec/org.spec.ts create mode 100644 src/RSI/interfaces/omit.type.ts create mode 100644 src/RSI/orgs/entities/organisation.entity.ts create mode 100644 src/RSI/orgs/index.ts create mode 100644 src/RSI/orgs/interfaces/api/get-org-members-params.interface.ts create mode 100644 src/RSI/orgs/interfaces/api/get-org-members.interface.ts create mode 100644 src/RSI/orgs/interfaces/rsi/get-applicants-params.interface.ts create mode 100644 src/RSI/orgs/interfaces/rsi/get-applicants.interface.ts create mode 100644 src/RSI/orgs/services/orgs.service.ts diff --git a/package-lock.json b/package-lock.json index afe8db8..f48db05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2052,6 +2052,11 @@ } } }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" + }, "highlight.js": { "version": "9.13.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.13.1.tgz", @@ -3065,6 +3070,14 @@ "is-stream": "^1.0.1" } }, + "node-html-parser": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.1.11.tgz", + "integrity": "sha512-KOjvmbk0yWuy/cN8uqk6bVYS0Lue+jVWcLO/zmnCtz8FPXhj00apBN376FoM6QmFMMbJwXQdKf5ko6G1S6bnrw==", + "requires": { + "he": "1.1.1" + } + }, "normalize-package-data": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", diff --git a/package.json b/package.json index d75352a..fd363f9 100755 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "draft-js": "^0.10.0", "draft-js-emoji-plugin": "^2.0.0-rc9", "fs-extra": "^7.0.1", + "node-html-parser": "^1.1.11", "popsicle": "^10.0.1", "react": "^16.7.0", "react-dom": "^16.7.0", diff --git a/spec/org.spec.ts b/spec/org.spec.ts new file mode 100644 index 0000000..dba00dd --- /dev/null +++ b/spec/org.spec.ts @@ -0,0 +1,19 @@ +import { Container } from "typedi"; +import { SpectrumLobby } from "./../src/"; +import { SpectrumChannel } from "./../src/"; +import { SpectrumBroadcaster } from "./../src/"; +import { SpectrumCommunity, SpectrumCommands } from "../src/"; +import { TestInstance } from "./_.instance"; +import { TestShared } from "./_.shared"; + +import {} from "jasmine"; +import { SpectrumCommand } from "../src/Spectrum/components/api/decorators/spectrum-command.decorator"; + +describe("Organizations", () => { + describe(`Management`, () => { + describe(`Member list`, () => { + it(`Should list memberlist`); + it(`Should search through memberlist`); + }) + }) +}); \ No newline at end of file diff --git a/src/RSI/index.ts b/src/RSI/index.ts index 6d58d1a..afe775e 100644 --- a/src/RSI/index.ts +++ b/src/RSI/index.ts @@ -7,3 +7,4 @@ */ /** */ export { RSIService as RSI } from "./services/rsi.service"; +export * from "./orgs"; diff --git a/src/RSI/interfaces/RSIApiResponse.interface.ts b/src/RSI/interfaces/RSIApiResponse.interface.ts index 461574f..2ad421e 100644 --- a/src/RSI/interfaces/RSIApiResponse.interface.ts +++ b/src/RSI/interfaces/RSIApiResponse.interface.ts @@ -2,9 +2,9 @@ * @module RSI */ /** */ -export interface RSIApiResponse { - success:number, - data:any, - code:string; - msg:string; -} \ No newline at end of file +export interface RSIApiResponse { + success: number; + data: T; + code: string; + msg: string; +} diff --git a/src/RSI/interfaces/omit.type.ts b/src/RSI/interfaces/omit.type.ts new file mode 100644 index 0000000..7b5f23e --- /dev/null +++ b/src/RSI/interfaces/omit.type.ts @@ -0,0 +1 @@ +export type Omit = Pick>; \ No newline at end of file diff --git a/src/RSI/orgs/entities/organisation.entity.ts b/src/RSI/orgs/entities/organisation.entity.ts new file mode 100644 index 0000000..538a124 --- /dev/null +++ b/src/RSI/orgs/entities/organisation.entity.ts @@ -0,0 +1,177 @@ +import { GetApplicants } from "./../interfaces/rsi/get-applicants.interface"; +import { GetApplicantsParams } from "./../interfaces/rsi/get-applicants-params.interface"; +import { + GetOrgMembersOpts, + OrgMemberRank, + OrgMemberVisibility +} from "../interfaces/api/get-org-members-params.interface"; +import { Container } from "typedi"; +import { RSIService } from "../../services/rsi.service"; +import { GetOrgMembers, OrgMember } from "../interfaces/api/get-org-members.interface"; +import { GetOrgMembersOptions } from "../interfaces/api/get-org-members-params.interface"; +import { parse, HTMLElement } from "node-html-parser"; +import { OrgApplicant } from "../interfaces/rsi/get-applicants.interface"; + +/** + * Represents an RSI Organisation + */ +export class Organisation { + /** main rsi service */ + protected _rsi: RSIService = Container.get(RSIService); + + /** + * @param SSID unique SSID of the organisation + */ + constructor(public readonly SSID: string) {} + + /** + * Get the list of members for this organisation that can be seen by everyone + * this does not require any specific privilege. + * + * this will **NOT** return "HIDDEN" members and will return no personal info for "REDACTED" members + * + * @param options fetch options + * @see `getMembers()` If you have memberlist privilege and want all the members + */ + public async getPublicMembers(): Promise; + public async getPublicMembers(options: GetOrgMembersOptions): Promise; + public async getPublicMembers(options?: GetOrgMembersOptions): Promise { + const res = await this._rsi.post(`api/orgs/getOrgMembers`, { + ...options, + symbol: this.SSID + }); + + return this.buildMembersReturn(res.data, options); + } + + /** + * Get the list of members for this organisation. + * You **will** need memberlist privilege for this to work. + * + * @param options fetch options + * @see `getPublicMembers()` for a list of public members if you do not have privilege + */ + public async getMembers(): Promise; + public async getMembers(options: GetOrgMembersOptions): Promise; + public async getMembers(options?: GetOrgMembersOptions): Promise { + const res = await this._rsi.post(`api/orgs/getOrgMembers`, { + ...options, + admin_mode: 1, + symbol: this.SSID + }); + + return this.buildMembersReturn(res.data, options, true); + } + + /** + * Get the list of current applicants + * + * will fetch the first 500 applicants by default. + */ + public async getApplicants(): Promise; + public async getApplicants(options: GetApplicantsParams): Promise; + public async getApplicants( + options: GetApplicantsParams = { page: 1, pagesize: 500 } + ): Promise { + const res = await this._rsi.navigate( + `orgs/${this.SSID}/admin/applications?page=${options.page}&pagesize=${options.pagesize}` + ); + + return this.parseApplicants(res); + } + + /** + * Parse the HTML returned by getApplicants() into an array of OrgApplicant + */ + protected parseApplicants(resHTML: string): Array { + const root = parse(resHTML); + + + return root + .querySelectorAll("ul.applicants-listing li.clearfix") + .map((li: HTMLElement) => { + const applicant: OrgApplicant = { + id: Number( + (li.querySelectorAll("div.player-cell")[0] as HTMLElement).attributes[ + "data-app-id" + ] + ), + // no, this is not a typo, they do display the HANDLE in a .nick + handle: li.querySelectorAll("span.nick")[0].text, + nick: li.querySelectorAll("a.name")[0].text, + message: li.querySelectorAll("span.message")[0].text + }; + + return applicant; + }); + } + + protected async buildMembersReturn( + res: GetOrgMembers, + opts?: GetOrgMembersOptions, + admin = false + ) { + const firstPassMembers = this.parseMembers(res); + + if (opts && (opts as GetOrgMembersOpts).allMembers) { + if (firstPassMembers.length < res.totalrows) { + /** + * We have to make multiple calls because rsi api + * does not support a pagesize != 32 ... + */ + for (let i = 2; i <= Math.ceil(res.totalrows / 32); i++) { + // We need to fetch again :( + const res2 = await this._rsi.post(`api/orgs/getOrgMembers`, { + ...opts, + page: i, + admin_mode: admin ? 1 : undefined, + symbol: this.SSID + }); + firstPassMembers.push(...this.parseMembers(res2.data)); + } + } + } + + return firstPassMembers; + } + + /** + * Parse the HTML of a getOrgMembers calls into an OrgMember array + * @param res the res of getOrgMembers call + */ + protected parseMembers(res: GetOrgMembers) { + const root = parse(res.html); + + const members = root.querySelectorAll("li").map(li => { + const el = li as HTMLElement; + + const user: OrgMember = { + id: Number(el.attributes["data-member-id"]), + handle: el.attributes["data-member-nickname"], + monicker: el.attributes["data-member-displayname"], + avatar: + el.attributes["data-member-avatar"].length > 5 + ? el.attributes["data-member-avatar"] + : null, + rank: null, + visibility: null + }; + + const [_, rank] = (el.querySelector("span.ranking-stars") as HTMLElement).classNames + .join(" ") + .match(/data([0-9])/); + + user["rank"] = OrgMemberRank[rank]; + + const [_1, visibility] = (el.querySelector( + "span.visibility" + ) as HTMLElement).text.match(/Membership: (.*)/); + + user["visibility"] = visibility.substr(0, 1).toUpperCase() as OrgMemberVisibility; + + return user; + }); + + return members; + } +} diff --git a/src/RSI/orgs/index.ts b/src/RSI/orgs/index.ts new file mode 100644 index 0000000..ec1fbb5 --- /dev/null +++ b/src/RSI/orgs/index.ts @@ -0,0 +1,7 @@ +import { Container } from "typedi"; +export { OrgsService } from "./services/orgs.service"; +import { OrgsService } from "./services/orgs.service"; + +const organisations = Container.get(OrgsService); + +export { organisations }; diff --git a/src/RSI/orgs/interfaces/api/get-org-members-params.interface.ts b/src/RSI/orgs/interfaces/api/get-org-members-params.interface.ts new file mode 100644 index 0000000..72355f9 --- /dev/null +++ b/src/RSI/orgs/interfaces/api/get-org-members-params.interface.ts @@ -0,0 +1,79 @@ +import { Omit } from "../../../interfaces/omit.type"; + +/** + * Params for GetOrgMembers + */ +export interface GetOrgMembersParams { + /** if we want to do the search as an "admin" (ie: with privilege and see HIDDEN) */ + admin_mode?: 1; + /** filter by member ranks (1 being the higest, 6 the lowest) */ + rank?: OrgMemberRank; + /** filter by member rank () */ + role?: OrgMemberRole; + /** filter by member main/affiliate status (1 = Main only | 0 = Affiliate only) */ + main_org?: 0 | 1; + /** filter by member monicker/handle */ + search?: string; + /** filter by member visibility */ + visibility?: OrgMemberVisibility; + /** SSID of the org to search for */ + symbol: string; +} + +export interface PaginatedGetOrgMembersParam extends GetOrgMembersParams { + page: number; + pagesize: number; +} + +export type GetOrgMembersPaginatedOpts = Omit; +export type GetOrgMembersOpts = Omit & { + /** + * return every members in the org + * + * **\/!\ due to an RSI's API limitation, this will generate ceil(OrgMembers / 32) API calls.** + * This is therefore a "little" slow. + * + * If you only want the total **count** of members and not their infos, see GetOrgsMembers.totalrows + */ + allMembers: boolean; +}; + +/** + * Options for + */ +export type GetOrgMembersOptions = GetOrgMembersOpts | GetOrgMembersPaginatedOpts; + +/** + * Available ranks in an org for members + */ +export enum OrgMemberRank { + FIVE_STARS = 1, + FOUR_STARS = 2, + THREE_STARS = 3, + TWO_STARS = 4, + ONE_STAR = 5, + ZERO_STAR = 6 +} + +/** + * Available visibilities in an org for members + */ +export enum OrgMemberVisibility { + VISIBLE = "V", + REDACTED = "R", + HIDDEN = "H" +} + +/** + * Available roles in an org for main members + */ +export enum OrgMemberRole { + /**can do anything, from recruiting to customization, to simply disbanding the organization*/ + Owner = 1, + /** can send out invites to the org, and accept or deny applicants */ + Recruitment = 2, + /** can manage the org’s members, and their roles/ranks, as well as moderating the Org’s private Chat channel. */ + Officer = 3, + /**can change the org’s public appearance, official texts, history, manifesto and charter. */ + Marketing = 4 +} diff --git a/src/RSI/orgs/interfaces/api/get-org-members.interface.ts b/src/RSI/orgs/interfaces/api/get-org-members.interface.ts new file mode 100644 index 0000000..bdcb300 --- /dev/null +++ b/src/RSI/orgs/interfaces/api/get-org-members.interface.ts @@ -0,0 +1,23 @@ +import { OrgMemberRank, OrgMemberVisibility } from "./get-org-members-params.interface"; + +/** + * Raw search result when calling getorgmembers + */ +export interface GetOrgMembers { + /** total amount of items that matched the search */ + totalrows: number; + /** html containing the list of members in the org */ + html: string; +} + +/** + * Parsed return from GetOrgMembers call + */ +export interface OrgMember { + id: number; + handle: string; + monicker: string; + avatar: string; + rank: OrgMemberRank; + visibility: OrgMemberVisibility; +} diff --git a/src/RSI/orgs/interfaces/rsi/get-applicants-params.interface.ts b/src/RSI/orgs/interfaces/rsi/get-applicants-params.interface.ts new file mode 100644 index 0000000..809ff2d --- /dev/null +++ b/src/RSI/orgs/interfaces/rsi/get-applicants-params.interface.ts @@ -0,0 +1,4 @@ +export interface GetApplicantsParams { + page: number; + pagesize: number; +} diff --git a/src/RSI/orgs/interfaces/rsi/get-applicants.interface.ts b/src/RSI/orgs/interfaces/rsi/get-applicants.interface.ts new file mode 100644 index 0000000..5c06ba8 --- /dev/null +++ b/src/RSI/orgs/interfaces/rsi/get-applicants.interface.ts @@ -0,0 +1,9 @@ +export type GetApplicants = Array; + +export interface OrgApplicant { + id: number; + handle: string; + nick: string; + /** application message filled by the applicant */ + message: string; +} \ No newline at end of file diff --git a/src/RSI/orgs/services/orgs.service.ts b/src/RSI/orgs/services/orgs.service.ts new file mode 100644 index 0000000..08986bd --- /dev/null +++ b/src/RSI/orgs/services/orgs.service.ts @@ -0,0 +1,30 @@ +import { Container, Service } from "typedi"; +import { RSIService } from "../../services/rsi.service"; +import { GetOrgMembers } from "../interfaces/api/get-org-members.interface"; +import { Organisation } from "../entities/organisation.entity"; + +@Service() +export class OrgsService { + protected _rsi: RSIService = Container.get(RSIService); + + /** + * Get a given organization + * @param ssid the ssid of the organization + */ + public async get(ssid: string) { + if (!(await this.ensureExists(ssid))) throw new Error(`Org ${ssid} does not exist`); + else { + return new Organisation(ssid); + } + } + + /** + * Ensures an organization exists + * @param ssid unique id of the organization + */ + public async ensureExists(ssid: string): Promise { + const res = await this._rsi.post(`api/orgs/getOrgMembers`, { symbol: ssid }); + + return Boolean(res); + } +} diff --git a/src/RSI/services/rsi.service.ts b/src/RSI/services/rsi.service.ts index bee1033..5912695 100755 --- a/src/RSI/services/rsi.service.ts +++ b/src/RSI/services/rsi.service.ts @@ -28,6 +28,7 @@ export class RSIService { private tokens = {}; /** cookieJar for api calls */ private cookieJar: CookieJar; + /** input interface for MFA inputing */ private input = rl.createInterface(process.stdin, process.stdout, null); constructor() { @@ -41,8 +42,6 @@ export class RSIService { this.cookieJar = popsicle.jar(new cookieStore(path)); } - - /** * @deprecated use Container.get(RSIService) instead */ @@ -249,13 +248,15 @@ export class RSIService { * @param data an object of data to send * @return a popsicle Promise */ - public post(url, data?): Promise { - return popsicle + public async post( + url: string, + data?: { [data: string]: any } + ): Promise> { + const res = await popsicle .post(this.pop({ url: this.rsi + url, body: data })) - .use(popsicle.plugins.parse("json")) - .then((res: ApiResponse) => { - return res.body; - }); + .use(popsicle.plugins.parse("json")); + + return res.body; } /** @@ -264,13 +265,31 @@ export class RSIService { * @param url the endpoint * @return a popsicle Promise */ - public get(url): Promise { - return popsicle - .post(this.pop({ url: this.rsi + url })) - .use(popsicle.plugins.parse("json")) - .then((res: ApiResponse) => { - return res.body; - }); + public async get(url: string): Promise> { + const res = await popsicle + .get(this.pop({ url: this.rsi + url })) + .use(popsicle.plugins.parse("json")); + + return res.body; + } + + /** + * Navigate via GET to an HTML page of rsi's website + * @param url the endpoint to go to + */ + public async navigate(url: string): Promise { + const res = await popsicle.get(this.pop({ url: this.rsi + url })); + return res.body; + } + + /** + * Navigate via POST to an HTML page of rsi's website + * @param url the endpoint to go to + */ + public async navigatePost(url: string): Promise { + const res = await popsicle.post(this.pop({ url: this.rsi + url })); + + return res.body; } /**