viewing asked questions

master
John Montagu, the 4th Earl of Sandvich 2023-10-20 01:24:53 -07:00
parent ce737bfc22
commit ec8192531d
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
11 changed files with 1486 additions and 29 deletions

1065
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,9 @@
},
"license": "GPL-3.0-only",
"jsii": {
"excludeTypescript": ["*.test.ts"],
"excludeTypescript": [
"**/*.test.ts"
],
"versionFormat": "full",
"outdir": "dist",
"tsc": {
@ -42,6 +44,7 @@
"devDependencies": {
"@types/node": "^20.8.4",
"jsii": "^5.2.14",
"jsii-pacmak": "^1.90.0"
"jsii-pacmak": "^1.90.0",
"vitest": "^0.34.6"
}
}

View File

@ -0,0 +1,113 @@
import { assert, describe, expect, it } from "vitest";
import { ListDataResponse, QuestionData, QuestionMetadata, QuestionMetadataRaw, QuestionTextResponse, QuestionType } from "./types";
import { TCResponseRaw } from "../types";
describe("ask module", () => {
it("should parse QuestionMetadataRaw into QuestionMetadata", () => {
const raw: QuestionMetadataRaw = {
"id": 2219026,
"ia": true,
"r": 1,
"nr": 0,
"it": 1697785844,
"la": 13548862,
"at": 1697526644,
"p": false,
"pn": false,
"t": "text"
};
const constructed = new QuestionMetadata(raw);
expect(constructed).toEqual({
id: 2219026,
isActive: true,
replyCount: 1,
newReplyCount: 0,
expireTime: 1697785844,
creationTime: 1697526644,
isPinned: false,
questionType: QuestionType.TEXT,
});
});
it("should parse raw ListDataResponse", () => {
const response: TCResponseRaw = {
"questions": [
{
"id": 2200000,
"ia": true,
"r": 1,
"nr": 0,
"it": 1697785844,
"la": 13548862,
"at": 1697526644,
"p": false,
"pn": false,
"t": "text"
},
{
"id": 2200001,
"ia": false,
"r": 12,
"nr": 0,
"it": 1697731297,
"la": 13549378,
"at": 1697472097,
"p": false,
"pn": false,
"t": "text"
},
]
};
const constructed = new ListDataResponse(response);
expect(constructed.questions).toBeTruthy();
expect(constructed.questions).toHaveLength(2);
expect(constructed.questions[0]).toBeTruthy();
expect(constructed.questions[0].id).toBe(2200000);
expect(constructed.questions[1].id).toBe(2200001);
});
});
describe("fetchQuestions method", () => {
it("should create question data from metadata and text", () => {
const listData: TCResponseRaw = {
"questions": [
{
"id": 2219000,
"ia": true,
"r": 1,
"nr": 0,
"it": 1697785844,
"la": 13548862,
"at": 1697526644,
"p": false,
"pn": false,
"t": "text"
},
],
};
const questionText: TCResponseRaw = {
"questions": [
{
"id": 2219000,
"b": "among us"
},
]
};
const metadata = new ListDataResponse(listData).questions;
const metadataMap = new Map(metadata.map((u) => [u.id, u]));
const questions = new QuestionTextResponse(questionText).questions;
const data = QuestionData._mapFrom(metadataMap, questions);
assert(metadata.length > 0);
assert(data.length > 0);
expect(data[0].body).toEqual("among us");
expect(data[0].header).toEqual(metadata[0]);
});
});

50
src/ask/index.ts 100644
View File

@ -0,0 +1,50 @@
import { Module } from "../module";
import { ListDataResponse, QuestionData, QuestionMetadata, QuestionTextResponse } from "./types";
export class AskModule extends Module {
//#questionCache: Cache<QuestionData> = new Cache<QuestionData>();
/**
* Lists the metadata of all questions. This does nothing by itself.
*/
public async listData() {
return await this.client._call(
ListDataResponse,
"legacy.askapi",
{
arg1: "0",
arg2: "none",
arg3: "0",
name: "listdata",
rawEmbeddedJsonLolInternalTechDebt: null,
},
);
}
public async fetchQuestions(metadata: QuestionMetadata[]) {
const metadataMap = new Map(metadata.map((u) => [u.id, u]));
const text = await this.fetchQuestionsText(metadata);
//return text.questions
// .map((body) => new QuestionData(body, metadataMap.get(body.id)));
return QuestionData._mapFrom(metadataMap, text.questions);
}
/**
* Fetches question content given list of question metadata. Does nothing
* by itself.
*/
public async fetchQuestionsText(metadata: QuestionMetadata[]) {
const ids = metadata.map((q) => q.id);
return await this.client._call(
QuestionTextResponse,
"legacy.askapi",
{
arg1: ids.join("x"),
arg2: "0",
arg3: "0",
name: "qtext",
rawEmbeddedJsonLolInternalTechDebt: null,
}
);
}
}

120
src/ask/types.ts 100644
View File

@ -0,0 +1,120 @@
import { TCResponse, TCResponseRaw } from "../types";
//export class FetchQuestionsResponse extends TCResponse {
// public constructor(response: TCResponseRaw) {
//
// }
//}
export class ListDataResponse extends TCResponse {
public questions: QuestionMetadata[];
public constructor(res: TCResponseRaw) {
super(res);
const data = res["questions"] as QuestionMetadataRaw[] ?? [];
this.questions = data.map((meta) => new QuestionMetadata(meta));
}
}
export class QuestionTextResponse extends TCResponse {
public questions: QuestionText[];
public constructor(res: TCResponseRaw) {
super(res);
const data = (res.questions ?? []) as QuestionTextRaw[];
this.questions = data.map((c) => new QuestionText(c));
}
}
export interface QuestionMetadataRaw {
readonly id: number;
readonly ia: boolean; // is active
readonly r: number; // replies
readonly nr: number; // new replies
readonly it: number; // time expires
readonly la: number; // ???
readonly at: number; // time asked
readonly p: boolean; // pinned
readonly pn: boolean; // pinned???
readonly t: string; // "text" or "poll"
}
export enum QuestionType {
TEXT,
POLL,
}
export class QuestionMetadata {
public id: number;
public isActive: boolean;
public replyCount: number;
public newReplyCount: number;
public expireTime: number;
public creationTime: number;
public isPinned: boolean;
public questionType: QuestionType;
public constructor(metadata: QuestionMetadataRaw) {
this.id = metadata.id;
this.isActive = metadata.ia;
this.replyCount = metadata.r;
this.newReplyCount = metadata.nr;
this.expireTime = metadata.it;
this.creationTime = metadata.at;
this.isPinned = metadata.p;
this.questionType = metadata.t == "poll" ?
QuestionType.POLL : QuestionType.TEXT;
}
}
export class QuestionText {
public id: number;
public text: string;
public constructor(content: QuestionTextRaw) {
this.id = content.id;
this.text = content.b;
}
}
export interface QuestionTextRaw {
readonly id: number;
readonly b: string;
}
export interface PollMetadataRaw {
readonly id: number;
readonly options: PollOptionRaw[];
}
export interface PollOptionRaw {
readonly n: number;
readonly c: number;
}
export class QuestionData {
public body: string;
public header: QuestionMetadata;
public constructor(c: QuestionText, m: QuestionMetadata | undefined) {
if (!m) {
throw "No metadata associated";
}
if (c.id != m.id) {
throw "QuestionContent does not share ID with QuestionMetadata";
}
this.body = c.text;
this.header = m;
}
/** @internal */
static _mapFrom(
metadata: Map<number, QuestionMetadata>,
questions: QuestionText[]
) {
return questions
.map((t) => new QuestionData(t, metadata.get(t.id)));
}
}

49
src/cache.ts 100644
View File

@ -0,0 +1,49 @@
/** @internal */
export class Cache<T> {
#cache: { [key: string]: CacheItem<T> } = { };
/** @internal */
public query(key: string): CacheItem<T> | undefined {
if (key in this.#cache) {
return this.#cache[key];
}
return undefined;
}
public fetch(key: string): T | undefined {
return this.query(key)?.value;
}
public isItemStale(key: string): boolean {
return this.query(key)?.isStale ?? false;
}
public doesItemExist(key: string): boolean {
return this.query(key) ? true : false;
}
public isItemValid(key: string): boolean {
return this.doesItemExist(key) && !this.isItemStale(key);
}
}
/** @internal */
class CacheItem<T> {
public timeToLive: number;
public origin: number = 0;
public value: T;
public constructor(value: T, timeToLive: number = 60000) {
this.value = value;
this.timeToLive = timeToLive;
this.origin = new Date().getTime();
}
public get isStale(): boolean {
return new Date().getTime() > this.origin + this.timeToLive;
}
public kill(): void {
this.origin = 0;
}
}

View File

@ -1,35 +1,52 @@
import { AskModule } from "./ask";
import { MessagesModule } from "./messages";
import { MethodCall, TCJSONResponse, TCResponse } from "./types";
import { MethodCall, TCJSONResponse, TCResponse, TCResponseRaw } from "./types";
/**
* Client for Two Cans & String API.
*/
export class Client {
static readonly BASE_URI = "https://twocansandstring.com/api";
static readonly VERSION = "1.68"
static readonly VERSION = "1.68";
#messages: MessagesModule;
#ask: AskModule;
/** @internal */
public _cache: { [key: string]: any; } = { };
public auth?: string;
public get messages(): MessagesModule {
return this.#messages;
}
public auth: string;
public get ask(): AskModule {
return this.#ask;
}
public constructor(auth: string) {
public constructor(auth?: string) {
this.auth = auth;
// init modules
this.#messages = new MessagesModule(this);
this.#ask = new AskModule(this);
}
//public login(username: string, password: string) {
// this.call();
//}
/**
* Calls an API method.
* @param methodName The name of the API method to call.
* @param args The arguments to pass to the API method.
* @internal
*/
public async call<T extends TCResponse>(
methodName: string, args: { [key: string]: any }
public async _call<T extends TCResponse>(
creator: new(r: TCResponseRaw) => T,
methodName: string,
args: { [key: string]: any }
): Promise<T>
{
const methodCall: MethodCall = {
@ -39,15 +56,17 @@ export class Client {
const body = {
auth: this.auth,
requests: [ methodCall ],
requests: [methodCall],
};
const res = await this.fetch(body);
return {
const rawResponse: TCResponseRaw = {
ok: res?.ok ?? false,
...res?.responses[0],
} as T;
return new creator(rawResponse);
}
private async fetch(body: any): Promise<TCJSONResponse | undefined> {

View File

@ -3,3 +3,5 @@ export * from "./client";
export * as types from "./types";
export * from "./messages";
export * as messages from "./messages/types";
export * from "./ask";
export * as ask from "./ask/types";

View File

@ -4,7 +4,8 @@ import { FolderListResponse, FolderViewResponse, MessageViewResponse } from "./t
export class MessagesModule extends Module {
public async folderView(folder = "inbox", page = 1) {
return await this.client.call<FolderViewResponse>(
return await this.client._call(
FolderViewResponse,
"messages.folderview",
{
folder,
@ -19,7 +20,8 @@ export class MessagesModule extends Module {
markAsRead = true,
page = 1
) {
return await this.client.call<MessageViewResponse>(
return await this.client._call(
MessageViewResponse,
"messages.view",
{
conversationId,
@ -35,7 +37,8 @@ export class MessagesModule extends Module {
text: string,
unanonymize = false
) {
return await this.client.call<TCResponse>(
return await this.client._call(
TCResponse,
"messages.reply",
{
conversationId,
@ -46,7 +49,8 @@ export class MessagesModule extends Module {
}
public async folderList() {
return await this.client.call<FolderListResponse>(
return await this.client._call(
FolderListResponse,
"messages.folderlist",
{ }
);

View File

@ -1,14 +1,27 @@
import { TCResponse, TCUser } from "../types";
import { TCResponse, TCResponseRaw, TCUser } from "../types";
export interface FolderViewResponse extends TCResponse {
readonly hasMore: boolean;
readonly hasPrevious: boolean;
readonly messages: MessagePreview[];
export class FolderViewResponse extends TCResponse {
hasMore: boolean;
hasPrevious: boolean;
messages: MessagePreview[];
public constructor(response: TCResponseRaw) {
super(response);
this.hasMore = response["hasMore"] ?? false;
this.hasPrevious = response["hasPrevious"] ?? false;
this.messages = response["messages"] ?? [];
}
}
export interface MessageViewResponse extends TCResponse {
readonly messages: Message[];
readonly header: MessageThreadHeader;
export class MessageViewResponse extends TCResponse {
messages: Message[];
header: MessageThreadHeader;
public constructor(response: TCResponseRaw) {
super(response);
this.messages = response["messages"] ?? [];
this.header = response["header"];
}
}
export interface MessagePreview {
@ -46,8 +59,13 @@ export interface MessageFolder {
readonly name: string;
}
export interface FolderListResponse extends TCResponse {
readonly folders: Folder[];
export class FolderListResponse extends TCResponse {
folders: Folder[];
public constructor(response: TCResponseRaw) {
super(response);
this.folders = response["folders"];
}
}
export interface Folder {

View File

@ -6,15 +6,31 @@ export interface MethodCall {
export interface TCJSONResponse {
readonly ok: boolean;
readonly loginId: string;
readonly responses: TCResponse[];
readonly responses: TCResponseRaw[];
readonly profiles?: TCProfile[];
readonly auth: string;
readonly ver: string;
}
export interface TCResponse {
readonly ok: boolean;
readonly profiles?: TCProfile[];
export interface TCResponseRaw {
/** @internal */
[key: string]: any;
}
export class TCResponse {
public ok: boolean = false;
public error?: string;
public profiles?: TCProfile[] = [];
public constructor(response: TCResponseRaw) {
this.ok = response.ok;
this.error = response.error;
this.profiles = response.profiles;
}
}
export interface TCResponseConstructor {
new(response: TCJSONResponse): TCResponse;
}
// TODO: move this to another file