返回资讯中心
外部精选
软件工程
#开发者实时流

Pipedream 关停后,开发者用 AI 代理把机器人迁移到 Netlify 托管

来源标题:Porting My Bots to Netlify

开发者因 Pipedream 和 Val Town 相继关停,将自己搭建的多个机器人迁移到个人网站所在的 Netlify 上运行。他使用 Google Antigravity 代理,把原本用 JavaScript 或 Python 编写的定时发布机器人统一改写成 TypeScript 部署,迁移过程顺利完成。

Hacker News 最新speckx2026/11/13 · 今天20 分钟阅读

以下正文由 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 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →