diff --git a/demo/backend/nexus.config.ts b/demo/backend/nexus.config.ts index 89f3ce6..ab5f8e9 100644 --- a/demo/backend/nexus.config.ts +++ b/demo/backend/nexus.config.ts @@ -7,15 +7,17 @@ import { linear } from '@bitlerjs/nexus-linear'; import { homeassistant } from '@bitlerjs/nexus-homeassistant'; import { notes } from '@bitlerjs/nexus-notes'; import { timers } from '@bitlerjs/nexus-timers'; +import { signal } from '@bitlerjs/nexus-signal'; +import { notifications } from '@bitlerjs/nexus-notifications'; import { todos } from './src/extension.js'; const config = defineConfig({ oidc: process.env.OIDC_ISSUER_URL ? { - issuerUrl: process.env.OIDC_ISSUER_URL, - clientId: process.env.OIDC_CLIENT_ID, - } + issuerUrl: process.env.OIDC_ISSUER_URL, + clientId: process.env.OIDC_CLIENT_ID, + } : undefined, extensions: [ defineExtension(todos, {}), @@ -23,6 +25,8 @@ const config = defineConfig({ defineExtension(llmExtension, { defaultModel: 'openai.gpt-4o-mini', }), + defineExtension(signal, {}), + defineExtension(notifications, {}), defineExtension(linear, {}), defineExtension(openai, { kind: 'openai', diff --git a/demo/backend/package.json b/demo/backend/package.json index bf36e67..81f09c7 100644 --- a/demo/backend/package.json +++ b/demo/backend/package.json @@ -27,6 +27,8 @@ "@bitlerjs/nexus-notes": "workspace:*", "@bitlerjs/nexus-timers": "workspace:*", "@bitlerjs/nexus-calendars": "workspace:*", + "@bitlerjs/nexus-signal": "workspace:*", + "@bitlerjs/nexus-notifications": "workspace:*", "dotenv": "^16.4.7" } } diff --git a/extensions/nexus-notifications/.gitignore b/extensions/nexus-notifications/.gitignore new file mode 100644 index 0000000..3d2bc62 --- /dev/null +++ b/extensions/nexus-notifications/.gitignore @@ -0,0 +1,2 @@ +/dist/ +/node_modules/ diff --git a/extensions/nexus-notifications/package.json b/extensions/nexus-notifications/package.json new file mode 100644 index 0000000..4cd85db --- /dev/null +++ b/extensions/nexus-notifications/package.json @@ -0,0 +1,22 @@ +{ + "name": "@bitlerjs/nexus-notifications", + "version": "1.0.0", + "main": "dist/exports.js", + "type": "module", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build", + "dev": "tsc --build --watch" + }, + "devDependencies": { + "@bitlerjs/nexus-config": "workspace:*", + "typescript": "^5.7.2" + }, + "dependencies": { + "@bitlerjs/nexus": "workspace:*", + "@bitlerjs/nexus-data": "workspace:*", + "nanoid": "^5.0.9" + } +} diff --git a/extensions/nexus-notifications/src/databases/databases.ts b/extensions/nexus-notifications/src/databases/databases.ts new file mode 100644 index 0000000..363075c --- /dev/null +++ b/extensions/nexus-notifications/src/databases/databases.ts @@ -0,0 +1,43 @@ +import { createDatabase, createMigration } from '@bitlerjs/nexus-data'; + +const init = createMigration({ + name: 'init', + up: async (knex) => { + await knex.schema.createTable('notifications', (table) => { + table.string('id').primary(); + table.string('title').notNullable(); + table.string('message').notNullable(); + table.timestamp('createdAt').defaultTo(knex.fn.now()); + }); + + await knex.schema.createTable('notificationActions', (table) => { + table.string('id').primary(); + table.string('title').notNullable(); + table.string('description').nullable(); + table.string('notificationId').notNullable().references('id').inTable('notifications').onDelete('CASCADE'); + table.string('kind').notNullable(); + table.boolean('removeNotification').defaultTo(false); + table.json('data').notNullable(); + }); + + await knex.schema.createTable('notificationEntities', (table) => { + table.string('notificationId').notNullable().references('id').inTable('notifications').onDelete('CASCADE'); + table.string('kind').notNullable(); + table.string('role').nullable(); + table.string('entityId').notNullable(); + table.primary(['notificationId', 'kind', 'entityId']); + }); + }, + down: async (knex) => { + await knex.schema.dropTable('notificationEntities'); + await knex.schema.dropTable('notificationActions'); + await knex.schema.dropTable('notifications'); + }, +}); + +const dbConfig = createDatabase({ + name: 'notifications', + migrations: [init], +}); + +export { dbConfig }; diff --git a/extensions/nexus-notifications/src/events/events.created.ts b/extensions/nexus-notifications/src/events/events.created.ts new file mode 100644 index 0000000..9758a31 --- /dev/null +++ b/extensions/nexus-notifications/src/events/events.created.ts @@ -0,0 +1,29 @@ +import { createEvent, Type } from '@bitlerjs/nexus'; + +const notificationCreatedEvent = createEvent({ + kind: 'notification.created', + name: 'Notification created', + description: 'A notification was created', + input: Type.Object({}), + output: Type.Object({ + id: Type.String(), + title: Type.String(), + message: Type.String(), + entities: Type.Array( + Type.Object({ + kind: Type.String(), + id: Type.String(), + role: Type.Optional(Type.String()), + }), + ), + actions: Type.Array( + Type.Object({ + id: Type.String(), + title: Type.String(), + description: Type.Optional(Type.String()), + }), + ), + }), +}); + +export { notificationCreatedEvent }; diff --git a/extensions/nexus-notifications/src/events/events.removed.ts b/extensions/nexus-notifications/src/events/events.removed.ts new file mode 100644 index 0000000..1be5398 --- /dev/null +++ b/extensions/nexus-notifications/src/events/events.removed.ts @@ -0,0 +1,18 @@ +import { createEvent, Type } from '@bitlerjs/nexus'; + +const notificationRemovedEvent = createEvent({ + kind: 'notification.removed', + name: 'Notification Removed', + description: 'A notification was removed', + input: Type.Object({ + ids: Type.Optional(Type.Array(Type.String())), + }), + output: Type.Object({ + id: Type.String(), + }), + filter: async ({ input, event }) => { + return input.ids?.includes(event.id) || false; + }, +}); + +export { notificationRemovedEvent }; diff --git a/extensions/nexus-notifications/src/exports.ts b/extensions/nexus-notifications/src/exports.ts new file mode 100644 index 0000000..794e240 --- /dev/null +++ b/extensions/nexus-notifications/src/exports.ts @@ -0,0 +1,28 @@ +import { createExtension, EventsService, TasksService } from '@bitlerjs/nexus'; + +import { notificationRemovedEvent } from './events/events.removed.js'; +import { notificationCreatedEvent } from './events/events.created.js'; +import { addNotificationTask } from './tasks/tasks.add.js'; +import { listNotificationTask } from './tasks/tasks.list.js'; +import { removeNotificationsTask } from './tasks/tasks.remove.js'; +import { runNotificationActionTask } from './tasks/tasks.run-action.js'; + +const tasks = { + addNotification: addNotificationTask, + listNotifications: listNotificationTask, + removeNotifications: removeNotificationsTask, + runNotificationAction: runNotificationActionTask, +}; + +const events = [notificationRemovedEvent, notificationCreatedEvent]; +const notifications = createExtension({ + setup: async ({ container }) => { + const tasksService = container.get(TasksService); + tasksService.register(Object.values(tasks)); + + const eventsService = container.get(EventsService); + eventsService.register(Object.values(events)); + }, +}); + +export { notifications, tasks, events }; diff --git a/extensions/nexus-notifications/src/tasks/tasks.add.ts b/extensions/nexus-notifications/src/tasks/tasks.add.ts new file mode 100644 index 0000000..f59aa7b --- /dev/null +++ b/extensions/nexus-notifications/src/tasks/tasks.add.ts @@ -0,0 +1,88 @@ +import { EventsService, Type, createTask } from '@bitlerjs/nexus'; +import { nanoid } from 'nanoid'; +import { Databases } from '@bitlerjs/nexus-data'; + +import { dbConfig } from '../databases/databases.js'; +import { notificationCreatedEvent } from '../events/events.created.js'; + +const addNotificationTask = createTask({ + kind: 'notification.add', + name: 'Add notification', + description: 'Add a notification', + input: Type.Object({ + id: Type.Optional(Type.String()), + title: Type.String(), + message: Type.String(), + entities: Type.Optional( + Type.Array( + Type.Object({ + kind: Type.String(), + id: Type.String(), + rolw: Type.Optional(Type.String()), + }), + ), + ), + actions: Type.Optional( + Type.Array( + Type.Object({ + title: Type.String(), + description: Type.Optional(Type.String()), + kind: Type.String(), + removeNotification: Type.Optional(Type.Boolean()), + data: Type.Unknown(), + }), + ), + ), + }), + output: Type.Object({ + id: Type.String(), + }), + handler: async ({ input, container }) => { + const dbs = container.get(Databases); + const eventsService = container.get(EventsService); + const db = await dbs.get(dbConfig); + const id = input.id || nanoid(); + const actions = input.actions?.map((action) => ({ + id, + title: action.title, + description: action.description, + kind: action.kind, + removeNotification: action.removeNotification || false, + data: action.data, + })); + + await db.transaction(async (trx) => { + await trx('notifications') + .insert({ + id, + title: input.title, + message: input.message, + }) + .onConflict('id') + .merge(); + + await trx('notificationActions').where('notificationId', id).delete(); + await trx('notificationEntities').where('notificationId', id).delete(); + + if (actions) { + await trx('notificationActions').insert(actions); + } + + if (input.entities) { + await trx('notificationEntities').insert(input.entities.map((entity) => ({ ...entity, notificationId: id }))); + } + }); + + eventsService.emit(notificationCreatedEvent, { + id, + title: input.title, + message: input.message, + actions: actions || [], + entities: input.entities || [], + }); + + return { id }; + }, +}); + +export { addNotificationTask }; diff --git a/extensions/nexus-notifications/src/tasks/tasks.list.ts b/extensions/nexus-notifications/src/tasks/tasks.list.ts new file mode 100644 index 0000000..27ab39c --- /dev/null +++ b/extensions/nexus-notifications/src/tasks/tasks.list.ts @@ -0,0 +1,48 @@ +import { createTask, Type } from '@bitlerjs/nexus'; +import { Databases } from '@bitlerjs/nexus-data'; + +import { dbConfig } from '../databases/databases.js'; + +const listNotificationTask = createTask({ + kind: 'notification.list', + name: 'List', + group: 'Notification', + description: 'List notifications', + input: Type.Object({}), + output: Type.Object({ + notifications: Type.Array( + Type.Object({ + id: Type.String(), + title: Type.String(), + message: Type.String(), + createdAt: Type.String(), + actions: Type.Array( + Type.Object({ + id: Type.String(), + title: Type.String(), + description: Type.Optional(Type.String()), + kind: Type.String(), + removeNotification: Type.Optional(Type.Boolean()), + data: Type.Unknown(), + }), + ), + }), + ), + }), + handler: async ({ container }) => { + const dbs = container.get(Databases); + const db = await dbs.get(dbConfig); + + const notifications = await db('notifications').select('*'); + const actions = await db('notificationActions').select('*'); + + const result = notifications.map((notification) => ({ + ...notification, + actions: actions.filter((action) => action.notificationId === notification.id), + })); + + return { notifications: result }; + }, +}); + +export { listNotificationTask }; diff --git a/extensions/nexus-notifications/src/tasks/tasks.remove.ts b/extensions/nexus-notifications/src/tasks/tasks.remove.ts new file mode 100644 index 0000000..46313d5 --- /dev/null +++ b/extensions/nexus-notifications/src/tasks/tasks.remove.ts @@ -0,0 +1,35 @@ +import { createTask, EventsService, Type } from '@bitlerjs/nexus'; +import { Databases } from '@bitlerjs/nexus-data'; + +import { dbConfig } from '../databases/databases.js'; +import { notificationRemovedEvent } from '../events/events.removed.js'; + +const removeNotificationsTask = createTask({ + kind: 'notification.remove', + name: 'Remove', + group: 'Notification', + description: 'Remove a notification', + input: Type.Object({ + ids: Type.Array(Type.String()), + }), + output: Type.Object({ + success: Type.Boolean(), + }), + handler: async ({ input, container }) => { + const dbs = container.get(Databases); + const eventsService = container.get(EventsService); + const db = await dbs.get(dbConfig); + + await db.transaction(async (trx) => { + await trx('notifications').whereIn('id', input.ids).delete(); + }); + + input.ids.forEach((id) => { + eventsService.emit(notificationRemovedEvent, { id }); + }); + + return { success: true }; + }, +}); + +export { removeNotificationsTask }; diff --git a/extensions/nexus-notifications/src/tasks/tasks.run-action.ts b/extensions/nexus-notifications/src/tasks/tasks.run-action.ts new file mode 100644 index 0000000..f67f1d3 --- /dev/null +++ b/extensions/nexus-notifications/src/tasks/tasks.run-action.ts @@ -0,0 +1,51 @@ +import { createTask, TasksService, Type } from '@bitlerjs/nexus'; +import { Databases } from '@bitlerjs/nexus-data'; + +import { dbConfig } from '../databases/databases.js'; + +const runNotificationActionTask = createTask({ + kind: 'notification.run-action', + name: 'Run Action', + group: 'Notification', + description: 'Run a notification action', + input: Type.Object({ + actionId: Type.String(), + }), + output: Type.Object({ + actionId: Type.String(), + notificationId: Type.String(), + success: Type.Boolean(), + }), + handler: async ({ input, container, requestContext, continuation }) => { + const dbs = container.get(Databases); + const db = await dbs.get(dbConfig); + + const [action] = await db('notificationActions').select('*').where('id', input.actionId).limit(1); + if (!action) { + throw new Error('Action not found'); + } + const tasksService = container.get(TasksService); + const task = tasksService.get(action.kind); + if (!task) { + throw new Error('Task not found'); + } + await tasksService.run({ + task, + input: action.data, + requestContext, + continuation, + }); + + if (action.removeNotification) { + await db('notifications').where('id', action.notificationId).delete(); + } + + return { + success: true, + actionId: action.id, + notificationId: action.notificationId, + }; + }, +}); + +export { runNotificationActionTask }; diff --git a/extensions/nexus-notifications/tsconfig.json b/extensions/nexus-notifications/tsconfig.json new file mode 100644 index 0000000..fadd53c --- /dev/null +++ b/extensions/nexus-notifications/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@bitlerjs/nexus-config", + "compilerOptions": { + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ] +} diff --git a/extensions/nexus-signal/.gitignore b/extensions/nexus-signal/.gitignore new file mode 100644 index 0000000..3d2bc62 --- /dev/null +++ b/extensions/nexus-signal/.gitignore @@ -0,0 +1,2 @@ +/dist/ +/node_modules/ diff --git a/extensions/nexus-signal/package.json b/extensions/nexus-signal/package.json new file mode 100644 index 0000000..d1d135a --- /dev/null +++ b/extensions/nexus-signal/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bitlerjs/nexus-signal", + "version": "1.0.0", + "main": "dist/exports.js", + "type": "module", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build", + "dev": "tsc --build --watch", + "generate:types": "openapi-typescript https://bbernhard.github.io/signal-cli-rest-api/src/docs/swagger.json -o src/generated/api.ts" + }, + "devDependencies": { + "@bitlerjs/nexus-config": "workspace:*", + "openapi-typescript": "^5.4.2", + "typescript": "^5.7.2" + }, + "dependencies": { + "@bitlerjs/nexus": "workspace:*", + "@bitlerjs/nexus-configs": "workspace:*", + "@bitlerjs/nexus-data": "workspace:*", + "@bitlerjs/nexus-notifications": "workspace:*", + "nanoid": "^5.0.9", + "openapi-fetch": "^0.12.5" + } +} diff --git a/extensions/nexus-signal/src/client/client.ts b/extensions/nexus-signal/src/client/client.ts new file mode 100644 index 0000000..8e18bdf --- /dev/null +++ b/extensions/nexus-signal/src/client/client.ts @@ -0,0 +1,19 @@ +import createBaseClient from 'openapi-fetch'; + +import { paths } from '../generated/api.js'; + +type CreateClientOptions = { + host: string; + secure: boolean; +}; +const createClient = ({ host, secure }: CreateClientOptions) => { + const client = createBaseClient({ + baseUrl: `${secure ? 'https' : 'http'}://${host}`, + }); + + return client; +}; + +type Client = ReturnType; + +export { createClient, type Client }; diff --git a/extensions/nexus-signal/src/configs/configs.ts b/extensions/nexus-signal/src/configs/configs.ts new file mode 100644 index 0000000..8d56312 --- /dev/null +++ b/extensions/nexus-signal/src/configs/configs.ts @@ -0,0 +1,15 @@ +import { Type } from '@bitlerjs/nexus'; +import { createConfig } from '@bitlerjs/nexus-configs'; + +const signalConfig = createConfig({ + kind: 'signal.integration', + name: 'Signal', + group: 'Integrations', + description: 'Signal integration', + schema: Type.Object({ + host: Type.String(), + secure: Type.Boolean(), + }), +}); + +export { signalConfig }; diff --git a/extensions/nexus-signal/src/databases/databases.ts b/extensions/nexus-signal/src/databases/databases.ts new file mode 100644 index 0000000..ba55508 --- /dev/null +++ b/extensions/nexus-signal/src/databases/databases.ts @@ -0,0 +1,23 @@ +import { createDatabase, createMigration } from '@bitlerjs/nexus-data'; + +const addNotificationsTable = createMigration({ + name: 'add-notifications-table', + up: async (knex) => { + await knex.schema.createTable('notifications', (table) => { + table.increments('id'); + table.string('senderUuid'); + table.string('notificationId'); + table.datetime('timestamp').defaultTo(knex.fn.now()); + }); + }, + down: async (knex) => { + await knex.schema.dropTable('notifications'); + }, +}); + +const dbConfig = createDatabase({ + name: 'signal', + migrations: [addNotificationsTable], +}); + +export { dbConfig }; diff --git a/extensions/nexus-signal/src/exports.ts b/extensions/nexus-signal/src/exports.ts new file mode 100644 index 0000000..1ed15f9 --- /dev/null +++ b/extensions/nexus-signal/src/exports.ts @@ -0,0 +1,30 @@ +import { createExtension, TasksService } from '@bitlerjs/nexus'; +import { ConfigService } from '@bitlerjs/nexus-configs'; + +import { SignalService } from './services/services.signal.js'; +import { signalConfig } from './configs/configs.js'; +import { getContactsTask } from './tasks/tasks.get-contacts.js'; +import { getGroupsTask } from './tasks/tasks.get-groups.js'; +import { sendTask } from './tasks/tasks.send.js'; + +const signal = createExtension({ + setup: async ({ container }) => { + const configsService = container.get(ConfigService); + const tasksService = container.get(TasksService); + + configsService.register([signalConfig]); + + configsService.use({ + config: signalConfig, + handler: async (config) => { + if (config) { + const signalService = container.get(SignalService); + await signalService.setup(); + tasksService.register([getContactsTask, getGroupsTask, sendTask]); + } + }, + }); + }, +}); + +export { signal }; diff --git a/extensions/nexus-signal/src/generated/api.ts b/extensions/nexus-signal/src/generated/api.ts new file mode 100644 index 0000000..043af64 --- /dev/null +++ b/extensions/nexus-signal/src/generated/api.ts @@ -0,0 +1,1271 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/v1/about": { + /** Returns the supported API versions and the internal build nr */ + get: { + responses: { + /** OK */ + 200: { + schema: definitions["client.About"]; + }; + }; + }; + }; + "/v1/accounts": { + /** Lists all of the accounts linked or registered */ + get: { + responses: { + /** OK */ + 200: { + schema: string[]; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/accounts/{number}/rate-limit-challenge": { + /** When running into rate limits, sometimes the limit can be lifted, by solving a CAPTCHA. To get the captcha token, go to https://signalcaptchas.org/challenge/generate.html For the staging environment, use: https://signalcaptchas.org/staging/registration/generate.html. The "challenge_token" is the token from the failed send attempt. The "captcha" is the captcha result, starting with signalcaptcha:// */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Request */ + data: definitions["api.RateLimitChallengeRequest"]; + }; + }; + responses: { + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/accounts/{number}/settings": { + /** Update the account attributes on the signal server. */ + put: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Request */ + data: definitions["api.UpdateAccountSettingsRequest"]; + }; + }; + responses: { + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/accounts/{number}/username": { + /** Allows to set the username that should be used for this account. This can either be just the nickname (e.g. test) or the complete username with discriminator (e.g. test.123). Returns the new username with discriminator and the username link. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Request */ + data: definitions["api.SetUsernameRequest"]; + }; + }; + responses: { + /** Created */ + 201: { + schema: definitions["client.SetUsernameResponse"]; + }; + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Delete the username associated with this account. */ + delete: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + }; + responses: { + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/attachments": { + /** List all downloaded attachments */ + get: { + responses: { + /** OK */ + 200: { + schema: string[]; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/attachments/{attachment}": { + /** Serve the attachment with the given id */ + get: { + parameters: { + path: { + /** Attachment ID */ + attachment: string; + }; + }; + responses: { + /** OK */ + 200: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Remove the attachment with the given id from filesystem. */ + delete: { + parameters: { + path: { + /** Attachment ID */ + attachment: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/configuration": { + /** List the REST API configuration. */ + get: { + responses: { + /** OK */ + 200: { + schema: definitions["api.Configuration"]; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Set the REST API configuration. */ + post: { + parameters: { + body: { + /** Configuration */ + data: definitions["api.Configuration"]; + }; + }; + responses: { + /** OK */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/configuration/{number}/settings": { + /** List account specific settings. */ + get: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Request */ + data: definitions["api.TrustModeResponse"]; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Set account specific settings. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Request */ + data: definitions["api.TrustModeRequest"]; + }; + }; + responses: { + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/contacts/{number}": { + /** List all contacts for the given number. */ + get: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + }; + responses: { + /** OK */ + 200: { + schema: definitions["client.ListContactsResponse"][]; + }; + }; + }; + /** Updates the info associated to a number on the contact list. */ + put: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Contact */ + data: definitions["api.UpdateContactRequest"]; + }; + }; + responses: { + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/contacts/{number}/sync": { + /** Send a synchronization message with the local contacts list to all linked devices. This command should only be used if this is the primary device. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + }; + responses: { + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/devices/{number}": { + /** Links another device to this device. Only works, if this is the master device. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Request */ + data: definitions["api.AddDeviceRequest"]; + }; + }; + responses: { + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/groups/{number}": { + /** List all Signal Groups. */ + get: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + }; + responses: { + /** OK */ + 200: { + schema: definitions["client.GroupEntry"][]; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Create a new Signal Group with the specified members. */ + post: { + parameters: { + body: { + /** Input Data */ + data: definitions["api.CreateGroupRequest"]; + }; + path: { + /** Registered Phone Number */ + number: string; + }; + }; + responses: { + /** Created */ + 201: { + schema: definitions["api.CreateGroupResponse"]; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/groups/{number}/{groupid}": { + /** List a specific Signal Group. */ + get: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + /** Group ID */ + groupid: string; + }; + }; + responses: { + /** OK */ + 200: { + schema: definitions["client.GroupEntry"]; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Update the state of a Signal Group. */ + put: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + /** Group ID */ + groupid: string; + }; + body: { + /** Input Data */ + data: definitions["api.UpdateGroupRequest"]; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Delete the specified Signal Group. */ + delete: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + /** Group Id */ + groupid: string; + }; + }; + responses: { + /** OK */ + 200: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/groups/{number}/{groupid}/admins": { + /** Add one or more admins to an existing Signal Group. */ + post: { + parameters: { + body: { + /** Admins */ + data: definitions["api.ChangeGroupAdminsRequest"]; + }; + path: { + /** Registered Phone Number */ + number: string; + /** Group ID */ + groupid: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Remove one or more admins from an existing Signal Group. */ + delete: { + parameters: { + body: { + /** Admins */ + data: definitions["api.ChangeGroupAdminsRequest"]; + }; + path: { + /** Registered Phone Number */ + number: string; + /** Group ID */ + groupid: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/groups/{number}/{groupid}/block": { + /** Block the specified Signal Group. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + /** Group ID */ + groupid: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/groups/{number}/{groupid}/join": { + /** Join the specified Signal Group. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + /** Group ID */ + groupid: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/groups/{number}/{groupid}/members": { + /** Add one or more members to an existing Signal Group. */ + post: { + parameters: { + body: { + /** Members */ + data: definitions["api.ChangeGroupMembersRequest"]; + }; + path: { + /** Registered Phone Number */ + number: string; + /** Group ID */ + groupid: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Remove one or more members from an existing Signal Group. */ + delete: { + parameters: { + body: { + /** Members */ + data: definitions["api.ChangeGroupMembersRequest"]; + }; + path: { + /** Registered Phone Number */ + number: string; + /** Group ID */ + groupid: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/groups/{number}/{groupid}/quit": { + /** Quit the specified Signal Group. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + /** Group ID */ + groupid: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/health": { + /** Internally used by the docker container to perform the health check. */ + get: { + responses: { + /** No Content */ + 204: { + schema: string; + }; + }; + }; + }; + "/v1/identities/{number}": { + /** List all identities for the given number. */ + get: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + }; + responses: { + /** OK */ + 200: { + schema: definitions["client.IdentityEntry"][]; + }; + }; + }; + }; + "/v1/identities/{number}/trust/{numberToTrust}": { + /** Trust an identity. When 'trust_all_known_keys' is set to' true', all known keys of this user are trusted. **This is only recommended for testing.** */ + put: { + parameters: { + body: { + /** Input Data */ + data: definitions["api.TrustIdentityRequest"]; + }; + path: { + /** Registered Phone Number */ + number: string; + /** Number To Trust */ + numberToTrust: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + }; + }; + }; + "/v1/profiles/{number}": { + /** Set your name and optional an avatar. */ + put: { + parameters: { + body: { + /** Profile Data */ + data: definitions["api.UpdateProfileRequest"]; + }; + path: { + /** Registered Phone Number */ + number: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/qrcodelink": { + /** Link device and generate QR code */ + get: { + parameters: { + query: { + /** Device Name */ + device_name: string; + /** QRCode Version (defaults to 10) */ + qrcode_version?: number; + }; + }; + responses: { + /** Image */ + 200: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/reactions/{number}": { + /** React to a message */ + post: { + parameters: { + body: { + /** Reaction */ + data: definitions["api.Reaction"]; + }; + path: { + /** Registered phone number */ + number: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Remove a reaction */ + delete: { + parameters: { + body: { + /** Reaction */ + data: definitions["api.Reaction"]; + }; + path: { + /** Registered phone number */ + number: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/receipts/{number}": { + /** Send a read or viewed receipt */ + post: { + parameters: { + body: { + /** Receipt */ + data: definitions["api.Receipt"]; + }; + path: { + /** Registered phone number */ + number: string; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/receive/{number}": { + /** Receives Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint. */ + get: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + query: { + /** Receive timeout in seconds (default: 1) */ + timeout?: string; + /** Specify whether the attachments of the received message should be ignored */ + ignore_attachments?: string; + /** Specify whether stories should be ignored when receiving messages */ + ignore_stories?: string; + /** Specify the maximum number of messages to receive (default: unlimited) */ + max_messages?: string; + /** Specify whether read receipts should be sent when receiving messages */ + send_read_receipts?: string; + }; + }; + responses: { + /** OK */ + 200: { + schema: string[]; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/register/{number}": { + /** Register a phone number with the signal network. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Additional Settings */ + data?: definitions["api.RegisterNumberRequest"]; + }; + }; + responses: { + /** Created */ + 201: unknown; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/register/{number}/verify/{token}": { + /** Verify a registered phone number with the signal network. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + /** Verification Code */ + token: string; + }; + body: { + /** Additional Settings */ + data?: definitions["api.VerifyNumberSettings"]; + }; + }; + responses: { + /** OK */ + 201: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/search/{number}": { + /** Check if one or more phone numbers are registered with the Signal Service. */ + get: { + parameters: { + path: { + /** Registered Phone Number */ + number?: string; + }; + query: { + /** Numbers to check */ + numbers: string[]; + }; + }; + responses: { + /** OK */ + 200: { + schema: definitions["api.SearchResponse"][]; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/send": { + /** Send a signal message */ + post: { + parameters: { + body: { + /** Input Data */ + data: definitions["api.SendMessageV1"]; + }; + }; + responses: { + /** OK */ + 201: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/sticker-packs/{number}": { + /** List Installed Sticker Packs. */ + get: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + }; + responses: { + /** OK */ + 200: { + schema: definitions["client.ListInstalledStickerPacksResponse"][]; + }; + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** In order to add a sticker pack, browse to https://signalstickers.org/ and select the sticker pack you want to add. Then, press the "Add to Signal" button. If you look at the address bar in your browser you should see an URL in this format: https://signal.art/addstickers/#pack_id=XXX&pack_key=YYY, where XXX is the pack_id and YYY is the pack_key. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Request */ + data: definitions["api.AddStickerPackRequest"]; + }; + }; + responses: { + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/typing-indicator/{number}": { + /** Show Typing Indicator. */ + put: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Type */ + data: definitions["api.TypingIndicatorRequest"]; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + /** Hide Typing Indicator. */ + delete: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Type */ + data: definitions["api.TypingIndicatorRequest"]; + }; + }; + responses: { + /** No Content */ + 204: { + schema: string; + }; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v1/unregister/{number}": { + /** Disables push support for this device. **WARNING:** If *delete_account* is set to *true*, the account will be deleted from the Signal Server. This cannot be undone without loss. */ + post: { + parameters: { + path: { + /** Registered Phone Number */ + number: string; + }; + body: { + /** Additional Settings */ + data?: definitions["api.UnregisterNumberRequest"]; + }; + }; + responses: { + /** No Content */ + 204: never; + /** Bad Request */ + 400: { + schema: definitions["api.Error"]; + }; + }; + }; + }; + "/v2/send": { + /** Send a signal message. Set the text_mode to 'styled' in case you want to add formatting to your text message. Styling Options: \*italic text\*, \*\*bold text\*\*, ~strikethrough text~, ||spoiler||, \`monospace\`. If you want to escape a formatting character, prefix it with two backslashes. */ + post: { + parameters: { + body: { + /** Input Data */ + data: definitions["api.SendMessageV2"]; + }; + }; + responses: { + /** Created */ + 201: { + schema: definitions["api.SendMessageResponse"]; + }; + /** Bad Request */ + 400: { + schema: definitions["api.SendMessageError"]; + }; + }; + }; + }; +} + +export interface definitions { + "api.AddDeviceRequest": { + uri?: string; + }; + "api.AddStickerPackRequest": { + /** @example 9a32eda01a7a28574f2eb48668ae0dc4 */ + pack_id?: string; + /** @example 19546e18eba0ff69dea78eb591465289d39e16f35e58389ae779d4f9455aff3a */ + pack_key?: string; + }; + "api.ChangeGroupAdminsRequest": { + admins?: string[]; + }; + "api.ChangeGroupMembersRequest": { + members?: string[]; + }; + "api.Configuration": { + logging?: definitions["api.LoggingConfiguration"]; + }; + "api.CreateGroupRequest": { + description?: string; + expiration_time?: number; + /** @enum {string} */ + group_link?: "disabled" | "enabled" | "enabled-with-approval"; + members?: string[]; + name?: string; + permissions?: definitions["api.GroupPermissions"]; + }; + "api.CreateGroupResponse": { + id?: string; + }; + "api.Error": { + error?: string; + }; + "api.GroupPermissions": { + /** @enum {string} */ + add_members?: "only-admins" | "every-member"; + /** @enum {string} */ + edit_group?: "only-admins" | "every-member"; + }; + "api.LoggingConfiguration": { + Level?: string; + }; + "api.RateLimitChallengeRequest": { + /** @example signalcaptcha://{captcha value} */ + captcha?: string; + /** @example */ + challenge_token?: string; + }; + "api.Reaction": { + reaction?: string; + recipient?: string; + target_author?: string; + timestamp?: number; + }; + "api.Receipt": { + /** @enum {string} */ + receipt_type?: "read" | "viewed"; + recipient?: string; + timestamp?: number; + }; + "api.RegisterNumberRequest": { + captcha?: string; + use_voice?: boolean; + }; + "api.SearchResponse": { + number?: string; + registered?: boolean; + }; + "api.SendMessageError": { + account?: string; + challenge_tokens?: string[]; + error?: string; + }; + "api.SendMessageResponse": { + timestamp?: string; + }; + "api.SendMessageV1": { + /** @example '' OR 'data:;base64,' OR 'data:;filename=;base64,' */ + base64_attachment?: string; + is_group?: boolean; + message?: string; + number?: string; + recipients?: string[]; + }; + "api.SendMessageV2": { + /** + * @example [ + * "", + * "data:;base64", + * "data:;filename=;base64" + * ] + */ + base64_attachments?: string[]; + edit_timestamp?: number; + mentions?: definitions["data.MessageMention"][]; + message?: string; + notify_self?: boolean; + number?: string; + quote_author?: string; + quote_mentions?: definitions["data.MessageMention"][]; + quote_message?: string; + quote_timestamp?: number; + recipients?: string[]; + sticker?: string; + /** @enum {string} */ + text_mode?: "normal" | "styled"; + }; + "api.SetUsernameRequest": { + /** @example test */ + username?: string; + }; + "api.TrustIdentityRequest": { + /** @example false */ + trust_all_known_keys?: boolean; + verified_safety_number?: string; + }; + "api.TrustModeRequest": { + trust_mode?: string; + }; + "api.TrustModeResponse": { + trust_mode?: string; + }; + "api.TypingIndicatorRequest": { + recipient?: string; + }; + "api.UnregisterNumberRequest": { + /** @example false */ + delete_account?: boolean; + /** @example false */ + delete_local_data?: boolean; + }; + "api.UpdateAccountSettingsRequest": { + discoverable_by_number?: boolean; + share_number?: boolean; + }; + "api.UpdateContactRequest": { + expiration_in_seconds?: number; + name?: string; + recipient?: string; + }; + "api.UpdateGroupRequest": { + base64_avatar?: string; + description?: string; + expiration_time?: number; + name?: string; + }; + "api.UpdateProfileRequest": { + about?: string; + base64_avatar?: string; + name?: string; + }; + "api.VerifyNumberSettings": { + pin?: string; + }; + "client.About": { + build?: number; + capabilities?: { [key: string]: string[] }; + mode?: string; + version?: string; + versions?: string[]; + }; + "client.GroupEntry": { + admins?: string[]; + blocked?: boolean; + id?: string; + internal_id?: string; + invite_link?: string; + members?: string[]; + name?: string; + pending_invites?: string[]; + pending_requests?: string[]; + }; + "client.IdentityEntry": { + added?: string; + fingerprint?: string; + number?: string; + safety_number?: string; + status?: string; + }; + "client.ListContactsResponse": { + blocked?: boolean; + color?: string; + message_expiration?: string; + name?: string; + number?: string; + profile_name?: string; + username?: string; + uuid?: string; + }; + "client.ListInstalledStickerPacksResponse": { + author?: string; + installed?: boolean; + pack_id?: string; + title?: string; + url?: string; + }; + "client.SetUsernameResponse": { + username?: string; + username_link?: string; + }; + "data.MessageMention": { + author?: string; + length?: number; + start?: number; + }; +} + +export interface operations {} + +export interface external {} diff --git a/extensions/nexus-signal/src/services/services.signal.ts b/extensions/nexus-signal/src/services/services.signal.ts new file mode 100644 index 0000000..f47f6a4 --- /dev/null +++ b/extensions/nexus-signal/src/services/services.signal.ts @@ -0,0 +1,149 @@ +import { Container } from '@bitlerjs/nexus'; + +import { Client, createClient } from '../client/client.js'; +import { paths } from '../generated/api.js'; + +import { SignalSocket } from './services.socket.js'; + +type ApiReponse = TMethod extends keyof paths[TPath] + ? paths[TPath][TMethod] extends { responses: { 200: { schema: infer U } } } + ? U + : never + : never; + +type ApiBody = TMethod extends keyof paths[TPath] + ? paths[TPath][TMethod] extends { parameters: { body: { data: infer U } } } + ? U + : never + : never; + +type ApiPathParamters = TMethod extends keyof paths[TPath] + ? paths[TPath][TMethod] extends { parameters: { path: infer U } } + ? U + : never + : never; + +type ApiQueryParameters = TMethod extends keyof paths[TPath] + ? paths[TPath][TMethod] extends { parameters: { query: infer U } } + ? U + : never + : never; + +class SignalService { + #container: Container; + #config?: { + host: string; + secure: boolean; + }; + #setupPromise?: Promise<{ + client: Client; + sockets: SignalSocket[]; + }>; + + constructor(container: Container) { + this.#container = container; + } + + public set config(config: { host: string; secure: boolean }) { + this.#config = config; + this.destroy(); + } + + #setup = async () => { + if (!this.#config) { + throw new Error('No config'); + } + const { host, secure } = this.#config; + const client = createClient({ + host, + secure, + }); + const response = await client.GET('/v1/accounts'); + if (response.error) { + throw new Error(response.error instanceof Error ? response.error.message : 'API error'); + } + const accounts = response.data as string[]; + if (!accounts || accounts.length === 0) { + throw new Error('No accounts found'); + } + const sockets = accounts.map( + (account) => + new SignalSocket({ + id: account, + host, + secure, + container: this.#container, + }), + ); + + return { client, sockets }; + }; + + public setup = async () => { + if (!this.#setupPromise) { + this.#setupPromise = this.#setup(); + } + return this.#setupPromise; + }; + + public destroy = async () => { + if (!this.#setupPromise) { + return; + } + const promise = this.#setupPromise; + this.#setupPromise = undefined; + const { sockets } = await promise; + sockets.forEach((socket) => socket.destroy()); + }; + + public getAccounts = async () => { + const { sockets } = await this.setup(); + return sockets.map((account) => account.id); + }; + + public get = async ( + path: TPath, + params: { + query?: ApiQueryParameters; + path?: ApiPathParamters; + } = {}, + ): Promise> => { + const { client } = await this.setup(); + const { data, error } = await client.GET(path as any, { + params: { + query: params.query, + path: params.path, + }, + }); + if (error) { + console.error(error); + throw new Error('API error'); + } + return data! as ApiReponse; + }; + + public post = async ( + path: TPath, + params: { + body: ApiBody; + query?: ApiQueryParameters; + path?: ApiPathParamters; + }, + ): Promise> => { + const { client } = await this.setup(); + const { data, error } = await client.POST(path as any, { + body: params.body, + params: { + query: params.query, + path: params.path, + }, + }); + if (error) { + console.error(error); + throw new Error('API error'); + } + return data! as ApiReponse; + }; +} + +export { SignalService }; diff --git a/extensions/nexus-signal/src/services/services.socket.ts b/extensions/nexus-signal/src/services/services.socket.ts new file mode 100644 index 0000000..0ec6afe --- /dev/null +++ b/extensions/nexus-signal/src/services/services.socket.ts @@ -0,0 +1,95 @@ +import { Container, Continuation, RequestContext, TasksService } from '@bitlerjs/nexus'; +import { tasks as notificationTasks } from '@bitlerjs/nexus-notifications'; + +import { Message } from '../types/message.js'; + +type SignalSocketOptions = { + id: string; + host: string; + secure: boolean; + container: Container; +}; + +class SignalSocket { + #options: SignalSocketOptions; + #socket: WebSocket; + + constructor(options: SignalSocketOptions) { + this.#options = options; + this.#socket = this.#setup(); + } + + public get id() { + return this.#options.id; + } + + #onopen = () => { + console.log('connected'); + }; + + #onclose = () => { + console.log('disconnected'); + }; + + #onerror = (event: Event) => { + console.error(event); + }; + + #onmessage = async (event: MessageEvent) => { + const { container } = this.#options; + const tasksService = container.get(TasksService); + const message: Message = JSON.parse(event.data); + if (message.envelope.dataMessage) { + await tasksService.run({ + task: notificationTasks.addNotification, + input: { + id: `signal-${message.envelope.sourceUuid}-${message.envelope.timestamp}`, + title: 'New signal message', + message: [ + `**From ${message.envelope.sourceName} (${message.envelope.source})**`, + '', + message.envelope.dataMessage.message, + ].join('\n'), + }, + requestContext: new RequestContext(), + continuation: new Continuation({ + container, + }), + }); + } + + if (message.envelope.syncMessage?.readMessages) { + const ids = message.envelope.syncMessage.readMessages.map( + (readMessage) => `signal-${readMessage.senderUuid}-${readMessage.timestamp}`, + ); + + await tasksService.run({ + task: notificationTasks.removeNotifications, + input: { + ids, + }, + requestContext: new RequestContext(), + continuation: new Continuation({ + container, + }), + }); + } + }; + + #setup = () => { + const { id, host, secure } = this.#options; + const socketUrl = new URL(`v1/receive/${id}`, `${secure ? 'wss' : 'ws'}://${host}`); + const socket = new WebSocket(socketUrl); + socket.addEventListener('message', this.#onmessage); + socket.addEventListener('open', this.#onopen); + socket.addEventListener('close', this.#onclose); + socket.addEventListener('error', this.#onerror); + return socket; + }; + + public destroy = () => { + this.#socket.close(); + }; +} + +export { SignalSocket, type SignalSocketOptions }; diff --git a/extensions/nexus-signal/src/tasks/tasks.get-contacts.ts b/extensions/nexus-signal/src/tasks/tasks.get-contacts.ts new file mode 100644 index 0000000..1e627d7 --- /dev/null +++ b/extensions/nexus-signal/src/tasks/tasks.get-contacts.ts @@ -0,0 +1,40 @@ +import { createTask, Type } from '@bitlerjs/nexus'; + +import { SignalService } from '../services/services.signal.js'; + +const getContactsTask = createTask({ + kind: 'signal.get-contacts', + name: 'Get Contacts', + group: 'Signal', + description: 'Get contacts from Signal', + input: Type.Object({}), + output: Type.Object({ + contacts: Type.Array( + Type.Object({ + name: Type.String(), + phone: Type.String(), + }), + ), + }), + handler: async ({ container }) => { + const signalService = container.get(SignalService); + const accounts = await signalService.get('/v1/accounts'); + const contactGroups = await Promise.all( + accounts.map(async (account) => { + const response = await signalService.get('/v1/contacts/{number}', { + path: { + number: account, + }, + }); + return response.map((contact) => ({ + name: contact.profile_name || contact.name || contact.username || contact.number || 'Unknown', + phone: contact.number || 'Unknown', + })); + }), + ); + const contacts = contactGroups.flatMap((a) => a); + return { contacts }; + }, +}); + +export { getContactsTask }; diff --git a/extensions/nexus-signal/src/tasks/tasks.get-groups.ts b/extensions/nexus-signal/src/tasks/tasks.get-groups.ts new file mode 100644 index 0000000..262c5d9 --- /dev/null +++ b/extensions/nexus-signal/src/tasks/tasks.get-groups.ts @@ -0,0 +1,42 @@ +import { createTask, Type } from '@bitlerjs/nexus'; + +import { SignalService } from '../services/services.signal.js'; + +const getGroupsTask = createTask({ + kind: 'signal.get-groups', + name: 'Get Groups', + group: 'Signal', + description: 'Get groups from Signal', + input: Type.Object({}), + output: Type.Object({ + groups: Type.Array( + Type.Object({ + name: Type.String(), + id: Type.String(), + internalId: Type.String(), + }), + ), + }), + handler: async ({ container }) => { + const signalService = container.get(SignalService); + const accounts = await signalService.get('/v1/accounts'); + const byAccount = await Promise.all( + accounts.map(async (account) => { + const response = await signalService.get('/v1/groups/{number}', { + path: { + number: account, + }, + }); + return response.map((contact) => ({ + id: contact.id || 'Unknown', + internalId: contact.internal_id || 'Unknown', + name: contact.name || 'Unknown', + })); + }), + ); + const groups = byAccount.flatMap((a) => a); + return { groups }; + }, +}); + +export { getGroupsTask }; diff --git a/extensions/nexus-signal/src/tasks/tasks.send.ts b/extensions/nexus-signal/src/tasks/tasks.send.ts new file mode 100644 index 0000000..04952c7 --- /dev/null +++ b/extensions/nexus-signal/src/tasks/tasks.send.ts @@ -0,0 +1,42 @@ +import { createTask, Type } from '@bitlerjs/nexus'; + +import { SignalService } from '../services/services.signal.js'; + +const sendTask = createTask({ + kind: 'signal.send', + name: 'Send Signal Message', + group: 'Signal', + description: 'Send a message to a Signal contact', + input: Type.Object({ + recipient: Type.String({ + description: "The recipient's phone number", + }), + message: Type.String(), + attachments: Type.Optional( + Type.Array( + Type.String({ + description: + 'Base64 encoded attachment ("", "data:;base64", "data:;filename=;base64")', + }), + ), + ), + }), + output: Type.Object({ + success: Type.Boolean(), + }), + handler: async ({ container, input }) => { + const signalService = container.get(SignalService); + const accounts = await signalService.getAccounts(); + await signalService.post('/v2/send', { + body: { + base64_attachments: input.attachments, + number: accounts[0], + recipients: [input.recipient], + message: input.message, + }, + }); + return { success: true }; + }, +}); + +export { sendTask }; diff --git a/extensions/nexus-signal/src/types/message.ts b/extensions/nexus-signal/src/types/message.ts new file mode 100644 index 0000000..74ca5ce --- /dev/null +++ b/extensions/nexus-signal/src/types/message.ts @@ -0,0 +1,67 @@ +type TypingMessage = { + action: 'STARTED' | 'STOPPED'; + timestamp: number; + groupId?: string; // Optional, only present in some cases +}; + +// Define the data message structure +type DataMessage = { + timestamp: number; + message: string; + expiresInSeconds: number; + viewOnce: boolean; +}; + +// Define the receipt message structure +type ReceiptMessage = { + when: number; + isDelivery: boolean; + isRead: boolean; + isViewed: boolean; + timestamps: number[]; +}; + +// Define the sync message structure +type SyncMessage = { + readMessages?: { + sender: string; + senderNumber: string; + senderUuid: string; + timestamp: number; + }[]; + sentMessage?: { + destination: string | null; + destinationNumber: string | null; + destinationUuid: string | null; + timestamp: number; + message: string; + expiresInSeconds: number; + viewOnce: boolean; + groupInfo?: { + groupId: string; + type: 'DELIVER'; + }; + }; +}; + +// Define the envelope structure +type Envelope = { + source: string; + sourceNumber: string; + sourceUuid: string; + sourceName: string; + sourceDevice: number; + timestamp: number; + typingMessage?: TypingMessage; + dataMessage?: DataMessage; + receiptMessage?: ReceiptMessage; + syncMessage?: SyncMessage; +}; + +// Define the message structure +type Message = { + envelope: Envelope; + account: string; +}; + +export { type TypingMessage, type DataMessage, type ReceiptMessage, type SyncMessage, type Envelope, type Message }; diff --git a/extensions/nexus-signal/tsconfig.json b/extensions/nexus-signal/tsconfig.json new file mode 100644 index 0000000..fadd53c --- /dev/null +++ b/extensions/nexus-signal/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@bitlerjs/nexus-config", + "compilerOptions": { + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/nexus/src/tasks/tasks.ts b/packages/nexus/src/tasks/tasks.ts index 6dc7d80..bda7b53 100644 --- a/packages/nexus/src/tasks/tasks.ts +++ b/packages/nexus/src/tasks/tasks.ts @@ -1,3 +1,5 @@ +import { Static, TSchema } from '@sinclair/typebox'; + import { Continuation } from '../continuation/continuation.js'; import { Container } from '../container/container.js'; import { RequestContext } from '../request-context/request-context.js'; @@ -8,9 +10,9 @@ import { EventsService } from '../events/events.js'; import { Task } from './tasks.task.js'; import { tasksUpdated } from './tasks.events.js'; -type RunOptions = { - task: Task; - input: unknown; +type RunOptions = { + task: Task; + input: Static; requestContext: RequestContext; continuation: Continuation; }; @@ -39,7 +41,7 @@ class TasksService { return this.#tasks.get(kind); }; - public run = async (options: RunOptions) => { + public run = async (options: RunOptions) => { const { task, requestContext, continuation, input } = options; const sources = new Sources({ container: this.#container, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 674e479..7be3070 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,12 +77,18 @@ importers: '@bitlerjs/nexus-notes': specifier: workspace:* version: link:../../extensions/nexus-notes + '@bitlerjs/nexus-notifications': + specifier: workspace:* + version: link:../../extensions/nexus-notifications '@bitlerjs/nexus-openai': specifier: workspace:* version: link:../../packages/nexus-openai '@bitlerjs/nexus-openapi': specifier: workspace:* version: link:../../packages/nexus-openapi + '@bitlerjs/nexus-signal': + specifier: workspace:* + version: link:../../extensions/nexus-signal '@bitlerjs/nexus-studio': specifier: workspace:* version: link:../../packages/nexus-studio @@ -273,6 +279,56 @@ importers: specifier: ^5.7.2 version: 5.7.3 + extensions/nexus-notifications: + dependencies: + '@bitlerjs/nexus': + specifier: workspace:* + version: link:../../packages/nexus + '@bitlerjs/nexus-data': + specifier: workspace:* + version: link:../../packages/nexus-data + nanoid: + specifier: ^5.0.9 + version: 5.0.9 + devDependencies: + '@bitlerjs/nexus-config': + specifier: workspace:* + version: link:../../packages/nexus-config + typescript: + specifier: ^5.7.2 + version: 5.7.3 + + extensions/nexus-signal: + dependencies: + '@bitlerjs/nexus': + specifier: workspace:* + version: link:../../packages/nexus + '@bitlerjs/nexus-configs': + specifier: workspace:* + version: link:../../packages/nexus-configs + '@bitlerjs/nexus-data': + specifier: workspace:* + version: link:../../packages/nexus-data + '@bitlerjs/nexus-notifications': + specifier: workspace:* + version: link:../nexus-notifications + nanoid: + specifier: ^5.0.9 + version: 5.0.9 + openapi-fetch: + specifier: ^0.12.5 + version: 0.12.5 + devDependencies: + '@bitlerjs/nexus-config': + specifier: workspace:* + version: link:../../packages/nexus-config + openapi-typescript: + specifier: ^5.4.2 + version: 5.4.2 + typescript: + specifier: ^5.7.2 + version: 5.7.3 + extensions/nexus-timers: dependencies: '@bitlerjs/nexus': @@ -1136,6 +1192,10 @@ packages: '@fastify/ajv-compiler@4.0.2': resolution: {integrity: sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ==} + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@fastify/cors@10.0.2': resolution: {integrity: sha512-DGdxOG36sS/tZv1NFiCJGi7wGuXOSPL2CmNX5PbOVKx0C6LuIALRMrqLByHTCcX1Rbl8NJ9IWlJex32bzydvlw==} @@ -4093,6 +4153,12 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -5019,9 +5085,20 @@ packages: zod: optional: true + openapi-fetch@0.12.5: + resolution: {integrity: sha512-FnAMWLt0MNL6ComcL4q/YbB1tUgyz5YnYtwA1+zlJ5xcucmK5RlWsgH1ynxmEeu8fGJkYjm8armU/HVpORc9lw==} + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + openapi-typescript@5.4.2: + resolution: {integrity: sha512-tHeRv39Yh7brqJpbUntdjtUaXrTHmC4saoyTLU/0J2I8LEFQYDXRLgnmWTMiMOB2GXugJiqHa5n9sAyd6BRqiA==} + engines: {node: '>= 14.0.0'} + hasBin: true + openid-client@6.1.7: resolution: {integrity: sha512-JfY/KvQgOutmG2P+oVNKInE7zIh+im1MQOaO7g5CtNnTWMociA563WweiEMKfR9ry9XG3K2HGvj9wEqhCQkPMg==} @@ -5213,6 +5290,11 @@ packages: resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} engines: {node: '>=6.0.0'} + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + prettier@3.4.2: resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==} engines: {node: '>=14'} @@ -5682,6 +5764,9 @@ packages: resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==} engines: {node: '>=8'} + tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -5838,6 +5923,10 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici@5.28.5: + resolution: {integrity: sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==} + engines: {node: '>=14.0'} + unfetch@4.2.0: resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==} @@ -6144,6 +6233,10 @@ packages: engines: {node: '>= 14'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6394,6 +6487,8 @@ snapshots: ajv-formats: 3.0.1(ajv@8.17.1) fast-uri: 3.0.6 + '@fastify/busboy@2.1.1': {} + '@fastify/cors@10.0.2': dependencies: fastify-plugin: 5.0.1 @@ -10495,6 +10590,10 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globalyzer@0.1.0: {} + + globrex@0.1.2: {} + gopd@1.2.0: {} graceful-fs@4.2.10: {} @@ -11543,8 +11642,23 @@ snapshots: transitivePeerDependencies: - encoding + openapi-fetch@0.12.5: + dependencies: + openapi-typescript-helpers: 0.0.15 + openapi-types@12.1.3: {} + openapi-typescript-helpers@0.0.15: {} + + openapi-typescript@5.4.2: + dependencies: + js-yaml: 4.1.0 + mime: 3.0.0 + prettier: 2.8.8 + tiny-glob: 0.2.9 + undici: 5.28.5 + yargs-parser: 21.1.1 + openid-client@6.1.7: dependencies: jose: 5.9.6 @@ -11745,6 +11859,8 @@ snapshots: dependencies: fast-diff: 1.3.0 + prettier@2.8.8: {} + prettier@3.4.2: {} pretty-bytes@5.6.0: {} @@ -12332,6 +12448,11 @@ snapshots: tildify@2.0.0: {} + tiny-glob@0.2.9: + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -12486,6 +12607,10 @@ snapshots: undici-types@6.20.0: {} + undici@5.28.5: + dependencies: + '@fastify/busboy': 2.1.1 + unfetch@4.2.0: {} unified@11.0.5: @@ -12777,6 +12902,8 @@ snapshots: yaml@2.7.0: {} + yargs-parser@21.1.1: {} + yocto-queue@0.1.0: {} zhead@2.2.4: {}