├── .gitignore ├── LICENSE ├── README.md ├── example.py └── telegram.py /.gitignore: -------------------------------------------------------------------------------- 1 | misc/* 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Salvatore Sanfilippo 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 19 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 22 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This code implements a very simple non-blocking Telegram bot library 2 | for MicroPython based MCUs such as the ESP32 and similar microcontrollers. 3 | 4 | Quick facts about how this can be useful and how it is implemented: 5 | 6 | * **What you can do with this code?** You can implement bots that run into microcontrollers so that you can control your projects / IoT devices via Telegram. 7 | * **What the library implements?** It calls your callback when the bot receives a message, and then you have an API to send messages. Just that. No advanced features are supported. 8 | * **This implementation has limits.** The code uses non blocking sockets. It cut corners in order to be simple and use few memory, it may break, it's not a technically super "sane" implementation, but it is extremely easy to understand and modify. 9 | * The code is BSD licensed. 10 | * The MicroPython JSON library does not translate surrogate UTF-16 characters, so this library implements a quick and dirty conversion to UTF-8. 11 | 12 | ## How to test it? 13 | 14 | 1. Create your bot using the Telegram [@BotFather](https://t.me/botfather). 15 | 2. After obtaining your bot API key, edit the `example.py` file and put there your API key (also called *token*). Make sure to also put your WiFi credentials, as the microcontroller needs to connect to the Internet for this to work. 16 | 3. Put the `telegram.py` file into the device flash memory with: 17 | 18 | mp cp telegram.py : 19 | 20 | 4. Execute the example with: 21 | 22 | mp run example.y 23 | 24 | 5. Talk to your bot. It will echo what you write to it. 25 | 6. If your bot is inside a group, make sure to give it admin privileges, otherwise it will be unable to get messages. 26 | 27 | ## How to use the API? 28 | 29 | Run the bot in its own coroutine with: 30 | 31 | 32 | ```python 33 | bot = TelegramBot(Token,mycallback) 34 | bot.connect_wifi(WifiNetwork, WifiPassword) 35 | asyncio.create_task(bot.run()) 36 | loop = asyncio.get_event_loop() 37 | loop.run_forever() 38 | ``` 39 | 40 | The callback looks like that: 41 | 42 | 43 | ```python 44 | def mycallback(bot,msg_type,chat_name,sender_name,chat_id,text,entry): 45 | print(msg_type,chat_name,sender_name,chat_id,text) 46 | bot.send(sender_id,"Ehi! "+text) 47 | ``` 48 | 49 | The arguments it receives are: 50 | 51 | * `msg_type` is private, group, supergroup, channel, depending on the entity that sent us the message. 52 | * `chat_name` Group/Channel name if the message is not a private message. Otherwise `None`. 53 | * `sender_name` is the Telegram username of the caller, or the name of the group/channel. 54 | * `chat_id` is the Telegram ID of the user/chat: this ID is specific of the user/group/channel. You need to use this ID with the `.send()` method to reply in the same place the message arrived to you. 55 | * `text` is the content of the message. UTF-8 encoded. 56 | * `entry` is the raw JSON entry received from Telegram. From there you can take all the other stuff not directly passed to the function. 57 | 58 | The only two methods you can call are: 59 | 60 | 1. `.send()`, with the ID of the recipient and your text message. A third optional argument called **glue** can be `True` or `False`. By default it is `False`. When it is `True`, messages having the same target ID as the previous message are *glued* together, up to 2k of text, so we can avoid sending too many messages via the API. 61 | 2. `.stop()` that will just terminate the task handling the bot. This should be called before discarding the `TelegramBot` object. 62 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | # This example just connects to the WiFi network and 2 | # runs a bot that will simply reply 'Ehi!' echoing what 3 | # you just told it. 4 | 5 | ### Config your stuff here 6 | Token = "9283749392:HAF-Xd239cAAPOOpx9C7aFFzzAJrpo_EstE" 7 | WifiNetwork = "MyNetwork" 8 | WifiPassword = "mysecretpassword" 9 | ### 10 | 11 | import uasyncio as asyncio 12 | from telegram import TelegramBot 13 | 14 | def mycallback(bot,msg_type,chat_name,sender_name,chat_id,text,entry): 15 | print(msg_type,chat_name,sender_name,chat_id,text) 16 | bot.send(chat_id,"Ehi! "+text) 17 | 18 | bot = TelegramBot(Token,mycallback) 19 | bot.connect_wifi(WifiNetwork, WifiPassword) 20 | asyncio.create_task(bot.run()) 21 | loop = asyncio.get_event_loop() 22 | loop.run_forever() 23 | -------------------------------------------------------------------------------- /telegram.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2024 Salvatore Sanfilippo 2 | # All Rights Reserved 3 | # 4 | # This code is released under the BSD 2 clause license. 5 | # See the LICENSE file for more information 6 | 7 | import network, socket, ssl, time, uasyncio as asyncio, json 8 | 9 | class TelegramBot: 10 | def __init__(self,token,callback): 11 | self.token = token 12 | self.callback = callback 13 | self.rbuf = bytearray(4096) 14 | self.rbuf_mv = memoryview(self.rbuf) 15 | self.rbuf_used = 0 16 | self.active = True # So we can stop the task with .stop() 17 | self.debug = False 18 | self.missed_write = None # Failed write payload. This is useful 19 | # in order to retransfer after reconnection. 20 | 21 | # Array of outgoing messages. Each entry is a hash with 22 | # chat_id and text fields. 23 | self.outgoing = [] 24 | self.pending = False # Pending HTTP request, waiting for reply. 25 | self.reconnect = True # We need to reconnect the socket, either for 26 | # the first time or after errors. 27 | self.offset = 0 # Next message ID offset. 28 | self.watchdog_timeout_ms = 60000 # 60 seconds max idle time. 29 | 30 | # Stop the task handling the bot. This should be called before 31 | # destroying the object, in order to also terminate the task. 32 | def stop(self): 33 | self.active = False 34 | 35 | # Main telegram bot loop. 36 | # Sould be executed asynchronously, like with: 37 | # asyncio.create_task(bot.run()) 38 | async def run(self): 39 | while self.active: 40 | if self.reconnect: 41 | if self.debug: print("[telegram] Reconnecting socket.") 42 | # Reconnection (or first connection) 43 | try: 44 | addr = socket.getaddrinfo("api.telegram.org", 443, socket.AF_INET) 45 | addr = addr[0][-1] 46 | self.socket = socket.socket(socket.AF_INET) 47 | self.socket.connect(addr) 48 | self.socket.setblocking(False) 49 | self.ssl = ssl.wrap_socket(self.socket) 50 | self.reconnect = False 51 | self.pending = False 52 | except: 53 | self.reconnect = True 54 | 55 | self.send_api_requests() 56 | self.read_api_response() 57 | 58 | # Watchdog: if the connection is idle for a too long 59 | # time, force a reconnection. 60 | if self.pending and time.ticks_diff(time.ticks_ms(),self.pending_since) > self.watchdog_timeout_ms: 61 | self.reconnect = True 62 | print("[telegram] *** SOCKET WATCHDOG EXPIRED ***") 63 | 64 | # If there are outgoing messages pending, wait less 65 | # to do I/O again. 66 | sleep_time = 0.1 if len(self.outgoing) > 0 else 1.0 67 | await asyncio.sleep(sleep_time) 68 | 69 | # Send HTTP requests to the server. If there are no special requests 70 | # to handle (like sendMessage) we just ask for updates with getUpdates. 71 | def send_api_requests(self): 72 | if self.pending: return # Request already in progress. 73 | request = None 74 | 75 | # Re-issue a pending write that failed for OS error 76 | # after a reconnection. 77 | if self.missed_write != None: 78 | request = self.missed_write 79 | self.missed_write = None 80 | 81 | # Issue sendMessage requests if we have pending 82 | # messages to deliver. 83 | elif len(self.outgoing) > 0: 84 | oldest = self.outgoing.pop() 85 | request = self.build_post_request("sendMessage",oldest) 86 | 87 | # Issue a new getUpdates request if there is not 88 | # some request still pending. 89 | else: 90 | # Limit the fetch to a single message since we are using 91 | # a fixed 4k buffer. Very large incoming messages will break 92 | # the reading loop: that's a trade off. 93 | request = "GET /bot"+self.token+"/getUpdates?offset="+str(self.offset)+"&timeout=0&allowed_udpates=message&limit=1 HTTP/1.1\r\nHost:api.telegram.org\r\n\r\n" 94 | 95 | # Write the request to the SSL socket. 96 | # 97 | # Here we assume that the output buffer has enough 98 | # space available, since this is sent either at startup 99 | # or when we already received a reply. In both the 100 | # situations the socket buffer should be empty and 101 | # this request should work without sending just part 102 | # of the request. 103 | if request != None: 104 | if self.debug: print("[telegram] Writing payload:",request) 105 | try: 106 | self.ssl.write(request) 107 | self.pending = True 108 | self.pending_since = time.ticks_ms() 109 | except: 110 | self.reconnect = True 111 | self.missed_write = request 112 | 113 | # Try to read the reply from the Telegram server. Process it 114 | # and if needed ivoke the callback registered by the user for 115 | # incoming messages. 116 | def read_api_response(self): 117 | try: 118 | # Don't use await to read from the SSL socket (it's not 119 | # supported). We put the socket in non blocking mode 120 | # anyway. It will return None if there is no data to read. 121 | nbytes = self.ssl.readinto(self.rbuf_mv[self.rbuf_used:],len(self.rbuf)-self.rbuf_used) 122 | if self.debug: print("bytes from SSL socket:",nbytes) 123 | except: 124 | self.reconnect = True 125 | return 126 | 127 | if nbytes != None: 128 | if nbytes == 0: 129 | self.reconnect = True 130 | return 131 | else: 132 | self.rbuf_used += nbytes 133 | if self.debug: print(self.rbuf[:self.rbuf_used]) 134 | 135 | # Check if we got a well-formed JSON message. 136 | self.process_api_response() 137 | 138 | # Check if there is a well-formed JSON reply in the reply buffer: 139 | # if so, parses it, marks the current request as no longer "pending" 140 | # and resets the buffer. If the JSON reply is an incoming message, the 141 | # user callback is invoked. 142 | def process_api_response(self): 143 | if self.rbuf_used > 0: 144 | # Discard the HTTP request header by looking for the 145 | # start of the json message. 146 | start_idx = self.rbuf.find(b"{") 147 | if start_idx != -1: 148 | # It is possible that we read a non complete reply 149 | # from the socket. In such case the JSON message 150 | # will be broken and will produce a ValueError. 151 | try: 152 | mybuf = self.decode_surrogate_pairs(self.rbuf[start_idx:self.rbuf_used]) 153 | res = json.loads(mybuf) 154 | except ValueError: 155 | res = False 156 | if res != False: 157 | self.pending = False 158 | if len(res['result']) == 0: 159 | # Empty result set. Try again. 160 | if self.debug: print("No more messages.") 161 | elif not isinstance(res['result'],list): 162 | # This is the reply to SendMessage or other 163 | # non getUpdates related API calls? Discard 164 | # it. 165 | if self.debug: print("Got reply from sendMessage") 166 | pass 167 | else: 168 | # Update the last message ID we get so we 169 | # will get only next ones. 170 | offset = res['result'][0]['update_id'] 171 | offset += 1 172 | self.offset = offset 173 | if self.debug: print("New offset:",offset) 174 | 175 | # Process the received message. 176 | entry = res['result'][0] 177 | if "message" in entry: 178 | msg = entry['message'] 179 | elif "channel_post" in entry: 180 | msg = entry['channel_post'] 181 | 182 | # Fill the fields depending on the message 183 | msg_type = None 184 | chat_name = None 185 | sender_name = None 186 | chat_id = None 187 | text = None 188 | 189 | try: msg_type = msg['chat']['type'] 190 | except: pass 191 | try: chat_name = msg['chat']['title'] 192 | except: pass 193 | try: sender_name = msg['from']['username'] 194 | except: pass 195 | try: chat_id = msg['chat']['id'] 196 | except: pass 197 | try: text = msg['text'] 198 | except: pass 199 | 200 | # We don't care about join messages and other stuff. 201 | # We report just messages with some text content. 202 | if text != None: 203 | self.callback(self,msg_type,chat_name,sender_name,chat_id,text,entry) 204 | self.rbuf_used = 0 205 | 206 | # MicroPython seems to lack the urlencode module. We need very 207 | # little to kinda make it work. 208 | def quote(self,string): 209 | return ''.join(['%{:02X}'.format(c) if c < 33 or c > 126 or c in (37, 38, 43, 58, 61) else chr(c) for c in str(string).encode('utf-8')]) 210 | 211 | # Turn the GET/POST parameters in the 'fields' hash into a string 212 | # in url encoded form a=1&b=2&... quoting just the value (the key 213 | # of the hash is assumed to be already url encoded or just a plain 214 | # string without special chars). 215 | def urlencode(self,fields): 216 | return "&".join([str(key)+"="+self.quote(value) for key,value in fields.items()]) 217 | 218 | # Create a POST request with url-encoded parameters in the body. 219 | # Parameters are passed as a hash in 'fields'. 220 | def build_post_request(self,cmd,fields): 221 | params = self.urlencode(fields) 222 | headers = f"POST /bot{self.token}/{cmd} HTTP/1.1\r\nHost:api.telegram.org\r\nContent-Type:application/x-www-form-urlencoded\r\nContent-Length:{len(params)}\r\n\r\n" 223 | return headers+params 224 | 225 | # MicroPython JSON library does not handle surrogate UTF-16 pairs 226 | # generated by the Telegram API. We need to do it manually by scanning 227 | # the input bytearray and converting the surrogates to UTF-8. 228 | def decode_surrogate_pairs(self,ba): 229 | result = bytearray() 230 | i = 0 231 | while i < len(ba): 232 | if ba[i:i+2] == b'\\u' and i + 12 <= len(ba): 233 | if ba[i+2:i+4] in [b'd8', b'd9', b'da', b'db'] and ba[i+6:i+8] == b'\\u' and ba[i+8:i+10] in [b'dc', b'dd', b'de', b'df']: 234 | # We found a surrogate pairs. Convert. 235 | high = int(ba[i+2:i+6].decode(), 16) 236 | low = int(ba[i+8:i+12].decode(), 16) 237 | code_point = 0x10000 + (high - 0xD800) * 0x400 + (low - 0xDC00) 238 | result.extend(chr(code_point).encode('utf-8')) 239 | i += 12 240 | else: 241 | result.append(ba[i]) 242 | i += 1 243 | else: 244 | result.append(ba[i]) 245 | i += 1 246 | return result 247 | 248 | # Send a message via Telegram, to the specified chat_id and containing 249 | # the specified text. This function will just queue the item. The 250 | # actual sending will be performed in the main boot loop. 251 | # 252 | # If 'glue' is True, the new text will be glued to the old pending 253 | # message up to 2k, in order to reduce the API back-and-forth. 254 | def send(self,chat_id,text,glue=False): 255 | if glue and len(self.outgoing) > 0 and \ 256 | len(self.outgoing[0]["text"])+len(text)+1 < 2048: 257 | self.outgoing[0]["text"] += "\n" 258 | self.outgoing[0]["text"] += text 259 | return 260 | self.outgoing = [{"chat_id":chat_id, "text":text}]+self.outgoing 261 | 262 | # This is just a utility method that can be used in order to wait 263 | # for the WiFi network to be connected. 264 | def connect_wifi(self,ssid,password,timeout=30): 265 | self.sta_if = network.WLAN(network.STA_IF) 266 | self.sta_if.active(True) 267 | self.sta_if.connect(ssid,password) 268 | seconds = 0 269 | while not self.sta_if.isconnected(): 270 | time.sleep(1) 271 | seconds += 1 272 | if seconds == timeout: 273 | raise Exception("Timedout connecting to WiFi network") 274 | pass 275 | print("[WiFi] Connected") 276 | 277 | --------------------------------------------------------------------------------