少年,来个telebot耍耍?

于由astupidcoder发布

前段时间忽然对telegram bot感兴趣,于是动手写了一个,功能非常简单,就是把转发给它的所有图片都保存下来~

基础知识

官方文档

官方文档里面的东西非常庞杂,简明扼要地总结出以下几点:

  1. 想要折腾telegram bot,首先要申请一个telebot,申请的方式是打开@botfather,跟他聊几句,然后按他的指引创建一个bot。重要的是拿到一个token,以及你的bot的名字,所有的bot名字都以bot结尾。拿到的token形如123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/getMe,botname形如mylovelytelebot

  2. 所有的API都以REST API的方式提供,即向某个固定格式的URL发起get或者post请求,该网址如:

    https://api.telegram.org/bot<token>/METHOD_NAME
    其中token就是上面申请的token,method_name是官方支持的各种操作,完整例子如:
    https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/getMe
    

    所以,操作bot的方式就是不断向https://api.telegram.org/bot<token>/METHOD_NAME这样一个地址发请求,获取别人发到telebot的信息,决定如何处理,并发回处理的结果。

    有些方法是接受参数的,既可以用get方法,将参数在url中写成param,也就是:

    https://api.telegram.org/bot<token>/METHOD_NAME?param1=xxx&param2=xxx
    

    也可以POST一个JSON或XML来传递参数。

  3. 对于一个聊天机器人来说,最重要的操作当然是获取发给自己的信息了,官方提供了两种方式来获取聊天信息,分别是getUpdates方法,和webHook。

  • getUpdates方法:就是向:https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/getUpdates发送一个get请求,将会得到一个Json对象,这个Json对象是Update对象的序列化结果。在各种封装好的库里面,都有这样的一个对象。
  • webHook,就是自己开一个web服务器,在某个端口和地址上监听HTTP的POST请求,再将这个地址注册到telegram,之后telegram会保证你的bot收到信息之后,发送请求到这个地址,你就可以处理它了,详细情况一会再讲。
  1. 获取消息之后,就是对消息进行各种处理,你可以保存消息内容,不做任何处理,也可以通过调用各种method,向bot发送各种命令,如发送信息、发送语音、发送图片、发送视频、删除表情、删除信息等。都是不同的method。

可用的库

telebot官方推荐各语言API

上面的链接里各种封装好的库都有,不用再自己写繁琐的HTTP请求了。作为一个工作是写java的,虽然很多时候深感java的verbose,但仍然觉得自己的所有项目都应该用java写~主要是练手,所以以下示例代码都是java写的。

两个示例代码

为了不被如何发请求这种细节所困扰,我用了官方推荐的java框架,在maven中添加:

<dependency>
    <groupId>org.telegram</groupId>
    <artifactId>telegrambots</artifactId>
    <version>4.1.2</version>
</dependency>

Github地址

为了处理别人发给聊天机器人的信息,我们首先要获取信息,这也是两个示例代码的区别所在。正如上文所说,获取信息有两种方式,一种是getUpdates方法,由你主动去服务器pull回来结果,一种是注册webhook,当有结果时,服务器主动push到你的机器来处理。

在这里我并没有使用这个库推荐的编程方式,即继承抽象类TelegramLongPollingBot或TelegramWebhookBot,之后注册到TelegramBotsApi的方式,因为没心思去追踪注册后到底怎么处理消息的,所以只是用了他们的一些类和工具,轮询和webhook都是自己实现。

  • getUpdates

    这是你主动向服务器发申请取得updates,一般用轮询。

    public class LongPullingService extends DefaultAbsSender {
    
      //BotConfig是自己的写的类,保存了类似文件保存地址、botToken等数据,结构在最后
      private final BotConfig botConfig;
      //用来保存哪些数据处理过了。因为轮询时会得到重复的数据
      private Set<Integer> downloaded = new HashSet<>();
      //downloaded持久化后的文件,因为懒得用数据库,所以直接写txt了
      private java.io.File downloadedRecord;
      private int offset = 0;
    
      public LongPullingService(DefaultBotOptions options, BotConfig botConfig) {
    
          super(options);
          log.info("longPullingService bot config is " + botConfig.toString());
          this.botConfig = botConfig;
          this.downloadedRecord = new java.io.File(botConfig.getDownloadRootFolder(), botConfig.getDownloadedRecord());
          try {
              downloaded.addAll(FileUtils.readLines(downloadedRecord, "utf-8").stream().map(Integer::valueOf).collect(Collectors.toSet()));
          } catch (Exception e) {
              //no file?
          }
    
      }
    
      public void pulling() {
          while (true) {
              try {
                  GetUpdates getUpdates = new GetUpdates().setOffset(offset).setLimit(100);
                  List<Update> updates = sendApiMethod(getUpdates);
                  for (Update update : updates) {
                      //自己写的方法,处理update
                      onUpdateReceived(update);
                      //自己写的方法,以秒为单位休眠
                      sleep(1);
                  }
                  if (updates.size() == 100) {
                      offset++;
                  }
                  sleep(10);
              } catch (Exception e) {
                  sleep(300);
              }
          }
      }
    
      private void onUpdateReceived(Update update) {
          log.info("get unprocessed update " + update.getUpdateId());
    
          //这是自己写的方法
          downLoadFile(update);
    
      }
    
      @Override
      public String getBotToken() {
          return botConfig.getToken();
      }
    
      //下载方法的简单示例
      public void downLoadFile(Update update){
    
          Message message = update.getMessage();
          List<PhotoSize> photos = message.getPhoto();
          //在update中只能得到一个fileId,还需要向服务器发送一个GetFile请求获取文件下载地址
          GetFile getFile = null;
          String fileTypeSuffix = null;
          //一般一个message里有好几个图片,有缩略图级别的,有高清大图级别的
          //在这里找到最大的那张图。(一般就是最后一张)
          if (photos != null && !photos.isEmpty()) {
              PhotoSize largestOne = photos.get(photos.size()-1);
              getFile = new GetFile().setFileId(largestOne.getFileId());
              fileTypeSuffix = "/picture/%s.jpg";
          } else {
              return;
          }
    
          java.io.File savedFile = new java.io.File(botConfig.getDownloadRootFolder(), String.format(fileTypeSuffix, System.currentTimeMillis()));
          try {
              //获取一个File对象,这个File是telebot库的File,不是java.io.File
              File file = sendApiMethod(getFile);
              //自写方法,用httpClient下载图片。为什么不用库自带的版本呢?因为库自带的下载方法直接用了FileUtils.copyURLToFile()下载文件,而且接口没提供代理选项,在公司内网等网络环境下不可用
              //所以这个方法主要是用来使用代理下载图片的。具体代码不附了
              downloadFileUseClient(file, savedFile);
              log.info(savedFile.getAbsolutePath() + " saved succeed");
          } catch (Exception e) {
              log.info(savedFile.getAbsolutePath() + " saved failed", e);
          }
      }
    
    }
    
  • webHook

    使用webHook就麻烦多了,根据官网上的说法:

    You’ll need a server that:

    • Supports IPv4, IPv6 is currently not supported for Webhooks.
    • Accepts incoming POSTs from 149.154.167.197-233 on port 443,80,88 or 8443.
    • Is able to handle TLS1.0+ HTTPS-traffic.
    • Provides a supported, non-wildcard, verified or self-signed certificate.
    • Uses a CN or SAN that matches the domain you’ve supplied on setup.
    • Supplies all intermediate certificates to complete a verification chain.

    所以为了使用webHook,分为这么几个步骤:

    1. 有个网站,可以让你监听请求;
    2. 为这个网站申请一个可用的证书(或者自签的证书),因为webHook必须在Https上传输信息
    3. 打开一个端口来监听请求。
    4. 将webHook注册到telegram

    既然诸位能看到这篇文章,显然前两步我早都轻车熟路了,唯一要做的就是写一个webServer。Springboot走起……

    //webHookService
    public class WebHookService extends DefaultAbsSender {
    
      private final BotConfig botConfig;
    
      @Autowired
      public WebHookService(ProxyOptions options, BotConfig botConfig) {
          //不用维护已处理的信息记录,因为webhook只会推送一遍
          //如果需要容错处理,就用数据库先记录一下好了
          super(options);
          this.botConfig = botConfig;
      }
    
      public void processUpdate(Update update) {
          downLoadFile(update);
      }
    
      public void setWebHook() {
          //这是库里提供的接口。其实certpath直接传个null就可以了,因为telegram访问你的webhook时会自己获取证书
          WebhookUtils.setWebhook(this, botConfig.getWebHookUrl(), botConfig.getCertPath());
      }
    
      @Override
      public String getBotToken() {
          return botConfig.getToken();
      }
    
    }
    

    controller

    @RestController
    @RequestMapping("/")
    @ConditionalOnBean(WebHookService.class)
    public class WebHookController {
    
      private final WebHookService webHookService;
    
      @Autowired
      public WebHookController(WebHookService webHookService) {
          this.webHookService = webHookService;
      }
    
      @PostMapping("/your/webhookUrl")
      public void webHook(@RequestBody Update update) {
          webHookService.processUpdate(update);
      }
    
      @GetMapping   //一个测试方法,可访问telebot.history-of-my-life.com看到这段话
      public String hello() {
          return "<html>" +
              "<h1>welcome</h1>" +
              "<p>I'm a telegram robot, Love ya!</p>" +
              "<p>you will never be able to find my real work path!</p>" +
              "</html>";
      }
    
    }
    

    设置server监听端口(springboot,application.yml):

    server:
    port: 8443 #webHook仅支持443,80,88,8443端口(即使用了80端口,也必须是https连接)
    

    main如下:

    @SpringBootApplication
    @Log
    public class Application {
    
      public static void main(String[] args) {
          ApplicationContext context = SpringApplication.run(Application.class, args);
          if (context.containsBean("longPullingService")) {
              LongPullingService longPullingService = context.getBean("longPullingService", LongPullingService.class);
              longPullingService.removeWebHook();
              log.info("webHook removed");
              longPullingService.pulling();
          } else {
              WebHookService webHookService = context.getBean("webHookService", WebHookService.class);
              webHookService.setWebHook();
              log.info("webHook settled");
          }
      }
    
    }
    

    之后将服务器部署到vps上运行就可以了。

既然已经获得了Update,接下来怎么处理Update,怎么回复Update,就纯粹是自己的业务逻辑了,想怎么玩怎么玩。

用到的类

botConfig,用的是SpringBoot的机制,所有设置都从配置文件中获取

@Component
@Data
@ConfigurationProperties(prefix = "bot", ignoreInvalidFields = true)
public class BotConfig {
    String token;
    String botUserName;
    String downloadRootFolder;
    String webHookUrl;
    String downloadedRecord;
    String certPath;
}

对应的配置文件(application.yml):

bot:
  token: yourtoken
  botUserName: yourBotName
  downloadRootFolder: /path/to/save/photo
  downloadedRecord: history.txt #相对上一个目录
  webHookUrl: https://www.yourdomain/your/path/to/webHook
  downloadedRecord: history.txt

填的坑

看到官方API上说setWebHook方法有可选参数,其中一个是上传公钥,于是我就很老实地上传了,就是下面的方法:

public void setWebHook() {
    WebhookUtils.setWebhook(this, botConfig.getWebHookUrl(), botConfig.getCertPath());
}

如果设置了证书路径,就会上传证书。但不知道怎么回事,我上传的cert总是验证失败,telegram就是不能把信息push过来,后来看到一篇文章:

https://stackoverflow.com/questions/44885229/unable-to-use-lets-encrypt-cert-in-telegram-bot-webhook-self-signed-cert-works

You mentioned that you used the setwebhook?url= method with an additional argument (passing the certificate…). Somehow this isn´t needed. You can just enter the url starting with https:// and you are fine.

也就是说不用设置,其实telegram可以获取到证书的。不上传证书果然好了,不明所以……

进阶

  1. 需要代理的网络环境下如何设置:

    其实库已经封装好了代理的细节,只需要通过DefaultBotOptions设置代理细节就好,即:

    @Bean
    @Autowired
    public LongPullingService longPullingService(BotConfig botconfig){
    DefaultBotOptions options = new DefaultBotOptions();
       //可选值NO_PROXY, HTTP, SOCKS4, SOCKS5
       options.setProxyType(ProxyType.HTTP);
       options.setProxyHost(host);
       options.setProxyPort(port);
    new LongPullingService(options, botConfig;
    }
    

    需要在注意的是,虽然这里设置了代理,但是下载文件时库并没有使用代理,而是用了FileUtils.copyUrlToFile()方法,所以如果网络环境不好,不能用库来下载文件,只能自己写下载方法。

  2. 通过nginx反向代理

    如果不想向外暴露更多的端口,比如我的这个小站,已经绑定很多子域名了,都是nginx反代,这个也就一样处理了。

    nginx的设置如下:

    server {
       listen 80;
       # 修改成自己的域名
       server_name         telebot.history-of-my-life.com;
       # force redirect to https
       return 301 https://http_hostrequest_uri;
       server_tokens off;
    }
    server {
       listen 443 ssl;
       ssl on;
       ssl_certificate         /path/to/cert/telebot.crt;
       ssl_certificate_key     /path/to/cert/telebot.key;
       server_name telebot.history-of-my-life.com;
       ssl_session_timeout 5m;
       #ssl_session_cache shared:SSL:5m;
    
       ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
       ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-CAMELLIA256-SHA:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-SEED-SHA:DHE-RSA-CAMELLIA128-SHA:HIGH:!aNULL:!eNULL:!LOW:!3DES:!MD5:!EXP:!PSK:!SRP:!DSS';
       ssl_prefer_server_ciphers on;
    
       add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
       server_tokens off;
    
       location / {
           proxy_set_header   Host http_host;
           proxy_set_header   X-Real-IPremote_addr;
           proxy_set_header   X-Forwarded-For proxy_add_x_forwarded_for;
           proxy_set_header   X-Forwarded-Hostserver_name;
           proxy_set_header   X-Forwarded-Proto https;
           ## 转发到9527端口
           proxy_pass         http://127.0.0.1:9527;
    
           access_log      /var/log/nginx/telebot.access.log;
           error_log       /var/log/nginx/telebot.error.log;
    
           proxy_read_timeout  1200s;
    
           client_max_body_size 0;
       }
    
    }
    

    为了不暴露9527端口,application.yml设置如下:

    server:
     address: 127.0.0.1 #只监听本地的连接,其他主机的访问会被拒绝
     port: 9527
    

    具体建站细节请参考我之前的文章,有介绍证书怎么申请的。

    VPS配置网站详解


3 条评论

mz · 2019年4月28日 下午10:50

厉害

刘富贵 · 2021年12月8日 下午5:02

大佬我想问一下本地环境可以启动吗

    astupidcoder · 2021年12月18日 下午8:32

    可以启动,但是外网访问不到你啊……

发表回复

Avatar placeholder

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据。