viewing asked questions
parent
ce737bfc22
commit
ec8192531d
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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)));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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> {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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",
|
||||
{ }
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue