Repository: dtinth/automatron Branch: main Commit: 00720cfa1c6a Files: 74 Total size: 523.8 KB Directory structure: gitextract_gdn6le0m/ ├── .devcontainer/ │ ├── devcontainer.json │ └── postbuild ├── .gitattributes ├── .gitignore ├── .prettierrc ├── .vscode/ │ ├── settings.json │ └── tasks.json ├── README.md ├── core/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── BotSecrets.ts │ │ ├── CodeEvaluation.ts │ │ ├── Cron.ts │ │ ├── DataEncryption.ts │ │ ├── DeviceTracking.ts │ │ ├── ExpenseTracking.ts │ │ ├── ExpenseTrackingGrist.ts │ │ ├── HomeAutomation.ts │ │ ├── ImageMessageHandler.ts │ │ ├── LINEClient.ts │ │ ├── LINEMessageUtilities.ts │ │ ├── LanguageModelAssistant.ts │ │ ├── MessageHandler.ts │ │ ├── MessageHistory.ts │ │ ├── MongoDatabase.ts │ │ ├── NotificationProcessor.ts │ │ ├── PersistentState.ts │ │ ├── PhoneFinder.ts │ │ ├── PreludeCode.ts │ │ ├── RomanNumerals.ts │ │ ├── SMSHandler.ts │ │ ├── SlackMessageUtilities.ts │ │ ├── SpeedDial.ts │ │ ├── SpendingTracking.ts │ │ ├── TemporaryBlobStorage.ts │ │ ├── Tracing.ts │ │ ├── TypedGristDocAPI.ts │ │ ├── bot.ts │ │ ├── logger.ts │ │ ├── modules.d.ts │ │ ├── scripts/ │ │ │ └── updateEnv.ts │ │ ├── server.ts │ │ ├── typedefs.d.ts │ │ └── types.ts │ └── tsconfig.json ├── fnox.toml ├── frpc.toml ├── images/ │ └── .gitkeep ├── mise.toml ├── package.json ├── pnpm-workspace.yaml └── webui/ ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── app/ │ ├── Clock.tsx │ ├── backend.ts │ ├── firebase.ts │ ├── index.css │ ├── requireAuth.ts │ ├── root.tsx │ └── routes/ │ ├── _index.tsx │ ├── automatron._index.tsx │ ├── automatron.knobs.tsx │ └── automatron.tsx ├── env.d.ts ├── package.json ├── playwright-report/ │ └── index.html ├── playwright.config.ts ├── postcss.config.cjs ├── tailwind.config.cjs ├── tests/ │ └── automatron.spec.ts ├── tsconfig.json ├── vercel.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/devcontainer.json ================================================ // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.192.0/containers/codespaces-linux { "image": "mcr.microsoft.com/devcontainers/universal:2", "containerEnv": { "GOOGLE_APPLICATION_CREDENTIALS": "/home/codespace/.google-cloud-service-account.json" }, "postCreateCommand": "pnpm install && ./.devcontainer/postbuild" } ================================================ FILE: .devcontainer/postbuild ================================================ #!/bin/bash -e node core/dev set-up-codespaces ================================================ FILE: .gitattributes ================================================ # Don't allow people to merge changes to these generated files, because the result # may be invalid. You need to run "rush update" again. pnpm-lock.yaml merge=binary shrinkwrap.yaml merge=binary npm-shrinkwrap.json merge=binary yarn.lock merge=binary # Rush's JSON config files use JavaScript-style code comments. The rule below prevents pedantic # syntax highlighters such as GitHub's from highlighting these comments as errors. Your text editor # may also require a special configuration to allow comments in JSON. # # For more information, see this issue: https://github.com/microsoft/rushstack/issues/1088 # *.json linguist-language=JSON-with-Comments ================================================ FILE: .gitignore ================================================ .env .cache node_modules /automatron.js /automatron.js.map /dist/ *.gz webpack.stats.json *.env # Rush temporary files common/deploy/ common/temp/ common/autoinstallers/*/.npmrc **/.rush/temp/ # Heft .heft ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "semi": false } ================================================ FILE: .vscode/settings.json ================================================ { "cSpell.words": ["Automatron"], "typescript.experimental.useTsgo": true } ================================================ FILE: .vscode/tasks.json ================================================ { "version": "2.0.0", "tasks": [ { "label": "pnpm: dev", "type": "shell", "command": "pnpm dev", "group": "build" }, { "label": "pnpm: tunnel", "type": "shell", "command": "pnpm tunnel", "group": "build" }, { "label": "pnpm: dev+tunnel", "type": "shell", "command": "echo Running pnpm dev and pnpm tunnel in parallel...", "dependsOn": ["pnpm: dev", "pnpm: tunnel"], "group": { "kind": "build", "isDefault": true }, "problemMatcher": [] } ] } ================================================ FILE: README.md ================================================ # automatron This is my personal LINE bot that helps me automate various tasks of everyday life, such as **home control** (air conditioner, lights and plugs) and **expense tracking** (record how much I spend each day). [See below for a feature tour.](#features) I recommend every developer to try creating their own personal assistant chat bot. It’s a great way to practice coding and improve problem solving skills. And it helps make life more convenient! It is written in TypeScript and runs on [DigitalOcean App Platform](https://www.digitalocean.com/products/app-platform) with Node.js 24. ## features - [home automation](#home-automation) - [expense tracking](#expense-tracking) - [transaction aggregation](#transaction-aggregation) - [image-to-text](#image-to-text) - [livescript evaluation](#livescript-evaluation) ### home automation ![home automation](./images/home_automation.png) I have a [Home Assistant](https://www.home-assistant.io/) setup which can [control lights](https://www.home-assistant.io/integrations/hue/), [air conditioner](https://www.home-assistant.io/integrations/broadlink/#remote), and [smart plugs](https://www.home-assistant.io/integrations/tplink/). automatron can send commands to [Home Assistant’s REST API](https://developers.home-assistant.io/docs/api/rest/) to control these devices. ### expense tracking ![expense tracking](./images/expense_tracking.png) Simple expense tracking by typing in the amount + category. Example: 50f means ฿50 for food. Data is saved in [Grist](https://www.getgrist.com/). On mobile, tapping the bubble’s body (containing the amount) will take me to the created Grist record. This allows me to easily edit or add remarks to the record. ### transaction aggregation ![transaction_aggregation](./images/transaction_aggregation.png) I built a [notification exfiltrator](https://docs.dt.in.th/dtinth.tools-android/exfiltrate.html) that send notifications my phone receive to automatron, which saves it to a database for later processing. ### image-to-text ![image_to_text](./images/image_to_text.png) automatron can also convert image to text using [Google Cloud Vision API](https://cloud.google.com/vision/). ### livescript evaluation ![livescript](./images/livescript.png) [LiveScript](https://livescript.net/) interpreter is included, which allows me to do some quick calculations. ### cli / api ![api](./images/api.png) `POST /text` sends a text command to automatron. This is equivalent to sending a text message through LINE. This allows me to create a CLI tool that lets me talk to automatron from my terminal. `POST /post` sends a message to my LINE account directly. This allows the [home automation](#home-automation) scripts to report back to me whenever the script is invoked. `POST /run/automatron/reload` reloads the encrypted environment file (requires `X-API-Key` header with `API_KEY`). ## project structure - [core](./core) — The core automatron service. - [webui](./webui) — The web-based UI running on Vercel. ### other projects - [automatron-prelude](https://github.dev/dtinth/automatron-prelude/blob/main/prelude.js) contains code experimentation. ### development setup ```sh # Run the development server pnpm dev # Set up a tunnel using frp pnpm tunnel # Update a secret pnpm env:set SECRET_NAME ``` ================================================ FILE: core/README.md ================================================ # @dtinth/automatron-core The core service of automatron. Provides: - Chat bot interface (LINE, Slack) - REST API interface - Cron jobs ## secrets The secret data required to run the automation are defined in [BotSecrets.ts](./src/BotSecrets.ts). ## development workflow ### developing Watches for file changes and deploy the compiled code. Since it is my personal bot (I am the only one using it), I want a save-and-deploy workflow; there is no dev/staging environment at all. ```sh node dev ``` ### configuration ```sh # download gsutil cp gs://$GOOGLE_CLOUD_PROJECT-evalaas/evalaas/automatron.env automatron.env # upload gsutil cp automatron.env gs://$GOOGLE_CLOUD_PROJECT-evalaas/evalaas/automatron.env ``` ================================================ FILE: core/package.json ================================================ { "name": "@dtinth/automatron-core", "private": true, "description": "My LINE bot!", "version": "1.0.0", "license": "Apache-2.0", "author": "Google Inc.", "engines": { "node": ">=24" }, "repository": "https://github.com/dtinth/automatron", "main": "app.js", "scripts": { "start": "tsx src/server.ts", "dev": "env AUTOMATRON_ENV=dev tsx --watch src/server.ts" }, "dependencies": { "age-encryption": "0.2.4", "google-application-credentials-base64": "0.0.0", "@dtinth/encrypted": "^0.3.1", "@google-cloud/firestore": "^5.0.2", "@google-cloud/storage": "^7.18.0", "@google-cloud/trace-agent": "^5.1.6", "@google-cloud/vision": "^2.4.2", "@line/bot-sdk": "^9.2.2", "@slack/types": "^1.10.0", "@types/cors": "~2.8.13", "airtable": "0.5.9", "axios": "0.18.0", "body-parser": "1.18.3", "cors": "~2.8.5", "citty": "0.1.6", "dotenv": "^7.0.0", "express": "4.16.4", "express-async-handler": "~1.2.0", "express-oauth2-jwt-bearer": "~1.1.0", "form-data": "~4.0.0", "google-auth-library": "~7.12.0", "jsonwebtoken": "^8.5.1", "lib": "~4.3.3", "livescript": "1.6.0", "lodash": "^4.17.21", "mongodb": "^4.13.0", "mqtt": "2.18.8", "nanoid": "~3.3.4", "pino": "^7.11.0", "prelude-ls": "1.1.2", "transaction-parser-th": "0.1.1", "tweetnacl": "^1.0.3", "tweetnacl-sealedbox-js": "^1.2.0", "verror": "^1.10.1", "tsx": "^4.21.0", "grist-api": "0.1.7", "@inquirer/prompts": "^8.0.2" }, "devDependencies": { "@types/express": "^4.17.15", "@types/jsonwebtoken": "^8.5.9", "@types/node": "^24.10.1", "@vercel/ncc": "^0.36.0", "execa": "^1.0.0", "ora": "^3.4.0", "typescript": "^5.9.3", "yargs": "^13.3.2" } } ================================================ FILE: core/src/BotSecrets.ts ================================================ export interface BotSecrets { /** The secret key that must be sent with the request to use the [API](../README.md#cli-api) */ API_KEY: string /** Self-explanatory */ LINE_CHANNEL_SECRET: string /** For development */ DEV_LINE_CHANNEL_SECRET: string /** Self-explanatory */ LINE_CHANNEL_ACCESS_TOKEN: string /** For development */ DEV_LINE_CHANNEL_ACCESS_TOKEN: string /** My user ID, so that the bot receives commands from me only */ LINE_USER_ID: string /** Self-explanatory @deprecated */ AIRTABLE_API_KEY: string /** The ID of the Airtable base used for tracking expenses (should start with ‘app’) @deprecated */ AIRTABLE_EXPENSE_BASE: string /** The web URL to the Airtable base, used for deep linking @deprecated */ AIRTABLE_EXPENSE_URI: string /** The ID of the Airtable base used for cron jobs (should start with ‘app’) @deprecated */ AIRTABLE_CRON_BASE: string /** Two numbers in form of A/B, where A is usage budget per day (rolls over to the next day) and B is starting budget */ EXPENSE_PACEMAKER: string /** Slack webhook URL */ SLACK_WEBHOOK_URL: string /** My user ID, so that the bot receives commands from me only */ SLACK_USER_ID: string /** Google Cloud IoT Core device path to send */ CLOUD_IOT_CORE_DEVICE_PATH: string /** GitHub OAuth App credentials for making unauthenticated API calls with elevated rate limits. In form of ":" */ GITHUB_OAUTH_APP_CREDENTIALS: string /** Encryption secret for decrypting */ ENCRYPTION_SECRET: string /** Database storage */ MONGODB_URL: string /** Subject ID for Google Auth */ GOOGLE_AUTH_SUB: string /** Base URL for Grist */ GRIST_BASE_URL: string /** API key for Grist */ GRIST_API_KEY: string /** Document ID for Grist expense tracking */ GRIST_EXPENSE_DOC_ID: string /** Document URI for Grist expense tracking */ GRIST_EXPENSE_URI: string } ================================================ FILE: core/src/CodeEvaluation.ts ================================================ import { getCodeExecutionContext } from './PreludeCode' import { AutomatronContext, TextMessageHandler } from './types' export async function evaluateCode(input: string, context: AutomatronContext) { const code = input.startsWith(';') ? input : require('livescript') .compile(input, { run: true, print: true, header: false, }) .replace(/^\(function/, '(async function') console.log('Code compilation result', code) const runner = new Function( ...['prelude', 'self', 'code', 'context'], 'with (prelude) { with (self) { return [ eval(code) ] } }' ) const self = await getCodeExecutionContext(context) const [value] = runner(require('prelude-ls'), self, code, context) const returnedValue = await Promise.resolve(value) let result = postProcessResult(returnedValue) const extraMessages = [...self.extraMessages] return { result, extraMessages } } function postProcessResult(returnedValue: any) { if (typeof returnedValue === 'string') { return returnedValue } return require('util').inspect(returnedValue) } export const CodeEvaluationMessageHandler: TextMessageHandler = ( text, context ) => { if (text.startsWith(';')) { return async () => { const input = text.slice(1) try { var { result, extraMessages } = await evaluateCode(input, context) return [{ type: 'text', text: result }, ...extraMessages] } catch (error: any) { const stack = String(error.stack).replace( /\/evalaas\/webpack:\/@dtinth\/automatron-core\//g, '' ) return [{ type: 'text', text: `❌ EVALUATION FAILED ❌\n${stack}` }] } } } } ================================================ FILE: core/src/Cron.ts ================================================ import { ObjectId } from 'mongodb' import { getDb } from './MongoDatabase' import { AutomatronContext } from './types' interface CronEntry { name: string scheduledTime: string completed: boolean notes?: string } export async function addCronEntry( context: AutomatronContext, time: number, text: string ) { const targetTime = new Date(time) targetTime.setUTCSeconds(0) targetTime.setUTCMilliseconds(0) const collection = await getCronCollection(context) await collection.insertOne({ name: text, scheduledTime: targetTime.toISOString(), completed: false, }) return { localTime: new Date(targetTime.getTime() + 7 * 3600e3) .toJSON() .replace(/\.000Z/, ''), } } export async function getCronCollection(context: AutomatronContext) { const db = await getDb(context) return db.collection('cronJobs') } export async function getPendingCronJobs(context: AutomatronContext) { const collection = await getCronCollection(context) return collection.find({ completed: false }).toArray() } export async function updateCronJob( context: AutomatronContext, jobId: string, update: Partial ) { const collection = await getCronCollection(context) await collection.updateOne({ _id: new ObjectId(jobId) }, { $set: update }) } ================================================ FILE: core/src/DataEncryption.ts ================================================ import Encrypted from '@dtinth/encrypted' import { AutomatronContext } from './types' export function decrypt( context: AutomatronContext, encryptedPayload: string ): any { const encrypted = Encrypted(context.secrets.ENCRYPTION_SECRET) return encrypted(encryptedPayload) } export function encrypt(context: AutomatronContext, payload: any): string { const encrypted = Encrypted(context.secrets.ENCRYPTION_SECRET) return encrypted.encrypt(payload) } ================================================ FILE: core/src/DeviceTracking.ts ================================================ import { getDb } from './MongoDatabase' import { ref } from './PersistentState' import { AutomatronContext } from './types' import { logger } from './logger' async function getCollection(context: AutomatronContext) { const db = await getDb(context) return db.collection('deviceLog') } interface DeviceLogEntry { time: string deviceId: string key: string value: any } async function getDeviceStateUpdater( context: AutomatronContext, deviceId: string ) { const collection = await getCollection(context) const device = getDeviceRef(context, deviceId) const state = (await device.get()) || {} const time = new Date().toISOString() let changed = false return { time, state, update: async (key: string, value: any) => { if (JSON.stringify(state[key]) !== JSON.stringify(value)) { state[key] = value await collection.insertOne({ time, deviceId, key, value, }) logger.info( { deviceId, key, value }, `Device "${deviceId}" property "${key}" changed to "${value}"` ) changed = true } }, updateSilently: async (key: string, value: any) => { if (JSON.stringify(state[key]) !== JSON.stringify(value)) { state[key] = value changed = true } }, save: async () => { if (changed) { await device.set(state) } }, } } export async function trackDevice( context: AutomatronContext, deviceId: string, properties: Record ) { const updater = await getDeviceStateUpdater(context, deviceId) if (!('ip' in properties)) { properties.ip = context.requestInfo.ip } for (const [key, value] of Object.entries(properties)) { await updater.update(key, value) } await updater.updateSilently('lastSeen', updater.time) await updater.save() return updater.state } function getDeviceRef(context: AutomatronContext, deviceId: string) { return ref(context, 'devices.' + deviceId) } export async function checkDeviceOnlineStatus(context: AutomatronContext) { const deviceIds = await getDeviceIds(context) for (const deviceId of deviceIds) { const updater = await getDeviceStateUpdater(context, deviceId) if ( updater.state.online && Date.now() - new Date(updater.state.lastSeen).getTime() > 1000 * 60 * 3 ) { await updater.update('online', false) } await updater.save() } } async function getDeviceIds(context: AutomatronContext) { const value = await ref(context, 'deviceIds').get() return (value || '').split(',').filter(Boolean) } ================================================ FILE: core/src/ExpenseTracking.ts ================================================ import { messagingApi } from '@line/bot-sdk' import { GristDocAPI } from 'grist-api' import { ExpenseTrackingGristTables } from './ExpenseTrackingGrist' import { createBubble } from './LINEMessageUtilities' import { TypedGristDocAPI } from './TypedGristDocAPI' import { AutomatronContext } from './types' type ExpenseRecord = ExpenseTrackingGristTables['Daily_Expenses'] function getGristDoc(context: AutomatronContext) { const grist = new GristDocAPI(context.secrets.GRIST_EXPENSE_DOC_ID, { server: context.secrets.GRIST_BASE_URL, apiKey: context.secrets.GRIST_API_KEY, }) return grist as TypedGristDocAPI } export async function recordExpense( context: AutomatronContext, amount: string, category: string, remarks = '' ): Promise { const date = new Date().toJSON().split('T')[0] // Airtable const doc = getGristDoc(context) const [id] = await doc.addRecords('Daily_Expenses', [ { Date: new Date(date + 'T00:00:00Z').getTime() / 1000, Category: category, Amount: parseInt(amount), Note: remarks, }, ]) const gristUri = context.secrets.GRIST_EXPENSE_URI + `?openExternalBrowser=1#a1.s12.r${id}.c28` const body: messagingApi.FlexBox = { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: '฿' + amount, size: 'xxl', weight: 'bold', }, { type: 'text', text: `${category}\nrecorded`, wrap: true, }, ], action: { type: 'uri', label: 'Open Grist', uri: gristUri, }, } const footer = await getExpensesSummaryData(context) const bubble = createBubble('expense tracking', body, { headerColor: '#ffffbb', footer: { type: 'box', layout: 'horizontal', spacing: 'sm', contents: footer.map(([label, text]) => ({ type: 'box', layout: 'vertical', contents: [ { type: 'text', text: label, color: '#8b8685', size: 'xs', align: 'end', }, { type: 'text', text: text, color: '#8b8685', size: 'sm', align: 'end', }, ], })), action: { type: 'uri', label: 'Open Grist', uri: gristUri, }, }, }) return bubble } async function getExpensesSummaryData(context: AutomatronContext) { const date = new Date().toJSON().split('T')[0] const gristToDate = (t: number) => new Date(t * 1000).toISOString().split('T')[0] const grist = getGristDoc(context) const tableData = await grist.fetchTable('Daily_Expenses') const normalRecords = tableData.filter((r) => !r['Occasion']) const total = (records: ExpenseRecord[]) => records.map((r) => +r['Amount'] || 0).reduce((a, b) => a + b, 0) const firstDate = normalRecords .map((r) => gristToDate(r['Date'])) .reduce((a, b) => (a < b ? a : b), date) const todayUsage = total( normalRecords.filter((r) => gristToDate(r['Date']) === date) ) const totalUsage = total(normalRecords) const dayNumber = Math.round((Date.parse(date) - Date.parse(firstDate)) / 86400e3) + 1 const [pacemakerPerDay, pacemakerBase] = context.secrets.EXPENSE_PACEMAKER.split('/') const pacemaker = +pacemakerBase + +pacemakerPerDay * dayNumber - totalUsage const $ = (v: number) => `฿${v.toFixed(0)}` return [ ['today', $(todayUsage)], ['pace', $(pacemaker)], ['day', `${dayNumber}`], ] } ================================================ FILE: core/src/ExpenseTrackingGrist.ts ================================================ export namespace grist { export type Text = string export type Numeric = number export type Int = number export type Bool = boolean export type Date = number export type DateTime = string export type Choice = string export type Reference = number export type ReferenceList = ['L', ...number[]] | null export type ChoiceList = ['L', ...string[]] | null export type Attachments = ['L', ...number[]] | null export type Any = any } export type ExpenseTrackingGristTables = { Daily_Expenses: { id: number Date: grist.Date Amount: grist.Numeric Category: grist.Choice Beneficiary: grist.Choice Note: grist.Text Occasion: grist.Bool Month: grist.Any // calculated/formula Daily_Amount: grist.Numeric // calculated/formula } } ================================================ FILE: core/src/HomeAutomation.ts ================================================ import Encrypted from '@dtinth/encrypted' import { AutomatronContext } from './types' import axios from 'axios' export async function sendHomeCommand( context: AutomatronContext, cmd: string | string[] ): Promise { const cmds = Array.isArray(cmd) ? cmd : [cmd] const encrypted = Encrypted(context.secrets.ENCRYPTION_SECRET) const { url, key } = encrypted`mz8Dc0LiBPPI63I5lMJHdC3RC9VF//S2.QYg30oYhuEUS8b1u80vM7sO0cv4OLYNtxy52fTMnf2Vcg8QZHlLeXUV1qPnEjR/5jYTid5hG6of8mHDHjTE1A+luDzplgM4WJQBgDNM2pkRnKnbcmAUw8MXxBb4ZMqrrAyFELigKoELWwbDg51ErhFXrm+n3hzfpbRIcge1BdH6aEPtNipsUcMj7q7BfBqFLZA==` await Promise.all( cmds.map(async (command) => { const id = new Date().toJSON() + Math.floor(Math.random() * 10000) .toString() .padStart(2, '0') await axios.post(url, { id, topic: 'home', data: command }, { headers: { 'X-Api-Key': key } }) }) ) } ================================================ FILE: core/src/ImageMessageHandler.ts ================================================ import vision from '@google-cloud/vision' import { ref } from './PersistentState' import { getBlob, getBlobUrl } from './TemporaryBlobStorage' import { TextMessage, TextMessageHandler } from './types' export const ImageMessageHandler: TextMessageHandler = (text, context) => { if (text.startsWith('image:')) { return async () => { const blobName = text.slice(6) await ref(context, 'latestImage').set(blobName) return [{ type: 'text', text: blobName }] } } if (text === 'annotate') { return async () => { const blobName = await ref(context, 'latestImage').get() return await annotateImage(blobName) } } if (text === 'image url') { return async () => { const blobName = await ref(context, 'latestImage').get() return await getBlobUrl(blobName) } } } async function annotateImage(blobName: string) { const buffer = await getBlob(blobName) const imageAnnotator = new vision.ImageAnnotatorClient() const results = await imageAnnotator.documentTextDetection(buffer) const fullTextAnnotation = results[0].fullTextAnnotation const blocks: string[] = [] for (const page of fullTextAnnotation.pages) { blocks.push( ...page.blocks.map((block) => { return block.paragraphs .map((p) => p.words.map((w) => w.symbols.map((s) => s.text).join('')).join(' ') ) .join('\n\n') }) ) } const blocksToResponses = (blocks: string[]) => { if (blocks.length <= 4) return blocks let processedIndex = 0 const outBlocks = [] for (let i = 0; i < 4; i++) { const targetIndex = Math.ceil(((i + 1) * blocks.length) / 4) outBlocks.push( blocks .slice(processedIndex, targetIndex) .map((x) => `・ ${x}`) .join('\n') ) processedIndex = targetIndex } return outBlocks } const responses = blocksToResponses(blocks) return [...responses.map((r): TextMessage => ({ type: 'text', text: r }))] } ================================================ FILE: core/src/LINEClient.ts ================================================ import { messagingApi } from '@line/bot-sdk' export class LINEClient { private readonly api: messagingApi.MessagingApiClient private readonly blobApi: messagingApi.MessagingApiBlobClient constructor(config: { channelAccessToken: string }) { this.api = new messagingApi.MessagingApiClient({ channelAccessToken: config.channelAccessToken, }) this.blobApi = new messagingApi.MessagingApiBlobClient({ channelAccessToken: config.channelAccessToken, }) } replyMessage(replyToken: string, messages: messagingApi.Message[]) { return this.api.replyMessage({ replyToken, messages }) } pushMessage(to: string, messages: messagingApi.Message[]) { return this.api.pushMessage({ to, messages }) } getMessageContent(messageId: string) { return this.blobApi.getMessageContent(messageId) } showLoadingAnimation(chatId: string) { return this.api.showLoadingAnimation({ chatId }) } } ================================================ FILE: core/src/LINEMessageUtilities.ts ================================================ import { messagingApi } from '@line/bot-sdk' export function toMessages(data: any): messagingApi.Message[] { if (!data) data = '...' if (typeof data === 'string') data = [{ type: 'text', text: data }] if (!Array.isArray(data)) data = [data] return data } export function createBubble( title: string, text: string | messagingApi.FlexBox, { headerBackground = '#353433', headerColor = '#d7fc70', textSize = 'xl', altText = String(text), footer, }: { headerBackground?: string headerColor?: string textSize?: messagingApi.FlexText['size'] altText?: string footer?: string | messagingApi.FlexBox } = {} ): messagingApi.FlexMessage { const data: messagingApi.FlexContainer = { type: 'bubble', styles: { header: { backgroundColor: headerBackground }, }, header: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: title, color: headerColor, weight: 'bold' }, ], }, body: typeof text === 'string' ? { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: text, wrap: true, size: textSize }, ], } : text, } if (footer) { data.styles!.footer = { backgroundColor: '#e9e8e7' } data.footer = typeof footer === 'string' ? { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: footer, wrap: true, size: 'sm', color: '#8b8685', }, ], } : footer } return { type: 'flex', altText: truncate(`[${title}] ${altText}`, 400), contents: data, } } function truncate(text: string, maxLength: number) { return text.length + 5 > maxLength ? text.substr(0, maxLength - 5) + '…' : text } ================================================ FILE: core/src/LanguageModelAssistant.ts ================================================ import axios from 'axios' import { decrypt } from './DataEncryption' import { logger } from './logger' import { getDb } from './MongoDatabase' import { ref } from './PersistentState' import { AutomatronContext, AutomatronResponse, TextMessageHandler, } from './types' type Role = 'system' | 'user' | 'assistant' interface ChatMessage { role: Role content: string } interface LlmHistoryEntry { time: string contextMessages?: ChatMessage[] inText: string outText: string } async function getCollection(context: AutomatronContext) { const db = await getDb(context) return db.collection('llmHistory') } export const LanguageModelAssistantMessageHandler: TextMessageHandler = ( text, context ) => { const runLlm = async ( inText: string, continueFrom?: LlmHistoryEntry ): Promise => { const key = decrypt( context, 'c0wgjM3RJp/V40lLhcHbtjUDBvlT/NlI.yF9iWwImsHrUOiZkD6UMZdGyjLd3yCJm7WMkN6dxerzXlxCK4U6bSzNFHwyvUjPDop4+gLCs5Qa9Pcxwii3DvSVjjE+7' ) const nowIct = new Date(Date.now() + 7 * 3600e3) const day = [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', ][nowIct.getDay()] const prompt: string[] = [ // https://beta.openai.com/examples/default-chat 'Current date and time: ' + nowIct.toISOString().replace('Z', '+07:00') + ' (Asia/Bangkok).', 'Today is ' + day + '.', 'The following is a conversation with an AI assistant, automatron. ' + 'The assistant is helpful, creative, clever, funny, and very friendly. ' + (await ref(context, 'llmPrompt').get()), ] const newContext: ChatMessage[] = [ ...(continueFrom ? [ ...(continueFrom.contextMessages ?? []), { role: 'user' as Role, content: continueFrom.inText }, { role: 'assistant' as Role, content: continueFrom.outText }, ] : []), ] const messages: ChatMessage[] = [ { role: 'system', content: prompt.join('\n') }, ...newContext, { role: 'user', content: inText }, ] const payload = { model: 'chatgpt-4o-latest', messages, temperature: +(await ref(context, 'llmTemperature').get()) || 0.5, } const response = await axios.post( 'https://api.openai.com/v1/chat/completions', payload, { headers: { Authorization: `Bearer ${key}` } } ) const responseText = response.data.choices[0].message.content.trim() logger.info( { assistant: { prompt, response: response.data } }, 'Ran OpenAI assistant' ) const collection = await getCollection(context) await collection.insertOne({ time: new Date().toISOString(), contextMessages: newContext, inText, outText: responseText, }) return [{ type: 'text', text: responseText }] } if (text.match(/^hey\b/i)) { return async () => { return runLlm(text) } } if (text.match(/^hmm?\b/i)) { return async () => { const collection = await getCollection(context) const [lastEntry] = await collection .find({}) .sort({ _id: -1 }) .limit(1) .toArray() return runLlm(text, lastEntry) } } } ================================================ FILE: core/src/MessageHandler.ts ================================================ import { CodeEvaluationMessageHandler } from './CodeEvaluation' import { addCronEntry } from './Cron' import { recordExpense } from './ExpenseTracking' import { sendHomeCommand } from './HomeAutomation' import { ImageMessageHandler } from './ImageMessageHandler' import { LanguageModelAssistantMessageHandler } from './LanguageModelAssistant' import { MessageHistoryMessageHandler, saveMessageHistory, } from './MessageHistory' import { getDb } from './MongoDatabase' import { ref } from './PersistentState' import { PhoneFinderMessageHandler } from './PhoneFinder' import { getCodeExecutionContext } from './PreludeCode' import { decodeRomanNumerals } from './RomanNumerals' import { SpendingTrackingMessageHandler } from './SpendingTracking' import { putBlob } from './TemporaryBlobStorage' import { trace } from './Tracing' import { AutomatronContext, AutomatronResponse } from './types' const messageHandlers = [ CodeEvaluationMessageHandler, ImageMessageHandler, MessageHistoryMessageHandler, SpendingTrackingMessageHandler, PhoneFinderMessageHandler, LanguageModelAssistantMessageHandler, ] export async function handleTextMessage( context: AutomatronContext, message: string, options: { source: string } ): Promise { message = message.trim() let match: RegExpMatchArray | null context.addPromise( 'Save text history', getDb(context).then((db) => trace(context, 'Save history', () => saveMessageHistory(context, message, options.source) ) ) ) if (message === 'ac on' || message === 'sticker:2:27') { await sendHomeCommand(context, 'ac on') return 'ok, turning air-con on' } else if (message === 'ac off' || message === 'sticker:2:29') { await sendHomeCommand(context, 'ac off') return 'ok, turning air-con off' } else if (message === 'power on' || message === 'plugs on') { await sendHomeCommand(context, 'plugs on') return 'ok, turning smart plugs on' } else if (message === 'power off' || message === 'plugs off') { await sendHomeCommand(context, 'plugs off') return 'ok, turning smart plugs off' } else if ( message === 'home' || message === 'arriving' || message === 'sticker:2:503' ) { await sendHomeCommand(context, ['plugs on', 'lights normal', 'ac on']) return 'preparing home' } else if (message === 'leaving' || message === 'sticker:2:502') { await sendHomeCommand(context, ['plugs off', 'lights off', 'ac off']) return 'bye' } else if (message === 'lights' || message === 'sticker:4:275') { await sendHomeCommand(context, 'lights normal') return 'ok, lights normal' } else if ( message === 'bedtime' || message === 'gn' || message === 'gngn' || message === 'sticker:11539:52114128' ) { await sendHomeCommand(context, 'lights dimmed') await addCronEntry(context, Date.now() + 300e3, 'lights off') const prelude = await getCodeExecutionContext(context) await prelude.executeHandlers('bedtime') return 'ok, good night' } else if (message === 'ooo') { const prelude = await getCodeExecutionContext(context) await prelude.executeHandlers('ooo') return 'ok, out of office' } else if (message === 'work') { const prelude = await getCodeExecutionContext(context) await prelude.executeHandlers('work') return 'ok, set working status' } else if ((match = message.match(/^lights (\w+)$/))) { const cmd = match[1] await sendHomeCommand(context, 'lights ' + cmd) return 'ok, lights ' + cmd } else if ((match = message.match(/^in ([\d\.]+)([mh]),?\s+([^]+)$/))) { const targetTime = Date.now() + +match[1] * (match[2] === 'm' ? 60 : 3600) * 1e3 const result = await addCronEntry(context, targetTime, match[3]) return `will run "${match[3]}" at ${result.localTime}` } else if ((match = message.match(/^([\d.]+|[ivxlcdm]+)(j?)([tfghmol])$/i))) { const m = match const enteredAmount = m[1].match(/[ivxlcdm]/) ? decodeRomanNumerals(m[1]) : +m[1] const conversionRate = m[2] ? 0.302909 : 1 const amount = (enteredAmount * conversionRate).toFixed(2) const category = ( { f: 'Food', t: 'Transportation', s: 'Stationery', g: 'Grocery', h: 'Hygiene', m: 'Medicine', w: 'Wellness', // o: 'occasion', // l: 'lodging', } as { [k: string]: string } )[m[3].toLowerCase()] const remarks = m[2] ? `${m[1]} JPY` : '' return await recordExpense(context, amount, category, remarks) } else if ((match = message.match(/^([ivxlcdm]+)$/i))) { return `${match[1]} = ${decodeRomanNumerals(match[1])}` } // Go through message handlers and see if any of them can handle the message for (const handler of messageHandlers) { const action = handler(message, context) if (action) { return action() } } // At this point, the message is not recognized. // Just save it to the stack. const size = await ref(context, 'stack').push(message) return '(unrecognized message, saved to stack (size=' + size + '))' } export async function handleImage( context: AutomatronContext, imageBuffer: Buffer, options: { source: string } ) { const blobName = await putBlob(imageBuffer, '.jpg') return await handleTextMessage(context, 'image:' + blobName, options) } ================================================ FILE: core/src/MessageHistory.ts ================================================ import { getDb } from './MongoDatabase' import { AutomatronContext, TextMessage, TextMessageHandler } from './types' export interface MessageHistoryEntry { time: string text: string source: string } export async function saveMessageHistory( context: AutomatronContext, text: string, source: string ) { const db = await getDb(context) await db.collection('history').insertOne({ time: new Date().toISOString(), text: text, source: source, }) } export async function getMessageHistory( context: AutomatronContext, options: { limit: number } ) { const db = await getDb(context) return await db .collection('history') .find({}) .sort({ _id: -1 }) .limit(options.limit) .toArray() } export const MessageHistoryMessageHandler: TextMessageHandler = ( text, context ) => { if (text === 'history') { return async () => { const history = await getMessageHistory(context, { limit: 5 }) return [...history] .reverse() .map((entry): TextMessage => ({ type: 'text', text: entry.text })) } } } ================================================ FILE: core/src/MongoDatabase.ts ================================================ import { Db, MongoClient } from 'mongodb' import { trace } from './Tracing' import { AutomatronContext } from './types' export { Db } from 'mongodb' const globalCacheKey = Symbol.for('automatron/MongoDatabase') const cache: { dbPromise: Promise | null } = (() => { return ((global as any)[globalCacheKey] = (global as any)[globalCacheKey] || { dbPromise: null, }) })() export async function getDb(context: AutomatronContext): Promise { if (cache.dbPromise) { return cache.dbPromise } cache.dbPromise = trace(context, 'getDb', async () => { const client = await MongoClient.connect(context.secrets.MONGODB_URL) const db = client.db('automatron') return db }) cache.dbPromise.catch(() => { cache.dbPromise = null }) return cache.dbPromise } ================================================ FILE: core/src/NotificationProcessor.ts ================================================ import { getDb } from './MongoDatabase' import { AutomatronContext } from './types' import { logger } from './logger' export interface INotification { packageName: string text: string title: string time: string postTime: string when: string key: string } export async function handleNotification( context: AutomatronContext, notification: INotification ) { const text = notification.title + ' : ' + notification.text const time = notification.when || notification.postTime || notification.time const key = notification.key const promises: Promise[] = [] const process = (name: string, promise: Promise) => { promises.push( promise.catch((err) => { logger.error({ err, name }, `Unable to run processor "${name}": ${err}`) }) ) } process('save to DB', saveNotificationToDb(context, notification)) if (notification.packageName === 'com.kasikorn.retail.mbanking.wap') { process('process KBank', handleKbankNotification(context, key, text, time)) } return Promise.all(promises) } async function saveNotificationToDb( context: AutomatronContext, notification: INotification ) { const db = await getDb(context) await db .collection('notis') .insertOne({ received: new Date().toISOString(), ...notification }) } export async function handleKbankNotification( context: AutomatronContext, key: string, text: string, time: string = new Date().toISOString() ) { let m: RegExpMatchArray | null m = text.match( /^รายการใช้บัตร : หมายเลขบัตร (\S+) จำนวนเงิน (\S+) (\S+) ที่ ([^]*)$/ ) if (m) { const db = await getDb(context) db.collection('txs').insertOne({ notificationKey: key, time, type: 'charge', card: m[1], amount: parseAmount(m[2]), currency: m[3], merchant: m[4], }) return } m = text.match( /^รายการยกเลิก : หมายเลขบัตร (\S+) จำนวนเงิน (\S+) (\S+) ที่ ([^]*)$/ ) if (m) { const db = await getDb(context) db.collection('txs').insertOne({ notificationKey: key, time, type: 'refund', card: m[1], amount: parseAmount(m[2]), currency: m[3], merchant: m[4], }) return } } function parseAmount(text: string) { return +text.replace(/,/g, '') } ================================================ FILE: core/src/PersistentState.ts ================================================ import { getDb } from './MongoDatabase' import { trace } from './Tracing' import { AutomatronContext } from './types' interface StateDoc { _id: string value: any } interface StateDocStack extends StateDoc { value: any[] } async function push( context: AutomatronContext, key: string, value: any ): Promise { const db = await getDb(context) const result = await trace(context, `read(${key})`, () => db .collection('state') .findOneAndUpdate( { _id: key }, { $push: { value } }, { upsert: true, returnDocument: 'after' } ) ) return result.value!.value.length } async function pop(context: AutomatronContext, key: string): Promise { const db = await getDb(context) const result = await trace(context, `pop(${key})`, () => db .collection('state') .findOneAndUpdate({ _id: key }, { $pop: { value: 1 } }) ) return result.value!.value.pop() } async function get(context: AutomatronContext, key: string): Promise { const db = await getDb(context) const result = await trace(context, `get(${key})`, () => db.collection('state').findOne({ _id: key }) ) return result?.value } async function set( context: AutomatronContext, key: string, value: any ): Promise { const db = await getDb(context) const result = await trace(context, `set(${key})`, () => db .collection('state') .findOneAndUpdate( { _id: key }, { $set: { value } }, { upsert: true, returnDocument: 'after' } ) ) return !!result.ok } export function ref(context: AutomatronContext, key: string) { return { push: push.bind(null, context, key), pop: pop.bind(null, context, key), get: get.bind(null, context, key), set: set.bind(null, context, key), } } ================================================ FILE: core/src/PhoneFinder.ts ================================================ import { TextMessageHandler } from './types' import { GoogleAuth } from 'google-auth-library' import axios from 'axios' import { decrypt } from './DataEncryption' const auth = new GoogleAuth() export const PhoneFinderMessageHandler: TextMessageHandler = ( text, context ) => { if (text === 'where is my phone') { return async () => { const url = decrypt( context, '09oTErU3ru/YqzCNmUBIH2ftVYz1jSfG.NODxXCib7BiuF0vAtnxpwbsvVQ5fxNVXoYDYGZHmJNxBRLPfAWj9KmAK89cOjCtFw3uxudSjqCV1F79Icmn3/SWwi5Z313xXkKyJFr9YWT/1dq8+EWcZfbPR' ) const client = await auth.getIdTokenClient(url) const jwt = await client.idTokenProvider.fetchIdToken(url) await axios.post(url, {}, { headers: { Authorization: `Bearer ${jwt}` } }) return 'calling you now...' } } } ================================================ FILE: core/src/PreludeCode.ts ================================================ import axios from 'axios' import { Storage } from '@google-cloud/storage' import { AutomatronContext } from './types' import tweetnacl from 'tweetnacl' import jsonwebtoken from 'jsonwebtoken' import crypto from 'crypto' import util from 'util' import Encrypted from '@dtinth/encrypted' import { logger } from './logger' import * as mongodb from 'mongodb' import * as os from 'os' import { Db, getDb } from './MongoDatabase' import * as NotificationProcessor from './NotificationProcessor' import * as PersistentState from './PersistentState' import { getAllSpeedDials, getSpeedDialCode, saveSpeedDial } from './SpeedDial' const lib = require('lib') const storage = new Storage() const preludeFile = storage.bucket('dtinth-automatron-data').file('prelude.js') export async function deployPrelude(context: AutomatronContext) { const userPreludeResponse = await axios.get( 'https://api.github.com/repos/dtinth/automatron-prelude/contents/prelude.js', { auth: { username: context.secrets.GITHUB_OAUTH_APP_CREDENTIALS.split(':')[0], password: context.secrets.GITHUB_OAUTH_APP_CREDENTIALS.split(':')[1], }, } ) const buffer = Buffer.from(userPreludeResponse.data.content, 'base64') await preludeFile.save(buffer) await PersistentState.ref(context, 'preludeDeployedAt').set( new Date().toISOString() ) } let cache: { deployedAt: string; code: string } | undefined export async function getPreludeCode(context: AutomatronContext) { const latestDeployedAt = await PersistentState.ref( context, 'preludeDeployedAt' ).get() if (cache && cache.deployedAt === latestDeployedAt) { return cache.code } const [buffer] = await preludeFile.download() const code = buffer.toString('utf8') cache = { deployedAt: latestDeployedAt, code, } return code } export async function getCodeExecutionContext( context: AutomatronContext ): Promise { // Prepare "self" context const self: any = {} self.exec = (code: string) => { return new Function( ...['self', 'code'], 'with (self) { return eval(code) }' )(self, code) } self.encrypted = Encrypted(context.secrets.ENCRYPTION_SECRET) self.extraMessages = [] self.withDb = (f: (db: Db) => any) => getDb(context).then(f) self.ref = PersistentState.ref.bind(null, context) self.stack = self.ref('stack') self.require = (id: string) => { const availableModules: { [id: string]: any } = { axios, tweetnacl, jsonwebtoken, crypto, util, mongodb, os, lib, '@/NotificationProcessor': NotificationProcessor, } const available = {}.hasOwnProperty.call(availableModules, id) if (!available) { throw new Error( `Module ${id} not available; available modules: ${Object.keys( availableModules )}` ) } return availableModules[id] } self.json = (x: any) => JSON.stringify(x, null, 2) self.SD = async (name?: string, f?: () => any) => { if (name && f) { if (typeof f !== 'function') { throw new Error('f must be a function') } await saveSpeedDial(context, name, f.toString()) return 'Saved speed dial' } else if (name) { const code = await getSpeedDialCode(context, name) self.extraMessages.push({ type: 'text', text: `;;SD('${name}', ${code})`, }) return self.exec(code)() } else { const all = await getAllSpeedDials(context) return all.map((s) => s._id) } } // Plugin system type Handler = (...args: any[]) => Promise const registeredHandlers: Record> = {} self.registerHandler = (event: string, handler: Handler) => { if (!registeredHandlers[event]) { registeredHandlers[event] = new Set() } registeredHandlers[event].add(handler) } self.executeHandlers = async (event: string, ...args: any[]) => { await Promise.all( [...(registeredHandlers[event] || [])].map(async (handler, i) => { try { const start = Date.now() await handler(...args) const elapsed = Date.now() - start logger.info( `Done executing handler index ${i} for event ${event} in ${elapsed} ms` ) } catch (error) { logger.error( { err: error }, `Unable to execute handler index ${i} for event ${event}: ${error}` ) } }) ) } // Execute user prelude const userPrelude = await getPreludeCode(context) self.exec(userPrelude) return self } ================================================ FILE: core/src/RomanNumerals.ts ================================================ export function decodeRomanNumerals(romanNumerals: string) { const decode = (s: string): number => { if (s.startsWith('M')) return 1000 + decode(s.substr(1)) if (s.startsWith('CM')) return 900 + decode(s.substr(2)) if (s.startsWith('D')) return 500 + decode(s.substr(1)) if (s.startsWith('CD')) return 400 + decode(s.substr(2)) if (s.startsWith('C')) return 100 + decode(s.substr(1)) if (s.startsWith('XC')) return 90 + decode(s.substr(2)) if (s.startsWith('L')) return 50 + decode(s.substr(1)) if (s.startsWith('XL')) return 40 + decode(s.substr(2)) if (s.startsWith('X')) return 10 + decode(s.substr(1)) if (s.startsWith('IX')) return 9 + decode(s.substr(2)) if (s.startsWith('V')) return 5 + decode(s.substr(1)) if (s.startsWith('IV')) return 4 + decode(s.substr(2)) if (s.startsWith('I')) return 1 + decode(s.substr(1)) if (s === '') return 0 throw new InvalidRomanNumeralError(s) } try { return decode(romanNumerals.toUpperCase()) } catch (e) { if (e instanceof InvalidRomanNumeralError) { throw new InvalidRomanNumeralError(romanNumerals) } throw e } } class InvalidRomanNumeralError extends Error { constructor(s: string) { super('Invalid roman numeral in input ' + s) } } ================================================ FILE: core/src/SMSHandler.ts ================================================ import { messagingApi, QuickReplyItem } from '@line/bot-sdk' import { AutomatronContext } from './types' import { recordExpense } from './ExpenseTracking' import { createBubble } from './LINEMessageUtilities' import { LINEClient } from './LINEClient' export async function handleSMS( context: AutomatronContext, client: LINEClient, text: string ) { const { parseSMS } = require('transaction-parser-th') const result = parseSMS(text) if (!result || !result.amount) return { match: false } console.log('SMS parsing result', result) const title = result.type const pay = result.type === 'pay' const moneyOut = ['pay', 'transfer', 'withdraw'].includes(result.type) const body: messagingApi.FlexBox = { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: '฿' + result.amount, size: 'xxl', weight: 'bold', }, ], } const ordering = ['provider', 'from', 'to', 'via', 'date', 'time', 'balance'] const skip = ['type', 'amount'] const getOrder = (key: string) => ordering.indexOf(key) + 1 || 999 for (const key of Object.keys(result) .filter((key) => !skip.includes(key)) .sort((a, b) => getOrder(a) - getOrder(b))) { body.contents.push({ type: 'box', layout: 'horizontal', spacing: 'md', contents: [ { type: 'text', text: key, align: 'end', color: '#888888', flex: 2, }, { type: 'text', text: String(result[key]), flex: 5, }, ], }) } const quickReply = (suffix: string, label: string): QuickReplyItem => ({ type: 'action', action: { type: 'message', label: label, text: result.amount + suffix, }, }) const messages: messagingApi.Message[] = [ { ...createBubble(title, body, { headerBackground: pay ? '#91918F' : moneyOut ? '#DA9E00' : '#9471FF', headerColor: '#FFFFFF', altText: require('util').inspect(result), }), quickReply: { items: [ quickReply('f', 'food'), quickReply('h', 'health'), quickReply('t', 'transport'), quickReply('m', 'misc'), quickReply('o', 'occasion'), ], }, }, ] if (result.type === 'pay' && result.to === 'LINEPAY*BTS01') { messages.push( await recordExpense(context, result.amount, 'transportation', 'BTS') ) } await client.pushMessage(context.secrets.LINE_USER_ID, messages) return { match: true } } ================================================ FILE: core/src/SlackMessageUtilities.ts ================================================ import { KnownBlock, MessageAttachment } from '@slack/types' export type SlackMessage = SlackMessageWithoutBlocks | SlackMessageWithBlocks export type SlackMessageWithoutBlocks = { text: string attachments?: MessageAttachment[] } export type SlackMessageWithBlocks = { text?: string; blocks: KnownBlock[] } export function createErrorMessage(error: Error): SlackMessage { const title = (error.name || 'Error') + (error.message ? `: ${error.message}` : '') return { text: title, attachments: [ { color: 'danger', blocks: [ { type: 'section', text: { type: 'mrkdwn', text: ['```', String(error.stack || error), '```'].join('') } } ] } ] } } ================================================ FILE: core/src/SpeedDial.ts ================================================ import { getDb } from './MongoDatabase' import { AutomatronContext } from './types' async function getCollection(context: AutomatronContext) { const db = await getDb(context) return db.collection<{ _id: string; code: string }>('speedDial') } export async function saveSpeedDial( context: AutomatronContext, name: string, code: string ) { const collection = await getCollection(context) await collection.updateOne( { _id: name }, { $set: { code } }, { upsert: true } ) } export async function getSpeedDialCode( context: AutomatronContext, name: string ) { const collection = await getCollection(context) const result = await collection.findOne({ _id: name }) if (!result) { throw new Error(`Speed dial ${name} not found`) } return result.code } export async function getAllSpeedDials(context: AutomatronContext) { const collection = await getCollection(context) return collection.find({}).sort({ _id: 1 }).toArray() } ================================================ FILE: core/src/SpendingTracking.ts ================================================ import { getDb } from './MongoDatabase' import { ref } from './PersistentState' import { TextMessageHandler } from './types' export const SpendingTrackingMessageHandler: TextMessageHandler = ( text, context ) => { if (text === 'pace') { return async () => { const db = await getDb(context) const paceResetTimeRef = ref(context, 'SpendingTracking.paceResetTime') const paceResetTime = await paceResetTimeRef.get() const allowancePerMillis = 24000 / (32 * 86400e3) const elapsed = Date.now() - Date.parse(paceResetTime) const txs = await db .collection('txs') .find({ time: { $gt: paceResetTime } }) .sort({ _id: 1 }) .toArray() const warnings = [] let sum = 0 for (const tx of txs) { let currencyMul if (tx.currency === 'บาท') { currencyMul = 1 } else { warnings.push(`Unknown currency ${tx.currency}`) continue } const typeMul = tx.type === 'refund' ? -1 : 1 sum += tx.amount * typeMul * currencyMul } const remaining = Math.round(allowancePerMillis * elapsed - sum) // return { sum, warnings } return `used ${sum} บาท\nremaining ${remaining} บาท` } } if (text === 'reset pace') { return async () => { const now = new Date().toISOString() await ref(context, 'SpendingTracking.paceResetTime').set(now) return `Pace reset to ${now}` } } } ================================================ FILE: core/src/TemporaryBlobStorage.ts ================================================ import { Storage } from '@google-cloud/storage' import { nanoid } from 'nanoid' const storage = new Storage() let latest: { blobName: string; buffer: Buffer } | undefined export async function putBlob(buffer: Buffer, extension: string) { const blobName = nanoid() + extension await storage.bucket('tmpblob').file(blobName).save(buffer) latest = { blobName, buffer } return blobName } export async function getBlob(blobName: string) { if (latest && latest.blobName === blobName) { return latest.buffer } const response = await storage.bucket('tmpblob').file(blobName).download() return response[0] } export async function getBlobUrl(blobName: string) { const result = await storage .bucket('tmpblob') .file(blobName) .getSignedUrl({ action: 'read', expires: new Date(Date.now() + 86400e3), version: 'v4', virtualHostedStyle: true, }) return result[0] } ================================================ FILE: core/src/Tracing.ts ================================================ import { AutomatronContext } from './types' export async function trace( context: AutomatronContext, name: string, f: () => Promise ) { const tracer = context.tracer if (!tracer) { return f() } const span = tracer.createChildSpan({ name }) try { return await f() } finally { span.endSpan() } } ================================================ FILE: core/src/TypedGristDocAPI.ts ================================================ import type { GristDocAPI } from 'grist-api' // From: https://dt.in.th/GristTypeGenerator export interface TypedGristDocAPI extends Omit< GristDocAPI, | 'fetchTable' | 'addRecords' | 'deleteRecords' | 'updateRecords' | 'syncTable' > { fetchTable( tableName: TableName, filters?: FilterSpec ): Promise addRecords( tableName: TableName, records: Partial[] ): Promise deleteRecords( tableName: TableName, recordIds: number[] ): Promise updateRecords( tableName: TableName, records: (Partial & { id: number })[] ): Promise syncTable( tableName: TableName, records: Partial[], keyColIds: (keyof Tables[TableName])[], options?: { filters?: FilterSpec } ): Promise } export type AnyTable = { [colId: string]: unknown } export type AnyTables = { [table: string]: AnyTable } export type FilterSpec = { [ColId in keyof Table]?: Table[ColId][] } ================================================ FILE: core/src/bot.ts ================================================ import Encrypted from '@dtinth/encrypted' import { MessageEvent, middleware, WebhookEvent } from '@line/bot-sdk' import axios from 'axios' import cors from 'cors' import express, { NextFunction, Request, RequestHandler, Response, } from 'express' import handler from 'express-async-handler' import { claimEquals, auth as jwtAuth } from 'express-oauth2-jwt-bearer' import { Stream } from 'stream' import sealedbox from 'tweetnacl-sealedbox-js' import { getPendingCronJobs, updateCronJob } from './Cron' import { encrypt } from './DataEncryption' import { checkDeviceOnlineStatus, trackDevice } from './DeviceTracking' import { LINEClient } from './LINEClient' import { toMessages } from './LINEMessageUtilities' import { logger } from './logger' import { handleImage, handleTextMessage } from './MessageHandler' import { getMessageHistory } from './MessageHistory' import { handleNotification } from './NotificationProcessor' import { ref } from './PersistentState' import { deployPrelude } from './PreludeCode' import { createErrorMessage, SlackMessage } from './SlackMessageUtilities' import { handleSMS } from './SMSHandler' import { getAllSpeedDials } from './SpeedDial' import { AutomatronContext } from './types' const app = express() app.set('trust proxy', true) function getAutomatronContext(req: Request, res: Response): AutomatronContext { return { secrets: req.env, tracer: req.tracer, requestInfo: { ip: req.ip || '', ips: req.ips, headers: req.headers, }, addPromise: (name, promise) => { if (!res.yields) res.yields = [] res.yields.push(promise) }, } } async function runMiddleware( req: Request, res: Response, middleware: RequestHandler ): Promise { return new Promise((resolve, reject) => { middleware(req, res, (error) => { if (error) { reject(error) } else { resolve() } }) }) } async function handleWebhook( context: AutomatronContext, events: WebhookEvent[], client: LINEClient ) { async function main() { for (const event of events) { if (event.type === 'message') { await handleMessageEvent(event) } } } async function handleMessageEvent(event: MessageEvent) { const { replyToken, message } = event if (event.source.userId !== context.secrets.LINE_USER_ID) { await client.replyMessage(replyToken, toMessages('unauthorized')) return } client.showLoadingAnimation(event.source.userId).catch((e) => { logger.error({ err: e }, 'Unable to show loading animation') }) if (message.type === 'text') { const reply = await handleTextMessage(context, message.text, { source: 'line', }) await client.replyMessage(replyToken, toMessages(reply)) } else if (message.type === 'sticker') { const reply = await handleTextMessage( context, 'sticker:' + message.packageId + ':' + message.stickerId, { source: 'line' } ) await client.replyMessage(replyToken, toMessages(reply)) } else if (message.type === 'image') { const content = await client.getMessageContent(message.id) const buffer = await readAsBuffer(content) const reply = await handleImage(context, buffer as Buffer, { source: 'line', }) await client.replyMessage(replyToken, toMessages(reply)) } else { await client.replyMessage(replyToken, [ { type: 'text', text: 'don’t know how to handle this yet!' }, ]) } } return main() } app.post( '/webhook', handler(async (req, res) => { const lineConfig = getLineConfig(req, res) await runMiddleware(req, res, middleware(lineConfig)) await handleRequest(req, res, async (context, services) => { const lineClient = services.line logger.info( { ingest: 'line', event: JSON.stringify(req.body) }, 'Received webhook from LINE' ) const data = await handleWebhook(context, req.body.events, lineClient) return data }) }) ) app.post( '/slack', require('body-parser').json(), (req, res, next) => { logger.info( { ingest: 'slack', event: JSON.stringify(req.body) }, 'Received an event from Slack' ) if (req.body.type === 'url_verification') { res.set('Content-Type', 'text/plain').send(req.body.challenge) return } next() }, endpoint(async (context, req, services) => { if (req.body.type === 'event_callback') { let globalScope = global as unknown as { automatronSlackEventCache: Set } let eventCache = globalScope.automatronSlackEventCache if (!eventCache) { eventCache = new Set() globalScope.automatronSlackEventCache = eventCache } const eventId = req.body.event_id if (eventCache.has(eventId)) { return } eventCache.add(eventId) if (req.body.event.user === req.env.SLACK_USER_ID) { const text = String(req.body.event.text) .replace(/>/g, '>') .replace(/</g, '>') .replace(/&/g, '&') const slackClient = services.slack const reply = await handleTextMessage(context, text, { source: 'slack', }) await slackClient.pushMessage({ text: `\`\`\`${JSON.stringify(reply, null, 2)}\`\`\``, }) } } return 1 }) ) app.post( '/post', require('body-parser').json(), requireApiKey, endpoint(async (context, req, services) => { const lineClient = services.line const messages = toMessages(req.body.data) await lineClient.pushMessage(context.secrets.LINE_USER_ID, messages) }) ) app.post( '/text', require('body-parser').json(), requireApiKey, endpoint(async (context, req, services) => { logger.info( { ingest: 'text', event: JSON.stringify(req.body) }, 'Received a text API call' ) const text = String(req.body.text) logToSlack(context, services.auditSlack, text, req.body.source) const reply = await handleTextMessage(context, text, { source: 'text:' + req.body.source, }) return reply }) ) app.options('/webpost-firebase', cors() as any) app.post( '/webpost-firebase', require('body-parser').json(), requireFirebaseAuth, cors(), endpoint(async (context, req, services) => { logger.info( { ingest: 'webpost-firebase', event: JSON.stringify(req.body) }, 'Received a webpost API call with Firebase credentials' ) const text = String(req.body.text) logToSlack(context, services.auditSlack, text, req.body.source) const reply = await handleTextMessage(context, text, { source: 'webpost:' + req.body.source, }) return reply }) ) app.options('/history', cors() as any) app.get( '/history', requireFirebaseAuth, cors(), endpoint(async (context, req) => { logger.info( { ingest: 'history', event: JSON.stringify(req.body) }, 'Received a history API call' ) return { history: await getMessageHistory(context, { limit: 20 }), } }) ) app.options('/speed-dials', cors() as any) app.get( '/speed-dials', requireFirebaseAuth, cors(), endpoint(async (context, req) => { logger.info( { ingest: 'speed-dials', event: JSON.stringify(req.body) }, 'Received a speed dial API call' ) return { speedDials: await getAllSpeedDials(context), } }) ) app.options('/knobs', cors() as any) app.get( '/knobs', requireFirebaseAuth, cors(), endpoint(async (context, req) => { logger.info({ ingest: 'knobs' }, 'Received a knobs API call') const knobKeys = ((await ref(context, 'knobs').get()) as string).split(',') const knobs = Object.fromEntries( await Promise.all( knobKeys.map(async (key) => { const value = await ref(context, key).get() return [key, value] as [string, string] }) ) ) return { knobs } }) ) // http post $AUTOMATRON/encrypt data=meow app.post( '/encrypt', express.json(), endpoint(async (context, req) => { return encrypt(context, req.body.data) }) ) function logToSlack( context: AutomatronContext, slack: Slack, text: string, source: string ) { context.addPromise( 'Log to Slack', slack.pushMessage({ blocks: [ { type: 'context', elements: [{ type: 'plain_text', text: 'from ' + source }], }, { type: 'section', text: { type: 'plain_text', text: text }, }, ], }) ) } app.post( '/gh/prelude/push', require('body-parser').json(), endpoint(async (context, req, services) => { logger.info( { ingest: 'prelude-push', event: JSON.stringify(req.body) }, 'Received prelude push webhook from GitHub' ) await deployPrelude(context) return 'ok' }) ) app.post( '/sms', require('body-parser').json(), requireApiKey, endpoint(async (context, req, services) => { const text = String(req.body.text) return await handleSMS(context, services.line, text) }) ) app.post( '/notification', require('body-parser').text(), endpoint(async (context, req, services) => { try { const encrypted = Encrypted(context.secrets.ENCRYPTION_SECRET) const { publicKey, secretKey } = encrypted(` BpnjVPJcwkInfE/lPxxcl6E11BlDNh3v.KoCet77F7KC4pvuhRySH2wNP1AjYpUGVcHQSqhc rbFTDHUsXDaYjF/Jc584uh7Bd6yLl0a4scdEsX7EhxuHXUknD4bA8AXxkJe/OhI3EbmfleP5 ByVNvvvxqScM9pHvCy/bURK33REznhvW0MsscwgRGsqMxvI7Km9RpxpglexWANMlrkuVBJbC G3CeOqs9QGI3QS0K+jse8PM7HvJ8vg43AAjQsx6o85xSzaGWVWE1wdNtWfkdusGf/NYbDyb6 hgA9ddrCRJVMydqJ4g9A/LgpieO0v `) const result = sealedbox.open( Buffer.from(req.body, 'hex'), Buffer.from(publicKey, 'base64'), Buffer.from(secretKey, 'base64') ) const notification = JSON.parse(Buffer.from(result).toString('utf8')) logger.info( { ingest: 'notification', notification }, 'Received a notification from ' + notification.packageName ) const deviceId = String(req.query.deviceId ?? 'phone') await trackDevice(context, deviceId, {}) await handleNotification(context, notification) } catch (err) { logger.error({ err, data: req.body }, 'Unable to process notification') } }) ) app.get( '/cron', endpoint(async (context, req, services) => { const otherTasks: Promise[] = [] otherTasks.push( (async () => { await checkDeviceOnlineStatus(context) })() ) await Promise.all(otherTasks) const pendingJobs = await getPendingCronJobs(context) const jobsToRun = pendingJobs.filter( (j) => new Date().toISOString() >= j.scheduledTime ) logger.trace('Number of pending cron jobs found: %s', jobsToRun.length) try { for (const job of jobsToRun) { let result = 'No output' const logContext = { job: { id: job._id.toString(), name: job.name }, } try { const reply = await handleTextMessage(context, job.name, { source: 'cron:' + job._id.toString(), }) result = require('util').inspect(reply) logger.info( { ...logContext, result }, `Done processing cron job: ${job.name}` ) } catch (e) { logError('Unable to process cron job', e, logContext) result = `Error: ${e}` } await updateCronJob(context, job._id.toString(), { completed: true, notes: result, }) } return 'All OK' } catch (e) { logError('Unable to process cron jobs', e) return 'Error: ' + e } }) ) app.post( '/mac/ping', require('body-parser').json(), requireApiKey, endpoint(async (context, req, services) => { const deviceId = req.body.deviceId if (!deviceId) { throw new Error('Missing deviceId') } const newState = await trackDevice(context, deviceId, { locked: req.body.locked || null, powerSource: req.body.powerSource, online: true, }) return { newState } }) ) function requireApiKey(req: Request, res: Response, next: NextFunction) { const context = getAutomatronContext(req, res) if (req.body.key !== context.secrets.API_KEY) { return res.status(401).json({ error: 'Invalid API key' }) } next() } const firebaseAuthn = jwtAuth({ issuer: 'https://securetoken.google.com/dtinth-automatron', jwksUri: 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com', audience: 'dtinth-automatron', }) function requireFirebaseAuth(req: Request, res: Response, next: NextFunction) { const encrypted = Encrypted(req.env.ENCRYPTION_SECRET) const sub = encrypted( `HnX5zHMDR/F7ZUy8sw2829Wi7cYbi/3c.6CBZ+I3sTbyYsXUg81qf+OvjRQExIfVkkhGvEs9E+w7LAO4y9KwU1DtKAtIutA==` ) return firebaseAuthn(req, res, (err) => { if (err) { return next(err) } claimEquals('sub', sub)(req, res, next) }) } class Slack { constructor(private webhookUrl: string) {} async pushMessage(message: SlackMessage) { await axios.post(this.webhookUrl, message) } } interface ThirdPartyServices { line: LINEClient slack: Slack auditSlack: Slack } function endpoint( f: ( context: AutomatronContext, req: Request, services: ThirdPartyServices ) => Promise ): RequestHandler { return handler(async (req, res) => { await handleRequest(req, res, (context, services) => f(context, req, services) ) }) } async function handleRequest( req: Request, res: Response, f: (context: AutomatronContext, services: ThirdPartyServices) => Promise ) { const context = getAutomatronContext(req, res) const encrypted = Encrypted(context.secrets.ENCRYPTION_SECRET) const lineConfig = getLineConfig(req, res) const lineClient = new LINEClient(lineConfig) const slackClient = new Slack(context.secrets.SLACK_WEBHOOK_URL) const auditSlackClient = new Slack( encrypted( 'j3o0uDUL3OuYfUsYxZUkI8ECdaUIGxW0.HX1CMjS27oaZHnQormJbPIoE9xdPB3GsITBVXW2oIFeuuAb4xyWVJZyywWMubR1I1ECkXtBJN+Fs+98MECYk+u9YnlnAgw6DlE9e8TezE88C5DeNtOO0DOnSO6ww39Cn/w==' ) ) try { const result = await f(context, { line: lineClient, slack: slackClient, auditSlack: auditSlackClient, }) await Promise.allSettled(res.yields || []) res.json({ ok: true, result }) } catch (e) { logError('Unable to execute endpoint ' + req.path, e) try { await slackClient.pushMessage(createErrorMessage(e as any)) } catch (ee) { console.error('Cannot send error message to LINE!') logError('Unable to send error message to Slack', ee) } await Promise.allSettled(res.yields || []) throw e } } function logError(title: string, e: any, extra: Record = {}) { var response = e.response || (e.originalError && e.originalError.response) var data = response && response.data const bindings: Record = { ...extra, err: e } if (data) { bindings.responseData = JSON.stringify(data) } logger.error(bindings, `${title}: ${e}`) } function getLineConfig(req: Request, res: Response) { const context = getAutomatronContext(req, res) const isDev = process.env.AUTOMATRON_ENV === 'dev' return { channelAccessToken: isDev ? context.secrets.DEV_LINE_CHANNEL_ACCESS_TOKEN : context.secrets.LINE_CHANNEL_ACCESS_TOKEN, channelSecret: isDev ? context.secrets.DEV_LINE_CHANNEL_SECRET : context.secrets.LINE_CHANNEL_SECRET, } } function readAsBuffer(stream: Stream) { return new Promise((resolve, reject) => { stream.on('error', (e: Error) => { reject(e) }) const bufs: Buffer[] = [] stream.on('end', () => { resolve(Buffer.concat(bufs)) }) stream.on('data', (buf: Buffer) => { bufs.push(buf) }) }) } module.exports = app ================================================ FILE: core/src/logger.ts ================================================ import pino from 'pino' export const logger = pino({ // https://github.com/pinojs/pino/issues/726#issuecomment-605814879 messageKey: 'message', formatters: { level: (label) => { function getSeverity(label: string) { switch (label) { case 'trace': return 'DEBUG' case 'debug': return 'DEBUG' case 'info': return 'INFO' case 'warn': return 'WARNING' case 'error': return 'ERROR' case 'fatal': return 'CRITICAL' default: return 'DEFAULT' } } return { severity: getSeverity(label) } }, }, }).child({ name: 'automatron' }) ================================================ FILE: core/src/modules.d.ts ================================================ declare module '@google-cloud/vision' { export class ImageAnnotatorClient { constructor(options?: any) documentTextDetection( imageBuffer: Buffer ): Promise< { fullTextAnnotation: { pages: { blocks: { paragraphs: { words: { symbols: { text: string }[] }[] }[] }[] }[] } }[] > } } declare module 'airtable' { export interface AirtableRecord { get(field: string): any getId(): string } export default class Airtable { constructor(options: any) base( id: string ): { table( name: string ): { select( options?: any ): { all(): Promise } create(data: any, options?: any): Promise update(id: string, data: any): Promise } } } } ================================================ FILE: core/src/scripts/updateEnv.ts ================================================ import { Storage } from '@google-cloud/storage' import { password } from '@inquirer/prompts' import * as age from 'age-encryption' import { defineCommand, runMain } from 'citty' const main = defineCommand({ meta: { name: 'updateEnv', description: 'Encrypt and upload environment variables to Google Cloud Storage', }, args: { name: { type: 'positional', description: 'Name', required: true, }, }, async run({ args }) { const name = args.name const value = await password({ message: `Enter value for ${name}:`, mask: true, }) const e = new age.Encrypter() e.addRecipient( 'age1234vw9hka4p6eurluezk49kn46ywqpshywy6eypvs0kw0dep6a4sn6gyaz' ) e.addRecipient( 'age1eeezwmr0u6pnl2rgw56dl3k5pcsmxfmzv42varg2zzm96a7s64as9cffpl' ) const ciphertext = await e.encrypt(value) const encoded = age.armor.encode(ciphertext) console.log(name) console.log(encoded) await writeEnv(name, encoded) }, }) async function writeEnv(key: string, encodedValue: string) { const storage = new Storage() const [, bucket, file] = process.env.AUTOMATRON_ENV_GS_URI!.match( /^gs:\/\/([^\/]+)\/(.+)$/ )! const [data] = await storage.bucket(bucket).file(file).download() const encryptedEnv = JSON.parse(data.toString()) as Record encryptedEnv[key] = encodedValue await storage .bucket(bucket) .file(file) .save(JSON.stringify(encryptedEnv, null, 2)) console.log(`Updated ${key} in ${process.env.AUTOMATRON_ENV_GS_URI}`) } runMain(main) ================================================ FILE: core/src/server.ts ================================================ import { Storage } from '@google-cloud/storage' import { start } from '@google-cloud/trace-agent' import * as age from 'age-encryption' import crypto from 'crypto' import express from 'express' import 'google-application-credentials-base64' import { BotSecrets } from './BotSecrets' const bot = require('./bot') const tracer = start() const app = express() const PORT = process.env.PORT || 28364 const storage = new Storage() const [, bucket, file] = process.env.AUTOMATRON_ENV_GS_URI!.match( /^gs:\/\/([^\/]+)\/(.+)$/ )! function loadEnv(): Promise { return storage .bucket(bucket) .file(file) .download() .then(async ([data]) => { const encryptedEnv = JSON.parse(data.toString()) as Record const env: Record = {} const d = new age.Decrypter() d.addIdentity(process.env.AGE_SECRET_KEY!) for (const [key, value] of Object.entries(encryptedEnv)) { const ciphertext = age.armor.decode(value) env[key] = await d.decrypt(ciphertext, 'text') } console.log('Decrypted keys:', Object.keys(env)) return env }) as Promise } let envPromise: Promise = loadEnv() let reloadPromise: Promise | null = null envPromise .then(async (env) => { console.log('Environment has been loaded') }) .catch((err) => { console.error('Unable to load environment', err) }) app.post('/run/automatron/reload', async (req, res, next) => { try { let validationEnv: BotSecrets try { validationEnv = await envPromise } catch (err) { console.warn('Existing environment unavailable, attempting reload', err) validationEnv = await loadEnv() } const providedApiKey = req.get('X-API-Key') const providedBuffer = providedApiKey ? Buffer.from(providedApiKey, 'utf8') : undefined const expectedBuffer = Buffer.from(validationEnv.API_KEY, 'utf8') if ( !providedBuffer || providedBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(providedBuffer, expectedBuffer) ) { res.status(401).send('Invalid API key') return } const startReload = () => { const nextEnvPromise = loadEnv() return nextEnvPromise .then((env) => { envPromise = nextEnvPromise return env }) .catch((err) => { console.error('Unable to reload environment', err) throw err }) .finally(() => { reloadPromise = null }) } if (!reloadPromise) { reloadPromise = startReload() } await reloadPromise res.json({ ok: true }) } catch (err) { next(err) } }) app.use('/run/automatron', async (req, res, next) => { try { req.tracer = tracer req.env = (await envPromise) as unknown as BotSecrets bot(req, res) } catch (err) { next(err) } }) app.get('/', (req, res) => { res.send('Automatron is running') }) app.listen(PORT, () => { console.log(`Automatron server is running on port ${PORT}`) }) ================================================ FILE: core/src/typedefs.d.ts ================================================ declare module 'tweetnacl-sealedbox-js' declare module 'lib' ================================================ FILE: core/src/types.ts ================================================ import { FlexMessage, messagingApi, TextMessage } from '@line/bot-sdk' import { BotSecrets } from './BotSecrets' import type { PluginTypes } from '@google-cloud/trace-agent' import { IncomingHttpHeaders } from 'http' export interface AutomatronContext { secrets: BotSecrets requestInfo: HttpRequestInfo tracer?: PluginTypes.Tracer addPromise: (name: string, promise: Promise) => void } export interface HttpRequestInfo { ip: string ips: string[] headers: IncomingHttpHeaders } declare global { module Express { interface Request { env: BotSecrets tracer?: PluginTypes.Tracer } interface Response { yields?: Promise[] } } module NodeJS { interface Global { automatronSlackEventCache?: Set } } } export { FlexMessage, TextMessage } from '@line/bot-sdk' export type AutomatronResponse = | string | messagingApi.TextMessage | messagingApi.TextMessage[] | messagingApi.FlexMessage | messagingApi.FlexMessage[] export type TextMessageHandler = ( text: string, context: AutomatronContext ) => (() => Promise) | undefined ================================================ FILE: core/tsconfig.json ================================================ { "compilerOptions": { "target": "es2020", "module": "commonjs", "noEmit": true, "lib": ["es2020"], "esModuleInterop": true, "strict": true, "rootDir": "src", "skipLibCheck": true } } ================================================ FILE: fnox.toml ================================================ default_provider = "age" [providers.age] type = "age" recipients = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHac2M/WdCsejGpA87JlU2uwvVokPhqD7eufdMDoEU8z", "age1234vw9hka4p6eurluezk49kn46ywqpshywy6eypvs0kw0dep6a4sn6gyaz", ] [secrets] AUTOMATRON_ENV_GS_URI = { provider = "age", value = "YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IDU1NTU1QSBuUEs0cnFxTmowMWVGNFQ1WG41NUVIKzdoUHNodjFReW0wQW9LUFNIa2hNCnZEVFNWQ1FkYmhaaGR4cnhaNnBKQTFJcmlIblo3TWxlQTRKYTVyWHBRUjgKLT4gWDI1NTE5IHhYL3dOY0hUWWNwQzV1WTg5ejM2QUdKNTdUVVNUWVJEZlFlZk8wbU9GeWsKMS96TUdIWXpxbVJOOW5JQStRSnRWMHJJeEFYQmpmeXl5UnNFU0cvUmJrSQotLS0gWmErQlZXM1J3L2FCY1JTcHExZTVYWW1aN2xvV0FaT1FNTXZ1QXRvZWR5awoMoFXvpKR5HptiKYIwCNP1Nc2QygS/o6S1c3PY6V5kdAxAku0plOQMAef1PNR/ZZOUUdcAh8Eux38Yw+Cf6ARWPXSIewIt/csh" } AGE_SECRET_KEY = { provider = "age", value = "YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IDU1NTU1QSBmTjZmKy9lMzVmZDdjanE2NGttZHVtMTR0VEVQVHRMVVRTYmNLWC9DTVZZCjd2NHo5ZU5wd251NCtNd0cvNTREa3VtaCs3eXA4R2VuOVdsSUhueE1LZUEKLT4gWDI1NTE5IERleis4WFB6K2lLRC8wdjlVdTkxTDlxa01wb1BkMjJtOWc1aTU4ajY0SGcKVlpMbkRNZlZKNXFYRW9IQlBzVXl0OGlUYndCdVJMbTBzYWdMNWRlQWtrdwotLS0ga1RNaDVlVm5zUXc3cFF4UTVCMDhqS3ZjTk13bnFtd0xWcmJNeGxOSHhKMArxpe14F0CnSZsC3ULOoT+Ux+4LCbzE91ocIW15MnN6S6ltSKz7R22qN5gp5THqgb5D4wWiwgAJly6Zbi4lRr7QEXHEztHfMxKZbteRIQyFzXhpE0cijc7r9tz8fl2ZOjbBJzwe2Pxbl7b/" } FRP_SERVER_ADDR = { provider = "age", value = "YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IDU1NTU1QSBITld6M2xlMS8ya1lyTW9UZEdnWVJZMnE1RDZnMzBseVEvdHZ1Y3NJeHk4Ck03c0YzOGRvQmlUckdJVk9td3V5WkxnZG93QnlKRlNtWjRiU3dZK243NzgKLT4gWDI1NTE5IDg4L2tURkdLN250VThHQVNBTUFqeHpHNG5GVDFDdGlNemZYQ3YzaGozM0EKVjU0TUJYSHVSSEdzMUxjbmdOY2srT0dBTGxuanhweGJCaWQ2MDdiSTlLRQotLS0gZkVkVXZMSXd2YlMvbFI2a1l2VGQzcmgrbHBjS0wzL3hXbXpHTzVzRWNlQQo2o4ALYA0LvVvDchUo6X6Jg6uoIlAVsT7vI9BFsWER2q8d0XQn9Kp7TzCMYl8=" } ================================================ FILE: frpc.toml ================================================ serverAddr = "{{ .Envs.FRP_SERVER_ADDR }}" serverPort = 7000 [[proxies]] name = "automatron" type = "http" localPort = 28364 subdomain = "automatron-tunnel" ================================================ FILE: images/.gitkeep ================================================ ================================================ FILE: mise.toml ================================================ [tools] fnox = "latest" node = "24" [env] _.file = '.env' ================================================ FILE: package.json ================================================ { "name": "automatron-workspace", "private": true, "packageManager": "pnpm@9.7.1+sha512.faf344af2d6ca65c4c5c8c2224ea77a81a5e8859cbc4e06b1511ddce2f0151512431dd19e6aff31f2c6a8f5f2aced9bd2273e1fed7dd4de1868984059d2c4247", "engines": { "node": "24.x" }, "scripts": { "dev": "fnox exec -- pnpm -C core run dev", "tunnel": "fnox exec -- frpc -c frpc.toml", "env:set": "fnox exec -- pnpm -C core exec tsx src/scripts/updateEnv.ts" } } ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - core - webui ================================================ FILE: webui/.eslintrc.cjs ================================================ /** * This is intended to be a basic starting point for linting in your app. * It relies on recommended configs out of the box for simplicity, but you can * and should modify this configuration to best suit your team's needs. */ /** @type {import('eslint').Linter.Config} */ module.exports = { root: true, parserOptions: { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true, }, }, env: { browser: true, commonjs: true, es6: true, }, // Base config extends: ['eslint:recommended'], overrides: [ // React { files: ['**/*.{js,jsx,ts,tsx}'], plugins: ['react', 'jsx-a11y'], extends: [ 'plugin:react/recommended', 'plugin:react/jsx-runtime', 'plugin:react-hooks/recommended', 'plugin:jsx-a11y/recommended', ], settings: { react: { version: 'detect', }, formComponents: ['Form'], linkComponents: [ { name: 'Link', linkAttribute: 'to' }, { name: 'NavLink', linkAttribute: 'to' }, ], 'import/resolver': { typescript: {}, }, }, rules: { 'jsx-a11y/no-autofocus': 'off', }, }, // Typescript { files: ['**/*.{ts,tsx}'], plugins: ['@typescript-eslint', 'import'], parser: '@typescript-eslint/parser', settings: { 'import/internal-regex': '^~/', 'import/resolver': { node: { extensions: ['.ts', '.tsx'], }, typescript: { alwaysTryTypes: true, }, }, }, extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:import/recommended', 'plugin:import/typescript', ], rules: { '@typescript-eslint/no-unused-vars': 'off', 'import/no-unresolved': 'off', '@typescript-eslint/no-explicit-any': 'off', }, }, // Node { files: ['.eslintrc.js'], env: { node: true, }, }, ], } ================================================ FILE: webui/.gitignore ================================================ node_modules /.cache /build .env /dist/ ================================================ FILE: webui/README.md ================================================ # Welcome to Remix + Vite! 📖 See the [Remix docs](https://remix.run/docs) and the [Remix Vite docs](https://remix.run/docs/en/main/future/vite) for details on supported features. ## Development Run the Vite dev server: ```shellscript npm run dev ``` ## Deployment First, build your app for production: ```sh npm run build ``` Then run the app in production mode: ```sh npm start ``` Now you'll need to pick a host to deploy it to. ### DIY If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. Make sure to deploy the output of `npm run build` - `build/server` - `build/client` ================================================ FILE: webui/app/Clock.tsx ================================================ import { useEffect, useState } from 'react' export function Clock() { const [time, setTime] = useState(getTime) useEffect(() => { const interval = setInterval(() => { setTime(getTime()) }, 1000) return () => clearInterval(interval) }, []) return (
{time}
) } const getTime = () => { return new Date().toString().split(' ')[4].split(':').slice(0, 2).join(':') } ================================================ FILE: webui/app/backend.ts ================================================ import { getAuth, onAuthStateChanged, User, GoogleAuthProvider, signInWithPopup, signOut as signOutFirebase, } from 'firebase/auth' import { getFirestore, doc, getDoc } from 'firebase/firestore' import { app } from './firebase' import { SyncExternalStore } from 'sync-external-store' import axios from 'axios' const auth = getAuth(app) const firestore = getFirestore(app) class AutomatronBackend implements Backend { authReadyStatePromise: Promise authStore = new SyncExternalStore(undefined) private url?: string constructor() { this.authReadyStatePromise = new Promise((resolve) => { onAuthStateChanged(auth, (user) => { this.authStore.state = user resolve() if (user && !this.url) { this.getUrl() } }) }) } private async getUrl() { if (this.url) { return this.url } const s = await getDoc( doc(firestore, 'apps', 'automatron', 'config', 'url') ) this.url = s.data()?.service return this.url } async signIn() { try { const provider = new GoogleAuthProvider() await signInWithPopup(auth, provider) } catch (error) { console.error(error) alert(`Unable to sign in: ${error}`) } } async signOut() { await signOutFirebase(auth) } async send(text: string): Promise { const data = await this._post('/webpost-firebase', { text, source: 'web' }) if (typeof data.result === 'string') { data.result = [ { type: 'text', text: data.result, }, ] } return data } private async getHeaders() { return { Authorization: `Bearer ${await this.getIdToken()}`, } } async getHistory(): Promise { return this._get('/history') } async getSpeedDials(): Promise { return this._get('/speed-dials') } async getKnobs(): Promise }>> { return this._get('/knobs') } private async getIdToken() { return await auth.currentUser!.getIdToken() } async _get(url: string) { const response = await axios.get((await this.getUrl()) + url, { headers: await this.getHeaders(), }) return response.data } async _post(url: string, data: any) { const response = await axios.post((await this.getUrl()) + url, data, { headers: await this.getHeaders(), }) return response.data } } class FakeBackend implements Backend { authReadyStatePromise: Promise = Promise.resolve() authStore = new SyncExternalStore(null) async signIn() { this.authStore.state = {} as User } async signOut() { this.authStore.state = null } async getHistory(): Promise { return { ok: true, result: { history: [] } } } async getSpeedDials(): Promise { return { ok: true, result: { speedDials: [] } } } async getKnobs(): Promise }>> { return { ok: true, result: { knobs: {} } } } async send(text: string): Promise { return JSON.parse(text) } } type Ok = { ok: true; result: X } interface Backend { authReadyStatePromise: Promise authStore: SyncExternalStore signIn(): Promise signOut(): Promise send(text: string): Promise getHistory(): Promise getSpeedDials(): Promise getKnobs(): Promise }>> } export const backend = typeof location === 'undefined' || new URLSearchParams(location.search).get('backend') === 'fake' ? new FakeBackend() : new AutomatronBackend() if (typeof window !== 'undefined') { Object.assign(window, { backend }) } ================================================ FILE: webui/app/firebase.ts ================================================ import { initializeApp } from 'firebase/app' // Your web app's Firebase configuration const firebaseConfig = { apiKey: 'AIzaSyAmj0axCEWY5ojSMDs25h0hNNfMFCRIqys', authDomain: 'dtinth-automatron.firebaseapp.com', projectId: 'dtinth-automatron', storageBucket: 'dtinth-automatron.appspot.com', messagingSenderId: '347735770628', appId: '1:347735770628:web:8fce6a02b34c2d17c2d751', } // Initialize Firebase export const app = initializeApp(firebaseConfig) ================================================ FILE: webui/app/index.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; body { font-family: Arimo, Helvetica, Arial, sans-serif; } .come-up { animation: 0.25s come-up; } @keyframes come-up { from { transform: scale(0.99); opacity: 0; } to { transform: scale(1); opacity: 1; } } body::-webkit-scrollbar { display: none; } .bg-bevel { background: #252423 linear-gradient(to bottom, #454443, #151413); } .bg-emboss { background: #252423 linear-gradient(to bottom, #151413, #292827); } .bg-glossy { background: #252423 linear-gradient( to bottom, #353433 0%, #252423 50%, #151413 50%, #252423 100% ); } ================================================ FILE: webui/app/requireAuth.ts ================================================ import { backend } from './backend' import { redirect } from '@remix-run/react' export async function requireAuth() { await backend.authReadyStatePromise if (!backend.authStore.state) { throw redirect('/') } } ================================================ FILE: webui/app/root.tsx ================================================ import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, } from '@remix-run/react' import './index.css' export default function App() { return ( ) } ================================================ FILE: webui/app/routes/_index.tsx ================================================ import { ReactNode, useSyncExternalStore } from 'react' import { Clock } from '~/Clock' import { backend } from '~/backend' import { Icon } from '@iconify-icon/react' import running from '@iconify-icons/cil/running' import menu from '@iconify-icons/cil/menu' import { useNavigate } from '@remix-run/react' export default function Index() { return ( <> ) } function AuthButton() { const authState = useSyncExternalStore( backend.authStore.subscribe, backend.authStore.getSnapshot ) const navigate = useNavigate() return ( <> {authState === undefined ? ( <> ) : authState === null ? ( backend.signIn()} title="Sign In"> ) : ( { navigate('/automatron') }} title="Automatron" > )} ) } export interface FloatingButton { title: string children: ReactNode onClick?: () => void } export function FloatingButton(props: FloatingButton) { return (
) } ================================================ FILE: webui/app/routes/automatron._index.tsx ================================================ import { Icon } from '@iconify-icon/react' import chevronLeft from '@iconify-icons/cil/chevron-left' import chevronRight from '@iconify-icons/cil/chevron-right' import history from '@iconify-icons/cil/history' import send from '@iconify-icons/cil/send' import { Await, ClientActionFunctionArgs, Form, useActionData, useLoaderData, useNavigation, } from '@remix-run/react' import clsx from 'clsx' import { ReactNode, Suspense, useState } from 'react' import { z } from 'zod' import { backend } from '~/backend' import { requireAuth } from '~/requireAuth' export const clientLoader = async () => { await requireAuth() return { speedDialsPromise: backend.getSpeedDials(), historyPromise: backend.getHistory(), } } export interface ActionResult { result?: unknown error?: unknown } export const clientAction = async ( args: ClientActionFunctionArgs ): Promise => { await requireAuth() const form = await args.request.formData() const text = form.get('text') const result = await backend.send(String(text)) try { return { result } as ActionResult } catch (error) { return { error } } } const classes = { button: 'bg-bevel hover:border-#555453 block rounded border border-#454443 p-2 shadow-md shadow-black/50 active:border-#8b8685 flex flex-col items-center justify-center', } export default function AutomatronConsole() { const data = useLoaderData() const [speedDialEnabled, setSpeedDialEnabled] = useState(false) const [historyEnabled, setHistoryEnabled] = useState(false) const { error, result } = useActionData() ?? {} const navigation = useNavigation() const isSubmitting = navigation.state === 'submitting' return ( <>