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
diff --git a/WORKSPACE b/WORKSPACE
index 391980c..0d69a6e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1904,3 +1904,21 @@
     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",
+)
diff --git a/hswaw/kube/hswaw.jsonnet b/hswaw/kube/hswaw.jsonnet
index 49c9aa3..f550176 100644
--- a/hswaw/kube/hswaw.jsonnet
+++ b/hswaw/kube/hswaw.jsonnet
@@ -19,7 +19,8 @@
         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),
         },
     },
 
diff --git a/hswaw/kube/teleimg.libsonnet b/hswaw/kube/teleimg.libsonnet
index 58026fb..29f0c2d 100644
--- a/hswaw/kube/teleimg.libsonnet
+++ b/hswaw/kube/teleimg.libsonnet
@@ -6,14 +6,21 @@
         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 @@
             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,
+        },
+    },
 }
diff --git a/personal/q3k/lelegram/BUILD.bazel b/personal/q3k/lelegram/BUILD.bazel
new file mode 100644
index 0000000..87d41b8
--- /dev/null
+++ b/personal/q3k/lelegram/BUILD.bazel
@@ -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}",
+)
diff --git a/personal/q3k/lelegram/irc/BUILD.bazel b/personal/q3k/lelegram/irc/BUILD.bazel
new file mode 100644
index 0000000..b150584
--- /dev/null
+++ b/personal/q3k/lelegram/irc/BUILD.bazel
@@ -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",
+    ],
+)
diff --git a/personal/q3k/lelegram/irc/conn.go b/personal/q3k/lelegram/irc/conn.go
new file mode 100644
index 0000000..a59e754
--- /dev/null
+++ b/personal/q3k/lelegram/irc/conn.go
@@ -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
+			}
+		}
+	}
+}
diff --git a/personal/q3k/lelegram/irc/manager.go b/personal/q3k/lelegram/irc/manager.go
new file mode 100644
index 0000000..4e8365d
--- /dev/null
+++ b/personal/q3k/lelegram/irc/manager.go
@@ -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
+			}
+		}
+	}
+}
diff --git a/personal/q3k/lelegram/irc/manager_conns.go b/personal/q3k/lelegram/irc/manager_conns.go
new file mode 100644
index 0000000..6427ec6
--- /dev/null
+++ b/personal/q3k/lelegram/irc/manager_conns.go
@@ -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
+}
diff --git a/personal/q3k/lelegram/irc/manager_control.go b/personal/q3k/lelegram/irc/manager_control.go
new file mode 100644
index 0000000..68e9002
--- /dev/null
+++ b/personal/q3k/lelegram/irc/manager_control.go
@@ -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)
+	}
+}
diff --git a/personal/q3k/lelegram/irc/manager_event.go b/personal/q3k/lelegram/irc/manager_event.go
new file mode 100644
index 0000000..399915c
--- /dev/null
+++ b/personal/q3k/lelegram/irc/manager_event.go
@@ -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)
+	}
+}
diff --git a/personal/q3k/lelegram/main.go b/personal/q3k/lelegram/main.go
new file mode 100644
index 0000000..ef1ce37
--- /dev/null
+++ b/personal/q3k/lelegram/main.go
@@ -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)
+			}
+		}
+	}
+}
diff --git a/personal/q3k/lelegram/telegram.go b/personal/q3k/lelegram/telegram.go
new file mode 100644
index 0000000..a80e76e
--- /dev/null
+++ b/personal/q3k/lelegram/telegram.go
@@ -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
+}