Move broker + lib to its own repo
This commit is contained in:
commit
cc45fb5209
11 changed files with 922 additions and 0 deletions
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Bot API for Telegram bots
|
||||||
|
|
||||||
|
Should work if you can put the time to figure out how it works (check client_test.go or better yet, [clessy](https://github.com/hamcha/clessy))
|
154
api.go
Normal file
154
api.go
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
package tg
|
||||||
|
|
||||||
|
// APIUser represents the "User" JSON structure
|
||||||
|
type APIUser struct {
|
||||||
|
UserID int64 `json:"id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
LastName string `json:"last_name,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatType defines the type of chat
|
||||||
|
type ChatType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ChatTypePrivate is a private chat (between user and bot)
|
||||||
|
ChatTypePrivate ChatType = "private"
|
||||||
|
|
||||||
|
// ChatTypeGroup is a group chat (<100 members)
|
||||||
|
ChatTypeGroup ChatType = "group"
|
||||||
|
|
||||||
|
// ChatTypeSupergroup is a supergroup chat (>=100 members)
|
||||||
|
ChatTypeSupergroup ChatType = "supergroup"
|
||||||
|
|
||||||
|
// ChatTypeChannel is a channel (Read-only)
|
||||||
|
ChatTypeChannel ChatType = "channel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIChat represents the "Chat" JSON structure
|
||||||
|
type APIChat struct {
|
||||||
|
ChatID int64 `json:"id"`
|
||||||
|
Type ChatType `json:"type"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Username *string `json:"username,omitempty"`
|
||||||
|
FirstName *string `json:"first_name,omitempty"`
|
||||||
|
LastName *string `json:"last_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIMessage represents the "Message" JSON structure
|
||||||
|
type APIMessage struct {
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
User APIUser `json:"from"`
|
||||||
|
Time int64 `json:"date"`
|
||||||
|
Chat *APIChat `json:"chat"`
|
||||||
|
FwdUser *APIUpdate `json:"forward_from,omitempty"`
|
||||||
|
FwdTime *int `json:"forward_date,omitempty"`
|
||||||
|
ReplyTo *APIMessage `json:"reply_to_message,omitempty"`
|
||||||
|
Text *string `json:"text,omitempty"`
|
||||||
|
Audio *APIAudio `json:"audio,omitempty"`
|
||||||
|
Document *APIDocument `json:"document,omitempty"`
|
||||||
|
Photo []APIPhotoSize `json:"photo,omitempty"`
|
||||||
|
Sticker *APISticker `json:"sticker,omitempty"`
|
||||||
|
Video *APIVideo `json:"video,omitempty"`
|
||||||
|
Voice *APIVoice `json:"voice,omitempty"`
|
||||||
|
Caption *string `json:"caption,omitempty"`
|
||||||
|
Contact *APIContact `json:"contact,omitempty"`
|
||||||
|
Location *APILocation `json:"location,omitempty"`
|
||||||
|
NewUser *APIUser `json:"new_chat_partecipant,omitempty"`
|
||||||
|
LeftUser *APIUser `json:"left_chat_partecipant,omitempty"`
|
||||||
|
PhotoDeleted *bool `json:"delete_chat_photo,omitempty"`
|
||||||
|
GroupCreated *bool `json:"group_chat_created,omitempty"`
|
||||||
|
SupergroupCreated *bool `json:"supergroup_chat_created,omitempty"`
|
||||||
|
ChannelCreated *bool `json:"channel_chat_created,omitempty"`
|
||||||
|
GroupToSuper *int64 `json:"migrate_to_chat_id,omitempty"`
|
||||||
|
GroupFromSuper *int64 `json:"migrate_from_chat_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIPhotoSize represents the "PhotoSize" JSON structure
|
||||||
|
type APIPhotoSize struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
FileSize *int `json:"file_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAudio represents the "Audio" JSON structure
|
||||||
|
type APIAudio struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Performer *string `json:"performer,omitempty"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
MimeType *string `json:"mime_type,omitempty"`
|
||||||
|
FileSize *int `json:"file_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIDocument represents the "Document" JSON structure
|
||||||
|
type APIDocument struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Thumbnail *APIPhotoSize `json:"thumb,omitempty"`
|
||||||
|
Filename string `json:"file_name"`
|
||||||
|
MimeType *string `json:"mime_type,omitempty"`
|
||||||
|
FileSize *int `json:"file_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APISticker represents the "Sticker" JSON structure
|
||||||
|
type APISticker struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Thumbnail *APIPhotoSize `json:"thumb,omitempty"`
|
||||||
|
FileSize *int `json:"file_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIVideo represents the "Video" JSON structure
|
||||||
|
type APIVideo struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Thumbnail *APIPhotoSize `json:"thumb,omitempty"`
|
||||||
|
MimeType *string `json:"mime_type,omitempty"`
|
||||||
|
FileSize *int `json:"file_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIVoice represents the "Voice" JSON structure
|
||||||
|
type APIVoice struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
MimeType *string `json:"mime_type,omitempty"`
|
||||||
|
FileSize *int `json:"file_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIContact represents the "Contact" JSON structure
|
||||||
|
type APIContact struct {
|
||||||
|
PhoneNumber string `json:"phone_number"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
LastName *string `json:"last_name,omitempty"`
|
||||||
|
UserID *int64 `json:"user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APILocation represents the "Location" JSON structure
|
||||||
|
type APILocation struct {
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIUpdate represents the "Update" JSON structure
|
||||||
|
type APIUpdate struct {
|
||||||
|
UpdateID int64 `json:"update_id"`
|
||||||
|
Message APIMessage `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIFile represents the "File" JSON structure
|
||||||
|
type APIFile struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Size *int `json:"file_size,omitempty"`
|
||||||
|
Path *string `json:"file_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIResponse represents a response from the Telegram API
|
||||||
|
type APIResponse struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
ErrCode *int `json:"error_code,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
}
|
166
broker.go
Normal file
166
broker.go
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
package tg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Broker is a broker connection handler with callback management functions
|
||||||
|
type Broker struct {
|
||||||
|
Socket net.Conn
|
||||||
|
Callbacks []BrokerCallback
|
||||||
|
|
||||||
|
cbFree int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectToBroker creates a Broker connection
|
||||||
|
func ConnectToBroker(brokerAddr string) (*Broker, error) {
|
||||||
|
sock, err := net.Dial("tcp", brokerAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
broker := new(Broker)
|
||||||
|
broker.Socket = sock
|
||||||
|
broker.Callbacks = make([]BrokerCallback, 0)
|
||||||
|
broker.cbFree = 0
|
||||||
|
return broker, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes a broker connection
|
||||||
|
func (b *Broker) Close() {
|
||||||
|
b.Socket.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTextMessage sends a HTML-styles text message to a chat.
|
||||||
|
// A reply_to message ID can be specified as optional parameter.
|
||||||
|
func (b *Broker) SendTextMessage(chat *APIChat, text string, original *int64) {
|
||||||
|
b.sendCmd(ClientCommand{
|
||||||
|
Type: CmdSendTextMessage,
|
||||||
|
TextMessageData: &ClientTextMessageData{
|
||||||
|
Text: text,
|
||||||
|
ChatID: chat.ChatID,
|
||||||
|
ReplyID: original,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPhoto sends a photo with an optional caption to a chat.
|
||||||
|
// A reply_to message ID can be specified as optional parameter.
|
||||||
|
func (b *Broker) SendPhoto(chat *APIChat, data []byte, filename string, caption string, original *int64) {
|
||||||
|
b.sendCmd(ClientCommand{
|
||||||
|
Type: CmdSendPhoto,
|
||||||
|
PhotoData: &ClientPhotoData{
|
||||||
|
ChatID: chat.ChatID,
|
||||||
|
Filename: filename,
|
||||||
|
Bytes: base64.StdEncoding.EncodeToString(data),
|
||||||
|
Caption: caption,
|
||||||
|
ReplyID: original,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForwardMessage forwards a message between chats.
|
||||||
|
func (b *Broker) ForwardMessage(chat *APIChat, message APIMessage) {
|
||||||
|
b.sendCmd(ClientCommand{
|
||||||
|
Type: CmdForwardMessage,
|
||||||
|
ForwardMessageData: &ClientForwardMessageData{
|
||||||
|
ChatID: chat.ChatID,
|
||||||
|
FromChatID: message.Chat.ChatID,
|
||||||
|
MessageID: message.MessageID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendChatAction sets a chat action for 5 seconds or less (canceled at first message sent)
|
||||||
|
func (b *Broker) SendChatAction(chat *APIChat, action ChatAction) {
|
||||||
|
b.sendCmd(ClientCommand{
|
||||||
|
Type: CmdSendChatAction,
|
||||||
|
ChatActionData: &ClientChatActionData{
|
||||||
|
ChatID: chat.ChatID,
|
||||||
|
Action: action,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFile sends a file retrieval request to the Broker.
|
||||||
|
// This function is asynchronous as data will be delivered to the given callback.
|
||||||
|
func (b *Broker) GetFile(fileID string, fn BrokerCallback) int {
|
||||||
|
cid := b.RegisterCallback(fn)
|
||||||
|
b.sendCmd(ClientCommand{
|
||||||
|
Type: CmdGetFile,
|
||||||
|
FileRequestData: &FileRequestData{
|
||||||
|
FileID: fileID,
|
||||||
|
},
|
||||||
|
Callback: &cid,
|
||||||
|
})
|
||||||
|
return cid
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterCallback assigns a callback ID to the given callback and puts it on the callback list.
|
||||||
|
// This function should never be called by clients.
|
||||||
|
func (b *Broker) RegisterCallback(fn BrokerCallback) int {
|
||||||
|
cblen := len(b.Callbacks)
|
||||||
|
// List is full, append to end
|
||||||
|
if b.cbFree == cblen {
|
||||||
|
b.Callbacks = append(b.Callbacks, fn)
|
||||||
|
b.cbFree++
|
||||||
|
return cblen
|
||||||
|
}
|
||||||
|
// List is not full, use empty slot and find next one
|
||||||
|
id := b.cbFree
|
||||||
|
b.Callbacks[id] = fn
|
||||||
|
next := b.cbFree + 1
|
||||||
|
for ; next < cblen; next++ {
|
||||||
|
if b.Callbacks[next] == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.cbFree = next
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveCallback removes a callback from the callback list by ID.
|
||||||
|
// This function should never be called by clients.
|
||||||
|
func (b *Broker) RemoveCallback(id int) {
|
||||||
|
b.Callbacks[id] = nil
|
||||||
|
if id < b.cbFree {
|
||||||
|
b.cbFree = id
|
||||||
|
}
|
||||||
|
b.resizeCbArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpliceCallback retrieves a callback by ID and removes it from the list.
|
||||||
|
// This function should never be called by clients.
|
||||||
|
func (b *Broker) SpliceCallback(id int) BrokerCallback {
|
||||||
|
defer b.RemoveCallback(id)
|
||||||
|
return b.Callbacks[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) sendCmd(cmd ClientCommand) {
|
||||||
|
data, err := json.Marshal(cmd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[sendCmd] JSON Encode error: %s\n", err.Error())
|
||||||
|
}
|
||||||
|
fmt.Fprintln(b.Socket, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) resizeCbArray() {
|
||||||
|
var i int
|
||||||
|
cut := false
|
||||||
|
for i = len(b.Callbacks); i > 0; i-- {
|
||||||
|
if b.Callbacks[i-1] != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cut = true
|
||||||
|
}
|
||||||
|
if cut {
|
||||||
|
b.Callbacks = b.Callbacks[0:i]
|
||||||
|
if b.cbFree > i {
|
||||||
|
b.cbFree = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
64
client.go
Normal file
64
client.go
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
package tg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateHandler is an update handler for webhook updates
|
||||||
|
type UpdateHandler func(broker *Broker, message APIMessage)
|
||||||
|
|
||||||
|
// BrokerCallback is a callback for broker responses to client requests
|
||||||
|
type BrokerCallback func(broker *Broker, update BrokerUpdate)
|
||||||
|
|
||||||
|
// CreateBrokerClient creates a connection to a broker and sends all webhook updates to a given function.
|
||||||
|
// This is the intended way to create clients, please refer to examples for how to make a simple client.
|
||||||
|
func CreateBrokerClient(brokerAddr string, updateFn UpdateHandler) error {
|
||||||
|
broker, err := ConnectToBroker(brokerAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer broker.Close()
|
||||||
|
|
||||||
|
return RunBrokerClient(broker, updateFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunBrokerClient is a slimmer version of CreateBrokerClient for who wants to keep its own broker connection
|
||||||
|
func RunBrokerClient(broker *Broker, updateFn UpdateHandler) error {
|
||||||
|
in := bufio.NewReader(broker.Socket)
|
||||||
|
var buf []byte
|
||||||
|
for {
|
||||||
|
bytes, isPrefix, err := in.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
buf = append(buf, bytes...)
|
||||||
|
|
||||||
|
if isPrefix {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var update BrokerUpdate
|
||||||
|
err = json.Unmarshal(buf, &update)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[tg - CreateBrokerClient] ERROR reading JSON: %s\r\n", err.Error())
|
||||||
|
log.Printf("%s\n", string(buf))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if update.Callback == nil {
|
||||||
|
// It's a generic message: dispatch to UpdateHandler
|
||||||
|
go updateFn(broker, *(update.Message))
|
||||||
|
} else {
|
||||||
|
// It's a response to a request: retrieve callback and call it
|
||||||
|
go broker.SpliceCallback(*(update.Callback))(broker, update)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty buffer
|
||||||
|
buf = []byte{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return io.EOF
|
||||||
|
}
|
16
client_test.go
Normal file
16
client_test.go
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
package tg_test
|
||||||
|
|
||||||
|
// This example creates a basic client that connects to a broker and checks for message containing greetings.
|
||||||
|
// If it finds a greeting message it will greet back the user (using the reply_to parameter)
|
||||||
|
func ExampleCreateBrokerClient() {
|
||||||
|
CreateBrokerClient("localhost:7314", func(broker *Broker, message APIMessage) {
|
||||||
|
// Check if it's a text message
|
||||||
|
if message.Text != nil {
|
||||||
|
// Check that it's a greeting
|
||||||
|
if *(message.Text) == "hello" || *(message.Text) == "hi" {
|
||||||
|
// Reply with a greeting!
|
||||||
|
broker.SendTextMessage(message.Chat, "Hello!", message.MessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
27
cmd/tg-broker/action.go
Normal file
27
cmd/tg-broker/action.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/hamcha/clessy/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func executeClientCommand(action tg.ClientCommand, client net.Conn) {
|
||||||
|
switch action.Type {
|
||||||
|
case tg.CmdSendTextMessage:
|
||||||
|
data := *(action.TextMessageData)
|
||||||
|
api.SendTextMessage(data)
|
||||||
|
case tg.CmdGetFile:
|
||||||
|
data := *(action.FileRequestData)
|
||||||
|
api.GetFile(data, client, *action.Callback)
|
||||||
|
case tg.CmdSendPhoto:
|
||||||
|
data := *(action.PhotoData)
|
||||||
|
api.SendPhoto(data)
|
||||||
|
case tg.CmdForwardMessage:
|
||||||
|
data := *(action.ForwardMessageData)
|
||||||
|
api.ForwardMessage(data)
|
||||||
|
case tg.CmdSendChatAction:
|
||||||
|
data := *(action.ChatActionData)
|
||||||
|
api.SendChatAction(data)
|
||||||
|
}
|
||||||
|
}
|
79
cmd/tg-broker/clients.go
Normal file
79
cmd/tg-broker/clients.go
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/hamcha/clessy/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
var clients []net.Conn
|
||||||
|
|
||||||
|
func startClientsServer(bind string) {
|
||||||
|
listener, err := net.Listen("tcp", bind)
|
||||||
|
assert(err)
|
||||||
|
|
||||||
|
// Accept loop
|
||||||
|
for {
|
||||||
|
c, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Can't accept client: %s\n", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clients = append(clients, c)
|
||||||
|
go handleClient(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleClient(c net.Conn) {
|
||||||
|
b := bufio.NewReader(c)
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// Start reading messages
|
||||||
|
buf := make([]byte, 0)
|
||||||
|
for {
|
||||||
|
bytes, isPrefix, err := b.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
buf = append(buf, bytes...)
|
||||||
|
if isPrefix {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get command
|
||||||
|
var cmd tg.ClientCommand
|
||||||
|
err = json.Unmarshal(buf, &cmd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[handleClient] Can't parse JSON: %s\r\n", err.Error())
|
||||||
|
log.Printf("%s\n", string(buf))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty buffer
|
||||||
|
buf = []byte{}
|
||||||
|
|
||||||
|
executeClientCommand(cmd, c)
|
||||||
|
}
|
||||||
|
removeCon(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeCon(c net.Conn) {
|
||||||
|
for i, con := range clients {
|
||||||
|
if c == con {
|
||||||
|
clients = append(clients[:i], clients[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func broadcast(message string) {
|
||||||
|
for _, c := range clients {
|
||||||
|
_, err := fmt.Fprintln(c, message)
|
||||||
|
if err != nil {
|
||||||
|
removeCon(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
57
cmd/tg-broker/main.go
Normal file
57
cmd/tg-broker/main.go
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Config data (parsed from JSON)
|
||||||
|
type Config struct {
|
||||||
|
BindServer string /* Address:Port to bind for Telegram */
|
||||||
|
BindClients string /* Address:Port to bind for clients */
|
||||||
|
Token string /* Telegram bot token */
|
||||||
|
BaseURL string /* Base URL for webhook */
|
||||||
|
WebhookURL string /* Webhook URL */
|
||||||
|
}
|
||||||
|
|
||||||
|
func assert(err error) {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var api *Telegram
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgpath := flag.String("config", "config.json", "Path to configuration file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
file, err := os.Open(*cfgpath)
|
||||||
|
assert(err)
|
||||||
|
|
||||||
|
var config Config
|
||||||
|
err = json.NewDecoder(file).Decode(&config)
|
||||||
|
assert(err)
|
||||||
|
|
||||||
|
// Create Telegram API object
|
||||||
|
api = mkAPI(config.Token)
|
||||||
|
|
||||||
|
// Setup webhook handler
|
||||||
|
go func() {
|
||||||
|
log.Println("Starting webserver..")
|
||||||
|
http.HandleFunc(config.WebhookURL, webhook)
|
||||||
|
err := http.ListenAndServe(config.BindServer, nil)
|
||||||
|
assert(err)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Register webhook @ Telegram
|
||||||
|
log.Println("Registering webhook..")
|
||||||
|
api.SetWebhook(config.BaseURL + config.WebhookURL)
|
||||||
|
|
||||||
|
// Create server for clients
|
||||||
|
log.Println("Starting clients server..")
|
||||||
|
startClientsServer(config.BindClients)
|
||||||
|
}
|
220
cmd/tg-broker/telegram.go
Normal file
220
cmd/tg-broker/telegram.go
Normal file
|
@ -0,0 +1,220 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"mime/multipart"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/hamcha/clessy/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIEndpoint is Telegram's current Bot API base url endpoint
|
||||||
|
const APIEndpoint = "https://api.telegram.org/"
|
||||||
|
|
||||||
|
// Telegram is the API client for the Telegram Bot API
|
||||||
|
type Telegram struct {
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// mkAPI creates a Telegram instance from a Bot API token
|
||||||
|
func mkAPI(token string) *Telegram {
|
||||||
|
tg := new(Telegram)
|
||||||
|
tg.Token = token
|
||||||
|
return tg
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWebhook sets the webhook address so that Telegram knows where to send updates
|
||||||
|
func (t Telegram) SetWebhook(webhook string) {
|
||||||
|
resp, err := http.PostForm(t.apiURL("setWebhook"), url.Values{"url": {webhook}})
|
||||||
|
if !checkerr("SetWebhook/http.PostForm", err) {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var result tg.APIResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("[SetWebhook] Could not read reply: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Ok {
|
||||||
|
log.Println("Webhook successfully set!")
|
||||||
|
} else {
|
||||||
|
log.Printf("[SetWebhook] Error setting webhook (errcode %d): %s\n", *(result.ErrCode), *(result.Description))
|
||||||
|
panic(errors.New("Cannot set webhook"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTextMessage sends an HTML-styled text message to a specified chat
|
||||||
|
func (t Telegram) SendTextMessage(data tg.ClientTextMessageData) {
|
||||||
|
postdata := url.Values{
|
||||||
|
"chat_id": {strconv.FormatInt(data.ChatID, 10)},
|
||||||
|
"text": {data.Text},
|
||||||
|
"parse_mode": {"HTML"},
|
||||||
|
}
|
||||||
|
if data.ReplyID != nil {
|
||||||
|
postdata["reply_to_message_id"] = []string{strconv.FormatInt(*(data.ReplyID), 10)}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := http.PostForm(t.apiURL("sendMessage"), postdata)
|
||||||
|
checkerr("SendTextMessage/http.PostForm", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Telegram) SendPhoto(data tg.ClientPhotoData) {
|
||||||
|
// Decode photo from b64
|
||||||
|
photolen := base64.StdEncoding.DecodedLen(len(data.Bytes))
|
||||||
|
photobytes := make([]byte, photolen)
|
||||||
|
decoded, err := base64.StdEncoding.Decode(photobytes, []byte(data.Bytes))
|
||||||
|
if checkerr("SendPhoto/base64.Decode", err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file into multipart buffer
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("photo", data.Filename)
|
||||||
|
if checkerr("SendPhoto/multipart.CreateFormFile", err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
part.Write(photobytes[0:decoded])
|
||||||
|
|
||||||
|
// Write other fields
|
||||||
|
writer.WriteField("chat_id", strconv.FormatInt(data.ChatID, 10))
|
||||||
|
|
||||||
|
if data.ReplyID != nil {
|
||||||
|
writer.WriteField("reply_to_message_id", strconv.FormatInt(*data.ReplyID, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Caption != "" {
|
||||||
|
writer.WriteField("caption", data.Caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writer.Close()
|
||||||
|
if checkerr("SendPhoto/writer.Close", err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP client and execute request
|
||||||
|
client := &http.Client{}
|
||||||
|
req, err := http.NewRequest("POST", t.apiURL("sendPhoto"), body)
|
||||||
|
if checkerr("SendPhoto/http.NewRequest", err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Add("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
_, err = client.Do(req)
|
||||||
|
checkerr("SendPhoto/http.Do", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Telegram) ForwardMessage(data tg.ClientForwardMessageData) {
|
||||||
|
postdata := url.Values{
|
||||||
|
"chat_id": {strconv.FormatInt(data.ChatID, 10)},
|
||||||
|
"from_chat_id": {strconv.FormatInt(data.FromChatID, 10)},
|
||||||
|
"message_id": {strconv.FormatInt(data.MessageID, 10)},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := http.PostForm(t.apiURL("forwardMessage"), postdata)
|
||||||
|
checkerr("ForwardMessage/http.PostForm", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Telegram) SendChatAction(data tg.ClientChatActionData) {
|
||||||
|
postdata := url.Values{
|
||||||
|
"chat_id": {strconv.FormatInt(data.ChatID, 10)},
|
||||||
|
"action": {string(data.Action)},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := http.PostForm(t.apiURL("sendChatAction"), postdata)
|
||||||
|
checkerr("SendChatAction/http.PostForm", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFile sends a "getFile" API call to Telegram's servers and fetches the file
|
||||||
|
// specified afterward. The file will be then send back to the client that requested it
|
||||||
|
// with the specified callback id.
|
||||||
|
func (t Telegram) GetFile(data tg.FileRequestData, client net.Conn, callback int) {
|
||||||
|
fail := func(msg string) {
|
||||||
|
errmsg, _ := json.Marshal(tg.BrokerUpdate{
|
||||||
|
Type: tg.BError,
|
||||||
|
Error: &msg,
|
||||||
|
Callback: &callback,
|
||||||
|
})
|
||||||
|
fmt.Fprintln(client, string(errmsg))
|
||||||
|
}
|
||||||
|
|
||||||
|
postdata := url.Values{
|
||||||
|
"file_id": {data.FileID},
|
||||||
|
}
|
||||||
|
resp, err := http.PostForm(t.apiURL("getFile"), postdata)
|
||||||
|
if checkerr("GetFile/post", err) {
|
||||||
|
fail("Server didn't like my request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var filespecs = struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
Result *tg.APIFile `json:"result,omitempty"`
|
||||||
|
}{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&filespecs)
|
||||||
|
if checkerr("GetFile/json.Decode", err) {
|
||||||
|
fail("Server sent garbage (or error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if filespecs.Result == nil {
|
||||||
|
fail("Server didn't send a file info, does the file exist?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := *filespecs.Result
|
||||||
|
|
||||||
|
path := APIEndpoint + "file/bot" + t.Token + "/" + *result.Path
|
||||||
|
fileresp, err := http.Get(path)
|
||||||
|
if checkerr("GetFile/get", err) {
|
||||||
|
fail("Could not retrieve file from Telegram's servers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer fileresp.Body.Close()
|
||||||
|
|
||||||
|
rawdata, err := ioutil.ReadAll(fileresp.Body)
|
||||||
|
if checkerr("GetFile/ioutil.ReadAll", err) {
|
||||||
|
fail("Could not read file data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawlen := len(rawdata)
|
||||||
|
if rawlen != *result.Size {
|
||||||
|
// ???
|
||||||
|
log.Printf("[GetFile] WARN ?? Downloaded file does not match provided filesize: %d != %d\n", rawlen, *result.Size)
|
||||||
|
}
|
||||||
|
b64data := base64.StdEncoding.EncodeToString(rawdata)
|
||||||
|
|
||||||
|
clientmsg, err := json.Marshal(tg.BrokerUpdate{
|
||||||
|
Type: tg.BFile,
|
||||||
|
Bytes: &b64data,
|
||||||
|
Callback: &callback,
|
||||||
|
})
|
||||||
|
if checkerr("GetFile/json.Marshal", err) {
|
||||||
|
fail("Could not serialize reply JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(client, string(clientmsg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Telegram) apiURL(method string) string {
|
||||||
|
return APIEndpoint + "bot" + t.Token + "/" + method
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkerr(method string, err error) bool {
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[%s] Error: %s\n", method, err.Error())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
33
cmd/tg-broker/webhook.go
Normal file
33
cmd/tg-broker/webhook.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/hamcha/clessy/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func webhook(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
defer req.Body.Close()
|
||||||
|
|
||||||
|
// Re-encode request to ensure conformity
|
||||||
|
var update tg.APIUpdate
|
||||||
|
err := json.NewDecoder(req.Body).Decode(&update)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("[webhook] Received incorrect request: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(tg.BrokerUpdate{
|
||||||
|
Type: tg.BMessage,
|
||||||
|
Message: &(update.Message),
|
||||||
|
Callback: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("[webhook] Cannot re-encode json (??) : " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(string(data))
|
||||||
|
}
|
103
command.go
Normal file
103
command.go
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
package tg
|
||||||
|
|
||||||
|
// BrokerUpdateType distinguishes update types coming from the broker
|
||||||
|
type BrokerUpdateType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BMessage is a message update (mostly webhook updates)
|
||||||
|
BMessage BrokerUpdateType = "message"
|
||||||
|
|
||||||
|
// BFile is a file retrieval response update
|
||||||
|
BFile BrokerUpdateType = "file"
|
||||||
|
|
||||||
|
// BError is an error the broker occurred while fulfilling a request
|
||||||
|
BError BrokerUpdateType = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BrokerUpdate is what is sent by the broker as update
|
||||||
|
type BrokerUpdate struct {
|
||||||
|
Type BrokerUpdateType
|
||||||
|
Callback *int `json:",omitempty"`
|
||||||
|
Error *string `json:",omitempty"`
|
||||||
|
Message *APIMessage `json:",omitempty"`
|
||||||
|
Bytes *string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientCommandType distinguishes requests sent by clients to the broker
|
||||||
|
type ClientCommandType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CmdSendTextMessage requests the broker to send a text message to a chat
|
||||||
|
CmdSendTextMessage ClientCommandType = "sendText"
|
||||||
|
|
||||||
|
// CmdSendPhoto requests the broker to send a photo to a chat
|
||||||
|
CmdSendPhoto ClientCommandType = "sendPhoto"
|
||||||
|
|
||||||
|
// CmdForwardMessage requests the broker to forward a message between chats
|
||||||
|
CmdForwardMessage ClientCommandType = "forwardMessage"
|
||||||
|
|
||||||
|
// CmdGetFile requests the broker to get a file from Telegram
|
||||||
|
CmdGetFile ClientCommandType = "getFile"
|
||||||
|
|
||||||
|
// CmdSendChatAction requests the broker to set a chat action (typing, etc.)
|
||||||
|
CmdSendChatAction ClientCommandType = "sendChatAction"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientTextMessageData is the required data for a CmdSendTextMessage request
|
||||||
|
type ClientTextMessageData struct {
|
||||||
|
ChatID int64
|
||||||
|
Text string
|
||||||
|
ReplyID *int64 `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientPhotoData is the required data for a CmdSendPhoto request
|
||||||
|
type ClientPhotoData struct {
|
||||||
|
ChatID int64
|
||||||
|
Bytes string
|
||||||
|
Filename string
|
||||||
|
Caption string `json:",omitempty"`
|
||||||
|
ReplyID *int64 `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientForwardMessageData is the required data for a CmdForwardMessage request
|
||||||
|
type ClientForwardMessageData struct {
|
||||||
|
ChatID int64
|
||||||
|
FromChatID int64
|
||||||
|
MessageID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientChatActionData is the required data for a CmdSendChatAction request
|
||||||
|
type ClientChatActionData struct {
|
||||||
|
ChatID int64
|
||||||
|
Action ChatAction
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatAction is the action name for CmdSendChatAction requests
|
||||||
|
type ChatAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionTyping ChatAction = "typing"
|
||||||
|
ActionUploadingPhoto ChatAction = "upload_photo"
|
||||||
|
ActionRecordingVideo ChatAction = "record_video"
|
||||||
|
ActionUploadingVideo ChatAction = "upload_video"
|
||||||
|
ActionRecordingAudio ChatAction = "record_audio"
|
||||||
|
ActionUploadingAudio ChatAction = "upload_audio"
|
||||||
|
ActionUploadingDocument ChatAction = "upload_document"
|
||||||
|
ActionFindingLocation ChatAction = "find_location"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileRequestData is the required data for a CmdGetFile request
|
||||||
|
type FileRequestData struct {
|
||||||
|
FileID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientCommand is a request sent by clients to the broker
|
||||||
|
type ClientCommand struct {
|
||||||
|
Type ClientCommandType
|
||||||
|
TextMessageData *ClientTextMessageData `json:",omitempty"`
|
||||||
|
PhotoData *ClientPhotoData `json:",omitempty"`
|
||||||
|
ForwardMessageData *ClientForwardMessageData `json:",omitempty"`
|
||||||
|
ChatActionData *ClientChatActionData `json:",omitempty"`
|
||||||
|
FileRequestData *FileRequestData `json:",omitempty"`
|
||||||
|
Callback *int `json:",omitempty"`
|
||||||
|
}
|
Loading…
Reference in a new issue