initial commit
This commit is contained in:
109
src/Sati.ts
Normal file
109
src/Sati.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { EventEmitter } from './helpers/EventEmitter'
|
||||
import { SatiError } from './SatiError'
|
||||
import { SatiSocket, SocketState } from './SatiSocket'
|
||||
import { events, methods, tasks, Task } from './types'
|
||||
|
||||
/**
|
||||
* high level api wrapper
|
||||
* @example
|
||||
* const sati = new Sati({
|
||||
* token: 'your token here'
|
||||
* })
|
||||
* await sati.init() // you must call the init method after construction
|
||||
*
|
||||
* const task = await sati.solve('Turnstile', {
|
||||
* siteKey: '0x4AAAAAAAHMEd1rGJs9qy-0',
|
||||
* pageUrl: 'https://polygon.sati.ac/Turnstile'
|
||||
* })
|
||||
*
|
||||
* console.log(task, task.result.token)
|
||||
*
|
||||
* sati.close() // you must call close method after you've done
|
||||
*/
|
||||
export class Sati extends EventEmitter<events> {
|
||||
private socket: SatiSocket
|
||||
private awaitedTasks: {
|
||||
[ index: number ]: {
|
||||
resolve(data: any): void,
|
||||
reject(reason: any): void
|
||||
}
|
||||
} = Object.create(null)
|
||||
|
||||
/** @param token your api token. get it at https://sati.ac/dashboard */
|
||||
constructor({ token }: { token: string }) {
|
||||
super()
|
||||
this.socket = new SatiSocket(token)
|
||||
this.socket.on('event', ({ type, data }) => {
|
||||
this.emit(type as keyof events, data)
|
||||
})
|
||||
this.socket.on('stateChange', ({ state, error }) => {
|
||||
switch(state) {
|
||||
case SocketState.connected:
|
||||
// as of socket was disconnected, there might be missed events
|
||||
// so we need to recheck the state of each awaited task
|
||||
for(const [ id, awaited ] of Object.entries(this.awaitedTasks)) {
|
||||
this.call('getTask', { id: +id }).then(this.handleTaskResult.bind(this), err => {
|
||||
awaited.reject(err)
|
||||
delete this.awaitedTasks[+id]
|
||||
})
|
||||
}
|
||||
case SocketState.unrecoverable:
|
||||
// socket got an unrecoverable error, so it will never be connected again
|
||||
for(const awaited of Object.values(this.awaitedTasks)) {
|
||||
awaited.reject(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
this.on('taskUpdate', this.handleTaskResult.bind(this))
|
||||
}
|
||||
|
||||
private handleTaskResult(task: Task) {
|
||||
if(!(task.id in this.awaitedTasks)) return
|
||||
if(task.state !== 'error' && task.state !== 'success') return
|
||||
const awaited = this.awaitedTasks[task.id]
|
||||
|
||||
if(task.state === 'error') {
|
||||
awaited.reject(new SatiError(`unable to solve ${task.type} task #${task.id}`))
|
||||
} else if(task.state === 'success') {
|
||||
awaited.resolve(task)
|
||||
}
|
||||
delete this.awaitedTasks[task.id]
|
||||
}
|
||||
|
||||
/** connects to the socket, must be called before any other methods */
|
||||
public async init() {
|
||||
await this.socket.connect()
|
||||
}
|
||||
|
||||
/** closes the socket */
|
||||
public close() {
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
/** calls the api method, use top-level wrappers for them instead */
|
||||
public async call<M extends keyof methods>(method: M, data: methods[M]['request']): Promise<methods[M]['response']> {
|
||||
const resp = await this.socket.send('call', { method, data })
|
||||
|
||||
if(!resp.success) {
|
||||
throw new SatiError(resp.data.description)
|
||||
}
|
||||
|
||||
return resp.data
|
||||
}
|
||||
|
||||
/**
|
||||
* creates task, then waits for the result
|
||||
* @throws {SatiError} if unable to solve
|
||||
*/
|
||||
public async solve<T extends keyof tasks>(type: T, data: tasks[T]['params']): Promise<Task<T, 'success'>> {
|
||||
const task = await this.call('createTask', { type, data })
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.awaitedTasks[task.id] = { resolve, reject }
|
||||
})
|
||||
}
|
||||
|
||||
public async getBalance() {
|
||||
return (await this.call('getBalance', {})).balance
|
||||
}
|
||||
}
|
5
src/SatiError.ts
Normal file
5
src/SatiError.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export class SatiError extends Error {
|
||||
constructor(msg: string) {
|
||||
super(`sati: ${msg}`)
|
||||
}
|
||||
}
|
147
src/SatiSocket.ts
Normal file
147
src/SatiSocket.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { EventEmitter } from './helpers/EventEmitter'
|
||||
import { SocketAdapter } from '%env%'
|
||||
import { SatiError } from './SatiError'
|
||||
import { endpoint } from './config'
|
||||
|
||||
interface WebSocketMessage {
|
||||
id: number
|
||||
type: string
|
||||
data: any
|
||||
to?: number
|
||||
}
|
||||
|
||||
export enum SocketState {
|
||||
connected,
|
||||
disconnected,
|
||||
unrecoverable
|
||||
}
|
||||
|
||||
/** low-level api wrapper */
|
||||
export class SatiSocket extends EventEmitter<{
|
||||
event: {
|
||||
type: string,
|
||||
data: any
|
||||
},
|
||||
stateChange: {
|
||||
state: SocketState,
|
||||
error: Error | null
|
||||
}
|
||||
}> {
|
||||
private token: string
|
||||
private socket: SocketAdapter | null = null
|
||||
private error: Error | null = null
|
||||
private state: SocketState = SocketState.disconnected
|
||||
|
||||
private idCounter = 0
|
||||
private awaitedMessages: Record<number, {
|
||||
resolve: (data: any) => void
|
||||
reject: (error: any) => void
|
||||
}> = Object.create(null)
|
||||
private queue: [(data: any) => void, string, any][] = []
|
||||
|
||||
constructor(token: string) {
|
||||
super()
|
||||
this.token = token
|
||||
}
|
||||
|
||||
private setState(state: SocketState, error: Error | null = null) {
|
||||
this.state = state
|
||||
this.error = error
|
||||
this.emit('stateChange', { state, error })
|
||||
}
|
||||
|
||||
public async connect() {
|
||||
if(this.socket || this.state !== SocketState.disconnected) return;
|
||||
this.idCounter = 0
|
||||
this.socket = new SocketAdapter(endpoint)
|
||||
this.socket.once('close', this.onSocketClose.bind(this))
|
||||
this.socket.on('message', this.onMessage.bind(this))
|
||||
await new Promise((res, rej) => {
|
||||
this.socket?.once('open', res)
|
||||
this.socket?.once('error', rej)
|
||||
})
|
||||
const resp = await this.unqueuedSend('auth', { token: this.token })
|
||||
if(!resp.success) {
|
||||
const error = new SatiError('invalid auth token')
|
||||
this.setState(SocketState.unrecoverable, error)
|
||||
this.socket?.close()
|
||||
throw error
|
||||
}
|
||||
this.setState(SocketState.connected)
|
||||
await this.flushQueue()
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.setState(SocketState.unrecoverable, new SatiError('socket was closed'))
|
||||
this.socket?.close()
|
||||
}
|
||||
|
||||
private flushQueue() {
|
||||
const promises = []
|
||||
for(const [ cb, type, data ] of this.queue) {
|
||||
promises.push(this.unqueuedSend(type, data).then(cb))
|
||||
}
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
private onSocketClose() {
|
||||
this.socket = null
|
||||
|
||||
for(const awaited of Object.values(this.awaitedMessages)) {
|
||||
// we don't want to resend them, because it may cause task double-creation
|
||||
awaited.reject(new SatiError('socket gone'))
|
||||
}
|
||||
this.awaitedMessages = Object.create(null)
|
||||
|
||||
if(this.state !== SocketState.unrecoverable) {
|
||||
this.setState(SocketState.disconnected)
|
||||
setTimeout(() => {
|
||||
this.connect().catch(err => {
|
||||
console?.error?.('sati: error while reconnecting:', err)
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
private async unqueuedSend(type: string, data: any) {
|
||||
if(this.state === SocketState.unrecoverable) throw this.error
|
||||
if(!this.socket) return;
|
||||
const id = ++this.idCounter
|
||||
|
||||
this.socket.send(JSON.stringify({ id, type, data }))
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
this.awaitedMessages[id] = { resolve, reject }
|
||||
})
|
||||
}
|
||||
|
||||
public send(type: string, data: any) {
|
||||
switch(this.state) {
|
||||
case SocketState.connected:
|
||||
return this.unqueuedSend(type, data)
|
||||
case SocketState.disconnected:
|
||||
return new Promise(resolve => {
|
||||
this.queue.push([ resolve, type, data ])
|
||||
})
|
||||
case SocketState.unrecoverable:
|
||||
throw this.error
|
||||
}
|
||||
}
|
||||
|
||||
private onMessage(message: string) {
|
||||
const parsed: WebSocketMessage = JSON.parse(message)
|
||||
if(parsed.type === 'event') {
|
||||
if(parsed.data.type === 'tokenReissue') {
|
||||
this.setState(SocketState.unrecoverable, new SatiError('token was reissued'))
|
||||
this.socket?.close()
|
||||
}
|
||||
|
||||
this.emit('event', parsed.data)
|
||||
return
|
||||
}
|
||||
|
||||
if(parsed.to && parsed.to in this.awaitedMessages) {
|
||||
this.awaitedMessages[parsed.to].resolve(parsed.data)
|
||||
}
|
||||
}
|
||||
}
|
1
src/config.ts
Normal file
1
src/config.ts
Normal file
@ -0,0 +1 @@
|
||||
export const endpoint = 'wss://api.sati.ac/ws'
|
36
src/env/node/index.ts
vendored
Normal file
36
src/env/node/index.ts
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
import { WebSocket, RawData } from 'ws'
|
||||
import { SocketAdapter } from '../../helpers/SocketAdapter'
|
||||
|
||||
class NodeSocketAdapter extends SocketAdapter {
|
||||
private socket: WebSocket
|
||||
|
||||
constructor(url: string) {
|
||||
super()
|
||||
this.socket = new WebSocket(url)
|
||||
this.socket.on('message', this.emitMessage.bind(this))
|
||||
this.socket.on('error', err => this.emit('error', err))
|
||||
this.socket.on('open', () => this.emit('open', null))
|
||||
this.socket.on('close', () => this.emit('close', null))
|
||||
}
|
||||
|
||||
public send(message: string) {
|
||||
this.socket.send(message)
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
private emitMessage(message: RawData) {
|
||||
if(Array.isArray(message)) {
|
||||
message = Buffer.concat(message)
|
||||
}
|
||||
if(message instanceof ArrayBuffer) {
|
||||
message = Buffer.from(message)
|
||||
}
|
||||
|
||||
this.emit('message', message.toString())
|
||||
}
|
||||
}
|
||||
|
||||
export { NodeSocketAdapter as SocketAdapter }
|
23
src/env/web/index.ts
vendored
Normal file
23
src/env/web/index.ts
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
import { SocketAdapter } from '../../helpers/SocketAdapter'
|
||||
|
||||
class BrowserSocketAdapter extends SocketAdapter {
|
||||
private socket: WebSocket
|
||||
constructor(url: string) {
|
||||
super()
|
||||
this.socket = new WebSocket(url)
|
||||
this.socket.addEventListener('message', e => this.emit('message', e.data))
|
||||
this.socket.addEventListener('error', e => this.emit('error', e))
|
||||
this.socket.addEventListener('open', () => this.emit('open', null))
|
||||
this.socket.addEventListener('close', () => this.emit('close', null))
|
||||
}
|
||||
|
||||
public send(message: string) {
|
||||
this.socket.send(message)
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
export { BrowserSocketAdapter as SocketAdapter }
|
35
src/helpers/EventEmitter.ts
Normal file
35
src/helpers/EventEmitter.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* fully typed node-like event emitter
|
||||
* usage: `EventEmitter<{ event: eventDataType }>`
|
||||
*/
|
||||
export class EventEmitter<T extends {} = {}> {
|
||||
private listeners: { [E in keyof T]: Set<((data: T[E]) => void)> } = Object.create(null)
|
||||
|
||||
public on<E extends keyof T>(event: E, listener: ((data: T[E]) => void)) {
|
||||
this.listeners[event] ||= new Set()
|
||||
this.listeners[event].add(listener)
|
||||
}
|
||||
|
||||
public off<E extends keyof T>(event: E, listener: ((data: T[E]) => void)) {
|
||||
this.listeners[event]?.delete(listener)
|
||||
if(this.listeners[event]?.size === 0) {
|
||||
delete this.listeners[event]
|
||||
}
|
||||
}
|
||||
|
||||
public once<E extends keyof T>(event: E, listener: ((data: T[E]) => void)) {
|
||||
const wrapped = (data: T[E]) => {
|
||||
this.off(event, wrapped)
|
||||
listener(data)
|
||||
}
|
||||
|
||||
this.on(event, wrapped)
|
||||
}
|
||||
|
||||
protected emit<E extends keyof T>(event: E, data: T[E]) {
|
||||
if(!this.listeners[event]) return
|
||||
for(const listener of this.listeners[event]) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
}
|
16
src/helpers/SocketAdapter.ts
Normal file
16
src/helpers/SocketAdapter.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { EventEmitter } from '../helpers/EventEmitter'
|
||||
|
||||
export interface SocketAdapterConstructor {
|
||||
new(url: string): SocketAdapter
|
||||
}
|
||||
|
||||
/** cross-platform websocket interface */
|
||||
export abstract class SocketAdapter extends EventEmitter<{
|
||||
message: string,
|
||||
open: null,
|
||||
close: null,
|
||||
error: any
|
||||
}> {
|
||||
public abstract send(message: string): void
|
||||
public abstract close(): void
|
||||
}
|
3
src/index.ts
Normal file
3
src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './Sati'
|
||||
export * from './SatiError'
|
||||
export type { Task } from './types'
|
72
src/types.ts
Normal file
72
src/types.ts
Normal file
@ -0,0 +1,72 @@
|
||||
export type tasks = {
|
||||
Turnstile: {
|
||||
params: {
|
||||
siteKey: string,
|
||||
pageUrl: string,
|
||||
action?: string,
|
||||
cData?: string
|
||||
},
|
||||
result: {
|
||||
token: string
|
||||
}
|
||||
},
|
||||
ReCaptcha2: {
|
||||
params: {
|
||||
siteKey: string,
|
||||
pageUrl: string
|
||||
},
|
||||
result: {
|
||||
token: string
|
||||
}
|
||||
},
|
||||
FunCaptcha: {
|
||||
params: {
|
||||
siteKey: string,
|
||||
pageUrl: string,
|
||||
data: Record<string, string>
|
||||
serviceUrl?: string
|
||||
},
|
||||
result: {
|
||||
token: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type methods = {
|
||||
getBalance: {
|
||||
request: {},
|
||||
response: {
|
||||
balance: string
|
||||
}
|
||||
},
|
||||
createTask: {
|
||||
request: {
|
||||
type: keyof tasks,
|
||||
data: tasks[keyof tasks]['params']
|
||||
},
|
||||
response: Task
|
||||
},
|
||||
getTask: {
|
||||
request: {
|
||||
id: number
|
||||
},
|
||||
response: Task
|
||||
}
|
||||
}
|
||||
|
||||
export type events = {
|
||||
taskUpdate: Task,
|
||||
tokenReissue: {}
|
||||
}
|
||||
|
||||
export type TaskState = 'queued' | 'processing' | 'error' | 'success'
|
||||
|
||||
export type Task<T extends keyof tasks = keyof tasks, S extends TaskState = TaskState> = {
|
||||
id: number,
|
||||
type: T,
|
||||
creationDate: string,
|
||||
cost: string
|
||||
state: S,
|
||||
} & (S extends 'success'
|
||||
? { result: tasks[T]['result'] }
|
||||
: { result: null })
|
Reference in New Issue
Block a user