initial commit

This commit is contained in:
2023-07-19 11:56:01 +03:00
commit fb7610845e
17 changed files with 3291 additions and 0 deletions

109
src/Sati.ts Normal file
View 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
View File

@ -0,0 +1,5 @@
export class SatiError extends Error {
constructor(msg: string) {
super(`sati: ${msg}`)
}
}

147
src/SatiSocket.ts Normal file
View 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
View File

@ -0,0 +1 @@
export const endpoint = 'wss://api.sati.ac/ws'

36
src/env/node/index.ts vendored Normal file
View 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
View 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 }

View 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)
}
}
}

View 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
View File

@ -0,0 +1,3 @@
export * from './Sati'
export * from './SatiError'
export type { Task } from './types'

72
src/types.ts Normal file
View 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 })