
Pipedream 关停后,开发者用 AI 代理把机器人迁移到 Netlify 托管
来源标题:Porting My Bots to Netlify
开发者因 Pipedream 和 Val Town 相继关停,将自己搭建的多个机器人迁移到个人网站所在的 Netlify 上运行。他使用 Google Antigravity 代理,把原本用 JavaScript 或 Python 编写的定时发布机器人统一改写成 TypeScript 部署,迁移过程顺利完成。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
我为乐趣和学习(嗯,主要是为了乐趣)做了不少机器人,它们大多跑在 Pipedream 上。遗憾的是,Pipedream 要关停了。我自然考虑过改用 Val Town……但 Val Town 也要关了。是啊,如今这技术圈真是“美好”得很,对吧?
我有点拿不准该怎么处理它们。Cloudflare 的 serverless 支持很出色,我知道可以把它们迁过去,但我最终决定把它们打包进我自己的网站里。起初我有些犹豫——这些机器人实在算不上我博客的一部分——但考虑到它们都很小(忽略依赖的话,就一个文件),我想干脆把它们带回家,就在这里跑。
每个机器人都用同样的方法处理。我个人工作用的是 Google Antigravity,所以我跟我的 agent 说了大致这样的话:
我想迁移一个跑在 Pipedream 上的机器人。它每四小时向 Mastodon(或 Bluesky)发一条帖,用下面这段逻辑来生成实际的帖子内容:
然后我就把 Pipedream 里的代码片段粘了进去。有些是 JavaScript,有些是 Python。我相信我的 agent 知道需要把所有东西都用 TypeScript 部署。
说实话——它完美跑通了。我让 agent 把我的凭据(Mastodon 需要 access token,Bluesky 需要用户名和密码)存成 Netlify 环境变量。Netlify CLI 会读取这些变量,并在本地提供访问,所以测试很方便。更棒的是,Netlify CLI 支持通过命令行测试这些定时函数,比如:
netlify functions:invoke bot-superjoycat
作为最终成果的一个例子,这是我颇为得意的 Dragon Hoards 机器人:
import type { Config, Context } from '@netlify/functions';
import { generateSlug, type RandomWordOptions } from 'random-word-slugs';
import indefinite from 'indefinite';
import pluralize from 'pluralize';
import { BskyAgent } from '@atproto/api';
const getRandomIntInclusive = function (min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1) + min);
};
const locations = [
'in an ancient temple',
'in a jungle',
'by a dusty fountain',
'on the shore of an ocean',
'on a distant planet',
'inside a whirlpool',
'on an ancient moon',
'on an island',
'by a lake',
'by a forest lake',
'under your bed',
"in a unicorn's forest",
'far from people',
'under a lake',
'in a castle in the sky',
'on a forgotten mountain',
'at the bottom of a whirlpool',
'in a nebula',
'far away under rocks and stone',
'in a mossy grove',
'in a sock drawer',
'inside an abandoned castle',
'at the end of a rainbow',
'on a mountain top',
'in a moonlit forest',
'inside a black hole',
'in the ruins of a city',
'on a deserted island',
'in a jungle',
'in a forest temple',
'in a long forgotten city',
'on a moon',
'on a distant planet',
'deep inside a well',
'in an old mansion',
'on a crumbling mountain',
'on a forgotten island',
'in a haunted house',
'on the other side of a rainbow',
'on a lonely island',
"under a child's bed",
'in a castle in the clouds',
'in the ruins of a village',
'deep in a cave',
'under a haunted house',
'near a village',
];
const verbs = [
'sees',
'explores',
'sorts',
'inventories',
'loves',
'estimates',
'guards',
'tallies',
'reminisces over',
'regards',
'looks over',
'searches',
'investigates',
'is proud of',
'examines',
'fiddles with',
'counts',
'admires',
'loves',
'admires',
];
const getDragonType = function () {
const options: RandomWordOptions<1> = {
format: 'lower',
partsOfSpeech: ['adjective'],
categories: {
adjective: ['color', 'appearance', 'personality'],
},
};
return generateSlug(1, options);
};
const getLocation = function () {
return locations[getRandomIntInclusive(0, locations.length - 1)];
};
const getVerb = function () {
return verbs[getRandomIntInclusive(0, verbs.length - 1)];
};
const getHoard = function () {
const options: RandomWordOptions<1> = {
format: 'lower',
partsOfSpeech: ['adjective'],
categories: {
adjective: ['quantity'],
},
};
const quantity = generateSlug(1, options);
const options2: RandomWordOptions<1> = {
format: 'lower',
partsOfSpeech: ['noun'],
categories: {
noun: ['animals', 'food', 'thing'],
},
};
const item = pluralize(generateSlug(1, options2));
const ofWords = ['hundreds', 'thousands', 'millions', 'billions'];
let joiner = ' ';
/*
hard coded logic - may revisit
*/
if (ofWords.indexOf(quantity) >= 0) joiner = ' of ';
return `${quantity}${joiner}${item}`;
};
const getFeeling = function () {
const options: RandomWordOptions<1> = {
format: 'lower',
partsOfSpeech: ['adjective'],
categories: {
adjective: ['condition'],
},
};
return generateSlug(1, options);
};
export default async (req: Request, context: Context) => {
const mastodonToken = Netlify.env.get('BOT_DRAGONHOARDS');
const blueskyPassword =
Netlify.env.get('BLUESKY_DRAGONHOARDS_PASSWORD') || Netlify.env.get('BOT_DRAGONHOARDS_BLUESKY');
if (!mastodonToken || !blueskyPassword) {
console.error('Missing BOT_DRAGONHOARDS or BLUESKY_DRAGONHOARDS_PASSWORD environment variable');
return new Response('Missing BOT_DRAGONHOARDS or BLUESKY_DRAGONHOARDS_PASSWORD environment variable', {
status: 500,
});
}
const type = getDragonType();
const hoards: string[] = [];
for (let i = 0; i < 3; i++) {
hoards.push(getHoard());
}
const hoardStr = `${hoards[0]}, ${hoards[1]}, and ${hoards[2]}`;
const statusText = `${indefinite(type, { capitalize: true })} dragon lives ${getLocation()}. She ${getVerb()} her hoard, which consists of ${hoardStr}. She feels ${getFeeling()}.`;
// 1. Post to Mastodon
const tootRes = await fetch('https://mastodon.social/api/v1/statuses', {
method: 'POST',
headers: {
Authorization: `Bearer ${mastodonToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ status: statusText }),
});
if (!tootRes.ok) {
const errText = await tootRes.text();
console.error(`Failed to post to Mastodon: ${tootRes.status} ${errText}`);
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。