>백엔드 개발 >Golang >Bluesky Social용 봇 만들기

Bluesky Social용 봇 만들기

DDD
DDD원래의
2024-09-14 06:29:321063검색

봇 작동 방식

저희는 소셜 네트워크 Bluesky용 봇을 개발할 예정이며 이를 위해 Golang을 사용할 것입니다. 이 봇은 websocket을 통해 일부 해시태그를 모니터링합니다.
이러한 해시태그 중 하나를 찾으면 다시 게시되고 원본 게시물에 좋아요를 누르게 됩니다.

웹소켓, AT(bluesky에서 사용하는 프로토콜), CAR(Content Addressable aRchive), CBOR(Concise Binary Object Representation)과 같은 정말 멋진 것들을 다룰 것입니다.

프로젝트 구조

이 프로젝트는 간단한 구조를 가지며, 내부에는 봇을 실행하기 위한 모든 코드가 포함된 봇이라는 패키지가 있습니다.
utils에는 우리에게 도움이 되는 몇 가지 기능이 있습니다.

.env 파일에는 api에 액세스하기 위한 bluesky 자격 증명이 있습니다.

Creating a Bot for Bluesky Social

자격 증명 설정

bluesky API에 인증하려면 식별자와 비밀번호를 제공해야 하지만 비밀번호를 사용하여 계정에 액세스할 수는 없습니다.
이를 위해 앱 비밀번호를 생성하고 bluesky에서 계정에 액세스한 다음 설정에 액세스하고 앱 비밀번호에 액세스합니다.

생성된 비밀번호를 다음과 같이 .env 파일 안에 넣으세요.

BLUESKY_IDENTIFIER=<seu_identificador>
BLUESKY_PASSWORD=<seu_app_password>

API 토큰 생성

우리 봇이 우리가 모니터링하고 있는 새로운 해시태그를 식별할 때마다 응답이 이루어지지만, 다시 게시하려면 Bearer 토큰이 필요합니다.
토큰을 생성하는 함수를 만들겠습니다. 이 작업은 get-token.go 파일에서 수행하겠습니다.

먼저 API URL에 대한 전역 변수를 정의합니다.

var (
  API_URL = "https://bsky.social/xrpc"
)

이제 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"`
}

이제 DIDResponse를 반환하는 getToken 함수를 생성하겠습니다(원하는 이름을 지정할 수 있습니다).

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
}

이 함수는 bluesky 엔드포인트 com.atproto.server.createSession을 호출하고 일부 데이터를 수신하지만 지금 중요한 것은 Bearer를 통해 봇에 권한을 부여하는 데 필요한 accessJwt입니다. 토큰이 준비되었습니다.

웹소켓 생성

이것은 봇의 가장 복잡한 기능이 될 것이며 bluesky 엔드포인트를 사용해야 합니다.

먼저 엔드포인트를 저장할 변수를 만들어 보겠습니다. 자세한 내용은 문서를 참조하세요

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

이제 구조체를 만들어 보겠습니다.

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"`
}

또한 Gorilla Websocket 패키지를 사용하고 다음을 통해 패키지를 다운로드합니다.

go get github.com/gorilla/websocket

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
    }
  }
}

이제 우리는 무한대를 사용하여 웹소켓을 통해 수신된 메시지를 읽을 수 있지만 메시지는 CBOR로 인코딩됩니다.

CBOR 란 무엇입니까?

CBOR(Concise Binary Object Representation)은 데이터를 간결하고 효율적인 방식으로 표현하는 데 사용되는 바이너리 데이터 형식입니다.
JSON과 유사하지만 사람이 읽을 수 있는 텍스트를 사용하는 대신 바이너리 바이트를 사용하므로 전송 및 처리 속도가 더 작고 빠릅니다.

디코드하려면 이 패키지를 사용해야 합니다.

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

다음과 같이 메시지를 리더로 전환하세요.

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
      }
    }
  }
}
  • decoder.Decode(&evt): 디코더는 수신된 데이터를 읽고 이를 CBOR 형식에서 RepoCommitEvent 유형으로 디코딩하는 역할을 합니다. evt는 디코딩된 데이터를 저장합니다.

  • if err == io.EOF { break }: 디코더가 데이터 끝에 도달하면(더 이상 메시지가 없음) io.EOF(파일 끝)를 반환합니다. 이런 일이 발생하면 더 이상 처리할 데이터가 없기 때문에 break로 루프가 중단됩니다.

핸들이벤트 생성

이벤트를 처리하는 함수를 만들어 보겠습니다.

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
}
  • evt 매개변수: 이 함수는 RepoCommitEvent 유형의 이벤트인 evt 매개변수를 수신합니다. 이 이벤트에는 Ops 작업 목록이 포함되어 있으며 이러한 작업과 관련된 데이터 블록을 차단할 수도 있습니다.

  • Loop over Ops: evt 이벤트에는 여러 작업이 포함될 수 있습니다. 코드는 for _, op := range evt.Ops 루프를 사용하여 이러한 각 작업을 반복합니다.

  • op.Action == "create" 작업 확인: 각 작업에 대해 코드는 연결된 작업이 생성되는지, 즉 작업이 bluesky에서 게시물이나 게시물과 같은 새로운 항목을 생성하는지 확인합니다. 다른 유형의 콘텐츠.

  • 블록이 있는 경우 len(evt.Blocks) > 0: 생성 작업이 감지되면 코드는 이벤트에 블록 데이터 블록이 포함되어 있는지 확인합니다. 이 블록에는 작업과 관련될 수 있는 추가 정보가 포함되어 있습니다.

  • handleCARBlocks 블록 처리: 블록이 있는 경우 이러한 블록을 처리하기 위해 handlerCARBlocks 함수가 호출됩니다. 이 함수는 블록 내의 데이터를 해석하는 역할을 담당합니다(아래에서 CAR을 다루겠습니다).

What is CAR?

CAR (Content Addressable Archive) is an archive format that stores data efficiently and securely using content addressing. This means that each piece of data is identified by its content rather than a specific location.

Here is a simple explanation:

Content Identified by Hash: Each block of data in a CAR file is identified by a hash (a unique identifier generated from the content of the data). This ensures that the same piece of data always has the same identifier.

Used in IPFS and IPLD: CAR is widely used in systems such as IPFS (InterPlanetary File System) and IPLD (InterPlanetary Linked Data), where data is distributed and retrieved over the network based on content rather than location like bluesky.

Data Blocks: A CAR file can store multiple blocks of data, and each block can be retrieved individually using its content identifier (CID).

Efficient and Safe: Since a block's identifier depends on its content, it is easy to verify that the data is correct and has not been altered.

This is a very simple explanation, if you want to go deeper, I recommend accessing this.

Creating the handleCARBlocks

This will be the most complex function of the bot:

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
}

We will still create the repost() function, we will pass a pointer to *Post as a parameter.

Remember that our bot only monitors post comments, if a post is created and the hashtag we are monitoring is inserted, the repost will not be made, this
validation if post.Text == "" || post.Reply == nil will prevent it, it is necessary to have a reply and this only happens if it is a comment on a post.

The handleCARBlocks function processes data blocks in CAR format. Let's understand step by step what the function does in a simple way:

  • Initial Block Verification:
if len(blocks) == 0 {
  return errors.New("no blocks to process")
}

If the blocks are empty, the function returns an error saying that there are no blocks to process.

  • Creating a CAR Block Reader:
reader, err := carv2.NewBlockReader(bytes.NewReader(blocks))

The function creates a block reader to interpret the data contained in the CAR file, we are using the packages carV2 and go-cid

To install, run:

  go install github.com/ipld/go-car/cmd/car@latest
  go get github.com/ipfs/go-cid
  • Reading the Blocks:
for {
  block, err := reader.Next()
    if err == io.EOF {
      break
    }
}

The function enters a loop to read all data blocks one by one. When all blocks are read (i.e. the end is reached), the loop stops.

  • Checking the CID:
if opTag, ok := op.CID.(cbor.Tag); ok {
  if cidBytes, ok := opTag.Content.([]byte); ok {
    c, err := decodeCID(cidBytes)

The function checks whether the operation contains a CID (Content Identifier) ​​that can be decoded. This CID identifies the specific content of the block.

  • Comparing and Decoding the Block:
if block.Cid().Equals(c) {
  var post Post
  err := cbor.Unmarshal(block.RawData(), &post)

If the block read has the same CID as the operation, the block content is decoded into a format that the function understands, such as a "Post".

  • Filtering the Post:
if post.Text == "" || post.Reply == nil {
  continue
}
if utils.FilterTerms(post.Text) {
  repost(&post)
}

If the post has text and a reply, it is filtered with a function called FilterTerms. If it passes the filter, it is reposted.

Creating decodeCID

The decodeCID function is responsible for decoding a content identifier (CID) from a set of bytes. It takes these bytes and tries to transform them into a CID that can be used to identify blocks of data.

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
}

With that, we have the Websocket ready.

Creating the Hashtag Filter

Let's create the following within utils in 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
}

It is in this function that we define the hashtags to be monitored, in a simple way we receive a text that comes from the websocket and filter it based on the terms.

Creating createRecord

Let's create a function called createRecord in the create-record.go file, which will be responsible for creating a repost or a like, depending on the $type that is sent via parameter.

First, let's create a struct with the parameters we will need:

type CreateRecordProps struct {
  DIDResponse *DIDResponse
  Resource    string
  URI         string
  CID         string
}
  • DIDResponse: We will use it to extract the authorization token.
  • Resource: It will be used to inform whether we are going to do a like or repost.
  • URI: It will be used to inform the uri of the original post.
  • CID: This is what we extracted from the CAR, used as an identifier.

The final function will look like this:

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
}

It's simple to understand, we make a POST to the API_URL/com.atproto.repo.createRecord endpoint, informing that we are going to create a record, in the body we inform the $type, which informs the bluesky API the type of record we are going to create, then we assemble the request, inserting the bearer token and we do some error handling, simple, isn't it?

This way we can use the createRecord function to create several records, changing only the $type.

Sending the repost and like to Bluesky

With createRecord ready, it's simple to create the repost, let's do this in the repost.go file:

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
}

We receive a pointer to the *Post from the Websocket() function, we set up the CreateRecordProps informing that we are going to make a repost through the app.bsky.feed.repost resource, and finally we call createRecord.

After creating the post, we will give it a like (optional), just call createRecord again, but now with the app.bsky.feed.like resource, since we created the resource in a variable, just set a new value, which is what we do resource.Resource = "app.bsky.feed.like".

With that, we can now make the repost and the like.

Creating a health check

This part is optional, it will be used only for deployment, it will be used by the hosting service to check if our bot is still working, it is a very simple endpoint that only returns a status code 200.

Let's do it in the health-check.go file:

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

The HealthCheck function returns only a w.WriteHeader(http.StatusOK), this could be done directly in the main.go file, which is where we will start our web server, but I chose to separate it.

Getting the bot up and running

Well, now we just need to get everything running, let's do that in 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)
  }
}

Very simple too:

  • err := godotenv.Load(): We use the godotenv package to be able to access the variables of the .env locally.
  • go func(): We start our webserver for the HealthCheck in a goroutine.
  • err = bot.Websocket(): Finally we start the Websocket.

Now, let's run:

go run cdm/main.go

We will have the bot running:

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

We can test it on Bluesky, I used the hashtag #bot-teste for testing purposes, let's create a post and comment on it:

Creating a Bot for Bluesky Social

See that the repost was made and now it has the like, and in the terminal we have the logs:

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

Final considerations

We have covered how to create a bot for the Bluesky social network, using Golang and various technologies such as Websockets, AT Protocol, CAR and CBOR.

The bot is responsible for monitoring specific hashtags and, when it finds one of them, it reposts and likes the original post.

This is just one of the features we can do with the bot, the Bluesky API is very complete and allows for several possibilities, you can use this bot and add new features ?.

Links

See the post on my blog here

Subscribe and receive notification of new posts, participate

repository of the project

bot profile on Bluesky

Bluesky documentation

Gopher credits

위 내용은 Bluesky Social용 봇 만들기의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.