New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

@chatally/core

Package Overview
Dependencies
Maintainers
0
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@chatally/core - npm Package Compare versions

Comparing version
0.0.9
to
0.0.10
+4
.turbo/turbo-tsc.log
> @chatally/core@0.0.10 tsc
> tsc
/**
* Outgoing chat message (part of ChatResponse).
*/
export type ChatMessage =
| Text
| Buttons
| Menu
| Reaction
| Location
| Image
| Audio
| Video
| Document
| Custom
export type Action = {
readonly type: 'action'
} & _Action
interface _Action {
id: string
title: string
description?: string
}
export interface Buttons {
readonly type: 'buttons'
content: string
actions: _Action[]
}
export interface Custom {
readonly type: 'custom'
readonly schema: string
readonly custom: unknown
}
export interface Location {
readonly type: 'location'
/** Longitude of the location. */
longitude: number
/** Latitude of the location. */
latitude: number
/** Name of the location. */
name?: string
/** Address of the location. */
address?: string
}
export interface Menu {
readonly type: 'menu'
title: string
content: string
sections: Array<{
title?: string
actions: _Action[]
}>
}
export interface Reaction {
readonly type: 'reaction'
/** The ID of the message the customer reacted to. */
replyTo: string
/** The emoji the customer reacted with. */
emoji: string
}
export interface Text {
readonly type: 'text'
/** The content of the message */
readonly content: string
}
export interface Media {
/** Location of media data */
url: string
/** MIME type */
mimeType: string
/** [Optional] Caption */
caption?: string
}
export interface Image extends Media {
readonly type: 'image'
/** [Optional] Image description */
description?: string
}
export interface Audio extends Media {
readonly type: 'audio'
/** [Optional] transcript of the audio */
transcript?: string
}
export interface Video extends Media {
readonly type: 'video'
/** [Optional] transcript of the audio */
transcript?: string
}
export interface Document extends Media {
readonly type: 'document'
/** [Optional] description of the document */
description?: string
/** [Optional] Name for the file on the device. */
filename?: string
}
import type {
Action,
Audio,
Custom,
Document,
Image,
Location,
Reaction,
Text,
Video,
} from './chat-message.js'
/**
* Chat request with incoming message.
*/
export type ChatRequest = {
/** Arrival time of message */
readonly timestamp: number
/** Id of message */
readonly id: string
/** Id of sender */
readonly from: string
/** Source of the request, i.e. name of the server */
readonly source: string
/** [Optional] Id of message that this message is a reply to. */
readonly replyTo?: string
} & (
| Text
| Action
| Reaction
| Location
| Image
| Audio
| Video
| Document
| Custom
)
import type { ChatMessage } from './chat-message.js'
/**
* Chat response.
*/
// eslint-disable-next-line ts/no-unsafe-declaration-merging
export interface ChatResponse extends NodeJS.EventEmitter<ChatResponseEvents> {
/** Messages to send as response. */
readonly messages: ChatMessage[]
/** True if no middleware called end. */
readonly isWritable: Readonly<boolean>
/** Write a message. */
write: (msg: string | ChatMessage) => void
/** End the response, optionally with a message. */
end: (msg?: string | ChatMessage) => void
}
export interface ChatResponseEvents {
finished: [ChatResponse]
write: [ChatMessage]
}
/**
* Chat response.
*/
// eslint-disable-next-line ts/no-unsafe-declaration-merging
export declare class ChatResponse implements NodeJS.EventEmitter<ChatResponseEvents>, ChatResponse {
/**
* Create a new chat response.
*
* @param onFinished
* [Optional] Handler to be called, when response `end()` is called.
*/
constructor(onFinished?: (r: ChatResponse) => void)
readonly messages: ChatMessage[]
readonly isWritable: Readonly<boolean>
write: (msg: string | ChatMessage) => void
end: (msg?: string | ChatMessage | undefined) => void
}
import { EventEmitter } from 'node:events'
/**
* @typedef {import('./chat-message.d.ts').ChatMessage} ChatMessage
* @typedef {import('./chat-response.d.ts').ChatResponse} IChatResponse
*/
/**
* @class
* @extends {EventEmitter<import('./chat-response.d.ts').ChatResponseEvents>}
* @implements {IChatResponse}
*/
export class ChatResponse extends EventEmitter {
/** @type {ChatMessage[]} */
#messages = []
#finished = false
get messages() {
return this.#messages
}
get isWritable() {
return !this.#finished
}
/** @param {string | ChatMessage} [msg] */
end(msg) {
this.write(msg)
this.#finished = true
this.emit('finished', this)
}
/** @param {string | ChatMessage} [msg] */
write(msg) {
if (!msg)
return
if (this.#finished) {
throw new Error('Cannot write anymore, response is finished.')
}
if (typeof msg === 'string') {
msg = { type: 'text', content: msg }
}
if (Array.isArray(msg)) {
this.#messages.push(...msg)
} else {
this.#messages.push(msg)
}
this.emit('write', msg)
}
}
import type { Logger } from '@chatally/logger'
export interface IMediaServer {
/**
* Load a media asset and provide it's raw data as Buffer
*
* @param media Media meta-data that contains a URL.
* @returns The raw data
*/
load: (media: { url: string }) => Promise<Buffer>
}
export declare class MediaServer implements IMediaServer {
constructor(options?: MediaOptions)
load: (media: { url: string }) => Promise<Buffer>
addServer(d: Download): void
}
export interface Download {
canDownload: (url: string) => boolean
download: (url: string) => Promise<Buffer>
}
export interface MediaOptions {
log?: Logger
cache?: false
dir?: string
ttl?: number
ttl2?: number
}
import fss from 'node:fs'
import fs from 'node:fs/promises'
import { NoLogger } from '@chatally/logger'
import { nanoid } from 'nanoid'
import NodeCache from 'node-cache'
const __cwd = new URL(`file://${process.cwd()}/`)
export class MediaServer {
/** @type {import('@chatally/logger').Logger} */
#log
/** @type {string} */
#dir
/** @type {NodeCache | undefined} */
#cache
/** @type {number} */
#ttl = 0
/** @type {number} */
#ttl2 = 0
/** @type {import('./media.d.ts').Download[]} */
#servers = []
/**
* @param {import('./media.d.ts').MediaOptions} [config]
*/
constructor(config = {}) {
this.#log = config.log || new NoLogger()
this.#dir = config.dir || 'media-cache'
fss.mkdirSync(this.#dir, { recursive: true })
if (config.cache === false || (config.ttl === 0 && config.ttl2 === 0)) {
this.#cache = undefined
} else {
this.#ttl = config.ttl ?? 0
this.#ttl2 = config.ttl2 ?? 0
this.#cache = new NodeCache({
stdTTL: this.#ttl,
checkperiod: this.#ttl * 1.2,
useClones: false,
deleteOnExpire: false,
})
this.#cache?.on('expired', async (key, value) => {
if (value instanceof Buffer) {
if (this.#ttl2 > 0) {
const oldFile = this.#cache?.get(`---${key}`)
if (oldFile) {
this.#cache?.set(key, oldFile, this.#ttl2)
this.#cache?.del(`---${key}`)
} else {
// Move buffer to second-level cache (file-system)
const file = new URL(`${this.#dir}/${nanoid()}`, __cwd)
try {
await fs.writeFile(file, value)
this.#cache?.set(key, file.toString(), this.#ttl2)
} catch (e) {
this.#log.error(e)
}
}
} else {
// Second-level cache deactivated
this.#cache?.del(key)
}
} else {
// Second-level cache expired
this.#cache?.del(key)
try {
await fs.unlink(value)
} catch (e) {
this.#log.error(e)
}
}
})
}
// Self-register for 'file:' protocol
this.#servers.push(this)
}
/**
*
* @param {import('./media.d.ts').Download} server
*/
addServer(server) {
this.#servers.push(server)
}
/**
* @param {string} url
* @returns {boolean} True if this is a file URL
*/
canDownload(url) {
return url.startsWith('file:')
}
/**
* @param {string} url
* @returns {Promise<Buffer>} The raw data from the file
*/
async download(url) {
if (!this.canDownload(url)) {
throw new Error(`Cannot read ${url.toString()}, only 'file:' protocol is supported by default.`)
}
return await fs.readFile(url)
}
/**
* @param {import("./chat-message.d.ts").Media} media
* @returns {Promise<Buffer>} The raw data
*/
async load(media) {
const key = media.url
const cached = this.#cache?.get(key)
if (cached instanceof Buffer) {
// Refresh ttl
this.#cache?.ttl(key, this.#ttl)
// Refresh potential ttl2
this.#cache?.ttl(`---${key}`, this.#ttl2)
return cached
} else if (typeof cached === 'string') {
const value = await fs.readFile(cached)
if (this.#ttl > 0) {
// Move to first-level cache
this.#cache?.set(key, value, this.#ttl)
// Keep second cache entry for file
this.#cache?.set(`---${key}`, cached, this.#ttl2)
} else {
// Refresh ttl2
this.#cache?.ttl(key, this.#ttl2)
}
return value
}
const server = this.#servers.find(s => s.canDownload(key))
if (!server) {
throw new Error(`No server registered, which could download ${key}`)
}
const value = await server.download(key)
if (this.#ttl) {
this.#cache?.set(key, value, this.#ttl)
} else if (this.#ttl2) {
const file = new URL(`${this.#dir}/${nanoid()}`, __cwd)
await fs.writeFile(file, value)
this.#cache?.set(key, file.toString(), this.#ttl2)
}
return value
}
}
+1
-4
> @chatally/core@0.0.9 lint
> @chatally/core@0.0.10 lint
> eslint .
{
project: '/home/runner/work/chatally/chatally/packages/core/tsconfig.json'
}
+26
-27
> @chatally/core@0.0.9 test
> @chatally/core@0.0.10 test
> vitest run

@@ -8,31 +8,30 @@

stdout | lib/application.test.js > Application > dispatches to middleware
[09:53:53.145] INFO (@chatally/core): Registered middleware 'echo'
[15:15:12.806] INFO (@chatally/core): Registered middleware 'echo'
[15:15:12.817] INFO (@chatally/core): Registered middleware 'a'
[15:15:12.818] INFO (@chatally/core): Registered middleware 'b'
[15:15:12.818] INFO (@chatally/core): Registered middleware 'c'
[15:15:12.818] INFO (@chatally/core): Registered middleware 'd'
[15:15:12.818] INFO (@chatally/core): Registered middleware 'e'
[15:15:12.820] INFO (@chatally/core): Registered middleware 'echo'
[15:15:12.820] WARN (@chatally/core): ⚠️ For better traceability, prefer using named functions
instead of arrow functions or provide an optional
'name' parameter when registering it with 'use'.
[15:15:12.820] INFO (@chatally/core): Registered middleware '<unnamed>'
[15:15:12.821] INFO (@chatally/core): Registered middleware 'echo'
[15:15:12.821] INFO (@chatally/core): Registered middleware 'throws'
[15:15:12.822] INFO (@chatally/core): Registered middleware 'echo'
[15:15:12.822] WARN (@chatally/core): ⚠️ For better traceability, prefer using named functions
instead of arrow functions or provide an optional
'name' parameter when registering it with 'use'.
[15:15:12.822] INFO (@chatally/core): Registered middleware '<unnamed>'
[15:15:12.822] WARN (@chatally/core): ⚠️ For better traceability, prefer using named functions
instead of arrow functions or provide an optional
'name' parameter when registering it with 'use'.
[15:15:12.822] INFO (@chatally/core): Registered middleware '<unnamed>'
✓ lib/application.test.js  (7 tests) 22ms
stdout | lib/application.test.js > Application > dispatches in order of registration
[09:53:53.150] INFO (@chatally/core): Registered middleware 'a'
[09:53:53.150] INFO (@chatally/core): Registered middleware 'b'
[09:53:53.150] INFO (@chatally/core): Registered middleware 'c'
[09:53:53.151] INFO (@chatally/core): Registered middleware 'd'
[09:53:53.151] INFO (@chatally/core): Registered middleware 'e'
stdout | lib/application.test.js > Application > catches sync middleware errors
[09:53:53.153] INFO (@chatally/core): Registered middleware 'echo'
[09:53:53.153] INFO (@chatally/core): Registered middleware 'throws'
stdout | lib/application.test.js > Application > catches async middleware errors
[09:53:53.154] INFO (@chatally/core): Registered middleware 'echo'
[09:53:53.154] INFO (@chatally/core): Registered middleware 'throws'
stdout | lib/application.test.js > Application > exposes errors if demanded
[09:53:53.155] INFO (@chatally/core): Registered middleware 'echo'
[09:53:53.155] INFO (@chatally/core): Registered middleware 'throws'
[09:53:53.155] INFO (@chatally/core): Registered middleware 'throws'
✓ lib/application.test.js  (7 tests) 15ms
 Test Files  1 passed (1)
 Tests  7 passed (7)
 Start at  09:53:52
 Duration  847ms (transform 151ms, setup 0ms, collect 138ms, tests 15ms, environment 0ms, prepare 183ms)
 Start at  15:15:11
 Duration  1.09s (transform 269ms, setup 0ms, collect 317ms, tests 22ms, environment 0ms, prepare 322ms)
# @chatally/core
## 0.0.10
### Patch Changes
- 457fb65: Refactored media handling
- Updated dependencies [457fb65]
- @chatally/logger@0.0.8
## 0.0.9

@@ -4,0 +12,0 @@

@@ -1,7 +0,8 @@

import type { Level, Logger } from "@chatally/logger";
import type { EventEmitter } from "node:events";
import type { Context, Middleware } from "./middleware.d.ts";
import type { Server } from "./server.d.ts";
import { IResponse } from "./response.js";
import { IRequest } from "./request.js";
import type { EventEmitter } from 'node:events'
import type { Level, Logger, LoggerOptions } from '@chatally/logger'
import type { ChatRequest } from './chat-request.d.ts'
import type { ChatResponse } from './chat-response.d.ts'
import type { MediaOptions } from './media.js'
import type { Context, Middleware } from './middleware.d.ts'
import type { Server } from './server.d.ts'

@@ -17,6 +18,6 @@ /**

/**
* Create a Chatally application that dispatches incoming chat requests from
* Create a ChatAlly application that dispatches incoming chat requests from
* all registered servers to all registered middleware.
*/
constructor(options?: ApplicationOptions<D>);
constructor(options?: ApplicationOptions<D>)

@@ -37,3 +38,3 @@ /**

*/
use(m: Middleware<D> | Server, name?: string): this;
use(m: Middleware<D> | Server, name?: string): this

@@ -51,3 +52,3 @@ /**

*/
dispatch(req: IRequest, res: IResponse): Promise<void>;
dispatch(req: ChatRequest, res: ChatResponse): Promise<void>

@@ -59,3 +60,3 @@ /**

*/
listen(): void;
listen(): void

@@ -69,8 +70,8 @@ /**

*/
getLogger(name: string, level?: Level): Logger;
getLogger(name: string, level?: Level): Logger
}
type ApplicationEvents<D> = {
error: [Error & Record<string, unknown>, Omit<Context<D>, "next">];
};
interface ApplicationEvents<D> {
error: [Error & Record<string, unknown>, Omit<Context<D>, 'next'>]
}

@@ -83,3 +84,3 @@ export interface ApplicationOptions<D> {

*/
data?: D;
data?: D

@@ -89,6 +90,6 @@ /**

*
* [`default=new BaseLogger()`] If you want to turn of logging use `NoLogger`
* or `false`.
* [`default=new BaseLogger()`]
* If you want to turn of logging use `NoLogger` or `false`.
*/
log?: Logger | false;
log?: false | LoggerOptions | Logger

@@ -100,3 +101,5 @@ /**

*/
dev?: boolean;
dev?: boolean
media?: MediaOptions
}

@@ -1,11 +0,10 @@

import { BaseLogger, NoLogger } from "@chatally/logger";
import { EventEmitter } from "node:events";
import { isServer } from "./server.js";
import { BaseLogger, NoLogger, isLogger } from '@chatally/logger'
// eslint-disable-next-line import/order
import { EventEmitter } from 'node:events'
import { MediaServer } from './media.js'
import { isServer } from './server.js'
/**
* @typedef {import("./server.d.ts").Server} Server
* @typedef {import("./request.d.ts").IRequest} IRequest
* @typedef {import("./response.d.ts").IResponse} IResponse
* @typedef {import("@chatally/logger").Logger} Logger
* @typedef {import("@chatally/logger").Level} Level
* @typedef {import('./server.d.ts').Server} Server
* @typedef {import('@chatally/logger').Logger} Logger
*/

@@ -15,3 +14,3 @@

* @template {Record<string, unknown>} D
* @typedef {import("./middleware.d.ts").Middleware<D>} Middleware
* @typedef {import('./middleware.d.ts').Middleware<D>} Middleware
*/

@@ -21,3 +20,3 @@

* @template {Record<string, unknown>} D
* @typedef {import("./middleware.d.ts").Context<D>} Context
* @typedef {import('./middleware.d.ts').Context<D>} Context
*/

@@ -33,4 +32,5 @@

* Main logger for the application
* @type {Logger} */
#log;
* @type {Logger}
*/
#log

@@ -41,3 +41,3 @@ /**

*/
#middlewares = [];
#middlewares = []

@@ -48,9 +48,15 @@ /**

*/
#servers = [];
#servers = []
/**
* Media server
* @type {MediaServer}
*/
#media
/**
* Child loggers, one for each middleware
* @type {Logger[]}
*/
#middlewareLogs = [];
#middlewareLogs = []

@@ -61,21 +67,30 @@ /**

*/
#data;
#data
/**
* @param {import("./application.js").ApplicationOptions<D>} [options]
* @param {import('./application.js').ApplicationOptions<D>} [options]
*/
constructor(options = {}) {
super();
this.#data = options.data;
if (options.log === undefined) {
const level =
// eslint-disable-next-line turbo/no-undeclared-env-vars
options.dev || process.env.NODE_ENV === "development"
? "debug"
: "info";
this.#log = new BaseLogger({ level, name: "@chatally/core" });
super()
this.#data = options.data
if (options.log === false) {
this.#log = new NoLogger()
} else if (isLogger(options.log)) {
this.#log = options.log
} else {
this.#log = options.log || new NoLogger();
const config = typeof options.log === 'object' ? options.log : {}
const name = config.name || '@chatally/core'
const level
= config.level || options.dev || process.env.NODE_ENV === 'development'
? 'debug'
: 'info'
const data = config.data
this.#log = new BaseLogger({ level, name, data })
}
this.#log.debug("Application logging level: %s", this.#log.level);
this.#log.debug('Application logging level: %s', this.#log.level)
this.#media = new MediaServer({
log: this.#log.child({ name: 'Media' }),
...options.media,
})
}

@@ -85,6 +100,6 @@

* @param {string} name
* @param {Level} [level]
* @param {import('@chatally/logger').Level} [level]
*/
getLogger(name, level) {
return this.#log.child({ name, level });
return this.#log.child({ name, level })
}

@@ -94,28 +109,30 @@

* @param {Middleware<D> | Server} module
* @param {String} [name]
* @param {string} [name]
*/
use(module, name) {
name ??= module.name;
name ??= module.name
if (!name) {
name = "<unnamed>";
name = '<unnamed>'
this.#log.warn(`⚠️ For better traceability, prefer using named functions
instead of arrow functions or provide an optional
'name' parameter when registering it with 'use'.`);
'name' parameter when registering it with 'use'.`)
}
if (isServer(module)) {
module.on("dispatch", this.dispatch.bind(this));
module.on('dispatch', this.dispatch.bind(this))
if (module.log === undefined) {
module.log = this.#log.child({ name: module.name });
module.log = this.#log.child({ name: module.name })
}
this.#servers.push(module);
this.#log.info("Registered server '%s'", name);
} else if (typeof module === "function") {
this.#middlewareLogs.push(this.#log.child({ name }));
this.#middlewares.push(module);
this.#log.info("Registered middleware '%s'", name);
this.#servers.push(module)
this.#media.addServer(module)
this.#log.info('Registered server \'%s\'', name)
} else if (typeof module === 'function') {
this.#middlewareLogs.push(this.#log.child({ name }))
this.#middlewares.push(module)
this.#log.info('Registered middleware \'%s\'', name)
} else {
throw new TypeError(`Ineffective application module '${name}'.
Middleware must be a function, servers must provide a method 'listen' and a method 'on' to register an event callback.`);
Middleware must be a function, servers must provide a method 'listen' and a method 'on' to register an event callback.`)
}
return this;
return this
}

@@ -127,6 +144,6 @@

* This asynchronous method is used internally to trigger handling the
* "dispatch" events from registered servers. It decouples the servers' event
* 'dispatch' events from registered servers. It decouples the servers' event
* loop from message handling. The method resolves after the last middleware
* finished, but a server could send responses earlier, by registering the
* `on("write")` event on the response.
* `on('write')` event on the response.
*

@@ -138,27 +155,28 @@ * The application creates a context from request and response and passes it

*
* @param {IRequest} req
* @param {IResponse} res
* @param {import('./chat-request.d.ts').ChatRequest} req
* @param {import('./chat-response.d.ts').ChatResponse} res
*/
async dispatch(req, res) {
const data = Object.assign(Object.create(this.#data || {}), this.#data);
const log = this.#log;
const context = { req, res, data, log };
const data = Object.assign(Object.create(this.#data || {}), this.#data)
const media = this.#media
const log = this.#log
const context = { req, res, media, data, log }
try {
let current = 0;
let current = 0
const next = async () => {
while (current < this.#middlewares.length) {
try {
const log = this.#middlewareLogs[current];
await this.#middlewares[current++]({ ...context, log, next });
const log = this.#middlewareLogs[current]
await this.#middlewares[current++]({ ...context, log, next })
} catch (err) {
this.#handleError(err, context);
this.#handleError(err, context)
}
}
};
await next();
}
await next()
if (res.isWritable) {
res.end();
res.end()
}
} catch (err) {
this.#handleError(err, context);
this.#handleError(err, context)
}

@@ -170,3 +188,3 @@ }

*
* You can modify this behavior by registering a callback to the "error"
* You can modify this behavior by registering a callback to the 'error'
* event. If you rethrow an error, this will end the dispatch loop and be

@@ -180,6 +198,6 @@ * handed back to your error handler. If you rethrow it again, it will crash

* ```js
* .on("error", (err, { log }) => {
* if (err.message !== "STOP_DISPATCHING") {
* .on('error', (err, { log }) => {
* if (err.message !== 'STOP_DISPATCHING') {
* log.error(err);
* throw new Error("STOP_DISPATCHING");
* throw new Error('STOP_DISPATCHING');
* }

@@ -190,3 +208,3 @@ * })

* @param {unknown} err
* @param {Omit<Context<D>, "next">} context
* @param {Omit<Context<D>, 'next'>} context
*/

@@ -196,12 +214,12 @@ #handleError(err, context) {

function uncaught(err) {
context.log.error("Uncaught", err);
context.log.error('Uncaught', err)
}
if (err instanceof Error) {
if (this.listenerCount("error") === 0) {
uncaught(err);
if (this.listenerCount('error') === 0) {
uncaught(err)
} else {
this.emit("error", err, context);
this.emit('error', err, context)
}
} else {
uncaught(err);
uncaught(err)
}

@@ -214,11 +232,11 @@ }

listen() {
for (let server of this.#servers) {
for (const server of this.#servers) {
// overcome blocking `listen()` calls
new Promise((res) => {
server.listen();
res(undefined);
new Promise((_resolve) => {
server.listen()
_resolve(undefined)
// TODO: Use this.#handleError instead
}).catch((err) => this.#log.error(err));
}).catch(err => this.#log.error(err))
}
}
}

@@ -1,131 +0,165 @@

import { BaseLogger } from "@chatally/logger";
import { StringWritable, TestError } from "@internal/test-utils";
import { Application } from "./application.js";
import { Request } from "./request.js";
import { Response } from "./response.js";
import { BaseLogger } from '@chatally/logger'
import { StringWritable, TestError } from '@internal/test-utils'
import { nanoid } from 'nanoid'
import { Application } from './application.js'
import { ChatResponse } from './chat-response.js'
/** @type {import("./middleware.d.ts").Middleware<unknown>} */
/** @type {import('./middleware.d.ts').Middleware<unknown>} */
const echo = ({ req, res }) => {
if (res.isWritable && req.message.type === "text") {
res.write(`Echo: '${req.message.text}'`);
if (res.isWritable && req.type === 'text') {
res.write(`Echo: '${req.content}'`)
}
};
}
describe("Application", function () {
it("dispatches to middleware", async () => {
const app = new Application().use(echo);
describe('application', () => {
it('dispatches to middleware', async () => {
const app = new Application().use(echo)
const res = new Response();
await app.dispatch(new Request("test: foo"), res);
expect(res.text).toStrictEqual(["Echo: 'foo'"]);
});
const res = new ChatResponse()
await app.dispatch(textRequest('foo', 'test'), res)
const actual = res.messages.map(toText)
expect(actual).toStrictEqual(['Echo: \'foo\''])
})
it("dispatches in order of registration", async () => {
it('dispatches in order of registration', async () => {
const app = new Application()
.use(function a({ res }) {
// should run before all middlewares
res.write("a");
res.write('a')
})
.use(async function b({ res, next }) {
// should run after all following middlewares
await next();
res.write("b");
await next()
res.write('b')
})
.use(async function c({ res, next }) {
// should run before and after all following middlewares
res.write("c-pre");
await next();
res.write("c-post");
res.write('c-pre')
await next()
res.write('c-post')
})
.use(async function d({ res }) {
await new Promise((resolve) => setTimeout(resolve, 1));
res.write("d");
await new Promise(resolve => setTimeout(resolve, 1))
res.write('d')
})
.use(function e({ res }) {
res.write("e");
});
res.write('e')
})
const res = new Response();
await app.dispatch(new Request("test: foo"), res);
expect(res.text).toStrictEqual(["a", "c-pre", "d", "e", "c-post", "b"]);
});
const res = new ChatResponse()
await app.dispatch(textRequest('foo', 'test'), res)
const actual = res.messages.map(toText)
expect(actual).toStrictEqual(['a', 'c-pre', 'd', 'e', 'c-post', 'b'])
})
it("catches sync middleware errors", async () => {
it('catches sync middleware errors', async () => {
/** @type {Error} */
let error = new Error("Init");
let error = new Error('Init')
const app = new Application()
.use(echo)
.use(function throws() {
throw new Error("Boom");
.use(() => {
throw new Error('Boom')
})
.on("error", (err) => {
error = err;
});
const res = new Response();
await app.dispatch(new Request("test: foo"), res);
expect(res.text).toStrictEqual(["Echo: 'foo'"]);
expect(error?.message).toBe("Boom");
});
.on('error', (err) => {
error = err
})
const res = new ChatResponse()
await app.dispatch(textRequest('foo', 'test'), res)
const actual = res.messages.map(toText)
expect(actual).toStrictEqual(['Echo: \'foo\''])
expect(error?.message).toBe('Boom')
})
it("catches async middleware errors", async () => {
it('catches async middleware errors', async () => {
async function throwsAsync() {
throw new Error("Boom Async");
throw new Error('Boom Async')
}
const app = new Application().use(echo).use(function throws({ res }) {
throwsAsync().catch((e) => res.write(e.message));
res.write("First");
});
const res = new Response();
await app.dispatch(new Request("test: foo"), res);
expect(res.text).toStrictEqual(["Echo: 'foo'", "First", "Boom Async"]);
});
const app = new Application()
.use(echo)
.use(function throws({ res }) {
throwsAsync().catch(e => res.write(e.message))
res.write('First')
})
const res = new ChatResponse()
await app.dispatch(textRequest('foo', 'test'), res)
const actual = res.messages.map(toText)
expect(actual).toStrictEqual(['Echo: \'foo\'', 'First', 'Boom Async'])
})
it("exposes errors if demanded", async () => {
it('exposes errors if demanded', async () => {
const app = new Application()
.use(echo)
.use(function throws() {
throw new Error("Bang");
.use(() => {
throw new Error('Bang')
})
.use(function throws() {
throw new TestError("Boom", { expose: true });
.use(() => {
throw new TestError('Boom', { expose: true })
})
.on("error", (err, { res }) => {
.on('error', (err, { res }) => {
if (err.expose) {
res.write(err.message);
res.write(err.message)
}
});
})
const res = new Response();
await app.dispatch(new Request("test: foo"), res);
expect(res.text).toStrictEqual(["Echo: 'foo'", "Boom"]);
});
const res = new ChatResponse()
await app.dispatch(textRequest('foo', 'test'), res)
const actual = res.messages.map(toText)
expect(actual).toStrictEqual(['Echo: \'foo\'', 'Boom'])
})
it("warns about unnamed middleware", async () => {
const out = new StringWritable();
const log = new BaseLogger({ name: "root", level: "warn" });
log.out = out;
log.timestamp = false;
it('warns about unnamed middleware', async () => {
const log = new BaseLogger({ name: 'root', level: 'warn' })
const logged = new StringWritable()
log.out = logged
log.timestamp = false
new Application({ log })
// unnamed middleware
.use(() => {});
expect(out.data.startsWith("WARN (root):")).toBeTruthy();
});
.use(() => { })
expect(logged.data.startsWith('WARN (root):')).toBeTruthy()
})
it("logs middleware output", async () => {
const out = new StringWritable();
const log = new BaseLogger({ level: "warn" });
log.out = out;
log.timestamp = false;
it('logs middleware output', async () => {
const log = new BaseLogger({ level: 'warn' })
const logged = new StringWritable()
log.out = logged
log.timestamp = false
const app = new Application({ log }) //
.use(function logs({ log }) {
log.level = "debug";
log.debug("Hello");
});
log.level = 'debug'
log.debug('Hello')
})
const res = new Response();
await app.dispatch(new Request("test: foo"), res);
expect(out.data).toBe("DEBUG (logs): Hello\n");
});
});
const res = new ChatResponse()
await app.dispatch(textRequest('foo', 'test'), res)
expect(logged.data).toBe('DEBUG (logs): Hello\n')
})
})
/**
* @param {any} content
* @param {any} from
* @returns {import('./chat-request.js').ChatRequest}
* Text request with the given content
*/
function textRequest(content, from) {
return {
source: 'test',
type: 'text',
content,
timestamp: Date.now(),
from,
id: nanoid(),
}
}
/**
* @param {import('./chat-message.js').ChatMessage} msg
*/
function toText(msg) {
if (msg.type === 'text') {
return msg.content
}
return `<${msg.type}>`
}

@@ -1,11 +0,7 @@

export { Application } from "./application.js";
export { Request } from "./request.js";
export { Response } from "./response.js";
export type { Middleware } from "./middleware.d.ts";
export type * from "./message.d.ts";
export type { Server } from "./server.d.ts";
export { BaseServer } from "./server.js";
export { text } from "./text.js";
export { Application } from './application.js'
export type * from './chat-message.js'
export type { ChatRequest } from './chat-request.d.ts'
export type { ChatResponse } from './chat-response.js'
export type { Middleware } from './middleware.d.ts'
export type { Server } from './server.d.ts'
export { BaseServer } from './server.js'

@@ -1,5 +0,3 @@

export { Application } from "./application.js";
export { Request } from "./request.js";
export { Response } from "./response.js";
export { BaseServer } from "./server.js";
export { text } from "./text.js";
export { Application } from './application.js'
export { ChatResponse } from './chat-response.js'
export { BaseServer } from './server.js'

@@ -1,4 +0,5 @@

import type { Logger } from "@chatally/logger";
import type { IRequest } from "./request.d.ts";
import type { IResponse } from "./response.d.ts";
import type { Logger } from '@chatally/logger'
import type { ChatRequest } from './chat-request.d.ts'
import type { ChatResponse } from './chat-response.d.ts'
import type { IMediaServer } from './media.js'

@@ -9,4 +10,4 @@ /**

export type Middleware<D> =
| ((params: Context<D>) => void | unknown)
| ((params: Context<D>) => Promise<void | unknown>);
| ((params: Context<D>) => unknown)
| ((params: Context<D>) => Promise<unknown>)

@@ -18,11 +19,13 @@ /**

/** Request that triggered the handling */
readonly req: IRequest;
readonly req: ChatRequest
/** Response for the request */
readonly res: IResponse;
readonly res: ChatResponse
/** Access to media assets */
readonly media: IMediaServer
/** Trigger dispatching to the next middleware. */
readonly next: () => Promise<void>;
readonly next: () => Promise<void>
/** Context-specific logger for the middleware */
readonly log: Logger;
readonly log: Logger
/** Arbitrary data storage */
readonly data: D & Record<string, unknown>;
readonly data: D & Record<string, unknown>
}

@@ -1,6 +0,6 @@

import type { Logger } from "@chatally/logger";
import type { EventEmitter } from "node:events";
import type { IRequest } from "./request.d.ts";
import type { IResponse } from "./response.d.ts";
import { IncomingMessage, OutgoingMessage } from "./message.js";
import type { EventEmitter } from 'node:events'
import type { Logger } from '@chatally/logger'
import type { ChatMessage } from './chat-message.d.ts'
import type { ChatRequest } from './chat-request.d.ts'
import type { ChatResponse } from './chat-response.d.ts'

@@ -15,3 +15,3 @@ /**

/** The server's name */
name: string;
name: string

@@ -27,4 +27,4 @@ /**

*/
get log(): Logger | undefined;
set log(log: Logger | undefined);
get log(): Logger | undefined
set log(log: Logger | undefined)

@@ -37,3 +37,6 @@ /**

*/
listen: () => void;
listen: () => void
canDownload: (url: string) => boolean
download: (url: string) => Promise<Buffer>
}

@@ -43,9 +46,8 @@

extends EventEmitter<ServerEvents>
implements Server
{
name: string;
get log(): Logger | undefined;
set log(log: Logger | undefined);
constructor(name: string);
abstract listen(): void;
implements Server {
name: string
get log(): Logger | undefined
set log(log: Logger | undefined)
constructor(name: string)
abstract listen(): void
/**

@@ -61,14 +63,17 @@ * Dispatch an incoming message to all event listeners on the "dispatch"

dispatch(
incoming: IncomingMessage | string,
incoming: ChatRequest | string,
callbacks?: {
onWrite?: (msg: OutgoingMessage) => void;
onFinished?: (res: IResponse) => void;
onWrite?: (msg: ChatMessage) => void
onFinished?: (res: ChatResponse) => void
}
): void;
): void
canDownload(url: string): boolean
download(url: string): Promise<Buffer>
}
export type ServerEvents = {
dispatch: [IRequest, IResponse];
};
export interface ServerEvents {
dispatch: [ChatRequest, ChatResponse]
}
export function isServer(obj: unknown): obj is Server;
export function isServer(obj: unknown): obj is Server

@@ -1,27 +0,27 @@

import { EventEmitter } from "node:events";
import { Request } from "./request.js";
import { Response } from "./response.js";
import { EventEmitter } from 'node:events'
import { nanoid } from 'nanoid'
import { ChatResponse } from './chat-response.js'
/**
* @typedef {import("./message.d.ts").IncomingMessage} IncomingMessage
* @typedef {import("./message.d.ts").OutgoingMessage} OutgoingMessage
* @typedef {import('./chat-request.d.ts').ChatRequest} ChatRequest
* @typedef {import('./chat-message.d.ts').ChatMessage} ChatMessage
*/
/**
* @typedef {import("./server.d.ts").Server} Server
* @typedef {import('./server.d.ts').Server} Server
*
* @class
* @extends {EventEmitter<import("./server.d.ts").ServerEvents>}
* @extends {EventEmitter<import('./server.d.ts').ServerEvents>}
* @implements {Server}
*/
export class BaseServer extends EventEmitter {
/** @type {import("@chatally/logger").Logger | undefined} */
#log;
/** @type {import('@chatally/logger').Logger | undefined} */
#log
get log() {
return this.#log;
return this.#log
}
set log(log) {
this.#log = log;
this.#log = log
}

@@ -33,4 +33,4 @@

constructor(name) {
super();
this.name = name;
super()
this.name = name
}

@@ -40,35 +40,67 @@

throw new Error(
"The method `listen()` in BaseServer is abstract and must be overridden"
);
'The method `listen()` in BaseServer is abstract and must be overridden',
)
}
/**
* @param {string | IncomingMessage} incoming
* @param {string | ChatRequest} req
* @param {object} callbacks
* @param {((msg: OutgoingMessage) => void) | ((msg: OutgoingMessage) => Promise<void>)} [callbacks.onWrite]
* @param {((res: Response) => void) | ((res: Response) => Promise<void>)} [callbacks.onFinished]
* @param {((msg: ChatMessage) => void) | ((msg: ChatMessage) => Promise<void>)} [callbacks.onWrite]
* @param {((res: ChatResponse) => void) | ((res: ChatResponse) => Promise<void>)} [callbacks.onFinished]
*/
dispatch(incoming, { onWrite, onFinished }) {
const req = new Request(incoming);
const res = new Response();
dispatch(req, { onWrite, onFinished }) {
if (typeof req === 'string') {
let [from, ...rest] = req.split(': ')
let content = ''
if (rest.length === 0) {
content = from
from = 'unknown'
} else {
content = rest.join(': ')
}
req = /** @type {ChatRequest} */ {
type: 'text',
source: this.name,
id: nanoid(),
timestamp: Date.now(),
from,
content,
}
}
const res = new ChatResponse()
if (onWrite) {
res.on("write", async (msg) => {
res.on('write', async (msg) => {
try {
await onWrite(msg);
await onWrite(msg)
} catch (err) {
this.log?.error(err);
this.log?.error(err)
}
});
})
}
if (onFinished) {
res.on("finished", async (res) => {
res.on('finished', async (res) => {
try {
await onFinished(res);
await onFinished(res)
} catch (err) {
this.log?.error(err);
this.log?.error(err)
}
});
})
}
this.emit("dispatch", req, res);
this.emit('dispatch', req, res)
}
/**
* @param {string} _url
*/
canDownload(_url) {
return false
}
/**
* @param {string} _url
* @returns {Promise<Buffer>} Never returns, always throws an error
*/
async download(_url) {
throw new Error('Method not implemented')
}
}

@@ -78,11 +110,16 @@

* @param {any} object
* @returns {object is import("./index.d.ts").Server}
* @returns {object is import('./index.d.ts').Server}
* True if the provided object is a ChatAlly server
*/
export function isServer(object) {
if (!object) return false;
if (object instanceof BaseServer) return true;
if (!object)
return false
if (object instanceof BaseServer)
return true
if (typeof object.listen !== "function") return false;
if (typeof object.on !== "function") return false;
return true;
if (typeof object.listen !== 'function')
return false
if (typeof object.on !== 'function')
return false
return true
}
{
"name": "@chatally/core",
"version": "0.0.9",
"type": "module",
"version": "0.0.10",
"description": "ChatAlly Core Modules",

@@ -14,3 +15,2 @@ "license": "MIT",

],
"type": "module",
"exports": {

@@ -25,11 +25,12 @@ ".": {

"scripts": {
"tsc": "tsc",
"lint": "eslint .",
"repl": "nodemon repl.js",
"test": "vitest"
"lint:fix": "eslint . --fix",
"test": "vitest",
"tsc": "tsc"
},
"dependencies": {
"@chatally/logger": "*",
"nanoid": "^5.0.7"
"nanoid": "^5.0.7",
"node-cache": "^5.1.2"
}
}
}

@@ -17,13 +17,13 @@ # @chatally/core

// index.js
import { Application } from "@chatally/core";
import { ConsoleServer } from "@chatally/console";
import { Application } from '@chatally/core'
import { ConsoleServer } from '@chatally/console'
new Application({ log: false }) //
.use(new ConsoleServer())
.use(function echo({ req, res }) {
.use(({ req, res }) => {
if (res.isWritable) {
res.write(`You said '${req.text}'`);
res.write(`You said '${req.text}'`)
}
})
.listen();
.listen()
```

@@ -30,0 +30,0 @@

/**
* Generic message type (incoming and outgoing).
*/
export type Message = IncomingMessage | OutgoingMessage;
/**
* Incoming message.
*/
export type IncomingMessage = {
/** Arrival time of message */
timestamp: number;
/** Id of message */
id: string;
/** Id of sender */
from: string;
/** [Optional] Id of message that this message is a reply to. */
replyTo?: string;
} & (
| Action // Incoming only
| BidiMessage
);
/** Bidirectional message types */
type BidiMessage =
| Audio
| Custom
| Document
| Image
| Location
| Reaction
| Text
| Video;
/**
* Incoming message
*/
export type OutgoingMessage = {
/** [Optional] Id of message that this message is a reply to. */
readonly replyTo?: string;
} & (
| Buttons // Outgoing only
| Menu // Outgoing only
| BidiMessage
);
export type Action = {
readonly type: "action";
readonly action: _Action;
};
export type Audio = {
readonly type: "audio";
readonly audio: {
/** Location of audio data */
url: string;
/** MIME type */
mimeType: string;
/** [Optional] Caption */
caption?: string;
/** [Optional] transcript of the audio */
transcript?: string;
};
};
export type Buttons = {
readonly type: "buttons";
/** The content of the message */
readonly buttons: {
text: string;
actions: _Action[];
};
};
type _Action = {
id: string;
title: string;
description?: string;
};
export type Custom = {
readonly type: "custom";
readonly schema: string;
readonly custom: unknown;
};
export type Document = {
readonly type: "document";
readonly document: {
/** Location of document data */
url: string;
/** MIME type */
mimeType: string;
/** [Optional] Caption */
caption?: string;
/** [Optional] Name for the file on the device. */
filename?: string;
/** [Optional] description of the document */
description?: string;
};
};
export type Image = {
readonly type: "image";
readonly image: {
/** Location of image data */
url: string;
/** MIME type */
mimeType: string;
/** [Optional] Caption */
caption?: string;
/** [Optional] Image description */
description?: string;
};
};
export type Location = {
readonly type: "location";
readonly location: {
/** Longitude of the location. */
longitude: number;
/** Latitude of the location. */
latitude: number;
/** Name of the location. */
name?: string;
/** Address of the location. */
address?: string;
};
};
export type Menu = {
readonly type: "menu";
/** The content of the message */
readonly menu: {
title: string;
text: string;
sections: Array<{
title?: string;
actions: _Action[];
}>;
};
};
export type Reaction = {
readonly type: "reaction";
readonly reaction: {
/** The ID of the message the customer reacted to. */
replyTo: string;
/** The emoji the customer reacted with. */
emoji: string;
};
};
export type Text = {
readonly type: "text";
/** The content of the message */
readonly text: string;
};
export type Video = {
readonly type: "video";
readonly video: {
/** Location of video data */
url: string;
/** MIME type */
mimeType: string;
/** [Optional] Caption */
caption?: string;
/** [Optional] transcript of the video */
transcript?: string;
};
};
import type { IncomingMessage } from "./message.d.ts";
/**
* Chat request with incoming message
*/
export declare class Request implements IRequest {
/**
* Create a chat request for an incoming message.
*
* @param message Fully typed message or a string that can optionally contain
* a sender before a colon, i.e. `"<from>: <message>"` will create a text message with properties `from=<from>` and `text="<message>"`.
*/
constructor(message: string | IncomingMessage);
get message(): IncomingMessage;
get text(): string;
}
/**
* Chat request with incoming message.
*/
export interface IRequest {
/** Incoming message */
get message(): IncomingMessage;
/** Textual content of incoming message */
get text(): string;
}
import { nanoid } from "nanoid";
import { text } from "./text.js";
/**
* @typedef {import("./message.d.ts").IncomingMessage} IncomingMessage
*/
/**
* @type {import("./request.d.ts").Request}
*/
export class Request {
/** @type {IncomingMessage} */
#message;
/** @param {IncomingMessage | string} message */
constructor(message) {
if (typeof message === "string") {
let [from, text] = message.split(": ");
if (!text) {
text = from;
from = "";
}
this.#message = {
type: "text",
text,
timestamp: Date.now(),
from,
id: nanoid(),
};
} else {
this.#message = message;
}
}
get message() {
return this.#message;
}
get text() {
return text(this.#message);
}
}
import type { EventEmitter } from "node:events";
import type { OutgoingMessage } from "./message.js";
/**
* Chat response.
*/
export declare class Response
extends EventEmitter<ResponseEvents>
implements IResponse
{
/**
* Create a new chat response.
*
* @param onFinished
* [Optional] Handler to be called, when response `end()` is called.
*/
constructor(onFinished?: (r: Response) => void);
messages: OutgoingMessage[];
isWritable: Readonly<boolean>;
write: (msg: string | OutgoingMessage) => void;
end: (msg?: string | OutgoingMessage | undefined) => void;
get text(): string[];
}
type ResponseEvents = {
finished: [Response];
write: [OutgoingMessage];
};
/**
* Chat response.
*/
export interface IResponse {
/** Messages to send as response. */
messages: OutgoingMessage[];
/** True if no middleware called end. */
isWritable: Readonly<boolean>;
/** Write a message. */
write: (msg: string | OutgoingMessage) => void;
/** End the response, optionally with a message. */
end: (msg?: string | OutgoingMessage) => void;
/** Textual content of outgoing messages */
get text(): string[];
}
import { EventEmitter } from "node:events";
import { text } from "./text.js";
/**
* @typedef {import("./message.d.ts").OutgoingMessage} OutgoingMessage
*/
/** @type {import("./response.d.ts").Response} */
export class Response extends EventEmitter {
/** @type {OutgoingMessage[]} */
#messages = [];
#finished = false;
constructor() {
super();
}
get messages() {
return this.#messages;
}
get isWritable() {
return !this.#finished;
}
/** @param {string | OutgoingMessage} [msg] */
end(msg) {
this.write(msg);
this.#finished = true;
this.emit("finished", this);
}
/** @param {string | OutgoingMessage} [msg] */
write(msg) {
if (!msg) return;
if (this.#finished) {
throw new Error("Cannot write anymore, response is finished.");
}
if (typeof msg === "string") {
msg = { type: "text", text: msg };
}
if (Array.isArray(msg)) {
this.#messages.push(...msg);
} else {
this.#messages.push(msg);
}
this.emit("write", msg);
}
get text() {
return this.#messages.map(text);
}
}
import type { Message } from "./message.d.ts";
export declare function text(message: Message): string;
/**
* @param {import("./message.d.ts").Message} msg
* @returns {string}
*/
export function text(msg) {
switch (msg.type) {
case "audio":
return (
msg.audio.transcript ||
msg.audio.caption ||
`audio: ${msg.audio.url} (${msg.audio.mimeType})`
);
case "buttons":
return (
msg.buttons.text ||
msg.buttons.actions.map((a) => a.title).join(", ") ||
"<buttons>"
);
case "custom":
return "custom:" + JSON.stringify(msg.custom);
case "document":
return (
msg.document.description ||
msg.document.caption ||
msg.document.filename ||
"<document>"
);
case "image":
return (
msg.image.description ||
msg.image.caption ||
`image: ${msg.image.url} (${msg.image.mimeType}))`
);
case "location":
return (
msg.location.name ||
msg.location.address ||
`location: lon ${msg.location.longitude} lat ${msg.location.latitude}`
);
case "menu":
return `${msg.menu.title}: ${msg.menu.text}`;
case "reaction":
return msg.reaction.emoji;
case "action":
return `${msg.action.id}: ${msg.action.title}`;
case "text":
return msg.text;
case "video":
return (
msg.video.transcript ||
msg.video.caption ||
`video: ${msg.video.url} (${msg.video.mimeType})`
);
}
// return "unknown:" + JSON.stringify(msg);
}