From 3d0c4a183eed63092df94e4b23894d7101b8a2a4 Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 20 Oct 2026 12:03:36 +0800 Subject: [PATCH] Email yourself when your Bots finish, fail or wait for approval (#355) --- .github/scripts/docker-profile.test.mjs | 1 + changelog.d/355-email-to-self.added.md | 1 + compose.yaml | 4 + core/client.js | 11 + core/index.js | 3 +- core/src/client/i18n.js | 3 + core/src/client/settings.js | 11 +- core/src/host/approvals.js | 2 + core/src/host/authz.js | 1 + core/src/host/channel.js | 10 + core/src/host/email.js | 249 ++++++++++++++++++++++ core/src/host/turns.js | 7 +- core/test/approval-notices.test.js | 4 + core/test/email.test.js | 211 ++++++++++++++++++ core/test/install.test.js | 4 + docs/docker.md | 10 + docs/docker.zh.md | 10 + docs/subsystems/core-client.md | 1 + docs/subsystems/core-host.md | 4 + docs/subsystems/install.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 38 +++- patches/ds-bot/0002-flybot-api-host.patch | 10 +- patches/ds-bot/0005-social-seams.patch | 19 +- patches/ds-bot/0008-client-bundle.patch | 44 ++-- patches/ds-bot/0014-flybot-branding.patch | 13 +- patches/ds-bot/files/src/host/authz.js | 1 + patches/ds-bot/files/src/host/email.js | 249 ++++++++++++++++++++++ patches/ds-bot/files/test/email.test.js | 211 ++++++++++++++++++ patches/ds-bot/topics.json | 3 +- 29 files changed, 1101 insertions(+), 36 deletions(-) create mode 100644 changelog.d/355-email-to-self.added.md create mode 100644 core/src/host/email.js create mode 100644 core/test/email.test.js create mode 100644 patches/ds-bot/files/src/host/email.js create mode 100644 patches/ds-bot/files/test/email.test.js diff --git a/.github/scripts/docker-profile.test.mjs b/.github/scripts/docker-profile.test.mjs index 91b939f2..b2c9064e 100644 --- a/.github/scripts/docker-profile.test.mjs +++ b/.github/scripts/docker-profile.test.mjs @@ -77,6 +77,7 @@ test('secure defaults: loopback port, non-root user, no secret in the image, no assert.match(compose, /^ {6}- "127\.0\.0\.1:\$\{FLYBOT_PORT:-3080\}:3080"$/m, 'the port is published on the host loopback only') assert.match(compose, /^ {6}- data:\/data$/m, 'one data volume') assert.match(compose, /^ {6}- DEEPSEEK_API_KEY$/m, 'the key is passed through, never written into the file') + for (const name of ['FLYBOT_SMTP_URL', 'FLYBOT_EMAIL_TO', 'FLYBOT_URL']) assert.match(compose, new RegExp(`^ {6}- ${name}$`, 'm'), `${name} is passed through, never written into the file (#355)`) const dockerfile = read('Dockerfile') assert.match(dockerfile, /^USER node$/m) assert.match(dockerfile, /^VOLUME \["\/data"\]$/m) diff --git a/changelog.d/355-email-to-self.added.md b/changelog.d/355-email-to-self.added.md new file mode 100644 index 00000000..6db57148 --- /dev/null +++ b/changelog.d/355-email-to-self.added.md @@ -0,0 +1 @@ +FlyBot can email you, at your own address, when one of your Bots finishes, fails or waits for your approval: a short line plus a link back into FlyBot, at most one email every five minutes, with no chat or tool-call content. You approve only in FlyBot; replies to the email are not read. Set your own SMTP account with `FLYBOT_SMTP_URL`, `FLYBOT_EMAIL_TO` and `FLYBOT_URL` (see the [Docker guide](docs/docker.md)), then switch on Settings → General → **Email me**, which is off by default ([#355](https://github.com/ThinkFlowLab/FlyBot/issues/355)). diff --git a/compose.yaml b/compose.yaml index 721add79..f66bbfa8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,6 +16,10 @@ services: # Passed only when set in your shell or in .env next to this file; otherwise save the # key in the app (it is kept on the data volume). - DEEPSEEK_API_KEY + # Email to yourself (#355), optional; set in .env, see docs/docker.md. + - FLYBOT_SMTP_URL + - FLYBOT_EMAIL_TO + - FLYBOT_URL volumes: - data:/data volumes: diff --git a/core/client.js b/core/client.js index a5051ae9..359c2713 100644 --- a/core/client.js +++ b/core/client.js @@ -711,6 +711,9 @@ var ZH = { "{name} failed: {reason}": "{name} 失败:{reason}", Notifications: "通知", "When your Bots finish, fail, or wait for your approval while you are in another chat or app.": "你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。", + "Email me": "发邮件给我", + "The same news by email to {address}: a short line and a link back, at most every five minutes. Approve only in FlyBot.": "同样的提醒也发邮件到 {address}:一句话加一个回到 FlyBot 的链接,最多每五分钟一封。批准只能在 FlyBot 里进行。", + "Needs email set up on the server (see the Docker guide).": "需要先在服务器上设置邮件(见 Docker 指南)。", "{first} and {second} are waiting for your approval": "{first}、{second} 等你批准", "{first}, {second} and {count} more are waiting for your approval": "{first}、{second} 等 {total} 个等你批准", "Waiting for your approval": "等你批准", @@ -13785,6 +13788,14 @@ function GeneralPage({ roster, actions, run }) { hint: t("When your Bots finish, fail, or wait for your approval while you are in another chat or app."), on: prefs.notifications !== false, onChange: (on) => run(actions.setPrefs({ notifications: on })) + }), + // Email to yourself (#355): off by default, and only where the server has email set up. + (0, import_react33.createElement)(SwitchRow, { + label: t("Email me"), + hint: roster.email?.to ? t("The same news by email to {address}: a short line and a link back, at most every five minutes. Approve only in FlyBot.", { address: roster.email.to }) : t("Needs email set up on the server (see the Docker guide)."), + on: Boolean(roster.email?.to) && prefs.email === true, + disabled: !roster.email?.to, + onChange: (on) => run(actions.setPrefs({ email: on })) }) ) ]; diff --git a/core/index.js b/core/index.js index 86ef38df..3b19aed3 100644 --- a/core/index.js +++ b/core/index.js @@ -22,6 +22,7 @@ import * as tools from './src/host/tools.js' import * as chatIndex from './src/host/chat-index.js' import * as groups from './src/host/groups.js' import * as approvals from './src/host/approvals.js' +import * as email from './src/host/email.js' import * as resume from './src/host/resume.js' import * as memory from './src/host/memory.js' import * as parts from './src/host/parts.js' @@ -49,7 +50,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel] +const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, email, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 7832798d..5426de28 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -655,6 +655,9 @@ const ZH = { '{name} failed: {reason}': '{name} 失败:{reason}', Notifications: '通知', 'When your Bots finish, fail, or wait for your approval while you are in another chat or app.': '你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。', + 'Email me': '发邮件给我', + 'The same news by email to {address}: a short line and a link back, at most every five minutes. Approve only in FlyBot.': '同样的提醒也发邮件到 {address}:一句话加一个回到 FlyBot 的链接,最多每五分钟一封。批准只能在 FlyBot 里进行。', + 'Needs email set up on the server (see the Docker guide).': '需要先在服务器上设置邮件(见 Docker 指南)。', '{first} and {second} are waiting for your approval': '{first}、{second} 等你批准', '{first}, {second} and {count} more are waiting for your approval': '{first}、{second} 等 {total} 个等你批准', 'Waiting for your approval': '等你批准', diff --git a/core/src/client/settings.js b/core/src/client/settings.js index 93bbb4d5..1121927c 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -36,7 +36,7 @@ function SetRow({ label, hint, children }) { children ? h('div', { className: 'bt-set-control' }, children) : null) } -// General is Appearance and Notifications; the switch to classic Agent mode is the account menu's (#393). +// General is Appearance and Notifications (system notifications, email to yourself); the switch to classic Agent mode is the account menu's (#393). function GeneralPage({ roster, actions, run }) { const prefs = roster.prefs ?? {} const themes = allThemes() @@ -69,6 +69,15 @@ function GeneralPage({ roster, actions, run }) { label: t('Notifications'), showLabel: false, hint: t('When your Bots finish, fail, or wait for your approval while you are in another chat or app.'), on: prefs.notifications !== false, onChange: on => run(actions.setPrefs({ notifications: on })), + }), + // Email to yourself (#355): off by default, and only where the server has email set up. + h(SwitchRow, { + label: t('Email me'), + hint: roster.email?.to + ? t('The same news by email to {address}: a short line and a link back, at most every five minutes. Approve only in FlyBot.', { address: roster.email.to }) + : t('Needs email set up on the server (see the Docker guide).'), + on: Boolean(roster.email?.to) && prefs.email === true, disabled: !roster.email?.to, + onChange: on => run(actions.setPrefs({ email: on })), })), ] } diff --git a/core/src/host/approvals.js b/core/src/host/approvals.js index 9e7fa71a..3d730847 100644 --- a/core/src/host/approvals.js +++ b/core/src/host/approvals.js @@ -29,6 +29,8 @@ export function install(rt) { at: Date.now(), } pending.set(id, entry) + // Email to yourself, when switched on (email.js, #355). + rt.noticeOffApp?.('approval', entry) rt.noteApproval?.(sessionId, { tool: entry.tool, reason: entry.reason.slice(0, 200) }) const settled = () => { pending.delete(id) diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 33a24551..17e24956 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -169,6 +169,7 @@ export function install(rt) { agents: alone() ? full.agents : [], secrets: alone() ? full.secrets : [], secretRequests: alone() ? full.secretRequests : {}, + email: alone() ? full.email : null, } } rt.ownedLive = (full, actor) => { diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 5c34ffe2..4f37e608 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -99,6 +99,8 @@ export function install(rt) { permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), prefs: state.prefs, + // Email to yourself (#355): the address when this server has email set up, else null. + email: rt.emailView?.() ?? null, // Whether a Desktop window should serve Bot browser reads (see browser.js). browser: rt.browserEnabled, // False when config.memory turns memory off: no Memory row or panel. @@ -170,6 +172,14 @@ export function install(rt) { else if (body.notifications === false) state.prefs.notifications = false else throw invalid('Notifications must be on or off') } + // Email to yourself is off unless switched on, and only where the server has it set up (#355). + if (body.email !== undefined) { + if (body.email === null || body.email === false) delete state.prefs.email + else if (body.email !== true) throw invalid('Email must be on or off') + else if (!rt.emailView?.()) throw invalid('Email is not set up on this server') + else state.prefs.email = true + rt.emailSwitched?.(body.email === true) + } return rt.save() } diff --git a/core/src/host/email.js b/core/src/host/email.js new file mode 100644 index 00000000..8d61f83d --- /dev/null +++ b/core/src/host/email.js @@ -0,0 +1,249 @@ +// Email to yourself (#355, first slice). With the owner's opt-in (Settings → General → +// Email me, off by default), the system notifications of #353 (one of your Bots finished or +// failed a turn, or waits for your approval) also reach your own address, open window or not: +// a short line each plus a link back into FlyBot, at most one email every five minutes. An +// email never carries chat or tool-call content and never approves anything; a reply to it is +// not read. A send that fails is noted in the diagnostics log (code only) and not retried. +// +// The server is the owner's own SMTP account, set in the environment (docs/docker.md): +// FLYBOT_SMTP_URL smtps://user:password@smtp.example.com (implicit TLS, port 465), or +// smtp://user:password@smtp.example.com:587 (STARTTLS, required with a password) +// FLYBOT_EMAIL_TO your own address; the only recipient there is +// FLYBOT_URL the address you open FlyBot at, for the link (default http://127.0.0.1:3080/) +// The password stays in the process: it is never saved, shown, logged or sent without TLS. +import net from 'node:net' +import tls from 'node:tls' +import { hostname } from 'node:os' +import { randomUUID } from 'node:crypto' + +const ADDRESS = /^[^\s@<>()",;:\\[\]]+@[^\s@<>()",;:\\[\]]+\.[^\s@<>()",;:\\[\]]+$/ +const DEFAULT_URL = 'http://127.0.0.1:3080/' +// One email gathers what happened within a minute; at most one email every five minutes. +const BATCH_MS = 60_000 +const GAP_MS = 5 * 60_000 +const LINES_SHOWN = 10 +const TIMEOUT_MS = 30_000 + +// The SMTP settings from the environment, or undefined when email is not set up (or set up wrong: +// a bad value is never half-used). +export function emailConfig(env = process.env) { + const to = env.FLYBOT_EMAIL_TO?.trim() + const raw = env.FLYBOT_SMTP_URL?.trim() + if (!to || !raw || !ADDRESS.test(to)) return undefined + let url + try { url = new URL(raw) } catch { return undefined } + if ((url.protocol !== 'smtp:' && url.protocol !== 'smtps:') || !url.hostname) return undefined + const secure = url.protocol === 'smtps:' + let user, password + try { + user = decodeURIComponent(url.username) + password = decodeURIComponent(url.password) + } catch { return undefined } + let link = env.FLYBOT_URL?.trim() || DEFAULT_URL + try { link = new URL(link).href } catch { link = DEFAULT_URL } + if (!/^https?:/.test(link)) link = DEFAULT_URL + return { + host: url.hostname.replace(/^\[|\]$/g, ''), + port: Number(url.port) || (secure ? 465 : 587), + secure, + ...(user ? { user, password } : {}), + to, + from: user && ADDRESS.test(user) ? user : to, + link, + } +} + +// RFC 2047 for a header that is not plain ASCII; CR and LF never reach a header. +const header = text => { + const flat = String(text).replace(/[\r\n]+/g, ' ').trim() + return /^[\x20-\x7e]*$/.test(flat) ? flat : `=?UTF-8?B?${Buffer.from(flat).toString('base64')}?=` +} + +export function message({ from, to, subject, text, now = new Date() }) { + const body = Buffer.from(text.replace(/\r?\n/g, '\r\n')).toString('base64').replace(/.{76}/g, '$&\r\n') + return [ + `From: FlyBot <${from}>`, + `To: <${to}>`, + `Subject: ${header(subject)}`, + `Date: ${now.toUTCString().replace('GMT', '+0000')}`, + `Message-ID: <${randomUUID()}@flybot>`, + 'Auto-Submitted: auto-generated', + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=utf-8', + 'Content-Transfer-Encoding: base64', + '', + body, + ].join('\r\n') +} + +const smtpError = (code) => Object.assign(new Error(`SMTP ${code}`), { code: `SMTP_${code}` }) + +// One email over SMTP. Errors carry a code only (`SMTP_`, `SMTP_NO_TLS`, +// `SMTP_TIMEOUT`, `SMTP_CLOSED`, or the socket's code); the server's text is never kept. +export function sendMail(config, { subject, text }, { timeoutMs = TIMEOUT_MS } = {}) { + return new Promise((resolve, reject) => { + let socket + let buffer = '' + let lines = [] + let waiter + let finished = false + // Once the server took the message, a close (after QUIT, or instead of its answer) is no failure. + let delivered = false + const finish = (error) => { + if (finished) return + finished = true + clearTimeout(timer) + socket?.destroy() + if (error && !delivered) reject(error) + else resolve() + } + const timer = setTimeout(() => finish(smtpError('TIMEOUT')), timeoutMs) + // A reply is complete at its line with a space after the code. + const onData = (chunk) => { + buffer += chunk.toString('utf8') + let end + while ((end = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, end).replace(/\r$/, '') + buffer = buffer.slice(end + 1) + lines.push(line) + if (/^\d{3}(?: |$)/.test(line)) { + const reply = { code: Number(line.slice(0, 3)), lines } + lines = [] + const next = waiter + waiter = undefined + next?.(reply) + } + } + } + const attach = (next) => { + socket = next + socket.on('data', onData) + socket.on('error', error => finish(Object.assign(new Error('SMTP connection failed'), { code: error?.code ?? 'SMTP_CLOSED' }))) + socket.on('close', () => finish(smtpError('CLOSED'))) + } + const read = () => new Promise((done) => { waiter = done }) + const expect = async (ok) => { + const reply = await read() + if (!ok.includes(reply.code)) throw smtpError(reply.code) + return reply + } + const command = (line, ok) => { + socket.write(`${line}\r\n`) + return expect(ok) + } + const ehlo = async () => (await command(`EHLO ${hostname().replace(/[^\w.-]/g, '') || 'flybot'}`, [250])).lines.map(line => line.slice(4).toUpperCase()) + + const run = async () => { + await expect([220]) + let caps = await ehlo() + let secure = config.secure + if (!secure && caps.includes('STARTTLS')) { + await command('STARTTLS', [220]) + const plain = socket + plain.removeAllListeners('data') + plain.removeAllListeners('close') + plain.removeAllListeners('error') + await new Promise((done, fail) => { + const upgraded = tls.connect({ socket: plain, servername: net.isIP(config.host) ? undefined : config.host }, done) + attach(upgraded) + upgraded.once('error', fail) + }) + secure = true + caps = await ehlo() + } + if (config.user) { + // A password never goes over a connection without TLS. + if (!secure) throw smtpError('NO_TLS') + const auth = caps.find(cap => cap.startsWith('AUTH')) ?? '' + if (/\bPLAIN\b/.test(auth) || !/\bLOGIN\b/.test(auth)) { + await command(`AUTH PLAIN ${Buffer.from(`\0${config.user}\0${config.password}`).toString('base64')}`, [235]) + } else { + await command('AUTH LOGIN', [334]) + await command(Buffer.from(config.user).toString('base64'), [334]) + await command(Buffer.from(config.password).toString('base64'), [235]) + } + } + await command(`MAIL FROM:<${config.from}>`, [250]) + await command(`RCPT TO:<${config.to}>`, [250, 251]) + await command('DATA', [354]) + await command(`${message({ from: config.from, to: config.to, subject, text })}\r\n.`, [250]) + delivered = true + await command('QUIT', [221]).catch(() => { /* delivered already */ }) + } + + const options = { host: config.host, port: config.port } + attach(config.secure ? tls.connect({ ...options, servername: net.isIP(config.host) ? undefined : config.host }) : net.connect(options)) + run().then(() => finish(), finish) + }) +} + +export function install(rt, { env = process.env, send = sendMail } = {}) { + const config = emailConfig(env) + let queue = [] + let timer + let lastSent = -Infinity + + // Email only while one human uses this FlyBot: the address is theirs (set-prefs is blocked + // with more members, authz.js). + const on = () => config !== undefined && rt.state?.prefs?.email === true && Object.keys(rt.state.members ?? {}).length <= 1 + const emailView = () => (config ? { to: config.to } : null) + const drop = () => { + queue = [] + if (timer) clearTimeout(timer) + timer = undefined + } + + const lineOf = (event) => { + const bot = rt.botOf(event.botId) + const name = bot?.name ?? 'A Bot' + const room = event.roomId ? rt.roomOf(event.roomId)?.name : undefined + const what = event.kind === 'approval' ? `${name} is waiting for your approval` + : event.kind === 'failed' ? `${name} failed: ${event.failed}` : `${name} finished` + return `- ${what}${room ? ` (${room})` : ''}` + } + + const flush = async () => { + timer = undefined + // Approvals already answered are no news. + const open = new Set((rt.approvalsView?.() ?? []).map(entry => entry.id)) + const events = queue.filter(event => event.kind !== 'approval' || open.has(event.id)) + queue = [] + if (!on() || events.length === 0) return + lastSent = Date.now() + const lines = events.slice(0, LINES_SHOWN).map(lineOf) + if (events.length > LINES_SHOWN) lines.push(`- and ${events.length - LINES_SHOWN} more`) + const subject = events.length === 1 ? `FlyBot: ${lineOf(events[0]).slice(2)}` : `FlyBot: ${events.length} updates from your Bots` + const text = [ + ...lines, + '', + `Open FlyBot: ${config.link}`, + '', + 'Approve or reject only in FlyBot. Replies to this email are not read.', + 'Turn these emails off in FlyBot: Settings > General > Email me.', + ].join('\n') + try { + await send(config, { subject, text }) + } catch (error) { + rt.warn(`ds-bot: the email to yourself was not sent (${typeof error?.code === 'string' ? error.code : 'failed'})`) + } + } + + // A notification of #353 (approval waiting, turn finished or failed) of one of the owner's own + // Bots. Queued only while the switch is on; switching it off drops the queue at once. + const noticeOffApp = (kind, entry) => { + if (!on()) return drop() + const bot = rt.botOf(entry?.botId) + if (bot === undefined || bot.remote !== undefined) return + queue.push({ kind, id: entry.id, botId: entry.botId, roomId: entry.roomId, failed: entry.failed }) + if (timer) return + timer = setTimeout(() => { flush().catch(() => { /* logged in flush */ }) }, Math.max(BATCH_MS, lastSent + GAP_MS - Date.now())) + timer.unref?.() + } + + // Off: whatever waits to be sent is dropped. + const emailSwitched = (value) => { + if (!value) drop() + } + + Object.assign(rt, { emailView, noticeOffApp, emailSwitched, emailConfigured: () => config !== undefined }) +} diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 6f031bcd..15f842f8 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -268,13 +268,16 @@ export function install(rt) { if (owner === undefined && state.exchangeTurns?.[id]?.includes(turn)) return if (owner !== undefined && rt.roomOf(owner.roomId)?.origin !== undefined) return const at = Date.now() - ended = [...ended, { + const entry = { id: `${id}:${turn}:${at}`, botId: bot.id, ...(owner ? { roomId: owner.roomId } : { sessionId: id }), at, ...(failure ? { failed: (failure.message || failure.code).slice(0, 160) } : {}), - }].slice(-ENDED_KEPT) + } + ended = [...ended, entry].slice(-ENDED_KEPT) + // Email to yourself, when switched on (email.js, #355). + rt.noticeOffApp?.(failure ? 'failed' : 'finished', entry) } const endedView = () => ended diff --git a/core/test/approval-notices.test.js b/core/test/approval-notices.test.js index 99e281e4..9c10e3b1 100644 --- a/core/test/approval-notices.test.js +++ b/core/test/approval-notices.test.js @@ -133,6 +133,9 @@ function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) { switchJobs: () => [], dropKickoff() {}, flybotReplyRemote: async () => ({}), + // Email to yourself hears of the same turns (#355). + offApp: [], + noticeOffApp: (kind, entry) => rt.offApp.push([kind, entry.id.split(':').slice(0, 2).join(':')]), } installTurns(rt) let seq = 0 @@ -167,6 +170,7 @@ test('the host lists finished and failed turns of my own Bots, not stopped, aske ['b:2', { botId: 'b', sessionId: 'b', failed: 'upstream busy' }], ['g1:1', { botId: 'b', roomId: 'r1', failed: 'upstream busy' }], ]) + assert.deepEqual(rt.offApp, [['finished', 'b:1'], ['failed', 'b:2'], ['failed', 'g1:1']]) for (let n = 10; n < 40; n++) turn('b', n, completed) assert.equal(rt.endedView().length, 20) }) diff --git a/core/test/email.test.js b/core/test/email.test.js new file mode 100644 index 00000000..2e8ad9df --- /dev/null +++ b/core/test/email.test.js @@ -0,0 +1,211 @@ +// Email to yourself (#355): the SMTP settings, the message, the SMTP exchange, and which +// notifications become an email when. +import assert from 'node:assert/strict' +import net from 'node:net' +import { test } from 'node:test' +import { emailConfig, install, message, sendMail } from '../src/host/email.js' +import { install as installApprovals } from '../src/host/approvals.js' + +test('the settings come from the environment; anything missing or wrong means no email', () => { + assert.deepEqual(emailConfig({ FLYBOT_SMTP_URL: 'smtps://me%40mail.example:p%40ss@smtp.mail.example', FLYBOT_EMAIL_TO: 'me@mail.example' }), { + host: 'smtp.mail.example', port: 465, secure: true, user: 'me@mail.example', password: 'p@ss', to: 'me@mail.example', from: 'me@mail.example', link: 'http://127.0.0.1:3080/', + }) + assert.deepEqual(emailConfig({ FLYBOT_SMTP_URL: 'smtp://relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example', FLYBOT_URL: 'https://flybot.example.com' }), { + host: 'relay.lan', port: 587, secure: false, to: 'me@mail.example', from: 'me@mail.example', link: 'https://flybot.example.com/', + }) + assert.equal(emailConfig({ FLYBOT_SMTP_URL: 'smtp://user:pw@smtp.example.com:2525', FLYBOT_EMAIL_TO: 'me@mail.example' }).from, 'me@mail.example') + assert.equal(emailConfig({ FLYBOT_SMTP_URL: 'smtp://relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example', FLYBOT_URL: 'javascript:alert(1)' }).link, 'http://127.0.0.1:3080/') + for (const env of [ + {}, + { FLYBOT_SMTP_URL: 'smtp://relay.lan' }, + { FLYBOT_EMAIL_TO: 'me@mail.example' }, + { FLYBOT_SMTP_URL: 'https://relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example' }, + { FLYBOT_SMTP_URL: 'not a url', FLYBOT_EMAIL_TO: 'me@mail.example' }, + { FLYBOT_SMTP_URL: 'smtp://relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example\r\nBcc: other@x.example' }, + { FLYBOT_SMTP_URL: 'smtp://relay.lan', FLYBOT_EMAIL_TO: 'me, other@x.example' }, + ]) assert.equal(emailConfig(env), undefined, JSON.stringify(env)) +}) + +test('a message is plain UTF-8 text with a safe subject', () => { + const raw = message({ from: 'me@mail.example', to: 'me@mail.example', subject: '写手 finished\r\nBcc: x@y.example', text: 'line one\nline two', now: new Date(0) }) + const [head, body] = raw.split('\r\n\r\n') + assert.match(head, /^From: FlyBot \r\nTo: \r\nSubject: =\?UTF-8\?B\?[\w+/=]+\?=\r\n/) + assert.equal(Buffer.from(head.match(/B\?([\w+/=]+)\?=/)[1], 'base64').toString(), '写手 finished Bcc: x@y.example') + assert.doesNotMatch(head, /^Bcc:/m) + assert.match(head, /Auto-Submitted: auto-generated/) + assert.equal(Buffer.from(body, 'base64').toString(), 'line one\r\nline two') + assert.match(message({ from: 'a@b.example', to: 'a@b.example', subject: 'Plain', text: '' }), /\r\nSubject: Plain\r\n/) +}) + +// A minimal SMTP server: no STARTTLS, optional AUTH, a reply code per command on request. +function smtpServer({ auth = false, fail = {} } = {}) { + const seen = [] + const mails = [] + const server = net.createServer((socket) => { + let data = false + let mail = '' + let buffer = '' + socket.write('220 test ESMTP\r\n') + socket.on('data', (chunk) => { + buffer += chunk.toString() + let end + while ((end = buffer.indexOf('\r\n')) >= 0) { + const line = buffer.slice(0, end) + buffer = buffer.slice(end + 2) + if (data) { + if (line === '.') { + data = false + mails.push(mail) + socket.write('250 queued\r\n') + } else mail += `${line}\r\n` + continue + } + seen.push(line) + const verb = line.split(/[ :]/)[0].toUpperCase() + if (fail[verb]) socket.write(`${fail[verb]} no\r\n`) + else if (verb === 'EHLO') socket.write(`250-test\r\n${auth ? '250-AUTH PLAIN LOGIN\r\n' : ''}250 8BITMIME\r\n`) + else if (verb === 'DATA') { data = true; socket.write('354 go\r\n') } + else if (verb === 'QUIT') { socket.end('221 bye\r\n') } + else socket.write('250 ok\r\n') + } + }) + }) + return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, seen, mails, close: () => server.close() }))) +} + +test('an email goes through a relay that needs no password', async (t) => { + const smtp = await smtpServer() + t.after(smtp.close) + await sendMail({ host: '127.0.0.1', port: smtp.port, secure: false, to: 'me@mail.example', from: 'me@mail.example' }, { subject: 'FlyBot: Coder finished', text: '- Coder finished' }) + assert.deepEqual(smtp.seen.slice(1), ['MAIL FROM:', 'RCPT TO:', 'DATA', 'QUIT']) + assert.match(smtp.seen[0], /^EHLO \S+$/) + assert.equal(smtp.mails.length, 1) + assert.match(smtp.mails[0], /Subject: FlyBot: Coder finished/) +}) + +test('a password never goes over a connection without TLS, and a refusal keeps only its code', async (t) => { + const smtp = await smtpServer({ auth: true }) + t.after(smtp.close) + await assert.rejects( + sendMail({ host: '127.0.0.1', port: smtp.port, secure: false, user: 'me', password: 'hunter2', to: 'me@mail.example', from: 'me@mail.example' }, { subject: 's', text: 't' }), + error => error.code === 'SMTP_NO_TLS' && !error.message.includes('hunter2'), + ) + assert.equal(smtp.seen.some(line => /AUTH|aHVudGVyMg|hunter2/.test(line)), false) + assert.equal(smtp.mails.length, 0) + + const refusing = await smtpServer({ fail: { RCPT: 550 } }) + t.after(refusing.close) + await assert.rejects( + sendMail({ host: '127.0.0.1', port: refusing.port, secure: false, to: 'me@mail.example', from: 'me@mail.example' }, { subject: 's', text: 't' }), + error => error.code === 'SMTP_550', + ) +}) + +// The notifier on a stand-in runtime, with mocked timers and a recording sender. +function notifier(t, { prefs = { email: true }, members = {}, env = { FLYBOT_SMTP_URL: 'smtp://relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example', FLYBOT_URL: 'https://flybot.example.com/' }, send } = {}) { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1_000_000 }) + const sent = [] + const warnings = [] + const open = [] + const rt = { + state: { prefs, members }, + botOf: id => ({ b: { id: 'b', name: 'Coder' }, f: { id: 'f', name: 'Pal', remote: { handle: '@pal' } } })[id], + roomOf: id => ({ r1: { id: 'r1', name: 'Ops' } })[id], + approvalsView: () => open.map(id => ({ id })), + warn: line => warnings.push(line), + } + install(rt, { env, send: send ?? (async (config, mail) => { sent.push({ config, ...mail }) }) }) + const advance = async (ms) => { + t.mock.timers.tick(ms) + for (let i = 0; i < 5; i++) await Promise.resolve() + } + return { rt, sent, warnings, open, advance, notice: rt.noticeOffApp } +} + +test('switched on, a minute of news from my own Bots becomes one email with a link back and nothing to act on', async (t) => { + const box = notifier(t) + assert.deepEqual(box.rt.emailView(), { to: 'me@mail.example' }) + box.open.push('a1') + box.notice('finished', { id: 'e1', botId: 'b', sessionId: 'b' }) + box.notice('failed', { id: 'e2', botId: 'b', roomId: 'r1', failed: 'upstream busy' }) + box.notice('approval', { id: 'a1', botId: 'b', roomId: 'r1', tool: 'pwsh', reason: 'rm -rf /tmp/x', detail: { code: 'rm -rf /tmp/x' } }) + box.notice('approval', { id: 'a2', botId: 'b', sessionId: 'b', tool: 'pwsh' }) + box.notice('finished', { id: 'e3', botId: 'f', sessionId: 'f' }) + await box.advance(59_000) + assert.equal(box.sent.length, 0) + await box.advance(1_000) + assert.equal(box.sent.length, 1) + const [mail] = box.sent + assert.equal(mail.subject, 'FlyBot: 3 updates from your Bots') + assert.equal(mail.text, [ + '- Coder finished', + '- Coder failed: upstream busy (Ops)', + '- Coder is waiting for your approval (Ops)', + '', + 'Open FlyBot: https://flybot.example.com/', + '', + 'Approve or reject only in FlyBot. Replies to this email are not read.', + 'Turn these emails off in FlyBot: Settings > General > Email me.', + ].join('\n')) + assert.equal(mail.config.to, 'me@mail.example') + assert.doesNotMatch(mail.text, /pwsh|rm -rf/) +}) + +test('one email every five minutes at most; a single event names itself in the subject', async (t) => { + const box = notifier(t) + box.notice('finished', { id: 'e1', botId: 'b', sessionId: 'b' }) + await box.advance(60_000) + box.notice('finished', { id: 'e2', botId: 'b', sessionId: 'b' }) + await box.advance(4 * 60_000) + assert.equal(box.sent.length, 1) + assert.equal(box.sent[0].subject, 'FlyBot: Coder finished') + await box.advance(60_000) + assert.equal(box.sent.length, 2) +}) + +test('off by default, off with more than one member, off without settings, and switching off drops what waits', async (t) => { + for (const options of [{ prefs: {} }, { members: { 'owner-1': {}, 'owner-2': {} } }, { env: {} }]) { + const box = notifier(t, options) + box.notice('finished', { id: 'e1', botId: 'b', sessionId: 'b' }) + await box.advance(10 * 60_000) + assert.equal(box.sent.length, 0, JSON.stringify(options)) + t.mock.timers.reset() + } + assert.equal(notifier(t, { env: {} }).rt.emailView(), null) + t.mock.timers.reset() + + const box = notifier(t) + box.notice('finished', { id: 'e1', botId: 'b', sessionId: 'b' }) + box.rt.emailSwitched(false) + await box.advance(10 * 60_000) + assert.equal(box.sent.length, 0) +}) + +test('approvals answered before the email goes are left out, and a failed send is noted by its code', async (t) => { + const box = notifier(t) + box.notice('approval', { id: 'a1', botId: 'b', sessionId: 'b' }) + await box.advance(60_000) + assert.equal(box.sent.length, 0) + t.mock.timers.reset() + + const failing = notifier(t, { + env: { FLYBOT_SMTP_URL: 'smtp://u:secret@relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example' }, + send: async () => { throw Object.assign(new Error('535 bad secret'), { code: 'SMTP_535' }) }, + }) + failing.notice('finished', { id: 'e1', botId: 'b', sessionId: 'b' }) + await failing.advance(60_000) + assert.deepEqual(failing.warnings, ['ds-bot: the email to yourself was not sent (SMTP_535)']) +}) + +test('an approval that starts waiting reaches the notifier', async () => { + let middleware + const offApp = [] + installApprovals({ + ctx: { on: (name, fn) => { if (name === 'approval/request') middleware = fn } }, + groupOwners: new Map(), + chatOwners: new Map([['s1', 'b']]), + noticeOffApp: (kind, entry) => offApp.push([kind, entry.botId, entry.sessionId]), + }) + assert.equal(await middleware({ agent: { id: 's1' }, toolName: 'pwsh' }, () => 'allowed'), 'allowed') + assert.deepEqual(offApp, [['approval', 'b', 's1']]) +}) diff --git a/core/test/install.test.js b/core/test/install.test.js index 7c384212..d9236365 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -148,6 +148,10 @@ test('the client channel answers from the saved state', async (t) => { assert.equal((await call('set-prefs', { notifications: false })).value.notifications, false) assert.equal('notifications' in (await call('set-prefs', { notifications: true })).value, false) assert.deepEqual((await call('set-prefs', { notifications: 'loud' })).error, { code: 'bot/invalid', message: 'Notifications must be on or off', details: {} }) + // Email to yourself needs the server's SMTP settings; without them it stays off (#355). + assert.equal(loaded.email, null) + assert.deepEqual((await call('set-prefs', { email: true })).error, { code: 'bot/invalid', message: 'Email is not set up on this server', details: {} }) + assert.equal('email' in (await call('set-prefs', { email: false })).value, false) // A refusal from the team code keeps its message for the user. assert.deepEqual((await call('delete-bot', { id: 'nobody' })).error, { code: 'bot/refused', message: 'Unknown Bot', details: {} }) // Off the Desktop profile there are no Bot browsers. diff --git a/docs/docker.md b/docs/docker.md index 0ddaf610..6906d546 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -51,6 +51,16 @@ ssh -N -L 3080:127.0.0.1:3080 you@your-server Then open the link from the logs on your own machine. A reverse proxy (Caddy, nginx) on the server works too; give it HTTPS, forward to `127.0.0.1:3080`, and start DSH with that name trusted, for example by adding to `compose.yaml` under `flybot`: `command: ["--trusted-host", "flybot.example.com"]`. +**Email to yourself (optional).** FlyBot can email you a short line when one of your Bots finishes, fails or waits for your approval, with a link back into FlyBot. The email carries no chat or tool-call content, and you approve or reject only in FlyBot: a reply to the email is not read. It goes through your own mail account's SMTP server, only to your own address. Add to `.env`: + +```sh +FLYBOT_SMTP_URL=smtps://you%40example.com:app-password@smtp.example.com +FLYBOT_EMAIL_TO=you@example.com +FLYBOT_URL=https://flybot.example.com/ +``` + +`smtps://` is TLS on port 465; `smtp://host:587` uses STARTTLS, and a password is never sent without TLS. Write `@`, `:` and `/` in the user name or password as `%40`, `%3A` and `%2F`. `FLYBOT_URL` is the address you open FlyBot at, for the link (default `http://127.0.0.1:3080/`). Run `docker compose up -d`, then switch on Settings → General → **Email me** (off until you do). You get at most one email every five minutes; a failed send shows in the diagnostics log with its code. The password stays in `.env` and in the process: FlyBot never saves, shows or logs it. + **More people on one machine.** One project name, port and volume each: ```sh diff --git a/docs/docker.zh.md b/docs/docker.zh.md index 4970fc68..10e737aa 100644 --- a/docs/docker.zh.md +++ b/docs/docker.zh.md @@ -51,6 +51,16 @@ ssh -N -L 3080:127.0.0.1:3080 you@your-server 然后在自己的电脑上打开日志里的链接。也可以在服务器上用反向代理(Caddy、nginx):配置 HTTPS,转发到 `127.0.0.1:3080`,并让 DSH 信任这个域名,例如在 `compose.yaml` 的 `flybot` 下加上 `command: ["--trusted-host", "flybot.example.com"]`。 +**发邮件给自己(可选)。** 你的 Bot 完成、失败或等你批准时,FlyBot 可以给你发一封邮件:一句话加一个回到 FlyBot 的链接。邮件里没有对话或工具调用的内容;批准或拒绝只能在 FlyBot 里进行,回复邮件不会被读取。邮件走你自己邮箱账号的 SMTP 服务器,只发到你自己的地址。在 `.env` 里加上: + +```sh +FLYBOT_SMTP_URL=smtps://you%40example.com:app-password@smtp.example.com +FLYBOT_EMAIL_TO=you@example.com +FLYBOT_URL=https://flybot.example.com/ +``` + +`smtps://` 是 465 端口的 TLS;`smtp://host:587` 用 STARTTLS,密码不会在没有 TLS 的连接上发送。用户名或密码里的 `@`、`:`、`/` 写成 `%40`、`%3A`、`%2F`。`FLYBOT_URL` 是你打开 FlyBot 的地址,用于邮件里的链接(默认 `http://127.0.0.1:3080/`)。运行 `docker compose up -d`,再打开 设置 → 通用 → **发邮件给我**(默认关闭)。最多每五分钟一封;发送失败会带错误代码记在诊断日志里。密码只留在 `.env` 和进程里:FlyBot 不会保存、显示或记录它。 + **一台机器上多人使用。** 每人一个项目名、端口和数据卷: ```sh diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e07f093a..0bbeaa52 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -115,6 +115,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### System notifications (#353) - `approval-notices.js` `noticeApprovals` raises one system notification per event while the window is in the background or shows another chat: an approval that starts waiting (`{name} is waiting for your approval`), and a turn of one of the user's own Bots that finished (`{name} finished`) or failed (`{name} failed: `), from `work.ended` (`turns.js` `endedView`: the last 20, process-local; no friend's Bot, no turn a friend started, no hidden turn another Bot asked for, no face-to-face or inbound-share room, no turn the user stopped). Only when the platform already allows notifications (never asks), one per event (`tag` `ds-bot-approval-` / `ds-bot-turn-`), nothing from before the window opened; a click focuses the window and opens the chat or group, and a notification never approves, rejects or sends. Settings → General → **Notifications** (`prefs.notifications`, on unless `false`) switches every kind off; events seen while off stay told. Quiet hours, mute and *waiting too long* are later slices. Covered by `core/test/approval-notices.test.js`. +- Settings → General → Notifications also has **Email me** (#355): disabled with a hint while the server has no email set up (`roster.email` null), else it names `roster.email.to` and sets `prefs.email`. The sending is the host's ([core-host](core-host.md#email-to-yourself-355)). ### Bot tree chevron diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index da72f545..53c59aa5 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -145,6 +145,10 @@ credentials and team mode remain separate work under #55/#198. - `turns.js` keeps `endedView()`: the last 20 turns of the user's own Bots that finished (`reason.kind === 'completed'`) or failed (`failed`: the failure's message, else its code, at most 160 characters; an AUTH message stays out as in `turnFailure`), as `{ id: '::', botId, sessionId | roomId, at }`. Process-local, never saved. Not listed: a turn stopped, switched to a new part or retried for a tool call written as text; a friend's Bot (`bot.remote`); a turn a friend started (`fromRemote`, a remote-chat line); a hidden turn another Bot asked for (`exchangeTurns`); a group turn in a room with an `origin` (face-to-face, inbound-share). It rides `live().work.ended`; `rt.ownedLive` keeps the actor's own Bots' entries. `set-prefs` takes `notifications: false` (kept) or `true` / `null` (cleared: on by default). The client's use is in [core-client](core-client.md#system-notifications-353). +### Email to yourself (#355) + +- `email.js`: with `prefs.email === true` (Settings → General → **Email me**, off by default), the events behind the system notifications (`turns.js` `noteEnded` finished/failed, `approvals.js` an approval that starts waiting) call `rt.noticeOffApp(kind, entry)`; a friend's Bot never counts. The first event opens a one-minute batch; one email per batch, at most one every five minutes; approvals answered before the send are dropped; switching off (`set-prefs` `email: false`, `rt.emailSwitched`) drops the queue. Only while the deployment has one member (the address is that human's; `set-prefs` is blocked with more). Settings come from the environment only: `FLYBOT_SMTP_URL` (`smtps://` implicit TLS on 465, `smtp://` STARTTLS on 587; with a user the password is refused without TLS, `SMTP_NO_TLS`), `FLYBOT_EMAIL_TO` (the one recipient), `FLYBOT_URL` (the link, default `http://127.0.0.1:3080/`); anything malformed means no email. The body is one line per event (` finished`, ` failed: `, ` is waiting for your approval`, plus the group name), the link, and "Approve or reject only in FlyBot. Replies to this email are not read."; never a tool name, arguments or chat text. The SMTP client is `node:net`/`node:tls` with certificate checks; a failure is `rt.warn` with its code only (`SMTP_`, `SMTP_TIMEOUT`, …), never the server's text or the password, and is not retried. The view carries `email: { to } | null` (`authz.js` hides it with more than one member); `set-prefs` refuses `email: true` when nothing is set up. Slack, retries with backoff, a delivery log and the 24/7 runtime are later slices. Covered by `core/test/email.test.js`. + ### Diagnostics - Host diagnostics never depend on `ctx.logger`: DSH may export no logger, and a test or plugin-local host may carry one without `warn`. `rt.warn` still writes stderr and the diagnostics log in that case (`src/host/diagnostics.js`). Plugins reach the same log through `flybot.warn` (#68); see [plugin-runtime](plugin-runtime.md). diff --git a/docs/subsystems/install.md b/docs/subsystems/install.md index 156addfc..08c5de16 100644 --- a/docs/subsystems/install.md +++ b/docs/subsystems/install.md @@ -38,7 +38,7 @@ Tooling for setup checks, the architecture-docs gate and the container image (#3 | `core/scripts/install-doctor.mjs` | Install doctor: Node floor, the packages (`core`, `plugins/social`, `plugins/tree`, `plugins/timeline`) and the install commands (core, social, tree; `@flybot/timeline` is opt-in and not in the default commands, #281). | | `core/scripts/local-ci.sh` | Local run of CI's steps, including each plugin's suite. | | `Dockerfile` | Packs `core` and the plugins with `check-pack.mjs` (the release tarballs), then a runtime with DSH (pinned like the README), pnpm and tini, as user `node`, one volume `/data`. | -| `compose.yaml` | One instance: port on the host's `127.0.0.1`, volume `data`, `DEEPSEEK_API_KEY` passed through only when set. | +| `compose.yaml` | One instance: port on the host's `127.0.0.1`, volume `data`, `DEEPSEEK_API_KEY` and the email-to-yourself settings (`FLYBOT_SMTP_URL`, `FLYBOT_EMAIL_TO`, `FLYBOT_URL`, #355) passed through only when set. | | `docker/entrypoint.mjs`, `docker/profile.mjs` | Each start links the image's packages into `/data/profiles/web` (other plugins and `cordis.patch.yml` kept), `pnpm install`, then `dsh web` in `/data/workspace`. | | `docker/web.patch.yml` | Container-only overlay: `webserver.host: 0.0.0.0` (the CLI refuses `--host 0.0.0.0`; inside the container it reaches only the container's network). | | `docker/healthcheck.mjs` | Healthy when `GET /` answers (401 without the cookie). | diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index ad614280..8ebf3048 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -746,7 +746,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Stopped by': '叫停者:', '{count} messages with': '{count} 条消息,来自', 'Routine started': '例行任务开始', -@@ -450,9 +450,16 @@ const ZH = { +@@ -450,9 +450,19 @@ const ZH = { 'Asked to make the tool call it wrote as text': '工具调用被写成了文字,已让它重新调用', 'A Bot': '一个 Bot', '{name} is waiting for your approval': '{name} 等你批准', @@ -754,6 +754,9 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + '{name} failed: {reason}': '{name} 失败:{reason}', + Notifications: '通知', + 'When your Bots finish, fail, or wait for your approval while you are in another chat or app.': '你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。', ++ 'Email me': '发邮件给我', ++ 'The same news by email to {address}: a short line and a link back, at most every five minutes. Approve only in FlyBot.': '同样的提醒也发邮件到 {address}:一句话加一个回到 FlyBot 的链接,最多每五分钟一封。批准只能在 FlyBot 里进行。', ++ 'Needs email set up on the server (see the Docker guide).': '需要先在服务器上设置邮件(见 Docker 指南)。', '{first} and {second} are waiting for your approval': '{first}、{second} 等你批准', '{first}, {second} and {count} more are waiting for your approval': '{first}、{second} 等 {total} 个等你批准', 'Waiting for your approval': '等你批准', @@ -1538,6 +1541,18 @@ diff --git a/src/host/agents.js b/src/host/agents.js throw error } return agent +diff --git a/src/host/approvals.js b/src/host/approvals.js +--- a/src/host/approvals.js ++++ b/src/host/approvals.js +@@ -29,6 +29,8 @@ export function install(rt) { + at: Date.now(), + } + pending.set(id, entry) ++ // Email to yourself, when switched on (email.js, #355). ++ rt.noticeOffApp?.('approval', entry) + rt.noteApproval?.(sessionId, { tool: entry.tool, reason: entry.reason.slice(0, 200) }) + const settled = () => { + pending.delete(id) diff --git a/src/host/capabilities.js b/src/host/capabilities.js --- a/src/host/capabilities.js +++ b/src/host/capabilities.js @@ -2919,7 +2934,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js markExchangeTurn(sessionId, entry.turn) } } -@@ -238,6 +238,31 @@ export function install(rt) { +@@ -238,6 +238,34 @@ export function install(rt) { return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) } } @@ -2938,13 +2953,16 @@ diff --git a/src/host/turns.js b/src/host/turns.js + if (owner === undefined && state.exchangeTurns?.[id]?.includes(turn)) return + if (owner !== undefined && rt.roomOf(owner.roomId)?.origin !== undefined) return + const at = Date.now() -+ ended = [...ended, { ++ const entry = { + id: `${id}:${turn}:${at}`, + botId: bot.id, + ...(owner ? { roomId: owner.roomId } : { sessionId: id }), + at, + ...(failure ? { failed: (failure.message || failure.code).slice(0, 160) } : {}), -+ }].slice(-ENDED_KEPT) ++ } ++ ended = [...ended, entry].slice(-ENDED_KEPT) ++ // Email to yourself, when switched on (email.js, #355). ++ rt.noticeOffApp?.(failure ? 'failed' : 'finished', entry) + } + const endedView = () => ended + @@ -3192,7 +3210,7 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js const module = { exports: {} } runInNewContext(bundle.outputFiles[0].text, { module, exports: module.exports, Notification, -@@ -62,3 +62,109 @@ test('no notification for the conversation on screen, for approvals older than t +@@ -62,3 +62,113 @@ test('no notification for the conversation on screen, for approvals older than t denied.work.set({ bots: {}, approvals: [{ id: 'x1', botId: 'b', roomId: 'r1', tool: 'pwsh', at: 200 }] }) assert.equal(denied.shown.length, 0) }) @@ -3265,6 +3283,9 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js + switchJobs: () => [], + dropKickoff() {}, + flybotReplyRemote: async () => ({}), ++ // Email to yourself hears of the same turns (#355). ++ offApp: [], ++ noticeOffApp: (kind, entry) => rt.offApp.push([kind, entry.id.split(':').slice(0, 2).join(':')]), + } + installTurns(rt) + let seq = 0 @@ -3299,6 +3320,7 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js + ['b:2', { botId: 'b', sessionId: 'b', failed: 'upstream busy' }], + ['g1:1', { botId: 'b', roomId: 'r1', failed: 'upstream busy' }], + ]) ++ assert.deepEqual(rt.offApp, [['finished', 'b:1'], ['failed', 'b:2'], ['failed', 'g1:1']]) + for (let n = 10; n < 40; n++) turn('b', n, completed) + assert.equal(rt.endedView().length, 20) +}) @@ -4027,7 +4049,7 @@ diff --git a/test/install.test.js b/test/install.test.js 'effect ds-bot: api route', 'route POST /api/bot', 'effect ds-bot: load state', -@@ -137,6 +137,10 @@ test('the client channel answers from the saved state', async (t) => { +@@ -137,6 +137,14 @@ test('the client channel answers from the saved state', async (t) => { assert.equal((await call('set-prefs', { motion: 'lively' })).value.motion, 'lively') assert.equal('motion' in (await call('set-prefs', { motion: 'normal' })).value, false) assert.deepEqual((await call('set-prefs', { motion: 'wild' })).error, { code: 'bot/invalid', message: 'Motion must be quiet, normal or lively', details: {} }) @@ -4035,6 +4057,10 @@ diff --git a/test/install.test.js b/test/install.test.js + assert.equal((await call('set-prefs', { notifications: false })).value.notifications, false) + assert.equal('notifications' in (await call('set-prefs', { notifications: true })).value, false) + assert.deepEqual((await call('set-prefs', { notifications: 'loud' })).error, { code: 'bot/invalid', message: 'Notifications must be on or off', details: {} }) ++ // Email to yourself needs the server's SMTP settings; without them it stays off (#355). ++ assert.equal(loaded.email, null) ++ assert.deepEqual((await call('set-prefs', { email: true })).error, { code: 'bot/invalid', message: 'Email is not set up on this server', details: {} }) ++ assert.equal('email' in (await call('set-prefs', { email: false })).value, false) // A refusal from the team code keeps its message for the user. assert.deepEqual((await call('delete-bot', { id: 'nobody' })).error, { code: 'bot/refused', message: 'Unknown Bot', details: {} }) // Off the Desktop profile there are no Bot browsers. diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index fb4a213c..463fd321 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -15,6 +15,14 @@ diff --git a/index.js b/index.js import * as models from './src/host/models.js' import * as permissions from './src/host/permissions.js' import * as turns from './src/host/turns.js' +@@ -17,6 +17,7 @@ import * as tools from './src/host/tools.js' + import * as chatIndex from './src/host/chat-index.js' + import * as groups from './src/host/groups.js' + import * as approvals from './src/host/approvals.js' ++import * as email from './src/host/email.js' + import * as resume from './src/host/resume.js' + import * as memory from './src/host/memory.js' + import * as parts from './src/host/parts.js' @@ -24,11 +24,15 @@ import * as images from './src/host/images.js' import * as browser from './src/host/browser.js' import * as secrets from './src/host/secrets.js' @@ -36,7 +44,7 @@ diff --git a/index.js b/index.js // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel] ++const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, email, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 6bf22bb9..b34e5dc1 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -308,7 +308,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js // The team's DSH Agent (at most one): plain dsh Sessions, not Bots. agents: Object.values(state.agents), // Key names and who may use them; values never leave the host. -@@ -54,7 +54,8 @@ export function install(rt) { +@@ -54,33 +54,63 @@ export function install(rt) { capabilities: rt.capabilityLibraryView(), secretRequests: state.secretRequests ?? {}, colors: [...COLORS, ...EXTRA_COLORS], @@ -318,7 +318,12 @@ diff --git a/src/host/channel.js b/src/host/channel.js defaultModel: rt.cachedDefault() ?? null, permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), -@@ -65,22 +65,49 @@ export function install(rt) { + prefs: state.prefs, ++ // Email to yourself (#355): the address when this server has email set up, else null. ++ email: rt.emailView?.() ?? null, + // Whether a Desktop window should serve Bot browser reads (see browser.js). + browser: rt.browserEnabled, + // False when config.memory turns memory off: no Memory row or panel. memory: rt.memoryEnabled !== false, // Set while another dsh host holds the team (store.js): why this one cannot change it. readOnly: rt.readOnly() ?? null, @@ -379,7 +384,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js function setPrefs(body) { if (body.theme !== undefined) { -@@ -97,28 +97,43 @@ export function install(rt) { +@@ -97,28 +97,51 @@ export function install(rt) { else if (body.motion === 'quiet' || body.motion === 'lively') state.prefs.motion = body.motion else throw invalid('Motion must be quiet, normal or lively') } @@ -388,6 +393,14 @@ diff --git a/src/host/channel.js b/src/host/channel.js + if (body.notifications === null || body.notifications === true) delete state.prefs.notifications + else if (body.notifications === false) state.prefs.notifications = false + else throw invalid('Notifications must be on or off') ++ } ++ // Email to yourself is off unless switched on, and only where the server has it set up (#355). ++ if (body.email !== undefined) { ++ if (body.email === null || body.email === false) delete state.prefs.email ++ else if (body.email !== true) throw invalid('Email must be on or off') ++ else if (!rt.emailView?.()) throw invalid('Email is not set up on this server') ++ else state.prefs.email = true ++ rt.emailSwitched?.(body.email === true) + } return rt.save() } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c1f83416..c64aefec 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -386,7 +386,7 @@ diff --git a/client.js b/client.js "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", -@@ -506,9 +506,16 @@ var ZH = { +@@ -506,9 +506,19 @@ var ZH = { "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", "A Bot": "一个 Bot", "{name} is waiting for your approval": "{name} 等你批准", @@ -394,6 +394,9 @@ diff --git a/client.js b/client.js + "{name} failed: {reason}": "{name} 失败:{reason}", + Notifications: "通知", + "When your Bots finish, fail, or wait for your approval while you are in another chat or app.": "你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。", ++ "Email me": "发邮件给我", ++ "The same news by email to {address}: a short line and a link back, at most every five minutes. Approve only in FlyBot.": "同样的提醒也发邮件到 {address}:一句话加一个回到 FlyBot 的链接,最多每五分钟一封。批准只能在 FlyBot 里进行。", ++ "Needs email set up on the server (see the Docker guide).": "需要先在服务器上设置邮件(见 Docker 指南)。", "{first} and {second} are waiting for your approval": "{first}、{second} 等你批准", "{first}, {second} and {count} more are waiting for your approval": "{first}、{second} 等 {total} 个等你批准", "Waiting for your approval": "等你批准", @@ -2329,12 +2332,10 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2852,229 +2852,211 @@ function installCompiledStyles(text, tag) { - document.removeEventListener("load", onLoad, true); - clearTimeout(timer); +@@ -2854,227 +2854,209 @@ function installCompiledStyles(text, tag) { style.remove(); -- }; --} + }; + } - -// src/client/mark.js -var import_react2 = require("react"); @@ -2419,8 +2420,7 @@ diff --git a/client.js b/client.js - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); -+ }; - } +-} -function useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); @@ -3849,7 +3849,8 @@ diff --git a/client.js b/client.js { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, - (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + (0, import_react8.createElement)(BotMark, { bot, size: 22 }) -+ ); + ); +- return (0, import_react6.createElement)( + const name = (0, import_react8.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -3944,8 +3945,7 @@ diff --git a/client.js b/client.js + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ); -- return (0, import_react6.createElement)( ++ ); +} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react8.useState)(""); @@ -4372,12 +4372,12 @@ diff --git a/client.js b/client.js if (reply.kind === "stopped") { - return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react8.createElement)(RemoteMessage, { @@ -9818,13 +9818,13 @@ diff --git a/client.js b/client.js ), - (0, import_react23.createElement)( + face ? (0, import_react28.createElement)( - Card, -- { title: t("Members · {count}", { count: members.length }) }, ++ Card, + { title: t("Members · {count}", { count: people2.length }) }, + people2.map((person) => (0, import_react28.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room2.face?.lead?.handle })) + ) : null, + face ? null : (0, import_react28.createElement)( -+ Card, + Card, +- { title: t("Members · {count}", { count: members.length }) }, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react28.createElement)( + "div", @@ -11434,7 +11434,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +10626,79 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,75 +10626,87 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -11465,6 +11465,14 @@ diff --git a/client.js b/client.js + hint: t("When your Bots finish, fail, or wait for your approval while you are in another chat or app."), + on: prefs.notifications !== false, + onChange: (on) => run(actions.setPrefs({ notifications: on })) ++ }), ++ // Email to yourself (#355): off by default, and only where the server has email set up. ++ (0, import_react33.createElement)(SwitchRow, { ++ label: t("Email me"), ++ hint: roster.email?.to ? t("The same news by email to {address}: a short line and a link back, at most every five minutes. Approve only in FlyBot.", { address: roster.email.to }) : t("Needs email set up on the server (see the Docker guide)."), ++ on: Boolean(roster.email?.to) && prefs.email === true, ++ disabled: !roster.email?.to, ++ onChange: (on) => run(actions.setPrefs({ email: on })) + }) ) ]; diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index 59449b2a..ff997f9c 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -203,11 +203,11 @@ diff --git a/src/client/settings.js b/src/client/settings.js children ? h('div', { className: 'bt-set-control' }, children) : null) } -+// General is Appearance and Notifications; the switch to classic Agent mode is the account menu's (#393). ++// General is Appearance and Notifications (system notifications, email to yourself); the switch to classic Agent mode is the account menu's (#393). function GeneralPage({ roster, actions, run }) { const prefs = roster.prefs ?? {} const themes = allThemes() -@@ -61,9 +61,13 @@ function GeneralPage({ roster, actions, run }) { +@@ -61,9 +61,22 @@ function GeneralPage({ roster, actions, run }) { })), h('label', { className: 'bt-swatch bt-swatch-custom', title: t('Custom accent'), 'data-on': Boolean(prefs.accent) && !ACCENT_INKS.some(ink => INKS[ink][0] === prefs.accent) }, h('input', { type: 'color', value: custom, 'aria-label': t('Custom accent'), onChange: event => pickCustom(event.target.value) }))))), @@ -220,6 +220,15 @@ diff --git a/src/client/settings.js b/src/client/settings.js + label: t('Notifications'), showLabel: false, + hint: t('When your Bots finish, fail, or wait for your approval while you are in another chat or app.'), + on: prefs.notifications !== false, onChange: on => run(actions.setPrefs({ notifications: on })), ++ }), ++ // Email to yourself (#355): off by default, and only where the server has email set up. ++ h(SwitchRow, { ++ label: t('Email me'), ++ hint: roster.email?.to ++ ? t('The same news by email to {address}: a short line and a link back, at most every five minutes. Approve only in FlyBot.', { address: roster.email.to }) ++ : t('Needs email set up on the server (see the Docker guide).'), ++ on: Boolean(roster.email?.to) && prefs.email === true, disabled: !roster.email?.to, ++ onChange: on => run(actions.setPrefs({ email: on })), + })), ] } diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 33a24551..17e24956 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -169,6 +169,7 @@ export function install(rt) { agents: alone() ? full.agents : [], secrets: alone() ? full.secrets : [], secretRequests: alone() ? full.secretRequests : {}, + email: alone() ? full.email : null, } } rt.ownedLive = (full, actor) => { diff --git a/patches/ds-bot/files/src/host/email.js b/patches/ds-bot/files/src/host/email.js new file mode 100644 index 00000000..8d61f83d --- /dev/null +++ b/patches/ds-bot/files/src/host/email.js @@ -0,0 +1,249 @@ +// Email to yourself (#355, first slice). With the owner's opt-in (Settings → General → +// Email me, off by default), the system notifications of #353 (one of your Bots finished or +// failed a turn, or waits for your approval) also reach your own address, open window or not: +// a short line each plus a link back into FlyBot, at most one email every five minutes. An +// email never carries chat or tool-call content and never approves anything; a reply to it is +// not read. A send that fails is noted in the diagnostics log (code only) and not retried. +// +// The server is the owner's own SMTP account, set in the environment (docs/docker.md): +// FLYBOT_SMTP_URL smtps://user:password@smtp.example.com (implicit TLS, port 465), or +// smtp://user:password@smtp.example.com:587 (STARTTLS, required with a password) +// FLYBOT_EMAIL_TO your own address; the only recipient there is +// FLYBOT_URL the address you open FlyBot at, for the link (default http://127.0.0.1:3080/) +// The password stays in the process: it is never saved, shown, logged or sent without TLS. +import net from 'node:net' +import tls from 'node:tls' +import { hostname } from 'node:os' +import { randomUUID } from 'node:crypto' + +const ADDRESS = /^[^\s@<>()",;:\\[\]]+@[^\s@<>()",;:\\[\]]+\.[^\s@<>()",;:\\[\]]+$/ +const DEFAULT_URL = 'http://127.0.0.1:3080/' +// One email gathers what happened within a minute; at most one email every five minutes. +const BATCH_MS = 60_000 +const GAP_MS = 5 * 60_000 +const LINES_SHOWN = 10 +const TIMEOUT_MS = 30_000 + +// The SMTP settings from the environment, or undefined when email is not set up (or set up wrong: +// a bad value is never half-used). +export function emailConfig(env = process.env) { + const to = env.FLYBOT_EMAIL_TO?.trim() + const raw = env.FLYBOT_SMTP_URL?.trim() + if (!to || !raw || !ADDRESS.test(to)) return undefined + let url + try { url = new URL(raw) } catch { return undefined } + if ((url.protocol !== 'smtp:' && url.protocol !== 'smtps:') || !url.hostname) return undefined + const secure = url.protocol === 'smtps:' + let user, password + try { + user = decodeURIComponent(url.username) + password = decodeURIComponent(url.password) + } catch { return undefined } + let link = env.FLYBOT_URL?.trim() || DEFAULT_URL + try { link = new URL(link).href } catch { link = DEFAULT_URL } + if (!/^https?:/.test(link)) link = DEFAULT_URL + return { + host: url.hostname.replace(/^\[|\]$/g, ''), + port: Number(url.port) || (secure ? 465 : 587), + secure, + ...(user ? { user, password } : {}), + to, + from: user && ADDRESS.test(user) ? user : to, + link, + } +} + +// RFC 2047 for a header that is not plain ASCII; CR and LF never reach a header. +const header = text => { + const flat = String(text).replace(/[\r\n]+/g, ' ').trim() + return /^[\x20-\x7e]*$/.test(flat) ? flat : `=?UTF-8?B?${Buffer.from(flat).toString('base64')}?=` +} + +export function message({ from, to, subject, text, now = new Date() }) { + const body = Buffer.from(text.replace(/\r?\n/g, '\r\n')).toString('base64').replace(/.{76}/g, '$&\r\n') + return [ + `From: FlyBot <${from}>`, + `To: <${to}>`, + `Subject: ${header(subject)}`, + `Date: ${now.toUTCString().replace('GMT', '+0000')}`, + `Message-ID: <${randomUUID()}@flybot>`, + 'Auto-Submitted: auto-generated', + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=utf-8', + 'Content-Transfer-Encoding: base64', + '', + body, + ].join('\r\n') +} + +const smtpError = (code) => Object.assign(new Error(`SMTP ${code}`), { code: `SMTP_${code}` }) + +// One email over SMTP. Errors carry a code only (`SMTP_`, `SMTP_NO_TLS`, +// `SMTP_TIMEOUT`, `SMTP_CLOSED`, or the socket's code); the server's text is never kept. +export function sendMail(config, { subject, text }, { timeoutMs = TIMEOUT_MS } = {}) { + return new Promise((resolve, reject) => { + let socket + let buffer = '' + let lines = [] + let waiter + let finished = false + // Once the server took the message, a close (after QUIT, or instead of its answer) is no failure. + let delivered = false + const finish = (error) => { + if (finished) return + finished = true + clearTimeout(timer) + socket?.destroy() + if (error && !delivered) reject(error) + else resolve() + } + const timer = setTimeout(() => finish(smtpError('TIMEOUT')), timeoutMs) + // A reply is complete at its line with a space after the code. + const onData = (chunk) => { + buffer += chunk.toString('utf8') + let end + while ((end = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, end).replace(/\r$/, '') + buffer = buffer.slice(end + 1) + lines.push(line) + if (/^\d{3}(?: |$)/.test(line)) { + const reply = { code: Number(line.slice(0, 3)), lines } + lines = [] + const next = waiter + waiter = undefined + next?.(reply) + } + } + } + const attach = (next) => { + socket = next + socket.on('data', onData) + socket.on('error', error => finish(Object.assign(new Error('SMTP connection failed'), { code: error?.code ?? 'SMTP_CLOSED' }))) + socket.on('close', () => finish(smtpError('CLOSED'))) + } + const read = () => new Promise((done) => { waiter = done }) + const expect = async (ok) => { + const reply = await read() + if (!ok.includes(reply.code)) throw smtpError(reply.code) + return reply + } + const command = (line, ok) => { + socket.write(`${line}\r\n`) + return expect(ok) + } + const ehlo = async () => (await command(`EHLO ${hostname().replace(/[^\w.-]/g, '') || 'flybot'}`, [250])).lines.map(line => line.slice(4).toUpperCase()) + + const run = async () => { + await expect([220]) + let caps = await ehlo() + let secure = config.secure + if (!secure && caps.includes('STARTTLS')) { + await command('STARTTLS', [220]) + const plain = socket + plain.removeAllListeners('data') + plain.removeAllListeners('close') + plain.removeAllListeners('error') + await new Promise((done, fail) => { + const upgraded = tls.connect({ socket: plain, servername: net.isIP(config.host) ? undefined : config.host }, done) + attach(upgraded) + upgraded.once('error', fail) + }) + secure = true + caps = await ehlo() + } + if (config.user) { + // A password never goes over a connection without TLS. + if (!secure) throw smtpError('NO_TLS') + const auth = caps.find(cap => cap.startsWith('AUTH')) ?? '' + if (/\bPLAIN\b/.test(auth) || !/\bLOGIN\b/.test(auth)) { + await command(`AUTH PLAIN ${Buffer.from(`\0${config.user}\0${config.password}`).toString('base64')}`, [235]) + } else { + await command('AUTH LOGIN', [334]) + await command(Buffer.from(config.user).toString('base64'), [334]) + await command(Buffer.from(config.password).toString('base64'), [235]) + } + } + await command(`MAIL FROM:<${config.from}>`, [250]) + await command(`RCPT TO:<${config.to}>`, [250, 251]) + await command('DATA', [354]) + await command(`${message({ from: config.from, to: config.to, subject, text })}\r\n.`, [250]) + delivered = true + await command('QUIT', [221]).catch(() => { /* delivered already */ }) + } + + const options = { host: config.host, port: config.port } + attach(config.secure ? tls.connect({ ...options, servername: net.isIP(config.host) ? undefined : config.host }) : net.connect(options)) + run().then(() => finish(), finish) + }) +} + +export function install(rt, { env = process.env, send = sendMail } = {}) { + const config = emailConfig(env) + let queue = [] + let timer + let lastSent = -Infinity + + // Email only while one human uses this FlyBot: the address is theirs (set-prefs is blocked + // with more members, authz.js). + const on = () => config !== undefined && rt.state?.prefs?.email === true && Object.keys(rt.state.members ?? {}).length <= 1 + const emailView = () => (config ? { to: config.to } : null) + const drop = () => { + queue = [] + if (timer) clearTimeout(timer) + timer = undefined + } + + const lineOf = (event) => { + const bot = rt.botOf(event.botId) + const name = bot?.name ?? 'A Bot' + const room = event.roomId ? rt.roomOf(event.roomId)?.name : undefined + const what = event.kind === 'approval' ? `${name} is waiting for your approval` + : event.kind === 'failed' ? `${name} failed: ${event.failed}` : `${name} finished` + return `- ${what}${room ? ` (${room})` : ''}` + } + + const flush = async () => { + timer = undefined + // Approvals already answered are no news. + const open = new Set((rt.approvalsView?.() ?? []).map(entry => entry.id)) + const events = queue.filter(event => event.kind !== 'approval' || open.has(event.id)) + queue = [] + if (!on() || events.length === 0) return + lastSent = Date.now() + const lines = events.slice(0, LINES_SHOWN).map(lineOf) + if (events.length > LINES_SHOWN) lines.push(`- and ${events.length - LINES_SHOWN} more`) + const subject = events.length === 1 ? `FlyBot: ${lineOf(events[0]).slice(2)}` : `FlyBot: ${events.length} updates from your Bots` + const text = [ + ...lines, + '', + `Open FlyBot: ${config.link}`, + '', + 'Approve or reject only in FlyBot. Replies to this email are not read.', + 'Turn these emails off in FlyBot: Settings > General > Email me.', + ].join('\n') + try { + await send(config, { subject, text }) + } catch (error) { + rt.warn(`ds-bot: the email to yourself was not sent (${typeof error?.code === 'string' ? error.code : 'failed'})`) + } + } + + // A notification of #353 (approval waiting, turn finished or failed) of one of the owner's own + // Bots. Queued only while the switch is on; switching it off drops the queue at once. + const noticeOffApp = (kind, entry) => { + if (!on()) return drop() + const bot = rt.botOf(entry?.botId) + if (bot === undefined || bot.remote !== undefined) return + queue.push({ kind, id: entry.id, botId: entry.botId, roomId: entry.roomId, failed: entry.failed }) + if (timer) return + timer = setTimeout(() => { flush().catch(() => { /* logged in flush */ }) }, Math.max(BATCH_MS, lastSent + GAP_MS - Date.now())) + timer.unref?.() + } + + // Off: whatever waits to be sent is dropped. + const emailSwitched = (value) => { + if (!value) drop() + } + + Object.assign(rt, { emailView, noticeOffApp, emailSwitched, emailConfigured: () => config !== undefined }) +} diff --git a/patches/ds-bot/files/test/email.test.js b/patches/ds-bot/files/test/email.test.js new file mode 100644 index 00000000..2e8ad9df --- /dev/null +++ b/patches/ds-bot/files/test/email.test.js @@ -0,0 +1,211 @@ +// Email to yourself (#355): the SMTP settings, the message, the SMTP exchange, and which +// notifications become an email when. +import assert from 'node:assert/strict' +import net from 'node:net' +import { test } from 'node:test' +import { emailConfig, install, message, sendMail } from '../src/host/email.js' +import { install as installApprovals } from '../src/host/approvals.js' + +test('the settings come from the environment; anything missing or wrong means no email', () => { + assert.deepEqual(emailConfig({ FLYBOT_SMTP_URL: 'smtps://me%40mail.example:p%40ss@smtp.mail.example', FLYBOT_EMAIL_TO: 'me@mail.example' }), { + host: 'smtp.mail.example', port: 465, secure: true, user: 'me@mail.example', password: 'p@ss', to: 'me@mail.example', from: 'me@mail.example', link: 'http://127.0.0.1:3080/', + }) + assert.deepEqual(emailConfig({ FLYBOT_SMTP_URL: 'smtp://relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example', FLYBOT_URL: 'https://flybot.example.com' }), { + host: 'relay.lan', port: 587, secure: false, to: 'me@mail.example', from: 'me@mail.example', link: 'https://flybot.example.com/', + }) + assert.equal(emailConfig({ FLYBOT_SMTP_URL: 'smtp://user:pw@smtp.example.com:2525', FLYBOT_EMAIL_TO: 'me@mail.example' }).from, 'me@mail.example') + assert.equal(emailConfig({ FLYBOT_SMTP_URL: 'smtp://relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example', FLYBOT_URL: 'javascript:alert(1)' }).link, 'http://127.0.0.1:3080/') + for (const env of [ + {}, + { FLYBOT_SMTP_URL: 'smtp://relay.lan' }, + { FLYBOT_EMAIL_TO: 'me@mail.example' }, + { FLYBOT_SMTP_URL: 'https://relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example' }, + { FLYBOT_SMTP_URL: 'not a url', FLYBOT_EMAIL_TO: 'me@mail.example' }, + { FLYBOT_SMTP_URL: 'smtp://relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example\r\nBcc: other@x.example' }, + { FLYBOT_SMTP_URL: 'smtp://relay.lan', FLYBOT_EMAIL_TO: 'me, other@x.example' }, + ]) assert.equal(emailConfig(env), undefined, JSON.stringify(env)) +}) + +test('a message is plain UTF-8 text with a safe subject', () => { + const raw = message({ from: 'me@mail.example', to: 'me@mail.example', subject: '写手 finished\r\nBcc: x@y.example', text: 'line one\nline two', now: new Date(0) }) + const [head, body] = raw.split('\r\n\r\n') + assert.match(head, /^From: FlyBot \r\nTo: \r\nSubject: =\?UTF-8\?B\?[\w+/=]+\?=\r\n/) + assert.equal(Buffer.from(head.match(/B\?([\w+/=]+)\?=/)[1], 'base64').toString(), '写手 finished Bcc: x@y.example') + assert.doesNotMatch(head, /^Bcc:/m) + assert.match(head, /Auto-Submitted: auto-generated/) + assert.equal(Buffer.from(body, 'base64').toString(), 'line one\r\nline two') + assert.match(message({ from: 'a@b.example', to: 'a@b.example', subject: 'Plain', text: '' }), /\r\nSubject: Plain\r\n/) +}) + +// A minimal SMTP server: no STARTTLS, optional AUTH, a reply code per command on request. +function smtpServer({ auth = false, fail = {} } = {}) { + const seen = [] + const mails = [] + const server = net.createServer((socket) => { + let data = false + let mail = '' + let buffer = '' + socket.write('220 test ESMTP\r\n') + socket.on('data', (chunk) => { + buffer += chunk.toString() + let end + while ((end = buffer.indexOf('\r\n')) >= 0) { + const line = buffer.slice(0, end) + buffer = buffer.slice(end + 2) + if (data) { + if (line === '.') { + data = false + mails.push(mail) + socket.write('250 queued\r\n') + } else mail += `${line}\r\n` + continue + } + seen.push(line) + const verb = line.split(/[ :]/)[0].toUpperCase() + if (fail[verb]) socket.write(`${fail[verb]} no\r\n`) + else if (verb === 'EHLO') socket.write(`250-test\r\n${auth ? '250-AUTH PLAIN LOGIN\r\n' : ''}250 8BITMIME\r\n`) + else if (verb === 'DATA') { data = true; socket.write('354 go\r\n') } + else if (verb === 'QUIT') { socket.end('221 bye\r\n') } + else socket.write('250 ok\r\n') + } + }) + }) + return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, seen, mails, close: () => server.close() }))) +} + +test('an email goes through a relay that needs no password', async (t) => { + const smtp = await smtpServer() + t.after(smtp.close) + await sendMail({ host: '127.0.0.1', port: smtp.port, secure: false, to: 'me@mail.example', from: 'me@mail.example' }, { subject: 'FlyBot: Coder finished', text: '- Coder finished' }) + assert.deepEqual(smtp.seen.slice(1), ['MAIL FROM:', 'RCPT TO:', 'DATA', 'QUIT']) + assert.match(smtp.seen[0], /^EHLO \S+$/) + assert.equal(smtp.mails.length, 1) + assert.match(smtp.mails[0], /Subject: FlyBot: Coder finished/) +}) + +test('a password never goes over a connection without TLS, and a refusal keeps only its code', async (t) => { + const smtp = await smtpServer({ auth: true }) + t.after(smtp.close) + await assert.rejects( + sendMail({ host: '127.0.0.1', port: smtp.port, secure: false, user: 'me', password: 'hunter2', to: 'me@mail.example', from: 'me@mail.example' }, { subject: 's', text: 't' }), + error => error.code === 'SMTP_NO_TLS' && !error.message.includes('hunter2'), + ) + assert.equal(smtp.seen.some(line => /AUTH|aHVudGVyMg|hunter2/.test(line)), false) + assert.equal(smtp.mails.length, 0) + + const refusing = await smtpServer({ fail: { RCPT: 550 } }) + t.after(refusing.close) + await assert.rejects( + sendMail({ host: '127.0.0.1', port: refusing.port, secure: false, to: 'me@mail.example', from: 'me@mail.example' }, { subject: 's', text: 't' }), + error => error.code === 'SMTP_550', + ) +}) + +// The notifier on a stand-in runtime, with mocked timers and a recording sender. +function notifier(t, { prefs = { email: true }, members = {}, env = { FLYBOT_SMTP_URL: 'smtp://relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example', FLYBOT_URL: 'https://flybot.example.com/' }, send } = {}) { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1_000_000 }) + const sent = [] + const warnings = [] + const open = [] + const rt = { + state: { prefs, members }, + botOf: id => ({ b: { id: 'b', name: 'Coder' }, f: { id: 'f', name: 'Pal', remote: { handle: '@pal' } } })[id], + roomOf: id => ({ r1: { id: 'r1', name: 'Ops' } })[id], + approvalsView: () => open.map(id => ({ id })), + warn: line => warnings.push(line), + } + install(rt, { env, send: send ?? (async (config, mail) => { sent.push({ config, ...mail }) }) }) + const advance = async (ms) => { + t.mock.timers.tick(ms) + for (let i = 0; i < 5; i++) await Promise.resolve() + } + return { rt, sent, warnings, open, advance, notice: rt.noticeOffApp } +} + +test('switched on, a minute of news from my own Bots becomes one email with a link back and nothing to act on', async (t) => { + const box = notifier(t) + assert.deepEqual(box.rt.emailView(), { to: 'me@mail.example' }) + box.open.push('a1') + box.notice('finished', { id: 'e1', botId: 'b', sessionId: 'b' }) + box.notice('failed', { id: 'e2', botId: 'b', roomId: 'r1', failed: 'upstream busy' }) + box.notice('approval', { id: 'a1', botId: 'b', roomId: 'r1', tool: 'pwsh', reason: 'rm -rf /tmp/x', detail: { code: 'rm -rf /tmp/x' } }) + box.notice('approval', { id: 'a2', botId: 'b', sessionId: 'b', tool: 'pwsh' }) + box.notice('finished', { id: 'e3', botId: 'f', sessionId: 'f' }) + await box.advance(59_000) + assert.equal(box.sent.length, 0) + await box.advance(1_000) + assert.equal(box.sent.length, 1) + const [mail] = box.sent + assert.equal(mail.subject, 'FlyBot: 3 updates from your Bots') + assert.equal(mail.text, [ + '- Coder finished', + '- Coder failed: upstream busy (Ops)', + '- Coder is waiting for your approval (Ops)', + '', + 'Open FlyBot: https://flybot.example.com/', + '', + 'Approve or reject only in FlyBot. Replies to this email are not read.', + 'Turn these emails off in FlyBot: Settings > General > Email me.', + ].join('\n')) + assert.equal(mail.config.to, 'me@mail.example') + assert.doesNotMatch(mail.text, /pwsh|rm -rf/) +}) + +test('one email every five minutes at most; a single event names itself in the subject', async (t) => { + const box = notifier(t) + box.notice('finished', { id: 'e1', botId: 'b', sessionId: 'b' }) + await box.advance(60_000) + box.notice('finished', { id: 'e2', botId: 'b', sessionId: 'b' }) + await box.advance(4 * 60_000) + assert.equal(box.sent.length, 1) + assert.equal(box.sent[0].subject, 'FlyBot: Coder finished') + await box.advance(60_000) + assert.equal(box.sent.length, 2) +}) + +test('off by default, off with more than one member, off without settings, and switching off drops what waits', async (t) => { + for (const options of [{ prefs: {} }, { members: { 'owner-1': {}, 'owner-2': {} } }, { env: {} }]) { + const box = notifier(t, options) + box.notice('finished', { id: 'e1', botId: 'b', sessionId: 'b' }) + await box.advance(10 * 60_000) + assert.equal(box.sent.length, 0, JSON.stringify(options)) + t.mock.timers.reset() + } + assert.equal(notifier(t, { env: {} }).rt.emailView(), null) + t.mock.timers.reset() + + const box = notifier(t) + box.notice('finished', { id: 'e1', botId: 'b', sessionId: 'b' }) + box.rt.emailSwitched(false) + await box.advance(10 * 60_000) + assert.equal(box.sent.length, 0) +}) + +test('approvals answered before the email goes are left out, and a failed send is noted by its code', async (t) => { + const box = notifier(t) + box.notice('approval', { id: 'a1', botId: 'b', sessionId: 'b' }) + await box.advance(60_000) + assert.equal(box.sent.length, 0) + t.mock.timers.reset() + + const failing = notifier(t, { + env: { FLYBOT_SMTP_URL: 'smtp://u:secret@relay.lan', FLYBOT_EMAIL_TO: 'me@mail.example' }, + send: async () => { throw Object.assign(new Error('535 bad secret'), { code: 'SMTP_535' }) }, + }) + failing.notice('finished', { id: 'e1', botId: 'b', sessionId: 'b' }) + await failing.advance(60_000) + assert.deepEqual(failing.warnings, ['ds-bot: the email to yourself was not sent (SMTP_535)']) +}) + +test('an approval that starts waiting reaches the notifier', async () => { + let middleware + const offApp = [] + installApprovals({ + ctx: { on: (name, fn) => { if (name === 'approval/request') middleware = fn } }, + groupOwners: new Map(), + chatOwners: new Map([['s1', 'b']]), + noticeOffApp: (kind, entry) => offApp.push([kind, entry.botId, entry.sessionId]), + }) + assert.equal(await middleware({ agent: { id: 's1' }, toolName: 'pwsh' }, () => 'allowed'), 'allowed') + assert.deepEqual(offApp, [['approval', 'b', 's1']]) +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 77fcb9bb..264f2147 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -35,7 +35,8 @@ "src/host/schedules.js", "test/schedules.test.js", "src/client/approval-notices.js", - "test/approval-notices.test.js" + "test/approval-notices.test.js", + "src/host/approvals.js" ], "0002-flybot-api-host": [ "index.js",