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