发布于 2026-01-06 11 阅读
0

为 Bluesky Social 创建机器人

为 Bluesky Social 创建机器人

机器人将如何运作

我们将为社交网络Bluesky开发一个机器人,我们将使用 Golang 来实现这一点。该机器人将通过 websocket 监控一些话题标签,
如果找到其中一个话题标签,它将转发并点赞原帖。

我们将介绍一些非常酷的东西,例如 websocket、AT(bluesky 使用的协议)、CAR(内容可寻址归档)和CBOR(简洁二进制对象表示)是两种用于高效存储和传输数据的格式。

项目结构

该项目将采用简单的结构,其中包含internal一个名为 `<package_name>` 的包,该包bot包含运行机器人所需的所有代码,
其中utils还包含一些帮助我们的函数。

文件.env中将包含访问API 的bluesky 凭据。

项目结构

设置凭据

要对 bluesky API 进行身份验证,我们需要提供一个账号identifier和一个密码password,但是我们不能使用密码来访问我们的账号,
为此,我们将创建一个应用密码,只需访问 bluesky 中的账号,访问设置,然后选择应用密码

将生成的密码放入.env文件中,如下所示:

BLUESKY_IDENTIFIER=<your_identifier>
BLUESKY_PASSWORD=<your_app_password>
Enter fullscreen mode Exit fullscreen mode

生成 API 令牌

每当我们的机器人识别出我们正在监控的新话题标签时,它都会做出回复,但是我们需要 Bearer 令牌才能进行转发,
我们将创建一个生成令牌的函数,我们将在get-token.go文件中执行此操作。

首先,我们定义一个用于存储 API URL 的全局变量。

var (
  API_URL = "https://bsky.social/xrpc"
)
Enter fullscreen mode Exit fullscreen mode

现在我们定义一个结构体,其中包含 API 将返回的数据。

type DIDDoc struct {
  Context            []string `json:"@context"`
  ID                 string   `json:"id"`
  AlsoKnownAs        []string `json:"alsoKnownAs"`
  VerificationMethod []struct {
    ID                 string `json:"id"`
    Type               string `json:"type"`
    Controller         string `json:"controller"`
    PublicKeyMultibase string `json:"publicKeyMultibase"`
  } `json:"verificationMethod"`
  Service []struct {
    ID              string `json:"id"`
    Type            string `json:"type"`
    ServiceEndpoint string `json:"serviceEndpoint"`
  } `json:"service"`
}

type DIDResponse struct {
  DID             string `json:"did"`
  DIDDoc          DIDDoc `json:"didDoc"`
  Handle          string `json:"handle"`
  Email           string `json:"email"`
  EmailConfirmed  bool   `json:"emailConfirmed"`
  EmailAuthFactor bool   `json:"emailAuthFactor"`
  AccessJwt       string `json:"accessJwt"`
  RefreshJwt      string `json:"refreshJwt"`
  Active          bool   `json:"active"`
}
Enter fullscreen mode Exit fullscreen mode

现在我们将创建一个getToken返回 a 的函数DIDResponse(你可以给它起任何你想要的名字)。

func getToken() (*DIDResponse, error) {
  requestBody, err := json.Marshal(map[string]string{
    "identifier": os.Getenv("BLUESKY_IDENTIFIER"),
    "password":   os.Getenv("BLUESKY_PASSWORD"),
  })
  if err != nil {
    return nil, fmt.Errorf("failed to marshal request body: %w", err)
  }

  url := fmt.Sprintf("%s/com.atproto.server.createSession", API_URL)

  resp, err := http.Post(url, "application/json", bytes.NewBuffer(requestBody))
  if err != nil {
    return nil, fmt.Errorf("failed to send request: %w", err)
  }
  defer resp.Body.Close()

  if resp.StatusCode != http.StatusOK {
    return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
  }

  var tokenResponse DIDResponse
  if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
    return nil, fmt.Errorf("failed to decode response: %w", err)
  }

  return &tokenResponse, nil
}
Enter fullscreen mode Exit fullscreen mode

该函数调用 bluesky 端点com.atproto.server.createSession,我们将收到一些数据,但现在重要的是accessJwt我们需要通过 Bearer 授权我们的机器人,有了它,生成令牌的函数就准备就绪了。

创建 WebSocket

这将是机器人最复杂的功能,我们需要调用 bluesky 端点。

首先,我们创建一个变量来保存端点,更多信息请参见文档

var (
  wsURL = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos"
)
Enter fullscreen mode Exit fullscreen mode

现在让我们创建结构体:

type RepoCommitEvent struct {
  Repo   string      `cbor:"repo"`
  Rev    string      `cbor:"rev"`
  Seq    int64       `cbor:"seq"`
  Since  string      `cbor:"since"`
  Time   string      `cbor:"time"`
  TooBig bool        `cbor:"tooBig"`
  Prev   interface{} `cbor:"prev"`
  Rebase bool        `cbor:"rebase"`
  Blocks []byte      `cbor:"blocks"`

  Ops []RepoOperation `cbor:"ops"`
}

type RepoOperation struct {
  Action string      `cbor:"action"`
  Path   string      `cbor:"path"`
  Reply  *Reply      `cbor:"reply"`
  Text   []byte      `cbor:"text"`
  CID    interface{} `cbor:"cid"`
}

type Reply struct {
  Parent Parent `json:"parent"`
  Root   Root   `json:"root"`
}

type Parent struct {
  Cid string `json:"cid"`
  Uri string `json:"uri"`
}

type Root struct {
  Cid string `json:"cid"`
  Uri string `json:"uri"`
}

type Post struct {
  Type  string `json:"$type"`
  Text  string `json:"text"`
  Reply *Reply `json:"reply"`
}
Enter fullscreen mode Exit fullscreen mode

我们还将使用Gorilla Websocket软件包,请使用以下命令下载该软件包:

go get github.com/gorilla/websocket
Enter fullscreen mode Exit fullscreen mode

Websocket函数最初的样子是这样的:

func Websocket() error {
  conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
  if err != nil {
    slog.Error("Failed to connect to WebSocket", "error", err)
    return err
  }
  defer conn.Close()

  for {
    _, message, err := conn.ReadMessage()
    if err != nil {
      slog.Error("Error reading message from WebSocket", "error", err)
      continue
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

这样我们现在就可以读取通过 websocket 接收的无限长的消息for,但是这些消息是用CBOR编码的。

CBOR是什么?

CBOR(简洁二进制对象表示法)是一种二进制数据格式,用于以紧凑高效的方式表示数据。
它类似于JSON,但它不使用人类可读的文本,而是使用二进制字节,这使得数据体积更小,传输和处理速度更快。

要解码它,我们需要使用这个软件包

decoder := cbor.NewDecoder(bytes.NewReader(message))
Enter fullscreen mode Exit fullscreen mode

只需将其message转换为reader,像这样:

func Websocket() error {
  conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
  if err != nil {
    slog.Error("Failed to connect to WebSocket", "error", err)
    return err
  }
  defer conn.Close()

  slog.Info("Connected to WebSocket", "url", wsURL)

  for {
     _, message, err := conn.ReadMessage()
    if err != nil {
      slog.Error("Error reading message from WebSocket", "error", err)
      continue
    }

    decoder := cbor.NewDecoder(bytes.NewReader(message))

    for {
      var evt RepoCommitEvent
      err := decoder.Decode(&evt)
      if err == io.EOF {
        break
      }
      if err != nil {
        slog.Error("Error decoding CBOR message", "error", err)
        break
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • decoder.Decode(&evt)解码器负责读取接收到的数据,并将其从 CBOR 格式解码为目标RepoCommitEvent类型。解码器evt存储解码后的数据。

  • if err == io.EOF { break }如果解码器到达数据末尾(没有更多消息),它将返回io.EOF(文件结束)。此时,循环将被中断break,因为没有更多数据需要处理。

创建 handleEvent

让我们创建一个函数来处理该事件:

func handleEvent(evt RepoCommitEvent) error {
  for _, op := range evt.Ops {
    if op.Action == "create" {
      if len(evt.Blocks) > 0 {
        err := handleCARBlocks(evt.Blocks, op)
        if err != nil {
          slog.Error("Error handling CAR blocks", "error", err)
          return err
        }
      }
    }
  }

  return nil
}
Enter fullscreen mode Exit fullscreen mode
  • evt参数:该函数接收一个evt参数,该参数是一个事件类型RepoCommitEvent。此事件包含一个操作列表Ops,以及可能Blocks与这些操作相关的数据块。

  • 循环遍历Opsevt事件可以包含多个操作。代码使用循环遍历每个操作for _, op := range evt.Ops

  • 检查op.Action == "create"操作:对于每个操作,代码都会检查关联的操作是否为create,也就是说,该操作是否在 bluesky 中创建新内容,例如帖子或其他类型的内容。

  • 如果存在数据块len(evt.Blocks) > 0:如果检测到创建操作,代码会检查事件是否包含Blocks数据块。这些数据块包含可能与该操作相关的附加信息。

  • 处理handleCARBlocks数据块:如果存在数据块,handleCARBlocks则会调用该函数来处理这些数据块。该函数负责解释数据块内的数据(我们将在下文介绍 CAR)。

CAR是什么?

CAR(内容寻址归档)是一种归档格式,它利用内容寻址高效安全地存储数据。这意味着每条数据都由其内容而非特定位置来标识。

以下是一个简单的解释:

内容通过哈希值标识:CAR 文件中的每个数据块都由一个哈希值(根据数据内容生成的唯一标识符)标识。这确保了同一段数据始终具有相同的标识符。

CAR 广泛应用于 IPFS 和 IPLD 等系统中:CAR 广泛应用于 IPFS(星际文件系统)和 IPLD(星际链接数据)等系统中,在这些系统中,数据是根据内容而不是像 bluesky 那样根据位置在网络上进行分发和检索的。

数据块:CAR 文件可以存储多个数据块,并且可以使用其内容标识符 (CID) 单独检索每个数据块。

高效且安全:由于区块的标识符取决于其内容,因此很容易验证数据是否正确且未被篡改。

这是一个非常简单的解释,如果您想深入了解,我建议您访问这里

创建 handleCARBlocks

这将是机器人最复杂的功能:

func handleCARBlocks(blocks []byte, op RepoOperation) error {
  if len(blocks) == 0 {
    return errors.New("no blocks to process")
  }

  reader, err := carv2.NewBlockReader(bytes.NewReader(blocks))
  if err != nil {
    slog.Error("Error creating CAR block reader", "error", err)
    return err
  }

  for {
    block, err := reader.Next()
    if err == io.EOF {
        break
    }
    if err != nil {
      slog.Error("Error reading CAR block", "error", err)
      break
    }

    if opTag, ok := op.CID.(cbor.Tag); ok {
      if cidBytes, ok := opTag.Content.([]byte); ok {
        c, err := decodeCID(cidBytes)
        if err != nil {
          slog.Error("Error decoding CID from bytes", "error", err)
          continue
        }

        if block.Cid().Equals(c) {
          var post Post
          err := cbor.Unmarshal(block.RawData(), &post)
          if err != nil {
            slog.Error("Error decoding CBOR block", "error", err)
            continue
          }

          if post.Text == "" || post.Reply == nil {
            continue
          }

          if utils.FilterTerms(post.Text) {
            repost(&post) // we will still create
          }
        }
      }
    }
  }

  return nil
}
Enter fullscreen mode Exit fullscreen mode

我们仍然会创建该repost()函数,并将指向它的指针*Post作为参数传递。

请记住,我们的机器人只监控帖子评论。如果创建了一个帖子,并且插入了我们正在监控的标签,则不会进行转发。此
验证if post.Text == "" || post.Reply == nil将阻止转发,必须有标签,reply并且这种情况只会在帖子评论时发生。

handleCARBlocks函数处理 CAR 格式的数据块。让我们一步一步地用简单的方式了解该函数的工作原理:

  • 初始区块验证:
if len(blocks) == 0 {
  return errors.New("no blocks to process")
}
Enter fullscreen mode Exit fullscreen mode

如果数据块为空,则该函数返回错误,提示没有数据块要处理。

  • 创建 CAR 块读取器:
reader, err := carv2.NewBlockReader(bytes.NewReader(blocks))
Enter fullscreen mode Exit fullscreen mode

该函数创建一个块读取器来解析 CAR 文件中包含的数据,我们使用了carV2go-cid软件包。

安装方法:

  go install github.com/ipld/go-car/cmd/car@latest
Enter fullscreen mode Exit fullscreen mode
  go get github.com/ipfs/go-cid
Enter fullscreen mode Exit fullscreen mode
  • 阅读积木:
for {
  block, err := reader.Next()
    if err == io.EOF {
      break
    }
}
Enter fullscreen mode Exit fullscreen mode

该函数进入循环,逐个读取所有数据块。当所有数据块都被读取完毕(即到达末尾)后,循环停止。

  • 检查 CID:
if opTag, ok := op.CID.(cbor.Tag); ok {
  if cidBytes, ok := opTag.Content.([]byte); ok {
    c, err := decodeCID(cidBytes)
Enter fullscreen mode Exit fullscreen mode

该函数检查操作中是否包含可解码的 CID(内容标识符)。此 CID 用于标识数据块的具体内容。

  • 比较和解码积木:
if block.Cid().Equals(c) {
  var post Post
  err := cbor.Unmarshal(block.RawData(), &post)
Enter fullscreen mode Exit fullscreen mode

如果读取的块与操作具有相同的 CID,则将块内容解码为函数可以理解的格式,例如“Post”。

  • 筛选帖子:
if post.Text == "" || post.Reply == nil {
  continue
}
if utils.FilterTerms(post.Text) {
  repost(&post)
}
Enter fullscreen mode Exit fullscreen mode

如果帖子包含文本和标签reply,则会使用名为 `filter` 的函数进行过滤FilterTerms。如果通过过滤,则会重新发布。

创建decodeCID

decodeCID函数负责从一组字节中解码内容标识符 (CID)。它接收这些字节,并尝试将它们转换为可用于识别数据块的 CID。

func decodeCID(cidBytes []byte) (cid.Cid, error) {
  var c cid.Cid
  c, err := cid.Decode(string(cidBytes))
  if err != nil {
    return c, fmt.Errorf("error decoding CID: %w", err)
  }

  return c, nil
}
Enter fullscreen mode Exit fullscreen mode

这样,我们就准备Websocket就绪了。

创建话题标签过滤器

让我们在内部创建以下utils内容filter-terms.go

var (
  terms = []string{"#hashtag2", "#hashtag1"}
)

func FilterTerms(text string) bool {
  for _, term := range terms {
    if strings.Contains(strings.ToLower(text), strings.ToLower(term)) {
      return true
    }
  }
  return false
}
Enter fullscreen mode Exit fullscreen mode

在这个函数中,我们定义了要监控的标签,简单来说,我们接收text来自 websocket 的消息,并根据该消息进行过滤terms

创建记录

让我们createRecordcreate-record.go文件中创建一个名为 `repost` 的函数,该函数将负责根据$type通过参数发送的值来创建转发或点赞。

首先,我们创建一个包含所需参数的结构体:

type CreateRecordProps struct {
  DIDResponse *DIDResponse
  Resource    string
  URI         string
  CID         string
}
Enter fullscreen mode Exit fullscreen mode
  • DIDResponse我们将使用它来提取授权令牌。
  • Resource它将用于决定我们是点赞还是转发。
  • URI它将用于告知原始帖子的 URI。
  • CID这是我们从 CAR 中提取出来用作标识符的信息。

最终函数将如下所示:

func createRecord(r *CreateRecordProps) error {
  body := map[string]interface{}{
    "$type":      r.Resource,
    "collection": r.Resource,
    "repo":       r.DIDResponse.DID,
    "record": map[string]interface{}{
      "subject": map[string]interface{}{
        "uri": r.URI,
        "cid": r.CID,
      },
      "createdAt": time.Now(),
    },
  }

  jsonBody, err := json.Marshal(body)
  if err != nil {
    slog.Error("Error marshalling request", "error", err, "resource", r.Resource)
    return err
  }

  url := fmt.Sprintf("%s/com.atproto.repo.createRecord", API_URL)
  req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
  if err != nil {
    slog.Error("Error creating request", "error", err, "r.Resource", r.Resource)
    return nil
    }
  req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.DIDResponse.AccessJwt))
  req.Header.Set("Content-Type", "application/json")

  client := &http.Client{}
  resp, err := client.Do(req)
  if err != nil {
    slog.Error("Error sending request", "error", err, "r.Resource", r.Resource)
    return nil
  }
  if resp.StatusCode != http.StatusOK {
    slog.Error("Unexpected status code", "status", resp, "r.Resource", r.Resource)
    return nil
  }

  slog.Info("Published successfully", "resource", r.Resource)

  return nil
}
Enter fullscreen mode Exit fullscreen mode

很容易理解,我们POSTAPI_URL/com.atproto.repo.createRecord端点发送一个请求,告知我们将要创建一个记录,在请求中body我们$type告知 Bluesky API 我们将要创建的记录类型,然后我们组装请求,插入 bearer token,并进行一些错误处理,很简单,不是吗?

这样我们就可以使用该createRecord函数创建多个记录,只需更改其中的一个参数即可$type

转发并点赞 Bluesky

准备就绪后createRecord,创建就很简单了repost,让我们在repost.go文件中执行此操作:

func repost(p *Post) error {
  token, err := getToken()
  if err != nil {
    slog.Error("Error getting token", "error", err)
    return err
  }

  resource := &CreateRecordProps{
    DIDResponse: token,
    Resource:    "app.bsky.feed.repost",
    URI:         p.Reply.Root.Uri,
    CID:         p.Reply.Root.Cid,
  }

  err = createRecord(resource)
  if err != nil {
    slog.Error("Error creating record", "error", err, "resource", resource.Resource)
    return err
  }

  resource.Resource = "app.bsky.feed.like"
  err = createRecord(resource)
  if err != nil {
    slog.Error("Error creating record", "error", err, "resource", resource.Resource)
    return err
  }

  return nil
}
Enter fullscreen mode Exit fullscreen mode

*Post我们从函数中收到一个指向的指针Websocket(),我们设置通知,CreateRecordProps表明我们将通过该app.bsky.feed.repost资源进行重新发布,最后我们调用createRecord

创建帖子后,我们会给它点赞(可选),只需createRecord再次调用,但这次要使用app.bsky.feed.like资源,因为我们在一个变量中创建了资源,只需设置一个新值,这就是我们所做的resource.Resource = "app.bsky.feed.like"

这样一来,我们现在就可以进行转发和点赞了。

创建健康检查

这部分是可选的,它仅用于部署,托管服务会使用它来检查我们的机器人是否仍在运行,这是一个非常简单的端点,只返回一个状态码200

让我们在文件中操作health-check.go

func HealthCheck(w http.ResponseWriter, r *http.Request) {
  w.WriteHeader(http.StatusOK)
}
Enter fullscreen mode Exit fullscreen mode

HealthCheck函数只返回一个w.WriteHeader(http.StatusOK),这可以直接在文件中完成main.go,我们将在该文件中启动我们的 Web 服务器,但我选择将其分开。

启动并运行机器人

好了,现在我们只需要让一切运行起来,让我们在……中完成main.go

func main() {
  slog.Info("Starting bot")
  err := godotenv.Load()
  if err != nil {
    slog.Error("Error loading .env file")
  }

  go func() {
    http.HandleFunc("/health", bot.HealthCheck)
    slog.Info("Starting health check server on :8080")

    if err := http.ListenAndServe(":8080", nil); err != nil {
      log.Fatal("Failed to start health check server:", err)
    }
  }()

  err = bot.Websocket()
  if err != nil {
    log.Fatal(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

非常简单:

  • err := godotenv.Load()我们使用godotenv包来访问本地变量.env
  • go func()我们HealthCheck在 goroutine 中启动我们的 web 服务器。
  • err = bot.Websocket()最后我们开始Websocket

现在,我们开始跑吧:

go run cdm/main.go
Enter fullscreen mode Exit fullscreen mode

我们将启动机器人:

2024/09/13 09:11:31 INFO Starting bot
2024/09/13 09:11:31 INFO Starting health check server on :8080
2024/09/13 09:11:32 INFO Connected to WebSocket url=wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos
Enter fullscreen mode Exit fullscreen mode

我们可以在 Bluesky 上进行测试,我使用了#bot-teste标签进行测试,我们来创建一个帖子并发表评论:

后蓝天

请看,转发已成功,现在已有点赞,终端中也显示了日志:

2024/09/13 09:14:16 INFO Published successfully resource=app.bsky.feed.repost
2024/09/13 09:14:16 INFO Published successfully resource=app.bsky.feed.like
Enter fullscreen mode Exit fullscreen mode

最后考虑因素

我们已经介绍了如何使用 Golang 和各种技术(例如 Websockets、AT 协议、CAR 和 CBOR)为 Bluesky 社交网络创建一个机器人。

该机器人负责监控特定的话题标签,当它发现其中一个话题标签时,它会转发并点赞原帖。

这只是我们能用这个机器人实现的功能之一,Bluesky API 非常完善,提供了多种可能性,你可以使用这个机器人并添加新功能🐹。

链接

请点击此处查看我博客上的文章。

订阅并接收新帖子的通知,参与互动

项目仓库

Bluesky上的机器人个人资料

Bluesky 文档

地鼠积分

文章来源:https://dev.to/wiliamvj/creating-a-bot-for-bluesky-social-496p