lelegram: init

This is an IRC/Telegram bridge.

It does multi-account puppet-like access to IRC making everyone's life
easier.

Compared to teleirc it also:
 - is smarter about converting messages
 - uses teleimg for public image access
 - is not written in JS

Experimental for now.

Change-Id: I66ba3f83abdfdea6463ab3be5380d8d3f2769291
This commit is contained in:
q3k 2020-01-05 00:34:38 +01:00
parent c315aaccc7
commit a885488fd0
12 changed files with 1221 additions and 4 deletions

View file

@ -1904,3 +1904,21 @@ go_repository(
remote = "https://github.com/ulule/limiter",
vcs = "git",
)
go_repository(
name = "com_github_go_telegram_bot_api_telegram_bot_api",
commit = "b33efeebc78563cfeddf19563781cffb16aaabdf",
importpath = "github.com/go-telegram-bot-api/telegram-bot-api",
)
go_repository(
name = "com_github_technoweenie_multipartstreamer",
commit = "a90a01d73ae432e2611d178c18367fbaa13e0154",
importpath = "github.com/technoweenie/multipartstreamer",
)
go_repository(
name = "in_gopkg_irc_v3",
commit = "d07dcb9293789fdc99c797d3499a5799bc343b86",
importpath = "gopkg.in/irc.v3",
)

View file

@ -19,7 +19,8 @@ local teleimg = import "teleimg.libsonnet";
components: {
smsgw: smsgw.component(cfg.smsgw, env),
ldapweb: ldapweb.component(cfg.ldapweb, env),
teleimg: teleimg.component(cfg.teleimg, env),
teleimg: teleimg.teleimg(cfg.teleimg, env),
lelegram: teleimg.lelegram(cfg.teleimg, env),
},
},

View file

@ -6,14 +6,21 @@ local kube = import "../../kube/kube.libsonnet";
secret: {
telegram_token: error "telegram_token must be set",
},
image: "registry.k0.hswaw.net/q3k/teleimg:1578240550-1525c84e4cef4f382e2dca2210f31830533dc7c4",
image: {
teleimg: "registry.k0.hswaw.net/q3k/teleimg:1578243230-79e4e790f877597c3175823ee7783eb99744dc27",
lelegram: "registry.k0.hswaw.net/q3k/lelegram:1578253551-79e4e790f877597c3175823ee7783eb99744dc27",
},
bridge: {
telegram: "-1001345766954",
irc: "#hackerspace-krk",
},
webFQDN: error "webFQDN must be set!",
},
component(cfg, env):: mirko.Component(env, "teleimg") {
teleimg(cfg, env):: mirko.Component(env, "teleimg") {
local teleimg = self,
cfg+: {
image: cfg.image,
image: cfg.image.teleimg,
container: teleimg.GoContainer("main", "/teleimg/teleimg") {
env_: {
TELEGRAM_TOKEN: kube.SecretKeyRef(teleimg.secret, "telegram_token"),
@ -38,4 +45,27 @@ local kube = import "../../kube/kube.libsonnet";
data: cfg.secret,
},
},
lelegram(cfg, env):: mirko.Component(env, "lelegram") {
local lelegram = self,
cfg+: {
image: cfg.image.lelegram,
container: lelegram.GoContainer("main", "/lelegram/lelegram") {
env_: {
TELEGRAM_TOKEN: kube.SecretKeyRef(lelegram.secret, "telegram_token"),
},
command+: [
"-telegram_token", "$(TELEGRAM_TOKEN)",
"-telegram_chat", cfg.bridge.telegram,
"-irc_channel", cfg.bridge.irc,
"-irc_max_connections", "10",
],
},
},
secret: kube.Secret("lelegram") {
metadata+: lelegram.metadata,
data: cfg.secret,
},
},
}

View file

@ -0,0 +1,49 @@
load("@io_bazel_rules_docker//container:container.bzl", "container_image", "container_layer", "container_push")
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "go_default_library",
srcs = [
"main.go",
"telegram.go",
],
importpath = "code.hackerspace.pl/hscloud/personal/q3k/lelegram",
visibility = ["//visibility:private"],
deps = [
"//go/mirko:go_default_library",
"//personal/q3k/lelegram/irc:go_default_library",
"@com_github_go_telegram_bot_api_telegram_bot_api//:go_default_library",
"@com_github_golang_glog//:go_default_library",
],
)
go_binary(
name = "lelegram",
embed = [":go_default_library"],
visibility = ["//visibility:public"],
)
container_layer(
name = "layer_bin",
files = [
":lelegram",
],
directory = "/lelegram/",
)
container_image(
name = "runtime",
base = "@prodimage-bionic//image",
layers = [
":layer_bin",
],
)
container_push(
name = "push",
image = ":runtime",
format = "Docker",
registry = "registry.k0.hswaw.net",
repository = "q3k/lelegram",
tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
)

View file

@ -0,0 +1,18 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "go_default_library",
srcs = [
"conn.go",
"manager.go",
"manager_conns.go",
"manager_control.go",
"manager_event.go",
],
importpath = "code.hackerspace.pl/hscloud/personal/q3k/lelegram/irc",
visibility = ["//personal/q3k/lelegram:__pkg__"],
deps = [
"@com_github_golang_glog//:go_default_library",
"@in_gopkg_irc_v3//:go_default_library",
],
)

View file

@ -0,0 +1,282 @@
package irc
import (
"context"
"fmt"
"net"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/golang/glog"
irc "gopkg.in/irc.v3"
)
// ircconn is a connection to IRC as a given user.
type ircconn struct {
// server to connect to
server string
// channel to join
channel string
// 'native' name of this connection.
user string
// Event Handler, usually a Manager
eventHandler func(e *event)
// TCP connection to IRC
conn net.Conn
// IRC client
irc *irc.Client
/// Fields used by the manager - do not access from ircconn.
// last time this connection was used
last time.Time
// is primary source of IRC data
receiver bool
// only exists to be a receiver
backup bool
// iq is the IRC Queue of IRC messages, populated by the IRC client and
// read by the connection.
iq chan *irc.Message
// sq is the Say Queue of controlMessages, populated by the Manager and
// read by the connection (and passed onto IRC)
sq chan *controlMessage
// eq is the Evict Queue, used by the manager to signal that a connection
// should die.
eq chan struct{}
// connected is a flag (via sync/atomic) that is used to signal to the
// manager that this connection is up and healthy.
connected int64
}
// Say is called by the Manager when a message should be sent out by the
// connection.
func (i *ircconn) Say(msg *controlMessage) {
i.sq <- msg
}
// Evict is called by the Manager when a connection should die.
func (i *ircconn) Evict() {
close(i.eq)
}
// ircMessage is a message received on IRC by a connection, sent over to the
// Manager.
type IRCMessage struct {
conn *ircconn
nick string
text string
}
func NewConn(server, channel, user string, backup bool, h func(e *event)) (*ircconn, error) {
glog.Infof("Connecting to IRC/%s/%s/%s...", server, channel, user)
conn, err := net.Dial("tcp", server)
if err != nil {
return nil, fmt.Errorf("Dial(_, %q): %v", server, err)
}
i := &ircconn{
server: server,
channel: channel,
user: user,
eventHandler: h,
conn: conn,
irc: nil,
last: time.Now(),
backup: backup,
receiver: backup,
iq: make(chan *irc.Message),
sq: make(chan *controlMessage),
eq: make(chan struct{}),
connected: int64(0),
}
// Generate IRC nick from username.
nick := user
if len(nick) > 13 {
nick = nick[:13]
}
nick += "[t]"
// Configure IRC client to populate the IRC Queue.
config := irc.ClientConfig{
Nick: nick,
User: user,
Name: user,
Handler: irc.HandlerFunc(func(c *irc.Client, m *irc.Message) {
i.iq <- m
}),
}
i.irc = irc.NewClient(conn, config)
return i, nil
}
func (i *ircconn) Run(ctx context.Context) {
var wg sync.WaitGroup
wg.Add(2)
go func() {
i.loop(ctx)
wg.Done()
}()
go func() {
err := i.irc.RunContext(ctx)
if err != ctx.Err() {
glog.Errorf("IRC/%s/%s/%s exited: %v", i.server, i.channel, i.user, err)
i.conn.Close()
i.eventHandler(&event{
dead: &eventDead{i},
})
}
wg.Wait()
}()
wg.Wait()
}
// IsConnected returns whether a connection is fully alive and able to receive
// messages.
func (i *ircconn) IsConnected() bool {
return atomic.LoadInt64(&i.connected) > 0
}
// loop is the main loop of an IRC connection.
// It synchronizes the Handler Queue, Say Queue and Evict Queue, parses
func (i *ircconn) loop(ctx context.Context) {
sayqueue := []*controlMessage{}
connected := false
dead := false
die := func() {
dead = true
i.conn.Close()
go i.eventHandler(&event{
dead: &eventDead{i},
})
}
msg := func(s *controlMessage) {
lines := strings.Split(s.message, "\n")
for _, l := range lines {
l = strings.TrimSpace(l)
if l == "" {
continue
}
err := i.irc.WriteMessage(&irc.Message{
Command: "PRIVMSG",
Params: []string{
i.channel,
l,
},
})
if err != nil {
glog.Errorf("IRC/%s: WriteMessage: %v", i.user, err)
die()
s.done <- err
return
}
}
s.done <- nil
}
// Timeout ticker - give up connecting to IRC after 15 seconds.
t := time.NewTicker(time.Second * 15)
previousNick := ""
for {
select {
case <-ctx.Done():
return
case <-i.eq:
glog.Infof("IRC/%s/info: got evicted", i.user)
die()
return
case m := <-i.iq:
if m.Command != "372" {
glog.V(1).Infof("IRC/%s/debug: %+v", i.user, m)
}
switch {
case m.Command == "001":
glog.Infof("IRC/%s/info: joining %s...", i.user, i.channel)
i.irc.Write("JOIN " + i.channel)
case m.Command == "353":
glog.Infof("IRC/%s/info: joined and ready", i.user)
connected = true
atomic.StoreInt64(&i.connected, 1)
// drain queue of say messages...
for _, s := range sayqueue {
glog.Infof("IRC/%s/say: [backlog] %q", i.user, s.message)
msg(s)
}
sayqueue = []*controlMessage{}
case m.Command == "474":
// We are banned! :(
glog.Infof("IRC/%s/info: banned!", i.user)
go i.eventHandler(&event{
banned: &eventBanned{i},
})
// drain queue of say messages...
for _, s := range sayqueue {
glog.Infof("IRC/%s/say: [drop] %q", i.user, s.message)
s.done <- nil
}
sayqueue = []*controlMessage{}
die()
return
case m.Command == "KICK" && m.Params[1] == i.irc.CurrentNick():
glog.Infof("IRC/%s/info: got kicked", i.user)
die()
return
case m.Command == "PRIVMSG" && m.Params[0] == i.channel:
go i.eventHandler(&event{
message: &eventMessage{i, m.Prefix.Name, m.Params[1]},
})
}
// update nickmap if needed
nick := i.irc.CurrentNick()
if previousNick != nick {
i.eventHandler(&event{
nick: &eventNick{i, nick},
})
previousNick = nick
}
case s := <-i.sq:
if dead {
glog.Infof("IRC/%s/say: [DEAD] %q", i.user, s.message)
s.done <- fmt.Errorf("connection is dead")
} else if connected {
glog.Infof("IRC/%s/say: %s", i.user, s.message)
msg(s)
} else {
glog.Infof("IRC/%s/say: [writeback] %q", i.user, s.message)
sayqueue = append(sayqueue, s)
}
case <-t.C:
if !connected {
glog.Errorf("IRC/%s/info: connection timed out, dying", i.user)
die()
return
}
}
}
}

View file

@ -0,0 +1,161 @@
package irc
import (
"context"
"time"
"github.com/golang/glog"
)
// Manager maintains a set of IRC connections to a server and channel. Its has
// three interfaces to the outside world:
// - control, from the owner of Manager (eg. a bridge to another protocol)
// that allows sending messages as a given user and to subscribe to
// notifications
// - events, from IRC connections, to update the manager about a connection
// state (lifecycle or nick change)
// - subscriptions, that pass received messages from IRC to a channel requested
// by control.
//
// The Manager will maintain exactly one 'receiver', which is an IRC connection
// that is used as a source of truth for messages on an IRC channel. This will
// either be an existing connection for a user, or a 'backup' connection that
// will close as soon as a real/named connection exists and is fully connected.
type Manager struct {
// maximum IRC sessions to maintain
max int
// IRC server address
server string
// IRC channel name
channel string
// control channel (from owner)
ctrl chan *control
// event channel (from connections)
event chan *event
// map from user name to IRC connection
conns map[string]*ircconn
// map from user name to IRC nick
nickmap map[string]string
// set of users that we shouldn't attempt to bridge, and their expiry times
shitlist map[string]time.Time
// set of subscribing channels for notifications
subscribers map[chan *Notification]bool
// context representing the Manager lifecycle
runctx context.Context
}
func NewManager(max int, server, channel string) *Manager {
return &Manager{
max: max,
server: server,
channel: channel,
ctrl: make(chan *control),
event: make(chan *event),
}
}
// Notifications are sent to subscribers when things happen on IRC
type Notification struct {
// A new message appeared on the channel
Message *NotificationMessage
// Nicks of our connections have changed
Nickmap *map[string]string
}
// NotificationMessage is a message that happened in the connected IRC channel
type NotificationMessage struct {
// Nick is the IRC nickname of the sender
Nick string
// Message is the plaintext message from IRC
Message string
}
// Run maintains the main logic of the Manager - servicing control and event
// messages, and ensuring there is a receiver on the given channel.
func (m *Manager) Run(ctx context.Context) {
m.conns = make(map[string]*ircconn)
m.nickmap = make(map[string]string)
m.shitlist = make(map[string]time.Time)
m.subscribers = make(map[chan *Notification]bool)
m.runctx = context.Background()
glog.Infof("IRC Manager %s/%s running...", m.server, m.channel)
t := time.NewTicker(1 * time.Second)
for {
select {
case <-ctx.Done():
return
case c := <-m.ctrl:
m.doctrl(ctx, c)
case e := <-m.event:
m.doevent(ctx, e)
case <-t.C:
}
m.ensureReceiver(ctx)
}
}
// ensureReceiver ensures that there is exactly one 'receiver' IRC connection,
// possibly creating a backup receiver if needed.
func (m *Manager) ensureReceiver(ctx context.Context) {
// Ensure backup listener does not exist if there is a named connection
active := 0
for _, c := range m.conns {
if !c.IsConnected() {
continue
}
active += 1
}
if active > 1 {
var backup *ircconn
for _, c := range m.conns {
if c.backup {
backup = c
}
}
if backup != nil {
glog.Infof("Evicting backup listener")
backup.Evict()
delete(m.conns, backup.user)
}
}
// Ensure there exists exactly one reciever
count := 0
for _, c := range m.conns {
if !c.IsConnected() && !c.backup {
c.receiver = false
continue
}
if c.receiver {
count += 1
}
if count >= 2 {
c.receiver = false
}
}
// No receivers? make one.
if count == 0 {
if len(m.conns) == 0 {
// Noone said anything on telegram, make backup
glog.Infof("No receiver found, making backup")
name := "lelegram"
c, err := m.newconn(ctx, name, true)
if err != nil {
glog.Errorf("Could not make backup receiver: %v", err)
} else {
m.conns[name] = c
}
} else {
// Make first conn a receiver
glog.Infof("No receiver found, using conn")
for _, v := range m.conns {
glog.Infof("Elected %s for receiver", v.user)
v.receiver = true
}
}
}
}

View file

@ -0,0 +1,64 @@
package irc
import (
"context"
"fmt"
"time"
"github.com/golang/glog"
)
var (
errBanned = fmt.Errorf("user is shitlisted")
)
// getconn either gets a connection by username, or creates a new one (after
// evicting the least recently used connection).
func (m *Manager) getconn(ctx context.Context, user string) (*ircconn, error) {
// Is the user shitlisted?
if t, ok := m.shitlist[user]; ok && time.Now().Before(t) {
return nil, errBanned
}
// Do we already have a connection?
c, ok := m.conns[user]
if ok {
// Bump and return.
c.last = time.Now()
return c, nil
}
// Are we at the limit of allowed connections?
if len(m.conns) >= m.max {
// Evict least recently used
evict := ""
lru := time.Now()
for _, c := range m.conns {
if c.last.Before(lru) {
evict = c.user
lru = c.last
}
}
if evict == "" {
glog.Exitf("could not find connection to evict, %v", m.conns)
}
m.conns[evict].Evict()
delete(m.conns, evict)
}
// Allocate new connection
return m.newconn(ctx, user, false)
}
// newconn creates a new IRC connection as a given user, and saves it to the
// conns map.
func (m *Manager) newconn(ctx context.Context, user string, backup bool) (*ircconn, error) {
c, err := NewConn(m.server, m.channel, user, backup, m.Event)
if err != nil {
return nil, err
}
m.conns[user] = c
go c.Run(m.runctx)
return c, nil
}

View file

@ -0,0 +1,85 @@
package irc
import (
"context"
"fmt"
"github.com/golang/glog"
)
// Control: send a message to IRC.
func (m *Manager) SendMessage(user, text string) error {
done := make(chan error)
m.ctrl <- &control{
message: &controlMessage{
from: user,
message: text,
done: done,
},
}
return <-done
}
// Control: subscribe to notifiactions.
func (m *Manager) Subscribe(c chan *Notification) {
m.ctrl <- &control{
subscribe: &controlSubscribe{
c: c,
},
}
}
// control message from owner. Only one member can be set.
type control struct {
// message needs to be send to IRC
message *controlMessage
// a new subscription channel for notifications is presented
subscribe *controlSubscribe
}
// controlMessage is a request to send a message to IRC as a given user
type controlMessage struct {
// user name (native to application)
from string
// plaintext message
message string
// channel that will be sent nil or an error when the message has been
// succesfully sent or an error occured
done chan error
}
// controlSubscribe is a request to send notifications to a given channel
type controlSubscribe struct {
c chan *Notification
}
// doctrl processes a given control message.
func (m *Manager) doctrl(ctx context.Context, c *control) {
switch {
case c.message != nil:
// Send a message to IRC.
// Find a relevant connection, or make one.
conn, err := m.getconn(ctx, c.message.from)
if err != nil {
// Do not attempt to redeliver bans.
if err == errBanned {
c.message.done <- nil
} else {
c.message.done <- fmt.Errorf("getting connection: %v", err)
}
return
}
// Route message to connection.
conn.Say(c.message)
case c.subscribe != nil:
// Subscribe to notifications.
m.subscribers[c.subscribe.c] = true
default:
glog.Errorf("unhandled control %+v", c)
}
}

View file

@ -0,0 +1,153 @@
package irc
import (
"context"
"time"
"github.com/golang/glog"
)
func (m *Manager) Event(e *event) {
m.event <- e
}
// Event: a connection has a new nick.
func (m *Manager) UpdateNickmap(conn *ircconn, nick string) {
m.event <- &event{
nick: &eventNick{
conn: conn,
nick: nick,
},
}
}
// Event: mark a given connection as dead.
func (m *Manager) MarkDead(i *ircconn) {
m.event <- &event{
dead: &eventDead{
conn: i,
},
}
}
// event message from IRC connections. Only one member can be set.
type event struct {
// a connection has gotten a (new) nick
nick *eventNick
// a connection received a new PRIVMSG
message *eventMessage
// a connection is banned
banned *eventBanned
// a connection died
dead *eventDead
}
// eventNick is emitted when a connection has received a new nickname from IRC
type eventNick struct {
conn *ircconn
nick string
}
// eventMessage is emitted when there is a PRIVMSG to the IRC channel. This
// does not contain messages sent by ourselves, and messages are deduplicated
// from multiple active IRC connections.
type eventMessage struct {
conn *ircconn
nick string
message string
}
// eventBanned is amitted when a connection is banned from a channel.
type eventBanned struct {
conn *ircconn
}
// eventDead is emitted when a connection has died and needs to be disposed of
type eventDead struct {
conn *ircconn
}
func (m *Manager) notifyAll(n *Notification) {
for s, _ := range m.subscribers {
go func(c chan *Notification, n *Notification) {
c <- n
}(s, n)
}
}
// doevent handles incoming events.
func (m *Manager) doevent(ctx context.Context, e *event) {
switch {
case e.nick != nil:
// Nick update from connection
// Ensure this connection is still used.
if m.conns[e.nick.conn.user] != e.nick.conn {
return
}
// Edge-detect changes.
changed := false
if m.nickmap[e.nick.conn.user] != e.nick.nick {
glog.Infof("Event: Nick change for %s: %q -> %q", e.nick.conn.user, m.nickmap[e.nick.conn.user], e.nick.nick)
m.nickmap[e.nick.conn.user] = e.nick.nick
changed = true
}
if !changed {
return
}
// Notify subscribers about a new nickmap.
nm := make(map[string]string)
for k, v := range m.nickmap {
nm[k] = v
}
m.notifyAll(&Notification{
Nickmap: &nm,
})
case e.banned != nil:
// A connection is banned. Shitlist the given user to not retry again.
user := e.banned.conn.user
glog.Infof("Event: %s is banned!", user)
m.shitlist[user] = time.Now().Add(time.Hour)
case e.dead != nil:
// Dead update from connection.
// Ensure this connection is still used.
if m.conns[e.dead.conn.user] != e.dead.conn {
return
}
// Delete connection.
glog.Infof("Event: Connection for %s died", e.dead.conn.user)
delete(m.conns, e.dead.conn.user)
case e.message != nil:
// Route messages from receivers.
// Drop non-receiver events.
if !e.message.conn.receiver {
return
}
// Ensure this is not from us.
for _, i := range m.nickmap {
if e.message.nick == i {
return
}
}
m.notifyAll(&Notification{
Message: &NotificationMessage{
Nick: e.message.nick,
Message: e.message.message,
},
})
default:
glog.Errorf("Event: Unhandled event %+v", e)
}
}

View file

@ -0,0 +1,187 @@
package main
import (
"context"
"flag"
"fmt"
"strconv"
"strings"
"code.hackerspace.pl/hscloud/go/mirko"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
"github.com/golang/glog"
"code.hackerspace.pl/hscloud/personal/q3k/lelegram/irc"
)
func init() {
flag.Set("logtostderr", "true")
}
var (
flagTelegramToken string
flagTelegramChat string
flagTeleimgRoot string
flagIRCMaxConnections int
flagIRCServer string
flagIRCChannel string
)
// server is responsible for briding IRC and Telegram.
type server struct {
// groupId is the Telegram Group ID to bridge.
groupId int64
tel *tgbotapi.BotAPI
mgr *irc.Manager
// backlog from telegram
telLog chan *telegramPlain
// backlog from IRC
ircLog chan *irc.Notification
}
// telegramPlain is a plaintext telegram message - ie. one that's ready to send
// to IRC, possibly in mutliple lines.
type telegramPlain struct {
// Telegram name that sent message - without '@'.
user string
// Plain text of message, possibly multiline.
text string
}
func newServer(groupId int64, mgr *irc.Manager) (*server, error) {
tel, err := tgbotapi.NewBotAPI(flagTelegramToken)
if err != nil {
return nil, fmt.Errorf("when creating telegram bot: %v", err)
}
glog.Infof("Authorized with Telegram as %q", tel.Self.UserName)
return &server{
groupId: groupId,
tel: tel,
mgr: mgr,
telLog: make(chan *telegramPlain),
ircLog: make(chan *irc.Notification),
}, nil
}
func main() {
flag.StringVar(&flagTelegramToken, "telegram_token", "", "Telegram Bot API Token")
flag.StringVar(&flagTelegramChat, "telegram_chat", "", "Telegram chat/group ID to bridge. If not given, bridge will start in lame mode and allow you to find out IDs of groups which the bridge bot is part of")
flag.StringVar(&flagTeleimgRoot, "teleimg_root", "https://teleimg.hswaw.net/fileid/", "Root URL of teleimg file serving URL")
flag.IntVar(&flagIRCMaxConnections, "irc_max_connections", 10, "How many simulataneous connections can there be to IRC before they get recycled")
flag.StringVar(&flagIRCServer, "irc_server", "chat.freenode.net:6667", "The address (with port) of the IRC server to connect to")
flag.StringVar(&flagIRCChannel, "irc_channel", "", "The channel name (including hash(es)) to bridge")
flag.Parse()
if flagTelegramToken == "" {
glog.Exitf("telegram_token must be set")
}
if flagIRCChannel == "" {
glog.Exitf("irc_channel must be set")
}
// Parse given group ID.
// If not set, start server in 'lame' mode, ie. one that will not actually
// perform any bridging, but will let you figure out the IDs of groups that
// this bot is part of.
var groupId int64
if flagTelegramChat == "" {
glog.Warningf("telegram_chat NOT GIVEN, STARTING IN LAME MODE")
glog.Warningf("Watch for logs to find out the ID of groups which this bot is part of. Then, restart the bot with telegram_chat set.")
} else {
g, err := strconv.ParseInt(flagTelegramChat, 10, 64)
if err != nil {
glog.Exitf("telegram_chat must be a number")
}
groupId = g
}
m := mirko.New()
if err := m.Listen(); err != nil {
glog.Exitf("Listen(): %v", err)
}
mgr := irc.NewManager(flagIRCMaxConnections, flagIRCServer, flagIRCChannel)
s, err := newServer(groupId, mgr)
if err != nil {
glog.Exitf("newServer(): %v", err)
}
if err := m.Serve(); err != nil {
glog.Exitf("Serve(): %v", err)
}
ctx := m.Context()
// Start IRC manager
go mgr.Run(ctx)
// Start piping Telegram messages into telLog
go s.telegramLoop(ctx)
// Start piping IRC messages into ircLog
mgr.Subscribe(s.ircLog)
// Start message processing bridge (connecting telLog and ircLog)
go s.bridge(ctx)
<-m.Done()
}
// bridge connects telLog with ircLog, exchanging messages both ways and
// performing nick translation given an up-to-date nickmap.
func (s *server) bridge(ctx context.Context) {
nickmap := make(map[string]string)
for {
select {
case <-ctx.Done():
return
case m := <-s.telLog:
// Event from Telegram (message). Translate Telegram names into IRC names.
text := m.text
for t, i := range nickmap {
text = strings.ReplaceAll(text, "@"+t, i)
}
glog.Infof("telegram/%s: %v", m.user, text)
// Attempt to route message to IRC twice.
// This blocks until success or failure, making sure the log stays
// totally ordered in the face of some of our IRC connections being
// dead/slow.
err := s.mgr.SendMessage(m.user, text)
if err != nil {
glog.Warningf("Attempting redelivery of %v after error: %v...", m, err)
err = s.mgr.SendMessage(m.user, text)
glog.Errorf("Redelivery of %v failed: %v...", m, err)
}
case n := <-s.ircLog:
// Notification from IRC (message or new nickmap)
switch {
case n.Nickmap != nil:
// Nicks on IRC changed.
for k, v := range *n.Nickmap {
nickmap[k] = v
}
glog.Infof("New nickmap: %v", nickmap)
case n.Message != nil:
// New IRC message. Translate IRC names into Telegram names.
text := n.Message.Message
for t, i := range nickmap {
text = strings.ReplaceAll(text, i, "@"+t)
}
// And send message to Telegram.
msg := tgbotapi.NewMessage(s.groupId, fmt.Sprintf("<%s> %s", n.Message.Nick, text))
s.tel.Send(msg)
}
}
}
}

View file

@ -0,0 +1,169 @@
package main
import (
"context"
"fmt"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
"github.com/golang/glog"
)
// telegramConnection runs a long-lived connection to the Telegram API to receive
// updates and pipe resulting messages into telLog.
func (s *server) telegramConnection(ctx context.Context) error {
u := tgbotapi.NewUpdate(0)
// TODO(q3k): figure out what the _fuck_ does this even mean
u.Timeout = 60
updates, err := s.tel.GetUpdatesChan(u)
if err != nil {
return fmt.Errorf("GetUpdatesChan(%+v): %v", u, err)
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case u, ok := <-updates:
if !ok {
return fmt.Errorf("Updates channel closed")
}
// Dispatch update.
switch {
case u.Message != nil:
if u.Message.Chat.ID != s.groupId {
glog.Infof("[ignored group %d] <%s> %v", u.Message.Chat.ID, u.Message.From, u.Message.Text)
continue
}
if msg := plainFromTelegram(s.tel.Self.ID, &u); msg != nil {
s.telLog <- msg
}
}
}
}
}
// telegramLoop maintains a telegramConnection.
func (s *server) telegramLoop(ctx context.Context) {
for {
err := s.telegramConnection(ctx)
if err == ctx.Err() {
glog.Infof("Telegram connection closing: %v", err)
return
}
glog.Errorf("Telegram connection error: %v", err)
select {
case <-ctx.Done():
return
case <-time.After(1 * time.Second):
continue
}
}
}
// plainFromTelegram turns a Telegram message into a plain text message.
func plainFromTelegram(selfID int, u *tgbotapi.Update) *telegramPlain {
parts := []string{}
from := u.Message.From
replyto := u.Message.ReplyToMessage
text := u.Message.Text
// This message is in reply to someone.
if replyto != nil && text != "" && replyto.From != nil {
// The rendered name of the author of the quote.
ruid := "@" + replyto.From.String()
// First line of the quoted text.
quotedLine := ""
// Check if the quoted message is from our bridge.
if replyto.From.ID == selfID {
// Someone replied to an IRC bridge message, extract nick and line from there
// eg: "<q3k> foo bar baz" -> ruid = q3k; quotedLine = foo bar baz
t := replyto.Text
if strings.HasPrefix(t, "<") {
p := strings.SplitN(t[1:], ">", 2)
nick := p[0]
quoted := strings.TrimSpace(p[1])
// ensure nick looks sane
if len(nick) < 16 && len(strings.Fields(nick)) == 1 {
quotedLine = strings.TrimSpace(strings.Split(quoted, "\n")[0])
ruid = nick
}
}
} else {
// Someone replied to a native telegram message.
quoted := strings.TrimSpace(replyto.Text)
quotedLine = strings.TrimSpace(strings.Split(quoted, "\n")[0])
}
// If we have a line, quote it. Otherwise just refer to the nick without a quote.
if quotedLine != "" {
parts = append(parts, fmt.Sprintf("%s: >%s\n", ruid, quotedLine))
} else {
parts = append(parts, fmt.Sprintf("%s: ", ruid))
}
}
// This message contains a sticker.
if u.Message.Sticker != nil {
emoji := ""
if u.Message.Sticker.SetName != "" {
emoji += "/" + u.Message.Sticker.SetName
}
if u.Message.Sticker.Emoji != "" {
emoji += "/" + u.Message.Sticker.Emoji
}
parts = append(parts, fmt.Sprintf("<sticker%s>", emoji))
}
// This message contains an animation.
if u.Message.Animation != nil {
a := u.Message.Animation
parts = append(parts, fmt.Sprintf("<uploaded animation: %s >\n", fileURL(a.FileID, "mp4")))
}
// This message contains a document.
if u.Message.Document != nil {
d := u.Message.Document
fnp := strings.Split(d.FileName, ".")
ext := "bin"
if len(fnp) > 1 {
ext = fnp[len(fnp)-1]
}
parts = append(parts, fmt.Sprintf("<uploaded file: %s >\n", fileURL(d.FileID, ext)))
}
// This message contains a photo.
if u.Message.Photo != nil {
// Multiple entries are for different file sizes, choose the highest quality one.
hq := (*u.Message.Photo)[0]
for _, p := range *u.Message.Photo {
if p.FileSize > hq.FileSize {
hq = p
}
}
parts = append(parts, fmt.Sprintf("<uploaded photo: %s >\n", fileURL(hq.FileID, "jpg")))
}
// This message has some plain text.
if text != "" {
parts = append(parts, text)
}
// Was there anything that we extracted?
if len(parts) > 0 {
return &telegramPlain{from.String(), strings.Join(parts, " ")}
}
return nil
}
func fileURL(fid, ext string) string {
return flagTeleimgRoot + fid + "." + ext
}