diff --git a/broker/main.go b/broker/main.go index 764cf56..df2469e 100644 --- a/broker/main.go +++ b/broker/main.go @@ -8,6 +8,7 @@ import ( "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 */ diff --git a/broker/telegram.go b/broker/telegram.go index a54df4e..ea5ef35 100644 --- a/broker/telegram.go +++ b/broker/telegram.go @@ -11,18 +11,22 @@ import ( "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", err) { @@ -42,6 +46,7 @@ func (t Telegram) SetWebhook(webhook string) { } } +// SendTextMessage sends an HTML-styled text message to a specified chat func (t Telegram) SendTextMessage(data tg.ClientTextMessageData) { postdata := url.Values{ "chat_id": {strconv.Itoa(data.ChatID)}, diff --git a/broker/webhook.go b/broker/webhook.go index 8cd037a..8e999d1 100644 --- a/broker/webhook.go +++ b/broker/webhook.go @@ -19,7 +19,11 @@ func webhook(rw http.ResponseWriter, req *http.Request) { return } - data, err := json.Marshal(update) + 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 diff --git a/mods/memegen.go b/mods/memegen.go index cd5dfd6..ea70c6c 100644 --- a/mods/memegen.go +++ b/mods/memegen.go @@ -1,14 +1,24 @@ package main import ( + "fmt" "strings" "github.com/hamcha/clessy/tg" ) func memegen(broker *tg.Broker, update tg.APIMessage) { - if update.Caption != nil { + if update.Photo != nil && update.Caption != nil { if strings.HasPrefix(*(update.Caption), "/meme") { + maxsz := 0 + photo := tg.APIPhotoSize{} + for _, curphoto := range update.Photo { + if curphoto.Width > maxsz { + maxsz = curphoto.Width + photo = curphoto + } + } + fmt.Println(photo) } } } diff --git a/mods/metafora.go b/mods/metafora.go index a76cc20..8469d1f 100644 --- a/mods/metafora.go +++ b/mods/metafora.go @@ -6,7 +6,7 @@ import ( "github.com/hamcha/clessy/tg" ) -var actions []string = []string{ +var actions = []string{ "Puppami", "Degustami", "Lucidami", "Manipolami", "Disidratami", "Irritami", "Martorizzami", "Lustrami", "Osannami", "Sorseggiami", "Assaporami", "Apostrofami", "Spremimi", "Dimenami", "Agitami", "Stimolami", "Suonami", "Strimpellami", "Stuzzicami", "Spintonami", "Sguinzagliami", @@ -14,7 +14,7 @@ var actions []string = []string{ "Accordami", "Debuggami", } -var objects []string = []string{ +var objects = []string{ "il birillo", "il bastone", "l'ombrello", "il malloppo", "il manico", "il manganello", "il ferro", "la mazza", "l'archibugio", "il timone", "l'arpione", "il flauto", "la reliquia", "il fiorino", "lo scettro", "il campanile", "la proboscide", "il pino", "il maritozzo", "il perno", diff --git a/tg/api.go b/tg/api.go index 567c1ff..a7e0cb5 100644 --- a/tg/api.go +++ b/tg/api.go @@ -1,5 +1,6 @@ package tg +// APIUser represents the "User" JSON structure type APIUser struct { UserID int `json:"id"` FirstName string `json:"first_name"` @@ -7,15 +8,24 @@ type APIUser struct { Username string `json:"username,omitempty"` } +// ChatType defines the type of chat type ChatType string const ( - ChatTypePrivate ChatType = "private" - ChatTypeGroup ChatType = "group" + // 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 ChatType = "channel" + + // ChatTypeChannel is a channel (Read-only) + ChatTypeChannel ChatType = "channel" ) +// APIChat represents the "Chat" JSON structure type APIChat struct { ChatID int `json:"id"` Type ChatType `json:"type"` @@ -25,6 +35,7 @@ type APIChat struct { LastName *string `json:"last_name,omitempty"` } +// APIMessage represents the "Message" JSON structure type APIMessage struct { MessageID int `json:"message_id"` User APIUser `json:"from"` @@ -43,7 +54,7 @@ type APIMessage struct { Caption *string `json:"caption,omitempty"` Contact *APIContact `json:"contact,omitempty"` Location *APILocation `json:"location,omitempty"` - NewUser *APIUser `json:"new_chat_partecipant",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"` @@ -53,6 +64,7 @@ type APIMessage struct { GroupFromSuper *int `json:"migrate_from_chat_id,omitempty"` } +// APIPhotoSize represents the "PhotoSize" JSON structure type APIPhotoSize struct { FileID string `json:"file_id"` Width int `json:"width"` @@ -60,6 +72,7 @@ type APIPhotoSize struct { FileSize *int `json:"file_size,omitempty"` } +// APIAudio represents the "Audio" JSON structure type APIAudio struct { FileID string `json:"file_id"` Duration int `json:"duration"` @@ -69,6 +82,7 @@ type APIAudio struct { 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"` @@ -77,6 +91,7 @@ type APIDocument struct { FileSize *int `json:"file_size,omitempty"` } +// APISticker represents the "Sticker" JSON structure type APISticker struct { FileID string `json:"file_id"` Width int `json:"width"` @@ -85,6 +100,7 @@ type APISticker struct { FileSize *int `json:"file_size,omitempty"` } +// APIVideo represents the "Video" JSON structure type APIVideo struct { FileID string `json:"file_id"` Width int `json:"width"` @@ -95,6 +111,7 @@ type APIVideo struct { FileSize *int `json:"file_size,omitempty"` } +// APIVoice represents the "Voice" JSON structure type APIVoice struct { FileID string `json:"file_id"` Duration int `json:"duration"` @@ -102,6 +119,7 @@ type APIVoice struct { 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"` @@ -109,16 +127,26 @@ type APIContact struct { UserID *int `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 int `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"` diff --git a/tg/broker.go b/tg/broker.go index 4fc2059..9ebd1d4 100644 --- a/tg/broker.go +++ b/tg/broker.go @@ -7,10 +7,15 @@ import ( "net" ) +// Broker is a broker connection handler with callback management functions type Broker struct { - Socket net.Conn + 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 { @@ -19,13 +24,18 @@ func ConnectToBroker(brokerAddr string) (*Broker, error) { 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 specific chat +// A reply_to message ID can be specified as optional parameter func (b *Broker) SendTextMessage(chat *APIChat, text string, original *int) { cmd := ClientCommand{ Type: CmdSendTextMessage, @@ -42,3 +52,68 @@ func (b *Broker) SendTextMessage(chat *APIChat, text string, original *int) { } fmt.Fprintln(b.Socket, string(data)) } + +// 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) + // Make file request + 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) 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 + } + } +} diff --git a/tg/client.go b/tg/client.go index a8ebdc0..95fe387 100644 --- a/tg/client.go +++ b/tg/client.go @@ -7,8 +7,14 @@ import ( "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 { @@ -23,7 +29,7 @@ func CreateBrokerClient(brokerAddr string, updateFn UpdateHandler) error { break } - var update APIUpdate + var update BrokerUpdate err = json.Unmarshal(bytes, &update) if err != nil { log.Printf("[tg - CreateBrokerClient] ERROR reading JSON: %s\r\n", err.Error()) @@ -31,8 +37,13 @@ func CreateBrokerClient(brokerAddr string, updateFn UpdateHandler) error { continue } - // Dispatch to UpdateHandler - updateFn(broker, update.Message) + 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) + } } return io.EOF } diff --git a/tg/client_test.go b/tg/client_test.go new file mode 100644 index 0000000..4c4595e --- /dev/null +++ b/tg/client_test.go @@ -0,0 +1,16 @@ +package tg + +// This function 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 ExampleHelloClient() { + 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) + } + } + }) +} diff --git a/tg/command.go b/tg/command.go index f12b343..0c6a12c 100644 --- a/tg/command.go +++ b/tg/command.go @@ -1,18 +1,54 @@ package tg -type ClientCommandType uint +// BrokerUpdateType distinguishes update types coming from the broker +type BrokerUpdateType string const ( - CmdSendTextMessage ClientCommandType = 1 + // BMessage is a message update (mostly webhook updates) + BMessage BrokerUpdateType = "message" + + // BFile is a file retrieval response update + BFile BrokerUpdateType = "file" ) +// BrokerUpdate is what is sent by the broker as update +type BrokerUpdate struct { + Type BrokerUpdateType + Callback *int + Message *APIMessage + Bytes []byte +} + +// 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" + + // CmdGetFile requests the broker to get a file from Telegram + CmdGetFile ClientCommandType = "getFile" +) + +// ClientTextMessageData is the required data for a CmdSendTextMessage request type ClientTextMessageData struct { ChatID int Text string ReplyID *int } +// FileRequestData is the required data for a CmdGetFile request +type FileRequestData struct { + FileID int +} + +// ClientCommand is a request sent by clients to the broker type ClientCommand struct { Type ClientCommandType TextMessageData *ClientTextMessageData + FileRequestData *FileRequestData + Callback *int }