diff --git a/.env-sample b/.env-sample index dc9dcafc..521cd577 100644 --- a/.env-sample +++ b/.env-sample @@ -5,6 +5,11 @@ LND_MACAROON_BASE64='' LND_GRPC_HOST='127.0.0.1:10009' BOT_TOKEN='' +# When 'true' the bot stops trading: it never connects to the LN node, +# schedules no jobs, and replies to every message with a +# service-discontinued notice pointing users to Mostro +SUNSET_MODE='false' + # The max fee amount that the Bot will charge to the seller, 0.002 = 0.2% MAX_FEE=0 # Percentage of the total amount that the bot will take as a fee, 0.7 = 70% diff --git a/app.ts b/app.ts index e3da2cd7..bc791584 100644 --- a/app.ts +++ b/app.ts @@ -74,6 +74,12 @@ import { startMonitoring } from './monitoring'; }, }; const bot = await start(String(process.env.BOT_TOKEN), options); + if (process.env.SUNSET_MODE === 'true') { + logger.notice( + 'SUNSET_MODE is on: skipping LN node connection and monitoring.', + ); + return; + } // Wait 1 seconds before try to resubscribe hold invoices await delay(1000); await resubscribeInvoices(bot); diff --git a/bot/start.ts b/bot/start.ts index 7623a1ce..dc3054cf 100644 --- a/bot/start.ts +++ b/bot/start.ts @@ -175,6 +175,22 @@ const COMMIT_HASH = (() => { } })(); +const isSunsetMode = (): boolean => process.env.SUNSET_MODE === 'true'; + +// When SUNSET_MODE is on the bot no longer trades: every incoming update is +// answered with a service-discontinued notice in the user's language +const sunsetMiddleware = async (ctx: MainContext): Promise => { + try { + if (ctx.from === undefined) return; + const user = await User.findOne({ tg_id: ctx.from.id.toString() }); + const language = user?.lang || ctx.from.language_code || 'en'; + ctx.i18n.locale(language); + await ctx.reply(ctx.i18n.t('sunset'), { disable_web_page_preview: true }); + } catch (error) { + logger.error(error); + } +}; + const initialize = ( botToken: string, options: Partial>, @@ -194,6 +210,12 @@ const initialize = ( bot.use(session()); bot.use(limit()); bot.use(i18n.middleware()); + + if (isSunsetMode()) { + bot.use(sunsetMiddleware); + return bot; + } + bot.use(stageMiddleware()); bot.use(commandArgsMiddleware()); @@ -1199,4 +1221,4 @@ const start = async ( return bot; }; -export { initialize, start }; +export { initialize, start, sunsetMiddleware }; diff --git a/ln/connect.ts b/ln/connect.ts index 1cedb8ea..2b822e2a 100644 --- a/ln/connect.ts +++ b/ln/connect.ts @@ -32,9 +32,14 @@ if ( macaroon = fs.readFileSync(macaroonPath).toString('base64'); } -// Enforcing presence of LND_GRPC_HOST environment variable +// Enforcing presence of LND_GRPC_HOST environment variable, +// not needed in sunset mode because the node is never called const socket = process.env.LND_GRPC_HOST; -if (!socket && process.env.NODE_ENV !== 'test') { +if ( + !socket && + process.env.NODE_ENV !== 'test' && + process.env.SUNSET_MODE !== 'true' +) { throw new Error('You must provide a LND_GRPC_HOST environment variable'); } diff --git a/locales/en.yaml b/locales/en.yaml index fe6c5aec..8b9ff6de 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -595,6 +595,17 @@ upgraded_to_supergroup: The chat was upgraded to 'supergroup' and the ID has cha community_deleted: This community was deleted due to inactivity. I have unlinked you from it, try to create the order again dispute_too_soon: You can't start a dispute too soon, be patient and wait a few minutes for your counterparty to reply maintenance: 🚨 Bot in maintenance, please try again later 🚨 +sunset: | + ⚠️ This bot is no longer in service + + Thank you for being part of this community. The bot no longer processes orders or payments. + + 📄 Official announcement: https://x.com/negrunch/status/2086899005355704703 + + 🥜 We invite you to keep trading Bitcoin P2P without KYC using Mostro: + + If you want to use Mostro: https://mostro.network + If you want to run your own Mostro node: https://mostro.community/ # START modules/community community_admin: | diff --git a/locales/es.yaml b/locales/es.yaml index 62cd4029..955989e1 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -590,6 +590,17 @@ upgraded_to_supergroup: El chat fue actualizado a 'supergrupo' y el Id ha cambia community_deleted: Esta comunidad fue eliminada por inactividad, te he desvinculado de ella, intenta crear la orden nuevamente dispute_too_soon: No puedes iniciar una disputa tan pronto, ten paciencia y espera unos minutos a que tu contraparte responda maintenance: 🚨 Bot en mantenimiento, inténtalo de nuevo más tarde 🚨 +sunset: | + ⚠️ Este bot ha dejado de prestar servicios + + Gracias por haber sido parte de esta comunidad. El bot ya no procesa órdenes ni pagos. + + 📄 Comunicado oficial: https://x.com/negrunch/status/2086896990256799795 + + 🥜 Te invitamos a seguir comerciando Bitcoin de forma P2P y sin KYC con Mostro: + + Si quieres usar Mostro: https://mostro.network + Si quieres correr tu propio nodo Mostro: https://mostro.community/ # START modules/community community_admin: | diff --git a/tests/bot/sunset.spec.ts b/tests/bot/sunset.spec.ts new file mode 100644 index 00000000..e4f1aeb6 --- /dev/null +++ b/tests/bot/sunset.spec.ts @@ -0,0 +1,105 @@ +import path from 'path'; +import fs from 'fs'; + +import { sunsetMiddleware } from '../../bot/start'; +import { User } from '../../models'; + +const sinon = require('sinon'); +const { expect } = require('chai'); + +const SPANISH_ANNOUNCEMENT = + 'https://x.com/negrunch/status/2086896990256799795'; +const ENGLISH_ANNOUNCEMENT = + 'https://x.com/negrunch/status/2086899005355704703'; + +const makeCtx = (from: any) => { + const locales: string[] = []; + return { + from, + i18n: { + locale: (lang: string) => locales.push(lang), + t: (key: string) => `translated:${key}`, + }, + reply: sinon.stub().resolves(), + locales, + }; +}; + +describe('sunset mode', () => { + let sandbox: any; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('sunsetMiddleware', () => { + it('replies with the sunset notice in the language stored for the user', async () => { + sandbox.stub(User, 'findOne').resolves({ lang: 'es' }); + const ctx = makeCtx({ id: 1, language_code: 'de' }); + + await sunsetMiddleware(ctx as any); + + expect(ctx.locales).to.deep.equal(['es']); + expect(ctx.reply.calledOnce).to.equal(true); + expect(ctx.reply.firstCall.args[0]).to.equal('translated:sunset'); + }); + + it('falls back to the Telegram client language when the user is unknown', async () => { + sandbox.stub(User, 'findOne').resolves(null); + const ctx = makeCtx({ id: 1, language_code: 'fr' }); + + await sunsetMiddleware(ctx as any); + + expect(ctx.locales).to.deep.equal(['fr']); + expect(ctx.reply.calledOnce).to.equal(true); + }); + + it('falls back to English when no language can be determined', async () => { + sandbox.stub(User, 'findOne').resolves(null); + const ctx = makeCtx({ id: 1 }); + + await sunsetMiddleware(ctx as any); + + expect(ctx.locales).to.deep.equal(['en']); + expect(ctx.reply.calledOnce).to.equal(true); + }); + + it('does not reply when the update has no sender', async () => { + const findOne = sandbox.stub(User, 'findOne'); + const ctx = makeCtx(undefined); + + await sunsetMiddleware(ctx as any); + + expect(findOne.called).to.equal(false); + expect(ctx.reply.called).to.equal(false); + }); + }); + + describe('sunset locale messages', () => { + const readLocale = (lang: string) => + fs.readFileSync( + path.join(__dirname, '../../../locales', `${lang}.yaml`), + 'utf8', + ); + + it('spanish message links to the spanish announcement and Mostro', () => { + const es = readLocale('es'); + expect(es).to.include('sunset:'); + expect(es).to.include(SPANISH_ANNOUNCEMENT); + expect(es).to.include('https://mostro.network'); + expect(es).to.include('https://mostro.community'); + }); + + it('english message links to the english announcement and Mostro', () => { + const en = readLocale('en'); + expect(en).to.include('sunset:'); + expect(en).to.include(ENGLISH_ANNOUNCEMENT); + expect(en).to.include('https://mostro.network'); + expect(en).to.include('https://mostro.community'); + }); + }); +});